Skip to content

Commit 8b5299a

Browse files
ashwin-antclaude
andcommitted
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 <[email protected]>
1 parent 4cf96ab commit 8b5299a

File tree

3 files changed

+264
-0
lines changed

3 files changed

+264
-0
lines changed

pkg/github/pullrequests.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,156 @@ func GetPullRequestComments(client *github.Client, t translations.TranslationHel
499499
}
500500
}
501501

502+
// AddPullRequestReviewComment creates a tool to add a review comment to a pull request.
503+
func AddPullRequestReviewComment(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
504+
return mcp.NewTool("add_pull_request_review_comment",
505+
mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a review comment to a pull request")),
506+
mcp.WithString("owner",
507+
mcp.Required(),
508+
mcp.Description("Repository owner"),
509+
),
510+
mcp.WithString("repo",
511+
mcp.Required(),
512+
mcp.Description("Repository name"),
513+
),
514+
mcp.WithNumber("pull_number",
515+
mcp.Required(),
516+
mcp.Description("Pull request number"),
517+
),
518+
mcp.WithString("body",
519+
mcp.Required(),
520+
mcp.Description("The text of the review comment"),
521+
),
522+
mcp.WithString("commit_id",
523+
mcp.Required(),
524+
mcp.Description("The SHA of the commit to comment on"),
525+
),
526+
mcp.WithString("path",
527+
mcp.Required(),
528+
mcp.Description("The relative path to the file that necessitates a comment"),
529+
),
530+
mcp.WithString("subject_type",
531+
mcp.Description("The level at which the comment is targeted, 'line' or 'file'"),
532+
mcp.Enum("line", "file"),
533+
),
534+
mcp.WithNumber("line",
535+
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"),
536+
),
537+
mcp.WithString("side",
538+
mcp.Description("The side of the diff to comment on. Can be LEFT or RIGHT"),
539+
mcp.Enum("LEFT", "RIGHT"),
540+
),
541+
mcp.WithNumber("start_line",
542+
mcp.Description("For multi-line comments, the first line of the range that the comment applies to"),
543+
),
544+
mcp.WithString("start_side",
545+
mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. Can be LEFT or RIGHT"),
546+
mcp.Enum("LEFT", "RIGHT"),
547+
),
548+
mcp.WithNumber("in_reply_to",
549+
mcp.Description("The ID of the review comment to reply to. When specified, all parameters other than body are ignored"),
550+
),
551+
),
552+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
553+
owner, err := requiredParam[string](request, "owner")
554+
if err != nil {
555+
return mcp.NewToolResultError(err.Error()), nil
556+
}
557+
repo, err := requiredParam[string](request, "repo")
558+
if err != nil {
559+
return mcp.NewToolResultError(err.Error()), nil
560+
}
561+
pullNumber, err := requiredInt(request, "pull_number")
562+
if err != nil {
563+
return mcp.NewToolResultError(err.Error()), nil
564+
}
565+
body, err := requiredParam[string](request, "body")
566+
if err != nil {
567+
return mcp.NewToolResultError(err.Error()), nil
568+
}
569+
commitID, err := requiredParam[string](request, "commit_id")
570+
if err != nil {
571+
return mcp.NewToolResultError(err.Error()), nil
572+
}
573+
path, err := requiredParam[string](request, "path")
574+
if err != nil {
575+
return mcp.NewToolResultError(err.Error()), nil
576+
}
577+
578+
comment := &github.PullRequestComment{
579+
Body: github.Ptr(body),
580+
CommitID: github.Ptr(commitID),
581+
Path: github.Ptr(path),
582+
}
583+
584+
// Check for in_reply_to parameter which takes precedence
585+
if replyToFloat, ok := request.Params.Arguments["in_reply_to"].(float64); ok {
586+
comment.InReplyTo = github.Ptr(int64(replyToFloat))
587+
} else {
588+
// Handle subject_type parameter
589+
subjectType, err := optionalParam[string](request, "subject_type")
590+
if err != nil {
591+
return mcp.NewToolResultError(err.Error()), nil
592+
}
593+
if subjectType == "file" {
594+
// When commenting on a file, no line/position fields are needed
595+
} else {
596+
// Handle line or position-based comments
597+
line, lineExists := request.Params.Arguments["line"].(float64)
598+
startLine, startLineExists := request.Params.Arguments["start_line"].(float64)
599+
side, sideExists := request.Params.Arguments["side"].(string)
600+
startSide, startSideExists := request.Params.Arguments["start_side"].(string)
601+
602+
if subjectType != "file" && !lineExists {
603+
return mcp.NewToolResultError("line parameter is required unless using subject_type:file or in_reply_to"), nil
604+
}
605+
606+
if lineExists {
607+
comment.Line = github.Ptr(int(line))
608+
}
609+
if sideExists {
610+
comment.Side = github.Ptr(side)
611+
}
612+
if startLineExists {
613+
comment.StartLine = github.Ptr(int(startLine))
614+
}
615+
if startSideExists {
616+
comment.StartSide = github.Ptr(startSide)
617+
}
618+
619+
// Validate multi-line comment parameters
620+
if startLineExists && !lineExists {
621+
return mcp.NewToolResultError("if start_line is provided, line must also be provided"), nil
622+
}
623+
if startSideExists && !sideExists {
624+
return mcp.NewToolResultError("if start_side is provided, side must also be provided"), nil
625+
}
626+
}
627+
}
628+
629+
createdComment, resp, err := client.PullRequests.CreateComment(ctx, owner, repo, pullNumber, comment)
630+
if err != nil {
631+
return nil, fmt.Errorf("failed to create pull request comment: %w", err)
632+
}
633+
defer func() { _ = resp.Body.Close() }()
634+
635+
if resp.StatusCode != http.StatusCreated {
636+
body, err := io.ReadAll(resp.Body)
637+
if err != nil {
638+
return nil, fmt.Errorf("failed to read response body: %w", err)
639+
}
640+
return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request comment: %s", string(body))), nil
641+
}
642+
643+
r, err := json.Marshal(createdComment)
644+
if err != nil {
645+
return nil, fmt.Errorf("failed to marshal response: %w", err)
646+
}
647+
648+
return mcp.NewToolResultText(string(r)), nil
649+
}
650+
}
651+
502652
// GetPullRequestReviews creates a tool to get the reviews on a pull request.
503653
func GetPullRequestReviews(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
504654
return mcp.NewTool("get_pull_request_reviews",

pkg/github/pullrequests_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,3 +1537,116 @@ func Test_CreatePullRequest(t *testing.T) {
15371537
})
15381538
}
15391539
}
1540+
1541+
func Test_AddPullRequestReviewComment(t *testing.T) {
1542+
mockClient := github.NewClient(nil)
1543+
tool, _ := AddPullRequestReviewComment(mockClient, translations.NullTranslationHelper)
1544+
1545+
assert.Equal(t, "add_pull_request_review_comment", tool.Name)
1546+
assert.NotEmpty(t, tool.Description)
1547+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1548+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1549+
assert.Contains(t, tool.InputSchema.Properties, "pull_number")
1550+
assert.Contains(t, tool.InputSchema.Properties, "body")
1551+
assert.Contains(t, tool.InputSchema.Properties, "commit_id")
1552+
assert.Contains(t, tool.InputSchema.Properties, "path")
1553+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pull_number", "body", "commit_id", "path"})
1554+
1555+
mockComment := &github.PullRequestComment{
1556+
ID: github.Ptr(int64(123)),
1557+
Body: github.Ptr("Great stuff!"),
1558+
Path: github.Ptr("file1.txt"),
1559+
Line: github.Ptr(2),
1560+
Side: github.Ptr("RIGHT"),
1561+
}
1562+
1563+
tests := []struct {
1564+
name string
1565+
mockedClient *http.Client
1566+
requestArgs map[string]interface{}
1567+
expectError bool
1568+
expectedComment *github.PullRequestComment
1569+
expectedErrMsg string
1570+
}{
1571+
{
1572+
name: "successful line comment creation",
1573+
mockedClient: mock.NewMockedHTTPClient(
1574+
mock.WithRequestMatchHandler(
1575+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1576+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1577+
w.WriteHeader(http.StatusCreated)
1578+
json.NewEncoder(w).Encode(mockComment)
1579+
}),
1580+
),
1581+
),
1582+
requestArgs: map[string]interface{}{
1583+
"owner": "owner",
1584+
"repo": "repo",
1585+
"pull_number": float64(1),
1586+
"body": "Great stuff!",
1587+
"commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
1588+
"path": "file1.txt",
1589+
"line": float64(2),
1590+
"side": "RIGHT",
1591+
},
1592+
expectError: false,
1593+
expectedComment: mockComment,
1594+
},
1595+
{
1596+
name: "comment creation fails",
1597+
mockedClient: mock.NewMockedHTTPClient(
1598+
mock.WithRequestMatchHandler(
1599+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1600+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1601+
w.WriteHeader(http.StatusUnprocessableEntity)
1602+
w.Header().Set("Content-Type", "application/json")
1603+
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
1604+
}),
1605+
),
1606+
),
1607+
requestArgs: map[string]interface{}{
1608+
"owner": "owner",
1609+
"repo": "repo",
1610+
"pull_number": float64(1),
1611+
"body": "Great stuff!",
1612+
"commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
1613+
"path": "file1.txt",
1614+
"line": float64(2),
1615+
},
1616+
expectError: false,
1617+
expectedErrMsg: "failed to create pull request comment",
1618+
},
1619+
}
1620+
1621+
for _, tc := range tests {
1622+
t.Run(tc.name, func(t *testing.T) {
1623+
mockClient := github.NewClient(tc.mockedClient)
1624+
1625+
_, handler := AddPullRequestReviewComment(mockClient, translations.NullTranslationHelper)
1626+
1627+
request := createMCPRequest(tc.requestArgs)
1628+
1629+
result, err := handler(context.Background(), request)
1630+
1631+
if tc.name == "comment creation fails" {
1632+
require.Error(t, err)
1633+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
1634+
return
1635+
}
1636+
1637+
require.NoError(t, err)
1638+
assert.NotNil(t, result)
1639+
require.Len(t, result.Content, 1)
1640+
1641+
var returnedComment github.PullRequestComment
1642+
err = json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedComment)
1643+
require.NoError(t, err)
1644+
1645+
assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID)
1646+
assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body)
1647+
assert.Equal(t, *tc.expectedComment.Path, *returnedComment.Path)
1648+
assert.Equal(t, *tc.expectedComment.Line, *returnedComment.Line)
1649+
assert.Equal(t, *tc.expectedComment.Side, *returnedComment.Side)
1650+
})
1651+
}
1652+
}

pkg/github/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func NewServer(client *github.Client, version string, readOnly bool, t translati
5353
s.AddTool(UpdatePullRequestBranch(client, t))
5454
s.AddTool(CreatePullRequestReview(client, t))
5555
s.AddTool(CreatePullRequest(client, t))
56+
s.AddTool(AddPullRequestReviewComment(client, t))
5657
}
5758

5859
// Add GitHub tools - Repositories

0 commit comments

Comments
 (0)