Skip to content

Commit 51a19d4

Browse files
committed
feat: Add mark_pr_ready_for_review tool
This commit introduces a new tool that allows changing a GitHub pull request from draft state to ready for review. The implementation uses GitHub's GraphQL API with the 'markPullRequestReadyForReview' mutation, as the REST API doesn't support this state transition for existing draft PRs. Also fixes the GraphQL query by correcting 'Draft' to 'IsDraft' field name, ensuring proper detection of a PR's draft status. The changes include: - New tool implementation in the pull_requests toolset - Comprehensive tests - GraphQL query field correction
1 parent b9a06d0 commit 51a19d4

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed

pkg/github/pullrequests.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,3 +1606,87 @@ func newGQLIntPtr(i *int32) *githubv4.Int {
16061606
gi := githubv4.Int(*i)
16071607
return &gi
16081608
}
1609+
1610+
// MarkPullRequestReadyForReview creates a tool to mark a draft pull request as ready for review.
1611+
// This uses the GraphQL API because the REST API does not support changing a PR from draft to ready-for-review.
1612+
func MarkPullRequestReadyForReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
1613+
return mcp.NewTool("mark_pr_ready_for_review",
1614+
mcp.WithDescription(t("TOOL_MARK_PR_READY_FOR_REVIEW_DESCRIPTION", "Mark a draft pull request as ready for review. Use this to change a pull request from draft state to ready for review.")),
1615+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1616+
Title: t("TOOL_MARK_PR_READY_FOR_REVIEW_USER_TITLE", "Mark pull request ready for review"),
1617+
ReadOnlyHint: toBoolPtr(false),
1618+
}),
1619+
mcp.WithString("owner",
1620+
mcp.Required(),
1621+
mcp.Description("Repository owner"),
1622+
),
1623+
mcp.WithString("repo",
1624+
mcp.Required(),
1625+
mcp.Description("Repository name"),
1626+
),
1627+
mcp.WithNumber("pullNumber",
1628+
mcp.Required(),
1629+
mcp.Description("Pull request number"),
1630+
),
1631+
),
1632+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1633+
var params struct {
1634+
Owner string
1635+
Repo string
1636+
PullNumber int32
1637+
}
1638+
if err := mapstructure.Decode(request.Params.Arguments, &params); err != nil {
1639+
return mcp.NewToolResultError(err.Error()), nil
1640+
}
1641+
1642+
// Get the GraphQL client
1643+
client, err := getGQLClient(ctx)
1644+
if err != nil {
1645+
return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err)
1646+
}
1647+
1648+
// First, we need to get the GraphQL ID of the pull request
1649+
var getPullRequestQuery struct {
1650+
Repository struct {
1651+
PullRequest struct {
1652+
ID githubv4.ID
1653+
IsDraft githubv4.Boolean
1654+
} `graphql:"pullRequest(number: $prNum)"`
1655+
} `graphql:"repository(owner: $owner, name: $repo)"`
1656+
}
1657+
1658+
variables := map[string]any{
1659+
"owner": githubv4.String(params.Owner),
1660+
"repo": githubv4.String(params.Repo),
1661+
"prNum": githubv4.Int(params.PullNumber),
1662+
}
1663+
1664+
if err := client.Query(ctx, &getPullRequestQuery, variables); err != nil {
1665+
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request: %v", err)), nil
1666+
}
1667+
1668+
// Check if the PR is already in non-draft state
1669+
if !getPullRequestQuery.Repository.PullRequest.IsDraft {
1670+
return mcp.NewToolResultText("Pull request is already marked as ready for review"), nil
1671+
}
1672+
1673+
// Now we can mark the PR as ready for review using the GraphQL mutation
1674+
var markReadyForReviewMutation struct {
1675+
MarkPullRequestReadyForReview struct {
1676+
PullRequest struct {
1677+
ID githubv4.ID // We don't need this, but a selector is required or GQL complains
1678+
}
1679+
} `graphql:"markPullRequestReadyForReview(input: $input)"`
1680+
}
1681+
1682+
input := githubv4.MarkPullRequestReadyForReviewInput{
1683+
PullRequestID: getPullRequestQuery.Repository.PullRequest.ID,
1684+
}
1685+
1686+
if err := client.Mutate(ctx, &markReadyForReviewMutation, input, nil); err != nil {
1687+
return mcp.NewToolResultError(fmt.Sprintf("failed to mark pull request as ready for review: %v", err)), nil
1688+
}
1689+
1690+
return mcp.NewToolResultText("Pull request successfully marked as ready for review"), nil
1691+
}
1692+
}

pkg/github/pullrequests_test.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,3 +2291,228 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo
22912291
),
22922292
)
22932293
}
2294+
2295+
func TestMarkPullRequestReadyForReview(t *testing.T) {
2296+
t.Parallel()
2297+
2298+
// Verify tool definition once
2299+
mockClient := githubv4.NewClient(nil)
2300+
tool, _ := MarkPullRequestReadyForReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
2301+
2302+
assert.Equal(t, "mark_pr_ready_for_review", tool.Name)
2303+
assert.NotEmpty(t, tool.Description)
2304+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2305+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2306+
assert.Contains(t, tool.InputSchema.Properties, "pullNumber")
2307+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
2308+
2309+
tests := []struct {
2310+
name string
2311+
mockedClient *http.Client
2312+
requestArgs map[string]any
2313+
expectToolError bool
2314+
expectedToolErrMsg string
2315+
prIsDraft bool
2316+
}{
2317+
{
2318+
name: "successful mark ready for review",
2319+
mockedClient: githubv4mock.NewMockedHTTPClient(
2320+
githubv4mock.NewQueryMatcher(
2321+
struct {
2322+
Repository struct {
2323+
PullRequest struct {
2324+
ID githubv4.ID
2325+
IsDraft githubv4.Boolean
2326+
} `graphql:"pullRequest(number: $prNum)"`
2327+
} `graphql:"repository(owner: $owner, name: $repo)"`
2328+
}{},
2329+
map[string]any{
2330+
"owner": githubv4.String("owner"),
2331+
"repo": githubv4.String("repo"),
2332+
"prNum": githubv4.Int(42),
2333+
},
2334+
githubv4mock.DataResponse(
2335+
map[string]any{
2336+
"repository": map[string]any{
2337+
"pullRequest": map[string]any{
2338+
"id": "PR_kwDODKw3uc6WYN1T",
2339+
"isDraft": true,
2340+
},
2341+
},
2342+
},
2343+
),
2344+
),
2345+
githubv4mock.NewMutationMatcher(
2346+
struct {
2347+
MarkPullRequestReadyForReview struct {
2348+
PullRequest struct {
2349+
ID githubv4.ID
2350+
}
2351+
} `graphql:"markPullRequestReadyForReview(input: $input)"`
2352+
}{},
2353+
githubv4.MarkPullRequestReadyForReviewInput{
2354+
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
2355+
},
2356+
nil,
2357+
githubv4mock.DataResponse(map[string]any{}),
2358+
),
2359+
),
2360+
requestArgs: map[string]any{
2361+
"owner": "owner",
2362+
"repo": "repo",
2363+
"pullNumber": float64(42),
2364+
},
2365+
expectToolError: false,
2366+
prIsDraft: true,
2367+
},
2368+
{
2369+
name: "PR already ready for review",
2370+
mockedClient: githubv4mock.NewMockedHTTPClient(
2371+
githubv4mock.NewQueryMatcher(
2372+
struct {
2373+
Repository struct {
2374+
PullRequest struct {
2375+
ID githubv4.ID
2376+
IsDraft githubv4.Boolean
2377+
} `graphql:"pullRequest(number: $prNum)"`
2378+
} `graphql:"repository(owner: $owner, name: $repo)"`
2379+
}{},
2380+
map[string]any{
2381+
"owner": githubv4.String("owner"),
2382+
"repo": githubv4.String("repo"),
2383+
"prNum": githubv4.Int(42),
2384+
},
2385+
githubv4mock.DataResponse(
2386+
map[string]any{
2387+
"repository": map[string]any{
2388+
"pullRequest": map[string]any{
2389+
"id": "PR_kwDODKw3uc6WYN1T",
2390+
"isDraft": false,
2391+
},
2392+
},
2393+
},
2394+
),
2395+
),
2396+
),
2397+
requestArgs: map[string]any{
2398+
"owner": "owner",
2399+
"repo": "repo",
2400+
"pullNumber": float64(42),
2401+
},
2402+
expectToolError: false,
2403+
prIsDraft: false,
2404+
},
2405+
{
2406+
name: "failure to get pull request",
2407+
mockedClient: githubv4mock.NewMockedHTTPClient(
2408+
githubv4mock.NewQueryMatcher(
2409+
struct {
2410+
Repository struct {
2411+
PullRequest struct {
2412+
ID githubv4.ID
2413+
IsDraft githubv4.Boolean
2414+
} `graphql:"pullRequest(number: $prNum)"`
2415+
} `graphql:"repository(owner: $owner, name: $repo)"`
2416+
}{},
2417+
map[string]any{
2418+
"owner": githubv4.String("owner"),
2419+
"repo": githubv4.String("repo"),
2420+
"prNum": githubv4.Int(42),
2421+
},
2422+
githubv4mock.ErrorResponse("expected test failure"),
2423+
),
2424+
),
2425+
requestArgs: map[string]any{
2426+
"owner": "owner",
2427+
"repo": "repo",
2428+
"pullNumber": float64(42),
2429+
},
2430+
expectToolError: true,
2431+
expectedToolErrMsg: "failed to get pull request: expected test failure",
2432+
},
2433+
{
2434+
name: "failure to mark ready for review",
2435+
mockedClient: githubv4mock.NewMockedHTTPClient(
2436+
githubv4mock.NewQueryMatcher(
2437+
struct {
2438+
Repository struct {
2439+
PullRequest struct {
2440+
ID githubv4.ID
2441+
IsDraft githubv4.Boolean
2442+
} `graphql:"pullRequest(number: $prNum)"`
2443+
} `graphql:"repository(owner: $owner, name: $repo)"`
2444+
}{},
2445+
map[string]any{
2446+
"owner": githubv4.String("owner"),
2447+
"repo": githubv4.String("repo"),
2448+
"prNum": githubv4.Int(42),
2449+
},
2450+
githubv4mock.DataResponse(
2451+
map[string]any{
2452+
"repository": map[string]any{
2453+
"pullRequest": map[string]any{
2454+
"id": "PR_kwDODKw3uc6WYN1T",
2455+
"isDraft": true,
2456+
},
2457+
},
2458+
},
2459+
),
2460+
),
2461+
githubv4mock.NewMutationMatcher(
2462+
struct {
2463+
MarkPullRequestReadyForReview struct {
2464+
PullRequest struct {
2465+
ID githubv4.ID
2466+
}
2467+
} `graphql:"markPullRequestReadyForReview(input: $input)"`
2468+
}{},
2469+
githubv4.MarkPullRequestReadyForReviewInput{
2470+
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
2471+
},
2472+
nil,
2473+
githubv4mock.ErrorResponse("expected test failure"),
2474+
),
2475+
),
2476+
requestArgs: map[string]any{
2477+
"owner": "owner",
2478+
"repo": "repo",
2479+
"pullNumber": float64(42),
2480+
},
2481+
expectToolError: true,
2482+
expectedToolErrMsg: "failed to mark pull request as ready for review: expected test failure",
2483+
prIsDraft: true,
2484+
},
2485+
}
2486+
2487+
for _, tc := range tests {
2488+
t.Run(tc.name, func(t *testing.T) {
2489+
t.Parallel()
2490+
2491+
// Setup client with mock
2492+
client := githubv4.NewClient(tc.mockedClient)
2493+
_, handler := MarkPullRequestReadyForReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
2494+
2495+
// Create call request
2496+
request := createMCPRequest(tc.requestArgs)
2497+
2498+
// Call handler
2499+
result, err := handler(context.Background(), request)
2500+
require.NoError(t, err)
2501+
2502+
textContent := getTextResult(t, result)
2503+
2504+
if tc.expectToolError {
2505+
require.True(t, result.IsError)
2506+
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
2507+
return
2508+
}
2509+
2510+
// Check for the appropriate success message
2511+
if tc.prIsDraft {
2512+
require.Equal(t, "Pull request successfully marked as ready for review", textContent.Text)
2513+
} else {
2514+
require.Equal(t, "Pull request is already marked as ready for review", textContent.Text)
2515+
}
2516+
})
2517+
}
2518+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
7272
toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)),
7373
toolsets.NewServerTool(CreatePullRequest(getClient, t)),
7474
toolsets.NewServerTool(UpdatePullRequest(getClient, t)),
75+
toolsets.NewServerTool(MarkPullRequestReadyForReview(getGQLClient, t)),
7576
toolsets.NewServerTool(RequestCopilotReview(getClient, t)),
7677

7778
// Reviews

0 commit comments

Comments
 (0)