Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit6c05b40

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 commentsAdds 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 toolAdds 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 toolThe 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 parent8343fa5 commit6c05b40

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+
funcAddPullRequestReviewComment(getClientGetClientFn,t translations.TranslationHelperFunc) (tool mcp.Tool,handler server.ToolHandlerFunc) {
649+
returnmcp.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+
iferr!=nil {
698+
returnmcp.NewToolResultError(err.Error()),nil
699+
}
700+
repo,err:=requiredParam[string](request,"repo")
701+
iferr!=nil {
702+
returnmcp.NewToolResultError(err.Error()),nil
703+
}
704+
pullNumber,err:=RequiredInt(request,"pull_number")
705+
iferr!=nil {
706+
returnmcp.NewToolResultError(err.Error()),nil
707+
}
708+
body,err:=requiredParam[string](request,"body")
709+
iferr!=nil {
710+
returnmcp.NewToolResultError(err.Error()),nil
711+
}
712+
713+
client,err:=getClient(ctx)
714+
iferr!=nil {
715+
returnnil,fmt.Errorf("failed to get GitHub client: %w",err)
716+
}
717+
718+
// Check if this is a reply to an existing comment
719+
ifreplyToFloat,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+
iferr!=nil {
724+
returnnil,fmt.Errorf("failed to reply to pull request comment: %w",err)
725+
}
726+
deferfunc() {_=resp.Body.Close() }()
727+
728+
ifresp.StatusCode!=http.StatusCreated {
729+
respBody,err:=io.ReadAll(resp.Body)
730+
iferr!=nil {
731+
returnnil,fmt.Errorf("failed to read response body: %w",err)
732+
}
733+
returnmcp.NewToolResultError(fmt.Sprintf("failed to reply to pull request comment: %s",string(respBody))),nil
734+
}
735+
736+
r,err:=json.Marshal(createdReply)
737+
iferr!=nil {
738+
returnnil,fmt.Errorf("failed to marshal response: %w",err)
739+
}
740+
741+
returnmcp.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+
iferr!=nil {
748+
returnmcp.NewToolResultError(err.Error()),nil
749+
}
750+
path,err:=requiredParam[string](request,"path")
751+
iferr!=nil {
752+
returnmcp.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+
iferr!=nil {
763+
returnmcp.NewToolResultError(err.Error()),nil
764+
}
765+
ifsubjectType!="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+
returnmcp.NewToolResultError("line parameter is required unless using subject_type:file"),nil
773+
}
774+
775+
comment.Line=github.Ptr(int(line))
776+
ifsideExists {
777+
comment.Side=github.Ptr(side)
778+
}
779+
ifstartLineExists {
780+
comment.StartLine=github.Ptr(int(startLine))
781+
}
782+
ifstartSideExists {
783+
comment.StartSide=github.Ptr(startSide)
784+
}
785+
786+
ifstartLineExists&&!lineExists {
787+
returnmcp.NewToolResultError("if start_line is provided, line must also be provided"),nil
788+
}
789+
ifstartSideExists&&!sideExists {
790+
returnmcp.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+
iferr!=nil {
796+
returnnil,fmt.Errorf("failed to create pull request comment: %w",err)
797+
}
798+
deferfunc() {_=resp.Body.Close() }()
799+
800+
ifresp.StatusCode!=http.StatusCreated {
801+
respBody,err:=io.ReadAll(resp.Body)
802+
iferr!=nil {
803+
returnnil,fmt.Errorf("failed to read response body: %w",err)
804+
}
805+
returnmcp.NewToolResultError(fmt.Sprintf("failed to create pull request comment: %s",string(respBody))),nil
806+
}
807+
808+
r,err:=json.Marshal(createdComment)
809+
iferr!=nil {
810+
returnnil,fmt.Errorf("failed to marshal response: %w",err)
811+
}
812+
813+
returnmcp.NewToolResultText(string(r)),nil
814+
}
815+
}
816+
647817
// GetPullRequestReviews creates a tool to get the reviews on a pull request.
648818
funcGetPullRequestReviews(getClientGetClientFn,t translations.TranslationHelperFunc) (tool mcp.Tool,handler server.ToolHandlerFunc) {
649819
returnmcp.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+
funcTest_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+
namestring
1753+
mockedClient*http.Client
1754+
requestArgsmap[string]interface{}
1755+
expectErrorbool
1756+
expectedComment*github.PullRequestComment
1757+
expectedErrMsgstring
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+
iferr!=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+
iferr!=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:=rangetests {
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+
iftc.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+
iftc.expectedErrMsg!="" {
1895+
assert.Contains(t,textContent.Text,tc.expectedErrMsg)
1896+
return
1897+
}
1898+
1899+
varreturnedComment 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+
iftc.expectedComment.Path!=nil {
1908+
assert.Equal(t,*tc.expectedComment.Path,*returnedComment.Path)
1909+
}
1910+
iftc.expectedComment.Line!=nil {
1911+
assert.Equal(t,*tc.expectedComment.Line,*returnedComment.Line)
1912+
}
1913+
iftc.expectedComment.Side!=nil {
1914+
assert.Equal(t,*tc.expectedComment.Side,*returnedComment.Side)
1915+
}
1916+
})
1917+
}
1918+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp