Skip to content

Commit 025929b

Browse files
aalmanasirCopilot
andcommitted
Use REST endpoints for PR draft state transitions
Replace GraphQL draft-state mutations with REST calls in both update paths and add coverage for no-op and 403/404 handling in general and granular tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9bdbcaa commit 025929b

5 files changed

Lines changed: 206 additions & 324 deletions

File tree

pkg/github/granular_tools_test.go

Lines changed: 43 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,89 +1100,67 @@ func TestGranularCreatePullRequestReview(t *testing.T) {
11001100

11011101
func TestGranularUpdatePullRequestDraftState(t *testing.T) {
11021102
tests := []struct {
1103-
name string
1104-
draft bool
1103+
name string
1104+
initialDraftState bool
1105+
requestedDraftState bool
1106+
expectedActionCalls int
1107+
getStatusCode int
1108+
actionStatusCode int
1109+
expectError bool
1110+
expectedErrContains string
11051111
}{
1106-
{name: "convert to draft", draft: true},
1107-
{name: "mark ready for review", draft: false},
1112+
{name: "convert to draft", initialDraftState: false, requestedDraftState: true, expectedActionCalls: 1, getStatusCode: http.StatusOK, actionStatusCode: http.StatusOK},
1113+
{name: "mark ready for review", initialDraftState: true, requestedDraftState: false, expectedActionCalls: 1, getStatusCode: http.StatusOK, actionStatusCode: http.StatusOK},
1114+
{name: "no-op when already requested state", initialDraftState: true, requestedDraftState: true, expectedActionCalls: 0, getStatusCode: http.StatusOK, actionStatusCode: http.StatusOK},
1115+
{name: "returns error when action endpoint is forbidden", initialDraftState: false, requestedDraftState: true, expectedActionCalls: 1, getStatusCode: http.StatusOK, actionStatusCode: http.StatusForbidden, expectError: true, expectedErrContains: "failed to convert pull request to draft"},
1116+
{name: "returns error when pull request lookup is not found", initialDraftState: false, requestedDraftState: true, expectedActionCalls: 0, getStatusCode: http.StatusNotFound, actionStatusCode: http.StatusOK, expectError: true, expectedErrContains: "failed to get pull request"},
11081117
}
11091118

11101119
for _, tc := range tests {
11111120
t.Run(tc.name, func(t *testing.T) {
1112-
var matchers []githubv4mock.Matcher
1113-
1114-
matchers = append(matchers, githubv4mock.NewQueryMatcher(
1115-
struct {
1116-
Repository struct {
1117-
PullRequest struct {
1118-
ID githubv4.ID
1119-
} `graphql:"pullRequest(number: $number)"`
1120-
} `graphql:"repository(owner: $owner, name: $name)"`
1121-
}{},
1122-
map[string]any{
1123-
"owner": githubv4.String("owner"),
1124-
"name": githubv4.String("repo"),
1125-
"number": githubv4.Int(1),
1121+
actionCalls := 0
1122+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1123+
GetReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
1124+
if tc.getStatusCode != http.StatusOK {
1125+
writeJSONResponse(t, w, tc.getStatusCode, map[string]any{"message": "not found"})
1126+
return
1127+
}
1128+
writeJSONResponse(t, w, http.StatusOK, &gogithub.PullRequest{
1129+
Number: gogithub.Ptr(1),
1130+
Draft: gogithub.Ptr(tc.initialDraftState),
1131+
})
11261132
},
1127-
githubv4mock.DataResponse(map[string]any{
1128-
"repository": map[string]any{
1129-
"pullRequest": map[string]any{"id": "PR_123"},
1130-
},
1131-
}),
1132-
))
1133-
1134-
if tc.draft {
1135-
matchers = append(matchers, githubv4mock.NewMutationMatcher(
1136-
struct {
1137-
ConvertPullRequestToDraft struct {
1138-
PullRequest struct {
1139-
ID githubv4.ID
1140-
IsDraft githubv4.Boolean
1141-
}
1142-
} `graphql:"convertPullRequestToDraft(input: $input)"`
1143-
}{},
1144-
githubv4.ConvertPullRequestToDraftInput{PullRequestID: githubv4.ID("PR_123")},
1145-
nil,
1146-
githubv4mock.DataResponse(map[string]any{
1147-
"convertPullRequestToDraft": map[string]any{
1148-
"pullRequest": map[string]any{"id": "PR_123", "isDraft": true},
1149-
},
1150-
}),
1151-
))
1152-
} else {
1153-
matchers = append(matchers, githubv4mock.NewMutationMatcher(
1154-
struct {
1155-
MarkPullRequestReadyForReview struct {
1156-
PullRequest struct {
1157-
ID githubv4.ID
1158-
IsDraft githubv4.Boolean
1159-
}
1160-
} `graphql:"markPullRequestReadyForReview(input: $input)"`
1161-
}{},
1162-
githubv4.MarkPullRequestReadyForReviewInput{PullRequestID: githubv4.ID("PR_123")},
1163-
nil,
1164-
githubv4mock.DataResponse(map[string]any{
1165-
"markPullRequestReadyForReview": map[string]any{
1166-
"pullRequest": map[string]any{"id": "PR_123", "isDraft": false},
1167-
},
1168-
}),
1169-
))
1170-
}
1133+
PostReposPullsByOwnerByRepoByPullNumberConvertToDraft: func(w http.ResponseWriter, _ *http.Request) {
1134+
actionCalls++
1135+
writeJSONResponse(t, w, tc.actionStatusCode, map[string]any{"message": "forbidden"})
1136+
},
1137+
PostReposPullsByOwnerByRepoByPullNumberReadyForReview: func(w http.ResponseWriter, _ *http.Request) {
1138+
actionCalls++
1139+
writeJSONResponse(t, w, tc.actionStatusCode, map[string]any{"message": "forbidden"})
1140+
},
1141+
}))
11711142

1172-
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
1173-
deps := BaseDeps{GQLClient: gqlClient}
1143+
deps := BaseDeps{Client: client}
11741144
serverTool := GranularUpdatePullRequestDraftState(translations.NullTranslationHelper)
11751145
handler := serverTool.Handler(deps)
11761146

11771147
request := createMCPRequest(map[string]any{
11781148
"owner": "owner",
11791149
"repo": "repo",
11801150
"pullNumber": float64(1),
1181-
"draft": tc.draft,
1151+
"draft": tc.requestedDraftState,
11821152
})
11831153
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
11841154
require.NoError(t, err)
1155+
if tc.expectError {
1156+
assert.True(t, result.IsError)
1157+
errorContent := getErrorResult(t, result)
1158+
assert.Contains(t, errorContent.Text, tc.expectedErrContains)
1159+
assert.Equal(t, tc.expectedActionCalls, actionCalls)
1160+
return
1161+
}
11851162
assert.False(t, result.IsError)
1163+
assert.Equal(t, tc.expectedActionCalls, actionCalls)
11861164
})
11871165
}
11881166
}

pkg/github/helper_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ const (
8181
PatchReposPullsByOwnerByRepoByPullNumber = "PATCH /repos/{owner}/{repo}/pulls/{pull_number}"
8282
PutReposPullsMergeByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge"
8383
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch"
84+
PostReposPullsByOwnerByRepoByPullNumberConvertToDraft = "POST /repos/{owner}/{repo}/pulls/{pull_number}/convert-to-draft"
85+
PostReposPullsByOwnerByRepoByPullNumberReadyForReview = "POST /repos/{owner}/{repo}/pulls/{pull_number}/ready_for_review"
8486
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"
8587
PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments"
8688
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions"

pkg/github/pullrequests.go

Lines changed: 57 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,60 @@ import (
2323
"github.com/github/github-mcp-server/pkg/utils"
2424
)
2525

26+
func updatePullRequestDraftState(ctx context.Context, deps ToolDependencies, owner, repo string, pullNumber int, draft bool) *mcp.CallToolResult {
27+
client, err := deps.GetClient(ctx)
28+
if err != nil {
29+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err)
30+
}
31+
32+
pullRequest, getResp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
33+
if err != nil {
34+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", getResp, err)
35+
}
36+
defer func() { _ = getResp.Body.Close() }()
37+
38+
if getResp.StatusCode != http.StatusOK {
39+
bodyBytes, err := io.ReadAll(getResp.Body)
40+
if err != nil {
41+
return utils.NewToolResultErrorFromErr("failed to read response body", err)
42+
}
43+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", getResp, bodyBytes)
44+
}
45+
46+
if pullRequest.GetDraft() == draft {
47+
return nil
48+
}
49+
50+
actionPath := "ready_for_review"
51+
actionName := "mark pull request ready for review"
52+
if draft {
53+
actionPath = "convert-to-draft"
54+
actionName = "convert pull request to draft"
55+
}
56+
57+
apiURL := fmt.Sprintf("repos/%s/%s/pulls/%d/%s", owner, repo, pullNumber, actionPath)
58+
req, err := client.NewRequest(ctx, http.MethodPost, apiURL, nil)
59+
if err != nil {
60+
return utils.NewToolResultErrorFromErr("failed to create request", err)
61+
}
62+
63+
actionResp, err := client.Do(req, nil)
64+
if err != nil {
65+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to "+actionName, actionResp, err)
66+
}
67+
defer func() { _ = actionResp.Body.Close() }()
68+
69+
if actionResp.StatusCode != http.StatusOK {
70+
bodyBytes, err := io.ReadAll(actionResp.Body)
71+
if err != nil {
72+
return utils.NewToolResultErrorFromErr("failed to read response body", err)
73+
}
74+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to "+actionName, actionResp, bodyBytes)
75+
}
76+
77+
return nil
78+
}
79+
2680
// PullRequestRead creates a tool to get details of a specific pull request.
2781
func PullRequestRead(t translations.TranslationHelperFunc) inventory.ServerTool {
2882
schema := &jsonschema.Schema{
@@ -1012,69 +1066,10 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
10121066
}
10131067
}
10141068

1015-
// Handle draft status changes using GraphQL
1069+
// Handle draft status changes using REST
10161070
if draftProvided {
1017-
gqlClient, err := deps.GetGQLClient(ctx)
1018-
if err != nil {
1019-
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
1020-
}
1021-
1022-
var prQuery struct {
1023-
Repository struct {
1024-
PullRequest struct {
1025-
ID githubv4.ID
1026-
IsDraft githubv4.Boolean
1027-
} `graphql:"pullRequest(number: $prNum)"`
1028-
} `graphql:"repository(owner: $owner, name: $repo)"`
1029-
}
1030-
1031-
err = gqlClient.Query(ctx, &prQuery, map[string]any{
1032-
"owner": githubv4.String(owner),
1033-
"repo": githubv4.String(repo),
1034-
"prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers
1035-
})
1036-
if err != nil {
1037-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil, nil
1038-
}
1039-
1040-
currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft)
1041-
1042-
if currentIsDraft != draftValue {
1043-
if draftValue {
1044-
// Convert to draft
1045-
var mutation struct {
1046-
ConvertPullRequestToDraft struct {
1047-
PullRequest struct {
1048-
ID githubv4.ID
1049-
IsDraft githubv4.Boolean
1050-
}
1051-
} `graphql:"convertPullRequestToDraft(input: $input)"`
1052-
}
1053-
1054-
err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{
1055-
PullRequestID: prQuery.Repository.PullRequest.ID,
1056-
}, nil)
1057-
if err != nil {
1058-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil, nil
1059-
}
1060-
} else {
1061-
// Mark as ready for review
1062-
var mutation struct {
1063-
MarkPullRequestReadyForReview struct {
1064-
PullRequest struct {
1065-
ID githubv4.ID
1066-
IsDraft githubv4.Boolean
1067-
}
1068-
} `graphql:"markPullRequestReadyForReview(input: $input)"`
1069-
}
1070-
1071-
err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{
1072-
PullRequestID: prQuery.Repository.PullRequest.ID,
1073-
}, nil)
1074-
if err != nil {
1075-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil, nil
1076-
}
1077-
}
1071+
if result := updatePullRequestDraftState(ctx, deps, owner, repo, pullNumber, draftValue); result != nil {
1072+
return result, nil, nil
10781073
}
10791074
}
10801075

pkg/github/pullrequests_granular.go

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
gogithub "github.com/google/go-github/v87/github"
1616
"github.com/google/jsonschema-go/jsonschema"
1717
"github.com/modelcontextprotocol/go-sdk/mcp"
18-
"github.com/shurcooL/githubv4"
1918
)
2019

2120
// prUpdateTool is a helper to create single-field pull request update tools via REST.
@@ -218,57 +217,14 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i
218217
return utils.NewToolResultError(err.Error()), nil, nil
219218
}
220219

221-
gqlClient, err := deps.GetGQLClient(ctx)
222-
if err != nil {
223-
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
224-
}
225-
226-
// Get PR node ID
227-
var prQuery struct {
228-
Repository struct {
229-
PullRequest struct {
230-
ID githubv4.ID
231-
} `graphql:"pullRequest(number: $number)"`
232-
} `graphql:"repository(owner: $owner, name: $name)"`
233-
}
234-
if err := gqlClient.Query(ctx, &prQuery, map[string]any{
235-
"owner": githubv4.String(owner),
236-
"name": githubv4.String(repo),
237-
"number": githubv4.Int(pullNumber), // #nosec G115 - PR numbers are always small positive integers
238-
}); err != nil {
239-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request", err), nil, nil
220+
if result := updatePullRequestDraftState(ctx, deps, owner, repo, pullNumber, draft); result != nil {
221+
return result, nil, nil
240222
}
241223

242224
if draft {
243-
var mutation struct {
244-
ConvertPullRequestToDraft struct {
245-
PullRequest struct {
246-
ID githubv4.ID
247-
IsDraft githubv4.Boolean
248-
}
249-
} `graphql:"convertPullRequestToDraft(input: $input)"`
250-
}
251-
if err := gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{
252-
PullRequestID: prQuery.Repository.PullRequest.ID,
253-
}, nil); err != nil {
254-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to convert to draft", err), nil, nil
255-
}
256225
return utils.NewToolResultText("pull request converted to draft"), nil, nil
257226
}
258227

259-
var mutation struct {
260-
MarkPullRequestReadyForReview struct {
261-
PullRequest struct {
262-
ID githubv4.ID
263-
IsDraft githubv4.Boolean
264-
}
265-
} `graphql:"markPullRequestReadyForReview(input: $input)"`
266-
}
267-
if err := gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{
268-
PullRequestID: prQuery.Repository.PullRequest.ID,
269-
}, nil); err != nil {
270-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to mark ready for review", err), nil, nil
271-
}
272228
return utils.NewToolResultText("pull request marked as ready for review"), nil, nil
273229
},
274230
)

0 commit comments

Comments
 (0)