From a6059e14c8329c17381c8b395780b050e780a618 Mon Sep 17 00:00:00 2001 From: Piotr Laskowski Date: Mon, 21 Jul 2025 20:55:55 +0200 Subject: [PATCH] feat: add add_reply_to_pull_request_comment tool Add a new tool that allows AI agents to reply to existing pull request comments. This tool uses GitHub's CreateCommentInReplyTo REST API to create threaded conversations on pull requests. Features: - Reply to any existing PR comment using its ID - Proper error handling for missing parameters and API failures - Comprehensive test coverage (8 test cases) - Follows project patterns and conventions - Registered in pull_requests toolset as a write operation Parameters: - owner: Repository owner (required) - repo: Repository name (required) - pullNumber: Pull request number (required) - commentId: ID of comment to reply to (required) - body: Reply text content (required) This tool complements the existing add_comment_to_pending_review tool by enabling responses to already-posted comments, enhancing AI-powered code review workflows. Closes: #N/A --- .../add_reply_to_pull_request_comment.snap | 40 +++++ pkg/github/pullrequests.go | 79 +++++++++ pkg/github/pullrequests_test.go | 165 ++++++++++++++++++ pkg/github/tools.go | 1 + 4 files changed, 285 insertions(+) create mode 100644 pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap diff --git a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap new file mode 100644 index 000000000..4f0d86e3c --- /dev/null +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "title": "Add reply to pull request comment", + "readOnlyHint": false + }, + "description": "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment.", + "inputSchema": { + "properties": { + "body": { + "description": "The text of the reply comment", + "type": "string" + }, + "commentId": { + "description": "The ID of the comment to reply to", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "commentId", + "body" + ], + "type": "object" + }, + "name": "add_reply_to_pull_request_comment" +} \ No newline at end of file diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 47b7c6bd2..fef461e15 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -891,6 +891,85 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel } } +// AddReplyToPullRequestComment creates a tool to add a reply to an existing pull request comment. +func AddReplyToPullRequestComment(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("add_reply_to_pull_request_comment", + mcp.WithDescription(t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + mcp.WithNumber("commentId", + mcp.Required(), + mcp.Description("The ID of the comment to reply to"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("The text of the reply comment"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + commentID, err := RequiredInt(request, "commentId") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body, err := RequiredParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + comment, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID)) + if err != nil { + return nil, fmt.Errorf("failed to add reply to pull request comment: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to add reply to pull request comment: %s", string(body))), nil + } + + r, err := json.Marshal(comment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // GetPullRequestReviews creates a tool to get the reviews on a pull request. func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request_reviews", diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 42fd5bf03..f460b4001 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2590,3 +2590,168 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo ), ) } + +func TestAddReplyToPullRequestComment(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := AddReplyToPullRequestComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_reply_to_pull_request_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "commentId") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "commentId", "body"}) + + // Setup mock reply comment for success case + mockReplyComment := &github.PullRequestComment{ + ID: github.Ptr(int64(456)), + Body: github.Ptr("This is a reply to the comment"), + InReplyTo: github.Ptr(int64(123)), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"), + User: &github.User{ + Login: github.Ptr("responder"), + }, + CreatedAt: &github.Timestamp{Time: time.Now()}, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful reply to pull request comment", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsCommentsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + responseData, _ := json.Marshal(mockReplyComment) + _, _ = w.Write(responseData) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + }, + { + name: "missing required parameter owner", + requestArgs: map[string]interface{}{ + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + requestArgs: map[string]interface{}{ + "owner": "owner", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: repo", + }, + { + name: "missing required parameter pullNumber", + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: pullNumber", + }, + { + name: "missing required parameter commentId", + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: commentId", + }, + { + name: "missing required parameter body", + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: body", + }, + { + name: "API error when adding reply", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsCommentsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "failed to add reply to pull request comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := AddReplyToPullRequestComment(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + if tc.expectToolError { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and verify it's not an error + require.False(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "This is a reply to the comment") + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index bd349171d..47a178d8d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -85,6 +85,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(CreatePullRequest(getClient, t)), toolsets.NewServerTool(UpdatePullRequest(getClient, t)), toolsets.NewServerTool(RequestCopilotReview(getClient, t)), + toolsets.NewServerTool(AddReplyToPullRequestComment(getClient, t)), // Reviews toolsets.NewServerTool(CreateAndSubmitPullRequestReview(getGQLClient, t)), pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy