From 2ffb16ba1e2b9274caca6a4fae6224663cd69b46 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 23 Aug 2026 23:36:53 -0700 Subject: [PATCH] feat(pull): add get_comments method to pull_request_read List regular PR discussion comments via the issue comments endpoint, since Gitea pull requests are issues internally. Reuses the issue package's slim comment shape with attachment inlining. Co-Authored-By: Codet (GPT-5-Codex) --- operation/pull/pull.go | 43 ++++++++++++++++++- operation/pull/pull_test.go | 85 +++++++++++++++++++++++++++++++++++++ operation/pull/slim.go | 14 ++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/operation/pull/pull.go b/operation/pull/pull.go index 7fb36b8..9359781 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -20,6 +20,13 @@ import ( var Tool = tool.New("pull_request") +// commentWithAssets wraps the SDK Comment to capture the `assets` field that +// the SDK currently drops on the issue comments endpoint. +type commentWithAssets struct { + gitea_sdk.Comment + Assets []*gitea_sdk.Attachment `json:"assets"` +} + const ( ListRepoPullRequestsToolName = "list_pull_requests" PullRequestReadToolName = "pull_request_read" @@ -43,9 +50,9 @@ var ( PullRequestReadTool = tool.NewDefinition( PullRequestReadToolName, - "Read pull request: details, diff, changed files, head commit status, reviews, review comments.", + "Read pull request: details, diff, changed files, head commit status, reviews, review comments, discussion comments.", annotation.ReadOnly("Read pull request details"), - tool.String("method", tool.Required(), tool.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")), + tool.String("method", tool.Required(), tool.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments", "get_comments")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("pull_number", tool.Required()), @@ -151,6 +158,8 @@ func pullRequestReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolR return getPullRequestReviewFn(ctx, args) case "get_review_comments": return listPullRequestReviewCommentsFn(ctx, args) + case "get_comments": + return listPullRequestCommentsFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } @@ -603,6 +612,36 @@ func listPullRequestReviewCommentsFn(ctx context.Context, args map[string]any) ( return to.TextResult(slimReviewComments(comments)) } +func listPullRequestCommentsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + 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) + } + + // PRs are issues internally, so the regular discussion comments live on + // the issue comments endpoint rather than a pull-specific one. + var comments []commentWithAssets + path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v comments err: %v", owner, repo, index, err)) + } + out := make([]map[string]any, 0, len(comments)) + for i := range comments { + m := slimComment(&comments[i].Comment) + m["body"] = slim.BodyWithAttachments(comments[i].Body, comments[i].Assets) + out = append(out, m) + } + return to.TextResult(out) +} + func createPullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { owner, err := params.GetString(args, "owner") if err != nil { diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go index e3d3398..1ea1c3c 100644 --- a/operation/pull/pull_test.go +++ b/operation/pull/pull_test.go @@ -943,6 +943,91 @@ func Test_closePullRequestFn(t *testing.T) { } } +func Test_listPullRequestCommentsFn_missingArgs(t *testing.T) { + result, err := listPullRequestCommentsFn(context.Background(), map[string]any{ + "owner": "octo", + "repo": "demo", + }) + if err != nil { + t.Fatalf("listPullRequestCommentsFn() error = %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("listPullRequestCommentsFn() result = %#v, want an error result for missing pull_number", result) + } +} + +func Test_listPullRequestCommentsFn_apiError(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + serveStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + http.Error(w, "boom", http.StatusInternalServerError) + }) + + args := map[string]any{ + "owner": owner, "repo": repo, "pull_number": float64(index), + } + result, err := listPullRequestCommentsFn(context.Background(), args) + if err != nil { + t.Fatalf("listPullRequestCommentsFn() error = %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("listPullRequestCommentsFn() result = %#v, want an error result on API failure", result) + } +} + +func Test_listPullRequestCommentsFn_decodesComments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 7 + ) + + serveStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id": 1, "body": "see this", "assets": [ + {"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"} + ]}, + {"id": 2, "body": "no attachment", "assets": []} + ]`)) + }) + + args := map[string]any{ + "method": "get_comments", "owner": owner, "repo": repo, "pull_number": float64(index), + } + result, err := pullRequestReadFn(context.Background(), args) + if err != nil { + t.Fatalf("pullRequestReadFn() error = %v", err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result.Content) + } + body := result.Content[0].(*mcp.TextContent).Text + if !strings.Contains(body, `[log.txt](https://example/log.txt)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } + if !strings.Contains(body, `"no attachment"`) { + t.Fatalf("expected second comment body preserved, got: %s", body) + } + if strings.Contains(body, `"assets"`) { + t.Fatalf("assets should be inlined into body, not a separate field: %s", body) + } +} + func Test_reopenPullRequestFn(t *testing.T) { const ( owner = "octo" diff --git a/operation/pull/slim.go b/operation/pull/slim.go index 775c279..f2ce073 100644 --- a/operation/pull/slim.go +++ b/operation/pull/slim.go @@ -164,3 +164,17 @@ func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]an } return out } + +func slimComment(c *gitea_sdk.Comment) map[string]any { + if c == nil { + return nil + } + return map[string]any{ + "id": c.ID, + "body": c.Body, + "user": slim.UserLogin(c.Poster), + "html_url": c.HTMLURL, + "created_at": c.Created, + "updated_at": c.Updated, + } +}