commit 440547e9c1c594d99013059eb96c16dfc843cf47 Author: Ivan Loginov Date: Fri Jun 5 14:58:55 2026 +0300 first commit 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) + } + }) + } +}