diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0fcc2502..41adfae3 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -150,6 +150,80 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc } } +// UpdateIssueComment creates a tool to update a comment on an issue. +func UpdateIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_issue_comment", + mcp.WithDescription(t("TOOL_UPDATE_ISSUE_COMMENT_DESCRIPTION", "Update a comment on an issue")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_ISSUE_COMMENT_USER_TITLE", "Update issue comment"), + ReadOnlyHint: false, + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("commentId", + mcp.Required(), + mcp.Description("Comment ID to update"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("The new text for the 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 + } + 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 + } + + 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) + } + updatedComment, resp, err := client.Issues.EditComment(ctx, owner, repo, int64(commentID), comment) + if err != nil { + return nil, fmt.Errorf("failed to update issue comment: %w", err) + } + 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 update issue comment: %s", string(body))), nil + } + + r, err := json.Marshal(updatedComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // SearchIssues creates a tool to search for issues and pull requests. func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_issues", diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 61ca0ae7..8acbb473 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1130,3 +1130,139 @@ func Test_GetIssueComments(t *testing.T) { }) } } + +func Test_UpdateIssueComment(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := UpdateIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "update_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, "commentId") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "commentId", "body"}) + + // Setup mock comment for success case + mockUpdatedComment := &github.IssueComment{ + ID: github.Ptr(int64(123)), + Body: github.Ptr("Updated issue comment text here"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1#issuecomment-123"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-1 * time.Hour)}, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComment *github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comment update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesCommentsByOwnerByRepoByCommentId, + expectRequestBody(t, map[string]interface{}{ + "body": "Updated issue comment text here", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedComment), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "body": "Updated issue comment text here", + }, + expectError: false, + expectedComment: mockUpdatedComment, + }, + { + name: "comment update fails - not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesCommentsByOwnerByRepoByCommentId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message": "Comment not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(999), + "body": "This should fail", + }, + expectError: true, + expectedErrMsg: "failed to update issue comment", + }, + { + name: "comment update fails - validation error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesCommentsByOwnerByRepoByCommentId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "body": "Invalid body", + }, + expectError: true, + expectedErrMsg: "failed to update issue comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := UpdateIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + require.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + + // For non-error cases, check the returned comment + 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) + if tc.expectedComment.HTMLURL != nil { + assert.Equal(t, *tc.expectedComment.HTMLURL, *returnedComment.HTMLURL) + } + if tc.expectedComment.User != nil && tc.expectedComment.User.Login != nil { + assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) + } + }) + } +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9c8fca17..8fe789cd 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1132,6 +1132,80 @@ func CreatePullRequestReview(getClient GetClientFn, t translations.TranslationHe } } +// UpdatePullRequestComment creates a tool to update a review comment on a pull request. +func UpdatePullRequestComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_pull_request_comment", + mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_COMMENT_DESCRIPTION", "Update a review comment on a pull request")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PULL_REQUEST_COMMENT_USER_TITLE", "Update pull request comment"), + ReadOnlyHint: false, + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("commentId", + mcp.Required(), + mcp.Description("Comment ID to update"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("The new text for the 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 + } + 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 + } + + comment := &github.PullRequestComment{ + Body: github.Ptr(body), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + updatedComment, resp, err := client.PullRequests.EditComment(ctx, owner, repo, int64(commentID), comment) + if err != nil { + return nil, fmt.Errorf("failed to update pull request comment: %w", err) + } + 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 update pull request comment: %s", string(body))), nil + } + + r, err := json.Marshal(updatedComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_pull_request", diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index bb372624..2a45b32a 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1916,3 +1916,139 @@ func Test_AddPullRequestReviewComment(t *testing.T) { }) } } + +func Test_UpdatePullRequestComment(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := UpdatePullRequestComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "update_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, "commentId") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "commentId", "body"}) + + // Setup mock comment for success case + mockUpdatedComment := &github.PullRequestComment{ + ID: github.Ptr(int64(456)), + Body: github.Ptr("Updated comment text here"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1#discussion_r456"), + Path: github.Ptr("file1.txt"), + UpdatedAt: &github.Timestamp{Time: time.Now()}, + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComment *github.PullRequestComment + expectedErrMsg string + }{ + { + name: "successful comment update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsCommentsByOwnerByRepoByCommentId, + expectRequestBody(t, map[string]interface{}{ + "body": "Updated comment text here", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedComment), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(456), + "body": "Updated comment text here", + }, + expectError: false, + expectedComment: mockUpdatedComment, + }, + { + name: "comment update fails - not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsCommentsByOwnerByRepoByCommentId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message": "Comment not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(999), + "body": "This should fail", + }, + expectError: true, + expectedErrMsg: "failed to update pull request comment", + }, + { + name: "comment update fails - validation error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsCommentsByOwnerByRepoByCommentId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "commentId": float64(456), + "body": "Invalid body", // Changed this to a non-empty string + }, + expectError: true, + expectedErrMsg: "failed to update pull request comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := UpdatePullRequestComment(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + require.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + + // For non-error cases, check the returned comment + var returnedComment github.PullRequestComment + 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) + if tc.expectedComment.Path != nil { + assert.Equal(t, *tc.expectedComment.Path, *returnedComment.Path) + } + if tc.expectedComment.HTMLURL != nil { + assert.Equal(t, *tc.expectedComment.HTMLURL, *returnedComment.HTMLURL) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d..0cdee210 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -46,6 +46,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(CreateIssue(getClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), toolsets.NewServerTool(UpdateIssue(getClient, t)), + toolsets.NewServerTool(UpdateIssueComment(getClient, t)), ) users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( @@ -67,6 +68,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(CreatePullRequest(getClient, t)), toolsets.NewServerTool(UpdatePullRequest(getClient, t)), toolsets.NewServerTool(AddPullRequestReviewComment(getClient, t)), + toolsets.NewServerTool(UpdatePullRequestComment(getClient, t)), ) codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning"). AddReadTools(