Skip to content

Commit e0f735d

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

File tree

3 files changed

+175
-0
lines changed

3 files changed

+175
-0
lines changed

pkg/github/pullrequests.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,77 @@ func AddPullRequestReviewComment(client *github.Client, t translations.Translati
649649
}
650650
}
651651

652+
// ReplyToPullRequestReviewComment creates a tool to reply to an existing review comment on a pull request.
653+
func ReplyToPullRequestReviewComment(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool,
654+
handler server.ToolHandlerFunc) {
655+
return mcp.NewTool("reply_to_pull_request_review_comment",
656+
mcp.WithDescription(t("TOOL_REPLY_TO_PULL_REQUEST_REVIEW_COMMENT_DESCRIPTION", "Reply to an existing review comment on a pull request")),
657+
mcp.WithString("owner",
658+
mcp.Required(),
659+
mcp.Description("Repository owner"),
660+
),
661+
mcp.WithString("repo",
662+
mcp.Required(),
663+
mcp.Description("Repository name"),
664+
),
665+
mcp.WithNumber("pull_number",
666+
mcp.Required(),
667+
mcp.Description("Pull request number"),
668+
),
669+
mcp.WithNumber("comment_id",
670+
mcp.Required(),
671+
mcp.Description("The unique identifier of the comment to reply to"),
672+
),
673+
mcp.WithString("body",
674+
mcp.Required(),
675+
mcp.Description("The text of the reply comment"),
676+
),
677+
),
678+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
679+
owner, err := requiredParam[string](request, "owner")
680+
if err != nil {
681+
return mcp.NewToolResultError(err.Error()), nil
682+
}
683+
repo, err := requiredParam[string](request, "repo")
684+
if err != nil {
685+
return mcp.NewToolResultError(err.Error()), nil
686+
}
687+
pullNumber, err := requiredInt(request, "pull_number")
688+
if err != nil {
689+
return mcp.NewToolResultError(err.Error()), nil
690+
}
691+
commentID, err := requiredInt(request, "comment_id")
692+
if err != nil {
693+
return mcp.NewToolResultError(err.Error()), nil
694+
}
695+
body, err := requiredParam[string](request, "body")
696+
if err != nil {
697+
return mcp.NewToolResultError(err.Error()), nil
698+
}
699+
700+
createdReply, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID))
701+
if err != nil {
702+
return nil, fmt.Errorf("failed to reply to pull request comment: %w", err)
703+
}
704+
defer func() { _ = resp.Body.Close() }()
705+
706+
if resp.StatusCode != http.StatusCreated {
707+
body, err := io.ReadAll(resp.Body)
708+
if err != nil {
709+
return nil, fmt.Errorf("failed to read response body: %w", err)
710+
}
711+
return mcp.NewToolResultError(fmt.Sprintf("failed to reply to pull request comment: %s", string(body))), nil
712+
}
713+
714+
r, err := json.Marshal(createdReply)
715+
if err != nil {
716+
return nil, fmt.Errorf("failed to marshal response: %w", err)
717+
}
718+
719+
return mcp.NewToolResultText(string(r)), nil
720+
}
721+
}
722+
652723
// GetPullRequestReviews creates a tool to get the reviews on a pull request.
653724
func GetPullRequestReviews(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
654725
return mcp.NewTool("get_pull_request_reviews",

pkg/github/pullrequests_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,3 +1650,106 @@ func Test_AddPullRequestReviewComment(t *testing.T) {
16501650
})
16511651
}
16521652
}
1653+
1654+
func Test_ReplyToPullRequestReviewComment(t *testing.T) {
1655+
// Verify tool definition once
1656+
mockClient := github.NewClient(nil)
1657+
tool, _ := ReplyToPullRequestReviewComment(mockClient, translations.NullTranslationHelper)
1658+
1659+
assert.Equal(t, "reply_to_pull_request_review_comment", tool.Name)
1660+
assert.NotEmpty(t, tool.Description)
1661+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1662+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1663+
assert.Contains(t, tool.InputSchema.Properties, "pull_number")
1664+
assert.Contains(t, tool.InputSchema.Properties, "comment_id")
1665+
assert.Contains(t, tool.InputSchema.Properties, "body")
1666+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pull_number", "comment_id", "body"})
1667+
1668+
// Setup mock PR comment for success case
1669+
mockReply := &github.PullRequestComment{
1670+
ID: github.Ptr(int64(456)),
1671+
Body: github.Ptr("Good point, will fix!"),
1672+
}
1673+
1674+
tests := []struct {
1675+
name string
1676+
mockedClient *http.Client
1677+
requestArgs map[string]interface{}
1678+
expectError bool
1679+
expectedReply *github.PullRequestComment
1680+
expectedErrMsg string
1681+
}{
1682+
{
1683+
name: "successful reply creation",
1684+
mockedClient: mock.NewMockedHTTPClient(
1685+
mock.WithRequestMatchHandler(
1686+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1687+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1688+
w.WriteHeader(http.StatusCreated)
1689+
json.NewEncoder(w).Encode(mockReply)
1690+
}),
1691+
),
1692+
),
1693+
requestArgs: map[string]interface{}{
1694+
"owner": "owner",
1695+
"repo": "repo",
1696+
"pull_number": float64(1),
1697+
"comment_id": float64(123),
1698+
"body": "Good point, will fix!",
1699+
},
1700+
expectError: false,
1701+
expectedReply: mockReply,
1702+
},
1703+
{
1704+
name: "reply creation fails",
1705+
mockedClient: mock.NewMockedHTTPClient(
1706+
mock.WithRequestMatchHandler(
1707+
mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
1708+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1709+
w.WriteHeader(http.StatusNotFound)
1710+
w.Header().Set("Content-Type", "application/json")
1711+
_, _ = w.Write([]byte(`{"message": "Comment not found"}`))
1712+
}),
1713+
),
1714+
),
1715+
requestArgs: map[string]interface{}{
1716+
"owner": "owner",
1717+
"repo": "repo",
1718+
"pull_number": float64(1),
1719+
"comment_id": float64(999),
1720+
"body": "Good point, will fix!",
1721+
},
1722+
expectError: true,
1723+
expectedErrMsg: "failed to reply to pull request comment",
1724+
},
1725+
}
1726+
1727+
for _, tc := range tests {
1728+
t.Run(tc.name, func(t *testing.T) {
1729+
mockClient := github.NewClient(tc.mockedClient)
1730+
1731+
_, handler := replyToPullRequestReviewComment(mockClient, translations.NullTranslationHelper)
1732+
1733+
request := createMCPRequest(tc.requestArgs)
1734+
1735+
result, err := handler(context.Background(), request)
1736+
1737+
if tc.name == "reply creation fails" {
1738+
require.Error(t, err)
1739+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
1740+
return
1741+
}
1742+
1743+
require.NoError(t, err)
1744+
assert.NotNil(t, result)
1745+
require.Len(t, result.Content, 1)
1746+
1747+
var returnedReply github.PullRequestComment
1748+
err = json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedReply)
1749+
require.NoError(t, err)
1750+
1751+
assert.Equal(t, *tc.expectedReply.ID, *returnedReply.ID)
1752+
assert.Equal(t, *tc.expectedReply.Body, *returnedReply.Body)
1753+
})
1754+
}
1755+
}

pkg/github/server.go

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

5960
// Add GitHub tools - Repositories

0 commit comments

Comments
 (0)