diff --git a/README.md b/README.md index 614404c0a..f54db6644 100644 --- a/README.md +++ b/README.md @@ -841,9 +841,11 @@ The following sets of tools are available: - **add_issue_comment** - Add comment to issue or pull request - **Required OAuth Scopes**: `repo` - - `body`: Comment content (string, required) - - `issue_number`: Issue number to comment on (number, required) + - `body`: Comment content. Required unless reaction is provided. (string, optional) + - `comment_id`: The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. (number, optional) + - `issue_number`: Issue or pull request number to comment on or react to. (number, required) - `owner`: Repository owner (string, required) + - `reaction`: Emoji reaction to add. Required unless body is provided. (string, optional) - `repo`: Repository name (string, required) - **get_label** - Get a specific label from a repository @@ -1098,10 +1100,11 @@ The following sets of tools are available: - **add_reply_to_pull_request_comment** - Add reply to pull request comment - **Required OAuth Scopes**: `repo` - - `body`: The text of the reply (string, required) - - `commentId`: The ID of the comment to reply to (number, required) + - `body`: The text of the reply. Required unless reaction is provided. (string, optional) + - `commentId`: The numeric ID of the pull request review comment to reply or react to. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) + - `pullNumber`: Pull request number. Required when body is provided. (number, optional) + - `reaction`: Emoji reaction to add. Required unless body is provided. (string, optional) - `repo`: Repository name (string, required) - **create_pull_request** - Open new pull request diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 6ebc2dec9..52dca26fc 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -98,6 +98,20 @@ runtime behavior (such as output formatting) won't appear here. ### `issues_granular` +- **add_issue_comment_reaction** - Add Reaction to Issue or Pull Request Comment + - **Required OAuth Scopes**: `repo` + - `comment_id`: The issue or pull request comment ID (number, required) + - `content`: The emoji reaction type (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **add_issue_reaction** - Add Reaction to Issue or Pull Request + - **Required OAuth Scopes**: `repo` + - `content`: The emoji reaction type (string, required) + - `issue_number`: The issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **add_sub_issue** - Add Sub-Issue - **Required OAuth Scopes**: `repo` - `issue_number`: The parent issue number (number, required) @@ -204,6 +218,13 @@ runtime behavior (such as output formatting) won't appear here. - `startSide`: The start side of a multi-line comment (optional) (string, optional) - `subjectType`: The subject type of the comment (string, required) +- **add_pull_request_review_comment_reaction** - Add Pull Request Review Comment Reaction + - **Required OAuth Scopes**: `repo` + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `content`: The emoji reaction type (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **create_pull_request_review** - Create Pull Request Review - **Required OAuth Scopes**: `repo` - `body`: The review body text (optional) (string, optional) diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index 5479a16a6..01ca0a0db 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -2,21 +2,40 @@ "annotations": { "title": "Add comment to issue or pull request" }, - "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", + "description": "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required.", "inputSchema": { "properties": { "body": { - "description": "Comment content", + "description": "Comment content. Required unless reaction is provided.", "type": "string" }, + "comment_id": { + "description": "The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself.", + "minimum": 1, + "type": "number" + }, "issue_number": { - "description": "Issue number to comment on", + "description": "Issue or pull request number to comment on or react to.", "type": "number" }, "owner": { "description": "Repository owner", "type": "string" }, + "reaction": { + "description": "Emoji reaction to add. Required unless body is provided.", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" @@ -25,8 +44,7 @@ "required": [ "owner", "repo", - "issue_number", - "body" + "issue_number" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap new file mode 100644 index 000000000..5e2953a46 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Add Reaction to Issue or Pull Request Comment" + }, + "description": "Add a reaction to an issue or pull request comment.", + "inputSchema": { + "properties": { + "comment_id": { + "description": "The issue or pull request comment ID", + "minimum": 1, + "type": "number" + }, + "content": { + "description": "The emoji reaction type", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "content" + ], + "type": "object" + }, + "name": "add_issue_comment_reaction" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_reaction.snap b/pkg/github/__toolsnaps__/add_issue_reaction.snap new file mode 100644 index 000000000..db1148dee --- /dev/null +++ b/pkg/github/__toolsnaps__/add_issue_reaction.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Add Reaction to Issue or Pull Request" + }, + "description": "Add a reaction to an issue or pull request.", + "inputSchema": { + "properties": { + "content": { + "description": "The emoji reaction type", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, + "issue_number": { + "description": "The issue number", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "content" + ], + "type": "object" + }, + "name": "add_issue_reaction" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap new file mode 100644 index 000000000..e5fed3b01 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Add Pull Request Review Comment Reaction" + }, + "description": "Add a reaction to a pull request review comment.", + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "content": { + "description": "The emoji reaction type", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "content" + ], + "type": "object" + }, + "name": "add_pull_request_review_comment_reaction" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap index e2187478e..1a4d35a3f 100644 --- a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -2,15 +2,15 @@ "annotations": { "title": "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.", + "description": "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required.", "inputSchema": { "properties": { "body": { - "description": "The text of the reply", + "description": "The text of the reply. Required unless reaction is provided.", "type": "string" }, "commentId": { - "description": "The ID of the comment to reply to", + "description": "The numeric ID of the pull request review comment to reply or react to. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", "type": "number" }, "owner": { @@ -18,9 +18,23 @@ "type": "string" }, "pullNumber": { - "description": "Pull request number", + "description": "Pull request number. Required when body is provided.", "type": "number" }, + "reaction": { + "description": "Emoji reaction to add. Required unless body is provided.", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" @@ -29,9 +43,7 @@ "required": [ "owner", "repo", - "pullNumber", - "commentId", - "body" + "commentId" ], "type": "object" }, diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 4a274ac31..e302435ce 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "net/http" "strings" "testing" @@ -43,6 +44,8 @@ func TestGranularToolSnaps(t *testing.T) { GranularRemoveSubIssue, GranularReprioritizeSubIssue, GranularSetIssueFields, + GranularAddIssueReaction, + GranularAddIssueCommentReaction, GranularUpdatePullRequestTitle, GranularUpdatePullRequestBody, GranularUpdatePullRequestState, @@ -54,6 +57,7 @@ func TestGranularToolSnaps(t *testing.T) { GranularAddPullRequestReviewComment, GranularResolveReviewThread, GranularUnresolveReviewThread, + GranularAddPullRequestReviewCommentReaction, } for _, constructor := range toolConstructors { @@ -86,6 +90,8 @@ func TestIssuesGranularToolset(t *testing.T) { "remove_sub_issue", "reprioritize_sub_issue", "set_issue_fields", + "add_issue_reaction", + "add_issue_comment_reaction", } for _, name := range expected { assert.Contains(t, toolNames, name) @@ -121,6 +127,7 @@ func TestPullRequestsGranularToolset(t *testing.T) { "add_pull_request_review_comment", "resolve_review_thread", "unresolve_review_thread", + "add_pull_request_review_comment_reaction", } for _, name := range expected { assert.Contains(t, toolNames, name) @@ -2025,3 +2032,195 @@ func TestGranularSetIssueFields(t *testing.T) { assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) }) } + +// --- Reaction granular tool handler tests --- + +func TestGranularAddIssueReaction(t *testing.T) { + mockReaction := &gogithub.Reaction{ + ID: gogithub.Ptr(int64(12345)), + Content: gogithub.Ptr("+1"), + } + + tests := []struct { + name string + mockedClient *http.Client + args map[string]any + expectErr bool + }{ + { + name: "add reaction to issue successfully", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesReactionsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockReaction), + }), + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "content": "+1", + }, + expectErr: false, + }, + { + name: "missing owner returns error", + mockedClient: MockHTTPClientWithHandlers(nil), + args: map[string]any{ + "repo": "repo", + "issue_number": float64(42), + "content": "+1", + }, + expectErr: true, + }, + { + name: "missing content returns error", + mockedClient: MockHTTPClientWithHandlers(nil), + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := GranularAddIssueReaction(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if tc.expectErr { + assert.True(t, result.IsError) + } else { + assert.False(t, result.IsError) + textContent := getTextResult(t, result) + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + assert.Equal(t, "12345", response.ID) + assert.Equal(t, "https://api.github.com/repos/owner/repo/issues/42/reactions/12345", response.URL) + } + }) + } +} + +func TestGranularAddIssueCommentReaction(t *testing.T) { + mockReaction := &gogithub.Reaction{ + ID: gogithub.Ptr(int64(67890)), + Content: gogithub.Ptr("heart"), + } + + tests := []struct { + name string + mockedClient *http.Client + args map[string]any + expectErr bool + }{ + { + name: "add reaction to issue comment successfully", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction), + }), + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(999), + "content": "heart", + }, + expectErr: false, + }, + { + name: "missing comment_id returns error", + mockedClient: MockHTTPClientWithHandlers(nil), + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "content": "heart", + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := GranularAddIssueCommentReaction(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if tc.expectErr { + assert.True(t, result.IsError) + } else { + assert.False(t, result.IsError) + textContent := getTextResult(t, result) + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + assert.Equal(t, "67890", response.ID) + assert.Equal(t, "https://api.github.com/repos/owner/repo/issues/comments/999/reactions/67890", response.URL) + } + }) + } +} + +func TestGranularAddPullRequestReviewCommentReaction(t *testing.T) { + mockReaction := &gogithub.Reaction{ + ID: gogithub.Ptr(int64(54321)), + Content: gogithub.Ptr("rocket"), + } + + tests := []struct { + name string + mockedClient *http.Client + args map[string]any + expectErr bool + }{ + { + name: "add reaction to PR review comment successfully", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction), + }), + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(888), + "content": "rocket", + }, + expectErr: false, + }, + { + name: "missing repo returns error", + mockedClient: MockHTTPClientWithHandlers(nil), + args: map[string]any{ + "owner": "owner", + "comment_id": float64(888), + "content": "rocket", + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := GranularAddPullRequestReviewCommentReaction(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if tc.expectErr { + assert.True(t, result.IsError) + } else { + assert.False(t, result.IsError) + textContent := getTextResult(t, result) + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + assert.Equal(t, "54321", response.ID) + assert.Equal(t, "https://api.github.com/repos/owner/repo/pulls/comments/888/reactions/54321", response.URL) + } + }) + } +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 2ad173679..1d50ef0de 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -61,11 +61,13 @@ const ( GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments" PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues" PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" + PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions" PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}" GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority" + PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions" // Pull request endpoints GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls" @@ -79,6 +81,7 @@ const ( PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch" PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers" PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments" + PostReposPullsCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions" // Notifications endpoints GetNotifications = "GET /notifications" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 5479f3579..13bbffb51 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1101,13 +1101,13 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { }) } -// AddIssueComment creates a tool to add a comment to an issue. +// AddIssueComment creates a tool to add a comment or reaction to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", - Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."), + Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue or pull request"), ReadOnlyHint: false, @@ -1125,14 +1125,24 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool }, "issue_number": { Type: "number", - Description: "Issue number to comment on", + Description: "Issue or pull request number to comment on or react to.", + }, + "comment_id": { + Type: "number", + Description: "The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself.", + Minimum: jsonschema.Ptr(1.0), }, "body": { Type: "string", - Description: "Comment content", + Description: "Comment content. Required unless reaction is provided.", + }, + "reaction": { + Type: "string", + Description: "Emoji reaction to add. Required unless body is provided.", + Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, }, }, - Required: []string{"owner", "repo", "issue_number", "body"}, + Required: []string{"owner", "repo", "issue_number"}, }, }, []scopes.Scope{scopes.Repo}, @@ -1149,39 +1159,107 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - body, err := RequiredParam[string](args, "body") + var commentID int64 + hasCommentID := false + if _, ok := args["comment_id"]; ok { + commentID, err = RequiredBigInt(args, "comment_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + hasCommentID = true + } + body, hasBody, err := OptionalParamOK[string](args, "body") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - - comment := &github.IssueComment{ - Body: github.Ptr(body), + reactionContent, hasReaction, err := OptionalParamOK[string](args, "reaction") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if !hasBody && !hasReaction { + return utils.NewToolResultError("at least one of body or reaction is required"), nil, nil + } + if hasCommentID && !hasReaction { + return utils.NewToolResultError("comment_id can only be provided when reaction is provided"), nil, nil + } + if hasBody && body == "" { + return utils.NewToolResultError("body cannot be empty when provided"), nil, nil + } + if hasReaction && reactionContent == "" { + return utils.NewToolResultError("reaction cannot be empty when provided"), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to create comment", err), nil, nil + + var reactionResponse *MinimalResponse + if hasReaction { + if hasCommentID { + reaction, resp, err := client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, reactionContent) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + reactionResponse = &MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), + } + } else { + reaction, resp, err := client.Reactions.CreateIssueReaction(ctx, owner, repo, issueNumber, reactionContent) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + reactionResponse = &MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()), + } + } } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) + var commentResponse *MinimalResponse + if hasBody { + comment := &github.IssueComment{ + Body: github.Ptr(body), + } + createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) if err != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to create comment", err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create comment", resp, bodyBytes), nil, nil + } + + commentResponse = &MinimalResponse{ + ID: fmt.Sprintf("%d", createdComment.GetID()), + URL: createdComment.GetHTMLURL(), } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create comment", resp, body), nil, nil } - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", createdComment.GetID()), - URL: createdComment.GetHTMLURL(), + var result any + switch { + case hasBody && hasReaction: + result = map[string]MinimalResponse{ + "comment": *commentResponse, + "reaction": *reactionResponse, + } + case hasReaction: + result = reactionResponse + default: + result = commentResponse } - r, err := json.Marshal(minimalResponse) + r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 157d5595f..e965ce945 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1198,3 +1198,167 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv st.FeatureFlagEnable = FeatureFlagIssuesGranular return st } + +// GranularAddIssueReaction adds a reaction to an issue or pull request. +func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "add_issue_reaction", + Description: t("TOOL_ADD_ISSUE_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_ISSUE_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number", + Minimum: jsonschema.Ptr(1.0), + }, + "content": { + Type: "string", + Description: "The emoji reaction type", + Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, + }, + }, + Required: []string{"owner", "repo", "issue_number", "content"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + content, err := RequiredParam[string](args, "content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + reaction, resp, err := client.Reactions.CreateIssueReaction(ctx, owner, repo, issueNumber, content) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularAddIssueCommentReaction adds a reaction to an issue or pull request comment. +func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "add_issue_comment_reaction", + Description: t("TOOL_ADD_ISSUE_COMMENT_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_ISSUE_COMMENT_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request Comment"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "comment_id": { + Type: "number", + Description: "The issue or pull request comment ID", + Minimum: jsonschema.Ptr(1.0), + }, + "content": { + Type: "string", + Description: "The emoji reaction type", + Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, + }, + }, + Required: []string{"owner", "repo", "comment_id", "content"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + commentID, err := RequiredBigInt(args, "comment_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + content, err := RequiredParam[string](args, "content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + reaction, resp, err := client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, content) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2dea639f8..d4311410a 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "strings" + "sync/atomic" "testing" "time" @@ -571,8 +572,10 @@ func Test_AddIssueComment(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "comment_id") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number", "body"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "reaction") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) // Setup mock comment for success case mockComment := &github.IssueComment{ @@ -618,10 +621,10 @@ func Test_AddIssueComment(t *testing.T) { "owner": "owner", "repo": "repo", "issue_number": float64(42), - "body": "", + "body": "This is a test comment", }, expectError: false, - expectedErrMsg: "missing required parameter: body", + expectedErrMsg: "failed to create comment", }, } @@ -4287,6 +4290,198 @@ func Test_GetSubIssues(t *testing.T) { assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) } } + + } + }) + } +} + +func TestAddIssueComment(t *testing.T) { + t.Parallel() + + serverTool := AddIssueComment(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_issue_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "issue_number") + assert.Contains(t, schema.Properties, "comment_id") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "reaction") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) + + mockComment := &github.IssueComment{ + ID: github.Ptr(int64(456)), + Body: github.Ptr("This is a comment"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"), + } + mockReaction := &github.Reaction{ + ID: github.Ptr(int64(789)), + Content: github.Ptr("heart"), + } + commentCreatedAfterReactionFailure := &atomic.Bool{} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + unexpectedCall *atomic.Bool + }{ + { + name: "successful comment on issue", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "body": "This is a comment", + }, + }, + { + name: "successful reaction to issue", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesReactionsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockReaction), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "reaction": "heart", + }, + }, + { + name: "successful reaction to issue comment", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "comment_id": float64(999), + "reaction": "heart", + }, + }, + { + name: "successful comment and reaction to issue", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + PostReposIssuesReactionsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockReaction), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "body": "This is a comment", + "reaction": "heart", + }, + }, + { + name: "missing body and reaction", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectToolError: true, + expectedToolErrMsg: "at least one of body or reaction is required", + }, + { + name: "missing issue_number for reaction", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "reaction": "heart", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: issue_number", + }, + { + name: "missing issue_number for body", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "body": "This is a comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: issue_number", + }, + { + name: "comment_id without reaction", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "comment_id": float64(999), + "body": "This is a comment", + }, + expectToolError: true, + expectedToolErrMsg: "comment_id can only be provided when reaction is provided", + }, + { + name: "does not create comment when reaction fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesReactionsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "server error"}`)) + }, + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) { + commentCreatedAfterReactionFailure.Store(true) + w.WriteHeader(http.StatusCreated) + responseData, _ := json.Marshal(mockComment) + _, _ = w.Write(responseData) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "body": "This is a comment", + "reaction": "heart", + }, + expectToolError: true, + expectedToolErrMsg: "failed to add reaction to issue", + unexpectedCall: commentCreatedAfterReactionFailure, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectToolError { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg) + if tc.unexpectedCall != nil { + assert.False(t, tc.unexpectedCall.Load()) + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + if _, ok := tc.requestArgs["body"]; ok { + assert.Contains(t, textContent.Text, "456") + } + if _, ok := tc.requestArgs["reaction"]; ok { + assert.Contains(t, textContent.Text, "789") } }) } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index ef3e9c083..19de84e75 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1196,7 +1196,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return st } -// AddReplyToPullRequestComment creates a tool to add a reply to an existing pull request comment. +// AddReplyToPullRequestComment creates a tool to add a reply or reaction to an existing pull request comment. func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1211,25 +1211,30 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor }, "pullNumber": { Type: "number", - Description: "Pull request number", + Description: "Pull request number. Required when body is provided.", }, "commentId": { Type: "number", - Description: "The ID of the comment to reply to", + Description: "The numeric ID of the pull request review comment to reply or react to. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", }, "body": { Type: "string", - Description: "The text of the reply", + Description: "The text of the reply. Required unless reaction is provided.", + }, + "reaction": { + Type: "string", + Description: "Emoji reaction to add. Required unless body is provided.", + Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, }, }, - Required: []string{"owner", "repo", "pullNumber", "commentId", "body"}, + Required: []string{"owner", "repo", "commentId"}, } return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_reply_to_pull_request_comment", - Description: 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."), + Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"), ReadOnlyHint: false, @@ -1246,39 +1251,86 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - pullNumber, err := RequiredInt(args, "pullNumber") + commentID, err := RequiredBigInt(args, "commentId") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - commentID, err := RequiredInt(args, "commentId") + body, hasBody, err := OptionalParamOK[string](args, "body") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - body, err := RequiredParam[string](args, "body") + reactionContent, hasReaction, err := OptionalParamOK[string](args, "reaction") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + if !hasBody && !hasReaction { + return utils.NewToolResultError("at least one of body or reaction is required"), nil, nil + } + if hasBody && body == "" { + return utils.NewToolResultError("body cannot be empty when provided"), nil, nil + } + if hasReaction && reactionContent == "" { + return utils.NewToolResultError("reaction cannot be empty when provided"), nil, nil + } + var pullNumber int + if hasBody { + pullNumber, err = RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - comment, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reply to pull request comment", resp, err), nil, nil + var reactionResponse *MinimalResponse + if hasReaction { + reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, reactionContent) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to pull request review comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + reactionResponse = &MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/pulls/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), + } } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - bodyBytes, err := io.ReadAll(resp.Body) + var comment *github.PullRequestComment + if hasBody { + var resp *github.Response + comment, resp, err = client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, commentID) if err != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reply to pull request comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add reply to pull request comment", resp, bodyBytes), nil, nil + } + } + + var result any + switch { + case hasBody && hasReaction: + result = map[string]any{ + "comment": comment, + "reaction": reactionResponse, } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add reply to pull request comment", resp, bodyBytes), nil, nil + case hasReaction: + result = reactionResponse + default: + result = comment } - r, err := json.Marshal(comment) + r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 6bc2b99f3..d83d64853 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -757,3 +757,85 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento st.FeatureFlagEnable = FeatureFlagPullRequestsGranular return st } + +// GranularAddPullRequestReviewCommentReaction adds a reaction to a pull request review comment. +func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "add_pull_request_review_comment_reaction", + Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Add Pull Request Review Comment Reaction"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "comment_id": { + Type: "number", + Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + Minimum: jsonschema.Ptr(1.0), + }, + "content": { + Type: "string", + Description: "The emoji reaction type", + Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, + }, + }, + Required: []string{"owner", "repo", "comment_id", "content"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + commentID, err := RequiredBigInt(args, "comment_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + content, err := RequiredParam[string](args, "content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, content) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to pull request review comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", reaction.GetID()), + URL: fmt.Sprintf("%srepos/%s/%s/pulls/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 0f372519e..a96c2c0ac 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "sync/atomic" "testing" "time" @@ -3991,7 +3992,8 @@ func TestAddReplyToPullRequestComment(t *testing.T) { assert.Contains(t, schema.Properties, "pullNumber") assert.Contains(t, schema.Properties, "commentId") assert.Contains(t, schema.Properties, "body") - assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber", "commentId", "body"}) + assert.Contains(t, schema.Properties, "reaction") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "commentId"}) // Setup mock reply comment for success case mockReplyComment := &github.PullRequestComment{ @@ -4005,6 +4007,11 @@ func TestAddReplyToPullRequestComment(t *testing.T) { CreatedAt: &github.Timestamp{Time: time.Now()}, UpdatedAt: &github.Timestamp{Time: time.Now()}, } + mockReaction := &github.Reaction{ + ID: github.Ptr(int64(789)), + Content: github.Ptr("rocket"), + } + replyCreatedAfterReactionFailure := &atomic.Bool{} tests := []struct { name string @@ -4012,6 +4019,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { requestArgs map[string]any expectToolError bool expectedToolErrMsg string + unexpectedCall *atomic.Bool }{ { name: "successful reply to pull request comment", @@ -4053,7 +4061,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { expectedToolErrMsg: "missing required parameter: repo", }, { - name: "missing required parameter pullNumber", + name: "missing required parameter pullNumber when replying", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -4063,6 +4071,18 @@ func TestAddReplyToPullRequestComment(t *testing.T) { expectToolError: true, expectedToolErrMsg: "missing required parameter: pullNumber", }, + { + name: "missing required parameter pullNumber when replying with reaction", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "body": "This is a reply to the comment", + "reaction": "rocket", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: pullNumber", + }, { name: "missing required parameter commentId", requestArgs: map[string]any{ @@ -4075,7 +4095,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { expectedToolErrMsg: "missing required parameter: commentId", }, { - name: "missing required parameter body", + name: "missing body and reaction", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -4083,7 +4103,38 @@ func TestAddReplyToPullRequestComment(t *testing.T) { "commentId": float64(123), }, expectToolError: true, - expectedToolErrMsg: "missing required parameter: body", + expectedToolErrMsg: "at least one of body or reaction is required", + }, + { + name: "successful reaction to pull request comment", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "reaction": "rocket", + }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction), + }), + }, + { + name: "successful reply and reaction to pull request comment", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + "reaction": "rocket", + }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + responseData, _ := json.Marshal(mockReplyComment) + _, _ = w.Write(responseData) + }, + PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction), + }), }, { name: "API error when adding reply", @@ -4103,6 +4154,32 @@ func TestAddReplyToPullRequestComment(t *testing.T) { expectToolError: true, expectedToolErrMsg: "failed to add reply to pull request comment", }, + { + name: "does not create reply when reaction fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "server error"}`)) + }, + PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + replyCreatedAfterReactionFailure.Store(true) + w.WriteHeader(http.StatusCreated) + responseData, _ := json.Marshal(mockReplyComment) + _, _ = w.Write(responseData) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + "reaction": "rocket", + }, + expectToolError: true, + expectedToolErrMsg: "failed to add reaction to pull request review comment", + unexpectedCall: replyCreatedAfterReactionFailure, + }, } for _, tc := range tests { @@ -4128,13 +4205,21 @@ func TestAddReplyToPullRequestComment(t *testing.T) { require.True(t, result.IsError) errorContent := getErrorResult(t, result) assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg) + if tc.unexpectedCall != nil { + assert.False(t, tc.unexpectedCall.Load()) + } 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") + if _, ok := tc.requestArgs["body"]; ok { + assert.Contains(t, textContent.Text, "This is a reply to the comment") + } + if _, ok := tc.requestArgs["reaction"]; ok { + assert.Contains(t, textContent.Text, "789") + } }) } } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b937f8bfd..c2da38e08 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -314,6 +314,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GranularRemoveSubIssue(t), GranularReprioritizeSubIssue(t), GranularSetIssueFields(t), + GranularAddIssueReaction(t), + GranularAddIssueCommentReaction(t), // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write) GranularUpdatePullRequestTitle(t), @@ -327,6 +329,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GranularAddPullRequestReviewComment(t), GranularResolveReviewThread(t), GranularUnresolveReviewThread(t), + GranularAddPullRequestReviewCommentReaction(t), }) }