Skip to content

Commit eca853b

Browse files
williammartinSamMorrowDrums
authored andcommitted
Split PR review creation, commenting, submission and deletion
1 parent 50043c3 commit eca853b

19 files changed

+2936
-946
lines changed

e2e/e2e_test.go

Lines changed: 633 additions & 25 deletions
Large diffs are not rendered by default.

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ require (
1515
require (
1616
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
1717
github.com/fsnotify/fsnotify v1.8.0 // indirect
18-
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
18+
github.com/go-viper/mapstructure/v2 v2.2.1
1919
github.com/google/go-github/v71 v71.0.0 // indirect
2020
github.com/google/go-querystring v1.1.0 // indirect
2121
github.com/google/uuid v1.6.0 // indirect
@@ -25,13 +25,16 @@ require (
2525
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
2626
github.com/rogpeppe/go-internal v1.13.1 // indirect
2727
github.com/sagikazarmark/locafero v0.9.0 // indirect
28+
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
29+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
2830
github.com/sourcegraph/conc v0.3.0 // indirect
2931
github.com/spf13/afero v1.14.0 // indirect
3032
github.com/spf13/cast v1.7.1 // indirect
3133
github.com/spf13/pflag v1.0.6 // indirect
3234
github.com/subosito/gotenv v1.6.0 // indirect
3335
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
3436
go.uber.org/multierr v1.11.0 // indirect
37+
golang.org/x/oauth2 v0.29.0 // indirect
3538
golang.org/x/sys v0.31.0 // indirect
3639
golang.org/x/text v0.23.0 // indirect
3740
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=

internal/ghmcp/server.go

Lines changed: 176 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import (
55
"fmt"
66
"io"
77
"log"
8+
"net/http"
9+
"net/url"
810
"os"
911
"os/signal"
12+
"strings"
1013
"syscall"
1114

1215
"github.com/github/github-mcp-server/pkg/github"
@@ -15,6 +18,7 @@ import (
1518
gogithub "github.com/google/go-github/v69/github"
1619
"github.com/mark3labs/mcp-go/mcp"
1720
"github.com/mark3labs/mcp-go/server"
21+
"github.com/shurcooL/githubv4"
1822
"github.com/sirupsen/logrus"
1923
)
2024

@@ -44,25 +48,43 @@ type MCPServerConfig struct {
4448
}
4549

4650
func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
47-
ghClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
48-
ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
49-
50-
if cfg.Host != "" {
51-
var err error
52-
ghClient, err = ghClient.WithEnterpriseURLs(cfg.Host, cfg.Host)
53-
if err != nil {
54-
return nil, fmt.Errorf("failed to create GitHub client with host: %w", err)
55-
}
51+
apiHost, err := parseAPIHost(cfg.Host)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to parse API host: %w", err)
5654
}
5755

56+
// Construct our REST client
57+
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
58+
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
59+
restClient.BaseURL = apiHost.baseRESTURL
60+
restClient.UploadURL = apiHost.uploadURL
61+
62+
// Construct our GraphQL client
63+
// We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already
64+
// did the necessary API host parsing so that github.com will return the correct URL anyway.
65+
gqlHTTPClient := &http.Client{
66+
Transport: &bearerAuthTransport{
67+
transport: http.DefaultTransport,
68+
token: cfg.Token,
69+
},
70+
} // We're going to wrap the Transport later in beforeInit
71+
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
72+
5873
// When a client send an initialize request, update the user agent to include the client info.
5974
beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
60-
ghClient.UserAgent = fmt.Sprintf(
75+
userAgent := fmt.Sprintf(
6176
"github-mcp-server/%s (%s/%s)",
6277
cfg.Version,
6378
message.Params.ClientInfo.Name,
6479
message.Params.ClientInfo.Version,
6580
)
81+
82+
restClient.UserAgent = userAgent
83+
84+
gqlHTTPClient.Transport = &userAgentTransport{
85+
transport: gqlHTTPClient.Transport,
86+
agent: userAgent,
87+
}
6688
}
6789

6890
hooks := &server.Hooks{
@@ -83,14 +105,19 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
83105
}
84106

85107
getClient := func(_ context.Context) (*gogithub.Client, error) {
86-
return ghClient, nil // closing over client
108+
return restClient, nil // closing over client
109+
}
110+
111+
getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
112+
return gqlClient, nil // closing over client
87113
}
88114

89115
// Create default toolsets
90116
toolsets, err := github.InitToolsets(
91117
enabledToolsets,
92118
cfg.ReadOnly,
93119
getClient,
120+
getGQLClient,
94121
cfg.Translator,
95122
)
96123
if err != nil {
@@ -213,3 +240,141 @@ func RunStdioServer(cfg StdioServerConfig) error {
213240

214241
return nil
215242
}
243+
244+
type apiHost struct {
245+
baseRESTURL *url.URL
246+
graphqlURL *url.URL
247+
uploadURL *url.URL
248+
}
249+
250+
func newDotcomHost() (apiHost, error) {
251+
baseRestURL, err := url.Parse("https://api.github.com/")
252+
if err != nil {
253+
return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
254+
}
255+
256+
gqlURL, err := url.Parse("https://api.github.com/graphql")
257+
if err != nil {
258+
return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
259+
}
260+
261+
uploadURL, err := url.Parse("https://uploads.github.com")
262+
if err != nil {
263+
return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
264+
}
265+
266+
return apiHost{
267+
baseRESTURL: baseRestURL,
268+
graphqlURL: gqlURL,
269+
uploadURL: uploadURL,
270+
}, nil
271+
}
272+
273+
func newGHECHost(hostname string) (apiHost, error) {
274+
u, err := url.Parse(hostname)
275+
if err != nil {
276+
return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
277+
}
278+
279+
// Unsecured GHEC would be an error
280+
if u.Scheme == "http" {
281+
return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
282+
}
283+
284+
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
285+
if err != nil {
286+
return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
287+
}
288+
289+
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
290+
if err != nil {
291+
return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
292+
}
293+
294+
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
295+
if err != nil {
296+
return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
297+
}
298+
299+
return apiHost{
300+
baseRESTURL: restURL,
301+
graphqlURL: gqlURL,
302+
uploadURL: uploadURL,
303+
}, nil
304+
}
305+
306+
func newGHESHost(hostname string) (apiHost, error) {
307+
u, err := url.Parse(hostname)
308+
if err != nil {
309+
return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
310+
}
311+
312+
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
313+
if err != nil {
314+
return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
315+
}
316+
317+
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
318+
if err != nil {
319+
return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
320+
}
321+
322+
uploadURL, err := url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
323+
if err != nil {
324+
return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
325+
}
326+
327+
return apiHost{
328+
baseRESTURL: restURL,
329+
graphqlURL: gqlURL,
330+
uploadURL: uploadURL,
331+
}, nil
332+
}
333+
334+
// Note that this does not handle ports yet, so development environments are out.
335+
func parseAPIHost(s string) (apiHost, error) {
336+
if s == "" {
337+
return newDotcomHost()
338+
}
339+
340+
u, err := url.Parse(s)
341+
if err != nil {
342+
return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
343+
}
344+
345+
if u.Scheme == "" {
346+
return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
347+
}
348+
349+
if strings.HasSuffix(u.Hostname(), "github.com") {
350+
return newDotcomHost()
351+
}
352+
353+
if strings.HasSuffix(u.Hostname(), "ghe.com") {
354+
return newGHECHost(s)
355+
}
356+
357+
return newGHESHost(s)
358+
}
359+
360+
type userAgentTransport struct {
361+
transport http.RoundTripper
362+
agent string
363+
}
364+
365+
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
366+
req = req.Clone(req.Context())
367+
req.Header.Set("User-Agent", t.agent)
368+
return t.transport.RoundTrip(req)
369+
}
370+
371+
type bearerAuthTransport struct {
372+
transport http.RoundTripper
373+
token string
374+
}
375+
376+
func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
377+
req = req.Clone(req.Context())
378+
req.Header.Set("Authorization", "Bearer "+t.token)
379+
return t.transport.RoundTrip(req)
380+
}

0 commit comments

Comments
 (0)