Skip to content

Commit b37ef69

Browse files
Merge branch 'main' into copilot/add-string-override-logic
2 parents 89ef2ca + f93e526 commit b37ef69

File tree

4 files changed

+272
-3
lines changed

4 files changed

+272
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,7 @@ The following sets of tools are available:
11191119
- `owner`: Repository owner (string, required)
11201120
- `pullNumber`: Pull request number (number, required)
11211121
- `repo`: Repository name (string, required)
1122+
- `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional)
11221123

11231124
- **search_pull_requests** - Search pull requests
11241125
- **Required OAuth Scopes**: `repo`

pkg/github/__toolsnaps__/pull_request_review_write.snap

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"annotations": {
33
"title": "Write operations (create, submit, delete) on pull request reviews."
44
},
5-
"description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n",
5+
"description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n",
66
"inputSchema": {
77
"properties": {
88
"body": {
@@ -27,7 +27,9 @@
2727
"enum": [
2828
"create",
2929
"submit_pending",
30-
"delete_pending"
30+
"delete_pending",
31+
"resolve_thread",
32+
"unresolve_thread"
3133
],
3234
"type": "string"
3335
},
@@ -42,6 +44,10 @@
4244
"repo": {
4345
"description": "Repository name",
4446
"type": "string"
47+
},
48+
"threadId": {
49+
"description": "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.",
50+
"type": "string"
4551
}
4652
},
4753
"required": [

pkg/github/pullrequests.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,7 @@ type PullRequestReviewWriteParams struct {
15071507
Body string
15081508
Event string
15091509
CommitID *string
1510+
ThreadID string
15101511
}
15111512

15121513
func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
@@ -1519,7 +1520,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv
15191520
"method": {
15201521
Type: "string",
15211522
Description: `The write operation to perform on pull request review.`,
1522-
Enum: []any{"create", "submit_pending", "delete_pending"},
1523+
Enum: []any{"create", "submit_pending", "delete_pending", "resolve_thread", "unresolve_thread"},
15231524
},
15241525
"owner": {
15251526
Type: "string",
@@ -1546,6 +1547,10 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv
15461547
Type: "string",
15471548
Description: "SHA of commit to review",
15481549
},
1550+
"threadId": {
1551+
Type: "string",
1552+
Description: "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.",
1553+
},
15491554
},
15501555
Required: []string{"method", "owner", "repo", "pullNumber"},
15511556
}
@@ -1560,6 +1565,8 @@ Available methods:
15601565
- create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created.
15611566
- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review.
15621567
- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.
1568+
- resolve_thread: Resolve a review thread. Requires only "threadId" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.
1569+
- unresolve_thread: Unresolve a previously resolved review thread. Requires only "threadId" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.
15631570
`),
15641571
Annotations: &mcp.ToolAnnotations{
15651572
Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."),
@@ -1590,6 +1597,12 @@ Available methods:
15901597
case "delete_pending":
15911598
result, err := DeletePendingPullRequestReview(ctx, client, params)
15921599
return result, nil, err
1600+
case "resolve_thread":
1601+
result, err := ResolveReviewThread(ctx, client, params.ThreadID, true)
1602+
return result, nil, err
1603+
case "unresolve_thread":
1604+
result, err := ResolveReviewThread(ctx, client, params.ThreadID, false)
1605+
return result, nil, err
15931606
default:
15941607
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil
15951608
}
@@ -1819,6 +1832,60 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client
18191832
return utils.NewToolResultText("pending pull request review successfully deleted"), nil
18201833
}
18211834

1835+
// ResolveReviewThread resolves or unresolves a PR review thread using GraphQL mutations.
1836+
func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID string, resolve bool) (*mcp.CallToolResult, error) {
1837+
if threadID == "" {
1838+
return utils.NewToolResultError("threadId is required for resolve_thread and unresolve_thread methods"), nil
1839+
}
1840+
1841+
if resolve {
1842+
var mutation struct {
1843+
ResolveReviewThread struct {
1844+
Thread struct {
1845+
ID githubv4.ID
1846+
IsResolved githubv4.Boolean
1847+
}
1848+
} `graphql:"resolveReviewThread(input: $input)"`
1849+
}
1850+
1851+
input := githubv4.ResolveReviewThreadInput{
1852+
ThreadID: githubv4.ID(threadID),
1853+
}
1854+
1855+
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
1856+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
1857+
"failed to resolve review thread",
1858+
err,
1859+
), nil
1860+
}
1861+
1862+
return utils.NewToolResultText("review thread resolved successfully"), nil
1863+
}
1864+
1865+
// Unresolve
1866+
var mutation struct {
1867+
UnresolveReviewThread struct {
1868+
Thread struct {
1869+
ID githubv4.ID
1870+
IsResolved githubv4.Boolean
1871+
}
1872+
} `graphql:"unresolveReviewThread(input: $input)"`
1873+
}
1874+
1875+
input := githubv4.UnresolveReviewThreadInput{
1876+
ThreadID: githubv4.ID(threadID),
1877+
}
1878+
1879+
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
1880+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
1881+
"failed to unresolve review thread",
1882+
err,
1883+
), nil
1884+
}
1885+
1886+
return utils.NewToolResultText("review thread unresolved successfully"), nil
1887+
}
1888+
18221889
// AddCommentToPendingReview creates a tool to add a comment to a pull request review.
18231890
func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.ServerTool {
18241891
schema := &jsonschema.Schema{

pkg/github/pullrequests_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3609,3 +3609,198 @@ func TestAddReplyToPullRequestComment(t *testing.T) {
36093609
})
36103610
}
36113611
}
3612+
3613+
func TestResolveReviewThread(t *testing.T) {
3614+
t.Parallel()
3615+
3616+
tests := []struct {
3617+
name string
3618+
requestArgs map[string]any
3619+
mockedClient *http.Client
3620+
expectToolError bool
3621+
expectedToolErrMsg string
3622+
expectedResult string
3623+
}{
3624+
{
3625+
name: "successful resolve thread",
3626+
requestArgs: map[string]any{
3627+
"method": "resolve_thread",
3628+
"owner": "owner",
3629+
"repo": "repo",
3630+
"pullNumber": float64(42),
3631+
"threadId": "PRRT_kwDOTest123",
3632+
},
3633+
mockedClient: githubv4mock.NewMockedHTTPClient(
3634+
githubv4mock.NewMutationMatcher(
3635+
struct {
3636+
ResolveReviewThread struct {
3637+
Thread struct {
3638+
ID githubv4.ID
3639+
IsResolved githubv4.Boolean
3640+
}
3641+
} `graphql:"resolveReviewThread(input: $input)"`
3642+
}{},
3643+
githubv4.ResolveReviewThreadInput{
3644+
ThreadID: githubv4.ID("PRRT_kwDOTest123"),
3645+
},
3646+
nil,
3647+
githubv4mock.DataResponse(map[string]any{
3648+
"resolveReviewThread": map[string]any{
3649+
"thread": map[string]any{
3650+
"id": "PRRT_kwDOTest123",
3651+
"isResolved": true,
3652+
},
3653+
},
3654+
}),
3655+
),
3656+
),
3657+
expectedResult: "review thread resolved successfully",
3658+
},
3659+
{
3660+
name: "successful unresolve thread",
3661+
requestArgs: map[string]any{
3662+
"method": "unresolve_thread",
3663+
"owner": "owner",
3664+
"repo": "repo",
3665+
"pullNumber": float64(42),
3666+
"threadId": "PRRT_kwDOTest123",
3667+
},
3668+
mockedClient: githubv4mock.NewMockedHTTPClient(
3669+
githubv4mock.NewMutationMatcher(
3670+
struct {
3671+
UnresolveReviewThread struct {
3672+
Thread struct {
3673+
ID githubv4.ID
3674+
IsResolved githubv4.Boolean
3675+
}
3676+
} `graphql:"unresolveReviewThread(input: $input)"`
3677+
}{},
3678+
githubv4.UnresolveReviewThreadInput{
3679+
ThreadID: githubv4.ID("PRRT_kwDOTest123"),
3680+
},
3681+
nil,
3682+
githubv4mock.DataResponse(map[string]any{
3683+
"unresolveReviewThread": map[string]any{
3684+
"thread": map[string]any{
3685+
"id": "PRRT_kwDOTest123",
3686+
"isResolved": false,
3687+
},
3688+
},
3689+
}),
3690+
),
3691+
),
3692+
expectedResult: "review thread unresolved successfully",
3693+
},
3694+
{
3695+
name: "empty threadId for resolve",
3696+
requestArgs: map[string]any{
3697+
"method": "resolve_thread",
3698+
"owner": "owner",
3699+
"repo": "repo",
3700+
"pullNumber": float64(42),
3701+
"threadId": "",
3702+
},
3703+
mockedClient: githubv4mock.NewMockedHTTPClient(),
3704+
expectToolError: true,
3705+
expectedToolErrMsg: "threadId is required",
3706+
},
3707+
{
3708+
name: "empty threadId for unresolve",
3709+
requestArgs: map[string]any{
3710+
"method": "unresolve_thread",
3711+
"owner": "owner",
3712+
"repo": "repo",
3713+
"pullNumber": float64(42),
3714+
"threadId": "",
3715+
},
3716+
mockedClient: githubv4mock.NewMockedHTTPClient(),
3717+
expectToolError: true,
3718+
expectedToolErrMsg: "threadId is required",
3719+
},
3720+
{
3721+
name: "omitted threadId for resolve",
3722+
requestArgs: map[string]any{
3723+
"method": "resolve_thread",
3724+
"owner": "owner",
3725+
"repo": "repo",
3726+
"pullNumber": float64(42),
3727+
},
3728+
mockedClient: githubv4mock.NewMockedHTTPClient(),
3729+
expectToolError: true,
3730+
expectedToolErrMsg: "threadId is required",
3731+
},
3732+
{
3733+
name: "omitted threadId for unresolve",
3734+
requestArgs: map[string]any{
3735+
"method": "unresolve_thread",
3736+
"owner": "owner",
3737+
"repo": "repo",
3738+
"pullNumber": float64(42),
3739+
},
3740+
mockedClient: githubv4mock.NewMockedHTTPClient(),
3741+
expectToolError: true,
3742+
expectedToolErrMsg: "threadId is required",
3743+
},
3744+
{
3745+
name: "thread not found",
3746+
requestArgs: map[string]any{
3747+
"method": "resolve_thread",
3748+
"owner": "owner",
3749+
"repo": "repo",
3750+
"pullNumber": float64(42),
3751+
"threadId": "PRRT_invalid",
3752+
},
3753+
mockedClient: githubv4mock.NewMockedHTTPClient(
3754+
githubv4mock.NewMutationMatcher(
3755+
struct {
3756+
ResolveReviewThread struct {
3757+
Thread struct {
3758+
ID githubv4.ID
3759+
IsResolved githubv4.Boolean
3760+
}
3761+
} `graphql:"resolveReviewThread(input: $input)"`
3762+
}{},
3763+
githubv4.ResolveReviewThreadInput{
3764+
ThreadID: githubv4.ID("PRRT_invalid"),
3765+
},
3766+
nil,
3767+
githubv4mock.ErrorResponse("Could not resolve to a PullRequestReviewThread with the id of 'PRRT_invalid'"),
3768+
),
3769+
),
3770+
expectToolError: true,
3771+
expectedToolErrMsg: "Could not resolve to a PullRequestReviewThread",
3772+
},
3773+
}
3774+
3775+
for _, tc := range tests {
3776+
t.Run(tc.name, func(t *testing.T) {
3777+
t.Parallel()
3778+
3779+
// Setup client with mock
3780+
client := githubv4.NewClient(tc.mockedClient)
3781+
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
3782+
deps := BaseDeps{
3783+
GQLClient: client,
3784+
}
3785+
handler := serverTool.Handler(deps)
3786+
3787+
// Create call request
3788+
request := createMCPRequest(tc.requestArgs)
3789+
3790+
// Call handler
3791+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3792+
require.NoError(t, err)
3793+
3794+
textContent := getTextResult(t, result)
3795+
3796+
if tc.expectToolError {
3797+
require.True(t, result.IsError)
3798+
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
3799+
return
3800+
}
3801+
3802+
require.False(t, result.IsError)
3803+
assert.Equal(t, tc.expectedResult, textContent.Text)
3804+
})
3805+
}
3806+
}

0 commit comments

Comments
 (0)