Skip to content

Commit 476fc57

Browse files
committed
Support requesting copilot as a reviewer
1 parent 25200cc commit 476fc57

File tree

6 files changed

+307
-23
lines changed

6 files changed

+307
-23
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,8 +462,8 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
462462

463463
- `owner`: Repository owner (string, required)
464464
- `repo`: Repository name (string, required)
465-
- `pull_number`: Pull request number (number, required)
466-
- _Note: As of now, requesting a Copilot review programmatically is not supported by the GitHub API. This tool will return an error until GitHub exposes this functionality._
465+
- `pullNumber`: Pull request number (number, required)
466+
- _Note: Currently, this tool will only work for github.com
467467

468468
### Repositories
469469

e2e/e2e_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,3 +772,148 @@ func TestDirectoryDeletion(t *testing.T) {
772772
require.Equal(t, "test-dir/test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match")
773773
require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion")
774774
}
775+
776+
func TestRequestCopilotReview(t *testing.T) {
777+
t.Parallel()
778+
779+
mcpClient := setupMCPClient(t)
780+
781+
ctx := context.Background()
782+
783+
// First, who am I
784+
getMeRequest := mcp.CallToolRequest{}
785+
getMeRequest.Params.Name = "get_me"
786+
787+
t.Log("Getting current user...")
788+
resp, err := mcpClient.CallTool(ctx, getMeRequest)
789+
require.NoError(t, err, "expected to call 'get_me' tool successfully")
790+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
791+
792+
require.False(t, resp.IsError, "expected result not to be an error")
793+
require.Len(t, resp.Content, 1, "expected content to have one item")
794+
795+
textContent, ok := resp.Content[0].(mcp.TextContent)
796+
require.True(t, ok, "expected content to be of type TextContent")
797+
798+
var trimmedGetMeText struct {
799+
Login string `json:"login"`
800+
}
801+
err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
802+
require.NoError(t, err, "expected to unmarshal text content successfully")
803+
804+
currentOwner := trimmedGetMeText.Login
805+
806+
// Then create a repository with a README (via autoInit)
807+
repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
808+
createRepoRequest := mcp.CallToolRequest{}
809+
createRepoRequest.Params.Name = "create_repository"
810+
createRepoRequest.Params.Arguments = map[string]any{
811+
"name": repoName,
812+
"private": true,
813+
"autoInit": true,
814+
}
815+
816+
t.Logf("Creating repository %s/%s...", currentOwner, repoName)
817+
_, err = mcpClient.CallTool(ctx, createRepoRequest)
818+
require.NoError(t, err, "expected to call 'create_repository' tool successfully")
819+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
820+
821+
// Cleanup the repository after the test
822+
t.Cleanup(func() {
823+
// MCP Server doesn't support deletions, but we can use the GitHub Client
824+
ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t))
825+
t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
826+
_, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
827+
require.NoError(t, err, "expected to delete repository successfully")
828+
})
829+
830+
// Create a branch on which to create a new commit
831+
createBranchRequest := mcp.CallToolRequest{}
832+
createBranchRequest.Params.Name = "create_branch"
833+
createBranchRequest.Params.Arguments = map[string]any{
834+
"owner": currentOwner,
835+
"repo": repoName,
836+
"branch": "test-branch",
837+
"from_branch": "main",
838+
}
839+
840+
t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
841+
resp, err = mcpClient.CallTool(ctx, createBranchRequest)
842+
require.NoError(t, err, "expected to call 'create_branch' tool successfully")
843+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
844+
845+
// Create a commit with a new file
846+
commitRequest := mcp.CallToolRequest{}
847+
commitRequest.Params.Name = "create_or_update_file"
848+
commitRequest.Params.Arguments = map[string]any{
849+
"owner": currentOwner,
850+
"repo": repoName,
851+
"path": "test-file.txt",
852+
"content": fmt.Sprintf("Created by e2e test %s", t.Name()),
853+
"message": "Add test file",
854+
"branch": "test-branch",
855+
}
856+
857+
t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
858+
resp, err = mcpClient.CallTool(ctx, commitRequest)
859+
require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
860+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
861+
862+
textContent, ok = resp.Content[0].(mcp.TextContent)
863+
require.True(t, ok, "expected content to be of type TextContent")
864+
865+
var trimmedCommitText struct {
866+
SHA string `json:"sha"`
867+
}
868+
err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText)
869+
require.NoError(t, err, "expected to unmarshal text content successfully")
870+
commitId := trimmedCommitText.SHA
871+
872+
// Create a pull request
873+
prRequest := mcp.CallToolRequest{}
874+
prRequest.Params.Name = "create_pull_request"
875+
prRequest.Params.Arguments = map[string]any{
876+
"owner": currentOwner,
877+
"repo": repoName,
878+
"title": "Test PR",
879+
"body": "This is a test PR",
880+
"head": "test-branch",
881+
"base": "main",
882+
"commitId": commitId,
883+
}
884+
885+
t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
886+
resp, err = mcpClient.CallTool(ctx, prRequest)
887+
require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
888+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
889+
890+
// Request a copilot review
891+
requestCopilotReviewRequest := mcp.CallToolRequest{}
892+
requestCopilotReviewRequest.Params.Name = "request_copilot_review"
893+
requestCopilotReviewRequest.Params.Arguments = map[string]any{
894+
"owner": currentOwner,
895+
"repo": repoName,
896+
"pullNumber": 1,
897+
}
898+
899+
t.Logf("Requesting Copilot review for pull request in %s/%s...", currentOwner, repoName)
900+
resp, err = mcpClient.CallTool(ctx, requestCopilotReviewRequest)
901+
require.NoError(t, err, "expected to call 'request_copilot_review' tool successfully")
902+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
903+
904+
textContent, ok = resp.Content[0].(mcp.TextContent)
905+
require.True(t, ok, "expected content to be of type TextContent")
906+
require.Equal(t, "", textContent.Text, "expected content to be empty")
907+
908+
// Finally, get requested reviews and see copilot is in there
909+
// MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client
910+
ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t))
911+
t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
912+
reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil)
913+
require.NoError(t, err, "expected to get review requests successfully")
914+
915+
// Check that there is one review request from copilot
916+
require.Len(t, reviewRequests.Users, 1, "expected to find one review request")
917+
require.Equal(t, "Copilot", *reviewRequests.Users[0].Login, "expected review request to be for Copilot")
918+
require.Equal(t, "Bot", *reviewRequests.Users[0].Type, "expected review request to be for Bot")
919+
}

pkg/github/helper_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ import (
1010
"github.com/stretchr/testify/require"
1111
)
1212

13+
type expectations struct {
14+
path string
15+
queryParams map[string]string
16+
requestBody any
17+
}
18+
19+
// expect is a helper function to create a partial mock that expects various
20+
// request behaviors, such as path, query parameters, and request body.
21+
func expect(t *testing.T, e expectations) *partialMock {
22+
return &partialMock{
23+
t: t,
24+
expectedPath: e.path,
25+
expectedQueryParams: e.queryParams,
26+
expectedRequestBody: e.requestBody,
27+
}
28+
}
29+
1330
// expectPath is a helper function to create a partial mock that expects a
1431
// request with the given path, with the ability to chain a response handler.
1532
func expectPath(t *testing.T, expectedPath string) *partialMock {

pkg/github/pullrequests.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,9 +1248,15 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
12481248
}
12491249

12501250
// RequestCopilotReview creates a tool to request a Copilot review for a pull request.
1251-
func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1251+
// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this
1252+
// tool if the configured host does not support it.
1253+
func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
12521254
return mcp.NewTool("request_copilot_review",
12531255
mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot review for a pull request. Note: This feature depends on GitHub API support and may not be available for all users.")),
1256+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1257+
Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"),
1258+
ReadOnlyHint: toBoolPtr(false),
1259+
}),
12541260
mcp.WithString("owner",
12551261
mcp.Required(),
12561262
mcp.Description("Repository owner"),
@@ -1259,7 +1265,7 @@ func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelpe
12591265
mcp.Required(),
12601266
mcp.Description("Repository name"),
12611267
),
1262-
mcp.WithNumber("pull_number",
1268+
mcp.WithNumber("pullNumber",
12631269
mcp.Required(),
12641270
mcp.Description("Pull request number"),
12651271
),
@@ -1269,17 +1275,46 @@ func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelpe
12691275
if err != nil {
12701276
return mcp.NewToolResultError(err.Error()), nil
12711277
}
1278+
12721279
repo, err := requiredParam[string](request, "repo")
12731280
if err != nil {
12741281
return mcp.NewToolResultError(err.Error()), nil
12751282
}
1276-
pullNumber, err := RequiredInt(request, "pull_number")
1283+
1284+
pullNumber, err := RequiredInt(request, "pullNumber")
1285+
if err != nil {
1286+
return mcp.NewToolResultError(err.Error()), nil
1287+
}
1288+
1289+
client, err := getClient(ctx)
12771290
if err != nil {
12781291
return mcp.NewToolResultError(err.Error()), nil
12791292
}
12801293

1281-
// As of now, GitHub API does not support Copilot as a reviewer programmatically.
1282-
// This is a placeholder for future support.
1283-
return mcp.NewToolResultError(fmt.Sprintf("Requesting a Copilot review for PR #%d in %s/%s is not currently supported by the GitHub API. Please request a Copilot review via the GitHub UI.", pullNumber, owner, repo)), nil
1294+
_, resp, err := client.PullRequests.RequestReviewers(
1295+
ctx,
1296+
owner,
1297+
repo,
1298+
pullNumber,
1299+
github.ReviewersRequest{
1300+
// The login name of the copilot reviewer bot
1301+
Reviewers: []string{"copilot-pull-request-reviewer[bot]"},
1302+
},
1303+
)
1304+
if err != nil {
1305+
return nil, fmt.Errorf("failed to request copilot review: %w", err)
1306+
}
1307+
defer func() { _ = resp.Body.Close() }()
1308+
1309+
if resp.StatusCode != http.StatusCreated {
1310+
body, err := io.ReadAll(resp.Body)
1311+
if err != nil {
1312+
return nil, fmt.Errorf("failed to read response body: %w", err)
1313+
}
1314+
return mcp.NewToolResultError(fmt.Sprintf("failed to request copilot review: %s", string(body))), nil
1315+
}
1316+
1317+
// Return nothing on success, as there's not much value in returning the Pull Request itself
1318+
return mcp.NewToolResultText(""), nil
12841319
}
12851320
}

pkg/github/pullrequests_test.go

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,25 +1918,110 @@ func Test_AddPullRequestReviewComment(t *testing.T) {
19181918
}
19191919

19201920
func Test_RequestCopilotReview(t *testing.T) {
1921+
t.Parallel()
1922+
19211923
mockClient := github.NewClient(nil)
1922-
tool, handler := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1924+
tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper)
19231925

19241926
assert.Equal(t, "request_copilot_review", tool.Name)
19251927
assert.NotEmpty(t, tool.Description)
19261928
assert.Contains(t, tool.InputSchema.Properties, "owner")
19271929
assert.Contains(t, tool.InputSchema.Properties, "repo")
1928-
assert.Contains(t, tool.InputSchema.Properties, "pull_number")
1929-
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pull_number"})
1930-
1931-
request := createMCPRequest(map[string]interface{}{
1932-
"owner": "owner",
1933-
"repo": "repo",
1934-
"pull_number": float64(42),
1935-
})
1936-
1937-
result, err := handler(context.Background(), request)
1938-
assert.NoError(t, err)
1939-
assert.NotNil(t, result)
1940-
textContent := getTextResult(t, result)
1941-
assert.Contains(t, textContent.Text, "not currently supported by the GitHub API")
1930+
assert.Contains(t, tool.InputSchema.Properties, "pullNumber")
1931+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
1932+
1933+
// Setup mock PR for success case
1934+
mockPR := &github.PullRequest{
1935+
Number: github.Ptr(42),
1936+
Title: github.Ptr("Test PR"),
1937+
State: github.Ptr("open"),
1938+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
1939+
Head: &github.PullRequestBranch{
1940+
SHA: github.Ptr("abcd1234"),
1941+
Ref: github.Ptr("feature-branch"),
1942+
},
1943+
Base: &github.PullRequestBranch{
1944+
Ref: github.Ptr("main"),
1945+
},
1946+
Body: github.Ptr("This is a test PR"),
1947+
User: &github.User{
1948+
Login: github.Ptr("testuser"),
1949+
},
1950+
}
1951+
1952+
tests := []struct {
1953+
name string
1954+
mockedClient *http.Client
1955+
requestArgs map[string]any
1956+
expectError bool
1957+
expectedErrMsg string
1958+
}{
1959+
{
1960+
name: "successful request",
1961+
mockedClient: mock.NewMockedHTTPClient(
1962+
mock.WithRequestMatchHandler(
1963+
mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
1964+
expect(t, expectations{
1965+
path: "/repos/owner/repo/pulls/1/requested_reviewers",
1966+
requestBody: map[string]any{
1967+
"reviewers": []any{"copilot-pull-request-reviewer[bot]"},
1968+
},
1969+
}).andThen(
1970+
mockResponse(t, http.StatusCreated, mockPR),
1971+
),
1972+
),
1973+
),
1974+
requestArgs: map[string]any{
1975+
"owner": "owner",
1976+
"repo": "repo",
1977+
"pullNumber": float64(1),
1978+
},
1979+
expectError: false,
1980+
},
1981+
{
1982+
name: "request fails",
1983+
mockedClient: mock.NewMockedHTTPClient(
1984+
mock.WithRequestMatchHandler(
1985+
mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
1986+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1987+
w.WriteHeader(http.StatusNotFound)
1988+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
1989+
}),
1990+
),
1991+
),
1992+
requestArgs: map[string]any{
1993+
"owner": "owner",
1994+
"repo": "repo",
1995+
"pullNumber": float64(999),
1996+
},
1997+
expectError: true,
1998+
expectedErrMsg: "failed to request copilot review",
1999+
},
2000+
}
2001+
2002+
for _, tc := range tests {
2003+
t.Run(tc.name, func(t *testing.T) {
2004+
t.Parallel()
2005+
2006+
client := github.NewClient(tc.mockedClient)
2007+
_, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper)
2008+
2009+
request := createMCPRequest(tc.requestArgs)
2010+
2011+
result, err := handler(context.Background(), request)
2012+
2013+
if tc.expectError {
2014+
require.Error(t, err)
2015+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2016+
return
2017+
}
2018+
2019+
require.NoError(t, err)
2020+
assert.NotNil(t, result)
2021+
assert.Len(t, result.Content, 1)
2022+
2023+
textContent := getTextResult(t, result)
2024+
require.Equal(t, "", textContent.Text)
2025+
})
2026+
}
19422027
}

pkg/github/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
7070
toolsets.NewServerTool(CreatePullRequest(getClient, t)),
7171
toolsets.NewServerTool(UpdatePullRequest(getClient, t)),
7272
toolsets.NewServerTool(AddPullRequestReviewComment(getClient, t)),
73+
74+
toolsets.NewServerTool(RequestCopilotReview(getClient, t)),
7375
)
7476
codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning").
7577
AddReadTools(

0 commit comments

Comments
 (0)