Skip to content

Add tools to update PR review comments and issue comments #314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,76 @@ 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.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",
Expand Down
136 changes: 136 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
70 changes: 70 additions & 0 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,76 @@ 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.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",
Expand Down
Loading