From 4f7a191b678a25545ed9b1e21b5d307815267a7b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 15 Sep 2026 14:46:47 +0100 Subject: [PATCH 1/2] feat(issue): add block/unblock methods to issue tools Expose Gitea /issues/{i}/blocks endpoints: list_blocks on issue_read and block_issue/unblock_issue on issue_write, with pagination params on issue_read. Assisted-by: Devin:SWE-2 Max --- README.md | 4 +- README.zh-cn.md | 4 +- README.zh-tw.md | 4 +- operation/issue/blocking.go | 80 ++++++++++++++++++++++ operation/issue/blocking_test.go | 113 +++++++++++++++++++++++++++++++ operation/issue/issue.go | 17 +++-- 6 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 operation/issue/blocking.go create mode 100644 operation/issue/blocking_test.go diff --git a/README.md b/README.md index d31566f..23a0bd3 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,8 @@ Once configured, try `list all my repositories` in the chat box. | package_write | packages | Write | Delete a package version (irreversible) | | list_issues | issue | Read | List repository issues | | attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | -| issue_read | issue | Read | Read issue: details, comments, or labels | -| issue_write | issue | Write | Write issues: create, update, manage comments and labels | +| issue_read | issue | Read | Read issue: details, comments, labels, or blocking relationships | +| issue_write | issue | Write | Write issues: create, update, manage comments, labels, and blocking relationships | | list_pull_requests | pull_request | Read | List repository pull requests | | pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments | | pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers | diff --git a/README.zh-cn.md b/README.zh-cn.md index 3bf0e33..3084cc9 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -160,8 +160,8 @@ Cursor 等客户端可使用 stdio 命令: | package_write | packages | 写入 | 删除软件包版本(不可恢复) | | list_issues | issue | 读取 | 列出仓库问题 | | attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | -| issue_read | issue | 读取 | 读取问题:详情、评论或标签 | -| issue_write | issue | 写入 | 写入问题:创建、更新、管理评论和标签 | +| issue_read | issue | 读取 | 读取问题:详情、评论、标签或阻塞关系 | +| issue_write | issue | 写入 | 写入问题:创建、更新、管理评论、标签和阻塞关系 | | list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 | | pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 | | pull_request_write | pull_request | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 | diff --git a/README.zh-tw.md b/README.zh-tw.md index 6aa1868..3c55a48 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -160,8 +160,8 @@ Cursor 等客戶端可使用 stdio 命令: | package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) | | list_issues | issue | 讀取 | 列出倉庫問題 | | attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | -| issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | -| issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | +| issue_read | issue | 讀取 | 讀取問題:詳情、評論、標籤或阻塞關係 | +| issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論、標籤和阻塞關係 | | list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 | | pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 | | pull_request_write | pull_request | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 | diff --git a/operation/issue/blocking.go b/operation/issue/blocking.go new file mode 100644 index 0000000..d9f9318 --- /dev/null +++ b/operation/issue/blocking.go @@ -0,0 +1,80 @@ +package issue + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "gitea.dev/sdk" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func listIssueBlocksFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + owner, repo, index, err := issueRef(args) + 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)) + } + listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize} + issues, _, err := client.Issues.ListIssueBlocks(ctx, owner, repo, index, gitea_sdk.ListIssueBlocksOptions{ + ListOptions: listOpt, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/blocks err: %v", owner, repo, index, err)) + } + return to.TextResult(slimIssues(issues)) +} + +func blockIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + return setIssueBlockingFn(ctx, args, true) +} + +func unblockIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + return setIssueBlockingFn(ctx, args, false) +} + +func setIssueBlockingFn(ctx context.Context, args map[string]any, block bool) (*mcp.CallToolResult, error) { + owner, repo, index, err := issueRef(args) + if err != nil { + return to.ErrorResult(err) + } + blocked, err := params.GetIndex(args, "blocked_issue") + 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)) + } + meta := gitea_sdk.IssueMeta{Index: blocked} + var issue *gitea_sdk.Issue + if block { + issue, _, err = client.Issues.CreateIssueBlocking(ctx, owner, repo, index, meta) + } else { + issue, _, err = client.Issues.RemoveIssueBlocking(ctx, owner, repo, index, meta) + } + if err != nil { + return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/blocks err: %v", owner, repo, index, err)) + } + return to.TextResult(slimIssue(issue)) +} + +func issueRef(args map[string]any) (owner, repo string, index int64, err error) { + if owner, err = params.GetString(args, "owner"); err != nil { + return "", "", 0, err + } + if repo, err = params.GetString(args, "repo"); err != nil { + return "", "", 0, err + } + if index, err = params.GetIndex(args, "issue_number"); err != nil { + return "", "", 0, err + } + return owner, repo, index, nil +} diff --git a/operation/issue/blocking_test.go b/operation/issue/blocking_test.go new file mode 100644 index 0000000..a2b9c40 --- /dev/null +++ b/operation/issue/blocking_test.go @@ -0,0 +1,113 @@ +package issue + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func Test_issueBlocking(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + blocksPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/blocks", owner, repo) + + tests := []struct { + name string + fn func(context.Context, map[string]any) (*mcp.CallToolResult, error) + args map[string]any + wantMethod string + wantQuery string + wantIndex float64 + }{ + { + name: "list blocks paginates", + fn: listIssueBlocksFn, + args: map[string]any{"page": float64(2), "per_page": float64(10)}, + wantMethod: "GET", + wantQuery: "limit=10&page=2", + }, + { + name: "block issue posts index", + fn: blockIssueFn, + args: map[string]any{"blocked_issue": float64(7)}, + wantMethod: "POST", + wantIndex: 7, + }, + { + name: "unblock issue deletes with index", + fn: unblockIssueFn, + args: map[string]any{"blocked_issue": float64(7)}, + wantMethod: "DELETE", + wantIndex: 7, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + gotMethod string + gotQuery 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.27.2"}`)) + case blocksPath: + gotMethod = r.Method + gotQuery = r.URL.RawQuery + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"number":7,"title":"blocked","state":"open"}]`)) + } else { + _, _ = w.Write([]byte(`{"number":7,"title":"blocked","state":"open"}`)) + } + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + args := map[string]any{"owner": owner, "repo": repo, "issue_number": float64(42)} + maps.Copy(args, tt.args) + res, err := tt.fn(context.Background(), args) + if err != nil { + t.Fatalf("handler error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + if gotMethod != tt.wantMethod { + t.Fatalf("method = %s, want %s", gotMethod, tt.wantMethod) + } + if tt.wantQuery != "" && gotQuery != tt.wantQuery { + t.Fatalf("query = %s, want %s", gotQuery, tt.wantQuery) + } + if tt.wantIndex != 0 && gotBody["index"] != tt.wantIndex { + t.Fatalf("body index = %v, want %v", gotBody["index"], tt.wantIndex) + } + body := res.Content[0].(*mcp.TextContent).Text + if !strings.Contains(body, `"number":7`) && !strings.Contains(body, `"number": 7`) { + t.Fatalf("expected slim issue in output, got: %s", body) + } + }) + } +} diff --git a/operation/issue/issue.go b/operation/issue/issue.go index e26795b..18c7ee3 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -55,19 +55,21 @@ var ( IssueReadTool = tool.NewDefinition( IssueReadToolName, - "Read issue: details, comments, or labels.", + "Read issue: details, comments, labels, or blocking relationships.", annotation.ReadOnly("Read issue details"), - tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels")), + tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("issue_number", tool.Required()), + tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)), + tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)), ) IssueWriteTool = tool.NewDefinition( IssueWriteToolName, - "Write issues: create, update, manage comments and labels.", + "Write issues: create, update, manage comments, labels, and blocking relationships.", annotation.Write("Create or update issues, comments, and labels"), - tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), + tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("issue_number", tool.Description("required except for 'create'")), @@ -82,6 +84,7 @@ var ( tool.String("ref", tool.Description("branch to associate")), tool.String("deadline", tool.Description("ISO 8601")), tool.Boolean("remove_deadline"), + tool.Number("blocked_issue", tool.Description("issue number that issue_number blocks (required for 'block_issue'/'unblock_issue')")), ) ) @@ -112,6 +115,8 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, return getIssueCommentsByIndexFn(ctx, args) case "get_labels": return getIssueLabelsFn(ctx, args) + case "list_blocks": + return listIssueBlocksFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } @@ -139,6 +144,10 @@ func issueWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult return replaceIssueLabelsFn(ctx, args) case "clear_labels": return clearIssueLabelsFn(ctx, args) + case "block_issue": + return blockIssueFn(ctx, args) + case "unblock_issue": + return unblockIssueFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } -- 2.39.5 From 026f40bc15fa52e5ce198c302bfaac9d37730e20 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 15 Sep 2026 14:48:10 +0100 Subject: [PATCH 2/2] feat(issue): add dependency methods to issue tools Expose Gitea /issues/{i}/dependencies endpoints: list_dependencies on issue_read and add_dependency/remove_dependency on issue_write via the blocking_issue param. Assisted-by: Devin:SWE-2 Max --- operation/issue/blocking.go | 54 ++++++++++++++++++++++++++++++++ operation/issue/blocking_test.go | 42 ++++++++++++++++++++++--- operation/issue/issue.go | 11 +++++-- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/operation/issue/blocking.go b/operation/issue/blocking.go index d9f9318..62a1e14 100644 --- a/operation/issue/blocking.go +++ b/operation/issue/blocking.go @@ -66,6 +66,60 @@ func setIssueBlockingFn(ctx context.Context, args map[string]any, block bool) (* return to.TextResult(slimIssue(issue)) } +func listIssueDependenciesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + owner, repo, index, err := issueRef(args) + 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)) + } + listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize} + issues, _, err := client.Issues.ListIssueDependencies(ctx, owner, repo, index, gitea_sdk.ListIssueDependenciesOptions{ + ListOptions: listOpt, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err)) + } + return to.TextResult(slimIssues(issues)) +} + +func addIssueDependencyFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + return setIssueDependencyFn(ctx, args, true) +} + +func removeIssueDependencyFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + return setIssueDependencyFn(ctx, args, false) +} + +func setIssueDependencyFn(ctx context.Context, args map[string]any, add bool) (*mcp.CallToolResult, error) { + owner, repo, index, err := issueRef(args) + if err != nil { + return to.ErrorResult(err) + } + blocking, err := params.GetIndex(args, "blocking_issue") + 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)) + } + meta := gitea_sdk.IssueMeta{Index: blocking} + var issue *gitea_sdk.Issue + if add { + issue, _, err = client.Issues.CreateIssueDependency(ctx, owner, repo, index, meta) + } else { + issue, _, err = client.Issues.RemoveIssueDependency(ctx, owner, repo, index, meta) + } + if err != nil { + return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err)) + } + return to.TextResult(slimIssue(issue)) +} + func issueRef(args map[string]any) (owner, repo string, index int64, err error) { if owner, err = params.GetString(args, "owner"); err != nil { return "", "", 0, err diff --git a/operation/issue/blocking_test.go b/operation/issue/blocking_test.go index a2b9c40..cd1c4d2 100644 --- a/operation/issue/blocking_test.go +++ b/operation/issue/blocking_test.go @@ -21,11 +21,13 @@ func Test_issueBlocking(t *testing.T) { repo = "demo" ) blocksPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/blocks", owner, repo) + depsPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/dependencies", owner, repo) tests := []struct { name string fn func(context.Context, map[string]any) (*mcp.CallToolResult, error) args map[string]any + wantPath string wantMethod string wantQuery string wantIndex float64 @@ -34,29 +36,57 @@ func Test_issueBlocking(t *testing.T) { name: "list blocks paginates", fn: listIssueBlocksFn, args: map[string]any{"page": float64(2), "per_page": float64(10)}, - wantMethod: "GET", + wantPath: blocksPath, + wantMethod: http.MethodGet, wantQuery: "limit=10&page=2", }, { name: "block issue posts index", fn: blockIssueFn, args: map[string]any{"blocked_issue": float64(7)}, - wantMethod: "POST", + wantPath: blocksPath, + wantMethod: http.MethodPost, wantIndex: 7, }, { name: "unblock issue deletes with index", fn: unblockIssueFn, args: map[string]any{"blocked_issue": float64(7)}, - wantMethod: "DELETE", + wantPath: blocksPath, + wantMethod: http.MethodDelete, wantIndex: 7, }, + { + name: "list dependencies paginates", + fn: listIssueDependenciesFn, + args: map[string]any{"page": float64(3), "per_page": float64(5)}, + wantPath: depsPath, + wantMethod: http.MethodGet, + wantQuery: "limit=5&page=3", + }, + { + name: "add dependency posts index", + fn: addIssueDependencyFn, + args: map[string]any{"blocking_issue": float64(9)}, + wantPath: depsPath, + wantMethod: http.MethodPost, + wantIndex: 9, + }, + { + name: "remove dependency deletes with index", + fn: removeIssueDependencyFn, + args: map[string]any{"blocking_issue": float64(9)}, + wantPath: depsPath, + wantMethod: http.MethodDelete, + wantIndex: 9, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var ( gotMethod string + gotPath string gotQuery string gotBody map[string]any ) @@ -65,8 +95,9 @@ func Test_issueBlocking(t *testing.T) { case "/api/v1/version": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"version":"1.27.2"}`)) - case blocksPath: + case blocksPath, depsPath: gotMethod = r.Method + gotPath = r.URL.Path gotQuery = r.URL.RawQuery _ = json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") @@ -98,6 +129,9 @@ func Test_issueBlocking(t *testing.T) { if gotMethod != tt.wantMethod { t.Fatalf("method = %s, want %s", gotMethod, tt.wantMethod) } + if gotPath != tt.wantPath { + t.Fatalf("path = %s, want %s", gotPath, tt.wantPath) + } if tt.wantQuery != "" && gotQuery != tt.wantQuery { t.Fatalf("query = %s, want %s", gotQuery, tt.wantQuery) } diff --git a/operation/issue/issue.go b/operation/issue/issue.go index 18c7ee3..dc2a151 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -57,7 +57,7 @@ var ( IssueReadToolName, "Read issue: details, comments, labels, or blocking relationships.", annotation.ReadOnly("Read issue details"), - tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks")), + tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks", "list_dependencies")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("issue_number", tool.Required()), @@ -69,7 +69,7 @@ var ( IssueWriteToolName, "Write issues: create, update, manage comments, labels, and blocking relationships.", annotation.Write("Create or update issues, comments, and labels"), - tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue")), + tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue", "add_dependency", "remove_dependency")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("issue_number", tool.Description("required except for 'create'")), @@ -85,6 +85,7 @@ var ( tool.String("deadline", tool.Description("ISO 8601")), tool.Boolean("remove_deadline"), tool.Number("blocked_issue", tool.Description("issue number that issue_number blocks (required for 'block_issue'/'unblock_issue')")), + tool.Number("blocking_issue", tool.Description("issue number that blocks issue_number (required for 'add_dependency'/'remove_dependency')")), ) ) @@ -117,6 +118,8 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, return getIssueLabelsFn(ctx, args) case "list_blocks": return listIssueBlocksFn(ctx, args) + case "list_dependencies": + return listIssueDependenciesFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } @@ -148,6 +151,10 @@ func issueWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult return blockIssueFn(ctx, args) case "unblock_issue": return unblockIssueFn(ctx, args) + case "add_dependency": + return addIssueDependencyFn(ctx, args) + case "remove_dependency": + return removeIssueDependencyFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } -- 2.39.5