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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy