diff --git a/README.md b/README.md index ce27bdb06..819e64a08 100644 --- a/README.md +++ b/README.md @@ -534,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) 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..ccae57785 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -327,6 +327,73 @@ 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 { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to edit comment", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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..324ada4ad 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:", + }, + { + 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)),