Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+52
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Gitea MCP DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.24-bookworm",
"features": {},
"customizations": {
"vscode": {
"settings": {},
"extensions": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"golang.go",
"stylelint.vscode-stylelint",
"DavidAnson.vscode-markdownlint",
"github.copilot",
"eamodio.gitlens"
]
}
}
}
+61
View File
@@ -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
+51
View File
@@ -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 }}
+70
View File
@@ -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 }}
+19
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.idea
gitea-mcp
gitea-mcp.exe
*.log
tmp
+113
View File
@@ -0,0 +1,113 @@
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
disabled: 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
rules:
- linters:
- errcheck
- staticcheck
- unparam
path: _test\.go
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
settings:
gofumpt:
extra-rules: true
exclusions:
generated: lax
run:
timeout: 10m
+39
View File
@@ -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",
}
}
}
+230
View File
@@ -0,0 +1,230 @@
# AGENTS.md
This file provides guidance to AI coding agents 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 40+ MCP tools covering:
- **User**: get_my_user_info, get_user_orgs, search_users
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
- **Releases**: create_release, list_releases, get_latest_release
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
- **Search**: search_repos, search_users, search_org_teams
- **Version**: get_gitea_mcp_server_version
## Error Handling and Logging
The codebase provides comprehensive error handling and structured logging with context support.
### Enhanced Error Handling
The `pkg/errors` package provides enhanced error handling with context and fluent API:
```go
import "gitea.com/gitea/gitea-mcp/pkg/errors"
// Basic error translation
err := someGiteaOperation()
if err != nil {
return errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": owner,
"repo": repo,
"path": path,
})
}
// Using fluent API for building error context
err := someGiteaOperation()
if err != nil {
return errors.TranslateError(err, nil).
WithOperation("GetFile").
WithParam("owner", owner).
WithParam("repo", repo).
WithParam("path", path)
}
// Error includes automatic timestamp
enhanced := err.(*errors.EnhancedError)
fmt.Printf("Error occurred at: %v\n", enhanced.Timestamp)
// Format error for logging (human-readable)
fmt.Println(enhanced.Format())
// Output: Operation: GetFile | Error: File or directory not found | Category: file | Context: owner=gitea, path=README.md | Original: GetContents failed
// Format error for structured logging (JSON-like)
fmt.Println(enhanced.FormatDetailed())
// Output:
// {
// "error": "File or directory not found",
// "category": "file",
// "operation": "GetFile",
// "timestamp": "2024-01-15T10:30:00Z",
// "context": {
// "owner": "gitea",
// "path": "README.md"
// },
// "original": "GetContents failed with status 404"
// }
```
### Error Category Checking
```go
// Check error categories
if errors.IsNotFound(err) {
// Handle not found (404, "not found" messages)
}
if errors.IsAuthError(err) {
// Handle auth errors (401, 403)
}
if errors.IsTimeout(err) {
// Handle timeout errors
}
if errors.IsNetworkError(err) {
// Handle network connectivity issues
}
// Check specific HTTP status codes
if errors.IsUnauthorized(err) {
// Handle 401
}
if errors.IsForbidden(err) {
// Handle 403
}
if errors.IsServerError(err) {
// Handle 5xx errors
}
```
### Structured Logging with Context
The `pkg/log` package provides request-scoped structured logging with correlation IDs:
```go
import (
"context"
"gitea.com/gitea/gitea-mcp/pkg/log"
"go.uber.org/zap"
)
// Create context with correlation ID for request tracing
ctx := log.WithCorrelationID(context.Background(), "req-12345")
// Add operation name to context
ctx = log.WithOperation(ctx, "GetFile")
// Create logger with context
logger := log.WithContext(ctx)
// Log messages - correlation_id and operation are automatically included
logger.Info("processing request")
logger.Error("operation failed", zap.Error(err))
// Log with additional fields
logger.Info("file retrieved",
zap.String("owner", owner),
zap.String("repo", repo),
zap.String("path", path),
)
// Operation logging with timing
op := log.StartOperation(ctx, "CreatePullRequest")
op.Start("beginning pull request creation")
// ... do work ...
op.Success("pull request created successfully")
// Or on failure:
op.Failure("failed to create pull request", err)
```
### REST API Logging
The `pkg/gitea/rest.go` automatically logs all API requests with context:
```go
import (
"context"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
)
// Create context with operation name for tracing
ctx := log.WithOperation(context.Background(), "GetRepository")
// All API calls are automatically logged with:
// - operation name
// - HTTP method
// - request path (no sensitive data)
// - response status code
// - duration
// - correlation ID
status, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, nil, &repo)
// Logs will include:
// - Debug: "sending API request" with operation, method, path, correlation_id
// - Debug: "API request completed" with status_code and duration on success
// - Error: "API request returned error status" with details on failure
```
### 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
+63
View File
@@ -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.
+78
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
# 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
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/gitea-mcp .
USER nonroot:nonroot
LABEL org.opencontainers.image.version="${VERSION}"
CMD ["/app/gitea-mcp"]
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Lumbridge Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+77
View File
@@ -0,0 +1,77 @@
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.10.1
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.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: ## Clean the 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: 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
+584
View File
@@ -0,0 +1,584 @@
# 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)
- [📥 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 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}"
}
}
}
}
}
```
### 📥 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", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**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
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 |
| check_gitea_version | Server | Check Gitea server version and API capabilities |
| get_workflow_file_content | Actions | Get workflow file content from .gitea/workflows/ or .github/workflows/ |
| list_repo_structure | Repository | List complete directory and file structure using Git tree API |
| monitor_workflow_dispatch | Actions | Dispatch and monitor workflow until completion (requires Gitea 1.23+) |
| create_commit_status | Repository | Create a commit status check for CI/CD integration |
| 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 |
| list_action_runners | Actions | List self-hosted action runners (requires Gitea 1.23+) |
| list_action_artifacts | Actions | List and download artifacts from workflow runs (requires Gitea 1.23+) |
| 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 |
## 🆕 Wave 2 Tools
The following tools were added in Wave 2 and provide enhanced functionality for repository management, workflow operations, and CI/CD integration.
### Server Tools
#### check_gitea_version
Check the Gitea server version and API capabilities. Returns version string, parsed components, and a capability matrix indicating which APIs are available.
**Example:**
```json
{
"version": "1.23.1",
"major": 1,
"minor": 23,
"patch": 1,
"capabilities": {
"actions_api": true,
"secrets_api": true,
"variables_api": true,
"runners_api": true,
"artifacts_api": true,
"commit_status_api": true
}
}
```
### Repository Tools
#### list_repo_structure
List the complete directory and file structure of a repository using the Git tree API. Supports recursive listing, pattern filtering, and pagination.
**Parameters:**
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `ref`: Git reference (branch, tag, or commit SHA). Defaults to default branch
- `pattern`: Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')
- `recursive`: List contents recursively (default: true)
- `page`: Page number for pagination (default: 1)
- `per_page`: Items per page (default: 100, max: 1000)
**Example:**
```bash
# List all workflow files
list_repo_structure owner="gitea" repo="gitea-mcp" pattern=".gitea/workflows/*"
# List Go source files recursively
list_repo_structure owner="gitea" repo="gitea-mcp" pattern="**/*.go" recursive=true
```
#### create_commit_status
Create a commit status check for CI/CD integration. Adds a new status context to a commit without overwriting existing statuses.
**Parameters:**
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `sha` (required): Commit SHA (full 40-character or short SHA)
- `state` (required): Status state (pending, success, error, failure)
- `target_url`: URL with more details (e.g., review environment link)
- `context`: Status context identifier (default: "default")
- `description`: Short description of the status
**Example:**
```bash
# Set CI status to success
create_commit_status owner="gitea" repo="my-project" sha="abc123..." state="success" context="ci/build" description="Build passed"
# Mark deployment as pending
create_commit_status owner="gitea" repo="my-project" sha="abc123..." state="pending" context="deploy/review" target_url="https://review.example.com"
```
### Actions Tools
#### get_workflow_file_content
Get workflow file content from `.gitea/workflows/` or `.github/workflows/` directories. Auto-discovers workflow files and returns parsed YAML as JSON.
**Parameters:**
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `ref`: Git reference (branch/tag/commit). Defaults to default branch
- `pattern`: File pattern to match (e.g., '*.yml', 'build-*.yml')
- `filename`: Specific workflow filename to retrieve (ignores pattern if provided)
**Example:**
```bash
# Get all workflow files
get_workflow_file_content owner="gitea" repo="gitea-mcp"
# Get specific workflow file
get_workflow_file_content owner="gitea" repo="gitea-mcp" filename="build.yml"
# Get workflow files matching pattern
get_workflow_file_content owner="gitea" repo="gitea-mcp" pattern="test-*.yml"
```
#### monitor_workflow_dispatch
Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs.
**Note:** Requires Gitea 1.23+. Not available in Gitea 1.22.5.
**Parameters:**
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `workflow_id` (required): Workflow ID or filename
- `ref` (required): Git reference (branch/tag) to run workflow on
- `inputs`: Workflow inputs object
- `timeout_seconds`: Polling timeout in seconds (default: 300 = 5 minutes)
- `poll_interval_seconds`: Poll interval in seconds (default: 10)
**Example:**
```bash
# Dispatch and monitor a workflow
monitor_workflow_dispatch owner="gitea" repo="my-project" workflow_id="build.yml" ref="main"
# Dispatch with inputs
monitor_workflow_dispatch owner="gitea" repo="my-project" workflow_id="deploy.yml" ref="main" inputs='{"environment": "staging"}'
# Custom timeout and poll interval
monitor_workflow_dispatch owner="gitea" repo="my-project" workflow_id="build.yml" ref="main" timeout_seconds=600 poll_interval_seconds=30
```
#### list_action_runners
List self-hosted action runners for a repository. Shows runner status, labels, and availability.
**Note:** Requires Gitea 1.23+. Returns empty list with message on Gitea 1.22.5.
**Parameters:**
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `status`: Filter by status (online, offline, busy, idle)
- `page`: Page number (default: 1)
- `perPage`: Results per page (default: 30)
**Example:**
```bash
# List all runners
list_action_runners owner="gitea" repo="my-project"
# List only online runners
list_action_runners owner="gitea" repo="my-project" status="online"
# List busy runners
list_action_runners owner="gitea" repo="my-project" status="busy"
```
#### list_action_artifacts
List and download artifacts from workflow runs. Supports listing artifacts, getting specific artifact details, and downloading artifact content.
**Note:** Requires Gitea 1.23+. Returns empty list with message on Gitea 1.22.5.
**Methods:** `list`, `get`, `download`
**Parameters:**
- `method` (required): Operation to perform (list, get, download)
- `owner` (required): Repository owner
- `repo` (required): Repository name
- `run_id`: Run ID to filter artifacts (optional for list, required for get/download)
- `artifact_name`: Artifact name to filter (optional)
- `artifact_id`: Artifact ID (required for get and download methods)
- `output_path`: Output file path for download method
- `max_size`: Maximum artifact size in bytes (default: 100MB)
- `page`: Page number (default: 1)
- `perPage`: Results per page (default: 30)
**Example:**
```bash
# List all artifacts for a run
list_action_artifacts method="list" owner="gitea" repo="my-project" run_id=123
# Get specific artifact details
list_action_artifacts method="get" owner="gitea" repo="my-project" artifact_id=456
# Download artifact
list_action_artifacts method="download" owner="gitea" repo="my-project" artifact_id=456 output_path="./build-artifact.zip"
```
## 🔧 Gitea Version Compatibility
Gitea MCP Server supports different feature sets depending on your Gitea server version. Use the `check_gitea_version` tool to detect available APIs.
### Gitea 1.22.5
The following features are **NOT available** in Gitea 1.22.5:
| Feature | Status | Notes |
|---------|--------|-------|
| Actions API | Limited | Basic workflow support only |
| Artifacts API | Not available | Returns empty list with message |
| Runners API | Not available | Returns empty list with message |
| Workflow monitoring | Not available | Use basic dispatch only |
**Compatible tools:**
- `dispatch_repo_action_workflow` (basic dispatch without monitoring)
- `list_repo_action_runs` (limited support)
- `get_repo_action_run`
- `cancel_repo_action_run`
- `rerun_repo_action_run`
### Gitea 1.23+
Full Actions API support including:
| Feature | Status | Notes |
|---------|--------|-------|
| Actions API | Full | Complete workflow support |
| Artifacts API | Full | List, get, and download artifacts |
| Runners API | Full | List and manage self-hosted runners |
| Workflow monitoring | Full | Dispatch with monitoring until completion |
| Secrets API | Full | Repository and organization secrets |
| Variables API | Full | Repository and organization variables |
**All Wave 2 tools are fully supported on Gitea 1.23+.**
### Version Detection
Always check your Gitea version before using Actions-related tools:
```bash
check_gitea_version
```
The response includes a `capabilities` object that indicates which APIs are available.
## 🛡️ Enhanced Error Handling
Wave 1 introduced comprehensive error handling improvements that provide clearer, more actionable error messages.
### Error Categories
Errors are now categorized for better handling:
| Category | Description | Example |
|----------|-------------|---------|
| `auth` | Authentication/authorization errors | Invalid token, insufficient permissions |
| `not_found` | Resource not found | Repository, file, or issue doesn't exist |
| `network` | Network connectivity issues | Connection timeout, DNS failures |
| `timeout` | Operation timeouts | Request took too long |
| `server` | Server-side errors | 5xx errors from Gitea |
| `validation` | Input validation errors | Invalid parameters, missing required fields |
| `file` | File operation errors | File not found, path issues |
| `actions` | Actions API errors | Workflow not found, API unavailable |
### Error Context
All errors now include context information to help with debugging:
```json
{
"error": "File or directory not found",
"category": "file",
"operation": "GetFile",
"timestamp": "2024-01-15T10:30:00Z",
"context": {
"owner": "gitea",
"path": "README.md"
}
}
```
### Helper Functions
The enhanced error system provides helper functions for checking error types:
- `IsNotFound(err)` - Check if error is a 404/not found
- `IsAuthError(err)` - Check if error is auth-related (401/403)
- `IsTimeout(err)` - Check if error is a timeout
- `IsNetworkError(err)` - Check if error is network-related
- `IsUnauthorized(err)` - Check for 401 specifically
- `IsForbidden(err)` - Check for 403 specifically
- `IsServerError(err)` - Check for 5xx errors
- `IsActionsAPIUnavailable(err)` - Check if Actions API is unavailable
### Error Formatting
Errors can be formatted for different purposes:
```go
// Human-readable format
enhancedErr.Format()
// Output: "Operation: GetFile | Error: File not found | Category: file | Context: owner=gitea, path=README.md"
// Detailed JSON-like format
enhancedErr.FormatDetailed()
// Output: Full JSON with all error details
```
## 🐛 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 <your personal access 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!
+256
View File
@@ -0,0 +1,256 @@
# 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", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http 模式**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**默认日志路径**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!注意]
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
> 命令行参数优先。
一切设置完成后,可在 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 <your personal access token> -d
```
## 🛠 疑难排解
如遇问题,可参考以下步骤:
1. **检查 PATH**:确保 `gitea-mcp` 可执行文件已在系统 PATH 目录中。
2. **验证依赖**:确认已安装 `make``Golang` 等必要依赖。
3. **检查配置**:仔细检查 MCP 配置文件是否有错误或遗漏。
4. **查看日志**:检查日志消息或警告以获取更多信息。
享受通过聊天探索和管理您的 Gitea 仓库!
+256
View File
@@ -0,0 +1,256 @@
# 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", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http 模式**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**預設日誌路徑**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!注意]
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
> 命令列參數優先。
一切設定完成後,可在 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 <your personal access token> -d
```
## 🛠 疑難排解
如遇問題,可參考以下步驟:
1. **檢查 PATH**:確保 `gitea-mcp` 執行檔已在系統 PATH 目錄中。
2. **驗證依賴**:確認已安裝 `make``Golang` 等必要依賴。
3. **檢查設定**:仔細檢查 MCP 設定檔是否有錯誤或遺漏。
4. **查看日誌**:檢查日誌訊息或警告以獲取更多資訊。
享受透過聊天探索與管理您的 Gitea 倉庫!
+2
View File
@@ -0,0 +1,2 @@
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
+220
View File
@@ -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
}
}
+109
View File
@@ -0,0 +1,109 @@
package cmd
import (
"context"
"flag"
"fmt"
"os"
"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
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, "")
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 <type>\tTransport type: stdio or http (default: stdio)\n")
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n")
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\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_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, " 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 os.Getenv("MCP_MODE") != "" {
flagPkg.Mode = os.Getenv("MCP_MODE")
}
if os.Getenv("GITEA_READONLY") == "true" {
flagPkg.ReadOnly = true
}
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
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t", "stdio",
"--host", "https://gitea.com",
"--token", "<your personal access token>"
]
"env": {
"GITEA_HOST": "https://gitea.com",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
BIN
View File
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
module gitea.com/gitea/gitea-mcp
go 1.26.0
require (
code.gitea.io/sdk/gitea v0.23.2
github.com/mark3labs/mcp-go v0.44.0
go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/42wim/httpsig v1.2.3 // 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.8.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.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+74
View File
@@ -0,0 +1,74 @@
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
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.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.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.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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=
+23
View File
@@ -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()
}
+119
View File
@@ -0,0 +1,119 @@
package accesstoken
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CreateAccessTokenToolName = "create_access_token"
DeleteAccessTokenToolName = "delete_access_token"
)
var Tool = tool.New()
var (
CreateAccessTokenTool = mcp.NewTool(
CreateAccessTokenToolName,
mcp.WithDescription("Create a new personal access token"),
mcp.WithString("name", mcp.Required(), mcp.Description("Token name/description")),
mcp.WithArray("scopes", mcp.Description("Array of permission scopes"), mcp.Items(map[string]any{"type": "string"})),
)
DeleteAccessTokenTool = mcp.NewTool(
DeleteAccessTokenToolName,
mcp.WithDescription("Delete a personal access token"),
mcp.WithString("token", mcp.Required(), mcp.Description("The token value or name to delete")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateAccessTokenTool,
Handler: createAccessTokenFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteAccessTokenTool,
Handler: deleteAccessTokenFn,
})
}
func createAccessTokenFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[AccessToken] Called createAccessTokenFn")
args := req.GetArguments()
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
scopesRaw := params.GetStringSlice(args, "scopes")
var scopes []gitea_sdk.AccessTokenScope
if scopesRaw == nil {
scopes = []gitea_sdk.AccessTokenScope{"repo", "user"}
} else {
scopes = make([]gitea_sdk.AccessTokenScope, len(scopesRaw))
for i, s := range scopesRaw {
scopes[i] = gitea_sdk.AccessTokenScope(s)
}
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.GetMyUserInfo()
if err != nil {
return to.ErrorResult(fmt.Errorf("get current user err: %v", err))
}
createOpt := gitea_sdk.CreateAccessTokenOption{
Name: name,
Scopes: scopes,
}
token, _, err := client.CreateAccessToken(createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create access token err: %v", err))
}
return to.TextResult(map[string]interface{}{
"name": token.Name,
"token": token.Token,
"id": token.ID,
"scopes": token.Scopes,
"created": token.Created,
"token_last_eight": token.TokenLastEight,
})
}
func deleteAccessTokenFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[AccessToken] Called deleteAccessTokenFn")
args := req.GetArguments()
token, err := params.GetString(args, "token")
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.DeleteAccessToken(token)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete access token err: %v", err))
}
return to.TextResult("Access token deleted successfully")
}
+8
View File
@@ -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()
+345
View File
@@ -0,0 +1,345 @@
package actions
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
DefaultMaxArtifactSize = 100 * 1024 * 1024
ActionsArtifactToolName = "list_action_artifacts"
)
var (
ActionsArtifactTool = mcp.NewTool(
ActionsArtifactToolName,
mcp.WithDescription("List and download artifacts from workflow runs. Use method 'list' to list artifacts, 'get' to get a specific artifact, 'download' to download artifact content."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "download")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Description("run ID to filter artifacts (optional for list, required for get/download)")),
mcp.WithString("artifact_name", mcp.Description("artifact name to filter (optional)")),
mcp.WithNumber("artifact_id", mcp.Description("artifact ID (required for 'get' and 'download' methods)")),
mcp.WithString("output_path", mcp.Description("output file path (for 'download' method). If not specified, saves to ~/.gitea-mcp/artifacts/")),
mcp.WithNumber("max_size", mcp.Description("maximum artifact size in bytes to download (default 100MB)"), mcp.DefaultNumber(DefaultMaxArtifactSize), mcp.Min(1024)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsArtifactTool, Handler: artifactHandler})
}
func artifactHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"param": "method",
}))
}
switch method {
case "list":
return listActionArtifactsFn(ctx, req)
case "get":
return getActionArtifactFn(ctx, req)
case "download":
return downloadActionArtifactFn(ctx, req)
default:
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("unknown method: %s", method),
"Invalid method. Use 'list', 'get', or 'download'",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
}
func listActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionArtifactsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
artifactName, _ := req.GetArguments()["artifact_name"].(string)
var runID int64
if runIDVal, exists := req.GetArguments()["run_id"]; exists {
if runIDFloat, ok := runIDVal.(float64); ok {
runID = int64(runIDFloat)
}
}
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if artifactName != "" {
query.Set("name", artifactName)
}
if runID > 0 {
query.Set("run_id", strconv.FormatInt(runID, 10))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
if errors.IsActionsAPIUnavailable(err) {
return to.TextResult(map[string]any{
"artifacts": []any{},
"total_count": 0,
"message": "Actions API not available on this Gitea version",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"owner": owner,
"repo": repo,
}))
}
return to.TextResult(slimActionArtifacts(result))
}
func getActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
return to.TextResult(slimActionArtifact(result))
}
func downloadActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
maxSize := int64(params.GetOptionalInt(req.GetArguments(), "max_size", DefaultMaxArtifactSize))
outputPath, _ := req.GetArguments()["output_path"].(string)
var artifactInfo any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &artifactInfo,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
var artifactSize int64
if info, ok := artifactInfo.(map[string]any); ok {
if size, ok := info["size_in_bytes"].(float64); ok {
artifactSize = int64(size)
}
}
if artifactSize > maxSize {
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("artifact size %d exceeds maximum allowed size %d", artifactSize, maxSize),
fmt.Sprintf("Artifact size (%s) exceeds maximum allowed size (%s). Use max_size parameter to increase limit.",
formatBytes(artifactSize), formatBytes(maxSize)),
errors.CategoryActions,
).WithOperation("DownloadActionArtifact").
WithParam("owner", owner).
WithParam("repo", repo).
WithParam("artifact_id", strconv.FormatInt(artifactID, 10)))
}
artifactBytes, _, err := gitea.DoBytes(ctx, "GET",
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d/download", url.PathEscape(owner), url.PathEscape(repo), artifactID),
nil, nil, "application/zip",
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
if artifactName == "" {
artifactName = fmt.Sprintf("artifact-%d", artifactID)
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", owner, repo, fmt.Sprintf("%s.zip", artifactName))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "create_output_dir",
}))
}
if err := os.WriteFile(outputPath, artifactBytes, 0o600); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "write_file",
}))
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
return to.TextResult(map[string]any{
"artifact_id": artifactID,
"name": artifactName,
"path": outputPath,
"size_in_bytes": len(artifactBytes),
"message": "artifact downloaded successfully",
})
}
func slimActionArtifact(raw any) any {
if m, ok := raw.(map[string]any); ok {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
}
return raw
}
func slimActionArtifacts(raw any) any {
return slimPaginated(raw, func(m map[string]any) map[string]any {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
})
}
func formatBytes(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/GB)
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/MB)
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/KB)
default:
return fmt.Sprintf("%d B", bytes)
}
}
+415
View File
@@ -0,0 +1,415 @@
package actions
import (
"testing"
)
func TestSlimActionArtifact(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "complete artifact",
input: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
"extra_field": "should be removed",
},
expected: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
},
},
{
name: "minimal artifact",
input: map[string]any{
"id": float64(789),
"name": "test-results",
},
expected: map[string]any{
"id": float64(789),
"name": "test-results",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifact() did not return a map")
}
for key, expectedValue := range tt.expected {
if resultMap[key] != expectedValue {
t.Fatalf("slimActionArtifact()[%q] = %v, want %v", key, resultMap[key], expectedValue)
}
}
if _, exists := resultMap["extra_field"]; exists {
t.Fatalf("slimActionArtifact() should not include 'extra_field'")
}
})
}
}
func TestSlimActionArtifactNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifact() = %v, want %v", result, tt.input)
}
})
}
}
func TestSlimActionArtifacts(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "artifacts with total count",
input: map[string]any{
"total_count": float64(2),
"artifacts": []any{
map[string]any{
"id": float64(1),
"name": "artifact-1",
"size_in_bytes": float64(1000),
"download_url": "url1",
},
map[string]any{
"id": float64(2),
"name": "artifact-2",
"size_in_bytes": float64(2000),
"download_url": "url2",
},
},
},
expected: map[string]any{
"total_count": float64(2),
},
},
{
name: "empty artifacts list",
input: map[string]any{
"total_count": float64(0),
"artifacts": []any{},
},
expected: map[string]any{
"total_count": float64(0),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifacts() did not return a map")
}
if resultMap["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", resultMap["total_count"], tt.expected["total_count"])
}
if artifacts, ok := resultMap["artifacts"].([]any); ok {
for _, item := range artifacts {
if artifact, ok := item.(map[string]any); ok {
if _, exists := artifact["id"]; !exists {
t.Fatalf("slimmed artifact should have 'id' field")
}
}
}
}
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1536 * 1024, "1.50 MB"},
{100 * 1024 * 1024, "100.00 MB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1536 * 1024 * 1024, "1.50 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestFormatBytes_EdgeCases(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{-1, "-1 B"},
{0, "0 B"},
{1, "1 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.00 KB"},
{1025, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1024*1024 + 1, "1.00 MB"},
{1536 * 1024 * 1024, "1.50 GB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1024 * 1024 * 1024 * 1024, "1024.00 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestSlimActionArtifact_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "int input",
input: 123,
check: func(t *testing.T, result any) {
if result != 123 {
t.Fatalf("expected 123, got %v", result)
}
},
},
{
name: "empty map",
input: map[string]any{},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map, got %d fields", len(resultMap))
}
},
},
{
name: "map with only extra fields",
input: map[string]any{
"extra1": "value1",
"extra2": "value2",
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map after filtering, got %d fields", len(resultMap))
}
},
},
{
name: "map with null values",
input: map[string]any{
"id": float64(123),
"name": nil,
"size_in_bytes": float64(1000),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["name"] != nil {
t.Fatalf("expected nil name, got %v", resultMap["name"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
tt.check(t, result)
})
}
}
func TestSlimActionArtifacts_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "map without artifacts key",
input: map[string]any{
"total_count": float64(0),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
{
name: "map with nil artifacts",
input: map[string]any{
"total_count": float64(0),
"artifacts": nil,
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
tt.check(t, result)
})
}
}
func TestDefaultMaxArtifactSize_Constant(t *testing.T) {
expectedSize := int64(100 * 1024 * 1024)
if DefaultMaxArtifactSize != expectedSize {
t.Fatalf("DefaultMaxArtifactSize = %d, want %d", DefaultMaxArtifactSize, expectedSize)
}
}
func TestActionsArtifactToolName_Constant(t *testing.T) {
expectedName := "list_action_artifacts"
if ActionsArtifactToolName != expectedName {
t.Fatalf("ActionsArtifactToolName = %q, want %q", ActionsArtifactToolName, expectedName)
}
}
func TestSlimActionArtifactsNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifacts() = %v, want %v", result, tt.input)
}
})
}
}
+555
View File
@@ -0,0 +1,555 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"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 configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), 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("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), 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("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
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))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
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) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
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.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
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) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(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) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
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.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
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) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
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.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
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) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
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"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo 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 = 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) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(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) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(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) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(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) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(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) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
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.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
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) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(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) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
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.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
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) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
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.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
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) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, 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"})
}
+22
View File
@@ -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")
}
}
+598
View File
@@ -0,0 +1,598 @@
package actions
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
MonitorWorkflowDispatchToolName = "monitor_workflow_dispatch"
DefaultPollInterval = 10 * time.Second
DefaultTimeout = 5 * time.Minute
)
var (
MonitorWorkflowDispatchTool = mcp.NewTool(
MonitorWorkflowDispatchToolName,
mcp.WithDescription("Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch/tag) to run workflow on")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
mcp.WithNumber("timeout_seconds", mcp.Description("polling timeout in seconds (default: 300 = 5 minutes)"), mcp.DefaultNumber(300), mcp.Min(10)),
mcp.WithNumber("poll_interval_seconds", mcp.Description("poll interval in seconds (default: 10)"), mcp.DefaultNumber(10), mcp.Min(5)),
)
)
type MonitorResult struct {
RunID int64 `json:"run_id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name,omitempty"`
Branch string `json:"branch"`
CommitSHA string `json:"commit_sha"`
Duration string `json:"duration"`
DurationSec float64 `json:"duration_seconds"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Jobs []JobSummary `json:"jobs"`
Logs map[string]JobLogs `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
TimedOut bool `json:"timed_out"`
}
type JobSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Steps []StepInfo `json:"steps,omitempty"`
}
type StepInfo struct {
Name string `json:"name"`
Number int `json:"number"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
}
type JobLogs struct {
JobID int64 `json:"job_id"`
JobName string `json:"job_name"`
Log string `json:"log,omitempty"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated"`
}
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: MonitorWorkflowDispatchTool,
Handler: monitorWorkflowDispatchFn,
})
}
func monitorWorkflowDispatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called monitorWorkflowDispatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("owner is required"),
map[string]string{"operation": "MonitorWorkflowDispatch"},
))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("repo is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner},
))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("workflow_id is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo},
))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("ref is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo, "workflow_id": workflowID},
))
}
timeoutSec := int(params.GetOptionalInt(req.GetArguments(), "timeout_seconds", 300))
pollIntervalSec := int(params.GetOptionalInt(req.GetArguments(), "poll_interval_seconds", 10))
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
if err := checkActionsAPIAvailable(ctx); err != nil {
return to.ErrorResult(err)
}
log.Infof("Dispatching workflow %s for %s/%s on ref %s", workflowID, owner, repo, ref)
_, err = dispatchWorkflow(ctx, owner, repo, workflowID, ref, inputs)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DispatchWorkflow",
"owner": owner,
"repo": repo,
"workflow": workflowID,
}))
}
timeout := time.Duration(timeoutSec) * time.Second
pollInterval := time.Duration(pollIntervalSec) * time.Second
log.Infof("Waiting for workflow run to start (timeout: %v)...", timeout)
runID, err := waitForRunToStart(ctx, owner, repo, workflowID, ref, pollInterval, timeout)
if err != nil {
result := MonitorResult{
WorkflowID: workflowID,
Branch: ref,
Error: fmt.Sprintf("Workflow dispatched but run never started: %v", err),
}
return toErrorResultWithJSON(result)
}
log.Infof("Run %d started, monitoring until completion...", runID)
monitorResult, err := monitorRunUntilComplete(ctx, owner, repo, runID, pollInterval, timeout)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "MonitorRun",
"owner": owner,
"repo": repo,
"run_id": fmt.Sprintf("%d", runID),
}))
}
log.Infof("Retrieving logs for run %d...", runID)
logs, err := retrieveJobLogs(ctx, owner, repo, monitorResult.Jobs)
if err != nil {
log.Warnf("Failed to retrieve some job logs: %v", err)
}
monitorResult.Logs = logs
return toTextResultWithJSON(monitorResult)
}
func checkActionsAPIAvailable(ctx context.Context) error {
var versionResp struct {
Version string `json:"version"`
}
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to check Gitea version: status=%d, err=%v", status, err),
map[string]string{"operation": "CheckGiteaVersion"},
)
}
major, minor, patch, err := parseVersionForCheck(versionResp.Version)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to parse version '%s': %v", versionResp.Version, err),
map[string]string{"operation": "ParseVersion"},
)
}
if major < 1 || (major == 1 && minor < 23) {
return errors.NewEnhancedError(
stderrors.New("Actions API not available"),
fmt.Sprintf("Actions API requires Gitea 1.23+, found %d.%d.%d", major, minor, patch),
errors.CategoryActions,
).WithOperation("CheckActionsAPIAvailable")
}
return nil
}
func parseVersionForCheck(version string) (int, int, int, error) {
version = trimVersionPrefix(version)
parts := splitVersion(version)
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
patch = 0
}
}
return major, minor, patch, nil
}
func trimVersionPrefix(v string) string {
v = trimOnePrefix(v, "v")
v = trimOnePrefix(v, "V")
return v
}
func trimOnePrefix(s, prefix string) string {
if len(s) > 0 && s[0] == prefix[0] {
return s[1:]
}
return s
}
func splitVersion(v string) []string {
var parts []string
start := 0
for i := 0; i < len(v); i++ {
if v[i] == '.' {
if start < i {
parts = append(parts, v[start:i])
}
start = i + 1
}
}
if start < len(v) {
parts = append(parts, v[start:])
}
return parts
}
func dispatchWorkflow(ctx context.Context, owner, repo, workflowID, ref string, inputs map[string]any) (map[string]any, error) {
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 stderrors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return nil, errors.NewEnhancedError(
err,
fmt.Sprintf("workflow dispatch not supported on this Gitea version (endpoint returned %d)", httpErr.StatusCode),
errors.CategoryActions,
).WithOperation("DispatchWorkflow")
}
return nil, err
}
return map[string]any{"message": "workflow dispatched"}, nil
}
func waitForRunToStart(ctx context.Context, owner, repo, workflowID, ref string, pollInterval, timeout time.Duration) (int64, error) {
startTime := time.Now()
seenRunIDs := make(map[int64]bool)
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
runs, err := listRecentRuns(ctx, owner, repo, workflowID, ref, 10)
if err != nil {
log.Warnf("Failed to list runs: %v", err)
time.Sleep(pollInterval)
continue
}
for _, run := range runs {
runID := int64(run["id"].(float64))
if seenRunIDs[runID] {
continue
}
seenRunIDs[runID] = true
status := getString(run, "status")
if status == "queued" || status == "in_progress" || status == "waiting" {
return runID, nil
}
createdAt := getString(run, "created_at")
if createdAt != "" {
runTime, err := time.Parse(time.RFC3339, createdAt)
if err == nil && time.Since(runTime) < 2*time.Minute {
return runID, nil
}
}
}
time.Sleep(pollInterval)
}
return 0, fmt.Errorf("timeout waiting for run to start after %v", timeout)
}
func listRecentRuns(ctx context.Context, owner, repo, workflowID, ref string, limit int) ([]map[string]any, error) {
query := url.Values{}
query.Set("limit", strconv.Itoa(limit))
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
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 nil, err
}
var matchingRuns []map[string]any
for _, run := range result.WorkflowRuns {
if workflowID != "" {
runWorkflowID := getString(run, "workflow_id")
runPath := getString(run, "path")
if runWorkflowID != workflowID && runPath != workflowID &&
runWorkflowID != "" && !containsPath(runPath, workflowID) {
continue
}
}
if ref != "" {
headBranch := getString(run, "head_branch")
if headBranch != "" && headBranch != ref {
continue
}
}
matchingRuns = append(matchingRuns, run)
}
return matchingRuns, nil
}
func containsPath(path, substring string) bool {
return len(path) > 0 && len(substring) > 0 &&
(path == substring ||
(len(path) > len(substring) && (path[len(path)-len(substring):] == substring ||
path[:len(substring)] == substring)))
}
func getString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if v, ok := m[key].(float64); ok {
return strconv.FormatInt(int64(v), 10)
}
return ""
}
func monitorRunUntilComplete(ctx context.Context, owner, repo string, runID int64, pollInterval, timeout time.Duration) (*MonitorResult, error) {
startTime := time.Now()
var firstSeen time.Time
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("failed to get run %d: %v", runID, err)
}
status := getString(run, "status")
conclusion := getString(run, "conclusion")
if firstSeen.IsZero() {
firstSeen = time.Now()
}
if status == "completed" {
return buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
}
log.Debugf("Run %d status: %s (conclusion: %s), waiting...", runID, status, conclusion)
time.Sleep(pollInterval)
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("timeout after %v and failed to get final status: %v", timeout, err)
}
result, _ := buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
result.TimedOut = true
result.Error = fmt.Sprintf("Polling timed out after %v", timeout)
return result, nil
}
func getRun(ctx context.Context, owner, repo string, runID int64) (map[string]any, error) {
var result map[string]any
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
return result, err
}
func buildMonitorResult(run map[string]any, runID int64, owner, repo string, started, ended time.Time) (*MonitorResult, error) {
result := &MonitorResult{
RunID: runID,
Status: getString(run, "status"),
Conclusion: getString(run, "conclusion"),
WorkflowID: getString(run, "workflow_id"),
WorkflowName: getString(run, "name"),
Branch: getString(run, "head_branch"),
CommitSHA: getString(run, "head_sha"),
StartedAt: getString(run, "created_at"),
CompletedAt: getString(run, "updated_at"),
}
if createdAt := getString(run, "run_started_at"); createdAt != "" {
result.StartedAt = createdAt
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
started = t
}
}
if updatedAt := getString(run, "updated_at"); updatedAt != "" {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
ended = t
}
}
duration := ended.Sub(started)
if duration < 0 {
duration = 0
}
result.Duration = duration.String()
result.DurationSec = duration.Seconds()
jobs, err := listRunJobs(runID, owner, repo, run)
if err == nil {
result.Jobs = jobs
}
return result, nil
}
func listRunJobs(runID int64, owner, repo string, run map[string]any) ([]JobSummary, error) {
if jobsData, ok := run["jobs"].([]any); ok && len(jobsData) > 0 {
return parseJobSummaries(jobsData), nil
}
return nil, nil
}
func parseJobSummaries(jobsData []any) []JobSummary {
var summaries []JobSummary
for _, j := range jobsData {
job, ok := j.(map[string]any)
if !ok {
continue
}
summary := JobSummary{
ID: int64(job["id"].(float64)),
Name: getString(job, "name"),
Status: getString(job, "status"),
Conclusion: getString(job, "conclusion"),
StartedAt: getString(job, "started_at"),
CompletedAt: getString(job, "completed_at"),
}
if stepsData, ok := job["steps"].([]any); ok {
summary.Steps = parseSteps(stepsData)
}
summaries = append(summaries, summary)
}
return summaries
}
func parseSteps(stepsData []any) []StepInfo {
var steps []StepInfo
for _, s := range stepsData {
step, ok := s.(map[string]any)
if !ok {
continue
}
steps = append(steps, StepInfo{
Name: getString(step, "name"),
Number: int(step["number"].(float64)),
Status: getString(step, "status"),
Conclusion: getString(step, "conclusion"),
})
}
return steps
}
func retrieveJobLogs(ctx context.Context, owner, repo string, jobs []JobSummary) (map[string]JobLogs, error) {
logs := make(map[string]JobLogs)
for _, job := range jobs {
if job.ID == 0 {
continue
}
logData, _, err := fetchJobLogBytes(ctx, owner, repo, job.ID)
if err != nil {
log.Warnf("Failed to fetch logs for job %d: %v", job.ID, err)
continue
}
maxLogBytes := 100 * 1024
truncated := false
if len(logData) > maxLogBytes {
logData = logData[len(logData)-maxLogBytes:]
truncated = true
}
logs[job.Name] = JobLogs{
JobID: job.ID,
JobName: job.Name,
Log: string(logData),
Bytes: len(logData),
Truncated: truncated,
}
}
return logs, nil
}
func toTextResultWithJSON(result *MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("failed to marshal result: %v", err))
}
return to.TextResult(string(jsonBytes))
}
func toErrorResultWithJSON(result MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("%s (marshal error: %v)", result.Error, err))
}
return to.TextResult(fmt.Sprintf("Error: %s\n\nPartial Result:\n%s", result.Error, string(jsonBytes)))
}
+607
View File
@@ -0,0 +1,607 @@
package actions
import (
"testing"
)
func TestTrimVersionPrefix(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"v1.22.5", "1.22.5"},
{"V1.22.5", "1.22.5"},
{"1.22.5", "1.22.5"},
{"v1.23.0", "1.23.0"},
{"2.0.0", "2.0.0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := trimVersionPrefix(tt.input)
if result != tt.expected {
t.Errorf("trimVersionPrefix(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSplitVersion(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"1.22.5", []string{"1", "22", "5"}},
{"1.23", []string{"1", "23"}},
{"2.0.0", []string{"2", "0", "0"}},
{"1", []string{"1"}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := splitVersion(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("splitVersion(%q) = %v, want %v", tt.input, result, tt.expected)
return
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("splitVersion(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expected[i])
}
}
})
}
}
func TestParseVersionForCheck(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.22.5", 1, 22, 5, false},
{"1.23.0", 1, 23, 0, false},
{"v1.22.5", 1, 22, 5, false},
{"V1.23.0", 1, 23, 0, false},
{"2.0.0", 2, 0, 0, false},
{"1.22", 1, 22, 0, false},
{"invalid", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{".gitea/workflows/build.yml", "build.yml", true},
{".github/workflows/test.yml", "test.yml", true},
{".gitea/workflows/build.yml", "deploy.yml", false},
{"build.yml", "build.yml", true},
{"", "test", false},
{"test", "", false},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "string value",
m: map[string]any{"name": "test"},
key: "name",
expected: "test",
},
{
name: "float64 value",
m: map[string]any{"id": float64(123)},
key: "id",
expected: "123",
},
{
name: "missing key",
m: map[string]any{"other": "value"},
key: "name",
expected: "",
},
{
name: "nil map",
m: nil,
key: "name",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries(t *testing.T) {
jobsData := []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"started_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:05:00Z",
"steps": []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
},
}
summaries := parseJobSummaries(jobsData)
if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries))
}
if summaries[0].ID != 1 {
t.Errorf("expected ID 1, got %d", summaries[0].ID)
}
if summaries[0].Name != "build" {
t.Errorf("expected name 'build', got %s", summaries[0].Name)
}
if len(summaries[0].Steps) != 1 {
t.Errorf("expected 1 step, got %d", len(summaries[0].Steps))
}
}
func TestParseSteps(t *testing.T) {
stepsData := []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
map[string]any{
"name": "Build",
"number": float64(2),
"status": "completed",
"conclusion": "success",
},
}
steps := parseSteps(stepsData)
if len(steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(steps))
}
if steps[0].Name != "Checkout" || steps[0].Number != 1 {
t.Errorf("first step mismatch: %+v", steps[0])
}
if steps[1].Name != "Build" || steps[1].Number != 2 {
t.Errorf("second step mismatch: %+v", steps[1])
}
}
func TestMonitorResultTypes(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
Jobs: []JobSummary{
{
ID: 1,
Name: "build",
Status: "completed",
Conclusion: "success",
Steps: []StepInfo{
{Name: "Checkout", Number: 1, Status: "completed", Conclusion: "success"},
},
},
},
Logs: map[string]JobLogs{
"build": {
JobID: 1,
JobName: "build",
Log: "Building...",
Bytes: 100,
},
},
}
if result.RunID != 123 {
t.Errorf("RunID mismatch")
}
if result.Status != "completed" {
t.Errorf("Status mismatch")
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs length mismatch")
}
if len(result.Logs) != 1 {
t.Errorf("Logs length mismatch")
}
}
func TestTrimOnePrefix(t *testing.T) {
tests := []struct {
s string
prefix string
expected string
}{
{"v1.22.5", "v", "1.22.5"},
{"V1.22.5", "V", "1.22.5"},
{"1.22.5", "v", "1.22.5"},
{"", "v", ""},
{"v", "v", ""},
{"test", "x", "test"},
}
for _, tt := range tests {
t.Run(tt.s+"_"+tt.prefix, func(t *testing.T) {
result := trimOnePrefix(tt.s, tt.prefix)
if result != tt.expected {
t.Errorf("trimOnePrefix(%q, %q) = %q, want %q", tt.s, tt.prefix, result, tt.expected)
}
})
}
}
func TestParseVersionForCheck_EdgeCases(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.0.0", 1, 0, 0, false},
{"0.0.1", 0, 0, 1, false},
{"1.23.0+build", 1, 23, 0, false},
{"1.23.0-rc1", 1, 23, 0, false},
{"1.23", 1, 23, 0, false},
{"1", 1, 0, 0, false},
{"", 0, 0, 0, true},
{"abc", 0, 0, 0, true},
{"1.abc.5", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath_EdgeCases(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{"", "", false},
{"build.yml", "", false},
{"", "build", false},
{"a/b/c/d.yml", "c/d.yml", true},
{"a/b/c/d.yml", "b/c", false},
{"build.yml", "build.yml", true},
{"/absolute/path", "path", true},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString_EdgeCases(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "int value",
m: map[string]any{"count": int(42)},
key: "count",
expected: "",
},
{
name: "bool value",
m: map[string]any{"active": true},
key: "active",
expected: "",
},
{
name: "empty string value",
m: map[string]any{"name": ""},
key: "name",
expected: "",
},
{
name: "large float64",
m: map[string]any{"id": float64(9223372036854775807)},
key: "id",
expected: "9223372036854775807",
},
{
name: "zero float64",
m: map[string]any{"count": float64(0)},
key: "count",
expected: "0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries_EdgeCases(t *testing.T) {
tests := []struct {
name string
jobsData []any
wantLen int
}{
{
name: "empty jobs",
jobsData: []any{},
wantLen: 0,
},
{
name: "nil jobs",
jobsData: nil,
wantLen: 0,
},
{
name: "job without steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "job with empty steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"steps": []any{},
},
},
wantLen: 1,
},
{
name: "invalid job entry",
jobsData: []any{
"not a map",
map[string]any{
"id": float64(2),
"name": "test",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summaries := parseJobSummaries(tt.jobsData)
if len(summaries) != tt.wantLen {
t.Errorf("parseJobSummaries() returned %d summaries, want %d", len(summaries), tt.wantLen)
}
})
}
}
func TestParseSteps_EdgeCases(t *testing.T) {
tests := []struct {
name string
stepsData []any
wantLen int
}{
{
name: "empty steps",
stepsData: []any{},
wantLen: 0,
},
{
name: "nil steps",
stepsData: nil,
wantLen: 0,
},
{
name: "invalid step entry",
stepsData: []any{
"not a map",
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "step without conclusion",
stepsData: []any{
map[string]any{
"name": "Setup",
"number": float64(1),
"status": "in_progress",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
steps := parseSteps(tt.stepsData)
if len(steps) != tt.wantLen {
t.Errorf("parseSteps() returned %d steps, want %d", len(steps), tt.wantLen)
}
})
}
}
func TestMonitorResultStruct(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
WorkflowName: "Build",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
StartedAt: "2024-01-15T10:00:00Z",
CompletedAt: "2024-01-15T10:05:00Z",
Jobs: []JobSummary{},
Logs: map[string]JobLogs{},
Error: "",
TimedOut: false,
}
if result.RunID != 123 {
t.Errorf("RunID = %d, want 123", result.RunID)
}
if result.Status != "completed" {
t.Errorf("Status = %s, want completed", result.Status)
}
if result.Conclusion != "success" {
t.Errorf("Conclusion = %s, want success", result.Conclusion)
}
if result.TimedOut {
t.Error("TimedOut should be false")
}
}
func TestMonitorResultErrorState(t *testing.T) {
result := &MonitorResult{
RunID: 456,
Status: "completed",
Conclusion: "failure",
WorkflowID: "test.yml",
Branch: "develop",
CommitSHA: "def456",
Duration: "2m30s",
DurationSec: 150,
Error: "Test failed",
TimedOut: false,
Jobs: []JobSummary{
{
ID: 1,
Name: "test",
Status: "completed",
Conclusion: "failure",
},
},
}
if result.Conclusion != "failure" {
t.Errorf("Conclusion = %s, want failure", result.Conclusion)
}
if result.Error != "Test failed" {
t.Errorf("Error = %s, want 'Test failed'", result.Error)
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs count = %d, want 1", len(result.Jobs))
}
}
func TestMonitorResultTimeoutState(t *testing.T) {
result := &MonitorResult{
RunID: 789,
Status: "in_progress",
Conclusion: "",
WorkflowID: "deploy.yml",
Branch: "main",
CommitSHA: "ghi789",
Duration: "10m0s",
DurationSec: 600,
Error: "Polling timed out after 10m0s",
TimedOut: true,
Jobs: []JobSummary{},
}
if !result.TimedOut {
t.Error("TimedOut should be true")
}
if result.Error == "" {
t.Error("Error should not be empty for timeout")
}
}
+193
View File
@@ -0,0 +1,193 @@
package actions
import (
"context"
"fmt"
"net/http"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListActionRunnersToolName = "list_action_runners"
)
// ActionRunner represents a self-hosted action runner
// This is a local type since Gitea SDK v0.23.2 doesn't include it
type ActionRunner struct {
ID int64 `json:"id"`
Name string `json:"name"`
UUID string `json:"uuid"`
Status string `json:"status"`
Online bool `json:"online"`
Busy bool `json:"busy"`
Version string `json:"version,omitempty"`
Labels []string `json:"labels,omitempty"`
LastOnline string `json:"last_online,omitempty"`
}
// ActionRunnersResponse represents the API response for listing runners
type ActionRunnersResponse struct {
TotalCount int `json:"total_count"`
Runners []*ActionRunner `json:"runners"`
}
var (
ListActionRunnersTool = mcp.NewTool(
ListActionRunnersToolName,
mcp.WithDescription("List self-hosted action runners for a repository. Shows runner status, labels, and availability. Filter by status (online/offline). Note: Requires Gitea 1.23+; Gitea 1.22.5 does not support Actions API."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("status", mcp.Description("optional status filter (online, offline, busy, idle)"), mcp.Enum("online", "offline", "busy", "idle")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListActionRunnersTool,
Handler: listActionRunnersFn,
})
}
func listActionRunnersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionRunnersFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "repo",
}))
}
statusFilter, _ := req.GetArguments()["status"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
// Use REST API to get runners
apiPath := fmt.Sprintf("/repos/%s/%s/actions/runners", owner, repo)
query := url.Values{
"page": []string{fmt.Sprintf("%d", page)},
"per_page": []string{fmt.Sprintf("%d", pageSize)},
}
var runnersResp ActionRunnersResponse
statusCode, err := gitea.DoJSON(ctx, http.MethodGet, apiPath, query, nil, &runnersResp)
if err != nil {
if statusCode == http.StatusNotFound {
// Gitea 1.22.5 doesn't have Actions API - return empty list with message
return to.TextResult(map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
"note": "Actions API not available in Gitea 1.22.5. Requires Gitea 1.23+.",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"owner": owner,
"repo": repo,
}))
}
// Filter by status if requested
filteredRunners := make([]*ActionRunner, 0, len(runnersResp.Runners))
if statusFilter != "" {
for _, runner := range runnersResp.Runners {
switch statusFilter {
case "online":
if runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "offline":
if !runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "busy":
if runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
case "idle":
if runner.Online && !runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
default:
if runner.Status == statusFilter {
filteredRunners = append(filteredRunners, runner)
}
}
}
} else {
filteredRunners = runnersResp.Runners
}
result := slimActionRunners(filteredRunners)
return to.TextResult(result)
}
func slimActionRunners(runners []*ActionRunner) map[string]interface{} {
if len(runners) == 0 {
return map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
}
}
slimmed := make([]map[string]interface{}, 0, len(runners))
for _, runner := range runners {
slimmed = append(slimmed, slimActionRunner(runner))
}
return map[string]interface{}{
"total_count": len(runners),
"runners": slimmed,
}
}
func slimActionRunner(runner *ActionRunner) map[string]interface{} {
if runner == nil {
return nil
}
result := map[string]interface{}{
"id": runner.ID,
"name": runner.Name,
"uuid": runner.UUID,
"status": runner.Status,
"online": runner.Online,
"busy": runner.Busy,
}
if runner.Version != "" {
result["version"] = runner.Version
}
if len(runner.Labels) > 0 {
result["labels"] = runner.Labels
} else {
result["labels"] = []string{}
}
if runner.LastOnline != "" {
result["last_online"] = runner.LastOnline
}
return result
}
+354
View File
@@ -0,0 +1,354 @@
package actions
import (
"testing"
)
func TestSlimActionRunner(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
expected map[string]interface{}
}{
{
name: "complete runner",
runner: &ActionRunner{
ID: 1,
Name: "metal",
UUID: "uuid-123",
Status: "online",
Online: true,
Busy: false,
Version: "1.22.5",
Labels: []string{"metal", "self-hosted"},
LastOnline: "2024-01-01T00:00:00Z",
},
expected: map[string]interface{}{
"id": int64(1),
"name": "metal",
"uuid": "uuid-123",
"status": "online",
"online": true,
"busy": false,
"version": "1.22.5",
"labels": []string{"metal", "self-hosted"},
},
},
{
name: "runner without labels",
runner: &ActionRunner{
ID: 2,
Name: "cloud-1",
UUID: "uuid-456",
Status: "offline",
Online: false,
Busy: false,
Version: "1.22.5",
},
expected: map[string]interface{}{
"id": int64(2),
"name": "cloud-1",
"uuid": "uuid-456",
"status": "offline",
"online": false,
"busy": false,
"version": "1.22.5",
"labels": []string{},
},
},
{
name: "nil runner",
runner: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if !runnerMapsEqual(result, tt.expected) {
t.Fatalf("slimActionRunner() = %v, want %v", result, tt.expected)
}
})
}
}
func TestSlimActionRunners(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
expected map[string]interface{}
}{
{
name: "multiple runners",
runners: []*ActionRunner{
{
ID: 1,
Name: "metal",
Status: "online",
Online: true,
Labels: []string{"metal"},
},
{
ID: 2,
Name: "cloud-1",
Status: "offline",
Online: false,
Labels: []string{"cloud-1"},
},
},
expected: map[string]interface{}{
"total_count": 2,
},
},
{
name: "empty list",
runners: []*ActionRunner{},
expected: map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
},
},
{
name: "nil runners",
runners: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
if result["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", result["total_count"], tt.expected["total_count"])
}
})
}
}
func TestSlimActionRunner_EdgeCases(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "runner with zero ID",
runner: &ActionRunner{
ID: 0,
Name: "zero-runner",
Status: "offline",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["id"] != int64(0) {
t.Errorf("expected ID 0, got %v", result["id"])
}
},
},
{
name: "runner with empty name",
runner: &ActionRunner{
ID: 1,
Name: "",
Status: "online",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["name"] != "" {
t.Errorf("expected empty name, got %v", result["name"])
}
},
},
{
name: "runner with empty labels",
runner: &ActionRunner{
ID: 2,
Name: "no-labels",
Status: "online",
Labels: []string{},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 0 {
t.Errorf("expected empty labels slice, got %v", result["labels"])
}
},
},
{
name: "runner with empty LastOnline",
runner: &ActionRunner{
ID: 3,
Name: "never-online",
Status: "offline",
Online: false,
LastOnline: "",
},
check: func(t *testing.T, result map[string]interface{}) {
if _, exists := result["last_online"]; exists {
t.Error("should not have last_online when empty")
}
},
},
{
name: "runner with many labels",
runner: &ActionRunner{
ID: 4,
Name: "many-labels",
Status: "online",
Labels: []string{"label1", "label2", "label3", "label4", "label5"},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 5 {
t.Errorf("expected 5 labels, got %v", result["labels"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if result == nil {
t.Fatal("slimActionRunner returned nil")
}
tt.check(t, result)
})
}
}
func TestSlimActionRunners_EdgeCases(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "nil runners slice",
runners: nil,
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 0 {
t.Errorf("expected total_count 0, got %v", result["total_count"])
}
runners, ok := result["runners"].([]interface{})
if !ok || len(runners) != 0 {
t.Errorf("expected empty runners slice, got %v", result["runners"])
}
},
},
{
name: "single runner",
runners: []*ActionRunner{{ID: 1, Name: "single"}},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 1 {
t.Errorf("expected total_count 1, got %v", result["total_count"])
}
runners, ok := result["runners"].([]map[string]interface{})
if !ok || len(runners) != 1 {
t.Errorf("expected 1 runner, got %v", result["runners"])
}
},
},
{
name: "many runners",
runners: []*ActionRunner{
{ID: 1, Name: "runner1"},
{ID: 2, Name: "runner2"},
{ID: 3, Name: "runner3"},
{ID: 4, Name: "runner4"},
{ID: 5, Name: "runner5"},
},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 5 {
t.Errorf("expected total_count 5, got %v", result["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
tt.check(t, result)
})
}
}
func TestRunnerMapsEqual_EdgeCases(t *testing.T) {
tests := []struct {
name string
a map[string]interface{}
b map[string]interface{}
expected bool
}{
{
name: "both nil",
a: nil,
b: nil,
expected: true,
},
{
name: "one nil",
a: map[string]interface{}{"key": "value"},
b: nil,
expected: false,
},
{
name: "different lengths",
a: map[string]interface{}{"a": 1},
b: map[string]interface{}{"a": 1, "b": 2},
expected: false,
},
{
name: "same keys different values",
a: map[string]interface{}{"key": "value1"},
b: map[string]interface{}{"key": "value2"},
expected: false,
},
{
name: "empty maps",
a: map[string]interface{}{},
b: map[string]interface{}{},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runnerMapsEqual(tt.a, tt.b)
if result != tt.expected {
t.Errorf("runnerMapsEqual() = %v, want %v", result, tt.expected)
}
})
}
}
// runnerMapsEqual compares two map[string]interface{} values for equality
func runnerMapsEqual(a, b map[string]interface{}) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
bv, ok := b[k]
if !ok {
return false
}
// Simple comparison for basic types
switch vv := v.(type) {
case []string:
bvv, ok := bv.([]string)
if !ok || len(vv) != len(bvv) {
return false
}
for i, sv := range vv {
if sv != bvv[i] {
return false
}
}
default:
if v != bv {
return false
}
}
}
return true
}
+549
View File
@@ -0,0 +1,549 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"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/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 workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), 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("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required 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) {
log.Debugf("Called listRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo 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/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) {
log.Debugf("Called getRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
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) {
log.Debugf("Called dispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.New("ref is required"))
}
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) {
log.Debugf("Called listRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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) {
log.Debugf("Called getRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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) {
log.Debugf("Called cancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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) {
log.Debugf("Called rerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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) {
log.Debugf("Called listRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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) {
log.Debugf("Called listRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
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))
}
// Log functions (merged from logs.go)
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) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
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) {
log.Debugf("Called downloadRepoActionJobLogFn")
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),
})
}
+92
View File
@@ -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)
}
+326
View File
@@ -0,0 +1,326 @@
package actions
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gopkg.in/yaml.v3"
)
const (
GetWorkflowFileContentToolName = "get_workflow_file_content"
)
var (
GetWorkflowFileContentTool = mcp.NewTool(
GetWorkflowFileContentToolName,
mcp.WithDescription("Get workflow file content from .gitea/workflows/ or .github/workflows/ directories. Auto-discovers workflow files and returns parsed YAML as JSON."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("git ref (branch/tag/commit). Defaults to default branch if not specified")),
mcp.WithString("pattern", mcp.Description("file pattern to match (e.g., '*.yml', 'build-*.yml'). Defaults to all workflow files")),
mcp.WithString("filename", mcp.Description("specific workflow filename to retrieve. If provided, pattern is ignored")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetWorkflowFileContentTool,
Handler: getWorkflowFileContentFn,
})
}
type WorkflowFile struct {
Name string `json:"name"`
Path string `json:"path"`
SHA string `json:"sha"`
Size int64 `json:"size"`
Content interface{} `json:"content"`
RawContent string `json:"raw_content,omitempty"`
Encoding string `json:"encoding,omitempty"`
}
type WorkflowFilesResult struct {
Directory string `json:"directory"`
Files []WorkflowFile `json:"files"`
TotalCount int `json:"total_count"`
}
func getWorkflowFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWorkflowFileContentFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "repo",
}))
}
ref, _ := req.GetArguments()["ref"].(string)
pattern, _ := req.GetArguments()["pattern"].(string)
filename, _ := req.GetArguments()["filename"].(string)
directories := []string{".gitea/workflows", ".github/workflows"}
var result WorkflowFilesResult
var lastErr error
for _, dir := range directories {
if filename != "" {
file, err := getWorkflowFile(ctx, owner, repo, ref, filepath.Join(dir, filename))
if err == nil {
result.Directory = dir
result.Files = []WorkflowFile{*file}
result.TotalCount = 1
return to.TextResult(result)
}
if lastErr == nil {
lastErr = err
}
continue
}
files, err := discoverWorkflowFiles(ctx, owner, repo, ref, dir, pattern)
if err == nil && len(files) > 0 {
result.Directory = dir
result.Files = files
result.TotalCount = len(files)
return to.TextResult(result)
}
if err != nil && lastErr == nil {
lastErr = err
}
}
return to.TextResult(WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
})
}
func discoverWorkflowFiles(ctx context.Context, owner, repo, ref, dir, pattern string) ([]WorkflowFile, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "DiscoverWorkflowFiles",
"owner": owner,
"repo": repo,
})
}
contents, _, err := client.ListContents(owner, repo, ref, dir)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListWorkflowDirectory",
"owner": owner,
"repo": repo,
"path": dir,
})
}
var files []WorkflowFile
for _, content := range contents {
if content.Type != "file" {
continue
}
ext := strings.ToLower(filepath.Ext(content.Name))
if ext != ".yml" && ext != ".yaml" {
continue
}
if pattern != "" && !matchPattern(content.Name, pattern) {
continue
}
filePath := filepath.Join(dir, content.Name)
file, err := getWorkflowFile(ctx, owner, repo, ref, filePath)
if err != nil {
log.Debugf("Failed to get workflow file %s: %v", filePath, err)
continue
}
files = append(files, *file)
}
return files, nil
}
func getWorkflowFile(ctx context.Context, owner, repo, ref, path string) (*WorkflowFile, error) {
escapedOwner := url.PathEscape(owner)
escapedRepo := url.PathEscape(repo)
escapedPath := url.PathEscape(path)
var result any
_, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/contents/%s", escapedOwner, escapedRepo, escapedPath), nil, nil, &result)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFile",
"owner": owner,
"repo": repo,
"path": path,
})
}
contentMap, ok := result.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected response type for workflow file")
}
workflowFile := &WorkflowFile{
Path: path,
}
if name, ok := contentMap["name"].(string); ok {
workflowFile.Name = name
}
if sha, ok := contentMap["sha"].(string); ok {
workflowFile.SHA = sha
}
if size, ok := contentMap["size"].(float64); ok {
workflowFile.Size = int64(size)
}
if encoding, ok := contentMap["encoding"].(string); ok {
workflowFile.Encoding = encoding
}
if contentStr, ok := contentMap["content"].(string); ok && contentStr != "" {
var rawContent []byte
if workflowFile.Encoding == "base64" {
decoded, err := base64.StdEncoding.DecodeString(contentStr)
if err != nil {
return nil, errors.TranslateError(
errors.NewEnhancedError(err, "Failed to decode workflow file content", errors.CategoryActions),
map[string]string{
"operation": "DecodeWorkflowFile",
"path": path,
"encoding": workflowFile.Encoding,
},
)
}
rawContent = decoded
} else {
rawContent = []byte(contentStr)
}
workflowFile.RawContent = string(rawContent)
var yamlContent interface{}
if err := yaml.Unmarshal(rawContent, &yamlContent); err != nil {
log.Debugf("Failed to parse YAML for %s: %v", path, err)
yamlContent = nil
} else {
workflowFile.Content = convertYamlToInterface(yamlContent)
}
}
return workflowFile, nil
}
func matchPattern(filename, pattern string) bool {
pattern = strings.ToLower(pattern)
filename = strings.ToLower(filename)
if pattern == "*" || pattern == "*.*" {
return true
}
if strings.HasPrefix(pattern, "*") {
suffix := pattern[1:]
return strings.HasSuffix(filename, suffix)
}
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(filename, prefix)
}
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
mid := pattern[1 : len(pattern)-1]
return strings.Contains(filename, mid)
}
return filename == pattern || strings.HasPrefix(filename, pattern)
}
func convertYamlToInterface(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
result := make(map[string]interface{})
for k, v := range val {
result[k] = convertYamlToInterface(v)
}
return result
case map[interface{}]interface{}:
result := make(map[string]interface{})
for k, v := range val {
key := fmt.Sprintf("%v", k)
result[key] = convertYamlToInterface(v)
}
return result
case []interface{}:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(v)
}
return result
case []yaml.Node:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(&v)
}
return result
case *yaml.Node:
switch val.Kind {
case yaml.ScalarNode:
s := val.Value
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed
}
return s
case yaml.SequenceNode:
result := make([]interface{}, len(val.Content))
for i, n := range val.Content {
result[i] = convertYamlToInterface(&n)
}
return result
case yaml.MappingNode:
result := make(map[string]interface{})
for i := 0; i < len(val.Content); i += 2 {
key := val.Content[i].Value
result[key] = convertYamlToInterface(&val.Content[i+1])
}
return result
default:
return val.Value
}
default:
return val
}
}
+398
View File
@@ -0,0 +1,398 @@
package actions
import (
"testing"
)
func TestMatchPattern(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
{"build.yml", "*", true},
{"build.yml", "*.*", true},
{"build.yml", "*.yml", true},
{"build.yaml", "*.yml", true},
{"build.yaml", "*.yaml", true},
{"build-test.yml", "build-*.yml", true},
{"build-test.yml", "*-test.yml", true},
{"build.yml", "deploy*.yml", false},
{"build.yml", "*.yaml", false},
{"BUILD.YML", "*.yml", true},
{"build.yml", "build.yml", true},
{"deploy.yml", "build.yml", false},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "string value",
input: "hello",
expected: "hello",
},
{
name: "int value",
input: 42,
expected: 42,
},
{
name: "simple map",
input: map[string]interface{}{
"name": "test",
"val": 123,
},
expected: map[string]interface{}{
"name": "test",
"val": 123,
},
},
{
name: "nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
},
{
name: "slice",
input: []interface{}{"a", "b", "c"},
expected: []interface{}{"a", "b", "c"},
},
{
name: "map with interface keys",
input: map[interface{}]interface{}{
"key": "value",
123: "numeric key",
},
expected: map[string]interface{}{
"key": "value",
"123": "numeric key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func deepEqual(a, b interface{}) bool {
switch av := a.(type) {
case map[string]interface{}:
bv, ok := b.(map[string]interface{})
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqual(v, bv[k]) {
return false
}
}
return true
case []interface{}:
bv, ok := b.([]interface{})
if !ok || len(av) != len(bv) {
return false
}
for i, v := range av {
if !deepEqual(v, bv[i]) {
return false
}
}
return true
default:
return a == b
}
}
func TestWorkflowFileStruct(t *testing.T) {
wf := WorkflowFile{
Name: "test.yml",
Path: ".gitea/workflows/test.yml",
SHA: "abc123",
Size: 1024,
Content: map[string]interface{}{"name": "Test Workflow"},
RawContent: "name: Test Workflow",
Encoding: "base64",
}
if wf.Name != "test.yml" {
t.Fatalf("Name = %q, want %q", wf.Name, "test.yml")
}
if wf.Path != ".gitea/workflows/test.yml" {
t.Fatalf("Path = %q, want %q", wf.Path, ".gitea/workflows/test.yml")
}
}
func TestMatchPattern_EdgeCases(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
// Edge cases for empty strings
{"", "*", true},
{"file.yml", "", false},
{"", "", true},
// Edge cases for special characters
{"file-name.yml", "*-name.yml", true},
{"file_name.yml", "*.yml", true},
{"file.name.yml", "*.yml", true},
// Edge case: only wildcard
{"anything", "*", true},
{"", "*", true},
// Edge case: pattern equals filename
{"exact.yml", "exact.yml", true},
{"exact.yml", "exact.yaml", false},
// Edge case: case sensitivity
{"FILE.YML", "*.yml", true},
{"File.Yml", "*.yml", true},
// Edge case: middle wildcards
{"build-test-deploy.yml", "*test*.yml", true},
{"build-prod-deploy.yml", "*test*.yml", false},
// Edge case: multiple extensions
{"file.tar.gz", "*.gz", true},
{"file.tar.gz", "*.tar.gz", true},
// Edge case: dots in filename
{".github/workflows/build.yml", "*.yml", true},
// Edge case: numeric patterns
{"build-123.yml", "build-*.yml", true},
{"build-abc.yml", "build-*.yml", true},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface_EdgeCases(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "nil value",
input: nil,
expected: nil,
},
{
name: "empty map",
input: map[string]interface{}{},
expected: map[string]interface{}{},
},
{
name: "empty slice",
input: []interface{}{},
expected: []interface{}{},
},
{
name: "nested empty structures",
input: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
expected: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
},
{
name: "boolean value",
input: true,
expected: true,
},
{
name: "float value",
input: 3.14,
expected: 3.14,
},
{
name: "deeply nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func TestWorkflowFileStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
file WorkflowFile
want WorkflowFile
}{
{
name: "empty file",
file: WorkflowFile{},
want: WorkflowFile{},
},
{
name: "file with zero size",
file: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
Content: nil,
RawContent: "",
Encoding: "",
},
want: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
},
},
{
name: "file with large size",
file: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10, // 10MB
Content: map[string]interface{}{"name": "Large Workflow"},
RawContent: "name: Large Workflow\\n# ... lots of content ...",
Encoding: "base64",
},
want: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.file.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", tt.file.Name, tt.want.Name)
}
if tt.file.Path != tt.want.Path {
t.Errorf("Path = %q, want %q", tt.file.Path, tt.want.Path)
}
if tt.file.SHA != tt.want.SHA {
t.Errorf("SHA = %q, want %q", tt.file.SHA, tt.want.SHA)
}
if tt.file.Size != tt.want.Size {
t.Errorf("Size = %d, want %d", tt.file.Size, tt.want.Size)
}
})
}
}
func TestWorkflowFilesResultStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
result WorkflowFilesResult
}{
{
name: "empty result",
result: WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
},
},
{
name: "nil files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: nil,
TotalCount: 0,
},
},
{
name: "single file",
result: WorkflowFilesResult{
Directory: ".github/workflows",
Files: []WorkflowFile{
{Name: "ci.yml"},
},
TotalCount: 1,
},
},
{
name: "many files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: []WorkflowFile{
{Name: "build.yml"},
{Name: "test.yml"},
{Name: "deploy.yml"},
{Name: "lint.yml"},
{Name: "security.yml"},
},
TotalCount: 5,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if len(tt.result.Files) != tt.result.TotalCount {
t.Errorf("Files length (%d) != TotalCount (%d)", len(tt.result.Files), tt.result.TotalCount)
}
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package activity
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListUserActivityToolName = "list_user_activity"
ListOrgActivityToolName = "list_org_activity"
ListTeamActivityToolName = "list_team_activity"
)
var Tool = tool.New()
var (
ListUserActivityTool = mcp.NewTool(
ListUserActivityToolName,
mcp.WithDescription("List activity/feeds for a user"),
mcp.WithString("username", mcp.Required(), mcp.Description("Username")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
ListOrgActivityTool = mcp.NewTool(
ListOrgActivityToolName,
mcp.WithDescription("List activity/feeds for an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
ListTeamActivityTool = mcp.NewTool(
ListTeamActivityToolName,
mcp.WithDescription("List activity/feeds for a team"),
mcp.WithNumber("team_id", mcp.Required(), mcp.Description("Team ID")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListUserActivityTool,
Handler: listUserActivityFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgActivityTool,
Handler: listOrgActivityFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListTeamActivityTool,
Handler: listTeamActivityFn,
})
}
func listUserActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listUserActivityFn")
args := req.GetArguments()
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
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))
}
opt := gitea_sdk.ListUserActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListUserActivityFeeds(username, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list user activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func listOrgActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listOrgActivityFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
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))
}
opt := gitea_sdk.ListOrgActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListOrgActivityFeeds(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func listTeamActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listTeamActivityFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
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))
}
opt := gitea_sdk.ListTeamActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListTeamActivityFeeds(teamID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func slimActivities(activities []*gitea_sdk.Activity) []map[string]interface{} {
result := make([]map[string]interface{}, len(activities))
for i, a := range activities {
result[i] = slimActivity(a)
}
return result
}
func slimActivity(a *gitea_sdk.Activity) map[string]interface{} {
result := map[string]interface{}{
"id": a.ID,
"op_type": a.OpType,
"content": a.Content,
"repo_id": a.RepoID,
"comment_id": a.CommentID,
"ref_name": a.RefName,
"is_private": a.IsPrivate,
"user_id": a.UserID,
"created": a.Created,
}
if a.Repo != nil {
result["repo_name"] = a.Repo.FullName
}
if a.ActUser != nil {
result["act_user_id"] = a.ActUserID
result["act_user_name"] = a.ActUser.UserName
}
return result
}
+224
View File
@@ -0,0 +1,224 @@
package attachment
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListReleaseAttachmentsToolName = "list_release_attachments"
GetReleaseAttachmentToolName = "get_release_attachment"
ListIssueCommentAttachmentsToolName = "list_issue_comment_attachments"
GetIssueCommentAttachmentToolName = "get_issue_comment_attachment"
)
var Tool = tool.New()
var (
ListReleaseAttachmentsTool = mcp.NewTool(
ListReleaseAttachmentsToolName,
mcp.WithDescription("List attachments for a release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("release", mcp.Required(), mcp.Description("Release ID")),
)
GetReleaseAttachmentTool = mcp.NewTool(
GetReleaseAttachmentToolName,
mcp.WithDescription("Get a specific release attachment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("release", mcp.Required(), mcp.Description("Release ID")),
mcp.WithNumber("attachment", mcp.Required(), mcp.Description("Attachment ID")),
)
ListIssueCommentAttachmentsTool = mcp.NewTool(
ListIssueCommentAttachmentsToolName,
mcp.WithDescription("List attachments for an issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("comment", mcp.Required(), mcp.Description("Comment ID")),
)
GetIssueCommentAttachmentTool = mcp.NewTool(
GetIssueCommentAttachmentToolName,
mcp.WithDescription("Get a specific issue comment attachment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("comment", mcp.Required(), mcp.Description("Comment ID")),
mcp.WithNumber("attachment", mcp.Required(), mcp.Description("Attachment ID")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListReleaseAttachmentsTool,
Handler: listReleaseAttachmentsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetReleaseAttachmentTool,
Handler: getReleaseAttachmentFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListIssueCommentAttachmentsTool,
Handler: listIssueCommentAttachmentsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetIssueCommentAttachmentTool,
Handler: getIssueCommentAttachmentFn,
})
}
func listReleaseAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called listReleaseAttachmentsFn")
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)
}
release, err := params.GetIndex(args, "release")
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))
}
attachments, _, err := client.ListReleaseAttachments(owner, repo, release, gitea_sdk.ListReleaseAttachmentsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list release attachments err: %v", err))
}
return to.TextResult(slimAttachments(attachments))
}
func getReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called getReleaseAttachmentFn")
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)
}
release, err := params.GetIndex(args, "release")
if err != nil {
return to.ErrorResult(err)
}
attachment, err := params.GetIndex(args, "attachment")
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))
}
att, _, err := client.GetReleaseAttachment(owner, repo, release, attachment)
if err != nil {
return to.ErrorResult(fmt.Errorf("get release attachment err: %v", err))
}
return to.TextResult(slimAttachment(att))
}
func listIssueCommentAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called listIssueCommentAttachmentsFn")
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)
}
comment, err := params.GetIndex(args, "comment")
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))
}
attachments, _, err := client.ListIssueCommentAttachments(owner, repo, comment)
if err != nil {
return to.ErrorResult(fmt.Errorf("list issue comment attachments err: %v", err))
}
return to.TextResult(slimAttachments(attachments))
}
func getIssueCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called getIssueCommentAttachmentFn")
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)
}
comment, err := params.GetIndex(args, "comment")
if err != nil {
return to.ErrorResult(err)
}
attachment, err := params.GetIndex(args, "attachment")
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))
}
att, _, err := client.GetIssueCommentAttachment(owner, repo, comment, attachment)
if err != nil {
return to.ErrorResult(fmt.Errorf("get issue comment attachment err: %v", err))
}
return to.TextResult(slimAttachment(att))
}
func slimAttachments(atts []*gitea_sdk.Attachment) []map[string]interface{} {
result := make([]map[string]interface{}, len(atts))
for i, a := range atts {
result[i] = slimAttachment(a)
}
return result
}
func slimAttachment(a *gitea_sdk.Attachment) map[string]interface{} {
return map[string]interface{}{
"id": a.ID,
"name": a.Name,
"size": a.Size,
"download_count": a.DownloadCount,
"download_url": a.DownloadURL,
"uuid": a.UUID,
"created": a.Created,
}
}
+259
View File
@@ -0,0 +1,259 @@
package collaborator
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListCollaboratorsToolName = "list_collaborators"
GetCollaboratorToolName = "get_collaborator"
AddCollaboratorToolName = "add_collaborator"
DeleteCollaboratorToolName = "delete_collaborator"
CollaboratorPermissionToolName = "collaborator_permission"
)
var Tool = tool.New()
var (
ListCollaboratorsTool = mcp.NewTool(
ListCollaboratorsToolName,
mcp.WithDescription("List collaborators for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("role", mcp.Description("Filter by role (admin, write, read, none)")),
)
GetCollaboratorTool = mcp.NewTool(
GetCollaboratorToolName,
mcp.WithDescription("Check if a user is a collaborator"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to check")),
)
AddCollaboratorTool = mcp.NewTool(
AddCollaboratorToolName,
mcp.WithDescription("Add a collaborator to a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to add")),
mcp.WithString("permission", mcp.Description("Permission level"), mcp.Enum("read", "write", "admin")),
)
DeleteCollaboratorTool = mcp.NewTool(
DeleteCollaboratorToolName,
mcp.WithDescription("Remove a collaborator from a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to remove")),
)
CollaboratorPermissionTool = mcp.NewTool(
CollaboratorPermissionToolName,
mcp.WithDescription("Get collaborator permission details"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListCollaboratorsTool,
Handler: listCollaboratorsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetCollaboratorTool,
Handler: getCollaboratorFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: CollaboratorPermissionTool,
Handler: collaboratorPermissionFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AddCollaboratorTool,
Handler: addCollaboratorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteCollaboratorTool,
Handler: deleteCollaboratorFn,
})
}
func listCollaboratorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called listCollaboratorsFn")
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))
}
opt := gitea_sdk.ListCollaboratorsOptions{}
collabs, _, err := client.ListCollaborators(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list collaborators err: %v", err))
}
return to.TextResult(slimUsers(collabs))
}
func getCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called getCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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))
}
isCollab, _, err := client.IsCollaborator(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("check collaborator err: %v", err))
}
return to.TextResult(map[string]interface{}{
"is_collaborator": isCollab,
})
}
func addCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called addCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
if err != nil {
return to.ErrorResult(err)
}
permission := params.GetOptionalString(args, "permission", "read")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
mode := gitea_sdk.AccessMode(permission)
opt := gitea_sdk.AddCollaboratorOption{
Permission: &mode,
}
_, err = client.AddCollaborator(owner, repo, collaborator, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add collaborator err: %v", err))
}
return to.TextResult("Collaborator added successfully")
}
func deleteCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called deleteCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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.DeleteCollaborator(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete collaborator err: %v", err))
}
return to.TextResult("Collaborator removed successfully")
}
func collaboratorPermissionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called collaboratorPermissionFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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))
}
result, _, err := client.CollaboratorPermission(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("get collaborator permission err: %v", err))
}
return to.TextResult(map[string]interface{}{
"permission": result.Permission,
"role": result.Role,
"user": result.User.UserName,
})
}
func slimUsers(users []*gitea_sdk.User) []map[string]interface{} {
result := make([]map[string]interface{}, len(users))
for i, u := range users {
result[i] = map[string]interface{}{
"id": u.ID,
"username": u.UserName,
"name": u.FullName,
"email": u.Email,
}
}
return result
}
+92
View File
@@ -0,0 +1,92 @@
package compare
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CompareCommitsToolName = "compare_commits"
)
var Tool = tool.New()
var (
CompareCommitsTool = mcp.NewTool(
CompareCommitsToolName,
mcp.WithDescription("Compare commits between two branches/tags/commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithString("base", mcp.Required(), mcp.Description("Base branch/tag/commit")),
mcp.WithString("head", mcp.Required(), mcp.Description("Head branch/tag/commit")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: CompareCommitsTool,
Handler: compareCommitsFn,
})
}
func compareCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Compare] Called compareCommitsFn")
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)
}
base, err := params.GetString(args, "base")
if err != nil {
return to.ErrorResult(err)
}
head, err := params.GetString(args, "head")
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))
}
compare, _, err := client.CompareCommits(owner, repo, base, head)
if err != nil {
return to.ErrorResult(fmt.Errorf("compare commits err: %v", err))
}
return to.TextResult(slimCompare(compare))
}
func slimCompare(c *gitea_sdk.Compare) map[string]interface{} {
result := map[string]interface{}{
"total_commits": c.TotalCommits,
}
if c.Commits != nil {
commits := make([]map[string]interface{}, 0, len(c.Commits))
for _, cmt := range c.Commits {
commits = append(commits, map[string]interface{}{
"sha": cmt.SHA,
"url": cmt.URL,
})
}
result["commits"] = commits
}
return result
}
+208
View File
@@ -0,0 +1,208 @@
package deploykey
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListDeployKeysToolName = "list_deploy_keys"
GetDeployKeyToolName = "get_deploy_key"
CreateDeployKeyToolName = "create_deploy_key"
DeleteDeployKeyToolName = "delete_deploy_key"
)
var Tool = tool.New()
var (
ListDeployKeysTool = mcp.NewTool(
ListDeployKeysToolName,
mcp.WithDescription("List deploy keys for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
GetDeployKeyTool = mcp.NewTool(
GetDeployKeyToolName,
mcp.WithDescription("Get a specific deploy key by ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Deploy key ID")),
)
CreateDeployKeyTool = mcp.NewTool(
CreateDeployKeyToolName,
mcp.WithDescription("Create a new deploy key for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("Title/description for the deploy key")),
mcp.WithString("key", mcp.Required(), mcp.Description("The SSH public key content")),
mcp.WithBoolean("readOnly", mcp.Description("Whether the key is read-only"), mcp.DefaultBool(false)),
)
DeleteDeployKeyTool = mcp.NewTool(
DeleteDeployKeyToolName,
mcp.WithDescription("Delete a deploy key"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Deploy key ID to delete")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListDeployKeysTool,
Handler: listDeployKeysFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetDeployKeyTool,
Handler: getDeployKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateDeployKeyTool,
Handler: createDeployKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteDeployKeyTool,
Handler: deleteDeployKeyFn,
})
}
func listDeployKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called listDeployKeysFn")
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))
}
keys, _, err := client.ListDeployKeys(owner, repo, gitea_sdk.ListDeployKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list deploy keys err: %v", err))
}
return to.TextResult(slimDeployKeys(keys))
}
func getDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called getDeployKeyFn")
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))
}
key, _, err := client.GetDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get deploy key err: %v", err))
}
return to.TextResult(slimDeployKey(key))
}
func createDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called createDeployKeyFn")
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)
}
key, err := params.GetString(args, "key")
if err != nil {
return to.ErrorResult(err)
}
readOnly, _ := args["readOnly"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
createOpt := gitea_sdk.CreateKeyOption{
Title: title,
Key: key,
ReadOnly: readOnly,
}
respKey, _, err := client.CreateDeployKey(owner, repo, createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create deploy key err: %v", err))
}
return to.TextResult(slimDeployKey(respKey))
}
func deleteDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called deleteDeployKeyFn")
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.DeleteDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete deploy key err: %v", err))
}
return to.TextResult("Deploy key deleted successfully")
}
func slimDeployKeys(keys []*gitea_sdk.DeployKey) []map[string]interface{} {
result := make([]map[string]interface{}, len(keys))
for i, k := range keys {
result[i] = slimDeployKey(k)
}
return result
}
func slimDeployKey(k *gitea_sdk.DeployKey) map[string]interface{} {
return map[string]interface{}{
"id": k.ID,
"key": k.Key,
"title": k.Title,
"created": k.Created,
"fingerprint": k.Fingerprint,
"read_only": k.ReadOnly,
}
}
+582
View File
@@ -0,0 +1,582 @@
package operation
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
)
func TestErrorScenarios_NotFound(t *testing.T) {
tests := []struct {
name string
errMsg string
ctx map[string]string
isNotFound bool
}{
{
name: "404 file not found",
errMsg: "request failed with status 404: GetContents error",
ctx: map[string]string{"operation": "GetFile", "path": "README.md"},
isNotFound: true,
},
{
name: "404 repo not found",
errMsg: "request failed with status 404: GetRepo error",
ctx: map[string]string{"operation": "GetRepo"},
isNotFound: true,
},
{
name: "404 issue not found",
errMsg: "request failed with status 404: GetIssue error",
ctx: map[string]string{"operation": "GetIssue"},
isNotFound: true,
},
{
name: "404 pull request not found",
errMsg: "request failed with status 404: GetPullRequest error",
ctx: map[string]string{"operation": "GetPullRequest"},
isNotFound: true,
},
{
name: "401 auth error not found",
errMsg: "request failed with status 401: unauthorized",
ctx: map[string]string{"operation": "GetFile"},
isNotFound: false,
},
{
name: "500 server error not found",
errMsg: "request failed with status 500: internal server error",
ctx: map[string]string{"operation": "GetFile"},
isNotFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, tt.ctx)
if gitea_errors.IsNotFound(translated) != tt.isNotFound {
t.Errorf("IsNotFound() = %v, want %v", gitea_errors.IsNotFound(translated), tt.isNotFound)
}
})
}
}
func TestErrorScenarios_AuthErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isAuthErr bool
}{
{
name: "401 unauthorized",
errMsg: "request failed with status 401: unauthorized",
isAuthErr: true,
},
{
name: "403 forbidden",
errMsg: "request failed with status 403: forbidden",
isAuthErr: true,
},
{
name: "authentication failed message",
errMsg: "authentication failed",
isAuthErr: true,
},
{
name: "permission denied message",
errMsg: "permission denied",
isAuthErr: true,
},
{
name: "token error message",
errMsg: "check your access token",
isAuthErr: true,
},
{
name: "404 not auth error",
errMsg: "request failed with status 404: not found",
isAuthErr: false,
},
{
name: "500 not auth error",
errMsg: "request failed with status 500: server error",
isAuthErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, nil)
if gitea_errors.IsAuthError(translated) != tt.isAuthErr {
t.Errorf("IsAuthError() = %v, want %v", gitea_errors.IsAuthError(translated), tt.isAuthErr)
}
})
}
}
func TestErrorScenarios_NetworkErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isNetworkErr bool
isTimeout bool
}{
{
name: "connection refused",
errMsg: "connection refused",
isNetworkErr: true,
isTimeout: false,
},
{
name: "no such host",
errMsg: "no such host example.com",
isNetworkErr: true,
isTimeout: false,
},
{
name: "network unreachable",
errMsg: "network unreachable",
isNetworkErr: true,
isTimeout: false,
},
{
name: "timeout error",
errMsg: "request timeout",
isNetworkErr: true,
isTimeout: true,
},
{
name: "deadline exceeded",
errMsg: "context deadline exceeded",
isNetworkErr: true,
isTimeout: true,
},
{
name: "dial tcp",
errMsg: "dial tcp: connection refused",
isNetworkErr: true,
isTimeout: false,
},
{
name: "not network error",
errMsg: "file not found",
isNetworkErr: false,
isTimeout: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, nil)
if gitea_errors.IsNetworkError(translated) != tt.isNetworkErr {
t.Errorf("IsNetworkError() = %v, want %v", gitea_errors.IsNetworkError(translated), tt.isNetworkErr)
}
if gitea_errors.IsTimeout(translated) != tt.isTimeout {
t.Errorf("IsTimeout() = %v, want %v", gitea_errors.IsTimeout(translated), tt.isTimeout)
}
})
}
}
func TestErrorScenarios_ActionsAPIUnavailable(t *testing.T) {
tests := []struct {
name string
errMsg string
isActionsUnavailable bool
}{
{
name: "actions 404",
errMsg: "actions endpoint returned 404",
isActionsUnavailable: true,
},
{
name: "actions 405",
errMsg: "actions endpoint returned 405",
isActionsUnavailable: true,
},
{
name: "actions not found",
errMsg: "actions workflow not found",
isActionsUnavailable: true,
},
{
name: "actions method not allowed",
errMsg: "actions method not allowed",
isActionsUnavailable: true,
},
{
name: "actions enhanced error",
errMsg: "not supported on this Gitea version",
isActionsUnavailable: true,
},
{
name: "other actions error",
errMsg: "actions completed successfully",
isActionsUnavailable: false,
},
{
name: "file not found not actions",
errMsg: "file not found",
isActionsUnavailable: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
if gitea_errors.IsActionsAPIUnavailable(err) != tt.isActionsUnavailable {
t.Errorf("IsActionsAPIUnavailable() = %v, want %v", gitea_errors.IsActionsAPIUnavailable(err), tt.isActionsUnavailable)
}
})
}
}
func TestErrorScenarios_HTTPStatusCodes(t *testing.T) {
tests := []struct {
name string
statusCode int
isUnauthorized bool
isForbidden bool
isNotFound bool
isServerError bool
}{
{
name: "HTTP 401",
statusCode: 401,
isUnauthorized: true,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
{
name: "HTTP 403",
statusCode: 403,
isUnauthorized: false,
isForbidden: true,
isNotFound: false,
isServerError: false,
},
{
name: "HTTP 404",
statusCode: 404,
isUnauthorized: false,
isForbidden: false,
isNotFound: true,
isServerError: false,
},
{
name: "HTTP 500",
statusCode: 500,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 502",
statusCode: 502,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 503",
statusCode: 503,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 200",
statusCode: 200,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &testHTTPError{status: tt.statusCode, message: "test error"}
if gitea_errors.IsUnauthorized(err) != tt.isUnauthorized {
t.Errorf("IsUnauthorized() = %v, want %v", gitea_errors.IsUnauthorized(err), tt.isUnauthorized)
}
if gitea_errors.IsForbidden(err) != tt.isForbidden {
t.Errorf("IsForbidden() = %v, want %v", gitea_errors.IsForbidden(err), tt.isForbidden)
}
if gitea_errors.IsNotFoundHTTP(err) != tt.isNotFound {
t.Errorf("IsNotFoundHTTP() = %v, want %v", gitea_errors.IsNotFoundHTTP(err), tt.isNotFound)
}
if gitea_errors.IsServerError(err) != tt.isServerError {
t.Errorf("IsServerError() = %v, want %v", gitea_errors.IsServerError(err), tt.isServerError)
}
})
}
}
func TestErrorScenarios_EnhancedErrorChaining(t *testing.T) {
original := errors.New("original error")
err := gitea_errors.TranslateError(original, map[string]string{
"operation": "TestOp",
"param1": "value1",
})
if !errors.Is(err, original) {
t.Error("enhanced error should wrap original")
}
var enhanced *gitea_errors.EnhancedError
if !errors.As(err, &enhanced) {
t.Fatal("should be able to extract EnhancedError")
}
if enhanced.Operation != "TestOp" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "TestOp")
}
if enhanced.Context["param1"] != "value1" {
t.Errorf("context[param1] = %q, want %q", enhanced.Context["param1"], "value1")
}
formatted := enhanced.Format()
if formatted == "" {
t.Error("Format() should return non-empty string")
}
detailed := enhanced.FormatDetailed()
if detailed == "" {
t.Error("FormatDetailed() should return non-empty string")
}
}
func TestErrorScenarios_FluentAPI(t *testing.T) {
original := errors.New("test error")
enhanced := gitea_errors.TranslateError(original, nil).(*gitea_errors.EnhancedError)
result := enhanced.
WithOperation("GetFile").
WithParam("owner", "gitea").
WithParam("repo", "tea").
WithParam("path", "README.md")
if result != enhanced {
t.Error("fluent API should return same error for chaining")
}
if enhanced.Operation != "GetFile" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "GetFile")
}
if enhanced.Context["owner"] != "gitea" {
t.Errorf("context[owner] = %q, want %q", enhanced.Context["owner"], "gitea")
}
if enhanced.Context["repo"] != "tea" {
t.Errorf("context[repo] = %q, want %q", enhanced.Context["repo"], "tea")
}
if enhanced.Context["path"] != "README.md" {
t.Errorf("context[path] = %q, want %q", enhanced.Context["path"], "README.md")
}
}
type testHTTPError struct {
status int
message string
}
func (e *testHTTPError) Error() string { return e.message }
func (e *testHTTPError) Status() int { return e.status }
func TestErrorScenarios_CrossToolErrorConsistency(t *testing.T) {
ctx := map[string]string{
"owner": "test-owner",
"repo": "test-repo",
"operation": "CrossToolTest",
}
testCases := []struct {
name string
errMsg string
category gitea_errors.ErrorCategory
}{
{
name: "file operation error",
errMsg: "GetContents failed",
category: gitea_errors.CategoryFile,
},
{
name: "auth operation error",
errMsg: "GetUser failed",
category: gitea_errors.CategoryAuth,
},
{
name: "repo operation error",
errMsg: "GetRepo failed",
category: gitea_errors.CategoryRepo,
},
{
name: "issue operation error",
errMsg: "GetIssue failed",
category: gitea_errors.CategoryIssue,
},
{
name: "pull request operation error",
errMsg: "GetPullRequest failed",
category: gitea_errors.CategoryPull,
},
{
name: "branch operation error",
errMsg: "GetBranch failed",
category: gitea_errors.CategoryBranch,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, ctx)
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected EnhancedError")
}
if enhanced.Category != tt.category {
t.Errorf("category = %q, want %q", enhanced.Category, tt.category)
}
if enhanced.Context["owner"] != "test-owner" {
t.Errorf("context[owner] not preserved")
}
if enhanced.Context["repo"] != "test-repo" {
t.Errorf("context[repo] not preserved")
}
})
}
}
func TestErrorScenarios_NilHandling(t *testing.T) {
if gitea_errors.TranslateError(nil, nil) != nil {
t.Error("TranslateError(nil) should return nil")
}
if gitea_errors.IsNotFound(nil) {
t.Error("IsNotFound(nil) should return false")
}
if gitea_errors.IsAuthError(nil) {
t.Error("IsAuthError(nil) should return false")
}
if gitea_errors.IsNetworkError(nil) {
t.Error("IsNetworkError(nil) should return false")
}
if gitea_errors.IsTimeout(nil) {
t.Error("IsTimeout(nil) should return false")
}
if gitea_errors.IsActionsAPIUnavailable(nil) {
t.Error("IsActionsAPIUnavailable(nil) should return false")
}
if gitea_errors.IsServerError(nil) {
t.Error("IsServerError(nil) should return false")
}
if gitea_errors.Wrap(nil, "operation") != nil {
t.Error("Wrap(nil) should return nil")
}
}
func TestErrorScenarios_ServerErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isServerErr bool
}{
{
name: "500 internal server error",
errMsg: "status 500",
isServerErr: true,
},
{
name: "502 bad gateway",
errMsg: "status 502",
isServerErr: true,
},
{
name: "503 service unavailable",
errMsg: "status 503",
isServerErr: true,
},
{
name: "504 gateway timeout",
errMsg: "status 504",
isServerErr: true,
},
{
name: "404 not server error",
errMsg: "status 404",
isServerErr: false,
},
{
name: "200 not server error",
errMsg: "status 200",
isServerErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
if gitea_errors.IsServerError(err) != tt.isServerErr {
t.Errorf("IsServerError() = %v, want %v", gitea_errors.IsServerError(err), tt.isServerErr)
}
})
}
}
func TestErrorScenarios_ErrorWrapping(t *testing.T) {
original := errors.New("original error")
wrapped := gitea_errors.Wrap(original, "GetFile")
if !errors.Is(wrapped, original) {
t.Error("wrapped error should contain original")
}
var enhanced *gitea_errors.EnhancedError
if !errors.As(wrapped, &enhanced) {
t.Fatal("wrapped should be EnhancedError")
}
if enhanced.Operation != "GetFile" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "GetFile")
}
}
func TestErrorScenarios_ErrorUnwrap(t *testing.T) {
original := errors.New("original error")
enhanced := gitea_errors.NewEnhancedError(original, "translated", gitea_errors.CategoryFile)
unwrapped := enhanced.Unwrap()
if unwrapped != original {
t.Error("Unwrap() should return original error")
}
if !errors.Is(enhanced, original) {
t.Error("errors.Is should find original through unwrapping")
}
}
+507
View File
@@ -0,0 +1,507 @@
package issue
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListRepoIssuesToolName = "list_issues"
IssueReadToolName = "issue_read"
IssueWriteToolName = "issue_write"
)
var (
ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
IssueReadTool = mcp.NewTool(
IssueReadToolName,
mcp.WithDescription("Get information about a specific issue. Use method 'get' for issue details, 'get_comments' for issue comments, 'get_labels' for issue labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
)
IssueWriteTool = mcp.NewTool(
IssueWriteToolName,
mcp.WithDescription("Create or update issues and comments, manage labels. Use method 'create' to create an issue, 'update' to edit, 'add_comment'/'edit_comment' for comments, 'add_labels'/'remove_label'/'replace_labels'/'clear_labels' for label management."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("issue title (required for 'create')")),
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
)
)
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) {
log.Debugf("Called getIssueByIndexFn")
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(), "index")
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))
}
issue, _, err := client.GetIssue(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(issue))
}
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssuesFn")
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"
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state),
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))
}
issues, _, err := client.ListRepoIssues(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) {
log.Debugf("Called createIssueFn")
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
}
}
issue, _, err := client.CreateIssue(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) {
log.Debugf("Called createIssueCommentFn")
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(), "index")
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.CreateIssueComment(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) {
log.Debugf("Called editIssueFn")
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(), "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditIssueOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
body, ok := req.GetArguments()["body"].(string)
if ok {
opt.Body = new(body)
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = new(milestone)
}
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.EditIssue(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) {
log.Debugf("Called editIssueCommentFn")
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.EditIssueComment(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) {
log.Debugf("Called getIssueCommentsByIndexFn")
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(), "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.ListIssueCommentOptions{}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.ListIssueComments(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
}
return to.TextResult(slimComments(issue))
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueLabelsFn")
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(), "index")
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.GetIssueLabels(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(slimLabels(labels))
}
// Issue label operations (moved from label package)
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addIssueLabelsFn")
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(), "index")
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.AddIssueLabels(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(slimLabels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called replaceIssueLabelsFn")
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(), "index")
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.ReplaceIssueLabels(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(slimLabels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called clearIssueLabelsFn")
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(), "index")
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.ClearIssueLabels(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) {
log.Debugf("Called removeIssueLabelFn")
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(), "index")
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.DeleteIssueLabel(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")
}
+133
View File
@@ -0,0 +1,133 @@
package issue
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
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 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": userLogin(i.Poster),
"labels": 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"] = userLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"title": i.Milestone.Title,
}
}
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": userLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
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": userLogin(c.Poster),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimComments(comments []*gitea_sdk.Comment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimComment(c))
}
return out
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
if l == nil {
continue
}
out = append(out, map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
})
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package issue
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
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)
}
}
+377
View File
@@ -0,0 +1,377 @@
package label
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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 label information. Use method 'list_repo_labels' to list repository labels, 'get_repo_label' to get a specific repo label, 'list_org_labels' to list organization labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
LabelWriteTool = mcp.NewTool(
LabelWriteToolName,
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), 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("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")),
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels 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) {
log.Debugf("Called listRepoLabelsFn")
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.ListRepoLabels(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
}
return to.TextResult(slimLabels(labels))
}
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoLabelFn")
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.GetRepoLabel(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(slimLabel(label))
}
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoLabelFn")
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
opt := gitea_sdk.CreateLabelOption{
Name: name,
Color: color,
Description: description,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.CreateLabel(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
}
return to.TextResult(slimLabel(label))
}
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoLabelFn")
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)
}
opt := gitea_sdk.EditLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.EditLabel(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(slimLabel(label))
}
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoLabelFn")
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.DeleteLabel(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) {
log.Debugf("Called listOrgLabelsFn")
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.ListOrgLabels(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
}
return to.TextResult(slimLabels(labels))
}
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgLabelFn")
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.CreateOrgLabel(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
}
return to.TextResult(slimLabel(label))
}
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgLabelFn")
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)
}
opt := gitea_sdk.EditOrgLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = new(exclusive)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.EditOrgLabel(org, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err))
}
return to.TextResult(slimLabel(label))
}
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgLabelFn")
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.DeleteOrgLabel(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")
}
+26
View File
@@ -0,0 +1,26 @@
package label
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimLabel(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 slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, slimLabel(l))
}
return out
}
+25
View File
@@ -0,0 +1,25 @@
package label
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := slimLabel(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"])
}
}
+256
View File
@@ -0,0 +1,256 @@
package milestone
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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 milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName,
mcp.WithDescription("Create, edit, or delete milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed (for 'edit')")),
)
)
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 "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) {
log.Debugf("Called getMilestoneFn")
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.GetMilestone(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) {
log.Debugf("Called listMilestonesFn")
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.ListRepoMilestones(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) {
log.Debugf("Called createMilestoneFn")
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
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.CreateMilestone(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) {
log.Debugf("Called editMilestoneFn")
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)
}
opt := gitea_sdk.EditMilestoneOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = new(description)
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.EditMilestone(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) {
log.Debugf("Called deleteMilestoneFn")
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.DeleteMilestone(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")
}
+28
View File
@@ -0,0 +1,28 @@
package milestone
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
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
}
+256
View File
@@ -0,0 +1,256 @@
package mirror
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListPushMirrorsToolName = "list_push_mirrors"
GetPushMirrorToolName = "get_push_mirror"
CreatePushMirrorToolName = "create_push_mirror"
DeletePushMirrorToolName = "delete_push_mirror"
SyncMirrorToolName = "sync_mirror"
)
var Tool = tool.New()
var (
ListPushMirrorsTool = mcp.NewTool(
ListPushMirrorsToolName,
mcp.WithDescription("List push mirrors for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
GetPushMirrorTool = mcp.NewTool(
GetPushMirrorToolName,
mcp.WithDescription("Get a specific push mirror by remote name"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
)
CreatePushMirrorTool = mcp.NewTool(
CreatePushMirrorToolName,
mcp.WithDescription("Create a push mirror for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
mcp.WithString("remote_addr", mcp.Required(), mcp.Description("Remote address (git URL)")),
mcp.WithString("sync_interval", mcp.Description("Sync interval (e.g. 8h30m0s)")),
mcp.WithBoolean("sync_on_commit", mcp.Description("Sync on commit"), mcp.DefaultBool(false)),
)
DeletePushMirrorTool = mcp.NewTool(
DeletePushMirrorToolName,
mcp.WithDescription("Delete a push mirror"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
)
SyncMirrorTool = mcp.NewTool(
SyncMirrorToolName,
mcp.WithDescription("Trigger immediate sync for a mirror repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListPushMirrorsTool,
Handler: listPushMirrorsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetPushMirrorTool,
Handler: getPushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreatePushMirrorTool,
Handler: createPushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeletePushMirrorTool,
Handler: deletePushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: SyncMirrorTool,
Handler: syncMirrorFn,
})
}
func listPushMirrorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called listPushMirrorsFn")
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))
}
mirrors, _, err := client.ListPushMirrors(owner, repo, gitea_sdk.ListOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list push mirrors err: %v", err))
}
return to.TextResult(slimMirrors(mirrors))
}
func getPushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called getPushMirrorFn")
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)
}
remoteName, err := params.GetString(args, "remote_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))
}
mirror, _, err := client.GetPushMirrorByRemoteName(owner, repo, remoteName)
if err != nil {
return to.ErrorResult(fmt.Errorf("get push mirror err: %v", err))
}
return to.TextResult(slimMirror(mirror))
}
func createPushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called createPushMirrorFn")
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)
}
remoteAddr, err := params.GetString(args, "remote_addr")
if err != nil {
return to.ErrorResult(err)
}
syncInterval, _ := args["sync_interval"].(string)
syncOnCommit, _ := args["sync_on_commit"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.CreatePushMirrorOption{
RemoteAddress: remoteAddr,
Interval: syncInterval,
SyncONCommit: syncOnCommit,
}
mirror, _, err := client.PushMirrors(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create push mirror err: %v", err))
}
return to.TextResult(slimMirror(mirror))
}
func deletePushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called deletePushMirrorFn")
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)
}
remoteName, err := params.GetString(args, "remote_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.DeletePushMirror(owner, repo, remoteName)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete push mirror err: %v", err))
}
return to.TextResult("Push mirror deleted successfully")
}
func syncMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called syncMirrorFn")
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))
}
_, err = client.MirrorSync(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("sync mirror err: %v", err))
}
return to.TextResult("Mirror sync triggered successfully")
}
func slimMirrors(mirrors []*gitea_sdk.PushMirrorResponse) []map[string]interface{} {
result := make([]map[string]interface{}, len(mirrors))
for i, m := range mirrors {
result[i] = slimMirror(m)
}
return result
}
func slimMirror(m *gitea_sdk.PushMirrorResponse) map[string]interface{} {
return map[string]interface{}{
"remote_name": m.RemoteName,
"remote_addr": m.RemoteAddress,
"interval": m.Interval,
"sync_on_commit": m.SyncONCommit,
"last_update": m.LastUpdate,
"last_error": m.LastError,
}
}
+218
View File
@@ -0,0 +1,218 @@
package notification
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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. Use method 'list' to list all notifications, 'list_repo' for repo-specific notifications, 'check' to get unread count."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "list_repo", "check")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_repo')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_repo')")),
mcp.WithString("status", mcp.Description("status filter"), mcp.Enum("unread", "read", "pinned")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
NotificationWriteTool = mcp.NewTool(
NotificationWriteToolName,
mcp.WithDescription("Mark notifications as read."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("read", "read_repo")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'read_repo')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'read_repo')")),
mcp.WithNumber("id", mcp.Description("notification ID (optional, marks single if provided)")),
)
)
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) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listNotificationsFn(ctx, req)
case "list_repo":
return listRepoNotificationsFn(ctx, req)
case "check":
return checkNotificationsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func notificationWriteFn(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 "read":
return readNotificationsFn(ctx, req)
case "read_repo":
return readRepoNotificationsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listNotificationsFn")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
status, _ := req.GetArguments()["status"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListNotificationOptions{
Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)},
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
notifications, _, err := client.ListNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
}
return to.TextResult(slimNotifications(notifications))
}
func listRepoNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoNotificationsFn")
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)
status, _ := req.GetArguments()["status"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListNotificationOptions{
Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)},
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
notifications, _, err := client.ListRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo notifications err: %v", err))
}
return to.TextResult(slimNotifications(notifications))
}
func checkNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called checkNotificationsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
count, _, err := client.CheckNotifications()
if err != nil {
return to.ErrorResult(fmt.Errorf("check notifications err: %v", err))
}
return to.TextResult(map[string]any{"unread_count": count})
}
func readNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called readNotificationsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
id, hasID := req.GetArguments()["id"].(float64)
if hasID && id > 0 {
_, _, err := client.ReadNotification(int64(id), gitea_sdk.NotifyStatusRead)
if err != nil {
return to.ErrorResult(fmt.Errorf("read notification err: %v", err))
}
return to.TextResult("Notification marked as read")
}
opt := gitea_sdk.MarkNotificationOptions{Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatusRead}}
_, _, err = client.ReadNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("read notifications err: %v", err))
}
return to.TextResult("All notifications marked as read")
}
func readRepoNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called readRepoNotificationsFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.MarkNotificationOptions{Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatusRead}}
_, _, err = client.ReadRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("read repo notifications err: %v", err))
}
return to.TextResult("Repository notifications marked as read")
}
func slimNotifications(notifications []*gitea_sdk.NotificationThread) []map[string]any {
out := make([]map[string]any, 0, len(notifications))
for _, n := range notifications {
out = append(out, map[string]any{
"id": n.ID,
"unread": n.Unread,
"subject": n.Subject.Title,
"type": n.Subject.Type,
"url": n.Subject.URL,
"repository": n.Repository.FullName,
})
}
return out
}
+230
View File
@@ -0,0 +1,230 @@
package operation
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea.com/gitea/gitea-mcp/operation/accesstoken"
"gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/activity"
"gitea.com/gitea/gitea-mcp/operation/attachment"
"gitea.com/gitea/gitea-mcp/operation/collaborator"
"gitea.com/gitea/gitea-mcp/operation/compare"
"gitea.com/gitea/gitea-mcp/operation/deploykey"
"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/mirror"
"gitea.com/gitea/gitea-mcp/operation/notification"
"gitea.com/gitea/gitea-mcp/operation/org"
"gitea.com/gitea/gitea-mcp/operation/orgmember"
"gitea.com/gitea/gitea-mcp/operation/packages"
"gitea.com/gitea/gitea-mcp/operation/protection"
"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/settings"
"gitea.com/gitea/gitea-mcp/operation/sshkey"
"gitea.com/gitea/gitea-mcp/operation/stars"
"gitea.com/gitea/gitea-mcp/operation/team"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/transfer"
"gitea.com/gitea/gitea-mcp/operation/user"
"gitea.com/gitea/gitea-mcp/operation/version"
"gitea.com/gitea/gitea-mcp/operation/webhook"
"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"
"github.com/mark3labs/mcp-go/server"
)
var mcpServer *server.MCPServer
func RegisterTool(s *server.MCPServer) {
// User Tool
s.AddTools(user.Tool.Tools()...)
// Actions Tool
s.AddTools(actions.Tool.Tools()...)
// Repo Tool
s.AddTools(repo.Tool.Tools()...)
// Issue Tool
s.AddTools(issue.Tool.Tools()...)
// Label Tool
s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool
s.AddTools(pull.Tool.Tools()...)
// Search Tool
s.AddTools(search.Tool.Tools()...)
// Version Tool
s.AddTools(version.Tool.Tools()...)
// Wiki Tool
s.AddTools(wiki.Tool.Tools()...)
// Time Tracking Tool
s.AddTools(timetracking.Tool.Tools()...)
// Org Tool
s.AddTools(org.Tool.Tools()...)
// Team Tool
s.AddTools(team.Tool.Tools()...)
// Notification Tool
s.AddTools(notification.Tool.Tools()...)
// Webhook Tool
s.AddTools(webhook.Tool.Tools()...)
// Branch Protection Tool
s.AddTools(protection.Tool.Tools()...)
// Stars Tool
s.AddTools(stars.Tool.Tools()...)
// SSH Key Tool
s.AddTools(sshkey.Tool.Tools()...)
// Deploy Key Tool
s.AddTools(deploykey.Tool.Tools()...)
// Access Token Tool
s.AddTools(accesstoken.Tool.Tools()...)
// Activity Tool
s.AddTools(activity.Tool.Tools()...)
// Settings Tool
s.AddTools(settings.Tool.Tools()...)
// Packages Tool
s.AddTools(packages.Tool.Tools()...)
// Collaborator Tool
s.AddTools(collaborator.Tool.Tools()...)
// Attachment Tool
s.AddTools(attachment.Tool.Tools()...)
// Mirror Tool
s.AddTools(mirror.Tool.Tools()...)
// Compare Tool
s.AddTools(compare.Tool.Tools()...)
// Transfer Tool
s.AddTools(transfer.Tool.Tools()...)
// OrgMember Tool
s.AddTools(orgmember.Tool.Tools()...)
s.DeleteTools("")
}
// parseAuthToken extracts the token from an Authorization header.
// Supports "Bearer <token>" (case-insensitive per RFC 7235) and
// Gitea-style "token <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.New()),
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(),
)
}
+105
View File
@@ -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)
}
})
}
}
+313
View File
@@ -0,0 +1,313 @@
package org
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
OrgReadToolName = "org_read"
OrgWriteToolName = "org_write"
)
var (
OrgReadTool = mcp.NewTool(
OrgReadToolName,
mcp.WithDescription("Read organization information. Use method 'get' to get org details, 'list' to list orgs, 'list_members' to list org members, 'list_teams' to list org teams."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list", "list_members", "list_teams")),
mcp.WithString("org", mcp.Description("organization name (required for 'get', 'list_members', 'list_teams')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
OrgWriteTool = mcp.NewTool(
OrgWriteToolName,
mcp.WithDescription("Create or update organizations."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("org", mcp.Description("organization name (required for 'update', 'delete')")),
mcp.WithString("description", mcp.Description("organization description")),
mcp.WithString("full_name", mcp.Description("full name")),
mcp.WithString("location", mcp.Description("location")),
mcp.WithString("website", mcp.Description("website")),
mcp.WithString("visibility", mcp.Description("visibility"), mcp.Enum("public", "private", "limited")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: OrgReadTool,
Handler: orgReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: OrgWriteTool,
Handler: orgWriteFn,
})
}
func orgReadFn(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 getOrgFn(ctx, req)
case "list":
return listOrgsFn(ctx, req)
case "list_members":
return listOrgMembersFn(ctx, req)
case "list_teams":
return listOrgTeamsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func orgWriteFn(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 createOrgFn(ctx, req)
case "update":
return updateOrgFn(ctx, req)
case "delete":
return deleteOrgFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
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))
}
orgData, _, err := client.GetOrg(org)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org err: %v", err))
}
return to.TextResult(slimOrg(orgData))
}
func listOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgsFn")
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.ListMyOrgs(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
func listOrgMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgMembersFn")
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))
}
opt := gitea_sdk.ListOrgMembershipOption{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
members, _, err := client.ListOrgMembership(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org members err: %v", err))
}
return to.TextResult(slimUsers(members))
}
func listOrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgTeamsFn")
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))
}
opt := gitea_sdk.ListTeamsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
teams, _, err := client.ListOrgTeams(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org teams err: %v", err))
}
return to.TextResult(slimTeams(teams))
}
func createOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgFn")
name, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
fullName, _ := req.GetArguments()["full_name"].(string)
location, _ := req.GetArguments()["location"].(string)
website, _ := req.GetArguments()["website"].(string)
visibility, _ := req.GetArguments()["visibility"].(string)
opt := gitea_sdk.CreateOrgOption{
Name: name,
Description: description,
FullName: fullName,
Location: location,
Website: website,
Visibility: gitea_sdk.VisibleType(visibility),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
org, _, err := client.CreateOrg(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create org err: %v", err))
}
return to.TextResult(slimOrg(org))
}
func updateOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditOrgOption{}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = description
}
if fullName, ok := req.GetArguments()["full_name"].(string); ok {
opt.FullName = fullName
}
if location, ok := req.GetArguments()["location"].(string); ok {
opt.Location = location
}
if website, ok := req.GetArguments()["website"].(string); ok {
opt.Website = website
}
if visibility, ok := req.GetArguments()["visibility"].(string); ok {
opt.Visibility = gitea_sdk.VisibleType(visibility)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.EditOrg(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update org err: %v", err))
}
return to.TextResult("Organization updated successfully")
}
func deleteOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
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.DeleteOrg(org)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org err: %v", err))
}
return to.TextResult("Organization deleted successfully")
}
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,
"location": o.Location,
"visibility": o.Visibility,
}
}
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
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
})
}
return out
}
+160
View File
@@ -0,0 +1,160 @@
package orgmember
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CheckOrgMembershipToolName = "check_org_membership"
SetPublicOrgMembershipToolName = "set_public_org_membership"
ListUserOrgsToolName = "list_user_orgs_public"
)
var Tool = tool.New()
var (
CheckOrgMembershipTool = mcp.NewTool(
CheckOrgMembershipToolName,
mcp.WithDescription("Check if a user is a member of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("user", mcp.Required(), mcp.Description("Username to check")),
)
SetPublicOrgMembershipTool = mcp.NewTool(
SetPublicOrgMembershipToolName,
mcp.WithDescription("Set public organization membership visibility"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("user", mcp.Required(), mcp.Description("Username")),
mcp.WithBoolean("visible", mcp.Required(), mcp.Description("Make membership public (true) or private (false)")),
)
ListUserOrgsTool = mcp.NewTool(
ListUserOrgsToolName,
mcp.WithDescription("List organizations a user belongs to"),
mcp.WithString("user", mcp.Required(), mcp.Description("Username")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: CheckOrgMembershipTool,
Handler: checkOrgMembershipFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListUserOrgsTool,
Handler: listUserOrgsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: SetPublicOrgMembershipTool,
Handler: setPublicOrgMembershipFn,
})
}
func checkOrgMembershipFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called checkOrgMembershipFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(args, "user")
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))
}
isMember, _, err := client.CheckOrgMembership(org, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("check org membership err: %v", err))
}
return to.TextResult(map[string]interface{}{
"is_member": isMember,
})
}
func setPublicOrgMembershipFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called setPublicOrgMembershipFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(args, "user")
if err != nil {
return to.ErrorResult(err)
}
visible, _ := args["visible"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.SetPublicOrgMembership(org, user, visible)
if err != nil {
return to.ErrorResult(fmt.Errorf("set public org membership err: %v", err))
}
return to.TextResult(fmt.Sprintf("Public org membership %s for %s", map[bool]string{true: "enabled", false: "disabled"}[visible], user))
}
func listUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called listUserOrgsFn")
args := req.GetArguments()
user, err := params.GetString(args, "user")
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))
}
opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
orgs, _, err := client.ListUserOrgs(user, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list user orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]interface{} {
result := make([]map[string]interface{}, len(orgs))
for i, o := range orgs {
result[i] = map[string]interface{}{
"id": o.ID,
"username": o.UserName,
"full_name": o.FullName,
"description": o.Description,
"website": o.Website,
}
}
return result
}
+284
View File
@@ -0,0 +1,284 @@
package packages
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListPackagesToolName = "list_packages"
GetPackageToolName = "get_package"
GetLatestPackageToolName = "get_latest_package"
DeletePackageToolName = "delete_package"
ListPackageFilesToolName = "list_package_files"
)
var Tool = tool.New()
var (
ListPackagesTool = mcp.NewTool(
ListPackagesToolName,
mcp.WithDescription("List packages for a user/org"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner (user or org) name")),
mcp.WithString("type", mcp.Description("Package type (container, npm, pypi, etc.)")),
mcp.WithString("name", mcp.Description("Filter by package name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
GetPackageTool = mcp.NewTool(
GetPackageToolName,
mcp.WithDescription("Get a specific package version"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Required(), mcp.Description("Package version")),
)
GetLatestPackageTool = mcp.NewTool(
GetLatestPackageToolName,
mcp.WithDescription("Get the latest version of a package"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
)
DeletePackageTool = mcp.NewTool(
DeletePackageToolName,
mcp.WithDescription("Delete a package or specific version"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Description("Specific version to delete (omit to delete all)")),
)
ListPackageFilesTool = mcp.NewTool(
ListPackageFilesToolName,
mcp.WithDescription("List files in a package"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Required(), mcp.Description("Package version")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListPackagesTool,
Handler: listPackagesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetPackageTool,
Handler: getPackageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetLatestPackageTool,
Handler: getLatestPackageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListPackageFilesTool,
Handler: listPackageFilesFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeletePackageTool,
Handler: deletePackageFn,
})
}
func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called listPackagesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
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))
}
opt := gitea_sdk.ListPackagesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
pkgs, _, err := client.ListPackages(owner, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list packages err: %v", err))
}
return to.TextResult(slimPackages(pkgs))
}
func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called getPackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, 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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pkg, _, err := client.GetPackage(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("get package err: %v", err))
}
return to.TextResult(slimPackage(pkg))
}
func getLatestPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called getLatestPackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, 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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pkg, _, err := client.GetLatestPackage(owner, packageType, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get latest package err: %v", err))
}
return to.TextResult(slimPackage(pkg))
}
func deletePackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called deletePackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, 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, _ := args["version"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeletePackage(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete package err: %v", err))
}
return to.TextResult("Package deleted successfully")
}
func listPackageFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called listPackageFilesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, 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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
files, _, err := client.ListPackageFiles(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("list package files err: %v", err))
}
return to.TextResult(slimPackageFiles(files))
}
func slimPackages(pkgs []*gitea_sdk.Package) []map[string]interface{} {
result := make([]map[string]interface{}, len(pkgs))
for i, p := range pkgs {
result[i] = slimPackage(p)
}
return result
}
func slimPackage(p *gitea_sdk.Package) map[string]interface{} {
return map[string]interface{}{
"id": p.ID,
"name": p.Name,
"version": p.Version,
"package_type": p.Type,
"created_at": p.CreatedAt,
"owner": p.Owner.UserName,
}
}
func slimPackageFiles(files []*gitea_sdk.PackageFile) []map[string]interface{} {
result := make([]map[string]interface{}, len(files))
for i, f := range files {
result[i] = map[string]interface{}{
"id": f.ID,
"name": f.Name,
"size": f.Size,
"md5": f.MD5,
"sha256": f.SHA256,
"sha512": f.SHA512,
}
}
return result
}
+268
View File
@@ -0,0 +1,268 @@
package protection
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ProtectionReadToolName = "protection_read"
ProtectionWriteToolName = "protection_write"
)
var (
ProtectionReadTool = mcp.NewTool(
ProtectionReadToolName,
mcp.WithDescription("Read branch protection. Use method 'list' to list all protections, 'get' to get specific branch protection."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Description("branch name (required for 'get')")),
)
ProtectionWriteTool = mcp.NewTool(
ProtectionWriteToolName,
mcp.WithDescription("Create, update, or delete branch protection rules."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("branch name")),
mcp.WithBoolean("require_signed_commits", mcp.Description("require signed commits")),
mcp.WithBoolean("enable_status_check", mcp.Description("enable status checks")),
mcp.WithNumber("required_approvals", mcp.Description("required approval count")),
mcp.WithBoolean("dismiss_stale_approvals", mcp.Description("dismiss stale approvals")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ProtectionReadTool,
Handler: protectionReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ProtectionWriteTool,
Handler: protectionWriteFn,
})
}
func protectionReadFn(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 listProtectionsFn(ctx, req)
case "get":
return getProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func protectionWriteFn(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 createProtectionFn(ctx, req)
case "edit":
return editProtectionFn(ctx, req)
case "delete":
return deleteProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listProtectionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listProtectionsFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
protections, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list branch protections err: %v", err))
}
return to.TextResult(slimProtections(protections))
}
func getProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getProtectionFn")
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)
}
branch, err := params.GetString(req.GetArguments(), "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))
}
protection, _, err := client.GetBranchProtection(owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("get branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func createProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createProtectionFn")
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)
}
branch, err := params.GetString(req.GetArguments(), "branch")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.CreateBranchProtectionOption{
BranchName: branch,
}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = v
}
if v, ok := args["enable_status_check"].(bool); ok {
opt.EnableStatusCheck = v
}
if v, ok := args["required_approvals"].(float64); ok {
opt.RequiredApprovals = int64(v)
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
protection, _, err := client.CreateBranchProtection(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func editProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editProtectionFn")
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)
}
branch, err := params.GetString(req.GetArguments(), "branch")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditBranchProtectionOption{}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = &v
}
if v, ok := args["enable_status_check"].(bool); ok {
opt.EnableStatusCheck = &v
}
if v, ok := args["required_approvals"].(float64); ok {
vv := int64(v)
opt.RequiredApprovals = &vv
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = &v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
protection, _, err := client.EditBranchProtection(owner, repo, branch, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func deleteProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteProtectionFn")
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)
}
branch, err := params.GetString(req.GetArguments(), "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.DeleteBranchProtection(owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch protection err: %v", err))
}
return to.TextResult("Branch protection deleted successfully")
}
func slimProtections(bps []*gitea_sdk.BranchProtection) []map[string]any {
out := make([]map[string]any, 0, len(bps))
for _, bp := range bps {
out = append(out, slimProtection(bp))
}
return out
}
func slimProtection(bp *gitea_sdk.BranchProtection) map[string]any {
if bp == nil {
return nil
}
return map[string]any{
"branch_name": bp.BranchName,
"rule_name": bp.RuleName,
"require_signed_commits": bp.RequireSignedCommits,
"enable_status_check": bp.EnableStatusCheck,
"required_approvals": bp.RequiredApprovals,
"dismiss_stale_approvals": bp.DismissStaleApprovals,
}
}
+812
View File
@@ -0,0 +1,812 @@
package pull
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithDescription("List repository pull requests"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("state"), mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone", mcp.Description("milestone")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName,
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")),
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")),
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")),
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
)
PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")),
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body", mcp.Description("review body/comment")),
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")),
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")),
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "file path to comment on"},
"body": map[string]any{"type": "string", "description": "comment body"},
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"},
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"},
},
})),
)
)
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_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 "merge":
return mergePullRequestFn(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 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) {
log.Debugf("Called getPullRequestByIndexFn")
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, "index")
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.GetPullRequest(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestDiffFn")
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, "index")
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.GetPullRequestDiff(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) {
log.Debugf("Called ListRepoPullRequests")
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.ListRepoPullRequests(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))
}
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{
Title: title,
Body: body,
Head: head,
Base: base,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
}
return to.TextResult(slimPullRequest(pr))
}
func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestReviewerFn")
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, "index")
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))
}
_, err = client.CreateReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully created review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewerFn")
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, "index")
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))
}
_, err = client.DeleteReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewsFn")
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, "index")
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.ListPullReviews(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) {
log.Debugf("Called getPullRequestReviewFn")
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, "index")
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.GetPullReview(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) {
log.Debugf("Called listPullRequestReviewCommentsFn")
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, "index")
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.ListPullReviewComments(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) {
log.Debugf("Called createPullRequestReviewFn")
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, "index")
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.CreatePullReview(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) {
log.Debugf("Called submitPullRequestReviewFn")
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, "index")
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.SubmitPullReview(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) {
log.Debugf("Called deletePullRequestReviewFn")
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, "index")
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.DeletePullReview(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) {
log.Debugf("Called dismissPullRequestReviewFn")
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, "index")
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.DismissPullReview(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) {
log.Debugf("Called mergePullRequestFn")
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, "index")
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))
}
opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title,
Message: message,
DeleteBranchAfterMerge: deleteBranch,
}
merged, resp, err := client.MergePullRequest(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) {
log.Debugf("Called editPullRequestFn")
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, "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditPullRequestOption{}
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if body, ok := args["body"].(string); ok {
opt.Body = new(body)
}
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 {
opt.State = new(gitea_sdk.StateType(state))
}
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.EditPullRequest(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))
}
+379
View File
@@ -0,0 +1,379 @@
package pull
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"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,
"index": 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", 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,
"index": 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_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,
"index": 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)
}
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package pull
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
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 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": userLogin(pr.Poster),
"labels": 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"] = 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"] = 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": 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"] = 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": 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": 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
}
+124
View File
@@ -0,0 +1,124 @@
package pull
import (
"testing"
"time"
gitea_sdk "code.gitea.io/sdk/gitea"
)
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)
}
}
+150
View File
@@ -0,0 +1,150 @@
package repo
import (
"context"
"fmt"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithDescription("Create branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")),
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")),
)
DeleteBranchTool = mcp.NewTool(
DeleteBranchToolName,
mcp.WithDescription("Delete branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")),
)
ListBranchesTool = mcp.NewTool(
ListBranchesToolName,
mcp.WithDescription("List branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
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) {
log.Debugf("Called CreateBranchFn")
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.CreateBranch(owner, repo, gitea_sdk.CreateBranchOption{
BranchName: branch,
OldBranchName: oldBranch,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
}
return mcp.NewToolResultText("Branch Created"), nil
}
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteBranchFn")
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.DeleteRepoBranch(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) {
log.Debugf("Called ListBranchesFn")
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)
}
opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 30,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
branches, _, err := client.ListRepoBranches(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
}
return to.TextResult(slimBranches(branches))
}
+77
View File
@@ -0,0 +1,77 @@
package repo
import (
"context"
"fmt"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoCommitsToolName = "list_commits"
)
var ListRepoCommitsTool = mcp.NewTool(
ListRepoCommitsToolName,
mcp.WithDescription("List repository commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn,
})
}
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoCommitsFn")
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, err := params.GetIndex(args, "page")
if err != nil {
return to.ErrorResult(err)
}
pageSize, err := params.GetIndex(args, "perPage")
if err != nil {
return to.ErrorResult(err)
}
sha, _ := args["sha"].(string)
path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(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.ListRepoCommits(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
}
return to.TextResult(slimCommits(commits))
}
+321
View File
@@ -0,0 +1,321 @@
package repo
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")),
)
GetDirContentTool = mcp.NewTool(
GetDirToolName,
mcp.WithDescription("Get a list of entries in a directory"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
)
CreateOrUpdateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
)
DeleteFileTool = mcp.NewTool(
DeleteFileToolName,
mcp.WithDescription("Delete file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")),
)
)
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) {
log.Debugf("Called GetFileFn")
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, "filePath")
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.GetContents(owner, repo, ref, filePath)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": owner,
"repo": repo,
"path": filePath,
"ref": ref,
})
return to.ErrorResult(translatedErr)
}
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) {
log.Debugf("Called GetDirContentFn")
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, "filePath")
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.ListContents(owner, repo, ref, filePath)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "GetDir",
"owner": owner,
"repo": repo,
"path": filePath,
"ref": ref,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult(slimDirEntries(content))
}
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrUpdateFileFn")
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, "filePath")
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.UpdateFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "UpdateFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
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.CreateFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "CreateFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult("Create file success")
}
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteFileFn")
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, "filePath")
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.DeleteFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "DeleteFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult("Delete file success")
}
+365
View File
@@ -0,0 +1,365 @@
package repo
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
)
// mockClientError is a mock error that simulates SDK errors
type mockClientError struct {
message string
}
func (e *mockClientError) Error() string {
return e.message
}
func TestErrorTranslation_GetFile(t *testing.T) {
// Test that GetContentsOrList errors are translated properly
err := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"ref": "main",
})
// Should return an EnhancedError
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
// Check operation
if enhanced.Operation != "GetFile" {
t.Errorf("expected operation GetFile, got %s", enhanced.Operation)
}
// Check context
if enhanced.Context["owner"] != "karti-ai" {
t.Errorf("expected owner karti-ai, got %s", enhanced.Context["owner"])
}
if enhanced.Context["path"] != "README.md" {
t.Errorf("expected path README.md, got %s", enhanced.Context["path"])
}
// Should be a file-related error
if enhanced.Category != gitea_errors.CategoryFile {
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
}
// Should be identified as NotFound
if !gitea_errors.IsNotFound(translated) {
t.Error("expected error to be identified as NotFound")
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_GetDir(t *testing.T) {
// Test that ListContents errors are translated properly
err := errors.New("ListContents: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetDir",
"owner": "karti-ai",
"repo": "public_website",
"path": ".gitea/workflows",
"ref": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "GetDir" {
t.Errorf("expected operation GetDir, got %s", enhanced.Operation)
}
if enhanced.Category != gitea_errors.CategoryFile {
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_CreateFile(t *testing.T) {
// Test that CreateFile errors are translated properly
err := errors.New("CreateFile: 422 Unprocessable Entity")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "CreateFile",
"owner": "karti-ai",
"repo": "docs",
"path": "newfile.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "CreateFile" {
t.Errorf("expected operation CreateFile, got %s", enhanced.Operation)
}
if enhanced.Context["path"] != "newfile.md" {
t.Errorf("expected path newfile.md, got %s", enhanced.Context["path"])
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_UpdateFile(t *testing.T) {
// Test that UpdateFile errors are translated properly
err := errors.New("UpdateFile: 409 Conflict")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "UpdateFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "UpdateFile" {
t.Errorf("expected operation UpdateFile, got %s", enhanced.Operation)
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_DeleteFile(t *testing.T) {
// Test that DeleteFile errors are translated properly
err := errors.New("DeleteFile: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "DeleteFile",
"owner": "karti-ai",
"repo": "docs",
"path": "oldfile.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "DeleteFile" {
t.Errorf("expected operation DeleteFile, got %s", enhanced.Operation)
}
t.Logf("Translated error message: %s", enhanced.Error())
t.Logf("Error category: %s", enhanced.Category)
}
func TestErrorTranslation_Unwrap(t *testing.T) {
original := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(original, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
})
// Should be able to unwrap to get original error
var enhanced *gitea_errors.EnhancedError
if errors.As(translated, &enhanced) {
unwrapped := enhanced.Unwrap()
if unwrapped == nil {
t.Error("expected to be able to unwrap error")
}
if unwrapped.Error() != original.Error() {
t.Errorf("expected unwrapped error to match original: got %s, want %s", unwrapped.Error(), original.Error())
}
} else {
t.Error("expected translated error to be EnhancedError")
}
}
func TestErrorTranslation_AuthErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isAuth bool
}{
{
name: "401 Unauthorized",
errMsg: "GetContentsOrList: 401 Unauthorized",
isAuth: true,
},
{
name: "403 Forbidden",
errMsg: "GetContentsOrList: 403 Forbidden",
isAuth: true,
},
{
name: "404 Not Found (not auth)",
errMsg: "GetContentsOrList: 404 Not Found",
isAuth: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isAuth := gitea_errors.IsAuthError(translated)
if isAuth != tt.isAuth {
t.Errorf("IsAuthError() = %v, want %v", isAuth, tt.isAuth)
}
})
}
}
func TestErrorTranslation_PreservesExistingEnhancedError(t *testing.T) {
// If we translate an already-enhanced error, it should add context, not replace
original := errors.New("GetContentsOrList: 404 Not Found")
enhanced1 := gitea_errors.TranslateError(original, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
})
// Translate again with more context
enhanced2 := gitea_errors.TranslateError(enhanced1, map[string]string{
"repo": "docs",
"path": "README.md",
})
var e *gitea_errors.EnhancedError
if errors.As(enhanced2, &e) {
// Should have both sets of context
if e.Context["operation"] != "GetFile" {
t.Errorf("expected operation context to be preserved, got %s", e.Context["operation"])
}
if e.Context["owner"] != "karti-ai" {
t.Errorf("expected owner context to be preserved, got %s", e.Context["owner"])
}
if e.Context["repo"] != "docs" {
t.Errorf("expected repo context to be added, got %s", e.Context["repo"])
}
if e.Context["path"] != "README.md" {
t.Errorf("expected path context to be added, got %s", e.Context["path"])
}
} else {
t.Error("expected error to be EnhancedError")
}
}
func TestErrorTranslation_NetworkErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isNetwork bool
isTimeout bool
}{
{
name: "Connection refused",
errMsg: "GetContentsOrList: connection refused",
isNetwork: true,
isTimeout: false,
},
{
name: "Timeout",
errMsg: "GetContentsOrList: timeout",
isNetwork: true,
isTimeout: true,
},
{
name: "No such host",
errMsg: "GetContentsOrList: no such host",
isNetwork: true,
isTimeout: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isNetwork := gitea_errors.IsNetworkError(translated)
isTimeout := gitea_errors.IsTimeout(translated)
t.Logf("Error: %s, IsNetwork: %v, IsTimeout: %v", tt.errMsg, isNetwork, isTimeout)
})
}
}
func TestErrorTranslation_ServerErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isServer bool
}{
{
name: "500 Internal Server Error",
errMsg: "GetContentsOrList: 500 Internal Server Error",
isServer: true,
},
{
name: "502 Bad Gateway",
errMsg: "GetContentsOrList: 502 Bad Gateway",
isServer: true,
},
{
name: "503 Service Unavailable",
errMsg: "GetContentsOrList: 503 Service Unavailable",
isServer: true,
},
{
name: "404 Not Found (not server)",
errMsg: "GetContentsOrList: 404 Not Found",
isServer: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isServer := gitea_errors.IsServerError(translated)
t.Logf("Error: %s, IsServer: %v", tt.errMsg, isServer)
})
}
}
func TestErrorTranslation_Format(t *testing.T) {
err := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"ref": "main",
})
var enhanced *gitea_errors.EnhancedError
if errors.As(translated, &enhanced) {
formatted := enhanced.Format()
// Format should include operation
if formatted == "" {
t.Error("expected non-empty formatted error")
}
t.Logf("Formatted error: %s", formatted)
} else {
t.Error("expected translated error to be EnhancedError")
}
}
+557
View File
@@ -0,0 +1,557 @@
package repo
import (
"context"
"encoding/json"
"fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
RepoHealthCheckToolName = "repo_health_check"
)
var (
RepoHealthCheckTool = mcp.NewTool(
RepoHealthCheckToolName,
mcp.WithDescription("Check repository health by aggregating multiple status metrics including last commit date, open issues/PRs count, workflow status, and branch protection. Returns a comprehensive health score (0-100)."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithBoolean("include_workflows", mcp.Description("include workflow run status (may require additional API calls)"), mcp.DefaultBool(true)),
mcp.WithBoolean("include_protection", mcp.Description("include branch protection status"), mcp.DefaultBool(true)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: RepoHealthCheckTool,
Handler: repoHealthCheckFn,
})
}
// HealthResult represents the complete health check result
type HealthResult struct {
Repository string `json:"repository"`
HealthScore int `json:"health_score"`
HealthStatus string `json:"health_status"`
LastCommit *CommitInfo `json:"last_commit,omitempty"`
Issues *IssuesInfo `json:"issues,omitempty"`
PullRequests *PullRequestsInfo `json:"pull_requests,omitempty"`
WorkflowStatus *WorkflowStatusInfo `json:"workflow_status,omitempty"`
BranchProtection *BranchProtectionInfo `json:"branch_protection,omitempty"`
RepositoryInfo *RepositoryInfo `json:"repository_info,omitempty"`
Errors []HealthCheckError `json:"errors,omitempty"`
CheckedAt string `json:"checked_at"`
PartialResult bool `json:"partial_result"`
}
// CommitInfo contains last commit information
type CommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author string `json:"author"`
Date string `json:"date"`
DaysAgo int `json:"days_ago"`
Available bool `json:"available"`
}
// IssuesInfo contains issue metrics
type IssuesInfo struct {
OpenCount int `json:"open_count"`
TotalCount int `json:"total_count"`
Available bool `json:"available"`
}
// PullRequestsInfo contains PR metrics
type PullRequestsInfo struct {
OpenCount int `json:"open_count"`
TotalCount int `json:"total_count"`
Available bool `json:"available"`
}
// WorkflowStatusInfo contains workflow information
type WorkflowStatusInfo struct {
LastRunStatus string `json:"last_run_status,omitempty"`
LastRunConclusion string `json:"last_run_conclusion,omitempty"`
HasRecentRuns bool `json:"has_recent_runs"`
Available bool `json:"available"`
Error string `json:"error,omitempty"`
}
// BranchProtectionInfo contains protection metrics
type BranchProtectionInfo struct {
ProtectedBranchesCount int `json:"protected_branches_count"`
ProtectedBranches []string `json:"protected_branches,omitempty"`
Available bool `json:"available"`
}
// RepositoryInfo contains basic repo metrics
type RepositoryInfo struct {
Stars int `json:"stars"`
Forks int `json:"forks"`
Language string `json:"language,omitempty"`
IsPrivate bool `json:"is_private"`
IsArchived bool `json:"is_archived"`
Available bool `json:"available"`
}
// HealthCheckError represents an error from a specific check
type HealthCheckError struct {
Check string `json:"check"`
Error string `json:"error"`
}
func repoHealthCheckFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called repoHealthCheckFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"param": "owner",
}))
}
repoName, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"param": "repo",
}))
}
includeWorkflows := params.GetOptionalBool(req.GetArguments(), "include_workflows", true)
includeProtection := params.GetOptionalBool(req.GetArguments(), "include_protection", true)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"owner": owner,
"repo": repoName,
}))
}
result := &HealthResult{
Repository: fmt.Sprintf("%s/%s", owner, repoName),
CheckedAt: time.Now().UTC().Format(time.RFC3339),
PartialResult: false,
Errors: []HealthCheckError{},
}
// Check 1: Repository Info (always try first)
repoInfo, err := checkRepositoryInfo(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "repository_info",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.RepositoryInfo = repoInfo
}
// Check 2: Last Commit
commitInfo, err := checkLastCommit(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "last_commit",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.LastCommit = commitInfo
}
// Check 3: Issues
issuesInfo, err := checkIssues(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "issues",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.Issues = issuesInfo
}
// Check 4: Pull Requests
prsInfo, err := checkPullRequests(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "pull_requests",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.PullRequests = prsInfo
}
// Check 5: Workflow Status (optional, may fail on older Gitea versions)
if includeWorkflows {
workflowInfo, err := checkWorkflowStatus(ctx, owner, repoName)
if err != nil {
// Don't mark as partial for workflow errors on older Gitea versions
if !errors.IsActionsAPIUnavailable(err) {
result.Errors = append(result.Errors, HealthCheckError{
Check: "workflow_status",
Error: err.Error(),
})
}
result.WorkflowStatus = &WorkflowStatusInfo{
Available: false,
Error: err.Error(),
}
} else {
result.WorkflowStatus = workflowInfo
}
}
// Check 6: Branch Protection (optional)
if includeProtection {
protectionInfo, err := checkBranchProtection(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "branch_protection",
Error: err.Error(),
})
result.PartialResult = true
result.BranchProtection = &BranchProtectionInfo{
Available: false,
}
} else {
result.BranchProtection = protectionInfo
}
}
// Calculate health score
result.HealthScore = calculateHealthScore(result)
result.HealthStatus = getHealthStatus(result.HealthScore)
// Return result as JSON
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"step": "marshal_result",
}))
}
return to.TextResult(string(jsonBytes))
}
func checkRepositoryInfo(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*RepositoryInfo, error) {
r, _, err := client.GetRepo(owner, repo)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "GetRepo",
"owner": owner,
"repo": repo,
})
}
return &RepositoryInfo{
Stars: r.Stars,
Forks: r.Forks,
Language: r.Language,
IsPrivate: r.Private,
IsArchived: r.Archived,
Available: true,
}, nil
}
func checkLastCommit(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*CommitInfo, error) {
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
commits, _, err := client.ListRepoCommits(owner, repo, opt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoCommits",
"owner": owner,
"repo": repo,
})
}
if len(commits) == 0 {
return &CommitInfo{
Available: false,
}, nil
}
c := commits[0]
info := &CommitInfo{
SHA: c.SHA,
Available: true,
}
if c.RepoCommit != nil {
info.Message = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
info.Author = c.RepoCommit.Author.Name
info.Date = c.RepoCommit.Author.Date
// Calculate days ago
if commitTime, err := time.Parse(time.RFC3339, c.RepoCommit.Author.Date); err == nil {
info.DaysAgo = int(time.Since(commitTime).Hours() / 24)
}
}
}
return info, nil
}
func checkIssues(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*IssuesInfo, error) {
// Get open issues count
openOpt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateOpen,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
openIssues, _, err := client.ListRepoIssues(owner, repo, openOpt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoIssues",
"owner": owner,
"repo": repo,
"state": "open",
})
}
// Get total issues count (we can use the repo info for this to save API calls)
// For simplicity, we'll just use what we can get from list
totalOpt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateAll,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
totalIssues, _, err := client.ListRepoIssues(owner, repo, totalOpt)
if err != nil {
// If we got open count, we can still return partial info
return &IssuesInfo{
OpenCount: len(openIssues),
Available: true,
}, nil
}
return &IssuesInfo{
OpenCount: len(openIssues),
TotalCount: len(totalIssues),
Available: true,
}, nil
}
func checkPullRequests(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*PullRequestsInfo, error) {
// Get open PRs
openOpt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateOpen,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
openPRs, _, err := client.ListRepoPullRequests(owner, repo, openOpt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoPullRequests",
"owner": owner,
"repo": repo,
"state": "open",
})
}
// Get total PRs
totalOpt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateAll,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
totalPRs, _, err := client.ListRepoPullRequests(owner, repo, totalOpt)
if err != nil {
return &PullRequestsInfo{
OpenCount: len(openPRs),
Available: true,
}, nil
}
return &PullRequestsInfo{
OpenCount: len(openPRs),
TotalCount: len(totalPRs),
Available: true,
}, nil
}
func checkWorkflowStatus(ctx context.Context, owner, repo string) (*WorkflowStatusInfo, error) {
// Use the REST API directly to get recent workflow runs
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
status, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/runs", owner, repo), nil, nil, &result)
if err != nil {
// Check if this is an Actions API unavailability error
if status == 404 || status == 405 {
return nil, errors.NewEnhancedError(
err,
"Actions API not available on this Gitea version",
errors.CategoryActions,
).WithOperation("CheckWorkflowStatus")
}
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListWorkflowRuns",
"owner": owner,
"repo": repo,
})
}
info := &WorkflowStatusInfo{
Available: len(result.WorkflowRuns) > 0,
}
if len(result.WorkflowRuns) > 0 {
// Get the most recent run
run := result.WorkflowRuns[0]
info.LastRunStatus = getStringFromMap(run, "status")
info.LastRunConclusion = getStringFromMap(run, "conclusion")
info.HasRecentRuns = true
// Check if run is recent (within 7 days)
if createdAt := getStringFromMap(run, "created_at"); createdAt != "" {
if runTime, err := time.Parse(time.RFC3339, createdAt); err == nil {
info.HasRecentRuns = time.Since(runTime).Hours() < 24*7
}
}
}
return info, nil
}
func checkBranchProtection(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*BranchProtectionInfo, error) {
protections, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListBranchProtections",
"owner": owner,
"repo": repo,
})
}
branches := make([]string, 0, len(protections))
for _, p := range protections {
branches = append(branches, p.BranchName)
}
return &BranchProtectionInfo{
ProtectedBranchesCount: len(protections),
ProtectedBranches: branches,
Available: true,
}, nil
}
func calculateHealthScore(result *HealthResult) int {
score := 100
// Deduct for stale commits (more than 30 days)
if result.LastCommit != nil && result.LastCommit.Available {
if result.LastCommit.DaysAgo > 90 {
score -= 30
} else if result.LastCommit.DaysAgo > 30 {
score -= 15
}
}
// Deduct for too many open issues (relative scoring)
if result.Issues != nil && result.Issues.Available {
if result.Issues.OpenCount > 50 {
score -= 10
} else if result.Issues.OpenCount > 20 {
score -= 5
}
}
// Deduct for old/stale PRs
if result.PullRequests != nil && result.PullRequests.Available {
if result.PullRequests.OpenCount > 10 {
score -= 5
}
}
// Deduct for workflow failures
if result.WorkflowStatus != nil && result.WorkflowStatus.Available {
if result.WorkflowStatus.LastRunConclusion == "failure" {
score -= 15
} else if result.WorkflowStatus.LastRunConclusion == "cancelled" {
score -= 5
}
if !result.WorkflowStatus.HasRecentRuns {
score -= 5
}
}
// Bonus for good practices
if result.BranchProtection != nil && result.BranchProtection.Available {
if result.BranchProtection.ProtectedBranchesCount > 0 {
score += 5 // Bonus for having protected branches
}
}
// Penalty for archived repos
if result.RepositoryInfo != nil && result.RepositoryInfo.IsArchived {
score -= 40
}
// Ensure score is within bounds
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score
}
func getHealthStatus(score int) string {
switch {
case score >= 90:
return "excellent"
case score >= 70:
return "good"
case score >= 50:
return "fair"
case score >= 30:
return "poor"
default:
return "critical"
}
}
func getStringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
+697
View File
@@ -0,0 +1,697 @@
package repo
import (
"encoding/json"
"testing"
)
func TestCalculateHealthScore(t *testing.T) {
tests := []struct {
name string
result *HealthResult
expected int
}{
{
name: "perfect health - active repo",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
PullRequests: &PullRequestsInfo{
Available: true,
OpenCount: 2,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "success",
HasRecentRuns: true,
},
BranchProtection: &BranchProtectionInfo{
Available: true,
ProtectedBranchesCount: 1,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: false,
},
},
expected: 100,
},
{
name: "stale commits - 35 days",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 35,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
},
expected: 85,
},
{
name: "very stale commits - 100 days",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 100,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
},
expected: 70,
},
{
name: "too many open issues",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 60,
},
},
expected: 90,
},
{
name: "workflow failure",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: true,
},
},
expected: 85,
},
{
name: "archived repository",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
},
expected: 60,
},
{
name: "empty result - no data",
result: &HealthResult{},
expected: 100,
},
{
name: "boundary - minimum score",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 1000,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: false,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 100,
},
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := calculateHealthScore(tt.result)
if score != tt.expected {
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
}
})
}
}
func TestGetHealthStatus(t *testing.T) {
tests := []struct {
score int
expected string
}{
{95, "excellent"},
{90, "excellent"},
{85, "good"},
{70, "good"},
{60, "fair"},
{50, "fair"},
{40, "poor"},
{30, "poor"},
{20, "critical"},
{0, "critical"},
{100, "excellent"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
status := getHealthStatus(tt.score)
if status != tt.expected {
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
}
})
}
}
func TestGetStringFromMap(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "string value",
m: map[string]any{"status": "success"},
key: "status",
expected: "success",
},
{
name: "missing key",
m: map[string]any{"other": "value"},
key: "status",
expected: "",
},
{
name: "non-string value",
m: map[string]any{"count": 42},
key: "count",
expected: "",
},
{
name: "empty map",
m: map[string]any{},
key: "status",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getStringFromMap(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
}
})
}
}
func TestHealthResultJSONMarshaling(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 85,
HealthStatus: "good",
LastCommit: &CommitInfo{
SHA: "abc123",
Message: "Initial commit",
Author: "user",
Date: "2024-01-15T10:30:00Z",
DaysAgo: 5,
Available: true,
},
Issues: &IssuesInfo{
OpenCount: 10,
TotalCount: 50,
Available: true,
},
PullRequests: &PullRequestsInfo{
OpenCount: 3,
TotalCount: 15,
Available: true,
},
WorkflowStatus: &WorkflowStatusInfo{
LastRunStatus: "completed",
LastRunConclusion: "success",
HasRecentRuns: true,
Available: true,
},
BranchProtection: &BranchProtectionInfo{
ProtectedBranchesCount: 2,
ProtectedBranches: []string{"main", "develop"},
Available: true,
},
RepositoryInfo: &RepositoryInfo{
Stars: 100,
Forks: 20,
Language: "Go",
IsPrivate: false,
IsArchived: false,
Available: true,
},
CheckedAt: "2024-01-20T10:00:00Z",
PartialResult: false,
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
t.Fatalf("Failed to marshal HealthResult: %v", err)
}
if len(jsonBytes) == 0 {
t.Error("Expected non-empty JSON output")
}
var unmarshaled HealthResult
if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil {
t.Fatalf("Failed to unmarshal HealthResult: %v", err)
}
if unmarshaled.HealthScore != result.HealthScore {
t.Errorf("HealthScore mismatch: got %d, want %d", unmarshaled.HealthScore, result.HealthScore)
}
if unmarshaled.HealthStatus != result.HealthStatus {
t.Errorf("HealthStatus mismatch: got %s, want %s", unmarshaled.HealthStatus, result.HealthStatus)
}
if unmarshaled.LastCommit == nil || unmarshaled.LastCommit.SHA != result.LastCommit.SHA {
t.Error("LastCommit mismatch")
}
}
func TestCalculateHealthScore_EdgeCases(t *testing.T) {
tests := []struct {
name string
result *HealthResult
expected int
}{
{
name: "all nil fields",
result: &HealthResult{
LastCommit: nil,
Issues: nil,
PullRequests: nil,
WorkflowStatus: nil,
BranchProtection: nil,
RepositoryInfo: nil,
},
expected: 100,
},
{
name: "unavailable fields",
result: &HealthResult{
LastCommit: &CommitInfo{Available: false},
Issues: &IssuesInfo{Available: false},
},
expected: 100,
},
{
name: "stale commits boundary - exactly 30 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 30},
},
expected: 100,
},
{
name: "stale commits boundary - exactly 31 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 31},
},
expected: 85,
},
{
name: "stale commits boundary - exactly 90 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 90},
},
expected: 85,
},
{
name: "stale commits boundary - exactly 91 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 91},
},
expected: 70,
},
{
name: "issues boundary - exactly 20",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 20},
},
expected: 100,
},
{
name: "issues boundary - exactly 21",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 21},
},
expected: 95,
},
{
name: "issues boundary - exactly 50",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 50},
},
expected: 95,
},
{
name: "issues boundary - exactly 51",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 51},
},
expected: 90,
},
{
name: "PRs boundary - exactly 10",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 10},
},
expected: 100,
},
{
name: "PRs boundary - exactly 11",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 11},
},
expected: 95,
},
{
name: "workflow cancelled",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "cancelled",
HasRecentRuns: true,
},
},
expected: 95,
},
{
name: "workflow no recent runs",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "success",
HasRecentRuns: false,
},
},
expected: 95,
},
{
name: "archived with negative score",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 1000},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: false,
},
},
expected: 0,
},
{
name: "maximum score cap",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 0},
Issues: &IssuesInfo{Available: true, OpenCount: 0},
BranchProtection: &BranchProtectionInfo{
Available: true,
ProtectedBranchesCount: 10,
},
},
expected: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := calculateHealthScore(tt.result)
if score != tt.expected {
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
}
})
}
}
func TestGetHealthStatus_Boundaries(t *testing.T) {
tests := []struct {
score int
expected string
}{
{100, "excellent"},
{91, "excellent"},
{89, "good"},
{71, "good"},
{69, "fair"},
{51, "fair"},
{49, "poor"},
{31, "poor"},
{29, "critical"},
{1, "critical"},
{-10, "critical"},
{110, "excellent"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("score_%d", tt.score), func(t *testing.T) {
status := getHealthStatus(tt.score)
if status != tt.expected {
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
}
})
}
}
func TestGetStringFromMap_EdgeCases(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "nil map",
m: nil,
key: "status",
expected: "",
},
{
name: "empty map",
m: map[string]any{},
key: "status",
expected: "",
},
{
name: "int value",
m: map[string]any{"count": int(42)},
key: "count",
expected: "",
},
{
name: "float64 value",
m: map[string]any{"count": float64(42)},
key: "count",
expected: "",
},
{
name: "bool value",
m: map[string]any{"active": true},
key: "active",
expected: "",
},
{
name: "nested map value",
m: map[string]any{"data": map[string]any{"key": "value"}},
key: "data",
expected: "",
},
{
name: "slice value",
m: map[string]any{"items": []string{"a", "b"}},
key: "items",
expected: "",
},
{
name: "empty string value",
m: map[string]any{"name": ""},
key: "name",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getStringFromMap(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
}
})
}
}
func TestHealthCheckStructs(t *testing.T) {
t.Run("CommitInfo", func(t *testing.T) {
commit := &CommitInfo{
SHA: "abc123",
Message: "Initial commit",
Author: "user@example.com",
Date: "2024-01-15T10:30:00Z",
DaysAgo: 5,
Available: true,
}
if commit.SHA != "abc123" {
t.Error("SHA mismatch")
}
if commit.DaysAgo != 5 {
t.Error("DaysAgo mismatch")
}
})
t.Run("IssuesInfo", func(t *testing.T) {
issues := &IssuesInfo{
OpenCount: 10,
TotalCount: 50,
Available: true,
}
if issues.OpenCount != 10 {
t.Error("OpenCount mismatch")
}
if !issues.Available {
t.Error("Available should be true")
}
})
t.Run("PullRequestsInfo", func(t *testing.T) {
prs := &PullRequestsInfo{
OpenCount: 3,
TotalCount: 15,
Available: true,
}
if prs.OpenCount != 3 {
t.Error("OpenCount mismatch")
}
})
t.Run("WorkflowStatusInfo", func(t *testing.T) {
wf := &WorkflowStatusInfo{
LastRunStatus: "completed",
LastRunConclusion: "success",
HasRecentRuns: true,
Available: true,
Error: "",
}
if wf.LastRunConclusion != "success" {
t.Error("LastRunConclusion mismatch")
}
})
t.Run("BranchProtectionInfo", func(t *testing.T) {
bp := &BranchProtectionInfo{
ProtectedBranchesCount: 2,
ProtectedBranches: []string{"main", "develop"},
Available: true,
}
if bp.ProtectedBranchesCount != 2 {
t.Error("ProtectedBranchesCount mismatch")
}
if len(bp.ProtectedBranches) != 2 {
t.Error("ProtectedBranches length mismatch")
}
})
t.Run("RepositoryInfo", func(t *testing.T) {
repo := &RepositoryInfo{
Stars: 100,
Forks: 20,
Language: "Go",
IsPrivate: false,
IsArchived: false,
Available: true,
}
if repo.Stars != 100 {
t.Error("Stars mismatch")
}
if repo.IsArchived {
t.Error("IsArchived should be false")
}
})
t.Run("HealthCheckError", func(t *testing.T) {
err := HealthCheckError{
Check: "workflow_status",
Error: "API not available",
}
if err.Check != "workflow_status" {
t.Error("Check mismatch")
}
})
}
func TestHealthResultErrors(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 75,
HealthStatus: "good",
Errors: []HealthCheckError{
{Check: "workflow_status", Error: "API not available"},
{Check: "branch_protection", Error: "No permissions"},
},
PartialResult: true,
CheckedAt: "2024-01-20T10:00:00Z",
}
if !result.PartialResult {
t.Error("PartialResult should be true when errors exist")
}
if len(result.Errors) != 2 {
t.Errorf("Errors count = %d, want 2", len(result.Errors))
}
}
func TestHealthResultWithNilFields(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 100,
HealthStatus: "excellent",
LastCommit: nil,
Issues: nil,
PullRequests: nil,
WorkflowStatus: nil,
BranchProtection: nil,
RepositoryInfo: nil,
Errors: []HealthCheckError{},
CheckedAt: "2024-01-20T10:00:00Z",
PartialResult: false,
}
score := calculateHealthScore(result)
if score != 100 {
t.Errorf("calculateHealthScore() with nil fields = %d, want 100", score)
}
}
+264
View File
@@ -0,0 +1,264 @@
package repo
import (
"context"
"fmt"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithDescription("Create release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")),
mcp.WithString("title", mcp.Required(), mcp.Description("release title")),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithString("body", mcp.Description("release body")),
)
DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName,
mcp.WithDescription("Delete release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
)
GetReleaseTool = mcp.NewTool(
GetReleaseToolName,
mcp.WithDescription("Get release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
)
GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName,
mcp.WithDescription("Get latest release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
ListReleasesTool = mcp.NewTool(
ListReleasesToolName,
mcp.WithDescription("List releases"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), 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) {
log.Debugf("Called CreateReleasesFn")
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.CreateRelease(owner, repo, gitea_sdk.CreateReleaseOption{
TagName: tagName,
Target: target,
Title: title,
Note: body,
IsDraft: isDraft,
IsPrerelease: isPreRelease,
})
if err != nil {
return nil, fmt.Errorf("create release error: %v", err)
}
return mcp.NewToolResultText("Release Created"), nil
}
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn")
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.DeleteRelease(owner, repo, id)
if err != nil {
return nil, fmt.Errorf("delete release error: %v", err)
}
return to.TextResult("Release deleted successfully")
}
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseFn")
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.GetRelease(owner, repo, id)
if err != nil {
return nil, fmt.Errorf("get release error: %v", err)
}
return to.TextResult(slimRelease(release))
}
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn")
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.GetLatestRelease(owner, repo)
if err != nil {
return nil, fmt.Errorf("get latest release error: %v", err)
}
return to.TextResult(slimRelease(release))
}
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn")
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 pIsDraft *bool
isDraft, ok := args["is_draft"].(bool)
if ok {
pIsDraft = new(isDraft)
}
var pIsPreRelease *bool
isPreRelease, ok := args["is_pre_release"].(bool)
if ok {
pIsPreRelease = new(isPreRelease)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
releases, _, err := client.ListReleases(owner, repo, gitea_sdk.ListReleasesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
IsDraft: pIsDraft,
IsPreRelease: pIsPreRelease,
})
if err != nil {
return nil, fmt.Errorf("list releases error: %v", err)
}
return to.TextResult(slimReleases(releases))
}
+225
View File
@@ -0,0 +1,225 @@
package repo
import (
"context"
"errors"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithDescription("Create repository in personal account or organization"),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")),
mcp.WithString("description", mcp.Description("Description of the repository to create")),
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")),
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")),
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")),
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")),
mcp.WithString("gitignores", mcp.Description("Gitignores to use")),
mcp.WithString("license", mcp.Description("License to use")),
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
)
ForkRepoTool = mcp.NewTool(
ForkRepoToolName,
mcp.WithDescription("Fork repository"),
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")),
mcp.WithString("organization", mcp.Description("Organization name to fork")),
mcp.WithString("name", mcp.Description("Name of the forked repository")),
)
ListMyReposTool = mcp.NewTool(
ListMyReposToolName,
mcp.WithDescription("List my repositories"),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), 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) {
log.Debugf("Called CreateRepoFn")
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)
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,
}
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.CreateOrgRepo(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.CreateRepo(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
}
}
return to.TextResult(slimRepo(repo))
}
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ForkRepoFn")
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)
}
organization, ok := args["organization"].(string)
organizationPtr := new(organization)
if !ok || organization == "" {
organizationPtr = nil
}
name, ok := args["name"].(string)
namePtr := new(name)
if !ok || name == "" {
namePtr = nil
}
opt := gitea_sdk.CreateForkOption{
Organization: organizationPtr,
Name: namePtr,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.CreateFork(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) {
log.Debugf("Called ListMyReposFn")
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.ListMyRepos(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListOrgRepos(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos)
}
+201
View File
@@ -0,0 +1,201 @@
package repo
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func slimRepo(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 slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
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": 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 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
}
+142
View File
@@ -0,0 +1,142 @@
package repo
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimRepo(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",
Private: false,
Fork: false,
Archived: false,
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := slimRepo(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 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)
}
}
+138
View File
@@ -0,0 +1,138 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CreateCommitStatusToolName = "create_commit_status"
)
var (
CreateCommitStatusTool = mcp.NewTool(
CreateCommitStatusToolName,
mcp.WithDescription("Create a commit status check for a repository. Adds a new status context to a commit without overwriting existing statuses."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA (full 40-character SHA or short SHA)")),
mcp.WithString("state", mcp.Required(), mcp.Description("status state: pending, success, error, or failure")),
mcp.WithString("target_url", mcp.Description("URL with more details about the status (e.g., review environment link like https://review.lumbridgecorp.com)")),
mcp.WithString("context", mcp.Description("status context identifier (e.g., 'ci/metal', 'ci/cloud-1', 'continuous-integration/jenkins')"), mcp.DefaultString("default")),
mcp.WithString("description", mcp.Description("short description of the status")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateCommitStatusTool,
Handler: CreateCommitStatusFn,
})
}
// CreateCommitStatusFn creates a status check for a commit
func CreateCommitStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateCommitStatusFn")
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)
}
state, err := params.GetString(args, "state")
if err != nil {
return to.ErrorResult(err)
}
statusState, err := parseStatusState(state)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid state '%s': must be one of pending, success, failure, error", state))
}
targetURL, _ := args["target_url"].(string)
context, _ := args["context"].(string)
description, _ := args["description"].(string)
// Use default context if not provided
if context == "" {
context = "default"
}
opt := gitea_sdk.CreateStatusOption{
State: statusState,
TargetURL: targetURL,
Context: context,
Description: description,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
status, _, err := client.CreateStatus(owner, repo, sha, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "CreateCommitStatus",
"owner": owner,
"repo": repo,
"sha": sha,
"state": state,
"context": context,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult(slimStatus(status))
}
// parseStatusState converts a string state to gitea_sdk.StatusState
func parseStatusState(state string) (gitea_sdk.StatusState, error) {
switch state {
case "pending":
return gitea_sdk.StatusPending, nil
case "success":
return gitea_sdk.StatusSuccess, nil
case "failure":
return gitea_sdk.StatusFailure, nil
case "error":
return gitea_sdk.StatusError, nil
default:
return "", fmt.Errorf("invalid state: %s", state)
}
}
// slimStatus creates a slimmed down representation of a commit status
func slimStatus(s *gitea_sdk.Status) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"id": s.ID,
"state": s.State,
"target_url": s.TargetURL,
"context": s.Context,
"description": s.Description,
}
}
+483
View File
@@ -0,0 +1,483 @@
package repo
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestParseStatusState(t *testing.T) {
tests := []struct {
name string
state string
wantState gitea_sdk.StatusState
wantErr bool
errContains string
}{
{
name: "pending",
state: "pending",
wantState: gitea_sdk.StatusPending,
wantErr: false,
},
{
name: "success",
state: "success",
wantState: gitea_sdk.StatusSuccess,
wantErr: false,
},
{
name: "failure",
state: "failure",
wantState: gitea_sdk.StatusFailure,
wantErr: false,
},
{
name: "error",
state: "error",
wantState: gitea_sdk.StatusError,
wantErr: false,
},
{
name: "invalid state",
state: "invalid",
wantErr: true,
errContains: "invalid state",
},
{
name: "empty state",
state: "",
wantErr: true,
errContains: "invalid state",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseStatusState(tt.state)
if tt.wantErr {
if err == nil {
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
return
}
if tt.errContains != "" && !errors.Is(err, errors.New(tt.errContains)) {
if !contains(err.Error(), tt.errContains) {
t.Errorf("parseStatusState() error = %v, should contain %v", err.Error(), tt.errContains)
}
}
return
}
if err != nil {
t.Errorf("parseStatusState() unexpected error = %v", err)
return
}
if got != tt.wantState {
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
}
})
}
}
func TestSlimStatus(t *testing.T) {
tests := []struct {
name string
status *gitea_sdk.Status
want map[string]any
}{
{
name: "nil status",
status: nil,
want: nil,
},
{
name: "full status",
status: &gitea_sdk.Status{
ID: 123,
State: gitea_sdk.StatusSuccess,
TargetURL: "https://review.lumbridgecorp.com/project/commit/248ade7",
Context: "ci/metal",
Description: "Build succeeded on Metal",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(123),
"state": gitea_sdk.StatusSuccess,
"target_url": "https://review.lumbridgecorp.com/project/commit/248ade7",
"context": "ci/metal",
"description": "Build succeeded on Metal",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "pending status",
status: &gitea_sdk.Status{
ID: 456,
State: gitea_sdk.StatusPending,
Context: "ci/cloud-1",
CreatedAt: "2024-01-15T10:31:00Z",
},
want: map[string]any{
"id": int64(456),
"state": gitea_sdk.StatusPending,
"target_url": "",
"context": "ci/cloud-1",
"created_at": "2024-01-15T10:31:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slimStatus(tt.status)
if tt.want == nil {
if got != nil {
t.Errorf("slimStatus() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("slimStatus() = nil, want %v", tt.want)
return
}
for key, wantVal := range tt.want {
gotVal, ok := got[key]
if !ok {
t.Errorf("slimStatus() missing key %s", key)
continue
}
if gotVal != wantVal {
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
}
}
})
}
}
func TestErrorTranslation_CreateCommitStatus(t *testing.T) {
tests := []struct {
name string
errMsg string
expectedOp string
expectedCtxKey string
}{
{
name: "404 not found",
errMsg: "CreateStatus: 404 Not Found",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "sha",
},
{
name: "401 unauthorized",
errMsg: "CreateStatus: 401 Unauthorized",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "context",
},
{
name: "403 forbidden",
errMsg: "CreateStatus: 403 Forbidden",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "CreateCommitStatus",
"owner": "karti-ai",
"repo": "gitcoffee",
"sha": "248ade7a9c...",
"state": "success",
"context": "ci/metal",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != tt.expectedOp {
t.Errorf("expected operation %s, got %s", tt.expectedOp, enhanced.Operation)
}
if enhanced.Context[tt.expectedCtxKey] == "" {
t.Errorf("expected context key %s to be set", tt.expectedCtxKey)
}
t.Logf("Translated error: %s", enhanced.Error())
})
}
}
func TestParseStatusState_EdgeCases(t *testing.T) {
tests := []struct {
name string
state string
wantState gitea_sdk.StatusState
wantErr bool
errContains string
}{
{
name: "mixed case pending",
state: "Pending",
wantErr: true,
errContains: "invalid state",
},
{
name: "mixed case success",
state: "Success",
wantErr: true,
errContains: "invalid state",
},
{
name: "whitespace pending",
state: " pending",
wantErr: true,
errContains: "invalid state",
},
{
name: "whitespace success",
state: "success ",
wantErr: true,
errContains: "invalid state",
},
{
name: "long invalid string",
state: "this_is_not_a_valid_state",
wantErr: true,
errContains: "invalid state",
},
{
name: "numeric string",
state: "123",
wantErr: true,
errContains: "invalid state",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseStatusState(tt.state)
if tt.wantErr {
if err == nil {
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
return
}
return
}
if err != nil {
t.Errorf("parseStatusState() unexpected error = %v", err)
return
}
if got != tt.wantState {
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
}
})
}
}
func TestSlimStatus_EdgeCases(t *testing.T) {
tests := []struct {
name string
status *gitea_sdk.Status
want map[string]any
}{
{
name: "nil status",
status: nil,
want: nil,
},
{
name: "status with zero ID",
status: &gitea_sdk.Status{
ID: 0,
State: gitea_sdk.StatusPending,
TargetURL: "",
Context: "",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(0),
"state": gitea_sdk.StatusPending,
"target_url": "",
"context": "",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "status with long URL",
status: &gitea_sdk.Status{
ID: 789,
State: gitea_sdk.StatusSuccess,
TargetURL: "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
Context: "continuous-integration/jenkins/build-and-test-all-platforms",
Description: "Build succeeded on all platforms including Windows, macOS, and Linux with full test suite",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(789),
"state": gitea_sdk.StatusSuccess,
"target_url": "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
"context": "continuous-integration/jenkins/build-and-test-all-platforms",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "failure status",
status: &gitea_sdk.Status{
ID: 101,
State: gitea_sdk.StatusFailure,
TargetURL: "https://ci.example.com/build/101",
Context: "ci/build",
Description: "Build failed",
CreatedAt: "2024-01-15T11:00:00Z",
},
want: map[string]any{
"id": int64(101),
"state": gitea_sdk.StatusFailure,
"target_url": "https://ci.example.com/build/101",
"context": "ci/build",
"created_at": "2024-01-15T11:00:00Z",
},
},
{
name: "error status",
status: &gitea_sdk.Status{
ID: 102,
State: gitea_sdk.StatusError,
TargetURL: "",
Context: "ci/error",
Description: "Error occurred",
CreatedAt: "2024-01-15T11:01:00Z",
},
want: map[string]any{
"id": int64(102),
"state": gitea_sdk.StatusError,
"target_url": "",
"context": "ci/error",
"created_at": "2024-01-15T11:01:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slimStatus(tt.status)
if tt.want == nil {
if got != nil {
t.Errorf("slimStatus() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("slimStatus() = nil, want %v", tt.want)
return
}
for key, wantVal := range tt.want {
gotVal, ok := got[key]
if !ok {
t.Errorf("slimStatus() missing key %s", key)
continue
}
if gotVal != wantVal {
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
}
}
})
}
}
func TestErrorTranslation_EdgeCases(t *testing.T) {
tests := []struct {
name string
errMsg string
ctx map[string]string
checkField string
wantValue string
}{
{
name: "timeout error",
errMsg: "request timeout",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "network error",
errMsg: "connection refused",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "500 server error",
errMsg: "500 Internal Server Error",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "rate limit error",
errMsg: "429 Too Many Requests",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, tt.ctx)
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Context[tt.checkField] != tt.wantValue {
t.Errorf("expected context[%s] = %s, got %s", tt.checkField, tt.wantValue, enhanced.Context[tt.checkField])
}
})
}
}
func TestSlimStatusFieldTypes(t *testing.T) {
status := &gitea_sdk.Status{
ID: int64(999),
State: gitea_sdk.StatusSuccess,
TargetURL: "https://example.com",
Context: "test",
Description: "desc",
CreatedAt: "2024-01-15T10:30:00Z",
}
slimmed := slimStatus(status)
if slimmed == nil {
t.Fatal("slimStatus returned nil")
}
if id, ok := slimmed["id"].(int64); !ok {
t.Errorf("id should be int64, got %T", slimmed["id"])
} else if id != 999 {
t.Errorf("id = %d, want 999", id)
}
if state, ok := slimmed["state"].(gitea_sdk.StatusState); !ok {
t.Errorf("state should be StatusState, got %T", slimmed["state"])
} else if state != gitea_sdk.StatusSuccess {
t.Errorf("state = %v, want %v", state, gitea_sdk.StatusSuccess)
}
for _, key := range []string{"target_url", "context", "description", "created_at"} {
if val, ok := slimmed[key].(string); !ok && slimmed[key] != nil {
t.Errorf("%s should be string, got %T", key, slimmed[key])
} else if !ok {
t.Errorf("%s should not be nil", key)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package repo
import (
"context"
"fmt"
"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/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"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.WithDescription("Create tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")),
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")),
)
DeleteTagTool = mcp.NewTool(
DeleteTagToolName,
mcp.WithDescription("Delete tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
)
GetTagTool = mcp.NewTool(
GetTagToolName,
mcp.WithDescription("Get tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
)
ListTagsTool = mcp.NewTool(
ListTagsToolName,
mcp.WithDescription("List tags"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), 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) {
log.Debugf("Called CreateTagFn")
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.CreateTag(owner, repo, gitea_sdk.CreateTagOption{
TagName: tagName,
Target: target,
Message: message,
})
if err != nil {
return nil, fmt.Errorf("create tag error: %v", err)
}
return mcp.NewToolResultText("Tag Created"), nil
}
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteTagFn")
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.DeleteTag(owner, repo, tagName)
if err != nil {
return nil, fmt.Errorf("delete tag error: %v", err)
}
return to.TextResult("Tag deleted")
}
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetTagFn")
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.GetTag(owner, repo, tagName)
if err != nil {
return nil, fmt.Errorf("get tag error: %v", err)
}
return to.TextResult(slimTag(tag))
}
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListTagsFn")
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, "perPage", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tags, _, err := client.ListRepoTags(owner, repo, gitea_sdk.ListRepoTagsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
})
if err != nil {
return nil, fmt.Errorf("list tags error: %v", err)
}
return to.TextResult(slimTags(tags))
}
+228
View File
@@ -0,0 +1,228 @@
package repo
import (
"context"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoStructureToolName = "list_repo_structure"
)
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"`
SHA string `json:"sha"`
Size int64 `json:"size,omitempty"`
}
type TreeResponse struct {
SHA string `json:"sha"`
URL string `json:"url,omitempty"`
Tree []TreeEntry `json:"tree"`
Truncated bool `json:"truncated,omitempty"`
}
var (
ListRepoStructureTool = mcp.NewTool(
ListRepoStructureToolName,
mcp.WithDescription("List the complete directory and file structure of a repository using Git tree API. Supports recursive listing and pattern filtering."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("Git reference (branch, tag, or commit SHA). Defaults to default branch.")),
mcp.WithString("pattern", mcp.Description("Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')")),
mcp.WithBoolean("recursive", mcp.Description("List contents recursively (default: true)")),
mcp.WithNumber("page", mcp.Description("Page number for pagination (1-based, default: 1)")),
mcp.WithNumber("per_page", mcp.Description("Number of items per page (default: 100, max: 1000)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoStructureTool,
Handler: ListRepoStructureFn,
})
}
func ListRepoStructureFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoStructureFn")
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)
if ref == "" {
ref = "HEAD"
}
pattern, _ := args["pattern"].(string)
recursive := true
if recursiveVal, ok := args["recursive"].(bool); ok {
recursive = recursiveVal
}
page := 1
if pageVal, ok := args["page"].(float64); ok && pageVal > 0 {
page = int(pageVal)
}
perPage := 100
if perPageVal, ok := args["per_page"].(float64); ok && perPageVal > 0 {
perPage = int(perPageVal)
if perPage > 1000 {
perPage = 1000
}
}
query := url.Values{}
if recursive {
query.Set("recursive", "1")
}
query.Set("page", fmt.Sprintf("%d", page))
query.Set("per_page", fmt.Sprintf("%d", perPage))
path := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, ref)
var treeResp TreeResponse
statusCode, err := gitea.DoJSON(ctx, "GET", path, query, nil, &treeResp)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "ListRepoStructure",
"owner": owner,
"repo": repo,
"ref": ref,
"status": fmt.Sprintf("%d", statusCode),
})
return to.ErrorResult(translatedErr)
}
filteredEntries := filterEntries(treeResp.Tree, pattern)
result := map[string]any{
"owner": owner,
"repo": repo,
"ref": ref,
"sha": treeResp.SHA,
"truncated": treeResp.Truncated,
"total_count": len(filteredEntries),
"page": page,
"per_page": perPage,
"tree": slimTreeEntries(filteredEntries),
}
if treeResp.Truncated {
result["warning"] = "Tree listing was truncated due to size. Consider using pattern filtering or pagination."
}
resultJSON, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("marshal result: %w", err))
}
return to.TextResult(string(resultJSON))
}
func filterEntries(entries []TreeEntry, pattern string) []TreeEntry {
if pattern == "" {
return entries
}
filtered := make([]TreeEntry, 0, len(entries))
for _, entry := range entries {
if matchPattern(entry.Path, pattern) {
filtered = append(filtered, entry)
}
}
return filtered
}
func matchPattern(path, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return true
}
negate := false
if strings.HasPrefix(pattern, "!") {
negate = true
pattern = strings.TrimPrefix(pattern, "!")
}
matched, err := filepath.Match(pattern, path)
if err == nil && matched {
return !negate
}
filename := filepath.Base(path)
matched, err = filepath.Match(pattern, filename)
if err == nil && matched {
return !negate
}
if strings.HasPrefix(pattern, "**/") {
suffix := strings.TrimPrefix(pattern, "**/")
if strings.HasSuffix(path, suffix) {
return !negate
}
parts := strings.Split(path, "/")
for i := range parts {
subPath := strings.Join(parts[i:], "/")
if matched, _ := filepath.Match(suffix, subPath); matched {
return !negate
}
}
}
if strings.HasSuffix(pattern, "/*") || strings.HasSuffix(pattern, "/**") {
dirPrefix := strings.TrimSuffix(pattern, "/*")
dirPrefix = strings.TrimSuffix(dirPrefix, "/**")
if strings.HasPrefix(path, dirPrefix+"/") {
return !negate
}
}
if strings.HasPrefix(path, pattern+"/") || path == pattern {
return !negate
}
return negate
}
func slimTreeEntries(entries []TreeEntry) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
m := map[string]any{
"path": e.Path,
"type": e.Type,
"sha": e.SHA,
}
if e.Type == "blob" && e.Size > 0 {
m["size"] = e.Size
}
out = append(out, m)
}
return out
}
+468
View File
@@ -0,0 +1,468 @@
package repo
import (
"testing"
)
func TestMatchPattern(t *testing.T) {
tests := []struct {
name string
path string
pattern string
want bool
}{
{
name: "exact match",
path: "README.md",
pattern: "README.md",
want: true,
},
{
name: "wildcard match - all md files",
path: "docs/README.md",
pattern: "*.md",
want: true,
},
{
name: "wildcard match - yaml files",
path: ".gitea/workflows/build.yml",
pattern: "*.yml",
want: true,
},
{
name: "directory prefix match",
path: ".gitea/workflows/build.yml",
pattern: ".gitea/*",
want: true,
},
{
name: "recursive directory match",
path: ".github/workflows/test.yml",
pattern: ".github/**",
want: true,
},
{
name: "double star pattern",
path: "src/components/Button.tsx",
pattern: "**/*.tsx",
want: true,
},
{
name: "double star with prefix",
path: "src/internal/utils/helpers.go",
pattern: "src/**/*.go",
want: true,
},
{
name: "no match - wrong extension",
path: "main.go",
pattern: "*.md",
want: false,
},
{
name: "no match - wrong directory",
path: "docs/readme.md",
pattern: ".gitea/*",
want: false,
},
{
name: "negation pattern - exclude",
path: "node_modules/lodash/index.js",
pattern: "!node_modules/**",
want: false,
},
{
name: "negation pattern - include others",
path: "src/main.js",
pattern: "!node_modules/**",
want: true,
},
{
name: "empty pattern matches all",
path: "any/path/file.txt",
pattern: "",
want: true,
},
{
name: "exact directory match",
path: "src/components",
pattern: "src",
want: true,
},
{
name: "file inside directory",
path: "src/components/Button.tsx",
pattern: "src",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchPattern(tt.path, tt.pattern)
if got != tt.want {
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
}
})
}
}
func TestFilterEntries(t *testing.T) {
entries := []TreeEntry{
{Path: "README.md", Type: "blob", SHA: "abc123"},
{Path: "main.go", Type: "blob", SHA: "def456"},
{Path: "docs", Type: "tree", SHA: "ghi789"},
{Path: "docs/guide.md", Type: "blob", SHA: "jkl012"},
{Path: ".gitea/workflows/build.yml", Type: "blob", SHA: "mno345"},
}
tests := []struct {
name string
pattern string
expected int
}{
{
name: "no pattern returns all",
pattern: "",
expected: 5,
},
{
name: "filter markdown files",
pattern: "*.md",
expected: 2,
},
{
name: "filter yaml files",
pattern: "*.yml",
expected: 1,
},
{
name: "filter by directory",
pattern: ".gitea/*",
expected: 1,
},
{
name: "filter go files",
pattern: "*.go",
expected: 1,
},
{
name: "no match returns empty",
pattern: "*.py",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := filterEntries(entries, tt.pattern)
if len(filtered) != tt.expected {
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
}
})
}
}
func TestSlimTreeEntries(t *testing.T) {
entries := []TreeEntry{
{Path: "README.md", Type: "blob", SHA: "abc123", Size: 1024},
{Path: "docs", Type: "tree", SHA: "def456", Size: 0},
{Path: "main.go", Type: "blob", SHA: "ghi789", Size: 2048},
}
slimmed := slimTreeEntries(entries)
if len(slimmed) != len(entries) {
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), len(entries))
}
for i, entry := range slimmed {
if _, ok := entry["path"]; !ok {
t.Errorf("entry %d missing 'path' field", i)
}
if _, ok := entry["type"]; !ok {
t.Errorf("entry %d missing 'type' field", i)
}
if _, ok := entry["sha"]; !ok {
t.Errorf("entry %d missing 'sha' field", i)
}
entryType := entry["type"](string)
_, hasSize := entry["size"]
if entryType == "blob" && entries[i].Size > 0 && !hasSize {
t.Errorf("blob entry %d should have size field", i)
}
if entryType == "tree" && hasSize {
t.Errorf("tree entry %d should not have size field", i)
}
}
}
func TestMatchPattern_EdgeCases(t *testing.T) {
tests := []struct {
name string
path string
pattern string
want bool
}{
{
name: "empty path with empty pattern",
path: "",
pattern: "",
want: true,
},
{
name: "empty path with pattern",
path: "",
pattern: "*.go",
want: false,
},
{
name: "path with spaces",
path: "path with spaces/file.txt",
pattern: "*.txt",
want: true,
},
{
name: "special characters in path",
path: "path-with-dashes/file_name.txt",
pattern: "*.txt",
want: true,
},
{
name: "double star at start and end",
path: "deep/nested/path/file.go",
pattern: "**/*.go",
want: true,
},
{
name: "negation with double star",
path: "node_modules/deep/package.json",
pattern: "!node_modules/**",
want: false,
},
{
name: "complex glob pattern",
path: "src/components/Button.test.tsx",
pattern: "**/*.test.tsx",
want: true,
},
{
name: "directory only pattern",
path: "src/components",
pattern: "src/*",
want: true,
},
{
name: "trailing slash in directory",
path: "src/components/",
pattern: "src/*",
want: true,
},
{
name: "single character wildcard",
path: "file1.txt",
pattern: "file?.txt",
want: true,
},
{
name: "range pattern",
path: "file5.txt",
pattern: "file[0-9].txt",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchPattern(tt.path, tt.pattern)
if got != tt.want {
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
}
})
}
}
func TestFilterEntries_EdgeCases(t *testing.T) {
tests := []struct {
name string
entries []TreeEntry
pattern string
expected int
}{
{
name: "nil entries",
entries: nil,
pattern: "*.go",
expected: 0,
},
{
name: "empty entries",
entries: []TreeEntry{},
pattern: "*.go",
expected: 0,
},
{
name: "entries with empty paths",
entries: []TreeEntry{
{Path: "", Type: "blob", SHA: "abc123"},
{Path: "main.go", Type: "blob", SHA: "def456"},
},
pattern: "*.go",
expected: 1,
},
{
name: "negation pattern",
entries: []TreeEntry{
{Path: "test.go", Type: "blob", SHA: "abc123"},
{Path: "vendor/lib.go", Type: "blob", SHA: "def456"},
},
pattern: "!vendor/**",
expected: 1,
},
{
name: "complex pattern",
entries: []TreeEntry{
{Path: "src/main.go", Type: "blob", SHA: "abc123"},
{Path: "src/test/main_test.go", Type: "blob", SHA: "def456"},
{Path: "docs/readme.md", Type: "blob", SHA: "ghi789"},
},
pattern: "src/**/*.go",
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := filterEntries(tt.entries, tt.pattern)
if len(filtered) != tt.expected {
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
}
})
}
}
func TestSlimTreeEntries_EdgeCases(t *testing.T) {
tests := []struct {
name string
entries []TreeEntry
wantLen int
wantErr bool
}{
{
name: "nil entries",
entries: nil,
wantLen: 0,
},
{
name: "empty entries",
entries: []TreeEntry{},
wantLen: 0,
},
{
name: "entry with zero size blob",
entries: []TreeEntry{
{Path: "empty.txt", Type: "blob", SHA: "abc", Size: 0},
},
wantLen: 1,
},
{
name: "entry with symlink type",
entries: []TreeEntry{
{Path: "link", Type: "symlink", SHA: "def", Size: 0},
},
wantLen: 1,
},
{
name: "many entries",
entries: []TreeEntry{
{Path: "file1.txt", Type: "blob", SHA: "a", Size: 100},
{Path: "file2.txt", Type: "blob", SHA: "b", Size: 200},
{Path: "file3.txt", Type: "blob", SHA: "c", Size: 300},
{Path: "dir1", Type: "tree", SHA: "d", Size: 0},
{Path: "dir2", Type: "tree", SHA: "e", Size: 0},
},
wantLen: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
slimmed := slimTreeEntries(tt.entries)
if len(slimmed) != tt.wantLen {
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), tt.wantLen)
}
})
}
}
func TestTreeEntryStruct(t *testing.T) {
tests := []struct {
name string
entry TreeEntry
}{
{
name: "minimal entry",
entry: TreeEntry{
Path: "file.txt",
Type: "blob",
SHA: "abc123",
},
},
{
name: "full entry",
entry: TreeEntry{
Path: "file.txt",
Type: "blob",
SHA: "def456789abc",
Size: 1024,
},
},
{
name: "tree entry",
entry: TreeEntry{
Path: "directory",
Type: "tree",
SHA: "ghi789",
Size: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.entry.Path == "" {
t.Error("Path should not be empty")
}
if tt.entry.Type == "" {
t.Error("Type should not be empty")
}
if tt.entry.SHA == "" {
t.Error("SHA should not be empty")
}
})
}
}
func TestTreeResponseStruct(t *testing.T) {
response := TreeResponse{
SHA: "abc123def456",
URL: "https://api.example.com/repos/owner/repo/git/trees/abc123",
Tree: []TreeEntry{},
Truncated: false,
}
if response.SHA == "" {
t.Error("SHA should not be empty")
}
if response.URL == "" {
t.Error("URL should not be empty")
}
if response.Tree == nil {
t.Error("Tree should not be nil")
}
if response.Truncated {
t.Error("Truncated should be false")
}
}
+177
View File
@@ -0,0 +1,177 @@
package search
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"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"
)
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
SearchReposTool = mcp.NewTool(
SearchReposToolName,
mcp.WithDescription("search repos"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
mcp.WithBoolean("isPrivate", mcp.Description("IsPrivate")),
mcp.WithBoolean("isArchived", mcp.Description("IsArchived")),
mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), 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,
})
}
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UsersFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
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.SearchUsers(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) {
log.Debugf("Called OrgTeamsFn")
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.SearchOrgTeams(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) {
log.Debugf("Called ReposFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
if err != nil {
return to.ErrorResult(err)
}
keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool)
keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool)
ownerID := params.GetOptionalInt(req.GetArguments(), "ownerID", 0)
var pIsPrivate *bool
isPrivate, ok := req.GetArguments()["isPrivate"].(bool)
if ok {
pIsPrivate = new(isPrivate)
}
var pIsArchived *bool
isArchived, ok := req.GetArguments()["isArchived"].(bool)
if ok {
pIsArchived = new(isArchived)
}
sort, _ := req.GetArguments()["sort"].(string)
order, _ := req.GetArguments()["order"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword,
KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription,
OwnerID: ownerID,
IsPrivate: pIsPrivate,
IsArchived: pIsArchived,
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.SearchRepos(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
}
return to.TextResult(slimRepos(repos))
}
+42
View File
@@ -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{"keyword"},
},
{
name: "search_org_teams",
tool: SearOrgTeamsTool,
required: []string{"org", "query"},
},
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"keyword"},
},
}
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)
}
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package search
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(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 slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUserDetail(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 slimRepo(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 slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
+232
View File
@@ -0,0 +1,232 @@
package settings
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetUserSettingsToolName = "get_user_settings"
UpdateUserSettingsToolName = "update_user_settings"
GetRepoSettingsToolName = "get_repo_settings"
UpdateRepoSettingsToolName = "update_repo_settings"
)
var Tool = tool.New()
var (
GetUserSettingsTool = mcp.NewTool(
GetUserSettingsToolName,
mcp.WithDescription("Get current user's settings"),
)
UpdateUserSettingsTool = mcp.NewTool(
UpdateUserSettingsToolName,
mcp.WithDescription("Update current user's settings"),
mcp.WithString("description", mcp.Description("User description")),
mcp.WithString("website", mcp.Description("Website URL")),
mcp.WithString("location", mcp.Description("Location")),
mcp.WithString("theme", mcp.Description("Theme preference")),
)
GetRepoSettingsTool = mcp.NewTool(
GetRepoSettingsToolName,
mcp.WithDescription("Get repository settings"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
UpdateRepoSettingsTool = mcp.NewTool(
UpdateRepoSettingsToolName,
mcp.WithDescription("Update repository settings"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("description", mcp.Description("Repository description")),
mcp.WithString("website", mcp.Description("Website URL")),
mcp.WithString("default_branch", mcp.Description("Default branch name")),
mcp.WithBoolean("private", mcp.Description("Whether repo is private")),
mcp.WithBoolean("protected", mcp.Description("Whether repo is protected")),
mcp.WithBoolean("enable_wiki", mcp.Description("Enable wiki")),
mcp.WithBoolean("enable_issues", mcp.Description("Enable issues")),
mcp.WithBoolean("enable_pull_requests", mcp.Description("Enable pull requests")),
mcp.WithString("default_merge_style", mcp.Description("Default merge style"), mcp.Enum("merge", "rebase", "squash")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetUserSettingsTool,
Handler: getUserSettingsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoSettingsTool,
Handler: getRepoSettingsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateUserSettingsTool,
Handler: updateUserSettingsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateRepoSettingsTool,
Handler: updateRepoSettingsFn,
})
}
func getUserSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called getUserSettingsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
settings, _, err := client.GetUserSettings()
if err != nil {
return to.ErrorResult(fmt.Errorf("get user settings err: %v", err))
}
return to.TextResult(slimUserSettings(settings))
}
func updateUserSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called updateUserSettingsFn")
args := req.GetArguments()
opt := gitea_sdk.UserSettingsOptions{}
if v, ok := args["description"].(string); ok && v != "" {
opt.Description = &v
}
if v, ok := args["website"].(string); ok && v != "" {
opt.Website = &v
}
if v, ok := args["location"].(string); ok && v != "" {
opt.Location = &v
}
if v, ok := args["theme"].(string); ok && v != "" {
opt.Theme = &v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
settings, _, err := client.UpdateUserSettings(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update user settings err: %v", err))
}
return to.TextResult(slimUserSettings(settings))
}
func getRepoSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called getRepoSettingsFn")
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))
}
r, _, err := client.GetRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo err: %v", err))
}
return to.TextResult(slimRepoSettings(r))
}
func updateRepoSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called updateRepoSettingsFn")
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)
}
opt := gitea_sdk.EditRepoOption{}
if v, ok := args["description"].(string); ok && v != "" {
opt.Description = &v
}
if v, ok := args["website"].(string); ok && v != "" {
opt.Website = &v
}
if v, ok := args["default_branch"].(string); ok && v != "" {
opt.DefaultBranch = &v
}
if v, ok := args["private"].(bool); ok {
opt.Private = &v
}
if v, ok := args["protected"].(bool); ok {
opt.Archived = &v
}
if v, ok := args["enable_wiki"].(bool); ok {
opt.HasWiki = &v
}
if v, ok := args["enable_issues"].(bool); ok {
opt.HasIssues = &v
}
if v, ok := args["enable_pull_requests"].(bool); ok {
opt.HasPullRequests = &v
}
if v, ok := args["default_merge_style"].(string); ok && v != "" {
style := gitea_sdk.MergeStyle(v)
opt.DefaultMergeStyle = &style
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
r, _, err := client.EditRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo settings err: %v", err))
}
return to.TextResult(slimRepoSettings(r))
}
func slimUserSettings(s *gitea_sdk.UserSettings) map[string]interface{} {
return map[string]interface{}{
"full_name": s.FullName,
"description": s.Description,
"website": s.Website,
"location": s.Location,
"theme": s.Theme,
"language": s.Language,
"diff_view_style": s.DiffViewStyle,
"hide_email": s.HideEmail,
"hide_activity": s.HideActivity,
}
}
func slimRepoSettings(r *gitea_sdk.Repository) map[string]interface{} {
return map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"description": r.Description,
"website": r.Website,
"default_branch": r.DefaultBranch,
"private": r.Private,
"has_wiki": r.HasWiki,
"has_issues": r.HasIssues,
"has_projects": r.HasProjects,
"default_merge_style": r.DefaultMergeStyle,
"default_delete_branch_after_merge": r.DefaultDeleteBranchAfterMerge,
}
}
+192
View File
@@ -0,0 +1,192 @@
package sshkey
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListMySSHKeysToolName = "list_my_ssh_keys"
GetSSHKeyToolName = "get_ssh_key"
CreateSSHKeyToolName = "create_ssh_key"
DeleteSSHKeyToolName = "delete_ssh_key"
ListUserSSHKeysToolName = "list_user_ssh_keys"
)
var Tool = tool.New()
var (
ListMySSHKeysTool = mcp.NewTool(
ListMySSHKeysToolName,
mcp.WithDescription("List SSH keys for the authenticated user"),
)
GetSSHKeyTool = mcp.NewTool(
GetSSHKeyToolName,
mcp.WithDescription("Get a specific SSH key by ID"),
mcp.WithNumber("id", mcp.Required(), mcp.Description("SSH key ID")),
)
CreateSSHKeyTool = mcp.NewTool(
CreateSSHKeyToolName,
mcp.WithDescription("Create a new SSH key for the authenticated user"),
mcp.WithString("title", mcp.Required(), mcp.Description("Title/description for the SSH key")),
mcp.WithString("key", mcp.Required(), mcp.Description("The SSH public key content")),
)
DeleteSSHKeyTool = mcp.NewTool(
DeleteSSHKeyToolName,
mcp.WithDescription("Delete an SSH key"),
mcp.WithNumber("id", mcp.Required(), mcp.Description("SSH key ID to delete")),
)
ListUserSSHKeysTool = mcp.NewTool(
ListUserSSHKeysToolName,
mcp.WithDescription("List SSH keys for a specific user"),
mcp.WithString("username", mcp.Required(), mcp.Description("Username")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListMySSHKeysTool,
Handler: listMySSHKeysFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetSSHKeyTool,
Handler: getSSHKeyFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListUserSSHKeysTool,
Handler: listUserSSHKeysFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateSSHKeyTool,
Handler: createSSHKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteSSHKeyTool,
Handler: deleteSSHKeyFn,
})
}
func listMySSHKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called listMySSHKeysFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
keys, _, err := client.ListMyPublicKeys(gitea_sdk.ListPublicKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list SSH keys err: %v", err))
}
return to.TextResult(slimSSHKeys(keys))
}
func getSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called getSSHKeyFn")
args := req.GetArguments()
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid key id: %v", err))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
key, _, err := client.GetPublicKey(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get SSH key err: %v", err))
}
return to.TextResult(slimSSHKey(key))
}
func createSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called createSSHKeyFn")
args := req.GetArguments()
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
key, err := params.GetString(args, "key")
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))
}
createOpt := gitea_sdk.CreateKeyOption{
Title: title,
Key: key,
}
respKey, _, err := client.CreatePublicKey(createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create SSH key err: %v", err))
}
return to.TextResult(slimSSHKey(respKey))
}
func deleteSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called deleteSSHKeyFn")
args := req.GetArguments()
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid key id: %v", err))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeletePublicKey(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete SSH key err: %v", err))
}
return to.TextResult("SSH key deleted successfully")
}
func listUserSSHKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called listUserSSHKeysFn")
args := req.GetArguments()
username, err := params.GetString(args, "username")
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))
}
keys, _, err := client.ListPublicKeys(username, gitea_sdk.ListPublicKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list user SSH keys err: %v", err))
}
return to.TextResult(slimSSHKeys(keys))
}
func slimSSHKeys(keys []*gitea_sdk.PublicKey) []map[string]interface{} {
result := make([]map[string]interface{}, len(keys))
for i, k := range keys {
result[i] = slimSSHKey(k)
}
return result
}
func slimSSHKey(k *gitea_sdk.PublicKey) map[string]interface{} {
return map[string]interface{}{
"id": k.ID,
"key": k.Key,
"title": k.Title,
"created": k.Created,
"fingerprint": k.Fingerprint,
}
}
+227
View File
@@ -0,0 +1,227 @@
package stars
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
StarsReadToolName = "stars_read"
StarsWriteToolName = "stars_write"
)
var (
StarsReadTool = mcp.NewTool(
StarsReadToolName,
mcp.WithDescription("Read stars information. Use method 'list_stargazers' to list repo stargazers, 'list_starred' to list user's starred repos, 'my_starred' for your starred repos, 'check' to check if user starred a repo."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_stargazers", "list_starred", "my_starred", "check")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("user", mcp.Description("username (for 'list_starred', 'check')")),
)
StarsWriteTool = mcp.NewTool(
StarsWriteToolName,
mcp.WithDescription("Star or unstar a repository."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("star", "unstar")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: StarsReadTool,
Handler: starsReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: StarsWriteTool,
Handler: starsWriteFn,
})
}
func starsReadFn(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_stargazers":
return listStargazersFn(ctx, req)
case "list_starred":
return listStarredFn(ctx, req)
case "my_starred":
return myStarredFn(ctx, req)
case "check":
return checkStarFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func starsWriteFn(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 "star":
return starRepoFn(ctx, req)
case "unstar":
return unstarRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listStargazersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listStargazersFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
users, _, err := client.ListRepoStargazers(owner, repo, gitea_sdk.ListStargazersOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list stargazers err: %v", err))
}
return to.TextResult(slimUsers(users))
}
func listStarredFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listStarredFn")
user, err := params.GetString(req.GetArguments(), "user")
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))
}
repos, _, err := client.GetStarredRepos(user)
if err != nil {
return to.ErrorResult(fmt.Errorf("list starred repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func myStarredFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called myStarredFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.GetMyStarredRepos()
if err != nil {
return to.ErrorResult(fmt.Errorf("list my starred repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func checkStarFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called checkStarFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
starred, _, err := client.IsRepoStarring(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("check star err: %v", err))
}
return to.TextResult(map[string]any{"starred": starred})
}
func starRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called starRepoFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.StarRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("star repo err: %v", err))
}
return to.TextResult("Repository starred successfully")
}
func unstarRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called unstarRepoFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.UnStarRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("unstar repo err: %v", err))
}
return to.TextResult("Repository unstarred successfully")
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, map[string]any{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"private": r.Private,
})
}
return out
}
+376
View File
@@ -0,0 +1,376 @@
package team
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
TeamReadToolName = "team_read"
TeamWriteToolName = "team_write"
)
var (
TeamReadTool = mcp.NewTool(
TeamReadToolName,
mcp.WithDescription("Read team information. Use method 'get' to get team details, 'list_members' to list team members, 'list_repos' to list team repositories."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list_members", "list_repos")),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Description("team ID (required for 'get', 'list_members', 'list_repos')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
TeamWriteTool = mcp.NewTool(
TeamWriteToolName,
mcp.WithDescription("Create, update, or delete teams, manage team members."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete", "add_member", "remove_member", "add_repo", "remove_repo")),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Description("team ID (required for 'edit', 'delete', 'add_member', 'remove_member', 'add_repo', 'remove_repo')")),
mcp.WithString("name", mcp.Description("team name (required for 'create', optional for 'edit')")),
mcp.WithString("description", mcp.Description("team description")),
mcp.WithString("permission", mcp.Description("permission level"), mcp.Enum("read", "write", "admin", "owner")),
mcp.WithString("user", mcp.Description("username (required for 'add_member', 'remove_member')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'add_repo', 'remove_repo')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: TeamReadTool,
Handler: teamReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: TeamWriteTool,
Handler: teamWriteFn,
})
}
func teamReadFn(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 getTeamFn(ctx, req)
case "list_members":
return listTeamMembersFn(ctx, req)
case "list_repos":
return listTeamReposFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func teamWriteFn(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 createTeamFn(ctx, req)
case "edit":
return editTeamFn(ctx, req)
case "delete":
return deleteTeamFn(ctx, req)
case "add_member":
return addTeamMemberFn(ctx, req)
case "remove_member":
return removeTeamMemberFn(ctx, req)
case "add_repo":
return addTeamRepoFn(ctx, req)
case "remove_repo":
return removeTeamRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getTeamFn")
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))
}
team, _, err := client.GetTeam(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get team err: %v", err))
}
return to.TextResult(slimTeam(team))
}
func listTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamMembersFn")
id, err := params.GetIndex(req.GetArguments(), "id")
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))
}
opt := gitea_sdk.ListTeamMembersOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
members, _, err := client.ListTeamMembers(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team members err: %v", err))
}
return to.TextResult(slimUsers(members))
}
func listTeamReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamReposFn")
id, err := params.GetIndex(req.GetArguments(), "id")
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))
}
opt := gitea_sdk.ListTeamRepositoriesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
repos, _, err := client.ListTeamRepositories(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func createTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createTeamFn")
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)
}
description, _ := req.GetArguments()["description"].(string)
permission, _ := req.GetArguments()["permission"].(string)
opt := gitea_sdk.CreateTeamOption{
Name: name,
Description: description,
Permission: gitea_sdk.AccessMode(permission),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
team, _, err := client.CreateTeam(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create team err: %v", err))
}
return to.TextResult(slimTeam(team))
}
func editTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editTeamFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditTeamOption{}
if name, ok := req.GetArguments()["name"].(string); ok && name != "" {
opt.Name = name
}
if description, ok := req.GetArguments()["description"].(string); ok && description != "" {
opt.Description = &description
}
if permission, ok := req.GetArguments()["permission"].(string); ok {
opt.Permission = gitea_sdk.AccessMode(permission)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.EditTeam(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit team err: %v", err))
}
return to.TextResult("Team updated successfully")
}
func deleteTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTeamFn")
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.DeleteTeam(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete team err: %v", err))
}
return to.TextResult("Team deleted successfully")
}
func addTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamMemberFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(req.GetArguments(), "user")
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.AddTeamMember(id, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("add team member err: %v", err))
}
return to.TextResult("Team member added successfully")
}
func removeTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamMemberFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(req.GetArguments(), "user")
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.RemoveTeamMember(id, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove team member err: %v", err))
}
return to.TextResult("Team member removed successfully")
}
func addTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamRepoFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "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))
}
_, err = client.AddTeamRepository(id, org, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("add team repo err: %v", err))
}
return to.TextResult("Team repository added successfully")
}
func removeTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamRepoFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "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))
}
_, err = client.RemoveTeamRepository(id, org, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove team repo err: %v", err))
}
return to.TextResult("Team repository removed successfully")
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
orgName := ""
if t.Organization != nil {
orgName = t.Organization.Name
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
"org_name": orgName,
}
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, map[string]any{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
})
}
return out
}
+47
View File
@@ -0,0 +1,47 @@
package timetracking
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
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
}
+332
View File
@@ -0,0 +1,332 @@
// Package timetracking provides MCP tools for Gitea time tracking operations
package timetracking
import (
"context"
"fmt"
gitea_sdk "code.gitea.io/sdk/gitea"
"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/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 (
TimetrackingReadToolName = "timetracking_read"
TimetrackingWriteToolName = "timetracking_write"
)
var (
TimetrackingReadTool = mcp.NewTool(
TimetrackingReadToolName,
mcp.WithDescription("Read time tracking data. Use method 'list_issue_times' for issue times, 'list_repo_times' for repository times, 'get_my_stopwatches' for active stopwatches, 'get_my_times' for all your tracked times."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")),
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required 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))
}
}
// Stopwatch handler functions
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called startStopwatchFn")
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(), "index")
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.StartIssueStopWatch(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) {
log.Debugf("Called stopStopwatchFn")
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(), "index")
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.StopIssueStopWatch(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) {
log.Debugf("Called deleteStopwatchFn")
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(), "index")
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.DeleteIssueStopwatch(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) {
log.Debugf("Called getMyStopwatchesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
stopwatches, _, err := client.ListMyStopwatches(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))
}
// Tracked time handler functions
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTrackedTimesFn")
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(), "index")
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.ListIssueTrackedTimes(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) {
log.Debugf("Called addTrackedTimeFn")
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(), "index")
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.AddTime(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) {
log.Debugf("Called deleteTrackedTimeFn")
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(), "index")
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.DeleteTime(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) {
log.Debugf("Called listRepoTimesFn")
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.ListRepoTrackedTimes(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) {
log.Debugf("Called getMyTimesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListMyTrackedTimes(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))
}
+157
View File
@@ -0,0 +1,157 @@
package transfer
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
TransferRepoToolName = "transfer_repo"
AcceptTransferToolName = "accept_transfer"
RejectTransferToolName = "reject_transfer"
)
var Tool = tool.New()
var (
TransferRepoTool = mcp.NewTool(
TransferRepoToolName,
mcp.WithDescription("Transfer repository ownership to another user or organization"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Current repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithString("new_owner", mcp.Required(), mcp.Description("New owner (user or org)")),
mcp.WithArray("teams", mcp.Description("Teams to transfer (for org-to-org)"), mcp.Items(map[string]any{"type": "string"})),
)
AcceptTransferTool = mcp.NewTool(
AcceptTransferToolName,
mcp.WithDescription("Accept a repository transfer"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
)
RejectTransferTool = mcp.NewTool(
RejectTransferToolName,
mcp.WithDescription("Reject a repository transfer"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: TransferRepoTool,
Handler: transferRepoFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AcceptTransferTool,
Handler: acceptTransferFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: RejectTransferTool,
Handler: rejectTransferFn,
})
}
func transferRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called transferRepoFn")
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)
}
newOwner, err := params.GetString(args, "new_owner")
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.TransferRepoOption{
NewOwner: newOwner,
}
r, _, err := client.TransferRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("transfer repo err: %v", err))
}
return to.TextResult(map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"owner": r.Owner.UserName,
})
}
func acceptTransferFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called acceptTransferFn")
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))
}
r, _, err := client.AcceptRepoTransfer(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("accept transfer err: %v", err))
}
return to.TextResult(map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"owner": r.Owner.UserName,
})
}
func rejectTransferFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called rejectTransferFn")
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))
}
_, _, err = client.RejectRepoTransfer(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("reject transfer err: %v", err))
}
return to.TextResult("Repository transfer rejected")
}
+42
View File
@@ -0,0 +1,42 @@
package user
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(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 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
}
+39
View File
@@ -0,0 +1,39 @@
package user
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimUserDetail(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 := slimUserDetail(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 TestSlimUserDetail_Nil(t *testing.T) {
if m := slimUserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}
+106
View File
@@ -0,0 +1,106 @@
package user
import (
"context"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
// GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_me command.
GetMyUserInfoToolName = "get_me"
// GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command.
GetUserOrgsToolName = "get_user_orgs"
// defaultPage is the default starting page number used for paginated organization listings.
defaultPage = 1
// defaultPageSize is the default number of organizations per page for paginated queries.
defaultPageSize = 30
)
// Tool is the MCP tool manager instance for registering all MCP tools in this package.
var Tool = tool.New()
var (
// GetMyUserInfoTool is the MCP tool for retrieving the current user's info.
// It is registered with a specific name and a description string.
GetMyUserInfoTool = mcp.NewTool(
GetMyUserInfoToolName,
mcp.WithDescription("Get my user info"),
)
// GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user.
// It supports pagination via "page" and "perPage" arguments with default values specified above.
GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)),
)
)
// init registers all MCP tools in Tool at package initialization.
// This function ensures the handler functions are registered before server usage.
func init() {
registerTools()
}
// registerTools registers all local MCP tool definitions and their handler functions.
// To add new functionality, append your tool/handler pair to the tools slice below.
func registerTools() {
tools := []server.ServerTool{
{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn},
{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
// GetUserInfoFn is the handler for "get_me" MCP tool requests.
// Logs invocation, fetches current user info from gitea, wraps result for MCP.
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserInfoFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
user, _, err := client.GetMyUserInfo()
if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
}
return to.TextResult(slimUserDetail(user))
}
// GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests.
// Logs invocation, pulls validated pagination arguments from request,
// performs Gitea organization listing, and wraps the result for MCP.
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserOrgsFn")
page, pageSize := params.GetPagination(req.GetArguments(), defaultPageSize)
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.ListMyOrgs(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
+182
View File
@@ -0,0 +1,182 @@
package version
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"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"
CheckGiteaVersion = "check_gitea_version"
)
var GetGiteaMCPServerVersionTool = mcp.NewTool(
GetGiteaMCPServerVersion,
mcp.WithDescription("Get Gitea MCP Server Version"),
)
var CheckGiteaVersionTool = mcp.NewTool(
CheckGiteaVersion,
mcp.WithDescription("Check the Gitea server version and API capabilities. Returns version string, parsed components, and capability matrix indicating which APIs are available based on the detected version."),
)
// VersionResponse represents the Gitea version API response
type VersionResponse struct {
Version string `json:"version"`
}
// VersionInfo represents the complete version information with capabilities
type VersionInfo struct {
Version string `json:"version"`
Major int `json:"major"`
Minor int `json:"minor"`
Patch int `json:"patch"`
Capabilities CapabilityMatrix `json:"capabilities"`
}
// CapabilityMatrix indicates which APIs are available
type CapabilityMatrix struct {
ActionsAPI bool `json:"actions_api"`
SecretsAPI bool `json:"secrets_api"`
VariablesAPI bool `json:"variables_api"`
RunnersAPI bool `json:"runners_api"`
ArtifactsAPI bool `json:"artifacts_api"`
CommitStatusAPI bool `json:"commit_status_api"`
}
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetGiteaMCPServerVersionTool,
Handler: GetGiteaMCPServerVersionFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: CheckGiteaVersionTool,
Handler: CheckGiteaVersionFn,
})
}
func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetGiteaMCPServerVersionFn")
version := flag.Version
if version == "" {
version = "dev"
}
return to.TextResult(fmt.Sprintf("Gitea MCP Server version: %v", version))
}
// CheckGiteaVersionFn retrieves Gitea server version and determines capabilities
func CheckGiteaVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CheckGiteaVersionFn")
var versionResp VersionResponse
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
log.Errorf("Failed to get Gitea version: status=%d, err=%v", status, err)
return to.TextResult(fmt.Sprintf("Error: Failed to get Gitea version (status %d): %v", status, err))
}
major, minor, patch, err := parseVersion(versionResp.Version)
if err != nil {
log.Errorf("Failed to parse version string '%s': %v", versionResp.Version, err)
return to.TextResult(fmt.Sprintf("Error: Failed to parse version '%s': %v", versionResp.Version, err))
}
capabilities := determineCapabilities(major, minor, patch)
info := VersionInfo{
Version: versionResp.Version,
Major: major,
Minor: minor,
Patch: patch,
Capabilities: capabilities,
}
jsonBytes, err := json.MarshalIndent(info, "", " ")
if err != nil {
log.Errorf("Failed to marshal version info: %v", err)
return to.TextResult(fmt.Sprintf("Error: Failed to format response: %v", err))
}
return to.TextResult(string(jsonBytes))
}
// parseVersion parses a version string like "1.22.5" into major, minor, patch
func parseVersion(version string) (int, int, int, error) {
version = strings.TrimPrefix(version, "v")
version = strings.TrimPrefix(version, "V")
parts := strings.Split(version, ".")
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s (expected major.minor.patch)", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid patch version: %s", parts[2])
}
}
return major, minor, patch, nil
}
// determineCapabilities determines which APIs are available based on version
func determineCapabilities(major, minor, patch int) CapabilityMatrix {
cm := CapabilityMatrix{}
// Commit Status API: Available in 1.12+
if major > 1 || (major == 1 && minor >= 12) {
cm.CommitStatusAPI = true
}
// Secrets API: Available in 1.22.0+
if major > 1 || (major == 1 && minor >= 22) {
cm.SecretsAPI = true
}
// Variables API: Available in 1.22.0+ (same as secrets)
if major > 1 || (major == 1 && minor >= 22) {
cm.VariablesAPI = true
}
// Runners API: Available in 1.22.0+ with improvements in 1.23+
if major > 1 || (major == 1 && minor >= 22) {
cm.RunnersAPI = true
}
// Actions API: Full support in 1.23+, limited in 1.22
// Artifacts API: Full support in 1.23+
if major > 1 || (major == 1 && minor >= 23) {
cm.ActionsAPI = true
cm.ArtifactsAPI = true
// Runners API is more complete in 1.23+
cm.RunnersAPI = true
}
return cm
}
+488
View File
@@ -0,0 +1,488 @@
package version
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_checkGiteaVersionFn_success(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.22.5"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
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 VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "1.22.5" {
t.Errorf("version = %q, want %q", parsed.Version, "1.22.5")
}
if parsed.Major != 1 {
t.Errorf("major = %d, want 1", parsed.Major)
}
if parsed.Minor != 22 {
t.Errorf("minor = %d, want 22", parsed.Minor)
}
if parsed.Patch != 5 {
t.Errorf("patch = %d, want 5", parsed.Patch)
}
// 1.22.5 should have secrets, variables, runners, commit_status but not actions/artifacts
if !parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=true for 1.22.5")
}
if !parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=true for 1.22.5")
}
if !parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=true for 1.22.5")
}
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.22.5")
}
if parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=false for 1.22.5")
}
if parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=false for 1.22.5")
}
}
func Test_checkGiteaVersionFn_version123(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.23.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "1.23.0" {
t.Errorf("version = %q, want %q", parsed.Version, "1.23.0")
}
// 1.23.0 should have all capabilities
if !parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=true for 1.23.0")
}
if !parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=true for 1.23.0")
}
if !parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=true for 1.23.0")
}
if !parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=true for 1.23.0")
}
if !parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=true for 1.23.0")
}
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.23.0")
}
}
func Test_checkGiteaVersionFn_version111(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.11.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
// 1.11.0 should only have commit_status
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.11.0")
}
if parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=false for 1.11.0")
}
if parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=false for 1.11.0")
}
if parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=false for 1.11.0")
}
if parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=false for 1.11.0")
}
if parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=false for 1.11.0")
}
}
func Test_checkGiteaVersionFn_withVPrefix(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"v1.24.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "v1.24.0" {
t.Errorf("version = %q, want %q", parsed.Version, "v1.24.0")
}
if parsed.Major != 1 {
t.Errorf("major = %d, want 1", parsed.Major)
}
if parsed.Minor != 24 {
t.Errorf("minor = %d, want 24", parsed.Minor)
}
if parsed.Patch != 0 {
t.Errorf("patch = %d, want 0", parsed.Patch)
}
// 1.24 should have all capabilities
if !parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=true for 1.24.0")
}
}
func Test_checkGiteaVersionFn_error(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Internal Server Error"}`, http.StatusInternalServerError)
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
// Should return error message in text result
if textContent.Text == "" {
t.Error("expected error message in result")
}
}
func Test_parseVersion(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.22.5", 1, 22, 5, false},
{"1.23.0", 1, 23, 0, false},
{"1.24.1", 1, 24, 1, false},
{"v1.22.5", 1, 22, 5, false},
{"V1.22.5", 1, 22, 5, false},
{"1.22", 1, 22, 0, false},
{"1", 0, 0, 0, true},
{"", 0, 0, 0, true},
{"abc", 0, 0, 0, true},
{"1.x.5", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersion(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor {
t.Errorf("parseVersion(%q) major = %d, want %d", tt.version, major, tt.wantMajor)
}
if minor != tt.wantMinor {
t.Errorf("parseVersion(%q) minor = %d, want %d", tt.version, minor, tt.wantMinor)
}
if patch != tt.wantPatch {
t.Errorf("parseVersion(%q) patch = %d, want %d", tt.version, patch, tt.wantPatch)
}
}
})
}
}
func Test_determineCapabilities(t *testing.T) {
tests := []struct {
name string
major int
minor int
patch int
expected CapabilityMatrix
}{
{
name: "1.11.0",
major: 1, minor: 11, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: false,
VariablesAPI: false,
RunnersAPI: false,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.12.0",
major: 1, minor: 12, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: false,
VariablesAPI: false,
RunnersAPI: false,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.22.0",
major: 1, minor: 22, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.22.5",
major: 1, minor: 22, patch: 5,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.23.0",
major: 1, minor: 23, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
{
name: "1.24.0",
major: 1, minor: 24, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
{
name: "2.0.0",
major: 2, minor: 0, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineCapabilities(tt.major, tt.minor, tt.patch)
if got != tt.expected {
t.Errorf("determineCapabilities(%d, %d, %d) = %+v, want %+v",
tt.major, tt.minor, tt.patch, got, tt.expected)
}
})
}
}
+345
View File
@@ -0,0 +1,345 @@
package webhook
import (
"context"
"encoding/json"
"fmt"
"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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
WebhookReadToolName = "webhook_read"
WebhookWriteToolName = "webhook_write"
)
var (
WebhookReadTool = mcp.NewTool(
WebhookReadToolName,
mcp.WithDescription("Read webhooks. Use method 'list_repo' to list repo webhooks, 'list_org' for org webhooks, 'get' to get a specific webhook."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo", "list_org", "get")),
mcp.WithString("owner", mcp.Description("repository or organization owner (required for 'list_repo', 'get')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_repo', 'get')")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'get')")),
)
WebhookWriteTool = mcp.NewTool(
WebhookWriteToolName,
mcp.WithDescription("Create, update, or delete webhooks."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo", "create_org", "edit_repo", "edit_org", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository or organization owner")),
mcp.WithString("repo", mcp.Description("repository name (required for repo operations)")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'edit', 'delete')")),
mcp.WithString("url", mcp.Required(), mcp.Description("webhook URL")),
mcp.WithString("secret", mcp.Description("webhook secret")),
mcp.WithBoolean("active", mcp.Description("whether webhook is active")),
mcp.WithArray("events", mcp.Description("events to trigger webhook"), mcp.Items(map[string]any{"type": "string"})),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: WebhookReadTool,
Handler: webhookReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: WebhookWriteTool,
Handler: webhookWriteFn,
})
}
func webhookReadFn(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":
return listRepoHooksFn(ctx, req)
case "list_org":
return listOrgHooksFn(ctx, req)
case "get":
return getHookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func webhookWriteFn(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_repo":
return createRepoHookFn(ctx, req)
case "create_org":
return createOrgHookFn(ctx, req)
case "edit_repo":
return editRepoHookFn(ctx, req)
case "edit_org":
return editOrgHookFn(ctx, req)
case "delete":
return deleteHookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoHooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoHooksFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
hooks, _, err := client.ListRepoHooks(owner, repo, gitea_sdk.ListHooksOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo hooks err: %v", err))
}
return to.TextResult(slimHooks(hooks))
}
func listOrgHooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgHooksFn")
owner, err := params.GetString(req.GetArguments(), "owner")
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))
}
hooks, _, err := client.ListOrgHooks(owner, gitea_sdk.ListHooksOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org hooks err: %v", err))
}
return to.TextResult(slimHooks(hooks))
}
func getHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, _ := req.GetArguments()["repo"].(string)
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))
}
var hook *gitea_sdk.Hook
if repo != "" {
hook, _, err = client.GetRepoHook(owner, repo, id)
} else {
hook, _, err = client.GetOrgHook(owner, id)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("get hook err: %v", err))
}
return to.TextResult(slimHook(hook))
}
func createRepoHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoHookFn")
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)
}
url, err := params.GetString(req.GetArguments(), "url")
if err != nil {
return to.ErrorResult(err)
}
return createHook(ctx, owner, repo, url, req.GetArguments(), false)
}
func createOrgHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
url, err := params.GetString(req.GetArguments(), "url")
if err != nil {
return to.ErrorResult(err)
}
return createHook(ctx, owner, "", url, req.GetArguments(), true)
}
func createHook(ctx context.Context, owner, repo, url string, args map[string]any, isOrg bool) (*mcp.CallToolResult, error) {
secret, _ := args["secret"].(string)
active, _ := args["active"].(bool)
events, _ := args["events"].([]any)
eventStr := "push"
if len(events) > 0 {
eventsJson, _ := json.Marshal(events)
eventStr = string(eventsJson)
}
opt := gitea_sdk.CreateHookOption{
Type: "gitea",
Active: active,
Events: []string{eventStr},
Config: map[string]string{
"url": url,
"content_type": "json",
"secret": secret,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
var hook *gitea_sdk.Hook
if isOrg {
hook, _, err = client.CreateOrgHook(owner, opt)
} else {
hook, _, err = client.CreateRepoHook(owner, repo, opt)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("create hook err: %v", err))
}
return to.TextResult(slimHook(hook))
}
func editRepoHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoHookFn")
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)
}
return editHook(ctx, owner, repo, id, req.GetArguments(), false)
}
func editOrgHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
return editHook(ctx, owner, "", id, req.GetArguments(), true)
}
func editHook(ctx context.Context, owner, repo string, id int64, args map[string]any, isOrg bool) (*mcp.CallToolResult, error) {
url, _ := args["url"].(string)
secret, _ := args["secret"].(string)
active, hasActive := args["active"].(bool)
opt := gitea_sdk.EditHookOption{}
if url != "" {
opt.Config = map[string]string{"url": url}
}
if secret != "" {
if opt.Config == nil {
opt.Config = map[string]string{}
}
opt.Config["secret"] = secret
}
if hasActive {
opt.Active = &active
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if isOrg {
_, err = client.EditOrgHook(owner, id, opt)
} else {
_, err = client.EditRepoHook(owner, repo, id, opt)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("edit hook err: %v", err))
}
return to.TextResult("Webhook updated successfully")
}
func deleteHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, hasRepo := req.GetArguments()["repo"].(string)
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))
}
if hasRepo && repo != "" {
_, err = client.DeleteRepoHook(owner, repo, id)
} else {
_, err = client.DeleteOrgHook(owner, id)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("delete hook err: %v", err))
}
return to.TextResult("Webhook deleted successfully")
}
func slimHooks(hooks []*gitea_sdk.Hook) []map[string]any {
out := make([]map[string]any, 0, len(hooks))
for _, h := range hooks {
out = append(out, slimHook(h))
}
return out
}
func slimHook(h *gitea_sdk.Hook) map[string]any {
if h == nil {
return nil
}
return map[string]any{
"id": h.ID,
"type": h.Type,
"url": h.URL,
"active": h.Active,
"events": h.Events,
"created": h.Created,
}
}
+273
View File
@@ -0,0 +1,273 @@
package wiki
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"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/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 page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
)
WikiWriteTool = mcp.NewTool(
WikiWriteToolName,
mcp.WithDescription("Create, update, or delete wiki pages."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content", mcp.Description("page content (required 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) {
log.Debugf("Called listWikiPagesFn")
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) {
log.Debugf("Called getWikiPageFn")
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) {
log.Debugf("Called getWikiRevisionsFn")
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) {
log.Debugf("Called createWikiPageFn")
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) {
log.Debugf("Called updateWikiPageFn")
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) {
log.Debugf("Called deleteWikiPageFn")
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"})
}
+75
View File
@@ -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)
}
})
}
}
+7
View File
@@ -0,0 +1,7 @@
package context
type contextKey string
const (
TokenContextKey = contextKey("token")
)

Some files were not shown because too many files have changed in this diff Show More