6 Commits

Author SHA1 Message Date
silverwind
4d5fa3ab2c feat: accept string or number for index parameters (#131)
This change makes index parameters more flexible by accepting both numeric and string values. LLM agents often pass issue/PR indices as strings (e.g., "123") since they appear as string identifiers in URLs and CLI contexts. The implementation:

- Created `pkg/params` package with `GetIndex()` helper function
- Updated 25+ tool functions across issue, pull, label, and timetracking operations
- Improved error messages to say "must be a valid integer" instead of misleading "is required"
- Added comprehensive tests for both numeric and string inputs

Based on #122 by @jamespharaoh with review feedback applied (replaced custom `contains()` test helper with `strings.Contains`). Verified working in Claude Code.

Fixes: https://gitea.com/gitea/gitea-mcp/issues/121
Fixes: https://gitea.com/gitea/gitea-mcp/issues/122
---------

Co-authored-by: James Pharaoh <james@pharaoh.uk>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/131
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-20 23:47:22 +00:00
silverwind
21e4e1b42b feat: add edit_pull_request tool (#125)
## Summary
- Add `edit_pull_request` MCP tool to modify pull request properties
- Supports editing title, body, base branch, assignees, milestone, state, and maintainer edit permission
- Enables toggling WIP/draft status by modifying the title prefix

Fixes https://gitea.com/gitea/gitea-mcp/issues/124

## Test plan
- [x] `go test ./...` passes
- [x] Verified against gitea.com: toggled WIP on/off via title edit, changed PR state

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/125
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-13 13:26:21 +00:00
tylermitchell
4aacfe348a feat(pull): add merge_pull_request tool (#123)
Add MCP tool to merge pull requests with support for:
- Multiple merge styles (merge, rebase, rebase-merge, squash, fast-forward-only)
- Custom merge commit messages
- Optional branch deletion after merge
- Detailed error handling for merge conflicts and edge cases

Updated all README files (English, Simplified Chinese, Traditional Chinese)
with the new tool entry.

---------

Co-authored-by: Tyler Potts <tyler@adhdafterdiagnosis.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/123
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
Co-authored-by: tylermitchell <tylermitchell@noreply.gitea.com>
Co-committed-by: tylermitchell <tylermitchell@noreply.gitea.com>
2026-02-11 17:53:09 +00:00
silverwind
1f7392305f docs: add --scope user to Claude Code examples in READMEs (#118)
Small followup to https://gitea.com/gitea/gitea-mcp/pulls/117. By using the user scope, the MCP server connection will be saved into the user's home directory, making it available for all repos, which is more useful than having to do this per-repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------

Co-authored-by: hiifong <f@f.style>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/118
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: hiifong <f@f.style>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-06 03:16:37 +00:00
Gustav
c3b24d65fe added support for get_pull_request_diff (#119)
This function call is needed to be able to do AI code review to actually get the diff from the PR.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/119
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
Reviewed-by: hiifong <f@f.style>
Co-authored-by: Gustav <tvarsis@hotmail.com>
Co-committed-by: Gustav <tvarsis@hotmail.com>
2026-02-06 03:14:47 +00:00
silverwind
dcd01441c5 docs: add Claude Code usage example to README files (#117)
I verified this is working:

```bash
$ claude mcp list | grep gitea
gitea: go run gitea.com/gitea/gitea-mcp@latest -t stdio - ✓ Connected
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/117
Reviewed-by: hiifong <f@f.style>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-04 02:45:37 +00:00
11 changed files with 842 additions and 129 deletions

71
AGENTS.md Normal file
View File

@@ -0,0 +1,71 @@
# 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
## 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

View File

@@ -13,6 +13,7 @@
- [What is Gitea?](#what-is-gitea) - [What is Gitea?](#what-is-gitea)
- [What is MCP?](#what-is-mcp) - [What is MCP?](#what-is-mcp)
- [🚧 Installation](#-installation) - [🚧 Installation](#-installation)
- [Usage with Claude Code](#usage-with-claude-code)
- [Usage with VS Code](#usage-with-vs-code) - [Usage with VS Code](#usage-with-vs-code)
- [📥 Download the official binary release](#-download-the-official-binary-release) - [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source) - [🔧 Build from Source](#-build-from-source)
@@ -32,6 +33,17 @@ Model Context Protocol (MCP) is a protocol that allows for the integration of va
## 🚧 Installation ## 🚧 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 ### Usage with VS Code
For quick installation, use one of the one-click install buttons at the top of this README. For quick installation, use one of the one-click install buttons at the top of this README.
@@ -197,6 +209,7 @@ The Gitea MCP Server supports the following tools:
| edit_issue_comment | Issue | Edit a comment on an 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_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_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 | | 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 | Pull Request | Create a new pull request |
| create_pull_request_reviewer | Pull Request | Add reviewers to a pull request | | create_pull_request_reviewer | Pull Request | Add reviewers to a pull request |
@@ -208,6 +221,7 @@ The Gitea MCP Server supports the following tools:
| submit_pull_request_review | Pull Request | Submit a pending review | | submit_pull_request_review | Pull Request | Submit a pending review |
| delete_pull_request_review | Pull Request | Delete a review | | delete_pull_request_review | Pull Request | Delete a review |
| dismiss_pull_request_review | Pull Request | Dismiss a review with optional message | | dismiss_pull_request_review | Pull Request | Dismiss a review with optional message |
| merge_pull_request | Pull Request | Merge a pull request |
| search_users | User | Search for users | | search_users | User | Search for users |
| search_org_teams | Organization | Search for teams in an organization | | search_org_teams | Organization | Search for teams in an organization |
| list_org_labels | Organization | List labels defined at organization level | | list_org_labels | Organization | List labels defined at organization level |

View File

@@ -13,6 +13,7 @@
- [什么是 Gitea](#什么是-gitea) - [什么是 Gitea](#什么是-gitea)
- [什么是 MCP](#什么是-mcp) - [什么是 MCP](#什么是-mcp)
- [🚧 安装](#-安装) - [🚧 安装](#-安装)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用) - [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下载官方二进制版本](#-下载官方二进制版本) - [📥 下载官方二进制版本](#-下载官方二进制版本)
- [🔧 从源码构建](#-从源码构建) - [🔧 从源码构建](#-从源码构建)
@@ -32,6 +33,17 @@ 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 中使用 ### 在 VS Code 中使用
要快速安装,请使用本 README 顶部的安装按钮。 要快速安装,请使用本 README 顶部的安装按钮。
@@ -208,6 +220,7 @@ Gitea MCP 服务器支持以下工具:
| submit_pull_request_review | 拉取请求 | 提交待处理的审查 | | submit_pull_request_review | 拉取请求 | 提交待处理的审查 |
| delete_pull_request_review | 拉取请求 | 删除审查 | | delete_pull_request_review | 拉取请求 | 删除审查 |
| dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) | | dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) |
| merge_pull_request | 拉取请求 | 合并拉取请求 |
| search_users | 用户 | 搜索用户 | | search_users | 用户 | 搜索用户 |
| search_org_teams | 组织 | 搜索组织团队 | | search_org_teams | 组织 | 搜索组织团队 |
| list_org_labels | 组织 | 列出组织标签 | | list_org_labels | 组织 | 列出组织标签 |

View File

@@ -13,6 +13,7 @@
- [什麼是 Gitea](#什麼是-gitea) - [什麼是 Gitea](#什麼是-gitea)
- [什麼是 MCP](#什麼是-mcp) - [什麼是 MCP](#什麼是-mcp)
- [🚧 安裝](#-安裝) - [🚧 安裝](#-安裝)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用) - [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下載官方二進位版本](#-下載官方二進位版本) - [📥 下載官方二進位版本](#-下載官方二進位版本)
- [🔧 從原始碼建置](#-從原始碼建置) - [🔧 從原始碼建置](#-從原始碼建置)
@@ -32,6 +33,17 @@ 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 中使用 ### 在 VS Code 中使用
欲快速安裝,請使用本 README 頂部的安裝按鈕。 欲快速安裝,請使用本 README 頂部的安裝按鈕。
@@ -208,6 +220,7 @@ Gitea MCP 伺服器支援以下工具:
| submit_pull_request_review | 拉取請求 | 提交待處理的審查 | | submit_pull_request_review | 拉取請求 | 提交待處理的審查 |
| delete_pull_request_review | 拉取請求 | 刪除審查 | | delete_pull_request_review | 拉取請求 | 刪除審查 |
| dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) | | dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) |
| merge_pull_request | 拉取請求 | 合併拉取請求 |
| search_users | 用戶 | 搜尋用戶 | | search_users | 用戶 | 搜尋用戶 |
| search_org_teams | 組織 | 搜尋組織團隊 | | search_org_teams | 組織 | 搜尋組織團隊 |
| list_org_labels | 組織 | 列出組織標籤 | | list_org_labels | 組織 | 列出組織標籤 |

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -136,17 +137,17 @@ func GetIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issue, _, err := client.GetIssue(owner, repo, int64(index)) issue, _, err := client.GetIssue(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(issue)
@@ -235,9 +236,9 @@ func CreateIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
body, ok := req.GetArguments()["body"].(string) body, ok := req.GetArguments()["body"].(string)
if !ok { if !ok {
@@ -250,9 +251,9 @@ func CreateIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issueComment, _, err := client.CreateIssueComment(owner, repo, int64(index), opt) issueComment, _, err := client.CreateIssueComment(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err))
} }
return to.TextResult(issueComment) return to.TextResult(issueComment)
@@ -268,9 +269,9 @@ func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditIssueOption{} opt := gitea_sdk.EditIssueOption{}
@@ -307,9 +308,9 @@ func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issue, _, err := client.EditIssue(owner, repo, int64(index), opt) issue, _, err := client.EditIssue(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(issue)
@@ -358,18 +359,18 @@ func GetIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.ListIssueCommentOptions{} opt := gitea_sdk.ListIssueCommentOptions{}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issue, _, err := client.ListIssueComments(owner, repo, int64(index), opt) issue, _, err := client.ListIssueComments(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(issue)

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -380,9 +381,9 @@ func AddIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("issue index is required")) return to.ErrorResult(err)
} }
labelsRaw, ok := req.GetArguments()["labels"].([]interface{}) labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok { if !ok {
@@ -405,9 +406,9 @@ func AddIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issueLabels, _, err := client.AddIssueLabels(owner, repo, int64(index), opt) issueLabels, _, err := client.AddIssueLabels(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issueLabels) return to.TextResult(issueLabels)
} }
@@ -422,9 +423,9 @@ func ReplaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("issue index is required")) return to.ErrorResult(err)
} }
labelsRaw, ok := req.GetArguments()["labels"].([]interface{}) labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok { if !ok {
@@ -447,9 +448,9 @@ func ReplaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, int64(index), opt) issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issueLabels) return to.TextResult(issueLabels)
} }
@@ -464,18 +465,18 @@ func ClearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("issue index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.ClearIssueLabels(owner, repo, int64(index)) _, err = client.ClearIssueLabels(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult("Labels cleared successfully") return to.TextResult("Labels cleared successfully")
} }
@@ -490,9 +491,9 @@ func RemoveIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("issue index is required")) return to.ErrorResult(err)
} }
labelID, ok := req.GetArguments()["label_id"].(float64) labelID, ok := req.GetArguments()["label_id"].(float64)
if !ok { if !ok {
@@ -503,9 +504,9 @@ func RemoveIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteIssueLabel(owner, repo, int64(index), int64(labelID)) _, err = client.DeleteIssueLabel(owner, repo, index, int64(labelID))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", int64(labelID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", int64(labelID), owner, repo, index, err))
} }
return to.TextResult("Label removed successfully") return to.TextResult("Label removed successfully")
} }

View File

@@ -6,6 +6,8 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,6 +20,7 @@ var Tool = tool.New()
const ( const (
GetPullRequestByIndexToolName = "get_pull_request_by_index" GetPullRequestByIndexToolName = "get_pull_request_by_index"
GetPullRequestDiffToolName = "get_pull_request_diff"
ListRepoPullRequestsToolName = "list_repo_pull_requests" ListRepoPullRequestsToolName = "list_repo_pull_requests"
CreatePullRequestToolName = "create_pull_request" CreatePullRequestToolName = "create_pull_request"
CreatePullRequestReviewerToolName = "create_pull_request_reviewer" CreatePullRequestReviewerToolName = "create_pull_request_reviewer"
@@ -29,6 +32,8 @@ const (
SubmitPullRequestReviewToolName = "submit_pull_request_review" SubmitPullRequestReviewToolName = "submit_pull_request_review"
DeletePullRequestReviewToolName = "delete_pull_request_review" DeletePullRequestReviewToolName = "delete_pull_request_review"
DismissPullRequestReviewToolName = "dismiss_pull_request_review" DismissPullRequestReviewToolName = "dismiss_pull_request_review"
MergePullRequestToolName = "merge_pull_request"
EditPullRequestToolName = "edit_pull_request"
) )
var ( var (
@@ -40,6 +45,15 @@ var (
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository pull request index")), mcp.WithNumber("index", mcp.Required(), mcp.Description("repository pull request index")),
) )
GetPullRequestDiffTool = mcp.NewTool(
GetPullRequestDiffToolName,
mcp.WithDescription("get pull request diff"),
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 pull request index")),
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes")),
)
ListRepoPullRequestsTool = mcp.NewTool( ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName, ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"), mcp.WithDescription("List repository pull requests"),
@@ -160,6 +174,33 @@ var (
mcp.WithNumber("review_id", mcp.Required(), mcp.Description("review ID")), mcp.WithNumber("review_id", mcp.Required(), mcp.Description("review ID")),
mcp.WithString("message", mcp.Description("dismissal reason")), mcp.WithString("message", mcp.Description("dismissal reason")),
) )
MergePullRequestTool = mcp.NewTool(
MergePullRequestToolName,
mcp.WithDescription("merge a pull request"),
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.WithString("merge_style", mcp.Description("merge style: merge, rebase, rebase-merge, squash, fast-forward-only"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("custom merge commit message (optional)")),
mcp.WithBoolean("delete_branch", mcp.Description("delete the branch after merge"), mcp.DefaultBool(false)),
)
EditPullRequestTool = mcp.NewTool(
EditPullRequestToolName,
mcp.WithDescription("edit a pull request"),
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.WithString("title", mcp.Description("pull request title")),
mcp.WithString("body", mcp.Description("pull request body content")),
mcp.WithString("base", mcp.Description("pull request base branch")),
mcp.WithString("assignee", mcp.Description("username to assign")),
mcp.WithArray("assignees", mcp.Description("usernames to assign"), mcp.Items(map[string]interface{}{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number")),
mcp.WithString("state", mcp.Description("pull request state"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit the pull request")),
)
) )
func init() { func init() {
@@ -167,6 +208,10 @@ func init() {
Tool: GetPullRequestByIndexTool, Tool: GetPullRequestByIndexTool,
Handler: GetPullRequestByIndexFn, Handler: GetPullRequestByIndexFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: GetPullRequestDiffTool,
Handler: GetPullRequestDiffFn,
})
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListRepoPullRequestsTool, Tool: ListRepoPullRequestsTool,
Handler: ListRepoPullRequestsFn, Handler: ListRepoPullRequestsFn,
@@ -211,6 +256,14 @@ func init() {
Tool: DismissPullRequestReviewTool, Tool: DismissPullRequestReviewTool,
Handler: DismissPullRequestReviewFn, Handler: DismissPullRequestReviewFn,
}) })
Tool.RegisterWrite(server.ServerTool{
Tool: MergePullRequestTool,
Handler: MergePullRequestFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditPullRequestTool,
Handler: EditPullRequestFn,
})
} }
func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -223,22 +276,59 @@ func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
pr, _, err := client.GetPullRequest(owner, repo, int64(index)) pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(pr) return to.TextResult(pr)
} }
func GetPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullRequestDiffFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
binary, _ := req.GetArguments()["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))
}
result := map[string]interface{}{
"diff": string(diffBytes),
"binary": binary,
"index": index,
"repo": repo,
"owner": owner,
}
return to.TextResult(result)
}
func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoPullRequests") log.Debugf("Called ListRepoPullRequests")
owner, ok := req.GetArguments()["owner"].(string) owner, ok := req.GetArguments()["owner"].(string)
@@ -337,9 +427,9 @@ func CreatePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
var reviewers []string var reviewers []string
@@ -369,12 +459,12 @@ func CreatePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.CreateReviewRequests(owner, repo, int64(index), gitea_sdk.PullReviewRequestOptions{ _, err = client.CreateReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers, Reviewers: reviewers,
TeamReviewers: teamReviewers, TeamReviewers: teamReviewers,
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
// Return a success message instead of the Response object which contains non-serializable functions // Return a success message instead of the Response object which contains non-serializable functions
@@ -382,7 +472,7 @@ func CreatePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
"message": "Successfully created review requests", "message": "Successfully created review requests",
"reviewers": reviewers, "reviewers": reviewers,
"team_reviewers": teamReviewers, "team_reviewers": teamReviewers,
"pr_index": int64(index), "pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo), "repository": fmt.Sprintf("%s/%s", owner, repo),
} }
@@ -399,9 +489,9 @@ func DeletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
var reviewers []string var reviewers []string
@@ -431,19 +521,19 @@ func DeletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteReviewRequests(owner, repo, int64(index), gitea_sdk.PullReviewRequestOptions{ _, err = client.DeleteReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers, Reviewers: reviewers,
TeamReviewers: teamReviewers, TeamReviewers: teamReviewers,
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
successMsg := map[string]interface{}{ successMsg := map[string]interface{}{
"message": "Successfully deleted review requests", "message": "Successfully deleted review requests",
"reviewers": reviewers, "reviewers": reviewers,
"team_reviewers": teamReviewers, "team_reviewers": teamReviewers,
"pr_index": int64(index), "pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo), "repository": fmt.Sprintf("%s/%s", owner, repo),
} }
@@ -460,9 +550,9 @@ func ListPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
page, ok := req.GetArguments()["page"].(float64) page, ok := req.GetArguments()["page"].(float64)
if !ok { if !ok {
@@ -478,14 +568,14 @@ func ListPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
reviews, _, err := client.ListPullReviews(owner, repo, int64(index), gitea_sdk.ListPullReviewsOptions{ reviews, _, err := client.ListPullReviews(owner, repo, index, gitea_sdk.ListPullReviewsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: int(page),
PageSize: int(pageSize), PageSize: int(pageSize),
}, },
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(reviews) return to.TextResult(reviews)
@@ -501,9 +591,9 @@ func GetPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
reviewID, ok := req.GetArguments()["review_id"].(float64) reviewID, ok := req.GetArguments()["review_id"].(float64)
if !ok { if !ok {
@@ -515,9 +605,9 @@ func GetPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
review, _, err := client.GetPullReview(owner, repo, int64(index), int64(reviewID)) review, _, err := client.GetPullReview(owner, repo, index, int64(reviewID))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, index, err))
} }
return to.TextResult(review) return to.TextResult(review)
@@ -533,9 +623,9 @@ func ListPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
reviewID, ok := req.GetArguments()["review_id"].(float64) reviewID, ok := req.GetArguments()["review_id"].(float64)
if !ok { if !ok {
@@ -547,9 +637,9 @@ func ListPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
comments, _, err := client.ListPullReviewComments(owner, repo, int64(index), int64(reviewID)) comments, _, err := client.ListPullReviewComments(owner, repo, index, int64(reviewID))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, index, err))
} }
return to.TextResult(comments) return to.TextResult(comments)
@@ -565,9 +655,9 @@ func CreatePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.CreatePullReviewOptions{} opt := gitea_sdk.CreatePullReviewOptions{}
@@ -611,9 +701,9 @@ func CreatePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
review, _, err := client.CreatePullReview(owner, repo, int64(index), opt) review, _, err := client.CreatePullReview(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(review) return to.TextResult(review)
@@ -629,9 +719,9 @@ func SubmitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
reviewID, ok := req.GetArguments()["review_id"].(float64) reviewID, ok := req.GetArguments()["review_id"].(float64)
if !ok { if !ok {
@@ -654,9 +744,9 @@ func SubmitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
review, _, err := client.SubmitPullReview(owner, repo, int64(index), int64(reviewID), opt) review, _, err := client.SubmitPullReview(owner, repo, index, int64(reviewID), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, index, err))
} }
return to.TextResult(review) return to.TextResult(review)
@@ -672,9 +762,9 @@ func DeletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
reviewID, ok := req.GetArguments()["review_id"].(float64) reviewID, ok := req.GetArguments()["review_id"].(float64)
if !ok { if !ok {
@@ -686,15 +776,15 @@ func DeletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeletePullReview(owner, repo, int64(index), int64(reviewID)) _, err = client.DeletePullReview(owner, repo, index, int64(reviewID))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, index, err))
} }
successMsg := map[string]interface{}{ successMsg := map[string]interface{}{
"message": "Successfully deleted review", "message": "Successfully deleted review",
"review_id": int64(reviewID), "review_id": int64(reviewID),
"pr_index": int64(index), "pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo), "repository": fmt.Sprintf("%s/%s", owner, repo),
} }
@@ -711,9 +801,9 @@ func DismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
reviewID, ok := req.GetArguments()["review_id"].(float64) reviewID, ok := req.GetArguments()["review_id"].(float64)
if !ok { if !ok {
@@ -730,17 +820,145 @@ func DismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DismissPullReview(owner, repo, int64(index), int64(reviewID), opt) _, err = client.DismissPullReview(owner, repo, index, int64(reviewID), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", int64(reviewID), owner, repo, index, err))
} }
successMsg := map[string]interface{}{ successMsg := map[string]interface{}{
"message": "Successfully dismissed review", "message": "Successfully dismissed review",
"review_id": int64(reviewID), "review_id": int64(reviewID),
"pr_index": int64(index), "pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo), "repository": fmt.Sprintf("%s/%s", owner, repo),
} }
return to.TextResult(successMsg) return to.TextResult(successMsg)
} }
func MergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called MergePullRequestFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("index is required"))
}
mergeStyle := "merge"
if style, exists := req.GetArguments()["merge_style"].(string); exists && style != "" {
mergeStyle = style
}
message := ""
if msg, exists := req.GetArguments()["message"].(string); exists {
message = msg
}
deleteBranch := false
if del, exists := req.GetArguments()["delete_branch"].(bool); exists {
deleteBranch = del
}
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),
Message: message,
DeleteBranchAfterMerge: deleteBranch,
}
merged, resp, err := client.MergePullRequest(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, int64(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, int64(index), resp.StatusCode, resp.Status))
}
if !merged {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, int64(index)))
}
successMsg := map[string]interface{}{
"merged": merged,
"pr_index": int64(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")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("index is required"))
}
opt := gitea_sdk.EditPullRequestOption{}
if title, ok := req.GetArguments()["title"].(string); ok {
opt.Title = title
}
if body, ok := req.GetArguments()["body"].(string); ok {
opt.Body = ptr.To(body)
}
if base, ok := req.GetArguments()["base"].(string); ok {
opt.Base = base
}
if assignee, ok := req.GetArguments()["assignee"].(string); ok {
opt.Assignee = assignee
}
if assigneesArg, exists := req.GetArguments()["assignees"]; exists {
if assigneesSlice, ok := assigneesArg.([]interface{}); ok {
var assignees []string
for _, a := range assigneesSlice {
if s, ok := a.(string); ok {
assignees = append(assignees, s)
}
}
opt.Assignees = assignees
}
}
if milestone, ok := req.GetArguments()["milestone"].(float64); ok {
opt.Milestone = int64(milestone)
}
if state, ok := req.GetArguments()["state"].(string); ok {
opt.State = ptr.To(gitea_sdk.StateType(state))
}
if allowMaintainerEdit, ok := req.GetArguments()["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = ptr.To(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, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/pr/%v err: %v", owner, repo, int64(index), err))
}
return to.TextResult(pr)
}

244
operation/pull/pull_test.go Normal file
View File

@@ -0,0 +1,244 @@
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 TestEditPullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
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([]byte(fmt.Sprintf(`{"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": float64(index),
"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 struct {
Result map[string]any `json:"Result"`
}
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if got := parsed.Result["title"].(string); got != "WIP: my feature" {
t.Fatalf("result title = %q, want %q", got, "WIP: my feature")
}
}
func TestGetPullRequestDiffFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 12
diffRaw = "diff --git a/file.txt b/file.txt\n+line\n"
)
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": float64(index),
"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])
}
var parsed struct {
Result map[string]any `json:"Result"`
}
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if got, ok := parsed.Result["diff"].(string); !ok || got != diffRaw {
t.Fatalf("diff = %q, want %q", got, diffRaw)
}
if got, ok := parsed.Result["binary"].(bool); !ok || got != true {
t.Fatalf("binary = %v, want true", got)
}
if got, ok := parsed.Result["index"].(float64); !ok || int64(got) != int64(index) {
t.Fatalf("index = %v, want %d", got, index)
}
if got, ok := parsed.Result["owner"].(string); !ok || got != owner {
t.Fatalf("owner = %q, want %q", got, owner)
}
if got, ok := parsed.Result["repo"].(string); !ok || got != repo {
t.Fatalf("repo = %q, want %q", got, repo)
}
}

View File

@@ -8,6 +8,7 @@ import (
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "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/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -134,19 +135,19 @@ func StartStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.StartIssueStopWatch(owner, repo, int64(index)) _, err = client.StartIssueStopWatch(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) 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, int64(index))) 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) { func StopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -159,19 +160,19 @@ func StopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.StopIssueStopWatch(owner, repo, int64(index)) _, err = client.StopIssueStopWatch(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) 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, int64(index))) 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) { func DeleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -184,19 +185,19 @@ func DeleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteIssueStopwatch(owner, repo, int64(index)) _, err = client.DeleteIssueStopwatch(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) 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, int64(index))) return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index))
} }
func GetMyStopwatchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetMyStopwatchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -227,9 +228,9 @@ func ListTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
page, ok := req.GetArguments()["page"].(float64) page, ok := req.GetArguments()["page"].(float64)
if !ok { if !ok {
@@ -244,17 +245,17 @@ func ListTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
times, _, err := client.ListIssueTrackedTimes(owner, repo, int64(index), gitea_sdk.ListTrackedTimesOptions{ times, _, err := client.ListIssueTrackedTimes(owner, repo, index, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: int(page),
PageSize: int(pageSize), PageSize: int(pageSize),
}, },
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, index, err))
} }
if len(times) == 0 { if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, int64(index))) return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index))
} }
return to.TextResult(times) return to.TextResult(times)
} }
@@ -269,9 +270,9 @@ func AddTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok { if !ok {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
timeSeconds, ok := req.GetArguments()["time"].(float64) timeSeconds, ok := req.GetArguments()["time"].(float64)
@@ -282,11 +283,11 @@ func AddTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
trackedTime, _, err := client.AddTime(owner, repo, int64(index), gitea_sdk.AddTimeOption{ trackedTime, _, err := client.AddTime(owner, repo, index, gitea_sdk.AddTimeOption{
Time: int64(timeSeconds), Time: int64(timeSeconds),
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err))
} }
return to.TextResult(trackedTime) return to.TextResult(trackedTime)
} }
@@ -302,9 +303,9 @@ func DeleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, ok := req.GetArguments()["id"].(float64)
if !ok { if !ok {
@@ -314,11 +315,11 @@ func DeleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteTime(owner, repo, int64(index), int64(id)) _, err = client.DeleteTime(owner, repo, index, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", int64(id), owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", int64(id), owner, repo, index, err))
} }
return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", int64(id), owner, repo, int64(index))) return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", int64(id), owner, repo, index))
} }
func ListRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

33
pkg/params/params.go Normal file
View File

@@ -0,0 +1,33 @@
package params
import (
"fmt"
"strconv"
)
// GetIndex extracts an index parameter from MCP tool arguments.
// It accepts both numeric (float64 from JSON) and string representations.
// This provides better UX for LLM callers that may naturally use strings
// for identifiers like issue/PR numbers.
func GetIndex(args map[string]interface{}, key string) (int64, error) {
val, exists := args[key]
if !exists {
return 0, fmt.Errorf("%s is required", key)
}
// Try float64 (JSON number type)
if f, ok := val.(float64); ok {
return int64(f), nil
}
// Try string and parse to integer
if s, ok := val.(string); ok {
i, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
}
return i, nil
}
return 0, fmt.Errorf("%s must be a number or numeric string", key)
}

104
pkg/params/params_test.go Normal file
View File

@@ -0,0 +1,104 @@
package params
import (
"strings"
"testing"
)
func TestGetIndex(t *testing.T) {
tests := []struct {
name string
args map[string]interface{}
key string
wantIndex int64
wantErr bool
errMsg string
}{
{
name: "valid float64",
args: map[string]interface{}{"index": float64(123)},
key: "index",
wantIndex: 123,
wantErr: false,
},
{
name: "valid string",
args: map[string]interface{}{"index": "456"},
key: "index",
wantIndex: 456,
wantErr: false,
},
{
name: "valid string with large number",
args: map[string]interface{}{"index": "999999"},
key: "index",
wantIndex: 999999,
wantErr: false,
},
{
name: "missing parameter",
args: map[string]interface{}{},
key: "index",
wantErr: true,
errMsg: "index is required",
},
{
name: "invalid string (not a number)",
args: map[string]interface{}{"index": "abc"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid string (decimal)",
args: map[string]interface{}{"index": "12.34"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid type (bool)",
args: map[string]interface{}{"index": true},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "invalid type (map)",
args: map[string]interface{}{"index": map[string]string{"foo": "bar"}},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "custom key name",
args: map[string]interface{}{"pr_index": "789"},
key: "pr_index",
wantIndex: 789,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIndex, err := GetIndex(tt.args, tt.key)
if tt.wantErr {
if err == nil {
t.Errorf("GetIndex() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("GetIndex() error = %v, want error containing %q", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("GetIndex() unexpected error = %v", err)
return
}
if gotIndex != tt.wantIndex {
t.Errorf("GetIndex() = %v, want %v", gotIndex, tt.wantIndex)
}
})
}
}