From 440547e9c1c594d99013059eb96c16dfc843cf47 Mon Sep 17 00:00:00 2001 From: Ivan Loginov Date: Fri, 5 Jun 2026 14:58:55 +0300 Subject: [PATCH] first commit --- .air.toml | 52 ++ .devcontainer/devcontainer.json | 19 + .dockerignore | 61 ++ .gitea/workflows/release-nightly.yml | 51 ++ .gitea/workflows/release-tag.yml | 70 ++ .gitea/workflows/test-pr.yml | 19 + .gitignore | 5 + .golangci.yml | 117 +++ .goreleaser.yaml | 76 ++ .vscode/mcp.json | 39 + AGENTS.md | 8 + BUILDING.md | 63 ++ CLAUDE.md | 78 ++ Dockerfile | 35 + LICENSE | 21 + Makefile | 89 ++ README.md | 330 ++++++++ README.zh-cn.md | 259 ++++++ README.zh-tw.md | 259 ++++++ build.bat | 2 + build.ps1 | 220 +++++ cmd/cmd.go | 137 ++++ config.json | 16 + go.mod | 29 + go.sum | 74 ++ main.go | 23 + operation/actions/actions.go | 8 + operation/actions/config.go | 534 ++++++++++++ operation/actions/logs_test.go | 22 + operation/actions/runs.go | 538 ++++++++++++ operation/actions/slim.go | 92 +++ operation/issue/issue.go | 539 ++++++++++++ operation/issue/issue_test.go | 324 ++++++++ operation/issue/slim.go | 95 +++ operation/issue/slim_test.go | 69 ++ operation/label/label.go | 366 +++++++++ operation/label/slim.go | 1 + operation/milestone/milestone.go | 254 ++++++ operation/milestone/milestone_test.go | 84 ++ operation/milestone/slim.go | 28 + operation/notification/notification.go | 213 +++++ operation/notification/slim.go | 66 ++ operation/operation.go | 139 ++++ operation/operation_test.go | 105 +++ operation/packages/packages.go | 220 +++++ operation/packages/packages_test.go | 381 +++++++++ operation/packages/slim.go | 43 + operation/pull/pull.go | 997 ++++++++++++++++++++++ operation/pull/pull_test.go | 1044 ++++++++++++++++++++++++ operation/pull/slim.go | 160 ++++ operation/pull/slim_test.go | 124 +++ operation/repo/branch.go | 150 ++++ operation/repo/commit.go | 109 +++ operation/repo/file.go | 283 +++++++ operation/repo/release.go | 249 ++++++ operation/repo/repo.go | 210 +++++ operation/repo/slim.go | 178 ++++ operation/repo/slim_test.go | 109 +++ operation/repo/tag.go | 195 +++++ operation/repo/tree.go | 73 ++ operation/repo/tree_test.go | 52 ++ operation/search/search.go | 222 +++++ operation/search/search_test.go | 42 + operation/search/slim.go | 71 ++ operation/search/slim_test.go | 54 ++ operation/timetracking/slim.go | 47 ++ operation/timetracking/timetracking.go | 321 ++++++++ operation/user/slim.go | 27 + operation/user/user.go | 77 ++ operation/version/version.go | 40 + operation/wiki/wiki.go | 269 ++++++ operation/wiki/wiki_test.go | 75 ++ pkg/annotation/annotation.go | 18 + pkg/context/context.go | 7 + pkg/flag/flag.go | 14 + pkg/gitea/gitea.go | 82 ++ pkg/gitea/redirect_test.go | 120 +++ pkg/gitea/rest.go | 184 +++++ pkg/gitea/rest_test.go | 30 + pkg/log/log.go | 120 +++ pkg/params/params.go | 156 ++++ pkg/params/params_test.go | 208 +++++ pkg/slim/slim.go | 135 +++ pkg/slim/slim_test.go | 110 +++ pkg/to/to.go | 27 + pkg/tool/tool.go | 78 ++ pkg/tool/tool_test.go | 100 +++ 87 files changed, 12840 insertions(+) create mode 100644 .air.toml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .gitea/workflows/release-nightly.yml create mode 100644 .gitea/workflows/release-tag.yml create mode 100644 .gitea/workflows/test-pr.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 .vscode/mcp.json create mode 100644 AGENTS.md create mode 100644 BUILDING.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.zh-cn.md create mode 100644 README.zh-tw.md create mode 100644 build.bat create mode 100644 build.ps1 create mode 100644 cmd/cmd.go create mode 100644 config.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 operation/actions/actions.go create mode 100644 operation/actions/config.go create mode 100644 operation/actions/logs_test.go create mode 100644 operation/actions/runs.go create mode 100644 operation/actions/slim.go create mode 100644 operation/issue/issue.go create mode 100644 operation/issue/issue_test.go create mode 100644 operation/issue/slim.go create mode 100644 operation/issue/slim_test.go create mode 100644 operation/label/label.go create mode 100644 operation/label/slim.go create mode 100644 operation/milestone/milestone.go create mode 100644 operation/milestone/milestone_test.go create mode 100644 operation/milestone/slim.go create mode 100644 operation/notification/notification.go create mode 100644 operation/notification/slim.go create mode 100644 operation/operation.go create mode 100644 operation/operation_test.go create mode 100644 operation/packages/packages.go create mode 100644 operation/packages/packages_test.go create mode 100644 operation/packages/slim.go create mode 100644 operation/pull/pull.go create mode 100644 operation/pull/pull_test.go create mode 100644 operation/pull/slim.go create mode 100644 operation/pull/slim_test.go create mode 100644 operation/repo/branch.go create mode 100644 operation/repo/commit.go create mode 100644 operation/repo/file.go create mode 100644 operation/repo/release.go create mode 100644 operation/repo/repo.go create mode 100644 operation/repo/slim.go create mode 100644 operation/repo/slim_test.go create mode 100644 operation/repo/tag.go create mode 100644 operation/repo/tree.go create mode 100644 operation/repo/tree_test.go create mode 100644 operation/search/search.go create mode 100644 operation/search/search_test.go create mode 100644 operation/search/slim.go create mode 100644 operation/search/slim_test.go create mode 100644 operation/timetracking/slim.go create mode 100644 operation/timetracking/timetracking.go create mode 100644 operation/user/slim.go create mode 100644 operation/user/user.go create mode 100644 operation/version/version.go create mode 100644 operation/wiki/wiki.go create mode 100644 operation/wiki/wiki_test.go create mode 100644 pkg/annotation/annotation.go create mode 100644 pkg/context/context.go create mode 100644 pkg/flag/flag.go create mode 100644 pkg/gitea/gitea.go create mode 100644 pkg/gitea/redirect_test.go create mode 100644 pkg/gitea/rest.go create mode 100644 pkg/gitea/rest_test.go create mode 100644 pkg/log/log.go create mode 100644 pkg/params/params.go create mode 100644 pkg/params/params_test.go create mode 100644 pkg/slim/slim.go create mode 100644 pkg/slim/slim_test.go create mode 100644 pkg/to/to.go create mode 100644 pkg/tool/tool.go create mode 100644 pkg/tool/tool_test.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..246cc5d --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["-t", "http"] + bin = "./gitea-mcp" + cmd = "make build" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..390cc0b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Gitea MCP DevContainer", + "image": "mcr.microsoft.com/devcontainers/go:1.26-bookworm", + "features": {}, + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "golang.go", + "stylelint.vscode-stylelint", + "DavidAnson.vscode-markdownlint", + "github.copilot", + "eamodio.gitlens" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d6aa01e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,61 @@ +# Git +.git +.gitignore +.github/ +.gitea/ + +# Docker +Dockerfile +.dockerignore + +# Build artifacts +bin/ +dist/ +build/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Go specific +vendor/ +go.work + +# Testing +*_test.go +**/test/ +**/tests/ +coverage.out +coverage.html + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS specific +.DS_Store +Thumbs.db + +# Temporary files +tmp/ +temp/ +*.tmp +*.log + +# Documentation +docs/ +*.md +LICENSE + +# Development tools +.air.toml +.golangci.yml +.goreleaser.yml + +# Debug files +debug +__debug_bin diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml new file mode 100644 index 0000000..994a34d --- /dev/null +++ b/.gitea/workflows/release-nightly.yml @@ -0,0 +1,51 @@ +name: release-nightly + +on: + push: + branches: [main] + tags: + - "*" + +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: gitea + DOCKER_LATEST: nightly + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 # all history for all branches and tags + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: | + ${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }} + build-args: | + VERSION=${{ steps.meta.outputs.REPO_VERSION }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml new file mode 100644 index 0000000..929acc1 --- /dev/null +++ b/.gitea/workflows/release-tag.yml @@ -0,0 +1,70 @@ +name: release + +on: + push: + tags: + - "*" + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + - name: Install GoReleaser + run: go install github.com/goreleaser/goreleaser/v2@latest + - name: Run GoReleaser + run: goreleaser release --clean + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_FORCE_TOKEN: "gitea" + + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: gitea + DOCKER_LATEST: latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 # all history for all branches and tags + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + linux/arm64 + push: true + build-args: | + VERSION=${{ steps.meta.outputs.REPO_VERSION }} + tags: | + ${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ steps.meta.outputs.REPO_VERSION }} + ${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }} diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml new file mode 100644 index 0000000..d7a3d02 --- /dev/null +++ b/.gitea/workflows/test-pr.yml @@ -0,0 +1,19 @@ +name: check-and-test + +on: + - pull_request + +jobs: + check-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: lint + run: make lint + - name: build + run: make build + - name: security-check + run: make security-check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac6b87b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +gitea-mcp +gitea-mcp.exe +*.log +tmp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f62038f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,117 @@ +version: "2" +output: + sort-order: + - file +linters: + default: none + enable: + - bidichk + - bodyclose + - depguard + - errcheck + - forbidigo + - gocheckcompilerdirectives + - gocritic + - govet + - ineffassign + - mirror + - modernize + - nakedret + - nilnil + - nolintlint + - perfsprint + - revive + - staticcheck + - testifylint + - unconvert + - unparam + - unused + - usestdlibvars + - usetesting + - wastedassign + settings: + depguard: + rules: + main: + deny: + - pkg: io/ioutil + desc: use os or io instead + - pkg: golang.org/x/exp + desc: it's experimental and unreliable + - pkg: github.com/pkg/errors + desc: use builtin errors package instead + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + gocritic: + enabled-checks: + - equalFold + disabled-checks: [] + revive: + severity: error + rules: + - name: blank-imports + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: empty-lines + - name: error-return + - name: error-strings + - name: exported + - name: identical-branches + - name: if-return + - name: increment-decrement + - name: modifies-value-receiver + - name: package-comments + - name: redefines-builtin-id + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: var-declaration + - name: var-naming + arguments: + - [] # AllowList - do not remove as args for the rule are positional and won't work without lists first + - [] # DenyList + - - skip-package-name-checks: true + staticcheck: + checks: + - all + testifylint: {} + usetesting: + os-temp-dir: true + perfsprint: + concat-loop: false + govet: + enable: + - nilness + - unusedwrite + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gci + - gofumpt + settings: + gci: + custom-order: true + sections: + - standard + - prefix(gitea.com/gitea/gitea-mcp) + - blank + - default + gofumpt: + extra-rules: true + exclusions: + generated: lax +run: + timeout: 10m diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..72da5ac --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,76 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json + +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + main: . + goos: + - linux + - windows + - darwin + flags: + - -trimpath + ldflags: + - -s -w + - -X main.Version={{.Version}} + +archives: + - formats: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: zip + +changelog: + sort: asc + groups: + - title: Features + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: "Bug fixes" + regexp: "^.*fix[(\\w)]*:+.*$" + order: 1 + - title: "Enhancements" + regexp: "^.*chore[(\\w)]*:+.*$" + order: 2 + - title: "Refactor" + regexp: "^.*refactor[(\\w)]*:+.*$" + order: 3 + - title: "Build process updates" + regexp: ^.*?(build|ci)(\(.+\))??!?:.+$ + order: 4 + - title: "Documentation updates" + regexp: ^.*?docs?(\(.+\))??!?:.+$ + order: 4 + - title: Others + order: 999 + filters: + exclude: + - "^docs:" + - "^test:" + +release: + footer: >- + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). + +gitea_urls: + api: https://gitea.com/api/v1 + download: https://gitea.com +force_token: gitea diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..95cf1bc --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,39 @@ +{ + // 💡 Inputs are prompted on first server start, then stored securely by VS Code. + "inputs": [ + { + "type": "promptString", + "id": "gitea-host", + "description": "Gitea Host", + "password": false + }, + { + "type": "promptString", + "id": "gitea-token", + "description": "Gitea Access Token", + "password": true + }, + { + "type": "promptString", + "id": "gitea-insecure", + "description": "Allow insecure connections (e.g., self-signed certificates)", + "default": "false" + } + ], + "servers": { + "gitea-mcp-stdio": { + "type": "stdio", + "command": "gitea-mcp", + "args": ["-t", "stdio"], + "env": { + "GITEA_HOST": "${input:gitea-host}", + "GITEA_ACCESS_TOKEN": "${input:gitea-token}", + "GITEA_INSECURE": "${input:gitea-insecure}" + } + }, + "gitea-mcp-http": { + "type": "http", + "url": "http://localhost:8080/mcp", + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ea22894 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +- Use `make help` to find available development targets +- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them +- Run `make tidy` after any `go.mod` changes +- Ensure no trailing whitespace in edited files +- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`) +- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates +- Include authorship attribution in issue and pull request comments +- Add `Co-Authored-By` lines to all commits, indicating name and model used diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..3b25276 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,63 @@ +# Building gitea-mcp on Windows + +This project includes PowerShell and batch scripts to build the gitea-mcp application on Windows systems. + +## Prerequisites + +- Go 1.24 or later +- Git (for version information) +- PowerShell 5.1 or later (included with Windows 10/11) + +## Build Scripts + +### PowerShell Script (`build.ps1`) + +The main build script that replicates all Makefile functionality: + +```powershell +# Show help +.\build.ps1 help + +# Build the application +.\build.ps1 build + +# Install the application +.\build.ps1 install + +# Clean build artifacts +.\build.ps1 clean + +# Run in development mode (hot reload) +.\build.ps1 dev + +# Update vendor dependencies +.\build.ps1 vendor +``` + +### Batch File Wrapper (`build.bat`) + +A simple wrapper to run the PowerShell script: + +```cmd +# Run with default help target +build.bat + +# Run specific target +build.bat build +build.bat install +``` + +## Available Targets + +- **help** - Print help message +- **build** - Build the application executable +- **install** - Build and install to GOPATH/bin +- **uninstall** - Remove executable from GOPATH/bin +- **clean** - Remove build artifacts +- **air** - Install air for hot reload development +- **dev** - Run with hot reload development +- **vendor** - Tidy and verify Go module dependencies + +## Output + +The build process creates `gitea-mcp.exe` in the project directory. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9d4ffe6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +**Build**: `make build` - Build the gitea-mcp binary +**Install**: `make install` - Build and install to GOPATH/bin +**Clean**: `make clean` - Remove build artifacts +**Test**: `go test ./...` - Run all tests +**Hot reload**: `make dev` - Start development server with hot reload (requires air) +**Dependencies**: `make vendor` - Tidy and verify module dependencies + +## Architecture Overview + +This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more. + +**Core Components**: + +- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing +- `operation/operation.go`: Main server setup and tool registration +- `pkg/tool/tool.go`: Tool registry with read/write categorization +- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.) + +**Transport Modes**: + +- **stdio** (default): Standard input/output for MCP clients +- **http**: HTTP server mode on configurable port (default 8080) + +**Authentication**: + +- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var +- HTTP mode supports per-request Bearer token override in Authorization header +- Token precedence: HTTP Authorization header > CLI flag > environment variable + +**Tool Organization**: + +- Tools are categorized as read-only or write operations +- `--read-only` flag exposes only read tools +- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()` + +**Key Configuration**: + +- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`) +- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE` +- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation + +## Available Tools + +The server provides 45 MCP tools covering: + +- **User**: get_me, get_user_orgs +- **Search**: search_users, search_repos, search_org_teams +- **Repository**: create_repo, fork_repo, list_my_repos +- **Branches**: list_branches, create_branch, delete_branch +- **Tags**: list_tags, get_tag, create_tag, delete_tag +- **Files**: get_file_contents, get_dir_contents, create_or_update_file, delete_file +- **Commits**: list_commits +- **Issues**: list_issues, issue_read, issue_write +- **Pull Requests**: list_pull_requests, pull_request_read, pull_request_write, pull_request_review_write +- **Labels**: label_read, label_write +- **Milestones**: milestone_read, milestone_write +- **Releases**: list_releases, get_release, get_latest_release, create_release, delete_release +- **Wiki**: wiki_read, wiki_write +- **Time Tracking**: timetracking_read, timetracking_write +- **Actions Runs**: actions_run_read, actions_run_write +- **Actions Config**: actions_config_read, actions_config_write +- **Version**: get_gitea_mcp_server_version + +## Common Development Patterns + +**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests + +**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access + +**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go` + +**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35e0d73 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.4 + +# Build stage +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +ARG VERSION=dev +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o gitea-mcp + +# Final stage +FROM gcr.io/distroless/static-debian12:nonroot + +ARG VERSION=dev + +WORKDIR /app +COPY --from=builder --chown=nonroot:nonroot /app/gitea-mcp . + +USER nonroot:nonroot + +LABEL org.opencontainers.image.version="${VERSION}" +LABEL org.opencontainers.image.source="https://gitea.com/gitea/gitea-mcp" + +CMD ["/app/gitea-mcp"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e98ecb2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 The Gitea Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9d94ca0 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +GO ?= go +EXECUTABLE := gitea-mcp +VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') +LDFLAGS := -X "main.Version=$(VERSION)" + +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 +GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 + +.PHONY: help +help: ## print this help message + @echo "Usage: make [target]" + @echo "" + @echo "Targets:" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: install +install: build ## install the application + @echo "Installing $(EXECUTABLE)..." + @mkdir -p $(GOPATH)/bin + @cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE) + @echo "Installed $(EXECUTABLE) to $(GOPATH)/bin/$(EXECUTABLE)" + @echo "Please add $(GOPATH)/bin to your PATH if it is not already there." + +.PHONY: uninstall +uninstall: ## uninstall the application + @echo "Uninstalling $(EXECUTABLE)..." + @rm -f $(GOPATH)/bin/$(EXECUTABLE) + @echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)" + +.PHONY: clean +clean: ## delete build artifacts + @echo "Cleaning up build artifacts..." + @rm -f $(EXECUTABLE) + @echo "Cleaned up $(EXECUTABLE)" + +.PHONY: build +build: ## build the application + $(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE) + +.PHONY: air +air: ## install air for hot reload + @hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + $(GO) install github.com/air-verse/air@latest; \ + fi + +.PHONY: dev +dev: air ## run the application with hot reload + air --build.cmd "make build" --build.bin ./gitea-mcp + +.PHONY: fmt +fmt: ## format the Go code + $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt + +.PHONY: fmt-check +fmt-check: fmt ## check that Go code is formatted + @diff=$$(git diff --color=always); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make fmt' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: lint +lint: lint-go ## lint everything + +.PHONY: lint-fix +lint-fix: lint-go-fix ## lint everything and fix issues + +.PHONY: lint-go +lint-go: ## lint go files + $(GO) run $(GOLANGCI_LINT_PACKAGE) run + +.PHONY: lint-go-fix +lint-go-fix: ## lint go files and fix issues + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix + +.PHONY: security-check +security-check: ## run security check + $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true + +.PHONY: tidy +tidy: ## run go mod tidy + $(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2)) + $(GO) mod tidy -compat=$(MIN_GO_VERSION) + +.PHONY: vendor +vendor: tidy ## tidy and verify module dependencies + $(GO) mod verify diff --git a/README.md b/README.md new file mode 100644 index 0000000..61baa6b --- /dev/null +++ b/README.md @@ -0,0 +1,330 @@ +# Gitea MCP Server + +[繁體中文](README.zh-tw.md) | [简体中文](README.zh-cn.md) + +**Gitea MCP Server** is an integration plugin designed to connect Gitea with Model Context Protocol (MCP) systems. This allows for seamless command execution and repository management through an MCP-compatible chat interface. + +[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) + +## Table of Contents + +- [Gitea MCP Server](#gitea-mcp-server) + - [Table of Contents](#table-of-contents) + - [What is Gitea?](#what-is-gitea) + - [What is MCP?](#what-is-mcp) + - [🚧 Installation](#-installation) + - [Usage with Claude Code](#usage-with-claude-code) + - [Usage with VS Code](#usage-with-vs-code) + - [Usage with Mistral Vibe](#usage-with-mistral-vibe) + - [📥 Download the official binary release](#-download-the-official-binary-release) + - [🔧 Build from Source](#-build-from-source) + - [📁 Add to PATH](#-add-to-path) + - [🚀 Usage](#-usage) + - [✅ Available Tools](#-available-tools) + - [🐛 Debugging](#-debugging) + - [🛠 Troubleshooting](#-troubleshooting) + +## What is Gitea? + +Gitea is a community-managed lightweight code hosting solution written in Go. It is published under the MIT license. Gitea provides Git hosting including a repository viewer, issue tracking, pull requests, and more. + +## What is MCP? + +Model Context Protocol (MCP) is a protocol that allows for the integration of various tools and systems through a chat interface. It enables seamless command execution and management of repositories, users, and other resources. + +## 🚧 Installation + +### Usage with OpenCode (opencode.ai) + +Add a snippet like the following in the "mcp" top-level object (add one if you don't have any): + +```json + "gitea-mcp": { + "enabled": true, + "type": "local", + "command": [ + "gitea-mcp", + "-t", "stdio", + "-H", "https://git.your-domain.org", + "-T", "" + ] + } +``` + +### Usage with Claude Code + +This method uses `go run` and requires [Go](https://go.dev) to be installed. + +```bash +claude mcp add --transport stdio --scope user gitea \ + --env GITEA_ACCESS_TOKEN=token \ + --env GITEA_HOST=https://gitea.com \ + -- go run gitea.com/gitea/gitea-mcp@latest -t stdio +``` + +### Usage with VS Code + +For quick installation, use one of the one-click install buttons at the top of this README. + +For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`. + +Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. + +> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file. + +```json +{ + "mcp": { + "inputs": [ + { + "type": "promptString", + "id": "gitea_token", + "description": "Gitea Personal Access Token", + "password": true + } + ], + "servers": { + "gitea-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITEA_ACCESS_TOKEN", + "docker.gitea.com/gitea-mcp-server" + ], + "env": { + "GITEA_ACCESS_TOKEN": "${input:gitea_token}" + } + } + } + } +} +``` + +### Usage with Mistral Vibe + +Add the following configuration to your Mistral Vibe MCP configuration file (`~/.vibe/config.toml`): + +```toml +[[mcp_servers]] +name = "gitea" +transport = "stdio" +command = "docker" +args = [ + "run", + "--rm", + "-i", + "-e", + "GITEA_ACCESS_TOKEN", + "-e", + "GITEA_HOST", + "docker.gitea.com/gitea-mcp-server", +] + +[mcp_servers.env] +GITEA_ACCESS_TOKEN = "TOKEN" +GITEA_HOST = "https://gitea.com" +``` + +### 📥 Download the official binary release + +You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases). + +### 🔧 Build from Source + +You can download the source code by cloning the repository using Git: + +```bash +git clone https://gitea.com/gitea/gitea-mcp.git +``` + +Before building, make sure you have the following installed: + +- make +- Golang (Go 1.24 or later recommended) + +Then run: + +```bash +make install +``` + +### 📁 Add to PATH + +After installing, copy the binary gitea-mcp to a directory included in your system's PATH. For example: + +```bash +cp gitea-mcp /usr/local/bin/ +``` + +## 🚀 Usage + +This example is for Cursor, you can also use plugins in VSCode. +To configure the MCP server for Gitea, add the following to your MCP configuration file: + +- **stdio mode** + +```json +{ + "mcpServers": { + "gitea": { + "command": "gitea-mcp", + "args": [ + "-t", + "stdio", + "--host", + "https://gitea.com" + // "--token", "" + ], + "env": { + // "GITEA_HOST": "https://gitea.com", + // "GITEA_INSECURE": "true", + "GITEA_ACCESS_TOKEN": "" + } + } + } +} +``` + +- **http mode** + +```json +{ + "mcpServers": { + "gitea": { + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +**Default log path**: `$HOME/.gitea-mcp/gitea-mcp.log` + +> [!NOTE] +> You can provide your Gitea host and access token either as command-line arguments or environment variables. +> Command-line arguments have the highest priority + +> [!NOTE] +> Many tools support `page` and `perPage` parameters for pagination. The maximum effective page size is determined by the Gitea server's `[api].MAX_RESPONSE_ITEMS` setting (default: **50**). Requesting a `perPage` value higher than this limit will be silently capped by the server. + +Once everything is set up, try typing the following in your MCP-compatible chatbox: + +```text +list all my repositories +``` + +## ✅ Available Tools + +The Gitea MCP Server supports the following tools: + +| Tool | Scope | Description | +| :-------------------------------: | :----------: | :------------------------------------------------------: | +| get_my_user_info | User | Get the information of the authenticated user | +| get_user_orgs | User | Get organizations associated with the authenticated user | +| create_repo | Repository | Create a new repository | +| fork_repo | Repository | Fork a repository | +| list_my_repos | Repository | List all repositories owned by the authenticated user | +| create_branch | Branch | Create a new branch | +| delete_branch | Branch | Delete a branch | +| list_branches | Branch | List all branches in a repository | +| create_release | Release | Create a new release in a repository | +| delete_release | Release | Delete a release from a repository | +| get_release | Release | Get a release | +| get_latest_release | Release | Get the latest release in a repository | +| list_releases | Release | List all releases in a repository | +| create_tag | Tag | Create a new tag | +| delete_tag | Tag | Delete a tag | +| get_tag | Tag | Get a tag | +| list_tags | Tag | List all tags in a repository | +| list_repo_commits | Commit | List all commits in a repository | +| get_file_content | File | Get the content and metadata of a file | +| get_dir_content | File | Get a list of entries in a directory | +| create_file | File | Create a new file | +| update_file | File | Update an existing file | +| delete_file | File | Delete a file | +| get_issue_by_index | Issue | Get an issue by its index | +| list_repo_issues | Issue | List all issues in a repository | +| create_issue | Issue | Create a new issue | +| create_issue_comment | Issue | Create a comment on an issue | +| edit_issue | Issue | Edit a issue | +| edit_issue_comment | Issue | Edit a comment on an issue | +| get_issue_comments_by_index | Issue | Get comments of an issue by its index | +| get_pull_request_by_index | Pull Request | Get a pull request by its index | +| get_pull_request_diff | Pull Request | Get a pull request diff | +| list_repo_pull_requests | Pull Request | List all pull requests in a repository | +| create_pull_request | Pull Request | Create a new pull request | +| create_pull_request_reviewer | Pull Request | Add reviewers to a pull request | +| delete_pull_request_reviewer | Pull Request | Remove reviewers from a pull request | +| list_pull_request_reviews | Pull Request | List all reviews for a pull request | +| get_pull_request_review | Pull Request | Get a specific review by ID | +| list_pull_request_review_comments | Pull Request | List inline comments for a review | +| create_pull_request_review | Pull Request | Create a review with optional inline comments | +| submit_pull_request_review | Pull Request | Submit a pending review | +| delete_pull_request_review | Pull Request | Delete a review | +| dismiss_pull_request_review | Pull Request | Dismiss a review with optional message | +| merge_pull_request | Pull Request | Merge a pull request | +| search_users | User | Search for users | +| search_org_teams | Organization | Search for teams in an organization | +| list_org_labels | Organization | List labels defined at organization level | +| create_org_label | Organization | Create a label in an organization | +| edit_org_label | Organization | Edit a label in an organization | +| delete_org_label | Organization | Delete a label in an organization | +| search_repos | Repository | Search for repositories | +| list_repo_action_secrets | Actions | List repository Actions secrets (metadata only) | +| upsert_repo_action_secret | Actions | Create/update (upsert) a repository Actions secret | +| delete_repo_action_secret | Actions | Delete a repository Actions secret | +| list_org_action_secrets | Actions | List organization Actions secrets (metadata only) | +| upsert_org_action_secret | Actions | Create/update (upsert) an organization Actions secret | +| delete_org_action_secret | Actions | Delete an organization Actions secret | +| list_repo_action_variables | Actions | List repository Actions variables | +| get_repo_action_variable | Actions | Get a repository Actions variable | +| create_repo_action_variable | Actions | Create a repository Actions variable | +| update_repo_action_variable | Actions | Update a repository Actions variable | +| delete_repo_action_variable | Actions | Delete a repository Actions variable | +| list_org_action_variables | Actions | List organization Actions variables | +| get_org_action_variable | Actions | Get an organization Actions variable | +| create_org_action_variable | Actions | Create an organization Actions variable | +| update_org_action_variable | Actions | Update an organization Actions variable | +| delete_org_action_variable | Actions | Delete an organization Actions variable | +| list_repo_action_workflows | Actions | List repository Actions workflows | +| get_repo_action_workflow | Actions | Get a repository Actions workflow | +| dispatch_repo_action_workflow | Actions | Trigger (dispatch) a repository Actions workflow | +| list_repo_action_runs | Actions | List repository Actions runs | +| get_repo_action_run | Actions | Get a repository Actions run | +| cancel_repo_action_run | Actions | Cancel a repository Actions run | +| rerun_repo_action_run | Actions | Rerun a repository Actions run | +| list_repo_action_jobs | Actions | List repository Actions jobs | +| list_repo_action_run_jobs | Actions | List Actions jobs for a run | +| get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) | +| download_repo_action_job_log | Actions | Download a job log to a file | +| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server | +| list_wiki_pages | Wiki | List all wiki pages in a repository | +| get_wiki_page | Wiki | Get a wiki page content and metadata | +| get_wiki_revisions | Wiki | Get revisions history of a wiki page | +| create_wiki_page | Wiki | Create a new wiki page | +| update_wiki_page | Wiki | Update an existing wiki page | +| delete_wiki_page | Wiki | Delete a wiki page | + +## 🐛 Debugging + +To enable debug mode, add the `-d` flag when running the Gitea MCP Server with http mode: + +```sh +./gitea-mcp -t http [--port 8080] --token -d +``` + +## 🛠 Troubleshooting + +If you encounter any issues, here are some common troubleshooting steps: + +1. **Check your PATH**: Ensure that the `gitea-mcp` binary is in a directory included in your system's PATH. +2. **Verify dependencies**: Make sure you have all the required dependencies installed, such as `make` and `Golang`. +3. **Review configuration**: Double-check your MCP configuration file for any errors or missing information. +4. **Consult logs**: Check the logs for any error messages or warnings that can provide more information about the issue. + +Enjoy exploring and managing your Gitea repositories via chat! diff --git a/README.zh-cn.md b/README.zh-cn.md new file mode 100644 index 0000000..f9266ec --- /dev/null +++ b/README.zh-cn.md @@ -0,0 +1,259 @@ +# Gitea MCP 服务器 + +[English](README.md) | [繁體中文](README.zh-tw.md) + +**Gitea MCP 服务器** 是一个集成插件,旨在将 Gitea 与 Model Context Protocol (MCP) 系统连接起来。这允许通过 MCP 兼容的聊天界面无缝执行命令和管理仓库。 + +[![在 VS Code 中使用 Docker 安装](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安装](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) + +## 目录 + +- [Gitea MCP 服务器](#gitea-mcp-服务器) + - [目录](#目录) + - [什么是 Gitea?](#什么是-gitea) + - [什么是 MCP?](#什么是-mcp) + - [🚧 安装](#-安装) + - [在 Claude Code 中使用](#在-claude-code-中使用) + - [在 VS Code 中使用](#在-vs-code-中使用) + - [📥 下载官方二进制版本](#-下载官方二进制版本) + - [🔧 从源码构建](#-从源码构建) + - [📁 加入 PATH](#-加入-path) + - [🚀 使用](#-使用) + - [✅ 可用工具](#-可用工具) + - [🐛 调试](#-调试) + - [🛠 疑难排解](#-疑难排解) + +## 什么是 Gitea? + +Gitea 是一个由社区管理的轻量级代码托管解决方案,使用 Go 语言编写,采用 MIT 许可证。Gitea 提供 Git 托管,包括仓库浏览、问题追踪、拉取请求等功能。 + +## 什么是 MCP? + +Model Context Protocol (MCP) 是一种协议,允许通过聊天界面整合各种工具和系统。它能够无缝执行命令并管理仓库、用户及其他资源。 + +## 🚧 安装 + +### 在 Claude Code 中使用 + +此方式使用 `go run`,需要安装 [Go](https://go.dev)。 + +```bash +claude mcp add --transport stdio --scope user gitea \ + --env GITEA_ACCESS_TOKEN=token \ + --env GITEA_HOST=https://gitea.com \ + -- go run gitea.com/gitea/gitea-mcp@latest -t stdio +``` + +### 在 VS Code 中使用 + +要快速安装,请使用本 README 顶部的安装按钮。 + +如需手动安装,请将以下 JSON 块添加到 VS Code 的用户设置 (JSON) 文件。可通过按 `Ctrl + Shift + P` 并输入 `Preferences: Open User Settings (JSON)`。 + +也可添加到工作区的 `.vscode/mcp.json` 文件,方便与他人共享配置。 + +> `.vscode/mcp.json` 文件不需要 `mcp` 键。 + +```json +{ + "mcp": { + "inputs": [ + { + "type": "promptString", + "id": "gitea_token", + "description": "Gitea 个人访问令牌", + "password": true + } + ], + "servers": { + "gitea-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITEA_ACCESS_TOKEN", + "docker.gitea.com/gitea-mcp-server" + ], + "env": { + "GITEA_ACCESS_TOKEN": "${input:gitea_token}" + } + } + } + } +} +``` + +### 📥 下载官方二进制版本 + +可在 [官方 Gitea MCP 二进制版本](https://gitea.com/gitea/gitea-mcp/releases) 下载。 + +### 🔧 从源码构建 + +可用 Git 下载源码: + +```bash +git clone https://gitea.com/gitea/gitea-mcp.git +``` + +构建前请先安装: + +- make +- Golang(建议 Go 1.24 及以上) + +然后运行: + +```bash +make install +``` + +### 📁 加入 PATH + +安装后,将 gitea-mcp 可执行文件复制到系统 PATH 目录,例如: + +```bash +cp gitea-mcp /usr/local/bin/ +``` + +## 🚀 使用 + +此示例适用于 Cursor,也可在 VSCode 使用插件。 +要配置 Gitea MCP 服务器,请将以下内容添加到 MCP 配置文件: + +- **stdio 模式** + +```json +{ + "mcpServers": { + "gitea": { + "command": "gitea-mcp", + "args": [ + "-t", + "stdio", + "--host", + "https://gitea.com" + // "--token", "" + ], + "env": { + // "GITEA_HOST": "https://gitea.com", + // "GITEA_INSECURE": "true", + "GITEA_ACCESS_TOKEN": "" + } + } + } +} +``` + +- **http 模式** + +```json +{ + "mcpServers": { + "gitea": { + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +**默认日志路径**: `$HOME/.gitea-mcp/gitea-mcp.log` + +> [!注意] +> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。 +> 命令行参数优先。 + +> [!注意] +> 许多工具支持 `page` 和 `perPage` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认值:**50**)。请求超过此限制的 `perPage` 值将被服务器静默截断。 + +一切设置完成后,可在 MCP 聊天框输入: + +```text +列出我所有的仓库 +``` + +## ✅ 可用工具 + +Gitea MCP 服务器支持以下工具: + +| 工具 | 范围 | 描述 | +| :-------------------------------: | :------: | :------------------------: | +| get_my_user_info | 用户 | 获取已认证用户信息 | +| get_user_orgs | 用户 | 获取已认证用户关联组织 | +| create_repo | 仓库 | 创建新仓库 | +| fork_repo | 仓库 | 复刻仓库 | +| list_my_repos | 仓库 | 列出用户所有仓库 | +| create_branch | 分支 | 创建新分支 | +| delete_branch | 分支 | 删除分支 | +| list_branches | 分支 | 列出所有分支 | +| create_release | 版本发布 | 创建新版本发布 | +| delete_release | 版本发布 | 删除版本发布 | +| get_release | 版本发布 | 获取版本发布 | +| get_latest_release | 版本发布 | 获取最新版本发布 | +| list_releases | 版本发布 | 列出所有版本发布 | +| create_tag | 标签 | 创建新标签 | +| delete_tag | 标签 | 删除标签 | +| get_tag | 标签 | 获取标签 | +| list_tags | 标签 | 列出所有标签 | +| list_repo_commits | 提交 | 列出所有提交 | +| get_file_content | 文件 | 获取文件内容和元数据 | +| get_dir_content | 文件 | 获取目录内容列表 | +| create_file | 文件 | 创建新文件 | +| update_file | 文件 | 更新现有文件 | +| delete_file | 文件 | 删除文件 | +| get_issue_by_index | 问题 | 按索引获取问题 | +| list_repo_issues | 问题 | 列出所有问题 | +| create_issue | 问题 | 创建新问题 | +| create_issue_comment | 问题 | 在问题上创建评论 | +| edit_issue | 问题 | 编辑问题 | +| edit_issue_comment | 问题 | 编辑问题评论 | +| get_issue_comments_by_index | 问题 | 按索引获取问题评论 | +| get_pull_request_by_index | 拉取请求 | 按索引获取拉取请求 | +| list_repo_pull_requests | 拉取请求 | 列出所有拉取请求 | +| create_pull_request | 拉取请求 | 创建新拉取请求 | +| create_pull_request_reviewer | 拉取请求 | 为拉取请求添加审查者 | +| delete_pull_request_reviewer | 拉取请求 | 移除拉取请求的审查者 | +| list_pull_request_reviews | 拉取请求 | 列出拉取请求的所有审查 | +| get_pull_request_review | 拉取请求 | 按 ID 获取特定审查 | +| list_pull_request_review_comments | 拉取请求 | 列出审查的行内评论 | +| create_pull_request_review | 拉取请求 | 创建审查(可含行内评论) | +| submit_pull_request_review | 拉取请求 | 提交待处理的审查 | +| delete_pull_request_review | 拉取请求 | 删除审查 | +| dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) | +| merge_pull_request | 拉取请求 | 合并拉取请求 | +| search_users | 用户 | 搜索用户 | +| search_org_teams | 组织 | 搜索组织团队 | +| list_org_labels | 组织 | 列出组织标签 | +| create_org_label | 组织 | 创建组织标签 | +| edit_org_label | 组织 | 编辑组织标签 | +| delete_org_label | 组织 | 删除组织标签 | +| search_repos | 仓库 | 搜索仓库 | +| get_gitea_mcp_server_version | 服务器 | 获取 Gitea MCP 服务器版本 | +| list_wiki_pages | Wiki | 列出所有 Wiki 页面 | +| get_wiki_page | Wiki | 获取 Wiki 页面内容和元数据 | +| get_wiki_revisions | Wiki | 获取 Wiki 修订历史 | +| create_wiki_page | Wiki | 创建新 Wiki 页面 | +| update_wiki_page | Wiki | 更新现有 Wiki 页面 | +| delete_wiki_page | Wiki | 删除 Wiki 页面 | + +## 🐛 调试 + +启用调试模式时,请在 http 模式运行 Gitea MCP 服务器时加上 `-d` 标志: + +```sh +./gitea-mcp -t http [--port 8080] --token -d +``` + +## 🛠 疑难排解 + +如遇问题,可参考以下步骤: + +1. **检查 PATH**:确保 `gitea-mcp` 可执行文件已在系统 PATH 目录中。 +2. **验证依赖**:确认已安装 `make` 和 `Golang` 等必要依赖。 +3. **检查配置**:仔细检查 MCP 配置文件是否有错误或遗漏。 +4. **查看日志**:检查日志消息或警告以获取更多信息。 + +享受通过聊天探索和管理您的 Gitea 仓库! diff --git a/README.zh-tw.md b/README.zh-tw.md new file mode 100644 index 0000000..6566353 --- /dev/null +++ b/README.zh-tw.md @@ -0,0 +1,259 @@ +# Gitea MCP 伺服器 + +[English](README.md) | [简体中文](README.zh-cn.md) + +**Gitea MCP 伺服器** 是一個整合插件,旨在將 Gitea 與 Model Context Protocol (MCP) 系統連接起來。這允許通過 MCP 兼容的聊天界面無縫執行命令和管理倉庫。 + +[![在 VS Code 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) + +## 目錄 + +- [Gitea MCP 伺服器](#gitea-mcp-伺服器) + - [目錄](#目錄) + - [什麼是 Gitea?](#什麼是-gitea) + - [什麼是 MCP?](#什麼是-mcp) + - [🚧 安裝](#-安裝) + - [在 Claude Code 中使用](#在-claude-code-中使用) + - [在 VS Code 中使用](#在-vs-code-中使用) + - [📥 下載官方二進位版本](#-下載官方二進位版本) + - [🔧 從原始碼建置](#-從原始碼建置) + - [📁 加入 PATH](#-加入-path) + - [🚀 使用](#-使用) + - [✅ 可用工具](#-可用工具) + - [🐛 調試](#-調試) + - [🛠 疑難排解](#-疑難排解) + +## 什麼是 Gitea? + +Gitea 是一個由社群管理的輕量級程式碼託管解決方案,使用 Go 語言編寫,採用 MIT 授權。Gitea 提供 Git 託管,包括倉庫瀏覽、議題追蹤、拉取請求等功能。 + +## 什麼是 MCP? + +Model Context Protocol (MCP) 是一種協議,允許透過聊天介面整合各種工具與系統。它能夠無縫執行命令並管理倉庫、使用者及其他資源。 + +## 🚧 安裝 + +### 在 Claude Code 中使用 + +此方式使用 `go run`,需要安裝 [Go](https://go.dev)。 + +```bash +claude mcp add --transport stdio --scope user gitea \ + --env GITEA_ACCESS_TOKEN=token \ + --env GITEA_HOST=https://gitea.com \ + -- go run gitea.com/gitea/gitea-mcp@latest -t stdio +``` + +### 在 VS Code 中使用 + +欲快速安裝,請使用本 README 頂部的安裝按鈕。 + +如需手動安裝,請將下列 JSON 區塊加入 VS Code 的使用者設定 (JSON) 檔案。可按 `Ctrl + Shift + P` 並輸入 `Preferences: Open User Settings (JSON)`。 + +也可加入至工作區的 `.vscode/mcp.json` 檔案,方便與他人共享設定。 + +> `.vscode/mcp.json` 檔案不需 `mcp` 鍵。 + +```json +{ + "mcp": { + "inputs": [ + { + "type": "promptString", + "id": "gitea_token", + "description": "Gitea 個人存取令牌", + "password": true + } + ], + "servers": { + "gitea-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITEA_ACCESS_TOKEN", + "docker.gitea.com/gitea-mcp-server" + ], + "env": { + "GITEA_ACCESS_TOKEN": "${input:gitea_token}" + } + } + } + } +} +``` + +### 📥 下載官方二進位版本 + +可至 [官方 Gitea MCP 二進位版本](https://gitea.com/gitea/gitea-mcp/releases) 下載。 + +### 🔧 從原始碼建置 + +可用 Git 下載原始碼: + +```bash +git clone https://gitea.com/gitea/gitea-mcp.git +``` + +建置前請先安裝: + +- make +- Golang(建議 Go 1.24 以上) + +然後執行: + +```bash +make install +``` + +### 📁 加入 PATH + +安裝後,將 gitea-mcp 執行檔複製到系統 PATH 目錄,例如: + +```bash +cp gitea-mcp /usr/local/bin/ +``` + +## 🚀 使用 + +此範例適用於 Cursor,也可在 VSCode 使用插件。 +欲設定 Gitea MCP 伺服器,請將下列內容加入 MCP 設定檔: + +- **stdio 模式** + +```json +{ + "mcpServers": { + "gitea": { + "command": "gitea-mcp", + "args": [ + "-t", + "stdio", + "--host", + "https://gitea.com" + // "--token", "" + ], + "env": { + // "GITEA_HOST": "https://gitea.com", + // "GITEA_INSECURE": "true", + "GITEA_ACCESS_TOKEN": "" + } + } + } +} +``` + +- **http 模式** + +```json +{ + "mcpServers": { + "gitea": { + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +**預設日誌路徑**: `$HOME/.gitea-mcp/gitea-mcp.log` + +> [!注意] +> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。 +> 命令列參數優先。 + +> [!注意] +> 許多工具支援 `page` 和 `perPage` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設值:**50**)。請求超過此限制的 `perPage` 值將被伺服器靜默截斷。 + +一切設定完成後,可在 MCP 聊天框輸入: + +```text +列出我所有的倉庫 +``` + +## ✅ 可用工具 + +Gitea MCP 伺服器支援以下工具: + +| 工具 | 範圍 | 描述 | +| :-------------------------------: | :------: | :--------------------------: | +| get_my_user_info | 用戶 | 取得已認證用戶資訊 | +| get_user_orgs | 用戶 | 取得已認證用戶所屬組織 | +| create_repo | 倉庫 | 創建新倉庫 | +| fork_repo | 倉庫 | 復刻倉庫 | +| list_my_repos | 倉庫 | 列出用戶所有倉庫 | +| create_branch | 分支 | 創建新分支 | +| delete_branch | 分支 | 刪除分支 | +| list_branches | 分支 | 列出所有分支 | +| create_release | 版本發布 | 創建新版本發布 | +| delete_release | 版本發布 | 刪除版本發布 | +| get_release | 版本發布 | 取得版本發布 | +| get_latest_release | 版本發布 | 取得最新版本發布 | +| list_releases | 版本發布 | 列出所有版本發布 | +| create_tag | 標籤 | 創建新標籤 | +| delete_tag | 標籤 | 刪除標籤 | +| get_tag | 標籤 | 取得標籤 | +| list_tags | 標籤 | 列出所有標籤 | +| list_repo_commits | 提交 | 列出所有提交 | +| get_file_content | 文件 | 取得文件內容與中繼資料 | +| get_dir_content | 文件 | 取得目錄內容列表 | +| create_file | 文件 | 創建新文件 | +| update_file | 文件 | 更新現有文件 | +| delete_file | 文件 | 刪除文件 | +| get_issue_by_index | 問題 | 依索引取得問題 | +| list_repo_issues | 問題 | 列出所有問題 | +| create_issue | 問題 | 創建新問題 | +| create_issue_comment | 問題 | 在問題上創建評論 | +| edit_issue | 問題 | 編輯問題 | +| edit_issue_comment | 問題 | 編輯問題評論 | +| get_issue_comments_by_index | 問題 | 依索引取得問題評論 | +| get_pull_request_by_index | 拉取請求 | 依索引取得拉取請求 | +| list_repo_pull_requests | 拉取請求 | 列出所有拉取請求 | +| create_pull_request | 拉取請求 | 創建新拉取請求 | +| create_pull_request_reviewer | 拉取請求 | 為拉取請求添加審查者 | +| delete_pull_request_reviewer | 拉取請求 | 移除拉取請求的審查者 | +| list_pull_request_reviews | 拉取請求 | 列出拉取請求的所有審查 | +| get_pull_request_review | 拉取請求 | 依 ID 取得特定審查 | +| list_pull_request_review_comments | 拉取請求 | 列出審查的行內評論 | +| create_pull_request_review | 拉取請求 | 創建審查(可含行內評論) | +| submit_pull_request_review | 拉取請求 | 提交待處理的審查 | +| delete_pull_request_review | 拉取請求 | 刪除審查 | +| dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) | +| merge_pull_request | 拉取請求 | 合併拉取請求 | +| search_users | 用戶 | 搜尋用戶 | +| search_org_teams | 組織 | 搜尋組織團隊 | +| list_org_labels | 組織 | 列出組織標籤 | +| create_org_label | 組織 | 創建組織標籤 | +| edit_org_label | 組織 | 編輯組織標籤 | +| delete_org_label | 組織 | 刪除組織標籤 | +| search_repos | 倉庫 | 搜尋倉庫 | +| get_gitea_mcp_server_version | 伺服器 | 取得 Gitea MCP 伺服器版本 | +| list_wiki_pages | Wiki | 列出所有 Wiki 頁面 | +| get_wiki_page | Wiki | 取得 Wiki 頁面內容與中繼資料 | +| get_wiki_revisions | Wiki | 取得 Wiki 修訂歷史 | +| create_wiki_page | Wiki | 創建新 Wiki 頁面 | +| update_wiki_page | Wiki | 更新現有 Wiki 頁面 | +| delete_wiki_page | Wiki | 刪除 Wiki 頁面 | + +## 🐛 調試 + +啟用調試模式時,請在 http 模式執行 Gitea MCP 伺服器時加上 `-d` 旗標: + +```sh +./gitea-mcp -t http [--port 8080] --token -d +``` + +## 🛠 疑難排解 + +如遇問題,可參考以下步驟: + +1. **檢查 PATH**:確保 `gitea-mcp` 執行檔已在系統 PATH 目錄中。 +2. **驗證依賴**:確認已安裝 `make` 與 `Golang` 等必要依賴。 +3. **檢查設定**:仔細檢查 MCP 設定檔是否有錯誤或遺漏。 +4. **查看日誌**:檢查日誌訊息或警告以獲取更多資訊。 + +享受透過聊天探索與管理您的 Gitea 倉庫! diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..b17ac33 --- /dev/null +++ b/build.bat @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..da8c595 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,220 @@ +#!/usr/bin/env pwsh + +# PowerShell build script for gitea-mcp +# Replicates the functionality of the Makefile + +param( + [string]$Target = "help" +) + +# Configuration +$EXECUTABLE = "gitea-mcp.exe" +$VERSION = & git describe --tags --always 2>$null | ForEach-Object { $_ -replace '-', '+' -replace '^v', '' } +if (-not $VERSION) { $VERSION = "dev" } +$LDFLAGS = "-X `"main.Version=$VERSION`"" + +# Colors for output (Windows PowerShell compatible) +$CYAN = "Cyan" +$RESET = "White" + +function Write-Header { + param([string]$Message) + Write-Host "=== $Message ===" -ForegroundColor Green +} + +function Write-Info { + param([string]$Message) + Write-Host $Message -ForegroundColor Yellow +} + +function Write-Success { + param([string]$Message) + Write-Host $Message -ForegroundColor Green +} + +function Write-Error { + param([string]$Message) + Write-Host $Message -ForegroundColor Red +} + +function Get-Help { + Write-Host "Usage: .\build.ps1 [target]" -ForegroundColor Green + Write-Host "" + Write-Host "Targets:" -ForegroundColor Green + Write-Host "" + + Write-Host ("{0,-30}" -f "help") -ForegroundColor Cyan -NoNewline + Write-Host " Print this help message." + Write-Host ("{0,-30}" -f "build") -ForegroundColor Cyan -NoNewline + Write-Host " Build the application." + Write-Host ("{0,-30}" -f "install") -ForegroundColor Cyan -NoNewline + Write-Host " Install the application." + Write-Host ("{0,-30}" -f "uninstall") -ForegroundColor Cyan -NoNewline + Write-Host " Uninstall the application." + Write-Host ("{0,-30}" -f "clean") -ForegroundColor Cyan -NoNewline + Write-Host " Clean the build artifacts." + Write-Host ("{0,-30}" -f "air") -ForegroundColor Cyan -NoNewline + Write-Host " Install air for hot reload." + Write-Host ("{0,-30}" -f "dev") -ForegroundColor Cyan -NoNewline + Write-Host " Run the application with hot reload." + Write-Host ("{0,-30}" -f "vendor") -ForegroundColor Cyan -NoNewline + Write-Host " Tidy and verify module dependencies." +} + +function Build-App { + Write-Header "Building application" + + $ldflags = "-s -w $LDFLAGS" + Write-Info "go build -v -ldflags '$ldflags' -o $EXECUTABLE" + + try { + & go build -v -ldflags $ldflags -o $EXECUTABLE + if ($LASTEXITCODE -eq 0) { + Write-Success "Build successful: $EXECUTABLE" + } else { + Write-Error "Build failed with exit code: $LASTEXITCODE" + exit $LASTEXITCODE + } + } catch { + Write-Error "Build failed: $_" + exit 1 + } +} + +function Install-App { + Write-Header "Installing application" + + # First build the application + Build-App + + $GOPATH = $env:GOPATH + if (-not $GOPATH) { + $GOPATH = Join-Path $env:USERPROFILE "go" + } + + $installDir = Join-Path $GOPATH "bin" + $installPath = Join-Path $installDir $EXECUTABLE + + Write-Info "Installing $EXECUTABLE to $installPath" + + # Create directory if it doesn't exist + if (-not (Test-Path $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + } + + # Copy the executable + if (Test-Path $EXECUTABLE) { + Copy-Item $EXECUTABLE $installPath -Force + Write-Success "Installed $EXECUTABLE to $installPath" + Write-Info "Please add $installDir to your PATH if it is not already there." + } else { + Write-Error "Executable not found. Please build first." + exit 1 + } +} + +function Uninstall-App { + Write-Header "Uninstalling application" + + $GOPATH = $env:GOPATH + if (-not $GOPATH) { + $GOPATH = Join-Path $env:USERPROFILE "go" + } + + $installPath = Join-Path $GOPATH "bin" $EXECUTABLE + + Write-Info "Uninstalling $EXECUTABLE from $installPath" + + if (Test-Path $installPath) { + Remove-Item $installPath -Force + Write-Success "Uninstalled $EXECUTABLE from $installPath" + } else { + Write-Warning "$EXECUTABLE not found at $installPath" + } +} + +function Clean-Build { + Write-Header "Cleaning build artifacts" + + Write-Info "Cleaning up $EXECUTABLE" + + if (Test-Path $EXECUTABLE) { + Remove-Item $EXECUTABLE -Force + Write-Success "Cleaned up $EXECUTABLE" + } else { + Write-Warning "$EXECUTABLE not found" + } +} + +function Install-Air { + Write-Header "Installing air for hot reload" + + # Check if air is already installed + $airPath = Get-Command air -ErrorAction SilentlyContinue + if ($airPath) { + Write-Success "air is already installed" + return + } + + Write-Info "Installing github.com/air-verse/air@latest" + try { + & go install github.com/air-verse/air@latest + if ($LASTEXITCODE -eq 0) { + Write-Success "air installed successfully" + } else { + Write-Error "Failed to install air" + exit $LASTEXITCODE + } + } catch { + Write-Error "Failed to install air: $_" + exit 1 + } +} + +function Start-Dev { + Write-Header "Starting development mode with hot reload" + + # Install air first + Install-Air + + Write-Info "Starting air with build configuration" + & air --build.cmd "go build -o $EXECUTABLE" --build.bin "./$EXECUTABLE" +} + +function Update-Vendor { + Write-Header "Tidying and verifying module dependencies" + + Write-Info "Running go mod tidy" + & go mod tidy + if ($LASTEXITCODE -ne 0) { + Write-Error "go mod tidy failed" + exit $LASTEXITCODE + } + + Write-Info "Running go mod verify" + & go mod verify + if ($LASTEXITCODE -ne 0) { + Write-Error "go mod verify failed" + exit $LASTEXITCODE + } + + Write-Success "Dependencies updated successfully" +} + +# Main execution logic +switch ($Target.ToLower()) { + "help" { Get-Help } + "build" { Build-App } + "install" { Install-App } + "uninstall" { Uninstall-App } + "clean" { Clean-Build } + "air" { Install-Air } + "dev" { Start-Dev } + "vendor" { Update-Vendor } + default { + Write-Error "Unknown target: $Target" + Write-Host "" + Get-Help + exit 1 + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..286b59d --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "text/tabwriter" + + "gitea.com/gitea/gitea-mcp/operation" + flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/log" +) + +var ( + host string + port int + token string + tools string + version bool +) + +func init() { + flag.StringVar(&flagPkg.Mode, "t", "stdio", "") + flag.StringVar(&flagPkg.Mode, "transport", "stdio", "") + flag.StringVar(&host, "H", os.Getenv("GITEA_HOST"), "") + flag.StringVar(&host, "host", os.Getenv("GITEA_HOST"), "") + flag.IntVar(&port, "p", 8080, "") + flag.IntVar(&port, "port", 8080, "") + flag.StringVar(&token, "T", "", "") + flag.StringVar(&token, "token", "", "") + flag.BoolVar(&flagPkg.ReadOnly, "r", false, "") + flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "") + defaultTools := os.Getenv("GITEA_TOOLS") + flag.StringVar(&tools, "O", defaultTools, "") + flag.StringVar(&tools, "tools", defaultTools, "") + flag.BoolVar(&flagPkg.Debug, "d", false, "") + flag.BoolVar(&flagPkg.Debug, "debug", false, "") + flag.BoolVar(&flagPkg.Insecure, "k", false, "") + flag.BoolVar(&flagPkg.Insecure, "insecure", false, "") + flag.BoolVar(&version, "v", false, "") + flag.BoolVar(&version, "version", false, "") + + flag.Usage = func() { + w := tabwriter.NewWriter(os.Stderr, 0, 0, 3, ' ', 0) + fmt.Fprintln(os.Stderr, "Usage: gitea-mcp [options]") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Options:") + fmt.Fprintf(w, " -t, -transport \tTransport type: stdio or http (default: stdio)\n") + fmt.Fprintf(w, " -H, -host \tGitea host URL (default: https://gitea.com)\n") + fmt.Fprintf(w, " -p, -port \tHTTP server port (default: 8080)\n") + fmt.Fprintf(w, " -T, -token \tPersonal access token\n") + fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") + fmt.Fprintf(w, " -O, -tools \tComma-separated list of tool names to expose\n") + fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n") + fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n") + fmt.Fprintf(w, " -v, -version\tPrint version and exit\n") + fmt.Fprintln(w) + fmt.Fprintln(w, "Environment variables:") + fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n") + fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n") + fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n") + fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n") + fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") + fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n") + fmt.Fprintf(w, " GITEA_TOOLS\tComma-separated list of tool names to expose\n") + fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n") + w.Flush() + } + + flag.Parse() + + flagPkg.Host = host + if flagPkg.Host == "" { + flagPkg.Host = "https://gitea.com" + } + + flagPkg.Port = port + + flagPkg.Token = token + if flagPkg.Token == "" { + flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN") + } + if flagPkg.Token == "" { + if tokenFile := os.Getenv("GITEA_ACCESS_TOKEN_FILE"); tokenFile != "" { + data, err := os.ReadFile(tokenFile) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading GITEA_ACCESS_TOKEN_FILE: %v\n", err) + os.Exit(1) + } + flagPkg.Token = strings.TrimRight(string(data), "\r\n") + } + } + + if os.Getenv("MCP_MODE") != "" { + flagPkg.Mode = os.Getenv("MCP_MODE") + } + + if os.Getenv("GITEA_READONLY") == "true" { + flagPkg.ReadOnly = true + } + + allowed := map[string]struct{}{} + for t := range strings.SplitSeq(tools, ",") { + if t = strings.TrimSpace(t); t != "" { + allowed[t] = struct{}{} + } + } + if len(allowed) > 0 { + flagPkg.AllowedTools = allowed + } + + if os.Getenv("GITEA_DEBUG") == "true" { + flagPkg.Debug = true + } + + // Set insecure mode based on environment variable + if os.Getenv("GITEA_INSECURE") == "true" { + flagPkg.Insecure = true + } +} + +func Execute() { + if version { + fmt.Fprintln(os.Stdout, flagPkg.Version) + return + } + defer log.Default().Sync() //nolint:errcheck // best-effort flush + if err := operation.Run(); err != nil { + if err == context.Canceled { + log.Info("Server shutdown due to context cancellation") + return + } + log.Fatalf("Run Gitea MCP Server Error: %v", err) //nolint:gocritic // intentional exit after defer + } +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..61cde97 --- /dev/null +++ b/config.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "gitea": { + "command": "gitea-mcp", + "args": [ + "-t", "stdio", + "--host", "https://gitea.com", + "--token", "" + ] + "env": { + "GITEA_HOST": "https://gitea.com", + "GITEA_ACCESS_TOKEN": "" + } + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d63b0df --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module gitea.com/gitea/gitea-mcp + +go 1.26.0 + +require ( + gitea.dev/sdk v1.0.1 + github.com/mark3labs/mcp-go v0.45.0 + go.uber.org/zap v1.27.1 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/42wim/httpsig v1.2.4 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6541b2d --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +gitea.dev/sdk v1.0.1 h1:CWXQUQvp2I6YKOWkhYo1Flx2sRNfMK1X9Op4oR2awXs= +gitea.dev/sdk v1.0.1/go.mod h1:jCf5Uzz0Jkb61jxNgMxLOCWwle1J1B2nKdcRtxuK9rY= +github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= +github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc= +github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f6b862d --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "runtime/debug" + + "gitea.com/gitea/gitea-mcp/cmd" + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +var Version = "dev" + +func init() { + if Version == "dev" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + Version = info.Main.Version + } + } + flag.Version = Version +} + +func main() { + cmd.Execute() +} diff --git a/operation/actions/actions.go b/operation/actions/actions.go new file mode 100644 index 0000000..3c314cc --- /dev/null +++ b/operation/actions/actions.go @@ -0,0 +1,8 @@ +package actions + +import ( + "gitea.com/gitea/gitea-mcp/pkg/tool" +) + +// Tool is the registry for all Actions-related MCP tools. +var Tool = tool.New() diff --git a/operation/actions/config.go b/operation/actions/config.go new file mode 100644 index 0000000..32a00c0 --- /dev/null +++ b/operation/actions/config.go @@ -0,0 +1,534 @@ +package actions + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ActionsConfigReadToolName = "actions_config_read" + ActionsConfigWriteToolName = "actions_config_write" +) + +type secretMeta struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at,omitzero"` +} + +func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta { + metas := make([]secretMeta, 0, len(secrets)) + for _, s := range secrets { + if s == nil { + continue + } + metas = append(metas, secretMeta{ + Name: s.Name, + Description: s.Description, + CreatedAt: s.Created, + }) + } + return metas +} + +var ( + ActionsConfigReadTool = mcp.NewTool( + ActionsConfigReadToolName, + mcp.WithDescription("Read Actions secrets and variables."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")), + mcp.WithString("owner", mcp.Description("for repo methods")), + mcp.WithString("repo", mcp.Description("for repo methods")), + mcp.WithString("org", mcp.Description("for org methods")), + mcp.WithString("name", mcp.Description("for get methods")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + ActionsConfigWriteTool = mcp.NewTool( + ActionsConfigWriteToolName, + mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, delete."), + mcp.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")), + mcp.WithString("method", mcp.Required(), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")), + mcp.WithString("owner", mcp.Description("for repo methods")), + mcp.WithString("repo", mcp.Description("for repo methods")), + mcp.WithString("org", mcp.Description("for org methods")), + mcp.WithString("name", mcp.Description("secret or variable name")), + mcp.WithString("data", mcp.Description("secret value (upsert)")), + mcp.WithString("value", mcp.Description("variable value")), + mcp.WithString("description"), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn}) + Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn}) +} + +func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list_repo_secrets": + return listRepoActionSecretsFn(ctx, req) + case "list_org_secrets": + return listOrgActionSecretsFn(ctx, req) + case "list_repo_variables": + return listRepoActionVariablesFn(ctx, req) + case "get_repo_variable": + return getRepoActionVariableFn(ctx, req) + case "list_org_variables": + return listOrgActionVariablesFn(ctx, req) + case "get_org_variable": + return getOrgActionVariableFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "upsert_repo_secret": + return upsertRepoActionSecretFn(ctx, req) + case "delete_repo_secret": + return deleteRepoActionSecretFn(ctx, req) + case "upsert_org_secret": + return upsertOrgActionSecretFn(ctx, req) + case "delete_org_secret": + return deleteOrgActionSecretFn(ctx, req) + case "create_repo_variable": + return createRepoActionVariableFn(ctx, req) + case "update_repo_variable": + return updateRepoActionVariableFn(ctx, req) + case "delete_repo_variable": + return deleteRepoActionVariableFn(ctx, req) + case "create_org_variable": + return createOrgActionVariableFn(ctx, req) + case "update_org_variable": + return updateOrgActionVariableFn(ctx, req) + case "delete_org_variable": + return deleteOrgActionVariableFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + secrets, _, err := client.Actions.ListRepoSecrets(ctx, owner, repo, gitea_sdk.ListRepoActionsSecretOption{ + ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err)) + } + + return to.TextResult(toSecretMetas(secrets)) +} + +func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + data, err := params.GetString(req.GetArguments(), "data") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.CreateRepoSecret(ctx, owner, repo, name, gitea_sdk.CreateOrUpdateSecretOption{ + Data: data, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) +} + +func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.DeleteRepoSecret(ctx, owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode}) +} + +func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + secrets, _, err := client.Actions.ListOrgSecrets(ctx, org, gitea_sdk.ListOrgActionsSecretOption{ + ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err)) + } + + return to.TextResult(toSecretMetas(secrets)) +} + +func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + data, err := params.GetString(req.GetArguments(), "data") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.CreateOrgSecret(ctx, org, name, gitea_sdk.CreateOrUpdateSecretOption{ + Data: data, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) +} + +func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + escapedOrg := url.PathEscape(org) + escapedSecret := url.PathEscape(name) + _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret deleted"}) +} + +func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err)) + } + return to.TextResult(result) +} + +func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err)) + } + return to.TextResult(variable) +} + +func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + value, err := params.GetString(req.GetArguments(), "value") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.CreateRepoVariable(ctx, owner, repo, name, value) + if err != nil { + return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) +} + +func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + value, err := params.GetString(req.GetArguments(), "value") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.UpdateRepoVariable(ctx, owner, repo, name, value) + if err != nil { + return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) +} + +func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.DeleteRepoVariable(ctx, owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode}) +} + +func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variables, _, err := client.Actions.ListOrgVariables(ctx, org, gitea_sdk.ListOrgActionsVariableOption{ + ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err)) + } + return to.TextResult(variables) +} + +func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variable, _, err := client.Actions.GetOrgVariable(ctx, org, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err)) + } + return to.TextResult(variable) +} + +func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + value, err := params.GetString(req.GetArguments(), "value") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.CreateOrgVariable(ctx, org, name, gitea_sdk.CreateActionsVariableOption{ + Value: value, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) +} + +func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + value, err := params.GetString(req.GetArguments(), "value") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.Actions.UpdateOrgVariable(ctx, org, name, gitea_sdk.UpdateActionsVariableOption{ + Name: name, + Value: value, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) +} + +func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + + _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable deleted"}) +} diff --git a/operation/actions/logs_test.go b/operation/actions/logs_test.go new file mode 100644 index 0000000..65a47df --- /dev/null +++ b/operation/actions/logs_test.go @@ -0,0 +1,22 @@ +package actions + +import "testing" + +func TestTailByLines(t *testing.T) { + in := []byte("a\nb\nc\nd\n") + got := string(tailByLines(in, 2)) + if got != "c\nd\n" { + t.Fatalf("tailByLines(...,2) = %q", got) + } +} + +func TestLimitBytesKeepsTail(t *testing.T) { + in := []byte("0123456789") + out, truncated := limitBytes(in, 4) + if !truncated { + t.Fatalf("expected truncated=true") + } + if string(out) != "6789" { + t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789") + } +} diff --git a/operation/actions/runs.go b/operation/actions/runs.go new file mode 100644 index 0000000..781dca2 --- /dev/null +++ b/operation/actions/runs.go @@ -0,0 +1,538 @@ +package actions + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ActionsRunReadToolName = "actions_run_read" + ActionsRunWriteToolName = "actions_run_write" +) + +var ( + ActionsRunReadTool = mcp.NewTool( + ActionsRunReadToolName, + mcp.WithDescription("Read Actions workflows, runs, jobs, and logs."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")), + mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")), + mcp.WithNumber("job_id", mcp.Description("for log methods")), + mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")), + mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)), + mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)), + mcp.WithString("output_path", mcp.Description("for 'download_job_log'")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + ActionsRunWriteTool = mcp.NewTool( + ActionsRunWriteToolName, + mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."), + mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")), + mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")), + mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")), + mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")), + mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn}) + Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn}) +} + +func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list_workflows": + return listRepoActionWorkflowsFn(ctx, req) + case "get_workflow": + return getRepoActionWorkflowFn(ctx, req) + case "list_runs": + return listRepoActionRunsFn(ctx, req) + case "get_run": + return getRepoActionRunFn(ctx, req) + case "list_jobs": + return listRepoActionJobsFn(ctx, req) + case "list_run_jobs": + return listRepoActionRunJobsFn(ctx, req) + case "get_job_log_preview": + return getRepoActionJobLogPreviewFn(ctx, req) + case "download_job_log": + return downloadRepoActionJobLogFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "dispatch_workflow": + return dispatchRepoActionWorkflowFn(ctx, req) + case "cancel_run": + return cancelRepoActionRunFn(ctx, req) + case "rerun_run": + return rerunRepoActionRunFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error { + var lastErr error + for _, p := range paths { + _, err := gitea.DoJSON(ctx, method, p, query, body, respOut) + if err == nil { + return nil + } + lastErr = err + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { + continue + } + return err + } + return lastErr +} + +func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err)) + } + return to.TextResult(slimActionWorkflows(result)) +} + +func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + workflowID, err := params.GetString(req.GetArguments(), "workflow_id") + if err != nil { + return to.ErrorResult(err) + } + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), + }, + nil, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err)) + } + return to.TextResult(slimActionWorkflow(result)) +} + +func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + workflowID, err := params.GetString(req.GetArguments(), "workflow_id") + if err != nil { + return to.ErrorResult(err) + } + ref, err := params.GetString(req.GetArguments(), "ref") + if err != nil { + return to.ErrorResult(err) + } + + var inputs map[string]any + if raw, exists := req.GetArguments()["inputs"]; exists { + if m, ok := raw.(map[string]any); ok { + inputs = m + } + } + + body := map[string]any{ + "ref": ref, + } + if inputs != nil { + body["inputs"] = inputs + } + + err = doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), + fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), + }, + nil, body, nil, + ) + if err != nil { + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { + return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) + } + return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err)) + } + return to.TextResult(map[string]any{"message": "workflow dispatched"}) +} + +func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + statusFilter, _ := req.GetArguments()["status"].(string) + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + if statusFilter != "" { + query.Set("status", statusFilter) + } + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action runs err: %v", err)) + } + return to.TextResult(slimActionRuns(result)) +} + +func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + runID, err := params.GetIndex(req.GetArguments(), "run_id") + if err != nil || runID <= 0 { + return to.ErrorResult(errors.New("run_id is required")) + } + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID), + }, + nil, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("get action run err: %v", err)) + } + return to.TextResult(slimActionRun(result)) +} + +func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + runID, err := params.GetIndex(req.GetArguments(), "run_id") + if err != nil || runID <= 0 { + return to.ErrorResult(errors.New("run_id is required")) + } + + err = doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID), + }, + nil, nil, nil, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err)) + } + return to.TextResult(map[string]any{"message": "run cancellation requested"}) +} + +func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + runID, err := params.GetIndex(req.GetArguments(), "run_id") + if err != nil || runID <= 0 { + return to.ErrorResult(errors.New("run_id is required")) + } + + err = doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID), + fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID), + }, + nil, nil, nil, + ) + if err != nil { + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { + return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) + } + return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err)) + } + return to.TextResult(map[string]any{"message": "run rerun requested"}) +} + +func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + statusFilter, _ := req.GetArguments()["status"].(string) + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + if statusFilter != "" { + query.Set("status", statusFilter) + } + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err)) + } + return to.TextResult(slimActionJobs(result)) +} + +func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + runID, err := params.GetIndex(req.GetArguments(), "run_id") + if err != nil || runID <= 0 { + return to.ErrorResult(errors.New("run_id is required")) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err)) + } + return to.TextResult(slimActionJobs(result)) +} + +func logPaths(owner, repo string, jobID int64) []string { + return []string{ + fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + } +} + +func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) { + var lastErr error + for _, p := range logPaths(owner, repo, jobID) { + b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain") + if err == nil { + return b, p, nil + } + lastErr = err + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { + continue + } + return nil, p, err + } + return nil, "", lastErr +} + +func tailByLines(data []byte, tailLines int) []byte { + if tailLines <= 0 || len(data) == 0 { + return data + } + lines := 0 + i := len(data) - 1 + for i >= 0 { + if data[i] == '\n' { + lines++ + if lines > tailLines { + return data[i+1:] + } + } + i-- + } + return data +} + +func limitBytes(data []byte, maxBytes int) ([]byte, bool) { + if maxBytes <= 0 { + return data, false + } + if len(data) <= maxBytes { + return data, false + } + return data[len(data)-maxBytes:], true +} + +func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + jobID, err := params.GetIndex(req.GetArguments(), "job_id") + if err != nil { + return to.ErrorResult(err) + } + tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200)) + maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536)) + raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get job log err: %v", err)) + } + + tailed := tailByLines(raw, tailLines) + limited, truncated := limitBytes(tailed, maxBytes) + + return to.TextResult(map[string]any{ + "endpoint": usedPath, + "job_id": jobID, + "bytes": len(raw), + "tail_lines": tailLines, + "max_bytes": maxBytes, + "truncated": truncated, + "log": string(limited), + }) +} + +func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + jobID, err := params.GetIndex(req.GetArguments(), "job_id") + if err != nil { + return to.ErrorResult(err) + } + outputPath, _ := req.GetArguments()["output_path"].(string) + + raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) + if err != nil { + return to.ErrorResult(fmt.Errorf("download job log err: %v", err)) + } + + if outputPath == "" { + home, _ := os.UserHomeDir() + if home == "" { + home = os.TempDir() + } + outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID)) + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return to.ErrorResult(fmt.Errorf("create output dir err: %v", err)) + } + if err := os.WriteFile(outputPath, raw, 0o600); err != nil { + return to.ErrorResult(fmt.Errorf("write log file err: %v", err)) + } + + return to.TextResult(map[string]any{ + "endpoint": usedPath, + "job_id": jobID, + "path": outputPath, + "bytes": len(raw), + }) +} diff --git a/operation/actions/slim.go b/operation/actions/slim.go new file mode 100644 index 0000000..3ecd07b --- /dev/null +++ b/operation/actions/slim.go @@ -0,0 +1,92 @@ +package actions + +func pick(m map[string]any, keys ...string) map[string]any { + out := make(map[string]any, len(keys)) + for _, k := range keys { + if v, ok := m[k]; ok { + out[k] = v + } + } + return out +} + +func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any { + m, ok := raw.(map[string]any) + if !ok { + return raw + } + result := make(map[string]any) + if tc, ok := m["total_count"]; ok { + result["total_count"] = tc + } + for key, val := range m { + if key == "total_count" { + continue + } + arr, ok := val.([]any) + if !ok { + continue + } + slimmed := make([]any, 0, len(arr)) + for _, item := range arr { + if im, ok := item.(map[string]any); ok { + slimmed = append(slimmed, itemFn(im)) + } + } + result[key] = slimmed + break + } + return result +} + +func slimRun(m map[string]any) map[string]any { + return pick(m, "id", "name", "head_branch", "head_sha", "run_number", + "event", "status", "conclusion", "workflow_id", + "html_url", "created_at", "updated_at") +} + +func slimJob(m map[string]any) map[string]any { + out := pick(m, "id", "run_id", "name", "workflow_name", + "status", "conclusion", "html_url", + "started_at", "completed_at") + if steps, ok := m["steps"].([]any); ok { + slim := make([]any, 0, len(steps)) + for _, s := range steps { + if sm, ok := s.(map[string]any); ok { + slim = append(slim, pick(sm, "name", "number", "status", "conclusion")) + } + } + out["steps"] = slim + } + return out +} + +func slimWorkflow(m map[string]any) map[string]any { + return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at") +} + +func slimActionRun(raw any) any { + if m, ok := raw.(map[string]any); ok { + return slimRun(m) + } + return raw +} + +func slimActionRuns(raw any) any { + return slimPaginated(raw, slimRun) +} + +func slimActionJobs(raw any) any { + return slimPaginated(raw, slimJob) +} + +func slimActionWorkflow(raw any) any { + if m, ok := raw.(map[string]any); ok { + return slimWorkflow(m) + } + return raw +} + +func slimActionWorkflows(raw any) any { + return slimPaginated(raw, slimWorkflow) +} diff --git a/operation/issue/issue.go b/operation/issue/issue.go new file mode 100644 index 0000000..4c023a5 --- /dev/null +++ b/operation/issue/issue.go @@ -0,0 +1,539 @@ +package issue + +import ( + "context" + "fmt" + "net/url" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// issueWithAssets / commentWithAssets wrap the SDK types to capture the +// `assets` field that the SDK currently drops on these endpoints. +type issueWithAssets struct { + gitea_sdk.Issue + Assets []*gitea_sdk.Attachment `json:"assets"` +} + +type commentWithAssets struct { + gitea_sdk.Comment + Assets []*gitea_sdk.Attachment `json:"assets"` +} + +var Tool = tool.New() + +const ( + ListRepoIssuesToolName = "list_issues" + IssueReadToolName = "issue_read" + IssueWriteToolName = "issue_write" +) + +var ( + ListRepoIssuesTool = mcp.NewTool( + ListRepoIssuesToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("state", mcp.DefaultString("all")), + mcp.WithString("type", mcp.Description("issues or pulls"), mcp.Enum("issues", "pulls")), + mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("milestones", mcp.Description("milestone name or ID filter"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithString("since", mcp.Description("updated after ISO 8601")), + mcp.WithString("before", mcp.Description("updated before ISO 8601")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + IssueReadTool = mcp.NewTool( + IssueReadToolName, + mcp.WithDescription("Read issue: details, comments, or labels."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")), + mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("issue_number", mcp.Required()), + ) + + IssueWriteTool = mcp.NewTool( + IssueWriteToolName, + mcp.WithDescription("Write issues: create, update, manage comments and labels."), + mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")), + mcp.WithString("title", mcp.Description("required for 'create'")), + mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")), + mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})), + mcp.WithNumber("milestone"), + mcp.WithString("state", mcp.Enum("open", "closed", "all")), + mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")), + mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})), + mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")), + mcp.WithString("ref", mcp.Description("branch to associate")), + mcp.WithString("deadline", mcp.Description("ISO 8601")), + mcp.WithBoolean("remove_deadline"), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: ListRepoIssuesTool, + Handler: listRepoIssuesFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: IssueReadTool, + Handler: issueReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: IssueWriteTool, + Handler: issueWriteFn, + }) +} + +func issueReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "get": + return getIssueByIndexFn(ctx, req) + case "get_comments": + return getIssueCommentsByIndexFn(ctx, req) + case "get_labels": + return getIssueLabelsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createIssueFn(ctx, req) + case "update": + return editIssueFn(ctx, req) + case "add_comment": + return createIssueCommentFn(ctx, req) + case "edit_comment": + return editIssueCommentFn(ctx, req) + case "add_labels": + return addIssueLabelsFn(ctx, req) + case "remove_label": + return removeIssueLabelFn(ctx, req) + case "replace_labels": + return replaceIssueLabelsFn(ctx, req) + case "clear_labels": + return clearIssueLabelsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + var issue issueWithAssets + path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + m := slimIssue(&issue.Issue) + m["body"] = slim.BodyWithAttachments(issue.Body, issue.Assets) + return to.TextResult(m) +} + +func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + state, ok := req.GetArguments()["state"].(string) + if !ok { + state = "all" + } + labels := params.GetStringSlice(req.GetArguments(), "labels") + milestones := params.GetStringSlice(req.GetArguments(), "milestones") + page, pageSize := params.GetPagination(req.GetArguments(), 30) + opt := gitea_sdk.ListIssueOption{ + State: gitea_sdk.StateType(state), + Labels: labels, + Milestones: milestones, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + switch req.GetArguments()["type"] { + case "issues": + opt.Type = gitea_sdk.IssueTypeIssue + case "pulls": + opt.Type = gitea_sdk.IssueTypePull + } + if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil { + opt.Since = *t + } + if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil { + opt.Before = *t + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issues, _, err := client.Issues.ListRepoIssues(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err)) + } + return to.TextResult(slimIssues(issues)) +} + +func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(req.GetArguments(), "title") + if err != nil { + return to.ErrorResult(err) + } + body, err := params.GetString(req.GetArguments(), "body") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + opt := gitea_sdk.CreateIssueOption{ + Title: title, + Body: body, + } + opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees") + if val, exists := req.GetArguments()["milestone"]; exists { + if milestone, ok := params.ToInt64(val); ok { + opt.Milestone = milestone + } + } + if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil { + opt.Labels = labelIDs + } + if ref, ok := req.GetArguments()["ref"].(string); ok { + opt.Ref = ref + } + opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline") + issue, _, err := client.Issues.CreateIssue(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) + } + + return to.TextResult(slimIssue(issue)) +} + +func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + body, err := params.GetString(req.GetArguments(), "body") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.CreateIssueCommentOption{ + Body: body, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issueComment, _, err := client.Issues.CreateIssueComment(ctx, owner, repo, index, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimComment(issueComment)) +} + +func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + + args := req.GetArguments() + opt := gitea_sdk.EditIssueOption{ + Body: params.GetPresentStringPtr(args, "body"), + Ref: params.GetPresentStringPtr(args, "ref"), + Assignees: params.GetStringSlice(args, "assignees"), + Deadline: params.GetOptionalTime(args, "deadline"), + RemoveDeadline: params.GetOptionalBoolPtr(args, "remove_deadline"), + } + if title, ok := args["title"].(string); ok { + opt.Title = title + } + if val, exists := args["milestone"]; exists { + if milestone, ok := params.ToInt64(val); ok { + opt.Milestone = &milestone + } + } + if state, ok := args["state"].(string); ok { + s := gitea_sdk.StateType(state) + opt.State = &s + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issue, _, err := client.Issues.EditIssue(ctx, owner, repo, index, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimIssue(issue)) +} + +func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + commentID, err := params.GetIndex(req.GetArguments(), "commentID") + if err != nil { + return to.ErrorResult(err) + } + body, err := params.GetString(req.GetArguments(), "body") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.EditIssueCommentOption{ + Body: body, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issueComment, _, err := client.Issues.EditIssueComment(ctx, owner, repo, commentID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err)) + } + + return to.TextResult(slimComment(issueComment)) +} + +func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + var comments []commentWithAssets + path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err)) + } + out := make([]map[string]any, 0, len(comments)) + for i := range comments { + m := slimComment(&comments[i].Comment) + m["body"] = slim.BodyWithAttachments(comments[i].Body, comments[i].Assets) + out = append(out, m) + } + return to.TextResult(out) +} + +func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + labels, _, err := client.Issues.GetIssueLabels(ctx, owner, repo, index, gitea_sdk.ListLabelsOptions{}) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err)) + } + return to.TextResult(slim.Labels(labels)) +} + +func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + labels, err := params.GetInt64Slice(req.GetArguments(), "labels") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issueLabels, _, err := client.Issues.AddIssueLabels(ctx, owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels}) + if err != nil { + return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + return to.TextResult(slim.Labels(issueLabels)) +} + +func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + labels, err := params.GetInt64Slice(req.GetArguments(), "labels") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issueLabels, _, err := client.Issues.ReplaceIssueLabels(ctx, owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels}) + if err != nil { + return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + return to.TextResult(slim.Labels(issueLabels)) +} + +func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.ClearIssueLabels(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + return to.TextResult("Labels cleared successfully") +} + +func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + labelID, err := params.GetIndex(req.GetArguments(), "label_id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.DeleteIssueLabel(ctx, owner, repo, index, labelID) + if err != nil { + return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err)) + } + return to.TextResult("Label removed successfully") +} diff --git a/operation/issue/issue_test.go b/operation/issue/issue_test.go new file mode 100644 index 0000000..767235d --- /dev/null +++ b/operation/issue/issue_test.go @@ -0,0 +1,324 @@ +package issue + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func Test_listRepoIssuesFn_filters(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + var ( + mu sync.Mutex + gotQuery string + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo): + mu.Lock() + gotQuery = r.URL.RawQuery + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "type": "issues", + "labels": []any{"bug", "enhancement"}, + "milestones": []any{"v1.0", "2"}, + "since": "2026-01-01T00:00:00Z", + }, + }, + } + + _, err := listRepoIssuesFn(context.Background(), req) + if err != nil { + t.Fatalf("listRepoIssuesFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") { + t.Fatalf("expected labels query param, got %s", gotQuery) + } + if !strings.Contains(gotQuery, "since=2026-01-01") { + t.Fatalf("expected since query param, got %s", gotQuery) + } + if !strings.Contains(gotQuery, "milestones=v1.0%2C2") { + t.Fatalf("expected milestones query param, got %s", gotQuery) + } + if !strings.Contains(gotQuery, "type=issues") { + t.Fatalf("expected type query param, got %s", gotQuery) + } +} + +func Test_listRepoIssuesFn_includesMilestone(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"number": 1, "title": "with milestone", "state": "closed", "milestone": {"id": 5, "title": "v1.0"}}, + {"number": 2, "title": "without milestone", "state": "open"} + ]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, + }}} + res, err := listRepoIssuesFn(context.Background(), req) + if err != nil { + t.Fatalf("listRepoIssuesFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) { + t.Fatalf("expected milestone in list output, got: %s", body) + } +} + +func Test_createIssueFn_labels(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + var ( + mu sync.Mutex + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo): + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "title": "test issue", + "body": "body", + "labels": []any{float64(10), float64(20)}, + "deadline": "2026-06-01T00:00:00Z", + }, + }, + } + + _, err := createIssueFn(context.Background(), req) + if err != nil { + t.Fatalf("createIssueFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + labels, ok := gotBody["labels"].([]any) + if !ok || len(labels) != 2 { + t.Fatalf("expected 2 labels, got %v", gotBody["labels"]) + } + if labels[0] != float64(10) || labels[1] != float64(20) { + t.Fatalf("expected labels [10,20], got %v", labels) + } + if gotBody["due_date"] == nil { + t.Fatalf("expected due_date to be set") + } +} + +func Test_getIssueByIndexFn_includesAttachments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 42, + "title": "bug with screenshot", + "body": "see attached", + "state": "open", + "assets": [ + {"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"} + ] + }`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "issue_number": float64(42), + }}} + res, err := getIssueByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getIssueByIndexFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `[shot.png](https://example/shot.png)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } + if strings.Contains(body, `"attachments"`) { + t.Fatalf("attachments should be inlined into body, not a separate field: %s", body) + } +} + +func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7/comments", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id": 1, "body": "see this", "assets": [ + {"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"} + ]}, + {"id": 2, "body": "no attachment", "assets": []} + ]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "issue_number": float64(7), + }}} + res, err := getIssueCommentsByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getIssueCommentsByIndexFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `[log.txt](https://example/log.txt)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } + if strings.Contains(body, `"attachments"`) { + t.Fatalf("attachments should be inlined into body, not a separate field: %s", body) + } +} diff --git a/operation/issue/slim.go b/operation/issue/slim.go new file mode 100644 index 0000000..84105e4 --- /dev/null +++ b/operation/issue/slim.go @@ -0,0 +1,95 @@ +package issue + +import ( + "gitea.com/gitea/gitea-mcp/pkg/slim" + + gitea_sdk "gitea.dev/sdk" +) + +func slimIssue(i *gitea_sdk.Issue) map[string]any { + if i == nil { + return nil + } + m := map[string]any{ + "number": i.Index, + "title": i.Title, + "body": i.Body, + "state": i.State, + "html_url": i.HTMLURL, + "user": slim.UserLogin(i.Poster), + "labels": slim.LabelNames(i.Labels), + "comments": i.Comments, + "created_at": i.Created, + "updated_at": i.Updated, + "closed_at": i.Closed, + } + if len(i.Assignees) > 0 { + m["assignees"] = slim.UserLogins(i.Assignees) + } + if i.Milestone != nil { + m["milestone"] = map[string]any{ + "id": i.Milestone.ID, + "title": i.Milestone.Title, + } + } + if i.Ref != "" { + m["ref"] = i.Ref + } + if i.Deadline != nil { + m["deadline"] = i.Deadline + } + if i.PullRequest != nil { + m["is_pull"] = true + } + return m +} + +func slimIssues(issues []*gitea_sdk.Issue) []map[string]any { + out := make([]map[string]any, 0, len(issues)) + for _, i := range issues { + if i == nil { + continue + } + m := map[string]any{ + "number": i.Index, + "title": i.Title, + "state": i.State, + "html_url": i.HTMLURL, + "user": slim.UserLogin(i.Poster), + "comments": i.Comments, + "created_at": i.Created, + "updated_at": i.Updated, + } + if len(i.Labels) > 0 { + m["labels"] = slim.LabelNames(i.Labels) + } + if i.Milestone != nil { + m["milestone"] = map[string]any{ + "id": i.Milestone.ID, + "title": i.Milestone.Title, + } + } + if i.Ref != "" { + m["ref"] = i.Ref + } + if i.Deadline != nil { + m["deadline"] = i.Deadline + } + out = append(out, m) + } + return out +} + +func slimComment(c *gitea_sdk.Comment) map[string]any { + if c == nil { + return nil + } + return map[string]any{ + "id": c.ID, + "body": c.Body, + "user": slim.UserLogin(c.Poster), + "html_url": c.HTMLURL, + "created_at": c.Created, + "updated_at": c.Updated, + } +} diff --git a/operation/issue/slim_test.go b/operation/issue/slim_test.go new file mode 100644 index 0000000..c61105b --- /dev/null +++ b/operation/issue/slim_test.go @@ -0,0 +1,69 @@ +package issue + +import ( + "testing" + + gitea_sdk "gitea.dev/sdk" +) + +func TestSlimIssue(t *testing.T) { + i := &gitea_sdk.Issue{ + Index: 42, + Title: "Bug report", + Body: "Something is broken", + State: "open", + HTMLURL: "https://gitea.com/org/repo/issues/42", + Poster: &gitea_sdk.User{UserName: "alice"}, + Labels: []*gitea_sdk.Label{{Name: "bug"}}, + Milestone: &gitea_sdk.Milestone{ + ID: 1, + Title: "v1.0", + }, + PullRequest: &gitea_sdk.PullRequestMeta{HasMerged: false}, + } + + m := slimIssue(i) + + if m["number"] != int64(42) { + t.Errorf("expected number 42, got %v", m["number"]) + } + if m["body"] != "Something is broken" { + t.Errorf("expected body, got %v", m["body"]) + } + if m["is_pull"] != true { + t.Error("expected is_pull true for issue with PullRequest") + } + + ms := m["milestone"].(map[string]any) + if ms["title"] != "v1.0" { + t.Errorf("expected milestone title v1.0, got %v", ms["title"]) + } +} + +func TestSlimIssues_ListIsSlimmer(t *testing.T) { + i := &gitea_sdk.Issue{ + Index: 1, + Title: "Issue", + State: "open", + Body: "Full body", + Poster: &gitea_sdk.User{UserName: "alice"}, + Labels: []*gitea_sdk.Label{{Name: "enhancement"}}, + } + + single := slimIssue(i) + list := slimIssues([]*gitea_sdk.Issue{i}) + + // Single has body, list does not + if _, ok := single["body"]; !ok { + t.Error("single issue should have body") + } + if _, ok := list[0]["body"]; ok { + t.Error("list issue should not have body") + } +} + +func TestSlimIssues_Nil(t *testing.T) { + if r := slimIssues(nil); len(r) != 0 { + t.Errorf("expected empty slice, got %v", r) + } +} diff --git a/operation/label/label.go b/operation/label/label.go new file mode 100644 index 0000000..3fce252 --- /dev/null +++ b/operation/label/label.go @@ -0,0 +1,366 @@ +package label + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + LabelReadToolName = "label_read" + LabelWriteToolName = "label_write" +) + +var ( + LabelReadTool = mcp.NewTool( + LabelReadToolName, + mcp.WithDescription("Read repo or org labels."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")), + mcp.WithString("owner", mcp.Description("for repo methods")), + mcp.WithString("repo", mcp.Description("for repo methods")), + mcp.WithString("org", mcp.Description("for org methods")), + mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + LabelWriteTool = mcp.NewTool( + LabelWriteToolName, + mcp.WithDescription("Write labels (repo or org): create, edit, delete."), + mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")), + mcp.WithString("owner", mcp.Description("for repo methods")), + mcp.WithString("repo", mcp.Description("for repo methods")), + mcp.WithString("org", mcp.Description("for org methods")), + mcp.WithNumber("id", mcp.Description("for edit/delete")), + mcp.WithString("name", mcp.Description("required for create")), + mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")), + mcp.WithString("description"), + mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")), + mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: LabelReadTool, + Handler: labelReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: LabelWriteTool, + Handler: labelWriteFn, + }) +} + +func labelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list_repo_labels": + return listRepoLabelsFn(ctx, req) + case "get_repo_label": + return getRepoLabelFn(ctx, req) + case "list_org_labels": + return listOrgLabelsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func labelWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create_repo_label": + return createRepoLabelFn(ctx, req) + case "edit_repo_label": + return editRepoLabelFn(ctx, req) + case "delete_repo_label": + return deleteRepoLabelFn(ctx, req) + case "create_org_label": + return createOrgLabelFn(ctx, req) + case "edit_org_label": + return editOrgLabelFn(ctx, req) + case "delete_org_label": + return deleteOrgLabelFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + opt := gitea_sdk.ListLabelsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + labels, _, err := client.Repositories.ListRepoLabels(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err)) + } + return to.TextResult(slim.Labels(labels)) +} + +func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + label, _, err := client.Repositories.GetRepoLabel(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err)) + } + return to.TextResult(slim.Label(label)) +} + +func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + color, err := params.GetString(req.GetArguments(), "color") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) // Optional + + isArchived, _ := req.GetArguments()["is_archived"].(bool) + + opt := gitea_sdk.CreateLabelOption{ + Name: name, + Color: color, + Description: description, + IsArchived: isArchived, + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + label, _, err := client.Repositories.CreateLabel(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err)) + } + return to.TextResult(slim.Label(label)) +} + +func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + args := req.GetArguments() + opt := gitea_sdk.EditLabelOption{ + Name: params.GetOptionalStringPtr(args, "name"), + Color: params.GetOptionalStringPtr(args, "color"), + Description: params.GetPresentStringPtr(args, "description"), + IsArchived: params.GetOptionalBoolPtr(args, "is_archived"), + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + label, _, err := client.Repositories.EditLabel(ctx, owner, repo, id, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err)) + } + return to.TextResult(slim.Label(label)) +} + +func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Repositories.DeleteLabel(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, id, err)) + } + return to.TextResult("Label deleted successfully") +} + +func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + opt := gitea_sdk.ListOrgLabelsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + labels, _, err := client.Organizations.ListOrgLabels(ctx, org, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err)) + } + return to.TextResult(slim.Labels(labels)) +} + +func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(req.GetArguments(), "name") + if err != nil { + return to.ErrorResult(err) + } + color, err := params.GetString(req.GetArguments(), "color") + if err != nil { + return to.ErrorResult(err) + } + description, _ := req.GetArguments()["description"].(string) + exclusive, _ := req.GetArguments()["exclusive"].(bool) + + opt := gitea_sdk.CreateOrgLabelOption{ + Name: name, + Color: color, + Description: description, + Exclusive: exclusive, + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + label, _, err := client.Organizations.CreateOrgLabel(ctx, org, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err)) + } + return to.TextResult(slim.Label(label)) +} + +func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + args := req.GetArguments() + opt := gitea_sdk.EditOrgLabelOption{ + Name: params.GetOptionalStringPtr(args, "name"), + Color: params.GetOptionalStringPtr(args, "color"), + Description: params.GetPresentStringPtr(args, "description"), + Exclusive: params.GetOptionalBoolPtr(args, "exclusive"), + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + label, _, err := client.Organizations.EditOrgLabel(ctx, org, id, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err)) + } + return to.TextResult(slim.Label(label)) +} + +func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Organizations.DeleteOrgLabel(ctx, org, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, id, err)) + } + return to.TextResult("Label deleted successfully") +} diff --git a/operation/label/slim.go b/operation/label/slim.go new file mode 100644 index 0000000..651677a --- /dev/null +++ b/operation/label/slim.go @@ -0,0 +1 @@ +package label diff --git a/operation/milestone/milestone.go b/operation/milestone/milestone.go new file mode 100644 index 0000000..8b304f3 --- /dev/null +++ b/operation/milestone/milestone.go @@ -0,0 +1,254 @@ +package milestone + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + MilestoneReadToolName = "milestone_read" + MilestoneWriteToolName = "milestone_write" +) + +var ( + MilestoneReadTool = mcp.NewTool( + MilestoneReadToolName, + mcp.WithDescription("Read milestones: get one or list."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")), + mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("id", mcp.Description("for 'get'")), + mcp.WithString("state", mcp.DefaultString("all")), + mcp.WithString("name", mcp.Description("name filter (for 'list')")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + MilestoneWriteTool = mcp.NewTool( + MilestoneWriteToolName, + mcp.WithDescription("Write milestones: create, update, delete."), + mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")), + mcp.WithString("title", mcp.Description("for 'create'")), + mcp.WithString("description"), + mcp.WithString("due_on", mcp.Description("due date")), + mcp.WithString("state", mcp.Enum("open", "closed")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: MilestoneReadTool, + Handler: milestoneReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: MilestoneWriteTool, + Handler: milestoneWriteFn, + }) +} + +func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "get": + return getMilestoneFn(ctx, req) + case "list": + return listMilestonesFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createMilestoneFn(ctx, req) + case "update": + return editMilestoneFn(ctx, req) + case "edit": + return editMilestoneFn(ctx, req) + case "delete": + return deleteMilestoneFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + milestone, _, err := client.Repositories.GetMilestone(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, id, err)) + } + + return to.TextResult(slimMilestone(milestone)) +} + +func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + state := params.GetOptionalString(req.GetArguments(), "state", "all") + name := params.GetOptionalString(req.GetArguments(), "name", "") + page, pageSize := params.GetPagination(req.GetArguments(), 30) + opt := gitea_sdk.ListMilestoneOption{ + State: gitea_sdk.StateType(state), + Name: name, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + milestones, _, err := client.Repositories.ListMilestones(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err)) + } + return to.TextResult(slimMilestones(milestones)) +} + +func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(req.GetArguments(), "title") + if err != nil { + return to.ErrorResult(err) + } + + opt := gitea_sdk.CreateMilestoneOption{ + Title: title, + } + + description, ok := req.GetArguments()["description"].(string) + if ok { + opt.Description = description + } + opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on") + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + milestone, _, err := client.Repositories.CreateMilestone(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err)) + } + + return to.TextResult(slimMilestone(milestone)) +} + +func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + + args := req.GetArguments() + opt := gitea_sdk.EditMilestoneOption{ + Description: params.GetPresentStringPtr(args, "description"), + Deadline: params.GetOptionalTime(args, "due_on"), + } + if title, ok := args["title"].(string); ok { + opt.Title = title + } + if state, ok := args["state"].(string); ok { + s := gitea_sdk.StateType(state) + opt.State = &s + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + milestone, _, err := client.Repositories.EditMilestone(ctx, owner, repo, id, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, id, err)) + } + + return to.TextResult(slimMilestone(milestone)) +} + +func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Repositories.DeleteMilestone(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, id, err)) + } + + return to.TextResult("Milestone deleted successfully") +} diff --git a/operation/milestone/milestone_test.go b/operation/milestone/milestone_test.go new file mode 100644 index 0000000..7a2a505 --- /dev/null +++ b/operation/milestone/milestone_test.go @@ -0,0 +1,84 @@ +package milestone + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func Test_milestoneWriteFn_dueOn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + id = 42 + due = "2026-05-18T23:59:59Z" + ) + + var ( + mu sync.Mutex + bodies = map[string]map[string]any{} + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo), + fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id): + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + bodies[r.Method] = body + mu.Unlock() + _, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"v1","due_on":%q}`, id, due)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + args := map[string]any{"owner": owner, "repo": repo, "due_on": due} + + cases := []struct { + name string + fn func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) + method string + extra map[string]any + }{ + {"create", createMilestoneFn, http.MethodPost, map[string]any{"title": "v1"}}, + {"edit", editMilestoneFn, http.MethodPatch, map[string]any{"id": float64(id)}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := map[string]any{} + maps.Copy(a, args) + maps.Copy(a, tc.extra) + res, err := tc.fn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: a}}) + if err != nil || res.IsError { + t.Fatalf("%s err=%v result=%v", tc.name, err, res) + } + mu.Lock() + body := bodies[tc.method] + mu.Unlock() + if got, _ := body["due_on"].(string); got != due { + t.Fatalf("%s: expected due_on=%q, got %v (body: %v)", tc.name, due, got, body) + } + }) + } +} diff --git a/operation/milestone/slim.go b/operation/milestone/slim.go new file mode 100644 index 0000000..3b6d1cf --- /dev/null +++ b/operation/milestone/slim.go @@ -0,0 +1,28 @@ +package milestone + +import ( + gitea_sdk "gitea.dev/sdk" +) + +func slimMilestone(m *gitea_sdk.Milestone) map[string]any { + if m == nil { + return nil + } + return map[string]any{ + "id": m.ID, + "title": m.Title, + "description": m.Description, + "state": m.State, + "open_issues": m.OpenIssues, + "closed_issues": m.ClosedIssues, + "due_on": m.Deadline, + } +} + +func slimMilestones(milestones []*gitea_sdk.Milestone) []map[string]any { + out := make([]map[string]any, 0, len(milestones)) + for _, m := range milestones { + out = append(out, slimMilestone(m)) + } + return out +} diff --git a/operation/notification/notification.go b/operation/notification/notification.go new file mode 100644 index 0000000..6ed5a2f --- /dev/null +++ b/operation/notification/notification.go @@ -0,0 +1,213 @@ +package notification + +import ( + "context" + "fmt" + "time" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + NotificationReadToolName = "notification_read" + NotificationWriteToolName = "notification_write" +) + +var ( + NotificationReadTool = mcp.NewTool( + NotificationReadToolName, + mcp.WithDescription("Read notifications: list (optionally scoped to a repo) or get a thread by ID."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read notifications")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get")), + mcp.WithString("owner", mcp.Description("scope 'list' to a repo")), + mcp.WithString("repo", mcp.Description("scope 'list' to a repo")), + mcp.WithNumber("id", mcp.Description("thread ID (for 'get')")), + mcp.WithString("status", mcp.Enum("unread", "read", "pinned")), + mcp.WithString("subject_type", mcp.Enum("Issue", "Pull", "Commit", "Repository")), + mcp.WithString("since", mcp.Description("updated after ISO 8601")), + mcp.WithString("before", mcp.Description("updated before ISO 8601")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + NotificationWriteTool = mcp.NewTool( + NotificationWriteToolName, + mcp.WithDescription("Mark a notification or all notifications as read."), + mcp.WithToolAnnotation(annotation.Write("Manage notifications")), + mcp.WithString("method", mcp.Required(), mcp.Enum("mark_read", "mark_all_read")), + mcp.WithNumber("id", mcp.Description("thread ID (for 'mark_read')")), + mcp.WithString("owner", mcp.Description("scope 'mark_all_read' to a repo")), + mcp.WithString("repo", mcp.Description("scope 'mark_all_read' to a repo")), + mcp.WithString("last_read_at", mcp.Description("ISO 8601; defaults to now")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: NotificationReadTool, + Handler: notificationReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: NotificationWriteTool, + Handler: notificationWriteFn, + }) +} + +func notificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listNotificationsFn(ctx, req) + case "get": + return getNotificationFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func notificationWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "mark_read": + return markNotificationReadFn(ctx, req) + case "mark_all_read": + return markAllNotificationsReadFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + page, pageSize := params.GetPagination(args, 30) + opt := gitea_sdk.ListNotificationOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + if status, ok := args["status"].(string); ok { + opt.Status = []gitea_sdk.NotificationStatus{gitea_sdk.NotificationStatus(status)} + } + if subjectType, ok := args["subject_type"].(string); ok { + opt.SubjectTypes = []gitea_sdk.NotificationSubjectType{gitea_sdk.NotificationSubjectType(subjectType)} + } + if t := params.GetOptionalTime(args, "since"); t != nil { + opt.Since = *t + } + if t := params.GetOptionalTime(args, "before"); t != nil { + opt.Before = *t + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + owner := params.GetOptionalString(args, "owner", "") + repo := params.GetOptionalString(args, "repo", "") + if owner != "" && repo != "" { + threads, _, err := client.Notifications.ListByRepo(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/%v/notifications err: %v", owner, repo, err)) + } + return to.TextResult(slimThreads(threads)) + } + + threads, _, err := client.Notifications.List(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list notifications err: %v", err)) + } + return to.TextResult(slimThreads(threads)) +} + +func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + thread, _, err := client.Notifications.GetByID(ctx, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get notification/%v err: %v", id, err)) + } + return to.TextResult(slimThread(thread)) +} + +func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + thread, _, err := client.Notifications.MarkReadByID(ctx, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("mark notification/%v read err: %v", id, err)) + } + if thread != nil { + return to.TextResult(slimThread(thread)) + } + return to.TextResult("Notification marked as read") +} + +func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + lastReadAt := time.Now() + if t := params.GetOptionalTime(args, "last_read_at"); t != nil { + lastReadAt = *t + } + opt := gitea_sdk.MarkNotificationOptions{ + LastReadAt: lastReadAt, + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + owner := params.GetOptionalString(args, "owner", "") + repo := params.GetOptionalString(args, "repo", "") + if owner != "" && repo != "" { + threads, _, err := client.Notifications.MarkReadByRepo(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("mark %v/%v/notifications read err: %v", owner, repo, err)) + } + if threads != nil { + return to.TextResult(slimThreads(threads)) + } + return to.TextResult("All repository notifications marked as read") + } + + threads, _, err := client.Notifications.MarkRead(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err)) + } + if threads != nil { + return to.TextResult(slimThreads(threads)) + } + return to.TextResult("All notifications marked as read") +} diff --git a/operation/notification/slim.go b/operation/notification/slim.go new file mode 100644 index 0000000..a1e4d40 --- /dev/null +++ b/operation/notification/slim.go @@ -0,0 +1,66 @@ +package notification + +import ( + gitea_sdk "gitea.dev/sdk" +) + +func slimThread(t *gitea_sdk.NotificationThread) map[string]any { + if t == nil { + return nil + } + m := map[string]any{ + "id": t.ID, + "unread": t.Unread, + "updated_at": t.UpdatedAt, + } + if t.Pinned { + m["pinned"] = true + } + if t.Repository != nil { + m["repository"] = t.Repository.FullName + } + if t.Subject != nil { + subject := map[string]any{ + "title": t.Subject.Title, + "type": t.Subject.Type, + "state": t.Subject.State, + } + if t.Subject.HTMLURL != "" { + subject["html_url"] = t.Subject.HTMLURL + } + if t.Subject.LatestCommentHTMLURL != "" { + subject["latest_comment_html_url"] = t.Subject.LatestCommentHTMLURL + } + m["subject"] = subject + } + return m +} + +func slimThreads(threads []*gitea_sdk.NotificationThread) []map[string]any { + out := make([]map[string]any, 0, len(threads)) + for _, t := range threads { + if t == nil { + continue + } + m := map[string]any{ + "id": t.ID, + "unread": t.Unread, + "updated_at": t.UpdatedAt, + } + if t.Pinned { + m["pinned"] = true + } + if t.Repository != nil { + m["repository"] = t.Repository.FullName + } + if t.Subject != nil { + m["subject"] = map[string]any{ + "title": t.Subject.Title, + "type": t.Subject.Type, + "state": t.Subject.State, + } + } + out = append(out, m) + } + return out +} diff --git a/operation/operation.go b/operation/operation.go new file mode 100644 index 0000000..c92a201 --- /dev/null +++ b/operation/operation.go @@ -0,0 +1,139 @@ +package operation + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "gitea.com/gitea/gitea-mcp/operation/actions" + "gitea.com/gitea/gitea-mcp/operation/issue" + "gitea.com/gitea/gitea-mcp/operation/label" + "gitea.com/gitea/gitea-mcp/operation/milestone" + "gitea.com/gitea/gitea-mcp/operation/notification" + "gitea.com/gitea/gitea-mcp/operation/packages" + "gitea.com/gitea/gitea-mcp/operation/pull" + "gitea.com/gitea/gitea-mcp/operation/repo" + "gitea.com/gitea/gitea-mcp/operation/search" + "gitea.com/gitea/gitea-mcp/operation/timetracking" + "gitea.com/gitea/gitea-mcp/operation/user" + "gitea.com/gitea/gitea-mcp/operation/version" + "gitea.com/gitea/gitea-mcp/operation/wiki" + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/server" +) + +var ( + mcpServer *server.MCPServer + + domainTools = []*tool.Tool{ + user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, + label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, + version.Tool, wiki.Tool, timetracking.Tool, + } +) + +func RegisterTool(s *server.MCPServer) { + for _, t := range domainTools { + s.AddTools(t.Tools()...) + } + tool.WarnUnmatchedAllowedTools(domainTools...) +} + +// parseAuthToken extracts the token from an Authorization header. +// Supports "Bearer " (case-insensitive per RFC 7235) and +// Gitea-style "token " formats. +// Returns the token and true if valid, empty string and false otherwise. +func parseAuthToken(authHeader string) (string, bool) { + if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") { + token := strings.TrimSpace(authHeader[7:]) + if token != "" { + return token, true + } + } + if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") { + token := strings.TrimSpace(authHeader[6:]) + if token != "" { + return token, true + } + } + return "", false +} + +func getContextWithToken(ctx context.Context, r *http.Request) context.Context { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return ctx + } + + token, ok := parseAuthToken(authHeader) + if !ok { + return ctx + } + + return context.WithValue(ctx, mcpContext.TokenContextKey, token) +} + +func Run() error { + mcpServer = newMCPServer(flag.Version) + RegisterTool(mcpServer) + switch flag.Mode { + case "stdio": + if err := server.ServeStdio( + mcpServer, + ); err != nil { + return err + } + case "http": + httpServer := server.NewStreamableHTTPServer( + mcpServer, + server.WithLogger(log.Default().Sugar()), + server.WithHeartbeatInterval(30*time.Second), + server.WithHTTPContextFunc(getContextWithToken), + ) + log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port) + + // Graceful shutdown setup + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + shutdownDone := make(chan struct{}) + + go func() { + <-sigCh + log.Infof("Shutdown signal received, gracefully stopping HTTP server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Errorf("HTTP server shutdown error: %v", err) + } + close(shutdownDone) + }() + + if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + <-shutdownDone // Wait for shutdown to finish + default: + return fmt.Errorf("invalid transport type: %s. Must be 'stdio' or 'http'", flag.Mode) + } + return nil +} + +func newMCPServer(version string) *server.MCPServer { + return server.NewMCPServer( + "Gitea MCP Server", + version, + server.WithToolCapabilities(true), + server.WithLogging(), + server.WithRecovery(), + ) +} diff --git a/operation/operation_test.go b/operation/operation_test.go new file mode 100644 index 0000000..55c642b --- /dev/null +++ b/operation/operation_test.go @@ -0,0 +1,105 @@ +package operation + +import ( + "testing" +) + +func TestParseAuthToken(t *testing.T) { + tests := []struct { + name string + header string + wantToken string + wantOK bool + }{ + { + name: "valid Bearer token", + header: "Bearer validtoken", + wantToken: "validtoken", + wantOK: true, + }, + { + name: "lowercase bearer", + header: "bearer lowercase", + wantToken: "lowercase", + wantOK: true, + }, + { + name: "uppercase BEARER", + header: "BEARER uppercase", + wantToken: "uppercase", + wantOK: true, + }, + { + name: "token with spaces trimmed", + header: "Bearer spacedToken ", + wantToken: "spacedToken", + wantOK: true, + }, + { + name: "bearer with no token", + header: "Bearer ", + wantToken: "", + wantOK: false, + }, + { + name: "bearer with only spaces", + header: "Bearer ", + wantToken: "", + wantOK: false, + }, + { + name: "missing space after Bearer", + header: "Bearertoken", + wantToken: "", + wantOK: false, + }, + { + name: "Gitea token format", + header: "token giteaapitoken", + wantToken: "giteaapitoken", + wantOK: true, + }, + { + name: "Gitea Token format capitalized", + header: "Token giteaapitoken", + wantToken: "giteaapitoken", + wantOK: true, + }, + { + name: "token with no value", + header: "token ", + wantToken: "", + wantOK: false, + }, + { + name: "different auth type", + header: "Basic dXNlcjpwYXNz", + wantToken: "", + wantOK: false, + }, + { + name: "empty header", + header: "", + wantToken: "", + wantOK: false, + }, + { + name: "bearer token with internal spaces", + header: "Bearer token with spaces", + wantToken: "token with spaces", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotToken, gotOK := parseAuthToken(tt.header) + if gotToken != tt.wantToken { + t.Errorf("parseAuthToken() token = %q, want %q", gotToken, tt.wantToken) + } + if gotOK != tt.wantOK { + t.Errorf("parseAuthToken() ok = %v, want %v", gotOK, tt.wantOK) + } + }) + } +} diff --git a/operation/packages/packages.go b/operation/packages/packages.go new file mode 100644 index 0000000..335bb77 --- /dev/null +++ b/operation/packages/packages.go @@ -0,0 +1,220 @@ +package packages + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + PackageReadToolName = "package_read" + PackageWriteToolName = "package_write" +) + +var ( + PackageReadTool = mcp.NewTool( + PackageReadToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Read package registry")), + mcp.WithDescription("Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version."), + mcp.WithString("method", mcp.Required(), mcp.Enum("list", "list_versions", "get")), + mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")), + mcp.WithString("type", mcp.Description("container/npm/maven/pypi/cargo/generic; required except 'list'")), + mcp.WithString("name", mcp.Description("slashes auto-encoded; required except 'list'")), + mcp.WithString("version", mcp.Description("for 'get'")), + mcp.WithString("q", mcp.Description("search query")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + PackageWriteTool = mcp.NewTool( + PackageWriteToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a package version")), + mcp.WithDescription("Delete a package version (irreversible)."), + mcp.WithString("method", mcp.Required(), mcp.Enum("delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")), + mcp.WithString("type", mcp.Required(), mcp.Description("container/npm/maven/pypi/cargo/generic")), + mcp.WithString("name", mcp.Required(), mcp.Description("slashes auto-encoded")), + mcp.WithString("version", mcp.Required()), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: PackageReadTool, + Handler: packageReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: PackageWriteTool, + Handler: packageWriteFn, + }) +} + +func packageReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listPackagesFn(ctx, req) + case "list_versions": + return listPackageVersionsFn(ctx, req) + case "get": + return getPackageFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func packageWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "delete": + return deletePackageVersionFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +// escapePackageName normalises a package name for use in URL paths. It +// accepts both raw names (my-repo/my-image) and pre-encoded names +// (my-repo%2Fmy-image), decoding first to avoid double-encoding. A literal +// '%' followed by two hex digits in a raw name will be folded into its +// decoded form, but package names typically do not contain '%'. +func escapePackageName(name string) string { + if strings.Contains(name, "%") { + if decoded, err := url.PathUnescape(name); err == nil { + name = decoded + } + } + return url.PathEscape(name) +} + +func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + + query := url.Values{} + if typ, ok := args["type"].(string); ok && typ != "" { + query.Set("type", typ) + } + if q, ok := args["q"].(string); ok && q != "" { + query.Set("q", q) + } + page, pageSize := params.GetPagination(args, 30) + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + _, err = gitea.DoJSON(ctx, "GET", "packages/"+url.PathEscape(owner), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list packages err: %v", err)) + } + + return to.TextResult(slimPackages(result)) +} + +func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + + query := url.Values{} + page, pageSize := params.GetPagination(args, 30) + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name)), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list package versions err: %v", err)) + } + + return to.TextResult(slimPackages(result)) +} + +func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + version, err := params.GetString(args, "version") + if err != nil { + return to.ErrorResult(err) + } + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("get package err: %v", err)) + } + + return to.TextResult(slimPackage(result)) +} + +func deletePackageVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + version, err := params.GetString(args, "version") + if err != nil { + return to.ErrorResult(err) + } + + _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete package version err: %v", err)) + } + + return to.TextResult("Package version deleted successfully") +} diff --git a/operation/packages/packages_test.go b/operation/packages/packages_test.go new file mode 100644 index 0000000..0027db8 --- /dev/null +++ b/operation/packages/packages_test.go @@ -0,0 +1,381 @@ +package packages + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestPackageReadList(t *testing.T) { + var mu sync.Mutex + var gotQuery map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotQuery = map[string]string{} + for k, v := range r.URL.Query() { + gotQuery[k] = v[0] + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"},"creator":{"login":"admin"}}]`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + t.Run("basic list", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + text := result.Content[0].(mcp.TextContent).Text + var packages []map[string]any + if err := json.Unmarshal([]byte(text), &packages); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0]["name"] != "myrepo/myimage" { + t.Errorf("expected name 'myrepo/myimage', got %v", packages[0]["name"]) + } + if packages[0]["owner"] != "test-org" { + t.Errorf("expected owner 'test-org', got %v", packages[0]["owner"]) + } + }) + + t.Run("with type and query filters", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + "type": "container", + "q": "myimage", + } + + _, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if gotQuery["type"] != "container" { + t.Errorf("expected type=container, got %q", gotQuery["type"]) + } + if gotQuery["q"] != "myimage" { + t.Errorf("expected q=myimage, got %q", gotQuery["q"]) + } + }) + + t.Run("with pagination", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + "page": float64(2), + "per_page": float64(10), + } + + _, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if gotQuery["page"] != "2" { + t.Errorf("expected page=2, got %q", gotQuery["page"]) + } + if gotQuery["limit"] != "10" { + t.Errorf("expected limit=10, got %q", gotQuery["limit"]) + } + }) +} + +func TestPackageReadListVersions(t *testing.T) { + var mu sync.Mutex + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0"},{"id":2,"type":"container","name":"myrepo/myimage","version":"v2.0.0"}]`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + tests := []struct { + testName string + name string + }{ + {"raw slash", "myrepo/myimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage"}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list_versions", + "owner": "test-org", + "type": "container", + "name": tt.name, + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + mu.Lock() + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } + mu.Unlock() + + text := result.Content[0].(mcp.TextContent).Text + var versions []map[string]any + if err := json.Unmarshal([]byte(text), &versions); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(versions)) + } + }) + } +} + +func TestPackageReadGet(t *testing.T) { + var mu sync.Mutex + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"}}`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + tests := []struct { + testName string + name string + }{ + {"raw slash", "myrepo/myimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage"}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "get", + "owner": "test-org", + "type": "container", + "name": tt.name, + "version": "v1.0.0", + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + mu.Lock() + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } + mu.Unlock() + + text := result.Content[0].(mcp.TextContent).Text + var pkg map[string]any + if err := json.Unmarshal([]byte(text), &pkg); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if pkg["name"] != "myrepo/myimage" { + t.Errorf("expected name 'myrepo/myimage', got %v", pkg["name"]) + } + if pkg["version"] != "v1.0.0" { + t.Errorf("expected version 'v1.0.0', got %v", pkg["version"]) + } + }) + } +} + +func TestPackageWriteDelete(t *testing.T) { + var mu sync.Mutex + var gotMethod string + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "delete", + "owner": "test-org", + "type": "container", + "name": "myrepo/myimage", + "version": "v1.0.0", + } + + result, err := packageWriteFn(ctx, req) + if err != nil { + t.Fatalf("packageWriteFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageWriteFn() returned error result") + } + + mu.Lock() + defer mu.Unlock() + if gotMethod != "DELETE" { + t.Errorf("expected DELETE method, got %q", gotMethod) + } + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } +} + +func TestPackageReadUnknownMethod(t *testing.T) { + ctx := context.Background() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "bogus", + "owner": "test-org", + } + if _, err := packageReadFn(ctx, req); err == nil { + t.Fatal("expected error for unknown method") + } +} + +func TestPackageWriteUnknownMethod(t *testing.T) { + ctx := context.Background() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "bogus", + "owner": "test-org", + } + if _, err := packageWriteFn(ctx, req); err == nil { + t.Fatal("expected error for unknown method") + } +} + +func TestEscapePackageName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple name", "mypackage", "mypackage"}, + {"raw slash", "myrepo/myimage", "myrepo%2Fmyimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage", "myrepo%2Fmyimage"}, + {"pre-encoded uppercase", "myrepo%2Fmyimage", "myrepo%2Fmyimage"}, + {"multiple slashes", "a/b/c", "a%2Fb%2Fc"}, + {"pre-encoded multiple slashes", "a%2Fb%2Fc", "a%2Fb%2Fc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := escapePackageName(tt.input) + if got != tt.expected { + t.Errorf("escapePackageName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestSlimPackage(t *testing.T) { + raw := map[string]any{ + "id": float64(1), + "type": "container", + "name": "test-repo/test-image", + "version": "v1.0.0", + "html_url": "http://example.com/pkg", + "created_at": "2025-01-01T00:00:00Z", + "owner": map[string]any{"login": "test-org", "id": float64(2), "email": ""}, + "creator": map[string]any{"login": "admin", "id": float64(1)}, + "repository": map[string]any{"full_name": "test-org/test-repo", "id": float64(3)}, + } + + slim := slimPackage(raw) + if slim["owner"] != "test-org" { + t.Errorf("expected owner 'test-org', got %v", slim["owner"]) + } + if slim["creator"] != "admin" { + t.Errorf("expected creator 'admin', got %v", slim["creator"]) + } + if slim["repository"] != "test-org/test-repo" { + t.Errorf("expected repository 'test-org/test-repo', got %v", slim["repository"]) + } + if _, ok := slim["owner"].(map[string]any); ok { + t.Error("expected owner to be a string, not a map") + } +} diff --git a/operation/packages/slim.go b/operation/packages/slim.go new file mode 100644 index 0000000..98f04b9 --- /dev/null +++ b/operation/packages/slim.go @@ -0,0 +1,43 @@ +package packages + +func slimPackage(v any) map[string]any { + m, ok := v.(map[string]any) + if !ok { + return nil + } + out := map[string]any{ + "id": m["id"], + "type": m["type"], + "name": m["name"], + "version": m["version"], + "html_url": m["html_url"], + "created_at": m["created_at"], + } + if owner, ok := m["owner"].(map[string]any); ok { + out["owner"] = owner["login"] + } + if creator, ok := m["creator"].(map[string]any); ok { + out["creator"] = creator["login"] + } + if repo, ok := m["repository"].(map[string]any); ok { + out["repository"] = repo["full_name"] + } + return out +} + +func slimPackages(v any) any { + switch val := v.(type) { + case []any: + out := make([]map[string]any, 0, len(val)) + for _, item := range val { + if slim := slimPackage(item); slim != nil { + out = append(out, slim) + } + } + return out + case map[string]any: + return slimPackage(val) + default: + return v + } +} diff --git a/operation/pull/pull.go b/operation/pull/pull.go new file mode 100644 index 0000000..36bcea2 --- /dev/null +++ b/operation/pull/pull.go @@ -0,0 +1,997 @@ +package pull + +import ( + "context" + "fmt" + "net/url" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + ListRepoPullRequestsToolName = "list_pull_requests" + PullRequestReadToolName = "pull_request_read" + PullRequestWriteToolName = "pull_request_write" + PullRequestReviewWriteToolName = "pull_request_review_write" +) + +var ( + ListRepoPullRequestsTool = mcp.NewTool( + ListRepoPullRequestsToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")), + mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")), + mcp.WithNumber("milestone"), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + PullRequestReadTool = mcp.NewTool( + PullRequestReadToolName, + mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")), + mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("pull_number", mcp.Required()), + mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")), + mcp.WithBoolean("binary", mcp.Description("include binary diff")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + PullRequestWriteTool = mcp.NewTool( + PullRequestWriteToolName, + mcp.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."), + mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")), + mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")), + mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")), + mcp.WithString("head", mcp.Description("head branch (required for 'create')")), + mcp.WithString("base", mcp.Description("base branch (required for 'create')")), + mcp.WithString("assignee", mcp.Description("for 'update'")), + mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithNumber("milestone", mcp.Description("for 'update'")), + mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")), + mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")), + mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})), + mcp.WithString("deadline", mcp.Description("ISO 8601")), + mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")), + mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")), + mcp.WithString("message", mcp.Description("merge commit message or dismissal reason")), + mcp.WithBoolean("delete_branch", mcp.Description("for 'merge'")), + mcp.WithBoolean("force_merge", mcp.Description("merge even if checks fail")), + mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("for 'merge'")), + mcp.WithString("head_commit_id", mcp.Description("expected head SHA for conflict detection")), + mcp.WithArray("reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("team_reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithBoolean("draft", mcp.Description("uses 'WIP: ' title prefix")), + ) + + PullRequestReviewWriteTool = mcp.NewTool( + PullRequestReviewWriteToolName, + mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."), + mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("pull_number", mcp.Required()), + mcp.WithNumber("review_id", mcp.Description("required except for 'create'")), + mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")), + mcp.WithString("body"), + mcp.WithString("commit_id", mcp.Description("for 'create'")), + mcp.WithString("message", mcp.Description("dismissal reason")), + mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + "body": map[string]any{"type": "string"}, + "old_line_num": map[string]any{"type": "number", "description": "old-file line (deletions)"}, + "new_line_num": map[string]any{"type": "number", "description": "new-file line (additions)"}, + }, + })), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: ListRepoPullRequestsTool, + Handler: listRepoPullRequestsFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: PullRequestReadTool, + Handler: pullRequestReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: PullRequestWriteTool, + Handler: pullRequestWriteFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: PullRequestReviewWriteTool, + Handler: pullRequestReviewWriteFn, + }) +} + +func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "get": + return getPullRequestByIndexFn(ctx, req) + case "get_diff": + return getPullRequestDiffFn(ctx, req) + case "get_files": + return getPullRequestFilesFn(ctx, req) + case "get_status": + return getPullRequestStatusFn(ctx, req) + case "get_reviews": + return listPullRequestReviewsFn(ctx, req) + case "get_review": + return getPullRequestReviewFn(ctx, req) + case "get_review_comments": + return listPullRequestReviewCommentsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createPullRequestFn(ctx, req) + case "update": + return editPullRequestFn(ctx, req) + case "close": + return closePullRequestFn(ctx, req) + case "reopen": + return reopenPullRequestFn(ctx, req) + case "merge": + return mergePullRequestFn(ctx, req) + case "update_branch": + return updatePullRequestBranchFn(ctx, req) + case "add_reviewers": + return createPullRequestReviewerFn(ctx, req) + case "remove_reviewers": + return deletePullRequestReviewerFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + state := gitea_sdk.StateClosed + pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, gitea_sdk.EditPullRequestOption{ + State: &state, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("close %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimPullRequest(pr)) +} + +func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + state := gitea_sdk.StateOpen + pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, gitea_sdk.EditPullRequestOption{ + State: &state, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("reopen %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimPullRequest(pr)) +} + +func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createPullRequestReviewFn(ctx, req) + case "submit": + return submitPullRequestReviewFn(ctx, req) + case "delete": + return deletePullRequestReviewFn(ctx, req) + case "dismiss": + return dismissPullRequestReviewFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + pr, _, err := client.PullRequests.GetPullRequest(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + // /pulls/{n} omits `assets`; PRs are issues internally, so the issue + // assets endpoint surfaces description attachments. + var assets []*gitea_sdk.Attachment + assetsPath := fmt.Sprintf("repos/%s/%s/issues/%d/assets", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", assetsPath, nil, nil, &assets); err != nil { + log.Debugf("fetch %v/%v/issues/%v/assets err: %v", owner, repo, index, err) + } + + m := slimPullRequest(pr) + m["body"] = slim.BodyWithAttachments(pr.Body, assets) + return to.TextResult(m) +} + +func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + binary, _ := args["binary"].(bool) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + diffBytes, _, err := client.PullRequests.GetPullRequestDiff(ctx, owner, repo, index, gitea_sdk.PullRequestDiffOptions{ + Binary: binary, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v diff err: %v", owner, repo, index, err)) + } + + return to.TextResult(string(diffBytes)) +} + +func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + state, _ := args["state"].(string) + sort := params.GetOptionalString(args, "sort", "recentupdate") + milestone := params.GetOptionalInt(args, "milestone", 0) + page, pageSize := params.GetPagination(args, 30) + opt := gitea_sdk.ListPullRequestsOptions{ + State: gitea_sdk.StateType(state), + Sort: sort, + Milestone: milestone, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + pullRequests, _, err := client.PullRequests.ListRepoPullRequests(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/%v/pull_requests err: %v", owner, repo, err)) + } + + return to.TextResult(slimPullRequests(pullRequests)) +} + +// defaultWIPPrefixes are the default Gitea title prefixes that mark a PR as +// work-in-progress / draft. Gitea matches these case-insensitively. +var defaultWIPPrefixes = []string{"WIP:", "[WIP]"} + +// applyDraftPrefix adds or removes a WIP title prefix that Gitea uses to mark +// pull requests as drafts. When the title already carries a recognized prefix +// and isDraft is true, the title is returned unchanged to avoid normalization. +func applyDraftPrefix(title string, isDraft bool) string { + for _, prefix := range defaultWIPPrefixes { + if len(title) >= len(prefix) && strings.EqualFold(title[:len(prefix)], prefix) { + if isDraft { + return title + } + return strings.TrimLeft(title[len(prefix):], " ") + } + } + if isDraft { + return "WIP: " + title + } + return title +} + +func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + body, err := params.GetString(args, "body") + if err != nil { + return to.ErrorResult(err) + } + head, err := params.GetString(args, "head") + if err != nil { + return to.ErrorResult(err) + } + base, err := params.GetString(args, "base") + if err != nil { + return to.ErrorResult(err) + } + + if draft, ok := args["draft"].(bool); ok { + title = applyDraftPrefix(title, draft) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + opt := gitea_sdk.CreatePullRequestOption{ + Title: title, + Body: body, + Head: head, + Base: base, + } + if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil { + opt.Labels = labelIDs + } + opt.Deadline = params.GetOptionalTime(args, "deadline") + pr, _, err := client.PullRequests.CreatePullRequest(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err)) + } + + return to.TextResult(slimPullRequest(pr)) +} + +type reviewerOp func(client *gitea_sdk.PullRequestsService, ctx context.Context, owner, repo string, index int64, opt gitea_sdk.PullReviewRequestOptions) (*gitea_sdk.Response, error) + +func pullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest, verb string, op reviewerOp) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + reviewers := params.GetStringSlice(args, "reviewers") + teamReviewers := params.GetStringSlice(args, "team_reviewers") + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + if _, err := op(client.PullRequests, ctx, owner, repo, index, gitea_sdk.PullReviewRequestOptions{ + Reviewers: reviewers, + TeamReviewers: teamReviewers, + }); err != nil { + return to.ErrorResult(fmt.Errorf("%s review requests for %v/%v/pr/%v err: %v", verb, owner, repo, index, err)) + } + + return to.TextResult(map[string]any{ + "message": fmt.Sprintf("Successfully %sd review requests", verb), + "reviewers": reviewers, + "team_reviewers": teamReviewers, + "pr_index": index, + "repository": fmt.Sprintf("%s/%s", owner, repo), + }) +} + +func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return pullRequestReviewerFn(ctx, req, "create", (*gitea_sdk.PullRequestsService).CreateReviewRequests) +} + +func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return pullRequestReviewerFn(ctx, req, "delete", (*gitea_sdk.PullRequestsService).DeleteReviewRequests) +} + +func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + reviews, _, err := client.PullRequests.ListPullReviews(ctx, owner, repo, index, gitea_sdk.ListPullReviewsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimReviews(reviews)) +} + +func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + reviewID, err := params.GetIndex(args, "review_id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + review, _, err := client.PullRequests.GetPullReview(ctx, owner, repo, index, reviewID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + + return to.TextResult(slimReview(review)) +} + +func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + reviewID, err := params.GetIndex(args, "review_id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + comments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID) + if err != nil { + return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + + return to.TextResult(slimReviewComments(comments)) +} + +func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + opt := gitea_sdk.CreatePullReviewOptions{} + + if state, ok := args["state"].(string); ok { + opt.State = gitea_sdk.ReviewStateType(state) + } + if body, ok := args["body"].(string); ok { + opt.Body = body + } + if commitID, ok := args["commit_id"].(string); ok { + opt.CommitID = commitID + } + + // Parse inline comments + if commentsArg, exists := args["comments"]; exists { + if commentsSlice, ok := commentsArg.([]any); ok { + for _, comment := range commentsSlice { + if commentMap, ok := comment.(map[string]any); ok { + reviewComment := gitea_sdk.CreatePullReviewComment{} + if path, ok := commentMap["path"].(string); ok { + reviewComment.Path = path + } + if body, ok := commentMap["body"].(string); ok { + reviewComment.Body = body + } + if oldLineNum, ok := params.ToInt64(commentMap["old_line_num"]); ok { + reviewComment.OldLineNum = oldLineNum + } + if newLineNum, ok := params.ToInt64(commentMap["new_line_num"]); ok { + reviewComment.NewLineNum = newLineNum + } + opt.Comments = append(opt.Comments, reviewComment) + } + } + } + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + review, _, err := client.PullRequests.CreatePullReview(ctx, owner, repo, index, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimReview(review)) +} + +func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + reviewID, err := params.GetIndex(args, "review_id") + if err != nil { + return to.ErrorResult(err) + } + state, err := params.GetString(args, "state") + if err != nil { + return to.ErrorResult(err) + } + + opt := gitea_sdk.SubmitPullReviewOptions{ + State: gitea_sdk.ReviewStateType(state), + } + if body, ok := args["body"].(string); ok { + opt.Body = body + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + review, _, err := client.PullRequests.SubmitPullReview(ctx, owner, repo, index, reviewID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + + return to.TextResult(slimReview(review)) +} + +func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + reviewID, err := params.GetIndex(args, "review_id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + _, err = client.PullRequests.DeletePullReview(ctx, owner, repo, index, reviewID) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + + successMsg := map[string]any{ + "message": "Successfully deleted review", + "review_id": reviewID, + "pr_index": index, + "repository": fmt.Sprintf("%s/%s", owner, repo), + } + + return to.TextResult(successMsg) +} + +func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + reviewID, err := params.GetIndex(args, "review_id") + if err != nil { + return to.ErrorResult(err) + } + + opt := gitea_sdk.DismissPullReviewOptions{} + if message, ok := args["message"].(string); ok { + opt.Message = message + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + _, err = client.PullRequests.DismissPullReview(ctx, owner, repo, index, reviewID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + + successMsg := map[string]any{ + "message": "Successfully dismissed review", + "review_id": reviewID, + "pr_index": index, + "repository": fmt.Sprintf("%s/%s", owner, repo), + } + + return to.TextResult(successMsg) +} + +func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + mergeStyle := params.GetOptionalString(args, "merge_style", "merge") + title, _ := args["title"].(string) + message, _ := args["message"].(string) + deleteBranch, _ := args["delete_branch"].(bool) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + forceMerge, _ := args["force_merge"].(bool) + mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool) + headCommitID, _ := args["head_commit_id"].(string) + deleteBranchAfterMerge := &deleteBranch + + opt := gitea_sdk.MergePullRequestOption{ + Style: gitea_sdk.MergeStyle(mergeStyle), + Title: title, + Message: message, + DeleteBranchAfterMerge: deleteBranchAfterMerge, + ForceMerge: forceMerge, + MergeWhenChecksSucceed: mergeWhenChecksSucceed, + HeadCommitId: headCommitID, + } + + merged, resp, err := client.PullRequests.MergePullRequest(ctx, owner, repo, index, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + if !merged && resp != nil && resp.StatusCode >= 400 { + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, index, resp.StatusCode, resp.Status)) + } + + if !merged { + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, index)) + } + + successMsg := map[string]any{ + "merged": merged, + "pr_index": index, + "repository": fmt.Sprintf("%s/%s", owner, repo), + "merge_style": mergeStyle, + "branch_deleted": deleteBranch, + } + + return to.TextResult(successMsg) +} + +func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + opt := gitea_sdk.EditPullRequestOption{} + + if title, ok := args["title"].(string); ok { + opt.Title = title + } + if draft, ok := args["draft"].(bool); ok { + if opt.Title == "" { + // Fetch current title so the caller doesn't have to provide it + // just to toggle draft status. + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + pr, _, err := client.PullRequests.GetPullRequest(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + opt.Title = pr.Title + } + opt.Title = applyDraftPrefix(opt.Title, draft) + } + opt.Body = params.GetPresentStringPtr(args, "body") + opt.AllowMaintainerEdit = params.GetOptionalBoolPtr(args, "allow_maintainer_edit") + opt.RemoveDeadline = params.GetOptionalBoolPtr(args, "remove_deadline") + opt.Deadline = params.GetOptionalTime(args, "deadline") + if base, ok := args["base"].(string); ok { + opt.Base = base + } + if assignee, ok := args["assignee"].(string); ok { + opt.Assignee = assignee + } + if assignees := params.GetStringSlice(args, "assignees"); assignees != nil { + opt.Assignees = assignees + } + if val, exists := args["milestone"]; exists { + if milestone, ok := params.ToInt64(val); ok { + opt.Milestone = milestone + } + } + if state, ok := args["state"].(string); ok { + s := gitea_sdk.StateType(state) + opt.State = &s + } + if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil { + opt.Labels = labelIDs + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + + return to.TextResult(slimPullRequest(pr)) +} + +func updatePullRequestBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + + path := fmt.Sprintf("repos/%s/%s/pulls/%d/update", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "POST", path, nil, nil, nil); err != nil { + return to.ErrorResult(fmt.Errorf("update %v/%v/pr/%v branch err: %v", owner, repo, index, err)) + } + return to.TextResult(map[string]any{"message": "branch updated from base"}) +} + +func getPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + files, _, err := client.PullRequests.ListPullRequestFiles(ctx, owner, repo, index, gitea_sdk.ListPullRequestFilesOptions{ + ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v files err: %v", owner, repo, index, err)) + } + return to.TextResult(files) +} + +func getPullRequestStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + pr, _, err := client.PullRequests.GetPullRequest(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + if pr.Head == nil || pr.Head.Sha == "" { + return to.ErrorResult(fmt.Errorf("pr %v/%v/%v has no head SHA", owner, repo, index)) + } + + status, _, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, pr.Head.Sha) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v status err: %v", owner, repo, index, err)) + } + return to.TextResult(status) +} diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go new file mode 100644 index 0000000..954ef1f --- /dev/null +++ b/operation/pull/pull_test.go @@ -0,0 +1,1044 @@ +package pull + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func Test_editPullRequestFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + indexInputs := []struct { + name string + val any + }{ + {"float64", float64(index)}, + {"string", "7"}, + } + + for _, ii := range indexInputs { + t.Run(ii.name, func(t *testing.T) { + var ( + mu sync.Mutex + gotMethod string + gotPath string + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.Path + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"%s","state":"open"}`, index, body["title"])) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": ii.val, + "title": "WIP: my feature", + "state": "open", + }, + }, + } + + result, err := editPullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("editPullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotMethod != http.MethodPatch { + t.Fatalf("expected PATCH request, got %s", gotMethod) + } + if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index) { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["title"] != "WIP: my feature" { + t.Fatalf("expected title 'WIP: my feature', got %v", gotBody["title"]) + } + if gotBody["state"] != "open" { + t.Fatalf("expected state 'open', got %v", gotBody["state"]) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if got := parsed["title"].(string); got != "WIP: my feature" { + t.Fatalf("result title = %q, want %q", got, "WIP: my feature") + } + }) + } +} + +func Test_mergePullRequestFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 5 + ) + + indexInputs := []struct { + name string + val any + }{ + {"float64", float64(index)}, + {"string", "5"}, + } + + for _, ii := range indexInputs { + t.Run(ii.name, func(t *testing.T) { + var ( + mu sync.Mutex + gotMethod string + gotPath string + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"head":{"sha":"abc123"}}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index): + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.Path + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": ii.val, + "merge_style": "squash", + "title": "feat: my squashed commit", + "message": "Squash merge of PR #5", + "delete_branch": true, + }, + }, + } + + result, err := mergePullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("mergePullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotMethod != http.MethodPost { + t.Fatalf("expected POST request, got %s", gotMethod) + } + if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["Do"] != "squash" { + t.Fatalf("expected Do 'squash', got %v", gotBody["Do"]) + } + if gotBody["MergeTitleField"] != "feat: my squashed commit" { + t.Fatalf("expected MergeTitleField 'feat: my squashed commit', got %v", gotBody["MergeTitleField"]) + } + if gotBody["MergeMessageField"] != "Squash merge of PR #5" { + t.Fatalf("expected MergeMessageField 'Squash merge of PR #5', got %v", gotBody["MergeMessageField"]) + } + if gotBody["delete_branch_after_merge"] != true { + t.Fatalf("expected delete_branch_after_merge true, got %v", gotBody["delete_branch_after_merge"]) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if parsed["merged"] != true { + t.Fatalf("expected merged=true, got %v", parsed["merged"]) + } + if parsed["merge_style"] != "squash" { + t.Fatalf("expected merge_style 'squash', got %v", parsed["merge_style"]) + } + if parsed["branch_deleted"] != true { + t.Fatalf("expected branch_deleted=true, got %v", parsed["branch_deleted"]) + } + }) + } +} + +func Test_mergePullRequestFn_newParams(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 8 + ) + + var ( + mu sync.Mutex + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index): + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": float64(index), + "merge_style": "merge", + "force_merge": true, + "merge_when_checks_succeed": true, + "head_commit_id": "abc123", + }, + }, + } + + _, err := mergePullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("mergePullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotBody["force_merge"] != true { + t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"]) + } + if gotBody["merge_when_checks_succeed"] != true { + t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"]) + } + if gotBody["head_commit_id"] != "abc123" { + t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"]) + } +} + +func Test_createPullRequestFn_labels(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + var ( + mu sync.Mutex + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo): + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "title": "test", + "body": "body", + "head": "feature", + "base": "main", + "labels": []any{float64(1), float64(2)}, + "deadline": "2026-06-01T00:00:00Z", + }, + }, + } + + _, err := createPullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("createPullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + labels, ok := gotBody["labels"].([]any) + if !ok || len(labels) != 2 { + t.Fatalf("expected 2 labels, got %v", gotBody["labels"]) + } + if labels[0] != float64(1) || labels[1] != float64(2) { + t.Fatalf("expected labels [1,2], got %v", labels) + } + if gotBody["due_date"] == nil { + t.Fatalf("expected due_date to be set") + } +} + +func Test_applyDraftPrefix(t *testing.T) { + tests := []struct { + name string + title string + isDraft bool + want string + }{ + {"add prefix", "my feature", true, "WIP: my feature"}, + {"already prefixed WIP:", "WIP: my feature", true, "WIP: my feature"}, + {"already prefixed WIP: no space", "WIP:my feature", true, "WIP:my feature"}, + {"already prefixed [WIP]", "[WIP] my feature", true, "[WIP] my feature"}, + {"already prefixed case insensitive", "wip: my feature", true, "wip: my feature"}, + {"already prefixed [wip]", "[wip] my feature", true, "[wip] my feature"}, + {"remove WIP: prefix", "WIP: my feature", false, "my feature"}, + {"remove WIP: no space", "WIP:my feature", false, "my feature"}, + {"remove [WIP] prefix", "[WIP] my feature", false, "my feature"}, + {"remove [wip] prefix", "[wip] my feature", false, "my feature"}, + {"remove wip: lowercase", "wip: my feature", false, "my feature"}, + {"no prefix not draft", "my feature", false, "my feature"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyDraftPrefix(tt.title, tt.isDraft) + if got != tt.want { + t.Fatalf("applyDraftPrefix(%q, %v) = %q, want %q", tt.title, tt.isDraft, got, tt.want) + } + }) + } +} + +func Test_createPullRequestFn_draft(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + tests := []struct { + name string + title string + draft any // bool or nil (omitted) + wantTitle string + }{ + {"draft true", "my feature", true, "WIP: my feature"}, + {"draft false strips WIP:", "WIP: my feature", false, "my feature"}, + {"draft false strips [WIP]", "[WIP] my feature", false, "my feature"}, + {"draft omitted preserves title", "WIP: my feature", nil, "WIP: my feature"}, + {"draft true already prefixed", "WIP: my feature", true, "WIP: my feature"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var ( + mu sync.Mutex + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo): + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fmt.Appendf(nil, `{"number":1,"title":%q,"state":"open"}`, body["title"])) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + args := map[string]any{ + "owner": owner, + "repo": repo, + "title": tc.title, + "body": "test body", + "head": "feature", + "base": "main", + } + if tc.draft != nil { + args["draft"] = tc.draft + } + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: args, + }, + } + + _, err := createPullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("createPullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotBody["title"] != tc.wantTitle { + t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"]) + } + }) + } +} + +func Test_editPullRequestFn_draft(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + tests := []struct { + name string + title string // title arg passed to the tool; empty means omitted + draft any + wantTitle string + }{ + {"set draft with title", "my feature", true, "WIP: my feature"}, + {"unset draft with title", "WIP: my feature", false, "my feature"}, + {"set draft without title fetches current", "", true, "WIP: existing title"}, + {"unset draft without title fetches current", "", false, "existing title"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var ( + mu sync.Mutex + gotBody map[string]any + ) + + prPath := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case prPath: + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + // Auto-fetch: return the existing PR with its current title + _, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"existing title","state":"open"}`, index)) + return + } + mu.Lock() + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + title := "existing title" + if s, ok := body["title"].(string); ok { + title = s + } + _, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":%q,"state":"open"}`, index, title)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + args := map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": float64(index), + } + if tc.title != "" { + args["title"] = tc.title + } + if tc.draft != nil { + args["draft"] = tc.draft + } + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: args, + }, + } + + _, err := editPullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("editPullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotBody["title"] != tc.wantTitle { + t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"]) + } + }) + } +} + +func Test_getPullRequestDiffFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 12 + diffRaw = "diff --git a/file.txt b/file.txt\n+line\n" + ) + + indexInputs := []struct { + name string + val any + }{ + {"float64", float64(index)}, + {"string", "12"}, + } + + for _, ii := range indexInputs { + t.Run(ii.name, func(t *testing.T) { + var ( + mu sync.Mutex + diffRequested bool + binaryValue string + ) + errCh := make(chan error, 1) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/%s/%s/pulls/%d.diff", owner, repo, index): + if r.Method != http.MethodGet { + select { + case errCh <- fmt.Errorf("unexpected method: %s", r.Method): + default: + } + } + mu.Lock() + diffRequested = true + binaryValue = r.URL.Query().Get("binary") + mu.Unlock() + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(diffRaw)) + default: + select { + case errCh <- fmt.Errorf("unexpected request path: %s", r.URL.Path): + default: + } + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": ii.val, + "binary": true, + }, + }, + } + + result, err := getPullRequestDiffFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestDiffFn() error = %v", err) + } + + select { + case reqErr := <-errCh: + t.Fatalf("handler error: %v", reqErr) + default: + } + + mu.Lock() + requested := diffRequested + gotBinary := binaryValue + mu.Unlock() + + if !requested { + t.Fatalf("expected diff request to be made") + } + if gotBinary != "true" { + t.Fatalf("expected binary=true query param, got %q", gotBinary) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + // The diff response is now a plain string + var parsed string + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if parsed != diffRaw { + t.Fatalf("diff = %q, want %q", parsed, diffRaw) + } + }) + } +} + +func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"see screenshot","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"name":"shot.png","browser_download_url":"https://example/shot.png"}]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "pull_number": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `[shot.png](https://example/shot.png)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } +} + +func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "pull_number": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() error = %v", err) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `"body":"plain body"`) { + t.Fatalf("expected body unchanged when assets are empty, got: %s", body) + } +} + +func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + http.Error(w, "boom", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "pull_number": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() error = %v", err) + } + if res.IsError { + t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `"plain body"`) { + t.Fatalf("expected PR body preserved when assets fail, got: %s", body) + } +} + +func Test_closePullRequestFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + var gotBody map[string]any + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH method, got %s", r.Method) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"closed","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + origHost := flag.Host + origToken := flag.Token + flag.Host = server.URL + flag.Token = "test-token" + t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken }) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "method": "close", + "owner": owner, + "repo": repo, + "pull_number": float64(index), + }, + }, + } + + result, err := closePullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("closePullRequestFn() error = %v", err) + } + + if gotBody["state"] != "closed" { + t.Errorf("expected state=closed, got %v", gotBody["state"]) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } +} + +func Test_reopenPullRequestFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + var gotBody map[string]any + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH method, got %s", r.Method) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"open","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + origHost := flag.Host + origToken := flag.Token + flag.Host = server.URL + flag.Token = "test-token" + t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken }) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "method": "reopen", + "owner": owner, + "repo": repo, + "pull_number": float64(index), + }, + }, + } + + result, err := reopenPullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("reopenPullRequestFn() error = %v", err) + } + + if gotBody["state"] != "open" { + t.Errorf("expected state=open, got %v", gotBody["state"]) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } +} diff --git a/operation/pull/slim.go b/operation/pull/slim.go new file mode 100644 index 0000000..f74257a --- /dev/null +++ b/operation/pull/slim.go @@ -0,0 +1,160 @@ +package pull + +import ( + "gitea.com/gitea/gitea-mcp/pkg/slim" + + gitea_sdk "gitea.dev/sdk" +) + +func repoRef(r *gitea_sdk.Repository) map[string]any { + if r == nil { + return nil + } + return map[string]any{ + "full_name": r.FullName, + "description": r.Description, + } +} + +func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any { + if pr == nil { + return nil + } + m := map[string]any{ + "number": pr.Index, + "title": pr.Title, + "body": pr.Body, + "state": pr.State, + "draft": pr.Draft, + "merged": pr.HasMerged, + "mergeable": pr.Mergeable, + "html_url": pr.HTMLURL, + "user": slim.UserLogin(pr.Poster), + "labels": slim.LabelNames(pr.Labels), + "comments": pr.Comments, + "created_at": pr.Created, + "updated_at": pr.Updated, + "closed_at": pr.Closed, + } + if pr.HasMerged { + m["merged_at"] = pr.Merged + m["merge_commit_sha"] = pr.MergedCommitID + m["merged_by"] = slim.UserLogin(pr.MergedBy) + } + if pr.Head != nil { + head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha} + if pr.Head.Repository != nil { + head["repo"] = repoRef(pr.Head.Repository) + } + m["head"] = head + } + if pr.Base != nil { + base := map[string]any{"ref": pr.Base.Ref, "sha": pr.Base.Sha} + if pr.Base.Repository != nil { + base["repo"] = repoRef(pr.Base.Repository) + } + m["base"] = base + } + if pr.Additions != nil { + m["additions"] = *pr.Additions + } + if pr.Deletions != nil { + m["deletions"] = *pr.Deletions + } + if pr.ChangedFiles != nil { + m["changed_files"] = *pr.ChangedFiles + } + if len(pr.Assignees) > 0 { + m["assignees"] = slim.UserLogins(pr.Assignees) + } + if pr.Milestone != nil { + m["milestone"] = pr.Milestone.Title + } + if pr.ReviewComments > 0 { + m["review_comments"] = pr.ReviewComments + } + return m +} + +func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any { + out := make([]map[string]any, 0, len(prs)) + for _, pr := range prs { + if pr == nil { + continue + } + m := map[string]any{ + "number": pr.Index, + "title": pr.Title, + "state": pr.State, + "draft": pr.Draft, + "merged": pr.HasMerged, + "html_url": pr.HTMLURL, + "user": slim.UserLogin(pr.Poster), + "created_at": pr.Created, + "updated_at": pr.Updated, + } + if pr.Head != nil { + m["head"] = pr.Head.Ref + } + if pr.Base != nil { + m["base"] = pr.Base.Ref + } + if len(pr.Labels) > 0 { + m["labels"] = slim.LabelNames(pr.Labels) + } + out = append(out, m) + } + return out +} + +func slimReview(r *gitea_sdk.PullReview) map[string]any { + if r == nil { + return nil + } + return map[string]any{ + "id": r.ID, + "state": r.State, + "body": r.Body, + "user": slim.UserLogin(r.Reviewer), + "comments_count": r.CodeCommentsCount, + "submitted_at": r.Submitted, + "html_url": r.HTMLURL, + "stale": r.Stale, + "official": r.Official, + "dismissed": r.Dismissed, + } +} + +func slimReviews(reviews []*gitea_sdk.PullReview) []map[string]any { + out := make([]map[string]any, 0, len(reviews)) + for _, r := range reviews { + out = append(out, slimReview(r)) + } + return out +} + +func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any { + if c == nil { + return nil + } + return map[string]any{ + "id": c.ID, + "body": c.Body, + "path": c.Path, + "position": c.LineNum, + "old_position": c.OldLineNum, + "diff_hunk": c.DiffHunk, + "user": slim.UserLogin(c.Reviewer), + "html_url": c.HTMLURL, + "created_at": c.Created, + "updated_at": c.Updated, + } +} + +func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any { + out := make([]map[string]any, 0, len(comments)) + for _, c := range comments { + out = append(out, slimReviewComment(c)) + } + return out +} diff --git a/operation/pull/slim_test.go b/operation/pull/slim_test.go new file mode 100644 index 0000000..bcd43f8 --- /dev/null +++ b/operation/pull/slim_test.go @@ -0,0 +1,124 @@ +package pull + +import ( + "testing" + "time" + + gitea_sdk "gitea.dev/sdk" +) + +func TestSlimPullRequest(t *testing.T) { + now := time.Now() + additions := 10 + deletions := 5 + changedFiles := 3 + pr := &gitea_sdk.PullRequest{ + Index: 1, + Title: "Fix bug", + Body: "Fixes #123", + State: "open", + Draft: false, + HasMerged: false, + Mergeable: true, + HTMLURL: "https://gitea.com/org/repo/pulls/1", + Poster: &gitea_sdk.User{UserName: "bob"}, + Labels: []*gitea_sdk.Label{ + {Name: "bug"}, + {Name: "priority"}, + }, + Comments: 2, + Created: &now, + Updated: &now, + Additions: &additions, + Deletions: &deletions, + ChangedFiles: &changedFiles, + Head: &gitea_sdk.PRBranchInfo{ + Ref: "fix-branch", + Sha: "abc123", + }, + Base: &gitea_sdk.PRBranchInfo{ + Ref: "main", + Sha: "def456", + }, + Assignees: []*gitea_sdk.User{ + {UserName: "alice"}, + }, + Milestone: &gitea_sdk.Milestone{Title: "v1.0"}, + } + + m := slimPullRequest(pr) + + if m["number"] != int64(1) { + t.Errorf("expected number 1, got %v", m["number"]) + } + if m["title"] != "Fix bug" { + t.Errorf("expected title Fix bug, got %v", m["title"]) + } + if m["user"] != "bob" { + t.Errorf("expected user bob, got %v", m["user"]) + } + if m["additions"] != 10 { + t.Errorf("expected additions 10, got %v", m["additions"]) + } + if m["milestone"] != "v1.0" { + t.Errorf("expected milestone v1.0, got %v", m["milestone"]) + } + + labels := m["labels"].([]string) + if len(labels) != 2 || labels[0] != "bug" { + t.Errorf("expected labels [bug priority], got %v", labels) + } + + head := m["head"].(map[string]any) + if head["ref"] != "fix-branch" { + t.Errorf("expected head ref fix-branch, got %v", head["ref"]) + } + + assignees := m["assignees"].([]string) + if len(assignees) != 1 || assignees[0] != "alice" { + t.Errorf("expected assignees [alice], got %v", assignees) + } + + // merged fields should not be present for unmerged PR + if _, ok := m["merged_at"]; ok { + t.Error("merged_at should not be present for unmerged PR") + } +} + +func TestSlimPullRequests_ListIsSlimmer(t *testing.T) { + pr := &gitea_sdk.PullRequest{ + Index: 1, + Title: "PR title", + State: "open", + HTMLURL: "https://gitea.com/org/repo/pulls/1", + Poster: &gitea_sdk.User{UserName: "bob"}, + Body: "Full body text here", + Head: &gitea_sdk.PRBranchInfo{Ref: "feature"}, + Base: &gitea_sdk.PRBranchInfo{Ref: "main"}, + } + + single := slimPullRequest(pr) + list := slimPullRequests([]*gitea_sdk.PullRequest{pr}) + + // Single has body, list does not + if _, ok := single["body"]; !ok { + t.Error("single PR should have body") + } + if _, ok := list[0]["body"]; ok { + t.Error("list PR should not have body") + } + + // List has head as string ref, single has head as map + if _, ok := single["head"].(map[string]any); !ok { + t.Error("single PR head should be a map") + } + if list[0]["head"] != "feature" { + t.Errorf("list PR head should be string ref, got %v", list[0]["head"]) + } +} + +func TestSlimPullRequests_Nil(t *testing.T) { + if r := slimPullRequests(nil); len(r) != 0 { + t.Errorf("expected empty slice, got %v", r) + } +} diff --git a/operation/repo/branch.go b/operation/repo/branch.go new file mode 100644 index 0000000..b4e71ae --- /dev/null +++ b/operation/repo/branch.go @@ -0,0 +1,150 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + CreateBranchToolName = "create_branch" + DeleteBranchToolName = "delete_branch" + ListBranchesToolName = "list_branches" +) + +var ( + CreateBranchTool = mcp.NewTool( + CreateBranchToolName, + mcp.WithToolAnnotation(annotation.Write("Create a new branch")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("branch", mcp.Required()), + mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")), + ) + + DeleteBranchTool = mcp.NewTool( + DeleteBranchToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("branch", mcp.Required()), + ) + + ListBranchesTool = mcp.NewTool( + ListBranchesToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) +) + +func init() { + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateBranchTool, + Handler: CreateBranchFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeleteBranchTool, + Handler: DeleteBranchFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListBranchesTool, + Handler: ListBranchesFn, + }) +} + +func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + branch, err := params.GetString(args, "branch") + if err != nil { + return to.ErrorResult(err) + } + oldBranch, _ := args["old_branch"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, _, err = client.Repositories.CreateBranch(ctx, owner, repo, gitea_sdk.CreateBranchOption{ + BranchName: branch, + OldBranchName: oldBranch, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create branch error: %v", err)) + } + + return to.TextResult("Branch Created") +} + +func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + branch, err := params.GetString(args, "branch") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, _, err = client.Repositories.DeleteRepoBranch(ctx, owner, repo, branch) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete branch error: %v", err)) + } + + return to.TextResult("Branch Deleted") +} + +func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + opt := gitea_sdk.ListRepoBranchesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + branches, _, err := client.Repositories.ListRepoBranches(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list branches error: %v", err)) + } + + return to.TextResult(slimBranches(branches)) +} diff --git a/operation/repo/commit.go b/operation/repo/commit.go new file mode 100644 index 0000000..dc619e6 --- /dev/null +++ b/operation/repo/commit.go @@ -0,0 +1,109 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ListRepoCommitsToolName = "list_commits" + GetCommitToolName = "get_commit" +) + +var ( + ListRepoCommitsTool = mcp.NewTool( + ListRepoCommitsToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List repository commits")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("sha", mcp.Description("starting SHA or branch")), + mcp.WithString("path", mcp.Description("only commits touching this path")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + GetCommitTool = mcp.NewTool( + GetCommitToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Get commit details")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("sha", mcp.Required()), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: ListRepoCommitsTool, + Handler: ListRepoCommitsFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetCommitTool, + Handler: GetCommitFn, + }) +} + +func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + sha, _ := args["sha"].(string) + path, _ := args["path"].(string) + opt := gitea_sdk.ListCommitOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + SHA: sha, + Path: path, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + commits, _, err := client.Repositories.ListRepoCommits(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err)) + } + return to.TextResult(slimCommits(commits)) +} + +func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + sha, err := params.GetString(args, "sha") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + commit, _, err := client.Repositories.GetSingleCommit(ctx, owner, repo, sha) + if err != nil { + return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err)) + } + return to.TextResult(slimCommit(commit)) +} diff --git a/operation/repo/file.go b/operation/repo/file.go new file mode 100644 index 0000000..81e0018 --- /dev/null +++ b/operation/repo/file.go @@ -0,0 +1,283 @@ +package repo + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetFileToolName = "get_file_contents" + GetDirToolName = "get_dir_contents" + CreateOrUpdateFileToolName = "create_or_update_file" + DeleteFileToolName = "delete_file" +) + +var ( + GetFileContentTool = mcp.NewTool( + GetFileToolName, + mcp.WithDescription("Get file content and metadata"), + mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")), + mcp.WithString("path", mcp.Required()), + mcp.WithBoolean("withLines", mcp.Description("return numbered lines")), + ) + + GetDirContentTool = mcp.NewTool( + GetDirToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")), + mcp.WithString("path", mcp.Required()), + ) + + CreateOrUpdateFileTool = mcp.NewTool( + CreateOrUpdateFileToolName, + mcp.WithDescription("Create or update a file (provide sha to update an existing file)."), + mcp.WithToolAnnotation(annotation.Write("Create or update a file")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("path", mcp.Required()), + mcp.WithString("content", mcp.Required()), + mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), + mcp.WithString("branch_name", mcp.Required()), + mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")), + mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")), + ) + + DeleteFileTool = mcp.NewTool( + DeleteFileToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a file")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("path", mcp.Required()), + mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), + mcp.WithString("branch_name", mcp.Required()), + mcp.WithString("sha", mcp.Required()), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetFileContentTool, + Handler: GetFileContentFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetDirContentTool, + Handler: GetDirContentFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateOrUpdateFileTool, + Handler: CreateOrUpdateFileFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeleteFileTool, + Handler: DeleteFileFn, + }) +} + +type ContentLine struct { + LineNumber int `json:"line"` + Content string `json:"content"` +} + +func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + ref, _ := args["ref"].(string) + filePath, err := params.GetString(args, "path") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + content, _, err := client.Repositories.GetContents(ctx, owner, repo, ref, filePath) + if err != nil { + return to.ErrorResult(fmt.Errorf("get file err: %v", err)) + } + withLines, _ := args["withLines"].(bool) + if withLines { + rawContent, err := base64.StdEncoding.DecodeString(*content.Content) + if err != nil { + return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err)) + } + + contentLines := make([]ContentLine, 0) + line := 0 + + scanner := bufio.NewScanner(bytes.NewReader(rawContent)) + + for scanner.Scan() { + line++ + + contentLines = append(contentLines, ContentLine{ + LineNumber: line, + Content: scanner.Text(), + }) + } + if err := scanner.Err(); err != nil { + return to.ErrorResult(fmt.Errorf("scan content err: %v", err)) + } + + // remove the last blank line if exists + // git does not consider the last line as a new line + if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" { + contentLines = contentLines[:len(contentLines)-1] + } + + contentBytes, err := json.MarshalIndent(contentLines, "", " ") + if err != nil { + return to.ErrorResult(fmt.Errorf("marshal content lines err: %v", err)) + } + contentStr := string(contentBytes) + content.Content = &contentStr + } + return to.TextResult(slimContents(content)) +} + +func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + ref, _ := args["ref"].(string) + filePath, err := params.GetString(args, "path") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + content, _, err := client.Repositories.ListContents(ctx, owner, repo, ref, filePath) + if err != nil { + return to.ErrorResult(fmt.Errorf("get dir content err: %v", err)) + } + return to.TextResult(slimDirEntries(content)) +} + +func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + filePath, err := params.GetString(args, "path") + if err != nil { + return to.ErrorResult(err) + } + content, _ := args["content"].(string) + message, _ := args["message"].(string) + branchName, _ := args["branch_name"].(string) + sha, _ := args["sha"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + if sha != "" { + // Update existing file + opt := gitea_sdk.UpdateFileOptions{ + SHA: sha, + Content: base64.StdEncoding.EncodeToString([]byte(content)), + FileOptions: gitea_sdk.FileOptions{ + Message: message, + BranchName: branchName, + }, + } + _, _, err = client.Repositories.UpdateFile(ctx, owner, repo, filePath, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("update file err: %v", err)) + } + return to.TextResult("Update file success") + } + + // Create new file + opt := gitea_sdk.CreateFileOptions{ + Content: base64.StdEncoding.EncodeToString([]byte(content)), + FileOptions: gitea_sdk.FileOptions{ + Message: message, + BranchName: branchName, + }, + } + if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" { + opt.NewBranchName = newBranch + } + _, _, err = client.Repositories.CreateFile(ctx, owner, repo, filePath, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create file err: %v", err)) + } + return to.TextResult("Create file success") +} + +func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + filePath, err := params.GetString(args, "path") + if err != nil { + return to.ErrorResult(err) + } + message, _ := args["message"].(string) + branchName, _ := args["branch_name"].(string) + sha, err := params.GetString(args, "sha") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.DeleteFileOptions{ + FileOptions: gitea_sdk.FileOptions{ + Message: message, + BranchName: branchName, + }, + SHA: sha, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Repositories.DeleteFile(ctx, owner, repo, filePath, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete file err: %v", err)) + } + return to.TextResult("Delete file success") +} diff --git a/operation/repo/release.go b/operation/repo/release.go new file mode 100644 index 0000000..3d6f1e5 --- /dev/null +++ b/operation/repo/release.go @@ -0,0 +1,249 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + CreateReleaseToolName = "create_release" + DeleteReleaseToolName = "delete_release" + GetReleaseToolName = "get_release" + GetLatestReleaseToolName = "get_latest_release" + ListReleasesToolName = "list_releases" +) + +var ( + CreateReleaseTool = mcp.NewTool( + CreateReleaseToolName, + mcp.WithToolAnnotation(annotation.Write("Create a release")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("tag_name", mcp.Required()), + mcp.WithString("target", mcp.Required(), mcp.Description("commitish")), + mcp.WithString("title", mcp.Required()), + mcp.WithBoolean("is_draft"), + mcp.WithBoolean("is_pre_release"), + mcp.WithString("body"), + ) + + DeleteReleaseTool = mcp.NewTool( + DeleteReleaseToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a release")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("id", mcp.Required()), + ) + + GetReleaseTool = mcp.NewTool( + GetReleaseToolName, + mcp.WithDescription("Get a release by ID"), + mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("id", mcp.Required()), + ) + + GetLatestReleaseTool = mcp.NewTool( + GetLatestReleaseToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + ) + + ListReleasesTool = mcp.NewTool( + ListReleasesToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List releases")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithBoolean("is_draft"), + mcp.WithBoolean("is_pre_release"), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)), + ) +) + +func init() { + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateReleaseTool, + Handler: CreateReleaseFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeleteReleaseTool, + Handler: DeleteReleaseFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetReleaseTool, + Handler: GetReleaseFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetLatestReleaseTool, + Handler: GetLatestReleaseFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListReleasesTool, + Handler: ListReleasesFn, + }) +} + +func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + tagName, err := params.GetString(args, "tag_name") + if err != nil { + return to.ErrorResult(err) + } + target, err := params.GetString(args, "target") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + isDraft, _ := args["is_draft"].(bool) + isPreRelease, _ := args["is_pre_release"].(bool) + body, _ := args["body"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, _, err = client.Releases.CreateRelease(ctx, owner, repo, gitea_sdk.CreateReleaseOption{ + TagName: tagName, + Target: target, + Title: title, + Note: body, + IsDraft: isDraft, + IsPrerelease: isPreRelease, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create release error: %v", err)) + } + + return to.TextResult("Release Created") +} + +func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Releases.DeleteRelease(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete release error: %v", err)) + } + + return to.TextResult("Release deleted successfully") +} + +func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + release, _, err := client.Releases.GetRelease(ctx, owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get release error: %v", err)) + } + + return to.TextResult(slimRelease(release)) +} + +func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + release, _, err := client.Releases.GetLatestRelease(ctx, owner, repo) + if err != nil { + return to.ErrorResult(fmt.Errorf("get latest release error: %v", err)) + } + + return to.TextResult(slimRelease(release)) +} + +func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 20) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + releases, _, err := client.Releases.ListReleases(ctx, owner, repo, gitea_sdk.ListReleasesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + IsDraft: params.GetOptionalBoolPtr(args, "is_draft"), + IsPreRelease: params.GetOptionalBoolPtr(args, "is_pre_release"), + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list releases error: %v", err)) + } + + return to.TextResult(slimReleases(releases)) +} diff --git a/operation/repo/repo.go b/operation/repo/repo.go new file mode 100644 index 0000000..fd8aac6 --- /dev/null +++ b/operation/repo/repo.go @@ -0,0 +1,210 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + CreateRepoToolName = "create_repo" + ForkRepoToolName = "fork_repo" + ListMyReposToolName = "list_my_repos" + ListOrgReposToolName = "list_org_repos" +) + +var ( + CreateRepoTool = mcp.NewTool( + CreateRepoToolName, + mcp.WithToolAnnotation(annotation.Write("Create a new repository")), + mcp.WithString("name", mcp.Required()), + mcp.WithString("description"), + mcp.WithBoolean("private"), + mcp.WithString("issue_labels"), + mcp.WithBoolean("auto_init"), + mcp.WithBoolean("template"), + mcp.WithString("gitignores"), + mcp.WithString("license"), + mcp.WithString("readme"), + mcp.WithString("default_branch"), + mcp.WithString("trust_model", mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")), + mcp.WithString("object_format_name", mcp.Enum("sha1", "sha256")), + mcp.WithString("organization", mcp.Description("defaults to personal account")), + ) + + ForkRepoTool = mcp.NewTool( + ForkRepoToolName, + mcp.WithToolAnnotation(annotation.Write("Fork a repository")), + mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")), + mcp.WithString("repo", mcp.Required()), + mcp.WithString("organization", mcp.Description("target org")), + mcp.WithString("name", mcp.Description("fork name")), + ) + + ListMyReposTool = mcp.NewTool( + ListMyReposToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + ListOrgReposTool = mcp.NewTool( + ListOrgReposToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")), + mcp.WithString("org", mcp.Required()), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(100), mcp.Min(1)), + ) +) + +func init() { + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateRepoTool, + Handler: CreateRepoFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: ForkRepoTool, + Handler: ForkRepoFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListMyReposTool, + Handler: ListMyReposFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListOrgReposTool, + Handler: ListOrgReposFn, + }) +} + +func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + description, _ := args["description"].(string) + private, _ := args["private"].(bool) + issueLabels, _ := args["issue_labels"].(string) + autoInit, _ := args["auto_init"].(bool) + template, _ := args["template"].(bool) + gitignores, _ := args["gitignores"].(string) + license, _ := args["license"].(string) + readme, _ := args["readme"].(string) + defaultBranch, _ := args["default_branch"].(string) + trustModel, _ := args["trust_model"].(string) + objectFormatName, _ := args["object_format_name"].(string) + organization, _ := args["organization"].(string) + + opt := gitea_sdk.CreateRepoOption{ + Name: name, + Description: description, + Private: private, + IssueLabels: issueLabels, + AutoInit: autoInit, + Template: template, + Gitignores: gitignores, + License: license, + Readme: readme, + DefaultBranch: defaultBranch, + TrustModel: gitea_sdk.TrustModel(trustModel), + ObjectFormatName: objectFormatName, + } + + var repo *gitea_sdk.Repository + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + if organization != "" { + repo, _, err = client.Repositories.CreateOrgRepo(ctx, organization, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create organization repository '%s' in '%s' err: %v", name, organization, err)) + } + } else { + repo, _, err = client.Repositories.CreateRepo(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err)) + } + } + return to.TextResult(slim.Repo(repo)) +} + +func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + user, err := params.GetString(args, "user") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.CreateForkOption{ + Organization: params.GetOptionalStringPtr(args, "organization"), + Name: params.GetOptionalStringPtr(args, "name"), + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, _, err = client.Repositories.CreateFork(ctx, user, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("fork repository error: %v", err)) + } + return to.TextResult("Fork success") +} + +func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + page, pageSize := params.GetPagination(req.GetArguments(), 30) + opt := gitea_sdk.ListReposOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + repos, _, err := client.Repositories.ListMyRepos(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err)) + } + + return to.TextResult(slim.Repos(repos)) +} + +func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 100) + opt := gitea_sdk.ListOrgReposOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + repos, _, err := client.Repositories.ListOrgRepos(ctx, org, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err)) + } + return to.TextResult(repos) +} diff --git a/operation/repo/slim.go b/operation/repo/slim.go new file mode 100644 index 0000000..0968a20 --- /dev/null +++ b/operation/repo/slim.go @@ -0,0 +1,178 @@ +package repo + +import ( + "gitea.com/gitea/gitea-mcp/pkg/slim" + + gitea_sdk "gitea.dev/sdk" +) + +func slimBranch(b *gitea_sdk.Branch) map[string]any { + if b == nil { + return nil + } + m := map[string]any{ + "name": b.Name, + "protected": b.Protected, + } + if b.Commit != nil { + m["commit_sha"] = b.Commit.ID + } + return m +} + +func slimBranches(branches []*gitea_sdk.Branch) []map[string]any { + out := make([]map[string]any, 0, len(branches)) + for _, b := range branches { + out = append(out, slimBranch(b)) + } + return out +} + +func slimCommit(c *gitea_sdk.Commit) map[string]any { + if c == nil { + return nil + } + m := map[string]any{ + "sha": c.SHA, + "html_url": c.HTMLURL, + "created": c.Created, + } + if c.RepoCommit != nil { + m["message"] = c.RepoCommit.Message + if c.RepoCommit.Author != nil { + m["author"] = map[string]any{ + "name": c.RepoCommit.Author.Name, + "email": c.RepoCommit.Author.Email, + "date": c.RepoCommit.Author.Date, + } + } + } + return m +} + +func slimCommits(commits []*gitea_sdk.Commit) []map[string]any { + out := make([]map[string]any, 0, len(commits)) + for _, c := range commits { + out = append(out, slimCommit(c)) + } + return out +} + +func slimTag(t *gitea_sdk.Tag) map[string]any { + if t == nil { + return nil + } + m := map[string]any{ + "name": t.Name, + "message": t.Message, + } + if t.Commit != nil { + m["commit_sha"] = t.Commit.SHA + } + return m +} + +func slimTags(tags []*gitea_sdk.Tag) []map[string]any { + out := make([]map[string]any, 0, len(tags)) + for _, t := range tags { + m := map[string]any{ + "name": t.Name, + } + if t.Commit != nil { + m["commit_sha"] = t.Commit.SHA + } + out = append(out, m) + } + return out +} + +func slimRelease(r *gitea_sdk.Release) map[string]any { + if r == nil { + return nil + } + return map[string]any{ + "id": r.ID, + "tag_name": r.TagName, + "target": r.Target, + "title": r.Title, + "body": r.Note, + "draft": r.IsDraft, + "prerelease": r.IsPrerelease, + "html_url": r.HTMLURL, + "author": slim.UserLogin(r.Publisher), + "created_at": r.CreatedAt, + "published_at": r.PublishedAt, + } +} + +func slimReleases(releases []*gitea_sdk.Release) []map[string]any { + out := make([]map[string]any, 0, len(releases)) + for _, r := range releases { + out = append(out, slimRelease(r)) + } + return out +} + +func slimContents(c *gitea_sdk.ContentsResponse) map[string]any { + if c == nil { + return nil + } + m := map[string]any{ + "name": c.Name, + "path": c.Path, + "sha": c.SHA, + "type": c.Type, + "size": c.Size, + } + if c.Content != nil { + m["content"] = *c.Content + } + if c.Encoding != nil { + m["encoding"] = *c.Encoding + } + if c.HTMLURL != nil { + m["html_url"] = *c.HTMLURL + } + if c.DownloadURL != nil { + m["download_url"] = *c.DownloadURL + } + return m +} + +func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any { + if t == nil { + return nil + } + entries := make([]map[string]any, 0, len(t.Entries)) + for _, e := range t.Entries { + entries = append(entries, map[string]any{ + "path": e.Path, + "mode": e.Mode, + "type": e.Type, + "size": e.Size, + "sha": e.SHA, + }) + } + return map[string]any{ + "sha": t.SHA, + "truncated": t.Truncated, + "total_count": t.TotalCount, + "tree": entries, + } +} + +func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any { + out := make([]map[string]any, 0, len(entries)) + for _, c := range entries { + if c == nil { + continue + } + out = append(out, map[string]any{ + "name": c.Name, + "path": c.Path, + "type": c.Type, + "size": c.Size, + }) + } + return out +} diff --git a/operation/repo/slim_test.go b/operation/repo/slim_test.go new file mode 100644 index 0000000..02a403c --- /dev/null +++ b/operation/repo/slim_test.go @@ -0,0 +1,109 @@ +package repo + +import ( + "testing" + + gitea_sdk "gitea.dev/sdk" +) + +func TestSlimTag(t *testing.T) { + tag := &gitea_sdk.Tag{ + Name: "v1.0.0", + Message: "Release v1.0.0", + Commit: &gitea_sdk.CommitMeta{SHA: "abc123"}, + } + + m := slimTag(tag) + if m["name"] != "v1.0.0" { + t.Errorf("expected name v1.0.0, got %v", m["name"]) + } + if m["message"] != "Release v1.0.0" { + t.Errorf("expected message, got %v", m["message"]) + } + + // List variant omits message + list := slimTags([]*gitea_sdk.Tag{tag}) + if _, ok := list[0]["message"]; ok { + t.Error("Tags list should omit message") + } + if list[0]["name"] != "v1.0.0" { + t.Errorf("expected name in list, got %v", list[0]["name"]) + } +} + +func TestSlimRelease(t *testing.T) { + r := &gitea_sdk.Release{ + ID: 1, + TagName: "v1.0.0", + Title: "First Release", + Note: "Release notes", + IsDraft: false, + Publisher: &gitea_sdk.User{UserName: "alice"}, + } + + m := slimRelease(r) + if m["tag_name"] != "v1.0.0" { + t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"]) + } + if m["body"] != "Release notes" { + t.Errorf("expected body from Note field, got %v", m["body"]) + } + if m["author"] != "alice" { + t.Errorf("expected author alice, got %v", m["author"]) + } +} + +func TestSlimContents(t *testing.T) { + content := "package main" + encoding := "base64" + htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go" + c := &gitea_sdk.ContentsResponse{ + Name: "main.go", + Path: "main.go", + SHA: "abc123", + Type: "file", + Size: 12, + Content: &content, + Encoding: &encoding, + HTMLURL: &htmlURL, + } + + m := slimContents(c) + if m["name"] != "main.go" { + t.Errorf("expected name main.go, got %v", m["name"]) + } + if m["content"] != "package main" { + t.Errorf("expected content, got %v", m["content"]) + } +} + +func TestSlimDirEntries(t *testing.T) { + entries := []*gitea_sdk.ContentsResponse{ + {Name: "src", Path: "src", Type: "dir", Size: 0}, + {Name: "main.go", Path: "main.go", Type: "file", Size: 100}, + } + + result := slimDirEntries(entries) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + if result[0]["name"] != "src" { + t.Errorf("expected first entry name src, got %v", result[0]["name"]) + } + // Dir entries should not have content + if _, ok := result[0]["content"]; ok { + t.Error("dir entries should not have content field") + } +} + +func TestSlimTags_Nil(t *testing.T) { + if r := slimTags(nil); len(r) != 0 { + t.Errorf("expected empty slice, got %v", r) + } +} + +func TestSlimReleases_Nil(t *testing.T) { + if r := slimReleases(nil); len(r) != 0 { + t.Errorf("expected empty slice, got %v", r) + } +} diff --git a/operation/repo/tag.go b/operation/repo/tag.go new file mode 100644 index 0000000..099953d --- /dev/null +++ b/operation/repo/tag.go @@ -0,0 +1,195 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + CreateTagToolName = "create_tag" + DeleteTagToolName = "delete_tag" + GetTagToolName = "get_tag" + ListTagsToolName = "list_tags" +) + +var ( + CreateTagTool = mcp.NewTool( + CreateTagToolName, + mcp.WithToolAnnotation(annotation.Write("Create a tag")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("tag_name", mcp.Required()), + mcp.WithString("target", mcp.Description("commitish")), + mcp.WithString("message", mcp.Description("tag message")), + ) + + DeleteTagTool = mcp.NewTool( + DeleteTagToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("tag_name", mcp.Required()), + ) + + GetTagTool = mcp.NewTool( + GetTagToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("tag_name", mcp.Required()), + ) + + ListTagsTool = mcp.NewTool( + ListTagsToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("List tags")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)), + ) +) + +func init() { + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateTagTool, + Handler: CreateTagFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeleteTagTool, + Handler: DeleteTagFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetTagTool, + Handler: GetTagFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListTagsTool, + Handler: ListTagsFn, + }) +} + +func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + tagName, err := params.GetString(args, "tag_name") + if err != nil { + return to.ErrorResult(err) + } + target, _ := args["target"].(string) + message, _ := args["message"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, _, err = client.Repositories.CreateTag(ctx, owner, repo, gitea_sdk.CreateTagOption{ + TagName: tagName, + Target: target, + Message: message, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create tag error: %v", err)) + } + + return to.TextResult("Tag Created") +} + +func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + tagName, err := params.GetString(args, "tag_name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Repositories.DeleteTag(ctx, owner, repo, tagName) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete tag error: %v", err)) + } + + return to.TextResult("Tag deleted") +} + +func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + tagName, err := params.GetString(args, "tag_name") + if err != nil { + return to.ErrorResult(err) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + tag, _, err := client.Repositories.GetTag(ctx, owner, repo, tagName) + if err != nil { + return to.ErrorResult(fmt.Errorf("get tag error: %v", err)) + } + + return to.TextResult(slimTag(tag)) +} + +func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page := params.GetOptionalInt(args, "page", 1) + pageSize := params.GetOptionalInt(args, "per_page", 20) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + tags, _, err := client.Repositories.ListRepoTags(ctx, owner, repo, gitea_sdk.ListRepoTagsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: int(page), + PageSize: int(pageSize), + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list tags error: %v", err)) + } + + return to.TextResult(slimTags(tags)) +} diff --git a/operation/repo/tree.go b/operation/repo/tree.go new file mode 100644 index 0000000..00bcb74 --- /dev/null +++ b/operation/repo/tree.go @@ -0,0 +1,73 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetRepoTreeToolName = "get_repository_tree" +) + +var GetRepoTreeTool = mcp.NewTool( + GetRepoTreeToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Get repository file tree")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch, or tag")), + mcp.WithBoolean("recursive"), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetRepoTreeTool, + Handler: GetRepoTreeFn, + }) +} + +func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + treeSHA, err := params.GetString(args, "tree_sha") + if err != nil { + return to.ErrorResult(err) + } + recursive, _ := args["recursive"].(bool) + page, pageSize := params.GetPagination(args, 30) + + opt := gitea_sdk.ListTreeOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + Ref: treeSHA, + Recursive: recursive, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + tree, _, err := client.Git.GetTrees(ctx, owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err)) + } + return to.TextResult(slimTree(tree)) +} diff --git a/operation/repo/tree_test.go b/operation/repo/tree_test.go new file mode 100644 index 0000000..0cb7a15 --- /dev/null +++ b/operation/repo/tree_test.go @@ -0,0 +1,52 @@ +package repo + +import ( + "slices" + "testing" + + gitea_sdk "gitea.dev/sdk" +) + +func TestSlimTree(t *testing.T) { + tree := &gitea_sdk.GitTreeResponse{ + SHA: "abc123", + TotalCount: 2, + Truncated: false, + Entries: []gitea_sdk.GitEntry{ + {Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"}, + {Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"}, + }, + } + + m := slimTree(tree) + if m["sha"] != "abc123" { + t.Errorf("expected sha abc123, got %v", m["sha"]) + } + if m["total_count"] != 2 { + t.Errorf("expected total_count 2, got %v", m["total_count"]) + } + entries := m["tree"].([]map[string]any) + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0]["path"] != "src" { + t.Errorf("expected first entry path src, got %v", entries[0]["path"]) + } + if entries[1]["type"] != "blob" { + t.Errorf("expected second entry type blob, got %v", entries[1]["type"]) + } +} + +func TestSlimTreeNil(t *testing.T) { + if m := slimTree(nil); m != nil { + t.Errorf("expected nil, got %v", m) + } +} + +func TestGetRepoTreeToolRequired(t *testing.T) { + for _, field := range []string{"owner", "repo", "tree_sha"} { + if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) { + t.Errorf("expected %q to be required", field) + } + } +} diff --git a/operation/search/search.go b/operation/search/search.go new file mode 100644 index 0000000..d8e7276 --- /dev/null +++ b/operation/search/search.go @@ -0,0 +1,222 @@ +package search + +import ( + "context" + "fmt" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + SearchUsersToolName = "search_users" + SearchOrgTeamsToolName = "search_org_teams" + SearchReposToolName = "search_repos" + SearchIssuesToolName = "search_issues" +) + +var ( + SearchUsersTool = mcp.NewTool( + SearchUsersToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Search users")), + mcp.WithString("query", mcp.Required()), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + SearOrgTeamsTool = mcp.NewTool( + SearchOrgTeamsToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Search organization teams")), + mcp.WithString("org", mcp.Required()), + mcp.WithString("query", mcp.Required()), + mcp.WithBoolean("includeDescription"), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + SearchReposTool = mcp.NewTool( + SearchReposToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Search repositories")), + mcp.WithString("query", mcp.Required()), + mcp.WithBoolean("keywordIsTopic"), + mcp.WithBoolean("keywordInDescription"), + mcp.WithNumber("ownerID"), + mcp.WithBoolean("isPrivate"), + mcp.WithBoolean("isArchived"), + mcp.WithString("sort"), + mcp.WithString("order"), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + SearchIssuesTool = mcp.NewTool( + SearchIssuesToolName, + mcp.WithDescription("Search issues and PRs across repositories"), + mcp.WithToolAnnotation(annotation.ReadOnly("Search issues")), + mcp.WithString("query", mcp.Required()), + mcp.WithString("state", mcp.Enum("open", "closed", "all")), + mcp.WithString("type", mcp.Enum("issues", "pulls")), + mcp.WithString("labels", mcp.Description("comma-separated")), + mcp.WithString("owner", mcp.Description("filter by owner")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: SearchUsersTool, + Handler: UsersFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: SearOrgTeamsTool, + Handler: OrgTeamsFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: SearchReposTool, + Handler: ReposFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: SearchIssuesTool, + Handler: IssuesFn, + }) +} + +func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + keyword, err := params.GetString(req.GetArguments(), "query") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + opt := gitea_sdk.SearchUsersOption{ + KeyWord: keyword, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + users, _, err := client.Users.SearchUsers(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("search users err: %v", err)) + } + return to.TextResult(slimUserDetails(users)) +} + +func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := params.GetString(req.GetArguments(), "org") + if err != nil { + return to.ErrorResult(err) + } + query, err := params.GetString(req.GetArguments(), "query") + if err != nil { + return to.ErrorResult(err) + } + includeDescription, _ := req.GetArguments()["includeDescription"].(bool) + page, pageSize := params.GetPagination(req.GetArguments(), 30) + opt := gitea_sdk.SearchTeamsOptions{ + Query: query, + IncludeDescription: includeDescription, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + teams, _, err := client.Organizations.SearchOrgTeams(ctx, org, &opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err)) + } + return to.TextResult(slimTeams(teams)) +} + +func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + keyword, err := params.GetString(req.GetArguments(), "query") + if err != nil { + return to.ErrorResult(err) + } + args := req.GetArguments() + keywordIsTopic, _ := args["keywordIsTopic"].(bool) + keywordInDescription, _ := args["keywordInDescription"].(bool) + sort, _ := args["sort"].(string) + order, _ := args["order"].(string) + page, pageSize := params.GetPagination(args, 30) + opt := gitea_sdk.SearchRepoOptions{ + Keyword: keyword, + KeywordIsTopic: keywordIsTopic, + KeywordInDescription: keywordInDescription, + OwnerID: params.GetOptionalInt(args, "ownerID", 0), + IsPrivate: params.GetOptionalBoolPtr(args, "isPrivate"), + IsArchived: params.GetOptionalBoolPtr(args, "isArchived"), + Sort: sort, + Order: order, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + repos, _, err := client.Repositories.SearchRepos(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("search repos error: %v", err)) + } + return to.TextResult(slim.Repos(repos)) +} + +func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + query, err := params.GetString(args, "query") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + + opt := gitea_sdk.ListIssueOption{ + KeyWord: query, + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + if state, ok := args["state"].(string); ok { + opt.State = gitea_sdk.StateType(state) + } + if issueType, ok := args["type"].(string); ok { + opt.Type = gitea_sdk.IssueType(issueType) + } + if labels, ok := args["labels"].(string); ok && labels != "" { + opt.Labels = strings.Split(labels, ",") + } + if owner, ok := args["owner"].(string); ok { + opt.Owner = owner + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + issues, _, err := client.Issues.ListIssues(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("search issues err: %v", err)) + } + return to.TextResult(slimIssues(issues)) +} diff --git a/operation/search/search_test.go b/operation/search/search_test.go new file mode 100644 index 0000000..1dce5d9 --- /dev/null +++ b/operation/search/search_test.go @@ -0,0 +1,42 @@ +package search + +import ( + "slices" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestSearchToolsRequiredFields(t *testing.T) { + tests := []struct { + name string + tool mcp.Tool + required []string + }{ + { + name: "search_users", + tool: SearchUsersTool, + required: []string{"query"}, + }, + { + name: "search_org_teams", + tool: SearOrgTeamsTool, + required: []string{"org", "query"}, + }, + { + name: "search_repos", + tool: SearchReposTool, + required: []string{"query"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, field := range tt.required { + if !slices.Contains(tt.tool.InputSchema.Required, field) { + t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required) + } + } + }) + } +} diff --git a/operation/search/slim.go b/operation/search/slim.go new file mode 100644 index 0000000..a8b8061 --- /dev/null +++ b/operation/search/slim.go @@ -0,0 +1,71 @@ +package search + +import ( + "gitea.com/gitea/gitea-mcp/pkg/slim" + + gitea_sdk "gitea.dev/sdk" +) + +func slimUserDetails(users []*gitea_sdk.User) []map[string]any { + out := make([]map[string]any, 0, len(users)) + for _, u := range users { + out = append(out, slim.UserDetail(u)) + } + return out +} + +func slimTeam(t *gitea_sdk.Team) map[string]any { + if t == nil { + return nil + } + return map[string]any{ + "id": t.ID, + "name": t.Name, + "description": t.Description, + "permission": t.Permission, + } +} + +func slimTeams(teams []*gitea_sdk.Team) []map[string]any { + out := make([]map[string]any, 0, len(teams)) + for _, t := range teams { + out = append(out, slimTeam(t)) + } + return out +} + +func slimIssues(issues []*gitea_sdk.Issue) []map[string]any { + out := make([]map[string]any, 0, len(issues)) + for _, i := range issues { + if i == nil { + continue + } + m := map[string]any{ + "number": i.Index, + "title": i.Title, + "state": i.State, + "html_url": i.HTMLURL, + "user": slim.UserLogin(i.Poster), + "comments": i.Comments, + "created_at": i.Created, + "updated_at": i.Updated, + } + if len(i.Labels) > 0 { + m["labels"] = slim.LabelNames(i.Labels) + } + if i.Repository != nil { + m["repository"] = i.Repository.FullName + } + if i.Ref != "" { + m["ref"] = i.Ref + } + if i.Deadline != nil { + m["deadline"] = i.Deadline + } + if i.PullRequest != nil { + m["is_pull"] = true + } + out = append(out, m) + } + return out +} diff --git a/operation/search/slim_test.go b/operation/search/slim_test.go new file mode 100644 index 0000000..e0914ed --- /dev/null +++ b/operation/search/slim_test.go @@ -0,0 +1,54 @@ +package search + +import ( + "slices" + "testing" + + gitea_sdk "gitea.dev/sdk" +) + +func TestSlimIssues(t *testing.T) { + issues := []*gitea_sdk.Issue{ + { + Index: 1, + Title: "Bug report", + State: gitea_sdk.StateOpen, + HTMLURL: "https://gitea.com/org/repo/issues/1", + Poster: &gitea_sdk.User{UserName: "alice"}, + Labels: []*gitea_sdk.Label{{Name: "bug"}}, + Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"}, + PullRequest: nil, + }, + { + Index: 2, + Title: "Add feature", + State: gitea_sdk.StateOpen, + Poster: &gitea_sdk.User{UserName: "bob"}, + Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"}, + PullRequest: &gitea_sdk.PullRequestMeta{}, + }, + } + + result := slimIssues(issues) + if len(result) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result)) + } + if result[0]["repository"] != "org/repo" { + t.Errorf("expected repository org/repo, got %v", result[0]["repository"]) + } + if result[0]["labels"].([]string)[0] != "bug" { + t.Errorf("expected label bug, got %v", result[0]["labels"]) + } + if _, ok := result[0]["is_pull"]; ok { + t.Error("issue should not have is_pull") + } + if result[1]["is_pull"] != true { + t.Error("PR should have is_pull=true") + } +} + +func TestSearchIssuesToolRequired(t *testing.T) { + if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") { + t.Error("search_issues should require query") + } +} diff --git a/operation/timetracking/slim.go b/operation/timetracking/slim.go new file mode 100644 index 0000000..a209be0 --- /dev/null +++ b/operation/timetracking/slim.go @@ -0,0 +1,47 @@ +package timetracking + +import ( + gitea_sdk "gitea.dev/sdk" +) + +func slimStopWatch(s *gitea_sdk.StopWatch) map[string]any { + if s == nil { + return nil + } + return map[string]any{ + "issue_index": s.IssueIndex, + "issue_title": s.IssueTitle, + "repo_name": s.RepoName, + "repo_owner": s.RepoOwnerName, + "created": s.Created, + "seconds": s.Seconds, + } +} + +func slimStopWatches(watches []*gitea_sdk.StopWatch) []map[string]any { + out := make([]map[string]any, 0, len(watches)) + for _, s := range watches { + out = append(out, slimStopWatch(s)) + } + return out +} + +func slimTrackedTime(t *gitea_sdk.TrackedTime) map[string]any { + if t == nil { + return nil + } + return map[string]any{ + "id": t.ID, + "time": t.Time, + "user_name": t.UserName, + "created": t.Created, + } +} + +func slimTrackedTimes(times []*gitea_sdk.TrackedTime) []map[string]any { + out := make([]map[string]any, 0, len(times)) + for _, t := range times { + out = append(out, slimTrackedTime(t)) + } + return out +} diff --git a/operation/timetracking/timetracking.go b/operation/timetracking/timetracking.go new file mode 100644 index 0000000..a708819 --- /dev/null +++ b/operation/timetracking/timetracking.go @@ -0,0 +1,321 @@ +// Package timetracking provides MCP tools for Gitea time tracking operations +package timetracking + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + TimetrackingReadToolName = "timetracking_read" + TimetrackingWriteToolName = "timetracking_write" +) + +var ( + TimetrackingReadTool = mcp.NewTool( + TimetrackingReadToolName, + mcp.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read tracked time")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")), + mcp.WithString("owner", mcp.Description("for list_* methods")), + mcp.WithString("repo", mcp.Description("for list_* methods")), + mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) + + TimetrackingWriteTool = mcp.NewTool( + TimetrackingWriteToolName, + mcp.WithDescription("Write time tracking: stopwatches and entries."), + mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")), + mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")), + mcp.WithString("owner", mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Description(params.RepoDesc)), + mcp.WithNumber("issue_number"), + mcp.WithNumber("time", mcp.Description("seconds (for 'add_time')")), + mcp.WithNumber("id", mcp.Description("entry ID (for 'delete_time')")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn}) + Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn}) +} + +func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list_issue_times": + return listTrackedTimesFn(ctx, req) + case "list_repo_times": + return listRepoTimesFn(ctx, req) + case "get_my_stopwatches": + return getMyStopwatchesFn(ctx, req) + case "get_my_times": + return getMyTimesFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "start_stopwatch": + return startStopwatchFn(ctx, req) + case "stop_stopwatch": + return stopStopwatchFn(ctx, req) + case "delete_stopwatch": + return deleteStopwatchFn(ctx, req) + case "add_time": + return addTrackedTimeFn(ctx, req) + case "delete_time": + return deleteTrackedTimeFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.StartIssueStopWatch(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, index, err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index)) +} + +func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.StopIssueStopWatch(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, index, err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index)) +} + +func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.DeleteIssueStopwatch(ctx, owner, repo, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, index, err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index)) +} + +func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + stopwatches, _, err := client.Issues.ListMyStopwatches(ctx, gitea_sdk.ListStopwatchesOptions{}) + if err != nil { + return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err)) + } + if len(stopwatches) == 0 { + return to.TextResult("No active stopwatches") + } + return to.TextResult(slimStopWatches(stopwatches)) +} + +func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(req.GetArguments(), 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + times, _, err := client.Issues.ListIssueTrackedTimes(ctx, owner, repo, index, gitea_sdk.ListTrackedTimesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, index, err)) + } + if len(times) == 0 { + return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index)) + } + return to.TextResult(slimTrackedTimes(times)) +} + +func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + + timeSeconds, err := params.GetIndex(req.GetArguments(), "time") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + trackedTime, _, err := client.Issues.AddTime(ctx, owner, repo, index, gitea_sdk.AddTimeOption{ + Time: timeSeconds, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err)) + } + return to.TextResult(slimTrackedTime(trackedTime)) +} + +func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + + index, err := params.GetIndex(req.GetArguments(), "issue_number") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(req.GetArguments(), "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Issues.DeleteTime(ctx, owner, repo, index, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", id, owner, repo, index, err)) + } + return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index)) +} + +func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + + page, pageSize := params.GetPagination(req.GetArguments(), 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + times, _, err := client.Issues.ListRepoTrackedTimes(ctx, owner, repo, gitea_sdk.ListTrackedTimesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo tracked times for %s/%s err: %v", owner, repo, err)) + } + if len(times) == 0 { + return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo)) + } + return to.TextResult(slimTrackedTimes(times)) +} + +func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + times, _, err := client.Issues.ListMyTrackedTimes(ctx, gitea_sdk.ListTrackedTimesOptions{}) + if err != nil { + return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err)) + } + if len(times) == 0 { + return to.TextResult("No tracked times found") + } + return to.TextResult(slimTrackedTimes(times)) +} diff --git a/operation/user/slim.go b/operation/user/slim.go new file mode 100644 index 0000000..8c11534 --- /dev/null +++ b/operation/user/slim.go @@ -0,0 +1,27 @@ +package user + +import ( + gitea_sdk "gitea.dev/sdk" +) + +func slimOrg(o *gitea_sdk.Organization) map[string]any { + if o == nil { + return nil + } + return map[string]any{ + "id": o.ID, + "name": o.Name, + "full_name": o.FullName, + "description": o.Description, + "avatar_url": o.AvatarURL, + "website": o.Website, + } +} + +func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any { + out := make([]map[string]any, 0, len(orgs)) + for _, o := range orgs { + out = append(out, slimOrg(o)) + } + return out +} diff --git a/operation/user/user.go b/operation/user/user.go new file mode 100644 index 0000000..de330c8 --- /dev/null +++ b/operation/user/user.go @@ -0,0 +1,77 @@ +package user + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/slim" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetMyUserInfoToolName = "get_me" + GetUserOrgsToolName = "get_user_orgs" +) + +var Tool = tool.New() + +var ( + GetMyUserInfoTool = mcp.NewTool( + GetMyUserInfoToolName, + mcp.WithDescription("Get current user"), + mcp.WithToolAnnotation(annotation.ReadOnly("Get current user information")), + ) + + GetUserOrgsTool = mcp.NewTool( + GetUserOrgsToolName, + mcp.WithDescription("List current user's organizations"), + mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")), + mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), + mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn}) +} + +func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + user, _, err := client.Users.GetMyUserInfo(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get user info err: %v", err)) + } + return to.TextResult(slim.UserDetail(user)) +} + +func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + page, pageSize := params.GetPagination(req.GetArguments(), 30) + + opt := gitea_sdk.ListOrgsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + orgs, _, err := client.Organizations.ListMyOrgs(ctx, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err)) + } + return to.TextResult(slimOrgs(orgs)) +} diff --git a/operation/version/version.go b/operation/version/version.go new file mode 100644 index 0000000..77737a6 --- /dev/null +++ b/operation/version/version.go @@ -0,0 +1,40 @@ +package version + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + GetGiteaMCPServerVersion = "get_gitea_mcp_server_version" +) + +var GetGiteaMCPServerVersionTool = mcp.NewTool( + GetGiteaMCPServerVersion, + mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")), +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetGiteaMCPServerVersionTool, + Handler: GetGiteaMCPServerVersionFn, + }) +} + +func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + version := flag.Version + if version == "" { + version = "dev" + } + return to.TextResult(fmt.Sprintf("Gitea MCP Server version: %v", version)) +} diff --git a/operation/wiki/wiki.go b/operation/wiki/wiki.go new file mode 100644 index 0000000..a8edee5 --- /dev/null +++ b/operation/wiki/wiki.go @@ -0,0 +1,269 @@ +package wiki + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + WikiReadToolName = "wiki_read" + WikiWriteToolName = "wiki_write" +) + +var ( + WikiReadTool = mcp.NewTool( + WikiReadToolName, + mcp.WithDescription("Read wiki: list pages, get content, revision history."), + mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")), + ) + + WikiWriteTool = mcp.NewTool( + WikiWriteToolName, + mcp.WithDescription("Write wiki pages: create, update, delete."), + mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), + mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), + mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")), + mcp.WithString("title", mcp.Description("for 'create'")), + mcp.WithString("content", mcp.Description("for 'create'/'update'")), + mcp.WithString("message", mcp.Description("commit message")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: WikiReadTool, + Handler: wikiReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: WikiWriteTool, + Handler: wikiWriteFn, + }) +} + +func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listWikiPagesFn(ctx, req) + case "get": + return getWikiPageFn(ctx, req) + case "get_revisions": + return getWikiRevisionsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := params.GetString(req.GetArguments(), "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createWikiPageFn(ctx, req) + case "update": + return updateWikiPageFn(ctx, req) + case "delete": + return deleteWikiPageFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(repo)), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err)) + } + + return to.TextResult(result) +} + +func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + pageName, err := params.GetString(args, "pageName") + if err != nil { + return to.ErrorResult(err) + } + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + pageName, err := params.GetString(args, "pageName") + if err != nil { + return to.ErrorResult(err) + } + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err)) + } + + return to.TextResult(result) +} + +func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + content, err := params.GetString(args, "content") + if err != nil { + return to.ErrorResult(err) + } + + message, _ := args["message"].(string) + if message == "" { + message = fmt.Sprintf("Create wiki page '%s'", title) + } + + requestBody := map[string]string{ + "title": title, + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + } + + var result any + _, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.PathEscape(owner), url.PathEscape(repo)), nil, requestBody, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + pageName, err := params.GetString(args, "pageName") + if err != nil { + return to.ErrorResult(err) + } + content, err := params.GetString(args, "content") + if err != nil { + return to.ErrorResult(err) + } + + requestBody := map[string]string{ + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + + // If title is given, use it. Otherwise, keep current page name + if title, ok := args["title"].(string); ok && title != "" { + requestBody["title"] = title + } else { + requestBody["title"] = pageName + } + + if message, ok := args["message"].(string); ok && message != "" { + requestBody["message"] = message + } else { + requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName) + } + + var result any + _, err = gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, requestBody, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + pageName, err := params.GetString(args, "pageName") + if err != nil { + return to.ErrorResult(err) + } + + _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err)) + } + + return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"}) +} diff --git a/operation/wiki/wiki_test.go b/operation/wiki/wiki_test.go new file mode 100644 index 0000000..2c9d5d9 --- /dev/null +++ b/operation/wiki/wiki_test.go @@ -0,0 +1,75 @@ +package wiki + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestWikiWriteBase64Encoding(t *testing.T) { + tests := []struct { + name string + method string + content string + }{ + {"create ascii", "create", "Hello, World!"}, + {"create unicode", "create", "日本語テスト 🎉"}, + {"create multiline", "create", "line1\nline2\nline3"}, + {"update ascii", "update", "Updated content"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"title":"test"}`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + args := map[string]any{ + "method": tt.method, + "owner": "org", + "repo": "repo", + "content": tt.content, + "pageName": "TestPage", + "title": "TestPage", + } + + req := mcp.CallToolRequest{} + req.Params.Arguments = args + + result, err := wikiWriteFn(ctx, req) + if err != nil { + t.Fatalf("wikiWriteFn() error: %v", err) + } + if result.IsError { + t.Fatalf("wikiWriteFn() returned error result") + } + + got := gotBody["content_base64"] + want := base64.StdEncoding.EncodeToString([]byte(tt.content)) + if got != want { + t.Errorf("content_base64 = %q, want %q", got, want) + } + }) + } +} diff --git a/pkg/annotation/annotation.go b/pkg/annotation/annotation.go new file mode 100644 index 0000000..c078419 --- /dev/null +++ b/pkg/annotation/annotation.go @@ -0,0 +1,18 @@ +package annotation + +import "github.com/mark3labs/mcp-go/mcp" + +func ReadOnly(title string) mcp.ToolAnnotation { + t := true + return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &t} +} + +func Write(title string) mcp.ToolAnnotation { + f := false + return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f} +} + +func Destructive(title string) mcp.ToolAnnotation { + f, t := false, true + return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f, DestructiveHint: &t} +} diff --git a/pkg/context/context.go b/pkg/context/context.go new file mode 100644 index 0000000..1671037 --- /dev/null +++ b/pkg/context/context.go @@ -0,0 +1,7 @@ +package context + +type contextKey string + +const ( + TokenContextKey = contextKey("token") +) diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go new file mode 100644 index 0000000..9e537c4 --- /dev/null +++ b/pkg/flag/flag.go @@ -0,0 +1,14 @@ +package flag + +var ( + Host string + Port int + Token string + Version string + Mode string + + Insecure bool + ReadOnly bool + Debug bool + AllowedTools map[string]struct{} +) diff --git a/pkg/gitea/gitea.go b/pkg/gitea/gitea.go new file mode 100644 index 0000000..de8122c --- /dev/null +++ b/pkg/gitea/gitea.go @@ -0,0 +1,82 @@ +package gitea + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "sync" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "gitea.dev/sdk" +) + +var ( + clientCache sync.Map // token -> *gitea.Client + sharedTransOnce sync.Once + sharedTrans *http.Transport +) + +func sharedTransport() *http.Transport { + sharedTransOnce.Do(func() { + sharedTrans = http.DefaultTransport.(*http.Transport).Clone() + if flag.Insecure { + sharedTrans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode + } + }) + return sharedTrans +} + +// NewClient returns a cached *gitea.Client keyed by host+token. The SDK's per-client +// version cache and the shared transport let us reuse keep-alive connections +// and avoid the SDK's /api/v1/version preflight on every tool call. +func NewClient(token string) (*gitea.Client, error) { + key := flag.Host + "\x00" + token + if v, ok := clientCache.Load(key); ok { + return v.(*gitea.Client), nil + } + + httpClient := &http.Client{ + Transport: sharedTransport(), + CheckRedirect: checkRedirect, + } + opts := []gitea.ClientOption{ + gitea.SetToken(token), + gitea.SetHTTPClient(httpClient), + } + if flag.Debug { + opts = append(opts, gitea.SetDebugMode()) + } + client, err := gitea.NewClient(flag.Host, opts...) + if err != nil { + return nil, fmt.Errorf("create gitea client err: %w", err) + } + client.SetUserAgent("gitea-mcp-server/" + flag.Version) + + actual, _ := clientCache.LoadOrStore(key, client) + return actual.(*gitea.Client), nil +} + +// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.) +// to GET when following 301/302/303 redirects, which would drop the request body and +// make writes appear to succeed when they didn't. +func checkRedirect(_ *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if via[0].Method != http.MethodGet && via[0].Method != http.MethodHead { + return http.ErrUseLastResponse + } + return nil +} + +func ClientFromContext(ctx context.Context) (*gitea.Client, error) { + token, ok := ctx.Value(mcpContext.TokenContextKey).(string) + if !ok { + token = flag.Token + } + return NewClient(token) +} diff --git a/pkg/gitea/redirect_test.go b/pkg/gitea/redirect_test.go new file mode 100644 index 0000000..761c34d --- /dev/null +++ b/pkg/gitea/redirect_test.go @@ -0,0 +1,120 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +func TestCheckRedirect(t *testing.T) { + for _, tc := range []struct { + name string + method string + wantErr error + }{ + {"allows GET", http.MethodGet, nil}, + {"allows HEAD", http.MethodHead, nil}, + {"blocks PATCH", http.MethodPatch, http.ErrUseLastResponse}, + {"blocks POST", http.MethodPost, http.ErrUseLastResponse}, + {"blocks PUT", http.MethodPut, http.ErrUseLastResponse}, + {"blocks DELETE", http.MethodDelete, http.ErrUseLastResponse}, + } { + t.Run(tc.name, func(t *testing.T) { + via := []*http.Request{{Method: tc.method}} + err := checkRedirect(nil, via) + if err != tc.wantErr { + t.Fatalf("expected %v, got %v", tc.wantErr, err) + } + }) + } + + t.Run("stops after 10 redirects", func(t *testing.T) { + via := make([]*http.Request, 10) + for i := range via { + via[i] = &http.Request{Method: http.MethodGet} + } + err := checkRedirect(nil, via) + if err == nil || err == http.ErrUseLastResponse { + t.Fatalf("expected redirect limit error, got %v", err) + } + }) +} + +// TestDoJSON_RepoRenameRedirect is a regression test for the bug where a PATCH +// request to a renamed repo got a 301 redirect, Go's http.Client silently +// changed the method to GET, and the write appeared to succeed without error. +func TestDoJSON_RepoRenameRedirect(t *testing.T) { + // Simulate a Gitea API that returns 301 for the old repo name (like a renamed repo). + mux := http.NewServeMux() + mux.HandleFunc("PATCH /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently) + }) + mux.HandleFunc("PATCH /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"id":1,"title":"updated"}`) + }) + mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"id":1,"title":"not-updated"}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + origHost := flag.Host + defer func() { flag.Host = origHost }() + flag.Host = srv.URL + + var result map[string]any + status, err := DoJSON(context.Background(), http.MethodPatch, "repos/owner/old-name/pulls/1", nil, map[string]string{"title": "updated"}, &result) + if err != nil { + // The redirect should be blocked, returning the 301 response directly. + // DoJSON treats non-2xx as an error, which is the correct behavior. + if status != http.StatusMovedPermanently { + t.Fatalf("expected status 301, got %d (err: %v)", status, err) + } + return + } + + // If we reach here without error, the redirect was followed. Verify the + // method was preserved (title should be "updated", not "not-updated"). + title, _ := result["title"].(string) + if title == "not-updated" { + t.Fatal("PATCH was silently converted to GET on 301 redirect — write was lost") + } +} + +// TestDoJSON_GETRedirectFollowed verifies that GET requests still follow redirects normally. +func TestDoJSON_GETRedirectFollowed(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently) + }) + mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + origHost := flag.Host + defer func() { flag.Host = origHost }() + flag.Host = srv.URL + + var result map[string]any + status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/old-name/pulls/1", nil, nil, &result) + if err != nil { + t.Fatalf("GET redirect should be followed, got error: %v (status %d)", err, status) + } + title, _ := result["title"].(string) + if title != "found" { + t.Fatalf("expected title 'found', got %q", title) + } +} diff --git a/pkg/gitea/rest.go b/pkg/gitea/rest.go new file mode 100644 index 0000000..ba745da --- /dev/null +++ b/pkg/gitea/rest.go @@ -0,0 +1,184 @@ +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +const ( + httpClientTimeout = 60 * time.Second + errBodySnippetSize = 8192 +) + +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + if e.Body == "" { + return fmt.Sprintf("request failed with status %d", e.StatusCode) + } + return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Body) +} + +func tokenFromContext(ctx context.Context) string { + if ctx != nil { + if token, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && token != "" { + return token + } + } + return flag.Token +} + +var ( + restClientOnce sync.Once + restClient *http.Client +) + +func restHTTPClient() *http.Client { + restClientOnce.Do(func() { + restClient = &http.Client{ + Transport: sharedTransport(), + Timeout: httpClientTimeout, + CheckRedirect: checkRedirect, + } + }) + return restClient +} + +func buildAPIURL(path string, query url.Values) (string, error) { + host := strings.TrimRight(flag.Host, "/") + if host == "" { + return "", errors.New("gitea host is empty") + } + p := strings.TrimLeft(path, "/") + u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p)) + if err != nil { + return "", err + } + if query != nil { + u.RawQuery = query.Encode() + } + return u.String(), nil +} + +// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil). +// It returns the HTTP status code. +func DoJSON(ctx context.Context, method, path string, query url.Values, body, respOut any) (int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + u, err := buildAPIURL(path, query) + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return 0, fmt.Errorf("create request: %w", err) + } + + token := tokenFromContext(ctx) + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := restHTTPClient() + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodySnippetSize)) + return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} + } + + if respOut == nil { + _, _ = io.Copy(io.Discard, resp.Body) // best-effort + return resp.StatusCode, nil + } + + if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil { + return resp.StatusCode, fmt.Errorf("decode response: %w", err) + } + return resp.StatusCode, nil +} + +// DoBytes performs an API request and returns the raw response bytes. +// It returns the HTTP status code. +func DoBytes(ctx context.Context, method, path string, query url.Values, body any, accept string) ([]byte, int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + u, err := buildAPIURL(path, query) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("create request: %w", err) + } + + token := tokenFromContext(ctx) + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := restHTTPClient() + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodySnippet := respBytes + if len(bodySnippet) > errBodySnippetSize { + bodySnippet = bodySnippet[:errBodySnippetSize] + } + return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} + } + + return respBytes, resp.StatusCode, nil +} diff --git a/pkg/gitea/rest_test.go b/pkg/gitea/rest_test.go new file mode 100644 index 0000000..4d3d841 --- /dev/null +++ b/pkg/gitea/rest_test.go @@ -0,0 +1,30 @@ +package gitea + +import ( + "context" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +func TestTokenFromContext(t *testing.T) { + orig := flag.Token + defer func() { flag.Token = orig }() + + flag.Token = "flag-token" + + t.Run("context token wins", func(t *testing.T) { + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "ctx-token") + if got := tokenFromContext(ctx); got != "ctx-token" { + t.Fatalf("tokenFromContext() = %q, want %q", got, "ctx-token") + } + }) + + t.Run("fallback to flag token", func(t *testing.T) { + ctx := context.Background() + if got := tokenFromContext(ctx); got != "flag-token" { + t.Fatalf("tokenFromContext() = %q, want %q", got, "flag-token") + } + }) +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..24404f2 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,120 @@ +package log + +import ( + "os" + "sync" + "time" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +var ( + defaultLoggerOnce sync.Once + defaultLogger *zap.Logger +) + +func Default() *zap.Logger { + defaultLoggerOnce.Do(func() { + if defaultLogger != nil { + return + } + + ec := zap.NewProductionEncoderConfig() + ec.EncodeTime = zapcore.TimeEncoderOfLayout(time.DateTime) + ec.EncodeLevel = zapcore.CapitalLevelEncoder + + var ws zapcore.WriteSyncer + var wss []zapcore.WriteSyncer + + home, _ := os.UserHomeDir() + if home == "" { + home = os.TempDir() + } + + logDir := home + "/.gitea-mcp" + if err := os.MkdirAll(logDir, 0o700); err != nil { + // Fallback to temp directory if creation fails + logDir = os.TempDir() + } + + wss = append(wss, zapcore.AddSync(&lumberjack.Logger{ + Filename: logDir + "/gitea-mcp.log", + MaxSize: 100, + MaxBackups: 10, + MaxAge: 30, + })) + + if flag.Mode == "http" { + wss = append(wss, zapcore.AddSync(os.Stdout)) + } + + ws = zapcore.NewMultiWriteSyncer(wss...) + + enc := zapcore.NewConsoleEncoder(ec) + var level zapcore.Level + if flag.Debug { + level = zapcore.DebugLevel + } else { + level = zapcore.InfoLevel + } + core := zapcore.NewCore(enc, ws, level) + options := []zap.Option{ + zap.AddStacktrace(zapcore.DPanicLevel), + zap.AddCaller(), + zap.AddCallerSkip(1), + } + defaultLogger = zap.New(core, options...) + }) + + return defaultLogger +} + +func SetDefault(logger *zap.Logger) { + if logger != nil { + defaultLogger = logger + } +} + +func Debug(msg string, fields ...zap.Field) { + Default().Debug(msg, fields...) +} + +func Info(msg string, fields ...zap.Field) { + Default().Info(msg, fields...) +} + +func Warn(msg string, fields ...zap.Field) { + Default().Warn(msg, fields...) +} + +func Error(msg string, fields ...zap.Field) { + Default().Error(msg, fields...) +} + +func Panic(msg string, fields ...zap.Field) { + Default().Panic(msg, fields...) +} + +func Debugf(format string, args ...any) { + Default().Sugar().Debugf(format, args...) +} + +func Infof(format string, args ...any) { + Default().Sugar().Infof(format, args...) +} + +func Warnf(format string, args ...any) { + Default().Sugar().Warnf(format, args...) +} + +func Errorf(format string, args ...any) { + Default().Sugar().Errorf(format, args...) +} + +func Fatalf(format string, args ...any) { + Default().Sugar().Fatalf(format, args...) +} diff --git a/pkg/params/params.go b/pkg/params/params.go new file mode 100644 index 0000000..ae0bf8b --- /dev/null +++ b/pkg/params/params.go @@ -0,0 +1,156 @@ +package params + +import ( + "fmt" + "strconv" + "time" +) + +// Shared parameter description strings used across tools. Extracted to avoid +// repeating the same boilerplate in every tool schema (saves tokens in the +// tool list sent to MCP clients). +const ( + OwnerDesc = "repo owner" + RepoDesc = "repo name" + PageDesc = "page" + PaginationDesc = "results per page" +) + +// GetString extracts a required string parameter. Empty strings are treated as missing. +func GetString(args map[string]any, key string) (string, error) { + val, ok := args[key].(string) + if !ok || val == "" { + return "", fmt.Errorf("%s is required", key) + } + return val, nil +} + +func GetOptionalString(args map[string]any, key, defaultVal string) string { + if val, ok := args[key].(string); ok { + return val + } + return defaultVal +} + +func GetStringSlice(args map[string]any, key string) []string { + val, ok := args[key] + if !ok { + return nil + } + sliceVal, ok := val.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(sliceVal)) + for _, item := range sliceVal { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) { + return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "per_page", defaultPageSize)) +} + +// ToInt64 accepts float64 (JSON number) and string representations. +func ToInt64(val any) (int64, bool) { + switch v := val.(type) { + case float64: + return int64(v), true + case string: + i, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, false + } + return i, true + default: + return 0, false + } +} + +// GetIndex extracts a required integer. Accepts numeric or string forms — LLM callers +// often pass identifiers like issue/PR numbers as strings. +func GetIndex(args map[string]any, key string) (int64, error) { + val, exists := args[key] + if !exists { + return 0, fmt.Errorf("%s is required", key) + } + + if i, ok := ToInt64(val); ok { + return i, nil + } + + if s, ok := val.(string); ok { + return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s) + } + + return 0, fmt.Errorf("%s must be a number or numeric string", key) +} + +func GetInt64Slice(args map[string]any, key string) ([]int64, error) { + raw, ok := args[key].([]any) + if !ok { + return nil, fmt.Errorf("%s (array of IDs) is required", key) + } + out := make([]int64, 0, len(raw)) + for _, v := range raw { + id, ok := ToInt64(v) + if !ok { + return nil, fmt.Errorf("invalid ID in %s array", key) + } + out = append(out, id) + } + return out, nil +} + +// GetOptionalTime parses RFC3339, returning nil if missing or unparseable. +func GetOptionalTime(args map[string]any, key string) *time.Time { + val, ok := args[key].(string) + if !ok { + return nil + } + if t, err := time.Parse(time.RFC3339, val); err == nil { + return &t + } + return nil +} + +func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 { + val, exists := args[key] + if !exists { + return defaultVal + } + if i, ok := ToInt64(val); ok { + return i + } + return defaultVal +} + +// GetOptionalBoolPtr is for SDK fields where nil/false/true are distinct (e.g. "no change" vs "set to false"). +func GetOptionalBoolPtr(args map[string]any, key string) *bool { + if v, ok := args[key].(bool); ok { + return &v + } + return nil +} + +// GetOptionalStringPtr returns nil when the key is missing OR the value is an empty string. +// Use this for create/fork-style fields where "" is meaningless (e.g. fork target name). +func GetOptionalStringPtr(args map[string]any, key string) *string { + if v, ok := args[key].(string); ok && v != "" { + return &v + } + return nil +} + +// GetPresentStringPtr returns &v whenever the key is present as a string, including "". +// Use this for PATCH-style fields where the SDK distinguishes "no change" (nil) from +// "set to empty" (&""), e.g. clearing an issue body or label description. +func GetPresentStringPtr(args map[string]any, key string) *string { + if v, ok := args[key].(string); ok { + return &v + } + return nil +} diff --git a/pkg/params/params_test.go b/pkg/params/params_test.go new file mode 100644 index 0000000..55ebd02 --- /dev/null +++ b/pkg/params/params_test.go @@ -0,0 +1,208 @@ +package params + +import ( + "strings" + "testing" +) + +func TestGetPagination(t *testing.T) { + page, perPage := GetPagination(map[string]any{"page": float64(2), "per_page": float64(40)}, 30) + if page != 2 || perPage != 40 { + t.Errorf("GetPagination = (%d, %d), want (2, 40)", page, perPage) + } + page, perPage = GetPagination(map[string]any{}, 30) + if page != 1 || perPage != 30 { + t.Errorf("GetPagination defaults = (%d, %d), want (1, 30)", page, perPage) + } +} + +func TestToInt64(t *testing.T) { + tests := []struct { + name string + val any + want int64 + ok bool + }{ + {"float64", float64(42), 42, true}, + {"float64 zero", float64(0), 0, true}, + {"float64 negative", float64(-5), -5, true}, + {"string", "123", 123, true}, + {"string zero", "0", 0, true}, + {"string negative", "-10", -10, true}, + {"invalid string", "abc", 0, false}, + {"decimal string", "1.5", 0, false}, + {"bool", true, 0, false}, + {"nil", nil, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ToInt64(tt.val) + if ok != tt.ok { + t.Errorf("ToInt64() ok = %v, want %v", ok, tt.ok) + } + if got != tt.want { + t.Errorf("ToInt64() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetOptionalInt(t *testing.T) { + tests := []struct { + name string + args map[string]any + key string + defaultVal int64 + want int64 + }{ + {"present float64", map[string]any{"page": float64(3)}, "page", 1, 3}, + {"present string", map[string]any{"page": "5"}, "page", 1, 5}, + {"missing key", map[string]any{}, "page", 1, 1}, + {"invalid string", map[string]any{"page": "abc"}, "page", 1, 1}, + {"invalid type", map[string]any{"page": true}, "page", 1, 1}, + {"zero value", map[string]any{"id": float64(0)}, "id", 99, 0}, + {"string zero", map[string]any{"id": "0"}, "id", 99, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetOptionalInt(tt.args, tt.key, tt.defaultVal) + if got != tt.want { + t.Errorf("GetOptionalInt() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetOptionalStringPtr(t *testing.T) { + if p := GetOptionalStringPtr(map[string]any{}, "k"); p != nil { + t.Errorf("missing key: got %v, want nil", p) + } + if p := GetOptionalStringPtr(map[string]any{"k": ""}, "k"); p != nil { + t.Errorf("empty string: got %v, want nil", p) + } + if p := GetOptionalStringPtr(map[string]any{"k": 42}, "k"); p != nil { + t.Errorf("non-string: got %v, want nil", p) + } + if p := GetOptionalStringPtr(map[string]any{"k": nil}, "k"); p != nil { + t.Errorf("nil value (JSON null): got %v, want nil", p) + } + if p := GetOptionalStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" { + t.Errorf("non-empty: got %v, want &\"x\"", p) + } +} + +func TestGetPresentStringPtr(t *testing.T) { + if p := GetPresentStringPtr(map[string]any{}, "k"); p != nil { + t.Errorf("missing key: got %v, want nil", p) + } + if p := GetPresentStringPtr(map[string]any{"k": 42}, "k"); p != nil { + t.Errorf("non-string: got %v, want nil", p) + } + if p := GetPresentStringPtr(map[string]any{"k": nil}, "k"); p != nil { + t.Errorf("nil value (JSON null): got %v, want nil", p) + } + if p := GetPresentStringPtr(map[string]any{"k": ""}, "k"); p == nil || *p != "" { + t.Errorf("empty string: got %v, want &\"\"", p) + } + if p := GetPresentStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" { + t.Errorf("non-empty: got %v, want &\"x\"", p) + } +} + +func TestGetIndex(t *testing.T) { + tests := []struct { + name string + args map[string]any + key string + wantIndex int64 + wantErr bool + errMsg string + }{ + { + name: "valid float64", + args: map[string]any{"index": float64(123)}, + key: "index", + wantIndex: 123, + wantErr: false, + }, + { + name: "valid string", + args: map[string]any{"index": "456"}, + key: "index", + wantIndex: 456, + wantErr: false, + }, + { + name: "valid string with large number", + args: map[string]any{"index": "999999"}, + key: "index", + wantIndex: 999999, + wantErr: false, + }, + { + name: "missing parameter", + args: map[string]any{}, + key: "index", + wantErr: true, + errMsg: "index is required", + }, + { + name: "invalid string (not a number)", + args: map[string]any{"index": "abc"}, + key: "index", + wantErr: true, + errMsg: "must be a valid integer", + }, + { + name: "invalid string (decimal)", + args: map[string]any{"index": "12.34"}, + key: "index", + wantErr: true, + errMsg: "must be a valid integer", + }, + { + name: "invalid type (bool)", + args: map[string]any{"index": true}, + key: "index", + wantErr: true, + errMsg: "must be a number or numeric string", + }, + { + name: "invalid type (map)", + args: map[string]any{"index": map[string]string{"foo": "bar"}}, + key: "index", + wantErr: true, + errMsg: "must be a number or numeric string", + }, + { + name: "custom key name", + args: map[string]any{"pr_index": "789"}, + key: "pr_index", + wantIndex: 789, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIndex, err := GetIndex(tt.args, tt.key) + if tt.wantErr { + if err == nil { + t.Errorf("GetIndex() expected error but got nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("GetIndex() error = %v, want error containing %q", err, tt.errMsg) + } + return + } + if err != nil { + t.Errorf("GetIndex() unexpected error = %v", err) + return + } + if gotIndex != tt.wantIndex { + t.Errorf("GetIndex() = %v, want %v", gotIndex, tt.wantIndex) + } + }) + } +} diff --git a/pkg/slim/slim.go b/pkg/slim/slim.go new file mode 100644 index 0000000..c7dd4ff --- /dev/null +++ b/pkg/slim/slim.go @@ -0,0 +1,135 @@ +package slim + +import ( + "fmt" + "strings" + + gitea_sdk "gitea.dev/sdk" +) + +func UserLogin(u *gitea_sdk.User) string { + if u == nil { + return "" + } + return u.UserName +} + +func UserLogins(users []*gitea_sdk.User) []string { + if len(users) == 0 { + return nil + } + out := make([]string, 0, len(users)) + for _, u := range users { + if u != nil { + out = append(out, u.UserName) + } + } + return out +} + +func LabelNames(labels []*gitea_sdk.Label) []string { + if len(labels) == 0 { + return nil + } + out := make([]string, 0, len(labels)) + for _, l := range labels { + if l != nil { + out = append(out, l.Name) + } + } + return out +} + +func BodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string { + links := make([]string, 0, len(atts)) + for _, a := range atts { + if a == nil || a.DownloadURL == "" { + continue + } + links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL)) + } + if len(links) == 0 { + return body + } + joined := strings.Join(links, "\n") + if body == "" { + return joined + } + return body + "\n\n" + joined +} + +func UserDetail(u *gitea_sdk.User) map[string]any { + if u == nil { + return nil + } + return map[string]any{ + "id": u.ID, + "login": u.UserName, + "full_name": u.FullName, + "email": u.Email, + "avatar_url": u.AvatarURL, + "html_url": u.HTMLURL, + "is_admin": u.IsAdmin, + } +} + +func Repo(r *gitea_sdk.Repository) map[string]any { + if r == nil { + return nil + } + m := map[string]any{ + "id": r.ID, + "full_name": r.FullName, + "description": r.Description, + "html_url": r.HTMLURL, + "clone_url": r.CloneURL, + "ssh_url": r.SSHURL, + "default_branch": r.DefaultBranch, + "private": r.Private, + "fork": r.Fork, + "archived": r.Archived, + "language": r.Language, + "stars_count": r.Stars, + "forks_count": r.Forks, + "open_issues_count": r.OpenIssues, + "open_pr_counter": r.OpenPulls, + "created_at": r.Created, + "updated_at": r.Updated, + } + if r.Owner != nil { + m["owner"] = r.Owner.UserName + } + if len(r.Topics) > 0 { + m["topics"] = r.Topics + } + return m +} + +func Repos(repos []*gitea_sdk.Repository) []map[string]any { + out := make([]map[string]any, 0, len(repos)) + for _, r := range repos { + out = append(out, Repo(r)) + } + return out +} + +func Label(l *gitea_sdk.Label) map[string]any { + if l == nil { + return nil + } + return map[string]any{ + "id": l.ID, + "name": l.Name, + "color": l.Color, + "description": l.Description, + "exclusive": l.Exclusive, + } +} + +func Labels(labels []*gitea_sdk.Label) []map[string]any { + out := make([]map[string]any, 0, len(labels)) + for _, l := range labels { + out = append(out, Label(l)) + } + return out +} diff --git a/pkg/slim/slim_test.go b/pkg/slim/slim_test.go new file mode 100644 index 0000000..4548edf --- /dev/null +++ b/pkg/slim/slim_test.go @@ -0,0 +1,110 @@ +package slim + +import ( + "testing" + + gitea_sdk "gitea.dev/sdk" +) + +func TestUserDetail(t *testing.T) { + u := &gitea_sdk.User{ + ID: 42, + UserName: "alice", + FullName: "Alice Smith", + Email: "alice@example.com", + AvatarURL: "https://gitea.com/avatars/42", + HTMLURL: "https://gitea.com/alice", + IsAdmin: true, + } + m := UserDetail(u) + + if m["id"] != int64(42) { + t.Errorf("expected id 42, got %v", m["id"]) + } + if m["login"] != "alice" { + t.Errorf("expected login alice, got %v", m["login"]) + } + if m["full_name"] != "Alice Smith" { + t.Errorf("expected full_name Alice Smith, got %v", m["full_name"]) + } + if m["is_admin"] != true { + t.Errorf("expected is_admin true, got %v", m["is_admin"]) + } +} + +func TestUserDetail_Nil(t *testing.T) { + if m := UserDetail(nil); m != nil { + t.Errorf("expected nil for nil user, got %v", m) + } +} + +func TestLabel(t *testing.T) { + l := &gitea_sdk.Label{ + ID: 1, + Name: "bug", + Color: "#d73a4a", + Description: "Something isn't working", + Exclusive: false, + } + + m := Label(l) + if m["name"] != "bug" { + t.Errorf("expected name bug, got %v", m["name"]) + } + if m["color"] != "#d73a4a" { + t.Errorf("expected color, got %v", m["color"]) + } +} + +func TestRepo(t *testing.T) { + r := &gitea_sdk.Repository{ + ID: 1, + FullName: "org/repo", + Description: "A test repo", + HTMLURL: "https://gitea.com/org/repo", + CloneURL: "https://gitea.com/org/repo.git", + SSHURL: "git@gitea.com:org/repo.git", + DefaultBranch: "main", + Language: "Go", + Stars: 10, + Forks: 2, + Owner: &gitea_sdk.User{UserName: "org"}, + Topics: []string{"mcp", "gitea"}, + } + + m := Repo(r) + + if m["full_name"] != "org/repo" { + t.Errorf("expected full_name org/repo, got %v", m["full_name"]) + } + if m["owner"] != "org" { + t.Errorf("expected owner org, got %v", m["owner"]) + } + topics := m["topics"].([]string) + if len(topics) != 2 { + t.Errorf("expected 2 topics, got %d", len(topics)) + } +} + +func TestBodyWithAttachments(t *testing.T) { + atts := []*gitea_sdk.Attachment{ + {Name: "shot.png", DownloadURL: "https://example/shot.png"}, + {Name: "log.txt", DownloadURL: "https://example/log.txt"}, + } + got := BodyWithAttachments("see attached", atts) + want := "see attached\n\n[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + if got := BodyWithAttachments("only body", nil); got != "only body" { + t.Errorf("nil attachments should return body unchanged, got %q", got) + } + if got := BodyWithAttachments("", atts); got != "[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" { + t.Errorf("empty body should drop separator, got %q", got) + } + skipped := []*gitea_sdk.Attachment{nil, {Name: "noop", DownloadURL: ""}} + if got := BodyWithAttachments("body", skipped); got != "body" { + t.Errorf("nil/empty-URL attachments should be skipped, got %q", got) + } +} diff --git a/pkg/to/to.go b/pkg/to/to.go new file mode 100644 index 0000000..b56dd23 --- /dev/null +++ b/pkg/to/to.go @@ -0,0 +1,27 @@ +package to + +import ( + "encoding/json" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/log" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TextResult(v any) (*mcp.CallToolResult, error) { + resultBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal result err: %v", err) + } + if flag.Debug { + log.Debugf("Text Result: %s", string(resultBytes)) + } + return mcp.NewToolResultText(string(resultBytes)), nil +} + +func ErrorResult(err error) (*mcp.CallToolResult, error) { + log.Errorf("%s", err.Error()) + return nil, err +} diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go new file mode 100644 index 0000000..84cc474 --- /dev/null +++ b/pkg/tool/tool.go @@ -0,0 +1,78 @@ +package tool + +import ( + "slices" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/log" + + "github.com/mark3labs/mcp-go/server" +) + +type Tool struct { + write []server.ServerTool + read []server.ServerTool +} + +func New() *Tool { + return &Tool{ + write: make([]server.ServerTool, 0, 100), + read: make([]server.ServerTool, 0, 100), + } +} + +func (t *Tool) RegisterWrite(s server.ServerTool) { + t.write = append(t.write, s) +} + +func (t *Tool) RegisterRead(s server.ServerTool) { + t.read = append(t.read, s) +} + +func (t *Tool) Tools() []server.ServerTool { + all := make([]server.ServerTool, 0, len(t.write)+len(t.read)) + if !flag.ReadOnly { + all = append(all, t.write...) + } + all = append(all, t.read...) + if len(flag.AllowedTools) == 0 { + return all + } + filtered := make([]server.ServerTool, 0, len(all)) + for _, st := range all { + if _, ok := flag.AllowedTools[st.Tool.Name]; ok { + filtered = append(filtered, st) + } + } + return filtered +} + +// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't +// match a tool registered on any of the given domains. No-op if the allowlist +// is empty. +func WarnUnmatchedAllowedTools(domains ...*Tool) { + if len(flag.AllowedTools) == 0 { + return + } + known := map[string]struct{}{} + for _, d := range domains { + for _, st := range d.read { + known[st.Tool.Name] = struct{}{} + } + for _, st := range d.write { + known[st.Tool.Name] = struct{}{} + } + } + var unmatched []string + for name := range flag.AllowedTools { + if _, ok := known[name]; !ok { + unmatched = append(unmatched, name) + } + } + if len(unmatched) == 0 { + return + } + slices.Sort(unmatched) + log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", ")) +} diff --git a/pkg/tool/tool_test.go b/pkg/tool/tool_test.go new file mode 100644 index 0000000..7b62857 --- /dev/null +++ b/pkg/tool/tool_test.go @@ -0,0 +1,100 @@ +package tool + +import ( + "slices" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func makeTool(name string) server.ServerTool { + return server.ServerTool{Tool: mcp.NewTool(name)} +} + +func names(sts []server.ServerTool) []string { + out := make([]string, len(sts)) + for i, st := range sts { + out[i] = st.Tool.Name + } + return out +} + +func TestTools(t *testing.T) { + tests := []struct { + name string + readOnly bool + allowed map[string]struct{} + read []string + write []string + want []string + }{ + { + name: "no filters returns write then read", + read: []string{"r1", "r2"}, + write: []string{"w1", "w2"}, + want: []string{"w1", "w2", "r1", "r2"}, + }, + { + name: "read-only excludes write", + readOnly: true, + read: []string{"r1", "r2"}, + write: []string{"w1"}, + want: []string{"r1", "r2"}, + }, + { + name: "allowlist keeps only listed", + allowed: map[string]struct{}{"r1": {}, "w1": {}}, + read: []string{"r1", "r2"}, + write: []string{"w1", "w2"}, + want: []string{"w1", "r1"}, + }, + { + name: "allowlist intersected with read-only drops write entries", + readOnly: true, + allowed: map[string]struct{}{"r1": {}, "w1": {}}, + read: []string{"r1", "r2"}, + write: []string{"w1", "w2"}, + want: []string{"r1"}, + }, + { + name: "allowlist with only unknown names returns empty", + allowed: map[string]struct{}{"unknown": {}}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{}, + }, + { + name: "empty allowlist map passes through", + allowed: map[string]struct{}{}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{"w1", "r1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origRO, origAllow := flag.ReadOnly, flag.AllowedTools + t.Cleanup(func() { + flag.ReadOnly, flag.AllowedTools = origRO, origAllow + }) + flag.ReadOnly = tt.readOnly + flag.AllowedTools = tt.allowed + + tr := New() + for _, n := range tt.read { + tr.RegisterRead(makeTool(n)) + } + for _, n := range tt.write { + tr.RegisterWrite(makeTool(n)) + } + + got := names(tr.Tools()) + if !slices.Equal(got, tt.want) { + t.Errorf("Tools() = %v, want %v", got, tt.want) + } + }) + } +}