Skip to content

Commit 45e90ae

Browse files
MayorFajmattdhollowaytommaso-moro
authored
feat: add reviewers parameter to UpdatePullRequest (github#285)
* feat: add reviewers parameter to UpdatePullRequest and update tests * Update pullrequests.go * feat: enhance update pull request functionality with reviewers support * update README to clarify optional reviewers parameter in API documentation- go run ./cmd/github-mcp-server generate-docs * feat: enhance UpdatePullRequest to return early if no updates or reviewers are provided * Add updating draft state to `update_pull_request` tool (github#774) * initial impl of pull request draft state update * appease linter * update README * add nosec * fixed err return type for json marshalling * add gql test * Add support for org-level discussions in list_discussions tool (github#775) * make repo optional, and default to .github when not provided. improve tool description * autogen * update tests * small copy paste error fixes * refactor: streamline UpdatePullRequest logic and enhance test cases for reviewer updates * refactor: remove redundant draft update tests and streamline UpdatePullRequest logic * test: add unit tests for updating pull request draft state * refactor: simplify UpdatePullRequest tests by removing unused mock data --------- Co-authored-by: Matt Holloway <mattdholloway@github.com> Co-authored-by: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com>
1 parent 1c6171b commit 45e90ae

File tree

5 files changed

+138
-7
lines changed

5 files changed

+138
-7
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ The following sets of tools are available (all are on by default):
766766
- `owner`: Repository owner (string, required)
767767
- `pullNumber`: Pull request number to update (number, required)
768768
- `repo`: Repository name (string, required)
769+
- `reviewers`: GitHub usernames to request reviews from (string[], optional)
769770
- `state`: New state (string, optional)
770771
- `title`: New title (string, optional)
771772

pkg/github/__toolsnaps__/update_pull_request.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
"description": "Repository name",
3535
"type": "string"
3636
},
37+
"reviewers": {
38+
"description": "GitHub usernames to request reviews from",
39+
"items": {
40+
"type": "string"
41+
},
42+
"type": "array"
43+
},
3744
"state": {
3845
"description": "New state",
3946
"enum": [

pkg/github/discussions_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ var (
8787
"url": "https://github.com/owner/.github/discussions/4",
8888
"category": map[string]any{"name": "General"},
8989
},
90-
9190
}
9291

9392
// Ordered mock responses
@@ -190,7 +189,7 @@ var (
190189
"startCursor": "",
191190
"endCursor": "",
192191
},
193-
"totalCount": 4,
192+
"totalCount": 4,
194193
},
195194
},
196195
})

pkg/github/pullrequests.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra
241241
mcp.WithBoolean("maintainer_can_modify",
242242
mcp.Description("Allow maintainer edits"),
243243
),
244+
mcp.WithArray("reviewers",
245+
mcp.Description("GitHub usernames to request reviews from"),
246+
mcp.Items(map[string]interface{}{
247+
"type": "string",
248+
}),
249+
),
244250
),
245251
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
246252
owner, err := RequiredParam[string](request, "owner")
@@ -256,15 +262,17 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra
256262
return mcp.NewToolResultError(err.Error()), nil
257263
}
258264

265+
// Check if draft parameter is provided
259266
draftProvided := request.GetArguments()["draft"] != nil
260267
var draftValue bool
261268
if draftProvided {
262269
draftValue, err = OptionalParam[bool](request, "draft")
263270
if err != nil {
264-
return nil, err
271+
return mcp.NewToolResultError(err.Error()), nil
265272
}
266273
}
267274

275+
// Build the update struct only with provided fields
268276
update := &github.PullRequest{}
269277
restUpdateNeeded := false
270278

@@ -303,10 +311,18 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra
303311
restUpdateNeeded = true
304312
}
305313

306-
if !restUpdateNeeded && !draftProvided {
314+
// Handle reviewers separately
315+
reviewers, err := OptionalStringArrayParam(request, "reviewers")
316+
if err != nil {
317+
return mcp.NewToolResultError(err.Error()), nil
318+
}
319+
320+
// If no updates, no draft change, and no reviewers, return error early
321+
if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 {
307322
return mcp.NewToolResultError("No update parameters provided."), nil
308323
}
309324

325+
// Handle REST API updates (title, body, state, base, maintainer_can_modify)
310326
if restUpdateNeeded {
311327
client, err := getClient(ctx)
312328
if err != nil {
@@ -332,6 +348,7 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra
332348
}
333349
}
334350

351+
// Handle draft status changes using GraphQL
335352
if draftProvided {
336353
gqlClient, err := getGQLClient(ctx)
337354
if err != nil {
@@ -397,6 +414,41 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra
397414
}
398415
}
399416

417+
// Handle reviewer requests
418+
if len(reviewers) > 0 {
419+
client, err := getClient(ctx)
420+
if err != nil {
421+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
422+
}
423+
424+
reviewersRequest := github.ReviewersRequest{
425+
Reviewers: reviewers,
426+
}
427+
428+
_, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest)
429+
if err != nil {
430+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
431+
"failed to request reviewers",
432+
resp,
433+
err,
434+
), nil
435+
}
436+
defer func() {
437+
if resp != nil && resp.Body != nil {
438+
_ = resp.Body.Close()
439+
}
440+
}()
441+
442+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
443+
body, err := io.ReadAll(resp.Body)
444+
if err != nil {
445+
return nil, fmt.Errorf("failed to read response body: %w", err)
446+
}
447+
return mcp.NewToolResultError(fmt.Sprintf("failed to request reviewers: %s", string(body))), nil
448+
}
449+
}
450+
451+
// Get the final state of the PR to return
400452
client, err := getClient(ctx)
401453
if err != nil {
402454
return nil, err

pkg/github/pullrequests_test.go

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func Test_UpdatePullRequest(t *testing.T) {
151151
assert.Contains(t, tool.InputSchema.Properties, "state")
152152
assert.Contains(t, tool.InputSchema.Properties, "base")
153153
assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify")
154+
assert.Contains(t, tool.InputSchema.Properties, "reviewers")
154155
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
155156

156157
// Setup mock PR for success case
@@ -173,6 +174,17 @@ func Test_UpdatePullRequest(t *testing.T) {
173174
State: github.Ptr("closed"), // State updated
174175
}
175176

177+
// Mock PR for when there are no updates but we still need a response
178+
mockPRWithReviewers := &github.PullRequest{
179+
Number: github.Ptr(42),
180+
Title: github.Ptr("Test PR"),
181+
State: github.Ptr("open"),
182+
RequestedReviewers: []*github.User{
183+
{Login: github.Ptr("reviewer1")},
184+
{Login: github.Ptr("reviewer2")},
185+
},
186+
}
187+
176188
tests := []struct {
177189
name string
178190
mockedClient *http.Client
@@ -238,6 +250,28 @@ func Test_UpdatePullRequest(t *testing.T) {
238250
expectError: false,
239251
expectedPR: mockClosedPR,
240252
},
253+
{
254+
name: "successful PR update with reviewers",
255+
mockedClient: mock.NewMockedHTTPClient(
256+
// Mock for RequestReviewers call, returning the PR with reviewers
257+
mock.WithRequestMatch(
258+
mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
259+
mockPRWithReviewers,
260+
),
261+
mock.WithRequestMatch(
262+
mock.GetReposPullsByOwnerByRepoByPullNumber,
263+
mockPRWithReviewers,
264+
),
265+
),
266+
requestArgs: map[string]interface{}{
267+
"owner": "owner",
268+
"repo": "repo",
269+
"pullNumber": float64(42),
270+
"reviewers": []interface{}{"reviewer1", "reviewer2"},
271+
},
272+
expectError: false,
273+
expectedPR: mockPRWithReviewers,
274+
},
241275
{
242276
name: "successful PR update (title only)",
243277
mockedClient: mock.NewMockedHTTPClient(
@@ -295,6 +329,27 @@ func Test_UpdatePullRequest(t *testing.T) {
295329
expectError: true,
296330
expectedErrMsg: "failed to update pull request",
297331
},
332+
{
333+
name: "request reviewers fails",
334+
mockedClient: mock.NewMockedHTTPClient(
335+
// Then reviewer request fails
336+
mock.WithRequestMatchHandler(
337+
mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
338+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
339+
w.WriteHeader(http.StatusUnprocessableEntity)
340+
_, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`))
341+
}),
342+
),
343+
),
344+
requestArgs: map[string]interface{}{
345+
"owner": "owner",
346+
"repo": "repo",
347+
"pullNumber": float64(42),
348+
"reviewers": []interface{}{"invalid-user"},
349+
},
350+
expectError: true,
351+
expectedErrMsg: "failed to request reviewers",
352+
},
298353
}
299354

300355
for _, tc := range tests {
@@ -347,6 +402,26 @@ func Test_UpdatePullRequest(t *testing.T) {
347402
if tc.expectedPR.MaintainerCanModify != nil {
348403
assert.Equal(t, *tc.expectedPR.MaintainerCanModify, *returnedPR.MaintainerCanModify)
349404
}
405+
406+
// Check reviewers if they exist in the expected PR
407+
if len(tc.expectedPR.RequestedReviewers) > 0 {
408+
assert.NotNil(t, returnedPR.RequestedReviewers)
409+
assert.Equal(t, len(tc.expectedPR.RequestedReviewers), len(returnedPR.RequestedReviewers))
410+
411+
// Create maps of reviewer logins for easy comparison
412+
expectedReviewers := make(map[string]bool)
413+
for _, reviewer := range tc.expectedPR.RequestedReviewers {
414+
expectedReviewers[*reviewer.Login] = true
415+
}
416+
417+
actualReviewers := make(map[string]bool)
418+
for _, reviewer := range returnedPR.RequestedReviewers {
419+
actualReviewers[*reviewer.Login] = true
420+
}
421+
422+
// Compare the maps
423+
assert.Equal(t, expectedReviewers, actualReviewers)
424+
}
350425
})
351426
}
352427
}
@@ -529,9 +604,6 @@ func Test_UpdatePullRequest_Draft(t *testing.T) {
529604
err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
530605
require.NoError(t, err)
531606
assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number)
532-
if tc.expectedPR.Draft != nil {
533-
assert.Equal(t, *tc.expectedPR.Draft, *returnedPR.Draft)
534-
}
535607
})
536608
}
537609
}

0 commit comments

Comments
 (0)