From 7b044f11f75e5de80de4a448f362a4ace2de17a6 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Wed, 13 Aug 2025 13:26:02 -0500 Subject: [PATCH 1/5] feat: Add edit_issue_comment tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the edit_issue_comment tool to allow editing existing issue comments via the GitHub API. This addresses the feature request in issue #868. Changes: - Added EditIssueComment function in pkg/github/issues.go - Registered the new tool in pkg/github/tools.go - Added comprehensive tests for the new functionality - Updated README documentation with the new tool Fixes #868 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 6 + .../__toolsnaps__/edit_issue_comment.snap | 35 +++++ pkg/github/issues.go | 85 ++++++++++++ pkg/github/issues_test.go | 125 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 252 insertions(+) create mode 100644 pkg/github/__toolsnaps__/edit_issue_comment.snap diff --git a/README.md b/README.md index ce27bdb06..da37c254a 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,12 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **edit_issue_comment** - Edit issue comment + - `body`: New comment text content (string, required) + - `comment_id`: The ID of the comment to edit (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **add_sub_issue** - Add sub-issue - `issue_number`: The number of the parent issue (number, required) - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/edit_issue_comment.snap b/pkg/github/__toolsnaps__/edit_issue_comment.snap new file mode 100644 index 000000000..0f7ca1ea6 --- /dev/null +++ b/pkg/github/__toolsnaps__/edit_issue_comment.snap @@ -0,0 +1,35 @@ +{ + "annotations": { + "title": "Edit issue comment", + "readOnlyHint": false + }, + "description": "Edit an existing comment on a GitHub issue.", + "inputSchema": { + "properties": { + "body": { + "description": "New comment text content", + "type": "string" + }, + "comment_id": { + "description": "The ID of the comment to edit", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "body" + ], + "type": "object" + }, + "name": "edit_issue_comment" +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3a1440489..6d3ebc748 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -327,6 +327,91 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc } } +// EditIssueComment creates a tool to edit an existing issue comment. +func EditIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("edit_issue_comment", + mcp.WithDescription(t("TOOL_EDIT_ISSUE_COMMENT_DESCRIPTION", "Edit an existing comment on a GitHub issue.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_EDIT_ISSUE_COMMENT_USER_TITLE", "Edit issue comment"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("comment_id", + mcp.Required(), + mcp.Description("The ID of the comment to edit"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("New comment text content"), + ), + ), + 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 + } + commentIDInt, err := RequiredInt(request, "comment_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + commentID := int64(commentIDInt) + body, err := RequiredParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + comment := &github.IssueComment{ + Body: github.Ptr(body), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + editedComment, resp, err := client.Issues.EditComment(ctx, owner, repo, commentID, comment) + if err != nil { + if resp != nil { + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + 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 edit comment: %s", string(body))), nil + } + } + return mcp.NewToolResultError(fmt.Sprintf("failed to edit comment: %v", err)), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 edit comment: %s", string(body))), nil + } + + r, err := json.Marshal(editedComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // AddSubIssue creates a tool to add a sub-issue to a parent issue. func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_sub_issue", diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 249fadef8..2f9d5be55 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -236,6 +236,131 @@ func Test_AddIssueComment(t *testing.T) { } } +func Test_EditIssueComment(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := EditIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "edit_issue_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, "comment_id") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "comment_id", "body"}) + + // Setup mock comment for success case + mockEditedComment := &github.IssueComment{ + ID: github.Ptr(int64(123)), + Body: github.Ptr("This is the edited comment"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComment *github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comment edit", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesCommentsByOwnerByRepoByCommentId, + mockResponse(t, http.StatusOK, mockEditedComment), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(123), + "body": "This is the edited comment", + }, + expectError: false, + expectedComment: mockEditedComment, + }, + { + name: "comment edit fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesCommentsByOwnerByRepoByCommentId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Comment not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(999), + "body": "This is the edited comment", + }, + expectError: false, + expectedErrMsg: "failed to edit comment: {\"message\": \"Comment not found\"}", + }, + { + name: "missing body parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(123), + }, + expectError: false, + expectedErrMsg: "missing required parameter: body", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := EditIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse JSON from result + var returnedComment github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComment) + require.NoError(t, err) + assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) + assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) + assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) + + }) + } +} + func Test_SearchIssues(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3fb39ada7..f1b9fa3e8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -61,6 +61,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), + toolsets.NewServerTool(EditIssueComment(getClient, t)), toolsets.NewServerTool(UpdateIssue(getClient, t)), toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), toolsets.NewServerTool(AddSubIssue(getClient, t)), From 58ebf67e3a01e9442f8b1cf610288543f98e9e5d Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Wed, 13 Aug 2025 14:35:45 -0400 Subject: [PATCH 2/5] Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da37c254a..66e044d14 100644 --- a/README.md +++ b/README.md @@ -514,10 +514,10 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **edit_issue_comment** - Edit issue comment - - `body`: New comment text content (string, required) - - `comment_id`: The ID of the comment to edit (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `comment_id`: The ID of the comment to edit (number, required) + - `body`: New comment text content (string, required) - **add_sub_issue** - Add sub-issue - `issue_number`: The number of the parent issue (number, required) From 02e5f8956fb536ae0e056edc5b9454b10581be0c Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Wed, 13 Aug 2025 13:42:08 -0500 Subject: [PATCH 3/5] refactor: simplify error handling in EditIssueComment - Use ghErrors.NewGitHubAPIErrorResponse for consistent error handling - Remove redundant status code check after successful API call - Update test expectation to match new error format Addresses review comments from PR #2 --- pkg/github/issues.go | 20 +------------------- pkg/github/issues_test.go | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6d3ebc748..ccae57785 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -381,28 +381,10 @@ func EditIssueComment(getClient GetClientFn, t translations.TranslationHelperFun } editedComment, resp, err := client.Issues.EditComment(ctx, owner, repo, commentID, comment) if err != nil { - if resp != nil { - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - 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 edit comment: %s", string(body))), nil - } - } - return mcp.NewToolResultError(fmt.Sprintf("failed to edit comment: %v", err)), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to edit comment", resp, err), nil } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - 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 edit comment: %s", string(body))), nil - } - r, err := json.Marshal(editedComment) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2f9d5be55..5d42a41d4 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -303,7 +303,7 @@ func Test_EditIssueComment(t *testing.T) { "body": "This is the edited comment", }, expectError: false, - expectedErrMsg: "failed to edit comment: {\"message\": \"Comment not found\"}", + expectedErrMsg: "failed to edit comment: PATCH", }, { name: "missing body parameter", From 66851514938a5dff11bd6c93aeaee3e4e0b44e30 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Wed, 13 Aug 2025 13:43:47 -0500 Subject: [PATCH 4/5] `go run ./cmd/github-mcp-server generate-docs` Signed-off-by: Tiger Kaovilai --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 66e044d14..819e64a08 100644 --- a/README.md +++ b/README.md @@ -513,12 +513,6 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **edit_issue_comment** - Edit issue comment - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `comment_id`: The ID of the comment to edit (number, required) - - `body`: New comment text content (string, required) - - **add_sub_issue** - Add sub-issue - `issue_number`: The number of the parent issue (number, required) - `owner`: Repository owner (string, required) @@ -540,6 +534,12 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `title`: Issue title (string, required) +- **edit_issue_comment** - Edit issue comment + - `body`: New comment text content (string, required) + - `comment_id`: The ID of the comment to edit (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **get_issue** - Get issue details - `issue_number`: The number of the issue (number, required) - `owner`: The owner of the repository (string, required) From dcc978fcad2430def54cf522439106a1f8e79be1 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Wed, 13 Aug 2025 14:17:55 -0500 Subject: [PATCH 5/5] test: fix brittle assertion to check stable error prefix Change test expectation to verify the stable prefix 'failed to edit comment:' rather than checking for the HTTP method string. This makes the test less dependent on implementation details of the error wrapper. --- pkg/github/issues_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5d42a41d4..324ada4ad 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -303,7 +303,7 @@ func Test_EditIssueComment(t *testing.T) { "body": "This is the edited comment", }, expectError: false, - expectedErrMsg: "failed to edit comment: PATCH", + expectedErrMsg: "failed to edit comment:", }, { name: "missing body parameter",