Skip to content

Commit 6c05b40

Browse files
ashwin-antclaudejuruen
authored
Add tools for one-off PR comments and replying to PR review comments (#143)
* Add add_pull_request_review_comment tool for PR review comments Adds the ability to add review comments to pull requests with support for line, multi-line, and file-level comments, as well as replying to existing comments. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add reply_to_pull_request_review_comment tool Adds a new tool to reply to existing pull request review comments using the GitHub API's comment reply endpoint. This allows for threaded discussions on pull request reviews. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update README with new PR review comment tools * rebase * use new getClient function inadd and reply pr review tools * Unify PR review comment tools into a single consolidated tool The separate AddPullRequestReviewComment and ReplyToPullRequestReviewComment tools have been merged into a single tool that handles both creating new comments and replying to existing ones. This approach simplifies the API and provides a more consistent interface for users. - Made commit_id and path optional when using in_reply_to for replies - Updated the tests to verify both comment and reply functionality - Removed the separate ReplyToPullRequestReviewComment tool - Fixed test expectations to match how errors are returned 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update README to reflect the unified PR review comment tool --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Javier Uruen Val <juruen@github.com>
1 parent 8343fa5 commit 6c05b40

File tree

4 files changed

+383
-0
lines changed

4 files changed

+383
-0
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,21 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
288288
- `draft`: Create as draft PR (boolean, optional)
289289
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
290290

291+
- **add_pull_request_review_comment** - Add a review comment to a pull request or reply to an existing comment
292+
293+
- `owner`: Repository owner (string, required)
294+
- `repo`: Repository name (string, required)
295+
- `pull_number`: Pull request number (number, required)
296+
- `body`: The text of the review comment (string, required)
297+
- `commit_id`: The SHA of the commit to comment on (string, required unless using in_reply_to)
298+
- `path`: The relative path to the file that necessitates a comment (string, required unless using in_reply_to)
299+
- `line`: The line of the blob in the pull request diff that the comment applies to (number, optional)
300+
- `side`: The side of the diff to comment on (LEFT or RIGHT) (string, optional)
301+
- `start_line`: For multi-line comments, the first line of the range (number, optional)
302+
- `start_side`: For multi-line comments, the starting side of the diff (LEFT or RIGHT) (string, optional)
303+
- `subject_type`: The level at which the comment is targeted (line or file) (string, optional)
304+
- `in_reply_to`: The ID of the review comment to reply to (number, optional). When specified, only body is required and other parameters are ignored.
305+
291306
- **update_pull_request** - Update an existing pull request in a GitHub repository
292307

293308
- `owner`: Repository owner (string, required)

pkg/github/pullrequests.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,176 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
644644
}
645645
}
646646

647+
// AddPullRequestReviewComment creates a tool to add a review comment to a pull request.
648+
func AddPullRequestReviewComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
649+
return mcp.NewTool("add_pull_request_review_comment",
650+
mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a review comment to a pull request")),
651+
mcp.WithString("owner",
652+
mcp.Required(),
653+
mcp.Description("Repository owner"),
654+
),
655+
mcp.WithString("repo",
656+
mcp.Required(),
657+
mcp.Description("Repository name"),
658+
),
659+
mcp.WithNumber("pull_number",
660+
mcp.Required(),
661+
mcp.Description("Pull request number"),
662+
),
663+
mcp.WithString("body",
664+
mcp.Required(),
665+
mcp.Description("The text of the review comment"),
666+
),
667+
mcp.WithString("commit_id",
668+
mcp.Description("The SHA of the commit to comment on. Required unless in_reply_to is specified."),
669+
),
670+
mcp.WithString("path",
671+
mcp.Description("The relative path to the file that necessitates a comment. Required unless in_reply_to is specified."),
672+
),
673+
mcp.WithString("subject_type",
674+
mcp.Description("The level at which the comment is targeted, 'line' or 'file'"),
675+
mcp.Enum("line", "file"),
676+
),
677+
mcp.WithNumber("line",
678+
mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"),
679+
),
680+
mcp.WithString("side",
681+
mcp.Description("The side of the diff to comment on. Can be LEFT or RIGHT"),
682+
mcp.Enum("LEFT", "RIGHT"),
683+
),
684+
mcp.WithNumber("start_line",
685+
mcp.Description("For multi-line comments, the first line of the range that the comment applies to"),
686+
),
687+
mcp.WithString("start_side",
688+
mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. Can be LEFT or RIGHT"),
689+
mcp.Enum("LEFT", "RIGHT"),
690+
),
691+
mcp.WithNumber("in_reply_to",
692+
mcp.Description("The ID of the review comment to reply to. When specified, only body is required and all other parameters are ignored"),
693+
),
694+
),
695+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
696+
owner, err := requiredParam[string](request, "owner")
697+
if err != nil {
698+
return mcp.NewToolResultError(err.Error()), nil
699+
}
700+
repo, err := requiredParam[string](request, "repo")
701+
if err != nil {
702+
return mcp.NewToolResultError(err.Error()), nil
703+
}
704+
pullNumber, err := RequiredInt(request, "pull_number")
705+
if err != nil {
706+
return mcp.NewToolResultError(err.Error()), nil
707+
}
708+
body, err := requiredParam[string](request, "body")
709+
if err != nil {
710+
return mcp.NewToolResultError(err.Error()), nil
711+
}
712+
713+
client, err := getClient(ctx)
714+
if err != nil {
715+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
716+
}
717+
718+
// Check if this is a reply to an existing comment
719+
if replyToFloat, ok := request.Params.Arguments["in_reply_to"].(float64); ok {
720+
// Use the specialized method for reply comments due to inconsistency in underlying go-github library: https://github.com/google/go-github/pull/950
721+
commentID := int64(replyToFloat)
722+
createdReply, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, commentID)
723+
if err != nil {
724+
return nil, fmt.Errorf("failed to reply to pull request comment: %w", err)
725+
}
726+
defer func() { _ = resp.Body.Close() }()
727+
728+
if resp.StatusCode != http.StatusCreated {
729+
respBody, err := io.ReadAll(resp.Body)
730+
if err != nil {
731+
return nil, fmt.Errorf("failed to read response body: %w", err)
732+
}
733+
return mcp.NewToolResultError(fmt.Sprintf("failed to reply to pull request comment: %s", string(respBody))), nil
734+
}
735+
736+
r, err := json.Marshal(createdReply)
737+
if err != nil {
738+
return nil, fmt.Errorf("failed to marshal response: %w", err)
739+
}
740+
741+
return mcp.NewToolResultText(string(r)), nil
742+
}
743+
744+
// This is a new comment, not a reply
745+
// Verify required parameters for a new comment
746+
commitID, err := requiredParam[string](request, "commit_id")
747+
if err != nil {
748+
return mcp.NewToolResultError(err.Error()), nil
749+
}
750+
path, err := requiredParam[string](request, "path")
751+
if err != nil {
752+
return mcp.NewToolResultError(err.Error()), nil
753+
}
754+
755+
comment := &github.PullRequestComment{
756+
Body: github.Ptr(body),
757+
CommitID: github.Ptr(commitID),
758+
Path: github.Ptr(path),
759+
}
760+
761+
subjectType, err := OptionalParam[string](request, "subject_type")
762+
if err != nil {
763+
return mcp.NewToolResultError(err.Error()), nil
764+
}
765+
if subjectType != "file" {
766+
line, lineExists := request.Params.Arguments["line"].(float64)
767+
startLine, startLineExists := request.Params.Arguments["start_line"].(float64)
768+
side, sideExists := request.Params.Arguments["side"].(string)
769+
startSide, startSideExists := request.Params.Arguments["start_side"].(string)
770+
771+
if !lineExists {
772+
return mcp.NewToolResultError("line parameter is required unless using subject_type:file"), nil
773+
}
774+
775+
comment.Line = github.Ptr(int(line))
776+
if sideExists {
777+
comment.Side = github.Ptr(side)
778+
}
779+
if startLineExists {
780+
comment.StartLine = github.Ptr(int(startLine))
781+
}
782+
if startSideExists {
783+
comment.StartSide = github.Ptr(startSide)
784+
}
785+
786+
if startLineExists && !lineExists {
787+
return mcp.NewToolResultError("if start_line is provided, line must also be provided"), nil
788+
}
789+
if startSideExists && !sideExists {
790+
return mcp.NewToolResultError("if start_side is provided, side must also be provided"), nil
791+
}
792+
}
793+
794+
createdComment, resp, err := client.PullRequests.CreateComment(ctx, owner, repo, pullNumber, comment)
795+
if err != nil {
796+
return nil, fmt.Errorf("failed to create pull request comment: %w", err)
797+
}
798+
defer func() { _ = resp.Body.Close() }()
799+
800+
if resp.StatusCode != http.StatusCreated {
801+
respBody, err := io.ReadAll(resp.Body)
802+
if err != nil {
803+
return nil, fmt.Errorf("failed to read response body: %w", err)
804+
}
805+
return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request comment: %s", string(respBody))), nil
806+
}
807+
808+
r, err := json.Marshal(createdComment)
809+
if err != nil {
810+
return nil, fmt.Errorf("failed to marshal response: %w", err)
811+
}
812+
813+
return mcp.NewToolResultText(string(r)), nil
814+
}
815+
}
816+
647817
// GetPullRequestReviews creates a tool to get the reviews on a pull request.
648818
func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
649819
return mcp.NewTool("get_pull_request_reviews",

pkg/github/pullrequests_test.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,3 +1719,200 @@ func Test_CreatePullRequest(t *testing.T) {
17191719
})
17201720
}
17211721
}
1722+
1723+
func Test_AddPullRequestReviewComment(t *testing.T) {
1724+
mockClient := github.NewClient(nil)
1725+
tool, _ := AddPullRequestReviewComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1726+
1727+
assert.Equal(t, "add_pull_request_review_comment", tool.Name)
1728+
assert.NotEmpty(t, tool.Description)
1729+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1730+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1731+
assert.Contains(t, tool.InputSchema.Properties, "pull_number")
1732+
assert.Contains(t, tool.InputSchema.Properties, "body")
1733+
assert.Contains(t, tool.InputSchema.Properties, "commit_id")
1734+
assert.Contains(t, tool.InputSchema.Properties, "path")
1735+
// Since we've updated commit_id and path to be optional when using in_reply_to
1736+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pull_number", "body"})
1737+
1738+
mockComment := &github.PullRequestComment{
1739+
ID: github.Ptr(int64(123)),
1740+
Body: github.Ptr("Great stuff!"),
1741+
Path: github.Ptr("file1.txt"),
1742+
Line: github.Ptr(2),
1743+
Side: github.Ptr("RIGHT"),
1744+
}
1745+
1746+
mockReply := &github.PullRequestComment{
1747+
ID: github.Ptr(int64(456)),
1748+
Body: github.Ptr("Good point, will fix!"),
1749+
}
1750+
1751+
tests := []struct {
1752+
name string
1753+
mockedClient *http.Client
1754+
requestArgs map[string]interface{}
1755+
expectError bool
1756+
expectedComment *github.PullRequestComment
1757+
expectedErrMsg string
1758+
}{
1759+
{
1760+
name: "successful line comment creation",
1761+
mockedClient: mock.NewMockedHTTPClient(
1762+
mock.WithRequestMatchHandler(
1763+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1764+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1765+
w.WriteHeader(http.StatusCreated)
1766+
err := json.NewEncoder(w).Encode(mockComment)
1767+
if err != nil {
1768+
http.Error(w, err.Error(), http.StatusInternalServerError)
1769+
return
1770+
}
1771+
}),
1772+
),
1773+
),
1774+
requestArgs: map[string]interface{}{
1775+
"owner": "owner",
1776+
"repo": "repo",
1777+
"pull_number": float64(1),
1778+
"body": "Great stuff!",
1779+
"commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
1780+
"path": "file1.txt",
1781+
"line": float64(2),
1782+
"side": "RIGHT",
1783+
},
1784+
expectError: false,
1785+
expectedComment: mockComment,
1786+
},
1787+
{
1788+
name: "successful reply using in_reply_to",
1789+
mockedClient: mock.NewMockedHTTPClient(
1790+
mock.WithRequestMatchHandler(
1791+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1792+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1793+
w.WriteHeader(http.StatusCreated)
1794+
err := json.NewEncoder(w).Encode(mockReply)
1795+
if err != nil {
1796+
http.Error(w, err.Error(), http.StatusInternalServerError)
1797+
return
1798+
}
1799+
}),
1800+
),
1801+
),
1802+
requestArgs: map[string]interface{}{
1803+
"owner": "owner",
1804+
"repo": "repo",
1805+
"pull_number": float64(1),
1806+
"body": "Good point, will fix!",
1807+
"in_reply_to": float64(123),
1808+
},
1809+
expectError: false,
1810+
expectedComment: mockReply,
1811+
},
1812+
{
1813+
name: "comment creation fails",
1814+
mockedClient: mock.NewMockedHTTPClient(
1815+
mock.WithRequestMatchHandler(
1816+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1817+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1818+
w.WriteHeader(http.StatusUnprocessableEntity)
1819+
w.Header().Set("Content-Type", "application/json")
1820+
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
1821+
}),
1822+
),
1823+
),
1824+
requestArgs: map[string]interface{}{
1825+
"owner": "owner",
1826+
"repo": "repo",
1827+
"pull_number": float64(1),
1828+
"body": "Great stuff!",
1829+
"commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
1830+
"path": "file1.txt",
1831+
"line": float64(2),
1832+
},
1833+
expectError: true,
1834+
expectedErrMsg: "failed to create pull request comment",
1835+
},
1836+
{
1837+
name: "reply creation fails",
1838+
mockedClient: mock.NewMockedHTTPClient(
1839+
mock.WithRequestMatchHandler(
1840+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1841+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1842+
w.WriteHeader(http.StatusNotFound)
1843+
w.Header().Set("Content-Type", "application/json")
1844+
_, _ = w.Write([]byte(`{"message": "Comment not found"}`))
1845+
}),
1846+
),
1847+
),
1848+
requestArgs: map[string]interface{}{
1849+
"owner": "owner",
1850+
"repo": "repo",
1851+
"pull_number": float64(1),
1852+
"body": "Good point, will fix!",
1853+
"in_reply_to": float64(999),
1854+
},
1855+
expectError: true,
1856+
expectedErrMsg: "failed to reply to pull request comment",
1857+
},
1858+
{
1859+
name: "missing required parameters for comment",
1860+
mockedClient: mock.NewMockedHTTPClient(),
1861+
requestArgs: map[string]interface{}{
1862+
"owner": "owner",
1863+
"repo": "repo",
1864+
"pull_number": float64(1),
1865+
"body": "Great stuff!",
1866+
// missing commit_id and path
1867+
},
1868+
expectError: false,
1869+
expectedErrMsg: "missing required parameter: commit_id",
1870+
},
1871+
}
1872+
1873+
for _, tc := range tests {
1874+
t.Run(tc.name, func(t *testing.T) {
1875+
mockClient := github.NewClient(tc.mockedClient)
1876+
1877+
_, handler := AddPullRequestReviewComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1878+
1879+
request := createMCPRequest(tc.requestArgs)
1880+
1881+
result, err := handler(context.Background(), request)
1882+
1883+
if tc.expectError {
1884+
require.Error(t, err)
1885+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
1886+
return
1887+
}
1888+
1889+
require.NoError(t, err)
1890+
assert.NotNil(t, result)
1891+
require.Len(t, result.Content, 1)
1892+
1893+
textContent := getTextResult(t, result)
1894+
if tc.expectedErrMsg != "" {
1895+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
1896+
return
1897+
}
1898+
1899+
var returnedComment github.PullRequestComment
1900+
err = json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedComment)
1901+
require.NoError(t, err)
1902+
1903+
assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID)
1904+
assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body)
1905+
1906+
// Only check Path, Line, and Side if they exist in the expected comment
1907+
if tc.expectedComment.Path != nil {
1908+
assert.Equal(t, *tc.expectedComment.Path, *returnedComment.Path)
1909+
}
1910+
if tc.expectedComment.Line != nil {
1911+
assert.Equal(t, *tc.expectedComment.Line, *returnedComment.Line)
1912+
}
1913+
if tc.expectedComment.Side != nil {
1914+
assert.Equal(t, *tc.expectedComment.Side, *returnedComment.Side)
1915+
}
1916+
})
1917+
}
1918+
}

0 commit comments

Comments
 (0)