Skip to content

Commit 1e9fbbc

Browse files
committed
Split PR review creation, commenting and submission
1 parent 4e26dce commit 1e9fbbc

File tree

6 files changed

+574
-2
lines changed

6 files changed

+574
-2
lines changed

cmd/github-mcp-server/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import (
1515
gogithub "github.com/google/go-github/v69/github"
1616
"github.com/mark3labs/mcp-go/mcp"
1717
"github.com/mark3labs/mcp-go/server"
18+
"github.com/shurcooL/githubv4"
1819
log "github.com/sirupsen/logrus"
1920
"github.com/spf13/cobra"
2021
"github.com/spf13/viper"
22+
"golang.org/x/oauth2"
2123
)
2224

2325
var version = "version"
@@ -161,6 +163,15 @@ func runStdioServer(cfg runConfig) error {
161163
return ghClient, nil // closing over client
162164
}
163165

166+
getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
167+
// TODO: Enterprise support
168+
src := oauth2.StaticTokenSource(
169+
&oauth2.Token{AccessToken: token},
170+
)
171+
httpClient := oauth2.NewClient(context.Background(), src)
172+
return githubv4.NewClient(httpClient), nil
173+
}
174+
164175
hooks := &server.Hooks{
165176
OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
166177
}
@@ -180,7 +191,7 @@ func runStdioServer(cfg runConfig) error {
180191
}
181192

182193
// Create default toolsets
183-
toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, t)
194+
toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, getGQLClient, t)
184195
context := github.InitContextToolset(getClient, t)
185196

186197
if err != nil {

e2e/e2e_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,199 @@ func TestToolsets(t *testing.T) {
206206
require.True(t, toolsContains("list_branches"), "expected to find 'list_branches' tool")
207207
require.False(t, toolsContains("get_pull_request"), "expected not to find 'get_pull_request' tool")
208208
}
209+
210+
func TestPullRequestReview(t *testing.T) {
211+
t.Parallel()
212+
213+
mcpClient := setupMCPClient(t)
214+
215+
ctx := context.Background()
216+
217+
// First, who am I
218+
getMeRequest := mcp.CallToolRequest{}
219+
getMeRequest.Params.Name = "get_me"
220+
221+
t.Log("Getting current user...")
222+
resp, err := mcpClient.CallTool(ctx, getMeRequest)
223+
require.NoError(t, err, "expected to call 'get_me' tool successfully")
224+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
225+
226+
require.False(t, resp.IsError, "expected result not to be an error")
227+
require.Len(t, resp.Content, 1, "expected content to have one item")
228+
229+
textContent, ok := resp.Content[0].(mcp.TextContent)
230+
require.True(t, ok, "expected content to be of type TextContent")
231+
232+
var trimmedGetMeText struct {
233+
Login string `json:"login"`
234+
}
235+
err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
236+
require.NoError(t, err, "expected to unmarshal text content successfully")
237+
238+
currentOwner := trimmedGetMeText.Login
239+
240+
// Then create a repository with a README (via autoInit)
241+
repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
242+
createRepoRequest := mcp.CallToolRequest{}
243+
createRepoRequest.Params.Name = "create_repository"
244+
createRepoRequest.Params.Arguments = map[string]any{
245+
"name": repoName,
246+
"private": true,
247+
"autoInit": true,
248+
}
249+
250+
t.Logf("Creating repository %s/%s...", currentOwner, repoName)
251+
_, err = mcpClient.CallTool(ctx, createRepoRequest)
252+
require.NoError(t, err, "expected to call 'get_me' tool successfully")
253+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
254+
255+
// Cleanup the repository after the test
256+
t.Cleanup(func() {
257+
// MCP Server doesn't support deletions, but we can use the GitHub Client
258+
ghClient := github.NewClient(nil).WithAuthToken(getE2EToken(t))
259+
t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
260+
_, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
261+
require.NoError(t, err, "expected to delete repository successfully")
262+
})
263+
264+
// Create a branch on which to create a new commit
265+
createBranchRequest := mcp.CallToolRequest{}
266+
createBranchRequest.Params.Name = "create_branch"
267+
createBranchRequest.Params.Arguments = map[string]any{
268+
"owner": currentOwner,
269+
"repo": repoName,
270+
"branch": "test-branch",
271+
"from_branch": "main",
272+
}
273+
274+
t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
275+
resp, err = mcpClient.CallTool(ctx, createBranchRequest)
276+
require.NoError(t, err, "expected to call 'create_branch' tool successfully")
277+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
278+
279+
// Create a commit with a new file
280+
commitRequest := mcp.CallToolRequest{}
281+
commitRequest.Params.Name = "create_or_update_file"
282+
commitRequest.Params.Arguments = map[string]any{
283+
"owner": currentOwner,
284+
"repo": repoName,
285+
"path": "test-file.txt",
286+
"content": fmt.Sprintf("Created by e2e test %s", t.Name()),
287+
"message": "Add test file",
288+
"branch": "test-branch",
289+
}
290+
291+
t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
292+
resp, err = mcpClient.CallTool(ctx, commitRequest)
293+
require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
294+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
295+
296+
textContent, ok = resp.Content[0].(mcp.TextContent)
297+
require.True(t, ok, "expected content to be of type TextContent")
298+
299+
var trimmedCommitText struct {
300+
SHA string `json:"sha"`
301+
}
302+
err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText)
303+
require.NoError(t, err, "expected to unmarshal text content successfully")
304+
commitId := trimmedCommitText.SHA
305+
306+
// Create a pull request
307+
prRequest := mcp.CallToolRequest{}
308+
prRequest.Params.Name = "create_pull_request"
309+
prRequest.Params.Arguments = map[string]any{
310+
"owner": currentOwner,
311+
"repo": repoName,
312+
"title": "Test PR",
313+
"body": "This is a test PR",
314+
"head": "test-branch",
315+
"base": "main",
316+
"commitId": commitId,
317+
}
318+
319+
t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
320+
resp, err = mcpClient.CallTool(ctx, prRequest)
321+
require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
322+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
323+
324+
// Create a review for the pull request, but we can't approve it
325+
// because the current owner also owns the PR.
326+
createPendingPullRequestReviewRequest := mcp.CallToolRequest{}
327+
createPendingPullRequestReviewRequest.Params.Name = "mvp_create_pending_pull_request_review"
328+
createPendingPullRequestReviewRequest.Params.Arguments = map[string]any{
329+
"owner": currentOwner,
330+
"repo": repoName,
331+
"pullNumber": 1,
332+
}
333+
334+
t.Logf("Creating pending review for pull request in %s/%s...", currentOwner, repoName)
335+
resp, err = mcpClient.CallTool(ctx, createPendingPullRequestReviewRequest)
336+
require.NoError(t, err, "expected to call 'mvp_create_pending_pull_request_review' tool successfully")
337+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
338+
339+
textContent, ok = resp.Content[0].(mcp.TextContent)
340+
require.True(t, ok, "expected content to be of type TextContent")
341+
342+
var trimmedReviewRequestResponse struct {
343+
PullRequestReviewID string `json:"pullRequestReviewID"`
344+
}
345+
err = json.Unmarshal([]byte(textContent.Text), &trimmedReviewRequestResponse)
346+
require.NoError(t, err, "expected to unmarshal text content successfully")
347+
pullRequestReviewId := trimmedReviewRequestResponse.PullRequestReviewID
348+
349+
// Add a review comment
350+
addReviewCommentRequest := mcp.CallToolRequest{}
351+
addReviewCommentRequest.Params.Name = "mvp_add_pull_request_review_comment"
352+
addReviewCommentRequest.Params.Arguments = map[string]any{
353+
"path": "test-file.txt",
354+
"body": "Very nice!",
355+
"line": 1,
356+
"pullRequestReviewID": pullRequestReviewId,
357+
}
358+
359+
t.Logf("Adding review comment to pull request in %s/%s...", currentOwner, repoName)
360+
resp, err = mcpClient.CallTool(ctx, addReviewCommentRequest)
361+
require.NoError(t, err, "expected to call 'add_pull_request_review_comment' tool successfully")
362+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
363+
364+
// Submit the review
365+
submitReviewRequest := mcp.CallToolRequest{}
366+
submitReviewRequest.Params.Name = "mvp_submit_pull_request_review"
367+
submitReviewRequest.Params.Arguments = map[string]any{
368+
"event": "COMMENT", // the only event we can use as the creator of the PR
369+
"body": "Needs improvement!",
370+
"pullRequestReviewID": pullRequestReviewId,
371+
}
372+
373+
t.Logf("Submitting review for pull request in %s/%s...", currentOwner, repoName)
374+
resp, err = mcpClient.CallTool(ctx, submitReviewRequest)
375+
require.NoError(t, err, "expected to call 'mvp_submit_pull_request_review' tool successfully")
376+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
377+
378+
// Finally, get the review and see that it has been created
379+
getPullRequestsReview := mcp.CallToolRequest{}
380+
getPullRequestsReview.Params.Name = "get_pull_request_reviews"
381+
getPullRequestsReview.Params.Arguments = map[string]any{
382+
"owner": currentOwner,
383+
"repo": repoName,
384+
"pullNumber": 1,
385+
}
386+
387+
t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
388+
resp, err = mcpClient.CallTool(ctx, getPullRequestsReview)
389+
require.NoError(t, err, "expected to call 'get_pull_request_reviews' tool successfully")
390+
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
391+
392+
textContent, ok = resp.Content[0].(mcp.TextContent)
393+
require.True(t, ok, "expected content to be of type TextContent")
394+
395+
var reviews []struct {
396+
NodeID string `json:"node_id"`
397+
}
398+
err = json.Unmarshal([]byte(textContent.Text), &reviews)
399+
require.NoError(t, err, "expected to unmarshal text content successfully")
400+
401+
// Check our review is the only one in the list
402+
require.Len(t, reviews, 1, "expected to find one review")
403+
require.Equal(t, pullRequestReviewId, reviews[0].NodeID, "expected to find our review in the list")
404+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ require (
2626
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
2727
github.com/rogpeppe/go-internal v1.13.1 // indirect
2828
github.com/sagikazarmark/locafero v0.9.0 // indirect
29+
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 // indirect
30+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
2931
github.com/sourcegraph/conc v0.3.0 // indirect
3032
github.com/spf13/afero v1.14.0 // indirect
3133
github.com/spf13/cast v1.7.1 // indirect
3234
github.com/spf13/pflag v1.0.6 // indirect
3335
github.com/subosito/gotenv v1.6.0 // indirect
3436
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
3537
go.uber.org/multierr v1.11.0 // indirect
38+
golang.org/x/oauth2 v0.29.0
3639
golang.org/x/sys v0.31.0 // indirect
3740
golang.org/x/text v0.23.0 // indirect
3841
golang.org/x/time v0.5.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
4545
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
4646
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
4747
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
48+
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M=
49+
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
50+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
51+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
4852
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
4953
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
5054
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -69,6 +73,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
6973
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
7074
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
7175
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
76+
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
77+
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
7278
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7379
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
7480
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

0 commit comments

Comments
 (0)