From 80114e32e61c4471081981da3f3f80a9034ed7e2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 28 Jul 2026 05:26:17 +0000 Subject: [PATCH] feat(pull): reply to and resolve review comments (#220) Adds `reply_comment`, `resolve_thread` and `unresolve_thread` to `pull_request_review_write`, using the endpoints from https://github.com/go-gitea/gitea/pull/36683 and https://github.com/go-gitea/gitea/pull/36441 (SDK v1.2.0, no dependency change). `review_id` is now optional for `pull_request_read` `get_review_comments`, so finding a comment to reply to takes one call instead of one per review. Review comments gained `review_id` and `resolved_by`. Fixes https://gitea.com/gitea/gitea-mcp/issues/129 Verified against gitea.com (1.27.0+dev). Written by Claude (Opus 5). Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/220 Reviewed-by: Lunny Xiao Co-authored-by: silverwind --- README.md | 4 +- README.zh-cn.md | 4 +- README.zh-tw.md | 4 +- operation/pull/pull.go | 144 ++++++++++++++++++++++++++++++++---- operation/pull/pull_test.go | 144 ++++++++++++++++++++++++++++++++++++ operation/pull/slim.go | 8 +- 6 files changed, 285 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 35afed3..471b731 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,9 @@ Once configured, try `list all my repositories` in the chat box. | issue_read | issue | Read | Read issue: details, comments, or labels | | issue_write | issue | Write | Write issues: create, update, manage comments and labels | | list_pull_requests | pull_request | Read | List repository pull requests | -| pull_request_read | pull_request | Read | Read pull request: details, diff, changed files, head commit status, reviews | +| 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 | -| pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss | +| pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments | | actions_config_read | actions | Read | Read Actions secrets and variables | | actions_config_write | actions | Write | Write Actions secrets and variables: upsert, create, update, delete | | actions_run_read | actions | Read | Read Actions workflows, runs, jobs, logs, and artifacts | diff --git a/README.zh-cn.md b/README.zh-cn.md index bd98548..5e64bfb 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -155,9 +155,9 @@ Cursor 等客户端可使用 stdio 命令: | issue_read | issue | 读取 | 读取问题:详情、评论或标签 | | issue_write | issue | 写入 | 写入问题:创建、更新、管理评论和标签 | | list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 | -| pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查 | +| pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 | | pull_request_write | pull_request | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 | -| pull_request_review_write | pull_request | 写入 | 写入 PR 审查:创建、提交、删除、驳回 | +| pull_request_review_write | pull_request | 写入 | 写入 PR 审查:创建、提交、删除、驳回、回复和解决审查评论 | | actions_config_read | actions | 读取 | 读取 Actions 密钥和变量 | | actions_config_write | actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 | | actions_run_read | actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 | diff --git a/README.zh-tw.md b/README.zh-tw.md index 1808c02..d9d8e98 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -155,9 +155,9 @@ Cursor 等客戶端可使用 stdio 命令: | issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | | issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | | list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 | -| pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查 | +| pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 | | pull_request_write | pull_request | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 | -| pull_request_review_write | pull_request | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回 | +| pull_request_review_write | pull_request | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回、回覆和解決審查評論 | | actions_config_read | actions | 讀取 | 讀取 Actions 密鑰和變數 | | actions_config_write | actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 | | actions_run_read | actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 | diff --git a/operation/pull/pull.go b/operation/pull/pull.go index 3983f6e..a354bef 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -44,13 +44,13 @@ var ( PullRequestReadTool = mcp.NewTool( PullRequestReadToolName, - mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."), + mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews, review comments."), mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")), mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithNumber("pull_number", mcp.Required()), - mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")), + mcp.WithNumber("review_id", mcp.Description("for 'get_review'; optional for 'get_review_comments', omit to list all")), mcp.WithBoolean("binary", mcp.Description("include binary diff")), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), @@ -89,15 +89,16 @@ var ( PullRequestReviewWriteTool = mcp.NewTool( PullRequestReviewWriteToolName, - mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."), - mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")), - mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")), + mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments."), + mcp.WithToolAnnotation(annotation.Write("Write pull request reviews")), + mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss", "reply_comment", "resolve_thread", "unresolve_thread")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), - mcp.WithNumber("pull_number", mcp.Required()), - mcp.WithNumber("review_id", mcp.Description("required except for 'create'")), + mcp.WithNumber("pull_number", mcp.Description("required except for 'resolve_thread'/'unresolve_thread'")), + mcp.WithNumber("review_id", mcp.Description("for 'submit'/'delete'/'dismiss'")), + mcp.WithNumber("comment_id", mcp.Description("comment ID from 'get_review_comments'; resolve takes the thread's first")), mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")), - mcp.WithString("body"), + mcp.WithString("body", mcp.Description("review body, or reply text for 'reply_comment'")), mcp.WithString("commit_id", mcp.Description("for 'create'")), mcp.WithString("message", mcp.Description("dismissal reason")), mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{ @@ -257,6 +258,12 @@ func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mc return deletePullRequestReviewFn(ctx, req) case "dismiss": return dismissPullRequestReviewFn(ctx, req) + case "reply_comment": + return replyPullRequestReviewCommentFn(ctx, req) + case "resolve_thread": + return resolveReviewThreadFn(ctx, req) + case "unresolve_thread": + return unresolveReviewThreadFn(ctx, req) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } @@ -567,19 +574,39 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques if err != nil { return to.ErrorResult(err) } - reviewID, err := params.GetIndex(args, "review_id") - if err != nil { - return to.ErrorResult(err) - } - client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } - comments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID) - if err != nil { - return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + // review comments hang off reviews, so without a review_id walk a page of + // reviews, keeping each thread and its replies together + var reviewIDs []int64 + if reviewID := params.GetOptionalInt(args, "review_id", 0); reviewID != 0 { + reviewIDs = append(reviewIDs, reviewID) + } else { + page, pageSize := params.GetPagination(args, 30) + reviews, _, err := client.PullRequests.ListPullReviews(ctx, owner, repo, index, gitea_sdk.ListPullReviewsOptions{ + ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err)) + } + reviewIDs = make([]int64, 0, len(reviews)) + for _, review := range reviews { + if review.CodeCommentsCount > 0 { + reviewIDs = append(reviewIDs, review.ID) + } + } + } + + var comments []*gitea_sdk.PullReviewComment + for _, reviewID := range reviewIDs { + reviewComments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID) + if err != nil { + return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) + } + comments = append(comments, reviewComments...) } return to.TextResult(slimReviewComments(comments)) @@ -775,6 +802,91 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (* return to.TextResult(successMsg) } +func replyPullRequestReviewCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "pull_number") + if err != nil { + return to.ErrorResult(err) + } + commentID, err := params.GetIndex(args, "comment_id") + if err != nil { + return to.ErrorResult(err) + } + body, err := params.GetString(args, "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)) + } + + comment, _, err := client.PullRequests.CreatePullReviewCommentReply(ctx, owner, repo, index, commentID, gitea_sdk.CreatePullReviewCommentReplyOptions{ + Body: body, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("reply to review comment %v on %v/%v/pr/%v err: %v", commentID, owner, repo, index, err)) + } + + return to.TextResult(slimReviewComment(comment)) +} + +func resolveReviewThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return setReviewThreadResolvedFn(ctx, req, true) +} + +func unresolveReviewThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return setReviewThreadResolvedFn(ctx, req, false) +} + +func setReviewThreadResolvedFn(ctx context.Context, req mcp.CallToolRequest, resolved bool) (*mcp.CallToolResult, error) { + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + commentID, err := params.GetIndex(args, "comment_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 resolved { + _, err = client.PullRequests.ResolvePullReviewComment(ctx, owner, repo, commentID) + } else { + _, err = client.PullRequests.UnresolvePullReviewComment(ctx, owner, repo, commentID) + } + if err != nil { + return to.ErrorResult(fmt.Errorf("set resolved=%v on review comment %v in %v/%v err: %v", resolved, commentID, owner, repo, err)) + } + + successMsg := map[string]any{ + "message": "Successfully updated review thread", + "comment_id": commentID, + "resolved": resolved, + "repository": fmt.Sprintf("%s/%s", owner, repo), + } + + return to.TextResult(successMsg) +} + func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() owner, err := params.GetString(args, "owner") diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go index 954ef1f..769fc82 100644 --- a/operation/pull/pull_test.go +++ b/operation/pull/pull_test.go @@ -1042,3 +1042,147 @@ func Test_reopenPullRequestFn(t *testing.T) { t.Fatalf("expected content in result") } } + +// serveStub points the client at a test server that answers the SDK version +// probe, leaving every other route to handler. +func serveStub(t *testing.T, handler http.HandlerFunc) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/version" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.27.0"}`)) + return + } + handler(w, r) + })) + t.Cleanup(server.Close) + + origHost, origToken := flag.Host, flag.Token + flag.Host, flag.Token = server.URL, "test-token" + t.Cleanup(func() { flag.Host, flag.Token = origHost, origToken }) +} + +func Test_pullRequestReviewWriteFn_comments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + commentID = 42 + ) + + for _, tc := range []struct { + method string + path string + wantBody string + }{ + {"reply_comment", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/comments/%d/replies", owner, repo, index, commentID), "sure"}, + {"resolve_thread", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d/resolve", owner, repo, commentID), ""}, + {"unresolve_thread", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d/unresolve", owner, repo, commentID), ""}, + } { + t.Run(tc.method, func(t *testing.T) { + var gotPath, gotBody string + + serveStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tc.path { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + gotPath = r.URL.Path + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody, _ = body["body"].(string) + if tc.wantBody == "" { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":43,"body":"sure","path":"main.go","position":3}`)) + }) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "method": tc.method, + "owner": owner, + "repo": repo, + "pull_number": float64(index), + "comment_id": float64(commentID), + "body": "sure", + }, + }, + } + + result, err := pullRequestReviewWriteFn(context.Background(), req) + if err != nil { + t.Fatalf("pullRequestReviewWriteFn() error = %v", err) + } + + if gotPath != tc.path { + t.Errorf("expected request to %s, got %q", tc.path, gotPath) + } + + // resolve and unresolve send no body, reply sends the reply text + if gotBody != tc.wantBody { + t.Errorf("expected body %q, got %q", tc.wantBody, gotBody) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + }) + } +} + +func Test_listPullRequestReviewCommentsFn_allReviews(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + var gotReviewPaths []string + + serveStub(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index): + // the middle review has no review comments and must not be fetched + _, _ = w.Write([]byte(`[{"id":1,"comments_count":1},{"id":2,"comments_count":0},{"id":3,"comments_count":2}]`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/1/comments", owner, repo, index), + fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/3/comments", owner, repo, index): + gotReviewPaths = append(gotReviewPaths, r.URL.Path) + _, _ = w.Write([]byte(`[{"id":11,"body":"nit","path":"main.go","position":3}]`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "method": "get_review_comments", + "owner": owner, + "repo": repo, + "pull_number": float64(index), + }, + }, + } + + result, err := pullRequestReadFn(context.Background(), req) + if err != nil { + t.Fatalf("pullRequestReadFn() error = %v", err) + } + + if len(gotReviewPaths) != 2 { + t.Errorf("expected comments of 2 reviews to be fetched, got %v", gotReviewPaths) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } +} diff --git a/operation/pull/slim.go b/operation/pull/slim.go index f74257a..775c279 100644 --- a/operation/pull/slim.go +++ b/operation/pull/slim.go @@ -137,8 +137,9 @@ func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any { if c == nil { return nil } - return map[string]any{ + m := map[string]any{ "id": c.ID, + "review_id": c.ReviewID, "body": c.Body, "path": c.Path, "position": c.LineNum, @@ -149,6 +150,11 @@ func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any { "created_at": c.Created, "updated_at": c.Updated, } + // the thread's first comment carries the resolver + if c.Resolver != nil { + m["resolved_by"] = slim.UserLogin(c.Resolver) + } + return m } func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {