Skip to content

Commit 7758959

Browse files
committed
feat: add add_reply_to_pull_request_comment tool
Add a new tool that allows AI agents to reply to existing pull request comments. This tool uses GitHub's CreateCommentInReplyTo REST API to create threaded conversations on pull requests. Features: Reply to any existing PR comment using its ID Proper error handling for missing parameters and API failures Comprehensive test coverage (8 test cases) Follows project patterns and conventions Registered in pull_requests toolset as a write operation Parameters: owner: Repository owner (required) repo: Repository name (required) pullNumber: Pull request number (required) commentId: ID of comment to reply to (required) body: Reply text content (required) This tool complements the existing add_comment_to_pending_review tool by enabling responses to already-posted comments, enhancing AI-powered code review workflows. Closes: #635
1 parent 99ace96 commit 7758959

File tree

5 files changed

+296
-0
lines changed

5 files changed

+296
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"annotations": {
3+
"title": "Add reply to pull request comment"
4+
},
5+
"description": "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment.",
6+
"inputSchema": {
7+
"properties": {
8+
"body": {
9+
"description": "The text of the reply",
10+
"type": "string"
11+
},
12+
"commentId": {
13+
"description": "The ID of the comment to reply to",
14+
"type": "string"
15+
},
16+
"owner": {
17+
"description": "Repository owner",
18+
"type": "string"
19+
},
20+
"pullNumber": {
21+
"description": "Pull request number",
22+
"type": "string"
23+
},
24+
"repo": {
25+
"description": "Repository name",
26+
"type": "string"
27+
}
28+
},
29+
"required": [
30+
"owner",
31+
"repo",
32+
"pullNumber",
33+
"commentId",
34+
"body"
35+
],
36+
"type": "object"
37+
},
38+
"name": "add_reply_to_pull_request_comment"
39+
}

pkg/github/helper_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const (
7272
PutReposPullsMergeByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge"
7373
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch"
7474
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"
75+
PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments"
7576

7677
// Notifications endpoints
7778
GetNotifications = "GET /notifications"

pkg/github/pullrequests.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,97 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
902902
})
903903
}
904904

905+
// AddReplyToPullRequestComment creates a tool to add a reply to an existing pull request comment.
906+
func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventory.ServerTool {
907+
schema := &jsonschema.Schema{
908+
Type: "object",
909+
Properties: map[string]*jsonschema.Schema{
910+
"owner": {
911+
Type: "string",
912+
Description: "Repository owner",
913+
},
914+
"repo": {
915+
Type: "string",
916+
Description: "Repository name",
917+
},
918+
"pullNumber": {
919+
Type: "string",
920+
Description: "Pull request number",
921+
},
922+
"commentId": {
923+
Type: "string",
924+
Description: "The ID of the comment to reply to",
925+
},
926+
"body": {
927+
Type: "string",
928+
Description: "The text of the reply",
929+
},
930+
},
931+
Required: []string{"owner", "repo", "pullNumber", "commentId", "body"},
932+
}
933+
934+
return NewTool(
935+
ToolsetMetadataPullRequests,
936+
mcp.Tool{
937+
Name: "add_reply_to_pull_request_comment",
938+
Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment."),
939+
Annotations: &mcp.ToolAnnotations{
940+
Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"),
941+
ReadOnlyHint: false,
942+
},
943+
InputSchema: schema,
944+
},
945+
[]scopes.Scope{scopes.Repo},
946+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
947+
owner, err := RequiredParam[string](args, "owner")
948+
if err != nil {
949+
return utils.NewToolResultError(err.Error()), nil, nil
950+
}
951+
repo, err := RequiredParam[string](args, "repo")
952+
if err != nil {
953+
return utils.NewToolResultError(err.Error()), nil, nil
954+
}
955+
pullNumber, err := RequiredInt(args, "pullNumber")
956+
if err != nil {
957+
return utils.NewToolResultError(err.Error()), nil, nil
958+
}
959+
commentID, err := RequiredInt(args, "commentId")
960+
if err != nil {
961+
return utils.NewToolResultError(err.Error()), nil, nil
962+
}
963+
body, err := RequiredParam[string](args, "body")
964+
if err != nil {
965+
return utils.NewToolResultError(err.Error()), nil, nil
966+
}
967+
968+
client, err := deps.GetClient(ctx)
969+
if err != nil {
970+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
971+
}
972+
973+
comment, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID))
974+
if err != nil {
975+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reply to pull request comment", resp, err), nil, nil
976+
}
977+
defer func() { _ = resp.Body.Close() }()
978+
979+
if resp.StatusCode != http.StatusCreated {
980+
bodyBytes, err := io.ReadAll(resp.Body)
981+
if err != nil {
982+
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
983+
}
984+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add reply to pull request comment", resp, bodyBytes), nil, nil
985+
}
986+
987+
r, err := json.Marshal(comment)
988+
if err != nil {
989+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
990+
}
991+
992+
return utils.NewToolResultText(string(r)), nil, nil
993+
})
994+
}
995+
905996
// ListPullRequests creates a tool to list and filter repository pull requests.
906997
func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool {
907998
schema := &jsonschema.Schema{

pkg/github/pullrequests_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3227,3 +3227,167 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo
32273227
),
32283228
)
32293229
}
3230+
3231+
func TestAddReplyToPullRequestComment(t *testing.T) {
3232+
t.Parallel()
3233+
3234+
// Verify tool definition once
3235+
serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
3236+
tool := serverTool.Tool
3237+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
3238+
3239+
assert.Equal(t, "add_reply_to_pull_request_comment", tool.Name)
3240+
assert.NotEmpty(t, tool.Description)
3241+
schema := tool.InputSchema.(*jsonschema.Schema)
3242+
assert.Contains(t, schema.Properties, "owner")
3243+
assert.Contains(t, schema.Properties, "repo")
3244+
assert.Contains(t, schema.Properties, "pullNumber")
3245+
assert.Contains(t, schema.Properties, "commentId")
3246+
assert.Contains(t, schema.Properties, "body")
3247+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber", "commentId", "body"})
3248+
3249+
// Setup mock reply comment for success case
3250+
mockReplyComment := &github.PullRequestComment{
3251+
ID: github.Ptr(int64(456)),
3252+
Body: github.Ptr("This is a reply to the comment"),
3253+
InReplyTo: github.Ptr(int64(123)),
3254+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"),
3255+
User: &github.User{
3256+
Login: github.Ptr("responder"),
3257+
},
3258+
CreatedAt: &github.Timestamp{Time: time.Now()},
3259+
UpdatedAt: &github.Timestamp{Time: time.Now()},
3260+
}
3261+
3262+
tests := []struct {
3263+
name string
3264+
mockedClient *http.Client
3265+
requestArgs map[string]interface{}
3266+
expectToolError bool
3267+
expectedToolErrMsg string
3268+
}{
3269+
{
3270+
name: "successful reply to pull request comment",
3271+
requestArgs: map[string]interface{}{
3272+
"owner": "owner",
3273+
"repo": "repo",
3274+
"pullNumber": float64(42),
3275+
"commentId": float64(123),
3276+
"body": "This is a reply to the comment",
3277+
},
3278+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3279+
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
3280+
w.WriteHeader(http.StatusCreated)
3281+
responseData, _ := json.Marshal(mockReplyComment)
3282+
_, _ = w.Write(responseData)
3283+
},
3284+
}),
3285+
},
3286+
{
3287+
name: "missing required parameter owner",
3288+
requestArgs: map[string]interface{}{
3289+
"repo": "repo",
3290+
"pullNumber": float64(42),
3291+
"commentId": float64(123),
3292+
"body": "This is a reply to the comment",
3293+
},
3294+
expectToolError: true,
3295+
expectedToolErrMsg: "missing required parameter: owner",
3296+
},
3297+
{
3298+
name: "missing required parameter repo",
3299+
requestArgs: map[string]interface{}{
3300+
"owner": "owner",
3301+
"pullNumber": float64(42),
3302+
"commentId": float64(123),
3303+
"body": "This is a reply to the comment",
3304+
},
3305+
expectToolError: true,
3306+
expectedToolErrMsg: "missing required parameter: repo",
3307+
},
3308+
{
3309+
name: "missing required parameter pullNumber",
3310+
requestArgs: map[string]interface{}{
3311+
"owner": "owner",
3312+
"repo": "repo",
3313+
"commentId": float64(123),
3314+
"body": "This is a reply to the comment",
3315+
},
3316+
expectToolError: true,
3317+
expectedToolErrMsg: "missing required parameter: pullNumber",
3318+
},
3319+
{
3320+
name: "missing required parameter commentId",
3321+
requestArgs: map[string]interface{}{
3322+
"owner": "owner",
3323+
"repo": "repo",
3324+
"pullNumber": float64(42),
3325+
"body": "This is a reply to the comment",
3326+
},
3327+
expectToolError: true,
3328+
expectedToolErrMsg: "missing required parameter: commentId",
3329+
},
3330+
{
3331+
name: "missing required parameter body",
3332+
requestArgs: map[string]interface{}{
3333+
"owner": "owner",
3334+
"repo": "repo",
3335+
"pullNumber": float64(42),
3336+
"commentId": float64(123),
3337+
},
3338+
expectToolError: true,
3339+
expectedToolErrMsg: "missing required parameter: body",
3340+
},
3341+
{
3342+
name: "API error when adding reply",
3343+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3344+
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
3345+
w.WriteHeader(http.StatusNotFound)
3346+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
3347+
},
3348+
}),
3349+
requestArgs: map[string]interface{}{
3350+
"owner": "owner",
3351+
"repo": "repo",
3352+
"pullNumber": float64(42),
3353+
"commentId": float64(123),
3354+
"body": "This is a reply to the comment",
3355+
},
3356+
expectToolError: true,
3357+
expectedToolErrMsg: "failed to add reply to pull request comment",
3358+
},
3359+
}
3360+
3361+
for _, tc := range tests {
3362+
t.Run(tc.name, func(t *testing.T) {
3363+
t.Parallel()
3364+
3365+
// Setup client with mock
3366+
client := github.NewClient(tc.mockedClient)
3367+
serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
3368+
deps := BaseDeps{
3369+
Client: client,
3370+
}
3371+
handler := serverTool.Handler(deps)
3372+
3373+
// Create call request
3374+
request := createMCPRequest(tc.requestArgs)
3375+
3376+
// Call handler
3377+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3378+
require.NoError(t, err)
3379+
3380+
if tc.expectToolError {
3381+
require.True(t, result.IsError)
3382+
errorContent := getErrorResult(t, result)
3383+
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
3384+
return
3385+
}
3386+
3387+
// Parse the result and verify it's not an error
3388+
require.False(t, result.IsError)
3389+
textContent := getTextResult(t, result)
3390+
assert.Contains(t, textContent.Text, "This is a reply to the comment")
3391+
})
3392+
}
3393+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
208208
RequestCopilotReview(t),
209209
PullRequestReviewWrite(t),
210210
AddCommentToPendingReview(t),
211+
AddReplyToPullRequestComment(t),
211212

212213
// Code security tools
213214
GetCodeScanningAlert(t),

0 commit comments

Comments
 (0)