From 525951397d96a7892300dab597cf7253974c7a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Tue, 12 May 2026 12:55:04 +0300 Subject: [PATCH 01/17] Add ifc label for list_issues tool (#2453) * Add ifc label for list_issues tool Emits an IFC SecurityLabel on the list_issues tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432. Public repositories are labelled PublicUntrusted; private repositories are labelled PrivateUntrusted with the repository owner as a placeholder reader (full collaborator enumeration is intentionally deferred to a follow-up shared helper). A new IsPrivate field is added to the ListIssues GraphQL query types so visibility is available without a second round trip. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. * list_issues: populate readers with repo collaborators Addresses Joanna's review feedback: for private repositories, populate the IFC confidentiality reader set with the repository's collaborator logins instead of the [owner] placeholder. Adds an exported FetchRepoCollaborators helper in pkg/github/repositories.go that paginates through Repositories.ListCollaborators. Mirrors the helper in github-mcp-server-remote (without the cache for now; cache can land in a follow-up). The lookup is invoked only for private repos under InsidersMode; if it fails we fall back to [owner] so the reader set is never empty for a private repo. --- pkg/github/helper_test.go | 1 + pkg/github/issues.go | 50 +++++++++-- pkg/github/issues_test.go | 173 ++++++++++++++++++++++++++++++++++++- pkg/github/repositories.go | 28 ++++++ pkg/ifc/ifc.go | 50 ++++++++++- 5 files changed, 294 insertions(+), 8 deletions(-) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f37..67a05fd6c0 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -31,6 +31,7 @@ const ( GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}" GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches" GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags" + GetReposCollaboratorsByOwnerByRepo = "GET /repos/{owner}/{repo}/collaborators" GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits" GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}" GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 81161626bb..e3e1f6b223 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -10,6 +10,7 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" @@ -130,6 +131,7 @@ type IssueFragment struct { // Common interface for all issue query types type IssueQueryResult interface { GetIssueFragment() IssueQueryFragment + GetIsPrivate() bool } type IssueQueryFragment struct { @@ -146,28 +148,32 @@ type IssueQueryFragment struct { // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. type ListIssuesQueryWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. type ListIssuesQueryTypeWithLabelsWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -176,18 +182,28 @@ func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabels) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryWithSince) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + func getIssueQueryType(hasLabels bool, hasSince bool) any { switch { case hasLabels && hasSince: @@ -1568,11 +1584,35 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } var resp MinimalIssuesResponse + var isPrivate bool if queryResult, ok := issueQuery.(IssueQueryResult); ok { resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) + isPrivate = queryResult.GetIsPrivate() } - return MarshalledTextResult(resp), nil, nil + result := MarshalledTextResult(resp) + if deps.GetFlags(ctx).InsidersMode { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + var readers []string + if isPrivate { + restClient, err := deps.GetClient(ctx) + if err == nil { + if collaborators, err := FetchRepoCollaborators(ctx, restClient, owner, repo); err == nil { + readers = collaborators + } + } + // Fall back to the repository owner so the reader set is + // never empty for a private repository even if the + // collaborators lookup fails. + if len(readers) == 0 { + readers = []string{owner} + } + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate, readers) + } + return result, nil, nil }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 9c20824746..49ce2dde9c 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1117,6 +1117,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1132,6 +1133,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1147,6 +1149,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 1, }, + "isPrivate": false, }, }) @@ -1272,8 +1275,8 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1349,6 +1352,172 @@ func Test_ListIssues(t *testing.T) { } } +func Test_ListIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + makeResponse := func(isPrivate bool) githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": isPrivate, + }, + }) + } + + query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + vars := map[string]any{ + "owner": "octocat", + "repo": "hello", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + reqParams := map[string]any{"owner": "octocat", "repo": "hello"} + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + confList, ok := ifcMap["confidentiality"].([]any) + require.True(t, ok, "confidentiality should be a list") + require.Len(t, confList, 1) + assert.Equal(t, "public", confList[0]) + }) + + t.Run("insiders mode enabled on private repo emits private untrusted label with collaborators", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ + {Login: github.Ptr("octocat")}, + {Login: github.Ptr("alice")}, + {Login: github.Ptr("bob")}, + }), + })) + deps := BaseDeps{ + Client: restClient, + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + confList, ok := ifcMap["confidentiality"].([]any) + require.True(t, ok, "confidentiality should be a list") + assert.Equal(t, []any{"octocat", "alice", "bob"}, confList) + }) + + t.Run("insiders mode enabled on private repo falls back to owner when collaborators lookup fails", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + })) + deps := BaseDeps{ + Client: restClient, + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcJSON, err := json.Marshal(result.Meta["ifc"]) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, []any{"octocat"}, ifcMap["confidentiality"]) + }) +} + func Test_UpdateIssue(t *testing.T) { // Verify tool definition serverTool := IssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 0ebacc6668..c946d6308e 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -653,6 +653,34 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// FetchRepoCollaborators returns the login names of all collaborators on a +// repository. It is provided as a shared helper for IFC label computation so +// tools can populate the reader set for private repositories. The full list +// is fetched eagerly via pagination; callers are expected to invoke this only +// when needed (e.g. private repos under InsidersMode). +func FetchRepoCollaborators(ctx context.Context, client *github.Client, owner, repo string) ([]string, error) { + opts := &github.ListCollaboratorsOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + var logins []string + for { + page, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + for _, c := range page { + if login := c.GetLogin(); login != "" { + logins = append(logins, login) + } + } + if resp == nil || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return logins, nil +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index cf0d72114f..43b39fc450 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -21,9 +21,57 @@ type SecurityLabel struct { Confidentiality []Confidentiality `json:"confidentiality"` } -func LabelGetMe() SecurityLabel { +// PublicTrusted returns a label for trusted, publicly readable data. +func PublicTrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityTrusted, Confidentiality: []Confidentiality{ConfidentialityPublic}, } } + +// PublicUntrusted returns a label for untrusted, publicly readable data. +func PublicUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: []Confidentiality{ConfidentialityPublic}, + } +} + +// PrivateTrusted returns a label for trusted data restricted to the given readers. +func PrivateTrusted(readers []string) SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: toConfidentiality(readers), + } +} + +// PrivateUntrusted returns a label for untrusted data restricted to the given readers. +func PrivateUntrusted(readers []string) SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: toConfidentiality(readers), + } +} + +func toConfidentiality(readers []string) []Confidentiality { + out := make([]Confidentiality, len(readers)) + for i, r := range readers { + out[i] = Confidentiality(r) + } + return out +} + +func LabelGetMe() SecurityLabel { + return PublicTrusted() +} + +// LabelListIssues returns the IFC label for a list_issues result. +// Public repositories are universally readable; private repositories are +// restricted to the provided reader set (typically repository collaborators). +// Issue contents are attacker-controllable, so integrity is always untrusted. +func LabelListIssues(isPrivate bool, readers []string) SecurityLabel { + if isPrivate { + return PrivateUntrusted(readers) + } + return PublicUntrusted() +} From 0cdcd4aa7314d916f31114ce7fd11b6e45a332be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Tue, 12 May 2026 17:42:15 +0300 Subject: [PATCH 02/17] Add ifc label for get_file_contents tool (#2454) * Add ifc label for get_file_contents tool Emits an IFC SecurityLabel on the get_file_contents tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in Public repositories are labelled PublicUntrusted (anyone can author file content via pull requests). Private repositories are labelled PrivateTrusted with the repository owner as a placeholder reader, since only collaborators can land changes there. Full collaborator enumeration is intentionally deferred to a follow-up shared helper. A new exported FetchRepoIsPrivate helper wraps Repositories.Get for visibility lookups; it is invoked lazily and only when InsidersMode is on, so non-insiders pay no extra round trip. Visibility lookup failures skip the label rather than fail the user-facing call. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. * get_file_contents: address Copilot review findings - FetchRepoIsPrivate: tighten doc to 'returns whether a repository is private' and close the underlying *github.Response body. - attachIFC: skip emitting the ifc label when the repository visibility lookup fails, instead of falling through to PublicUntrusted (which would mislabel a private or unknown-visibility repo as public). The failure is no longer cached so a subsequent return path can retry. - Add a test asserting the tool still succeeds and omits result.Meta ["ifc"] when the visibility lookup returns 500. --- pkg/github/repositories.go | 70 +++++++++++++-- pkg/github/repositories_test.go | 152 ++++++++++++++++++++++++++++++++ pkg/ifc/ifc.go | 11 +++ 3 files changed, 226 insertions(+), 7 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c946d6308e..507677ee57 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -10,6 +10,7 @@ import ( "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/scopes" @@ -681,6 +682,20 @@ func FetchRepoCollaborators(ctx context.Context, client *github.Client, owner, r return logins, nil } +// FetchRepoIsPrivate returns whether a repository is private. It is a thin +// wrapper around the GitHub Repositories.Get endpoint provided as a shared +// helper for IFC label computation across tools. +func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) { + r, resp, err := client.Repositories.Get(ctx, owner, repo) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return false, err + } + return r.GetPrivate(), nil +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -753,6 +768,46 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get GitHub client"), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // InsidersMode is enabled. The visibility and (for private + // repositories) collaborators lookups are performed lazily on + // first use. If the visibility lookup fails we skip the label + // rather than misclassify the result; the failure is not cached + // so a later return path can retry. If only the collaborators + // lookup fails for a private repo we fall back to the owner so + // the reader set is never empty. + var ( + ifcLabelKnown bool + ifcIsPrivate bool + ifcReaders []string + ) + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + return r + } + if !ifcLabelKnown { + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + ifcIsPrivate = isPrivate + if ifcIsPrivate { + if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil { + ifcReaders = collaborators + } + if len(ifcReaders) == 0 { + ifcReaders = []string{owner} + } + } + ifcLabelKnown = true + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate, ifcReaders) + return r + } + rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil @@ -774,7 +829,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // The path does not point to a file or directory. // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { - return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + return attachIFC(res), data, err } if fileContent != nil && fileContent.SHA != nil { @@ -804,7 +860,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: "", MIMEType: "text/plain", } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // For files >= 1MB, return a ResourceLink instead of content @@ -817,10 +873,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Title: fmt.Sprintf("File: %s", path), Size: &size, } - return utils.NewToolResultResourceLink( + return attachIFC(utils.NewToolResultResourceLink( fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s", path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote), - resourceLink), nil, nil + resourceLink)), nil, nil } // For files < 1MB, get content directly from Contents API @@ -848,7 +904,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: content, MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // Binary content - encode as base64 blob @@ -858,14 +914,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Blob: []byte(blobContent), MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory r, err := json.Marshal(dirContent) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return attachIFC(utils.NewToolResultText(string(r))), nil, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index c21709dad4..ceaa959019 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -477,6 +477,158 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := GetFileContents(translations.NullTranslationHelper) + + mockRawContent := []byte("hello") + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "default_branch": "main", + "private": isPrivate, + }), + GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ + {Login: github.Ptr("octocat")}, + {Login: github.Ptr("alice")}, + }), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + } + + reqParams := map[string]any{ + "owner": "octocat", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", + } + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false)), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + confList, ok := ifcMap["confidentiality"].([]any) + require.True(t, ok, "confidentiality should be a list") + require.Len(t, confList, 1) + assert.Equal(t, "public", confList[0]) + }) + + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(true)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + confList, ok := ifcMap["confidentiality"].([]any) + require.True(t, ok, "confidentiality should be a list") + assert.Equal(t, []any{"octocat", "alice"}, confList) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + deps := BaseDeps{ + Client: github.NewClient(mockedClient), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index 43b39fc450..c0926d8a39 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -75,3 +75,14 @@ func LabelListIssues(isPrivate bool, readers []string) SecurityLabel { } return PublicUntrusted() } + +// LabelGetFileContents returns the IFC label for a get_file_contents result. +// Public repository file contents may be authored by anyone via pull requests +// and are therefore untrusted. In private repositories only collaborators can +// land changes, so contents are treated as trusted. +func LabelGetFileContents(isPrivate bool, readers []string) SecurityLabel { + if isPrivate { + return PrivateTrusted(readers) + } + return PublicUntrusted() +} From e2ff518196e020b57765baa52b9e04529e1f796e Mon Sep 17 00:00:00 2001 From: Ross Tarrant Date: Tue, 12 May 2026 17:34:11 +0100 Subject: [PATCH 03/17] fix: add missing pagination on get_reviews (#2367) * Add pagination support to pull request reviews and update descriptions * Add pagination support to GetPullRequestReviews test case * Remove unintentional whitespace Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu --- README.md | 2 +- .../__toolsnaps__/pull_request_read.snap | 2 +- pkg/github/pullrequests.go | 11 +++--- pkg/github/pullrequests_test.go | 34 ++++++++++++++++--- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5f9baa780e..a12f1531ac 100644 --- a/README.md +++ b/README.md @@ -1100,7 +1100,7 @@ The following sets of tools are available: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 9bb14cc076..26b4f14ca9 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9c2a098755..0065b25a92 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -36,7 +36,7 @@ Possible options: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, @@ -124,7 +124,7 @@ Possible options: result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) return result, nil, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber) + result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) return result, nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) @@ -478,14 +478,17 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil } -func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) - reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) + reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request reviews", diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 4f0ec9493b..36a0207cc0 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2050,13 +2050,39 @@ func Test_GetPullRequestReviews(t *testing.T) { expectError: false, expectedReviews: mockReviews, }, + { + name: "successful reviews fetch with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockReviews), + ), + }), + requestArgs: map[string]any{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedReviews: mockReviews, + }, { name: "reviews fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsReviewsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), + GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), }), requestArgs: map[string]any{ "method": "get_reviews", From 59fa9a73ba79714674bf9ff794858147d1960099 Mon Sep 17 00:00:00 2001 From: Alon Dahari Date: Wed, 13 May 2026 11:47:41 +0100 Subject: [PATCH 04/17] Add optional `rationale` parameter to `update_issue_type` tool (#2458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add optional rationale parameter to update_issue_type tool Add an optional `rationale` string parameter (max 280 chars) to the `update_issue_type` MCP tool. When provided, the type is sent as an object `{"name": "...", "rationale": "..."}` to the REST API, enabling agents to explain their classification decisions. When omitted, existing behavior is preserved (type sent as a plain string). This supports the agent rationale experiment for type mutations. The parameter is always visible in the schema — the API gracefully ignores the rationale when the server-side feature flag is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Validate issue type rationale input * Format issue type rationale tests --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Adam Holt --- .../__toolsnaps__/update_issue_type.snap | 5 + pkg/github/granular_tools_test.go | 114 +++++++++++++--- pkg/github/issues_granular.go | 128 ++++++++++++++++-- 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index 6354a42e16..237603a6ed 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -20,6 +20,11 @@ "description": "Repository owner (username or organization)", "type": "string" }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + "maxLength": 280, + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 6623894e43..37a718f373 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -3,6 +3,7 @@ package github import ( "context" "net/http" + "strings" "testing" "github.com/github/github-mcp-server/internal/githubv4mock" @@ -304,24 +305,103 @@ func TestGranularUpdateIssueMilestone(t *testing.T) { } func TestGranularUpdateIssueType(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ - "type": "bug", - }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), - })) - deps := BaseDeps{Client: client} - serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "type only", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + }, + expectedReq: map[string]any{ + "type": "bug", + }, + }, + { + name: "type with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": " This issue requests a new capability ", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "This issue requests a new capability", + }, + }, + }, + } - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(1), - "issue_type": "bug", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - assert.False(t, result.IsError) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale wrong type", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": float64(123), + }, + expectedErrText: "parameter rationale is not of type string, is float64", + }, + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": strings.Repeat("a", 281), + }, + expectedErrText: "parameter rationale must be 280 characters or less", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: gogithub.NewClient(MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } } func TestGranularUpdateIssueState(t *testing.T) { diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index fe3b4bcc9b..973032c4ab 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -309,27 +309,131 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor ) } +// issueTypeWithRationale represents the object form of the issue type field, +// allowing a rationale to be sent alongside the type name. +type issueTypeWithRationale struct { + Value string `json:"value"` + Rationale string `json:"rationale"` +} + +// issueTypeUpdateRequest is a custom request body for updating an issue type +// with an optional rationale, using the object form that the REST API accepts. +type issueTypeUpdateRequest struct { + Type issueTypeWithRationale `json:"type"` +} + // GranularUpdateIssueType creates a tool to update an issue's type. func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool { - return issueUpdateTool(t, - "update_issue_type", - "Update the type of an existing issue (e.g. 'bug', 'feature').", - "Update Issue Type", - map[string]*jsonschema.Schema{ - "issue_type": { - Type: "string", - Description: "The issue type to set", + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_type", + Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "issue_type": { + Type: "string", + Description: "The issue type to set", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + MaxLength: jsonschema.Ptr(280), + }, + }, + Required: []string{"owner", "repo", "issue_number", "issue_type"}, }, }, - []string{"issue_type"}, - func(args map[string]any) (*github.IssueRequest, error) { + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } issueType, err := RequiredParam[string](args, "issue_type") if err != nil { - return nil, err + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if rationale != "" { + body = &issueTypeUpdateRequest{ + Type: issueTypeWithRationale{ + Value: issueType, + Rationale: rationale, + }, + } + } else { + body = &github.IssueRequest{Type: &issueType} + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest("PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } - return &github.IssueRequest{Type: &issueType}, nil + + issue := &github.Issue{} + resp, err := client.Do(ctx, req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil }, ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st } // GranularUpdateIssueState creates a tool to update an issue's state. From 9ad99c52c89b9f3efab75a6e672c0eb450375bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Wed, 13 May 2026 15:45:14 +0300 Subject: [PATCH 05/17] Add ifc label for search_issues tool (#2456) * Add ifc label for search_issues tool Emits an IFC SecurityLabel on the search_issues tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432, list_issues in #2453, and get_file_contents in #2454. Search results may span multiple repositories, so the label is the IFC join of the per-repository labels: - Integrity is always untrusted (issues are user-authored). - If any matched repository is public, the joined readers are ["public"] (the public side dominates the lub). - Otherwise the joined readers are the intersection of the collaborator sets across all matched private repositories. - Empty result sets are labelled public-untrusted (no data leaked). The shared searchHandler in search_utils.go gains an additive variadic 'searchOption' hook so SearchIssues can attach _meta.ifc without duplicating the search call. SearchPullRequests is unaffected; it does not pass any options. If any per-repository visibility or collaborators lookup fails the label is omitted entirely, consistent with get_file_contents, to avoid misclassifying the result. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. Note: this PR is chained on #2454 (gokhanarkan/fides-get-file-contents) because it depends on the FetchRepoIsPrivate and FetchRepoCollaborators helpers introduced there. GitHub will retarget the base to main once #2454 merges. * search_issues: address Copilot review findings - LabelSearchIssues now returns (SecurityLabel, bool); the bool is false when len(repoVisibilities) != len(readerSets), so callers can omit the label rather than emit one computed from inconsistent inputs. - searchIssuesIFCPostProcess no longer substitutes [owner] when the collaborators API returns an empty list. The substitution was inconsistent with the cross-repo intersection semantics: the owner could appear in another matched private repo's collaborator list and thereby widen the joined reader set incorrectly. Empty collaborator sets are now passed through unchanged. - Add a subtest exercising the collaborators-failure branch (500 on /repos/{owner}/{repo}/collaborators), asserting the tool still succeeds and result.Meta["ifc"] is absent. - Extend the LabelSearchIssues table tests with the slice-length mismatch case. Addresses the three Copilot findings on #2456. * search_issues: flip IFC join to intersection (private wins) Address Joanna's review feedback on #2456: a reader of a multi-repo result must be authorised to read every matched private repository, so the IFC join is the meet (intersection over private repos) rather than the join. Public matches contribute the universe set and drop out of the intersection without shrinking it. - LabelSearchIssues: collect only the private reader sets, then intersect. Empty result and all-public remain public-untrusted. - TestLabelSearchIssues: flip the mixed public+private expectation and add a 'two private + one public' case to lock in the new semantics. - Test_SearchIssues_IFC_InsidersMode: mixed subtest now expects the private repo's reader set instead of public. --- pkg/github/issues.go | 108 ++++++++++++++++- pkg/github/issues_test.go | 238 ++++++++++++++++++++++++++++++++++++- pkg/github/search_utils.go | 28 ++++- pkg/ifc/ifc.go | 77 ++++++++++++ pkg/ifc/ifc_test.go | 98 +++++++++++++++ 5 files changed, 544 insertions(+), 5 deletions(-) create mode 100644 pkg/ifc/ifc_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index e3e1f6b223..c89c38d137 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -543,7 +543,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, } return utils.NewToolResultText(string(out)), nil - } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. @@ -838,7 +837,6 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo } return utils.NewToolResultText(string(r)), nil - } func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { @@ -978,11 +976,115 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") + var options []searchOption + if deps.GetFlags(ctx).InsidersMode { + options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) + } + result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...) return result, nil, err }) } +// searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the +// IFC label for a search_issues result. It looks up the visibility (and, for +// private repos, collaborators) of every repository represented in the search +// payload and joins the labels via ifc.LabelSearchIssues. If any per-repo +// lookup fails the label is omitted to avoid misclassifying the result. +func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn { + return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError || result == nil { + return + } + + client, err := deps.GetClient(ctx) + if err != nil { + return + } + + uniqueRepos := uniqueSearchIssuesRepos(result) + visibilities := make([]bool, 0, len(uniqueRepos)) + readerSets := make([][]string, 0, len(uniqueRepos)) + for _, r := range uniqueRepos { + isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo) + if err != nil { + return + } + visibilities = append(visibilities, isPrivate) + if !isPrivate { + readerSets = append(readerSets, nil) + continue + } + collaborators, err := FetchRepoCollaborators(ctx, client, r.owner, r.repo) + if err != nil { + return + } + // Preserve an empty collaborator set as-is. Substituting the + // owner here would corrupt the cross-repo intersection (the + // owner could appear in another repo's collaborator list and + // widen the joined reader set incorrectly). + readerSets = append(readerSets, collaborators) + } + + label, ok := ifc.LabelSearchIssues(visibilities, readerSets) + if !ok { + return + } + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = label + } +} + +type searchIssuesRepoRef struct { + owner string + repo string +} + +// uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the +// search result, preserving order of first appearance and deduplicating. +func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef { + if result == nil { + return nil + } + seen := make(map[string]struct{}) + var out []searchIssuesRepoRef + for _, issue := range result.Issues { + if issue == nil { + continue + } + owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL()) + if !ok { + continue + } + key := owner + "/" + repo + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, searchIssuesRepoRef{owner: owner, repo: repo}) + } + return out +} + +// parseRepositoryURL extracts the owner and repo from a GitHub API repository +// URL of the form https://api.github.com/repos/{owner}/{repo}. +func parseRepositoryURL(repoURL string) (string, string, bool) { + if repoURL == "" { + return "", "", false + } + const marker = "/repos/" + idx := strings.LastIndex(repoURL, marker) + if idx < 0 { + return "", "", false + } + parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 49ce2dde9c..6ffc7f13c2 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -381,7 +381,6 @@ func Test_AddIssueComment(t *testing.T) { require.NoError(t, err) assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) - }) } } @@ -693,6 +692,243 @@ func Test_SearchIssues(t *testing.T) { } } +func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := SearchIssues(translations.NullTranslationHelper) + + makeIssue := func(owner, repo string, number int) *github.Issue { + return &github.Issue{ + Number: github.Ptr(number), + Title: github.Ptr("issue"), + State: github.Ptr("open"), + RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo), + User: &github.User{Login: github.Ptr("u")}, + } + } + + type repoFixture struct { + owner string + repo string + isPrivate bool + collaborators []string + repoStatus int + collaboratorsStatus int + } + + repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc { + repoByPath := map[string]repoFixture{} + for _, r := range repos { + repoByPath["/repos/"+r.owner+"/"+r.repo] = r + } + collaboratorsByPath := map[string]repoFixture{} + for _, r := range repos { + collaboratorsByPath["/repos/"+r.owner+"/"+r.repo+"/collaborators"] = r + } + return map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { + r, ok := repoByPath[req.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if r.repoStatus != 0 && r.repoStatus != http.StatusOK { + w.WriteHeader(r.repoStatus) + return + } + body, _ := json.Marshal(map[string]any{ + "name": r.repo, + "private": r.isPrivate, + }) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }, + GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { + r, ok := collaboratorsByPath[req.URL.Path] + if !ok { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + return + } + if r.collaboratorsStatus != 0 && r.collaboratorsStatus != http.StatusOK { + w.WriteHeader(r.collaboratorsStatus) + return + } + users := make([]*github.User, len(r.collaborators)) + for i, login := range r.collaborators { + users[i] = &github.User{Login: github.Ptr(login)} + } + body, _ := json.Marshal(users) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }, + } + } + + makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client { + handlers := repoHandlers(repos) + handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult) + return MockHTTPClientWithHandlers(handlers) + } + + reqParams := map[string]any{"query": "bug"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode enabled with single public repo emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode mixed public and private keeps the private readers", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ + makeIssue("octocat", "private-repo", 1), + makeIssue("octocat", "public-repo", 2), + }} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "private-repo", isPrivate: true, collaborators: []string{"alice"}}, + {owner: "octocat", repo: "public-repo"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"alice"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ + makeIssue("octocat", "repo-a", 1), + makeIssue("octocat", "repo-b", 2), + }} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}}, + {owner: "octocat", repo: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) + + t.Run("insiders mode skips ifc label when collaborators lookup fails", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "private-repo", 1)}} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "private-repo", isPrivate: true, collaboratorsStatus: http.StatusInternalServerError}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when collaborators lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when collaborators lookup fails") + } + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(searchResult, nil)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) +} + +func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { + t.Helper() + require.NotNil(t, ifcLabel, "ifc label should be present") + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + return ifcMap +} + func Test_CreateIssue(t *testing.T) { // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index c5502f6308..a0634d9795 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -37,13 +37,35 @@ func hasTypeFilter(query string) bool { return hasFilter(query, "type") } +// searchPostProcessFn is invoked after a successful search response, before +// the call result is returned. It may attach additional metadata (such as IFC +// labels) to the call result based on the search payload. +type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) + +type searchConfig struct { + postProcess searchPostProcessFn +} + +type searchOption func(*searchConfig) + +// withSearchPostProcess registers a callback invoked after a successful search +// response. The callback may mutate the call result (e.g. to attach _meta.ifc). +func withSearchPostProcess(fn searchPostProcessFn) searchOption { + return func(c *searchConfig) { c.postProcess = fn } +} + func searchHandler( ctx context.Context, getClient GetClientFn, args map[string]any, searchType string, errorPrefix string, + options ...searchOption, ) (*mcp.CallToolResult, error) { + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } query, err := RequiredParam[string](args, "query") if err != nil { return utils.NewToolResultError(err.Error()), nil @@ -113,5 +135,9 @@ func searchHandler( return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - return utils.NewToolResultText(string(r)), nil + callResult := utils.NewToolResultText(string(r)) + if cfg.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil } diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index c0926d8a39..61c81e255e 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -86,3 +86,80 @@ func LabelGetFileContents(isPrivate bool, readers []string) SecurityLabel { } return PublicUntrusted() } + +// LabelSearchIssues returns the IFC label for a search_issues result, joining +// per-repository labels across all matched repositories. +// +// Integrity is always untrusted because issue contents are user-authored. +// +// Confidentiality follows the IFC meet (greatest lower bound): the private +// side dominates because a reader of the combined result must be authorised +// to read every matched repository. Public repositories contribute the +// universe set and therefore drop out of the intersection without shrinking +// it. +// +// - If no repositories matched (empty result set), the label is +// public-untrusted because no repository data is leaked. +// - If every matched repository is public, the joined readers are +// ["public"]. +// - Otherwise the joined readers are the intersection of the reader sets +// of the matched private repositories only. +// +// repoVisibilities[i] reports whether the i-th matched repository is private; +// readerSets[i] is that repository's reader set (only consulted for private +// repos). The two slices must have the same length; the second return value +// is false when they do not, in which case the caller should omit the label +// rather than emit one computed from inconsistent inputs. +func LabelSearchIssues(repoVisibilities []bool, readerSets [][]string) (SecurityLabel, bool) { + if len(repoVisibilities) != len(readerSets) { + return SecurityLabel{}, false + } + if len(repoVisibilities) == 0 { + return PublicUntrusted(), true + } + privateReaderSets := make([][]string, 0, len(repoVisibilities)) + for i, isPrivate := range repoVisibilities { + if isPrivate { + privateReaderSets = append(privateReaderSets, readerSets[i]) + } + } + if len(privateReaderSets) == 0 { + return PublicUntrusted(), true + } + return PrivateUntrusted(intersectReaders(privateReaderSets)), true +} + +// intersectReaders returns the readers present in every set, preserving the +// order from the first set. Empty input yields nil. +func intersectReaders(sets [][]string) []string { + if len(sets) == 0 { + return nil + } + counts := make(map[string]int, len(sets[0])) + for _, login := range sets[0] { + if _, seen := counts[login]; seen { + continue + } + counts[login] = 1 + } + for _, set := range sets[1:] { + seen := make(map[string]struct{}, len(set)) + for _, login := range set { + if _, dup := seen[login]; dup { + continue + } + seen[login] = struct{}{} + if _, ok := counts[login]; ok { + counts[login]++ + } + } + } + out := make([]string, 0, len(counts)) + for _, login := range sets[0] { + if counts[login] == len(sets) { + out = append(out, login) + delete(counts, login) + } + } + return out +} diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go new file mode 100644 index 0000000000..644244a52e --- /dev/null +++ b/pkg/ifc/ifc_test.go @@ -0,0 +1,98 @@ +package ifc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelSearchIssues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool + readers [][]string + wantOK bool + wantIntegrity Integrity + wantConfidential []Confidentiality + }{ + { + name: "empty result is treated as public", + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{ConfidentialityPublic}, + }, + { + name: "single public repo", + visibilities: []bool{false}, + readers: [][]string{nil}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{ConfidentialityPublic}, + }, + { + name: "mixed public and private keeps the private reader set", + visibilities: []bool{true, false}, + readers: [][]string{{"alice"}, nil}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{"alice"}, + }, + { + name: "two private repos with intersecting collaborators", + visibilities: []bool{true, true}, + readers: [][]string{{"alice", "bob", "carol"}, {"bob", "carol", "dan"}}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{"bob", "carol"}, + }, + { + name: "private repos with no overlap yield empty reader set", + visibilities: []bool{true, true}, + readers: [][]string{{"alice"}, {"bob"}}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{}, + }, + { + name: "two private plus one public intersects only the private sets", + visibilities: []bool{true, false, true}, + readers: [][]string{{"alice", "bob", "carol"}, nil, {"bob", "carol", "dan"}}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{"bob", "carol"}, + }, + { + name: "intersection preserves first-set order and dedupes", + visibilities: []bool{true, true, true}, + readers: [][]string{{"alice", "bob", "alice"}, {"bob", "alice"}, {"alice", "bob"}}, + wantOK: true, + wantIntegrity: IntegrityUntrusted, + wantConfidential: []Confidentiality{"alice", "bob"}, + }, + { + name: "mismatched slice lengths return ok=false", + visibilities: []bool{true, true}, + readers: [][]string{{"alice"}}, + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label, ok := LabelSearchIssues(tc.visibilities, tc.readers) + assert.Equal(t, tc.wantOK, ok) + if !tc.wantOK { + return + } + assert.Equal(t, tc.wantIntegrity, label.Integrity) + if len(tc.wantConfidential) == 0 { + assert.Empty(t, label.Confidentiality) + return + } + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} From 883f58d97906041ca131ab9933fb606ac7ed7907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Wed, 13 May 2026 15:48:52 +0300 Subject: [PATCH 06/17] Add ifc label for issue_read tool (#2457) * Add ifc label for search_issues tool Emits an IFC SecurityLabel on the search_issues tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432, list_issues in #2453, and get_file_contents in #2454. Search results may span multiple repositories, so the label is the IFC join of the per-repository labels: - Integrity is always untrusted (issues are user-authored). - If any matched repository is public, the joined readers are ["public"] (the public side dominates the lub). - Otherwise the joined readers are the intersection of the collaborator sets across all matched private repositories. - Empty result sets are labelled public-untrusted (no data leaked). The shared searchHandler in search_utils.go gains an additive variadic 'searchOption' hook so SearchIssues can attach _meta.ifc without duplicating the search call. SearchPullRequests is unaffected; it does not pass any options. If any per-repository visibility or collaborators lookup fails the label is omitted entirely, consistent with get_file_contents, to avoid misclassifying the result. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. Note: this PR is chained on #2454 (gokhanarkan/fides-get-file-contents) because it depends on the FetchRepoIsPrivate and FetchRepoCollaborators helpers introduced there. GitHub will retarget the base to main once #2454 merges. * search_issues: address Copilot review findings - LabelSearchIssues now returns (SecurityLabel, bool); the bool is false when len(repoVisibilities) != len(readerSets), so callers can omit the label rather than emit one computed from inconsistent inputs. - searchIssuesIFCPostProcess no longer substitutes [owner] when the collaborators API returns an empty list. The substitution was inconsistent with the cross-repo intersection semantics: the owner could appear in another matched private repo's collaborator list and thereby widen the joined reader set incorrectly. Empty collaborator sets are now passed through unchanged. - Add a subtest exercising the collaborators-failure branch (500 on /repos/{owner}/{repo}/collaborators), asserting the tool still succeeds and result.Meta["ifc"] is absent. - Extend the LabelSearchIssues table tests with the slice-length mismatch case. Addresses the three Copilot findings on #2456. * search_issues: flip IFC join to intersection (private wins) Address Joanna's review feedback on #2456: a reader of a multi-repo result must be authorised to read every matched private repository, so the IFC join is the meet (intersection over private repos) rather than the join. Public matches contribute the universe set and drop out of the intersection without shrinking it. - LabelSearchIssues: collect only the private reader sets, then intersect. Empty result and all-public remain public-untrusted. - TestLabelSearchIssues: flip the mixed public+private expectation and add a 'two private + one public' case to lock in the new semantics. - Test_SearchIssues_IFC_InsidersMode: mixed subtest now expects the private repo's reader set instead of public. * Add ifc label for issue_read tool Emits an IFC SecurityLabel on the issue_read tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432, list_issues in #2453, get_file_contents in #2454, and search_issues in #2456. issue_read operates on a single issue in a single repository so the label has the same per-repo semantics as list_issues; the helper ifc.LabelListIssues is reused directly. Integrity is always untrusted (issue contents, comments, and label descriptions are user-authored). Public repos are labelled PublicUntrusted; private repos are labelled PrivateUntrusted with the repository's collaborator logins, falling back to [owner] when the collaborators lookup fails. The IssueRead handler dispatches to four sub-functions (GetIssue, GetIssueComments, GetSubIssues, GetIssueLabels). The IFC label is attached at the dispatch site via a single attachIFC closure, so all four method branches emit the label without changes to the underlying helpers. Visibility-lookup failures cause the label to be omitted entirely (consistent with get_file_contents and search_issues). A future cleanup PR can extract attachIFC into a shared helper now that get_file_contents, search_issues, and issue_read use near-identical closures; intentionally not bundled here to keep the diff minimal. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. Note: chained on #2456 (gokhanarkan/fides-search-issues), which is in turn chained on #2454. GitHub will retarget the base to main once those merge. * issue_read: simplify attachIFC by dropping unused lazy-cache Address Joanna's review feedback on #2457: the dispatch switch returns on exactly one branch, so attachIFC runs at most once per request. The ifcLabelKnown / ifcIsPrivate / ifcReaders cache variables were never reused across calls and only added complexity. Inline the visibility and collaborators lookups directly into the closure and drop the cache. Behaviour is identical; a follow-up can add real per-request caching across handlers if needed. --- pkg/github/issues.go | 39 ++++++++++-- pkg/github/issues_test.go | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index c89c38d137..ab8611afb2 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -296,19 +296,50 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // InsidersMode is enabled. If the visibility lookup fails the + // label is omitted rather than misclassifying the result. If + // only the collaborators lookup fails for a private repo we + // fall back to the owner so the reader set is never empty. The + // label matches list_issues semantics: per-repo visibility, + // integrity always untrusted. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + var readers []string + if isPrivate { + if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil { + readers = collaborators + } + if len(readers) == 0 { + readers = []string{owner} + } + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelListIssues(isPrivate, readers) + return r + } + switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 6ffc7f13c2..ed92c49ab3 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -275,6 +275,127 @@ func Test_GetIssue(t *testing.T) { } } +func Test_IssueRead_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/octocat/repo/issues/1"), + User: &github.User{Login: github.Ptr("u")}, + } + + mockComments := []*github.IssueComment{ + {Body: github.Ptr("hello"), User: &github.User{Login: github.Ptr("u")}}, + } + + makeMockClient := func(isPrivate bool, repoStatus int) *http.Client { + handlers := map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ + {Login: github.Ptr("octocat")}, + {Login: github.Ptr("alice")}, + }), + } + if repoStatus != 0 && repoStatus != http.StatusOK { + handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom") + } else { + handlers[GetReposByOwnerByRepo] = mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }) + } + return MockHTTPClientWithHandlers(handlers) + } + + getReq := map[string]any{ + "method": "get", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + commentsReq := map[string]any{ + "method": "get_comments", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(true, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(commentsReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"octocat", "alice"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, http.StatusInternalServerError)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) From 3a4bc2666f17d241c58e9ccd3d083a0bd6d24a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Wed, 13 May 2026 15:59:49 +0300 Subject: [PATCH 07/17] Add ifc label for search_repositories tool (#2459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits an IFC SecurityLabel on the search_repositories tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me (#2432), list_issues (#2453), get_file_contents (#2454), search_issues (#2456), and issue_read (#2457). Search results may span multiple repositories, so the join math (integrity always untrusted; private wins by intersecting collaborator sets across the matched private repos only) is shared with search_issues via ifc.LabelSearchIssues. Visibility is read directly off the search response's repo.Private field — no extra API call. Collaborators are fetched only for private hits, and any failure causes the label to be omitted entirely (consistent with search_issues / issue_read / get_file_contents). Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. --- pkg/github/search.go | 52 ++++++++++- pkg/github/search_test.go | 178 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..5009213760 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -8,6 +8,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -161,11 +162,60 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + if deps.GetFlags(ctx).InsidersMode { + attachSearchRepositoriesIFCLabel(ctx, client, result.Repositories, callResult) + } + return callResult, nil, nil }, ) } +// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across +// every matched repository and attaches the result to callResult. Visibility +// is read directly from the search response (no extra API call); collaborators +// are fetched once per private repository. If any collaborators lookup fails +// the label is omitted to avoid misclassifying the result. The join math is +// shared with search_issues via ifc.LabelSearchIssues: integrity is always +// untrusted, and confidentiality is the intersection of the reader sets of +// the matched private repositories (public matches contribute the universe +// set and drop out without shrinking it). +func attachSearchRepositoriesIFCLabel(ctx context.Context, client *github.Client, repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError { + return + } + + visibilities := make([]bool, 0, len(repos)) + readerSets := make([][]string, 0, len(repos)) + for _, repo := range repos { + isPrivate := repo.GetPrivate() + visibilities = append(visibilities, isPrivate) + if !isPrivate { + readerSets = append(readerSets, nil) + continue + } + owner := repo.GetOwner().GetLogin() + name := repo.GetName() + if owner == "" || name == "" { + return + } + collaborators, err := FetchRepoCollaborators(ctx, client, owner, name) + if err != nil { + return + } + readerSets = append(readerSets, collaborators) + } + + label, ok := ifc.LabelSearchIssues(visibilities, readerSets) + if !ok { + return + } + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = label +} + // SearchCode creates a tool to search for code across GitHub repositories. func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 85eb21bcb5..eb5d980753 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -163,9 +163,187 @@ func Test_SearchRepositories(t *testing.T) { assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) } + }) + } +} + +func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { + t.Parallel() + serverTool := SearchRepositories(translations.NullTranslationHelper) + + type repoFixture struct { + owner string + name string + isPrivate bool + collaborators []string + collaboratorsStatus int + } + + makeRepo := func(r repoFixture) *github.Repository { + return &github.Repository{ + ID: github.Ptr(int64(1)), + Name: github.Ptr(r.name), + FullName: github.Ptr(r.owner + "/" + r.name), + Private: github.Ptr(r.isPrivate), + Owner: &github.User{Login: github.Ptr(r.owner)}, + } + } + + makeMockClient := func(repos []repoFixture) *http.Client { + searchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(len(repos)), + IncompleteResults: github.Ptr(false), + } + for _, r := range repos { + searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) + } + + collaboratorsByPath := map[string]repoFixture{} + for _, r := range repos { + collaboratorsByPath["/repos/"+r.owner+"/"+r.name+"/collaborators"] = r + } + + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult), + GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { + r, ok := collaboratorsByPath[req.URL.Path] + if !ok { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + return + } + if r.collaboratorsStatus != 0 && r.collaboratorsStatus != http.StatusOK { + w.WriteHeader(r.collaboratorsStatus) + return + } + users := make([]*github.User, len(r.collaborators)) + for i, login := range r.collaborators { + users[i] = &github.User{Login: github.Ptr(login)} + } + body, _ := json.Marshal(users) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }, }) } + + reqParams := map[string]any{"query": "octocat"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient([]repoFixture{ + {owner: "octocat", name: "public-a"}, + {owner: "octocat", name: "public-b"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode mixed public and private keeps the private readers", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient([]repoFixture{ + {owner: "octocat", name: "private-repo", isPrivate: true, collaborators: []string{"alice"}}, + {owner: "octocat", name: "public-repo"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"alice"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient([]repoFixture{ + {owner: "octocat", name: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}}, + {owner: "octocat", name: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when collaborators lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient([]repoFixture{ + {owner: "octocat", name: "private-repo", isPrivate: true, collaboratorsStatus: http.StatusInternalServerError}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when collaborators lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when collaborators lookup fails") + } + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(nil)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) } func Test_SearchRepositories_FullOutput(t *testing.T) { From fbf68b2079e1cc78767f3c855c7ac4f41e45b1d8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 14 May 2026 12:07:15 +0200 Subject: [PATCH 08/17] feat: return minimal code search results with text match snippets (#2476) * feat: return minimal code search results with text match snippets Return a MinimalCodeSearchResult type from search_code instead of the raw GitHub API CodeSearchResult. This reduces token usage by ~4x by: - Projecting the repository object to just the full_name string instead of the full ~3KB repository payload repeated per result - Enabling the text-match Accept header so code snippets (fragments) are included in results, which were previously missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: drop html_url from MinimalCodeResult The URL is derivable from repository + path + sha, so it's redundant token cost per result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add minimal_output opt-out and Accept header test for code search Address PR review feedback: 1. Add minimal_output parameter (default: true) to search_code, matching the pattern from search_repositories. When false, returns the full GitHub API CodeSearchResult for backward compatibility. 2. Add Accept header assertion to tests via a new withHeaders() helper on partialMock, verifying the text-match Accept header is actually requested (not just mocked in the response). 3. Add test case for minimal_output=false path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove minimal_output opt-out from search_code The full CodeResult only adds a bloated Repository object (~3KB of template URLs) and a derivable HTMLURL. Nothing in the full output is useful beyond what the minimal type already provides, so always return the compact form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/helper_test.go | 18 ++++++++-- pkg/github/minimal_types.go | 16 +++++++++ pkg/github/search.go | 27 +++++++++++++-- pkg/github/search_test.go | 67 ++++++++++++++++++++++++------------- 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 67a05fd6c0..2a601c3199 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -220,9 +220,15 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { type partialMock struct { t *testing.T - expectedPath string - expectedQueryParams map[string]string - expectedRequestBody any + expectedPath string + expectedQueryParams map[string]string + expectedRequestBody any + expectedHeaderContains map[string]string +} + +func (p *partialMock) withHeaders(headers map[string]string) *partialMock { + p.expectedHeaderContains = headers + return p } func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { @@ -247,6 +253,12 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) } + if p.expectedHeaderContains != nil { + for k, v := range p.expectedHeaderContains { + require.Contains(p.t, r.Header.Get(k), v, "expected header %q to contain %q", k, v) + } + } + responseHandler(w, r) } } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a8757c51c3..b1e7c23573 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -51,6 +51,22 @@ type MinimalSearchRepositoriesResult struct { Items []MinimalRepository `json:"items"` } +// MinimalCodeSearchResult is the trimmed output type for code search results. +type MinimalCodeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCodeResult `json:"items"` +} + +// MinimalCodeResult is the trimmed output type for a single code search hit. +type MinimalCodeResult struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository string `json:"repository"` + TextMatches []*github.TextMatch `json:"text_matches,omitempty"` +} + // MinimalCommitAuthor represents commit author information. type MinimalCommitAuthor struct { Name string `json:"name,omitempty"` diff --git a/pkg/github/search.go b/pkg/github/search.go index 5009213760..8edfc948a6 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -270,8 +270,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: sort, + Order: order, + TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -301,7 +302,27 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil } - r, err := json.Marshal(result) + minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + item := MinimalCodeResult{ + Name: code.GetName(), + Path: code.GetPath(), + SHA: code.GetSHA(), + TextMatches: code.TextMatches, + } + if code.Repository != nil { + item.Repository = code.Repository.GetFullName() + } + minimalItems = append(minimalItems, item) + } + + minimalResult := &MinimalCodeSearchResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalItems, + } + + r, err := json.Marshal(minimalResult) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index eb5d980753..0c4a30c326 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -430,22 +430,35 @@ func Test_SearchCode(t *testing.T) { IncompleteResults: github.Ptr(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, + TextMatches: []*github.TextMatch{ + { + Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"), + }, + }, }, { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file2.go"), + Path: github.Ptr("path/to/file2.go"), + SHA: github.Ptr("def456abc123"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, }, }, } + textMatchAcceptHeader := map[string]string{ + "Accept": "text-match", + } + tests := []struct { name string mockedClient *http.Client @@ -463,7 +476,7 @@ func Test_SearchCode(t *testing.T) { "order": "desc", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -484,7 +497,7 @@ func Test_SearchCode(t *testing.T) { "q": "fmt.Println language:go", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -537,22 +550,28 @@ func Test_SearchCode(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult github.CodeSearchResult + var returnedResult MinimalCodeSearchResult err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) - for i, code := range returnedResult.CodeResults { - assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) - assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) - assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.CodeResults)) + for i, code := range returnedResult.Items { + assert.Equal(t, tc.expectedResult.CodeResults[i].GetName(), code.Name) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetPath(), code.Path) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetSHA(), code.SHA) + assert.Equal(t, tc.expectedResult.CodeResults[i].Repository.GetFullName(), code.Repository) + } + + // Verify text matches are included when present + if len(tc.expectedResult.CodeResults[0].TextMatches) > 0 { + require.NotEmpty(t, returnedResult.Items[0].TextMatches) + assert.Equal(t, + tc.expectedResult.CodeResults[0].TextMatches[0].GetFragment(), + returnedResult.Items[0].TextMatches[0].GetFragment(), + ) } }) } From 39d86b80af711a3277ffab08fa7d3068b3652913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= <5124654+gokhanarkan@users.noreply.github.com> Date: Thu, 14 May 2026 13:52:37 +0300 Subject: [PATCH 09/17] Replace ingress IFC reader list with private marker (#2478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace ingress IFC reader list with private marker Switches the ingress IFC labels from emitting a per-repo collaborator list to a single 'private' marker. The CLI engine now fetches readers from the GitHub endpoint on demand at egress decision time (P-F check), with pagination + caching, which removes a wire-bloat ceiling for repos with thousands of collaborators. Drops the per-call FetchRepoCollaborators from list_issues, issue_read, get_file_contents, search_issues, and search_repositories. The shared LabelSearchIssues helper collapses to a single []bool argument; the intersection logic and length-mismatch failure mode go away. This is a breaking wire-format change for _meta.ifc consumers — coordinate with the CLI cut-over. Refs github/copilot-mcp-core#1389. * format * Update FetchRepoCollaborators doc comment for marker-only ingress Addresses Copilot review on #2478. The helper is no longer called by the server itself; ingress emits a 'private' marker and the client engine resolves readers on demand. Kept exported per the library-consumer convention; updated the comment to reflect the new role. * Address review: drop FetchRepoCollaborators and make confidentiality a scalar Per Joanna's review on #2478: - Remove FetchRepoCollaborators entirely (no callers left after the marker switch). Drops the GetReposCollaboratorsByOwnerByRepo mock route too. - Change SecurityLabel.Confidentiality from []Confidentiality to a scalar Confidentiality. Wire format is now {integrity, confidentiality} where confidentiality is a single 'public' or 'private' string. Updated all tests and the LabelSearchIssues helper accordingly. --- pkg/github/context_tools_test.go | 5 +- pkg/github/helper_test.go | 1 - pkg/github/issues.go | 54 +----------- pkg/github/issues_test.go | 145 ++++--------------------------- pkg/github/repositories.go | 52 ++--------- pkg/github/repositories_test.go | 14 +-- pkg/github/search.go | 39 ++------- pkg/github/search_test.go | 84 ++---------------- pkg/ifc/ifc.go | 129 ++++++++------------------- pkg/ifc/ifc_test.go | 73 +++------------- 10 files changed, 95 insertions(+), 501 deletions(-) diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 365a019ab6..510372cd9b 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -192,10 +192,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { require.NoError(t, err) assert.Equal(t, "trusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - require.Len(t, confList, 1) - assert.Equal(t, "public", confList[0]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) } diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 2a601c3199..2346e40ca9 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -31,7 +31,6 @@ const ( GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}" GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches" GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags" - GetReposCollaboratorsByOwnerByRepo = "GET /repos/{owner}/{repo}/collaborators" GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits" GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}" GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ab8611afb2..98585e291e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -298,11 +298,7 @@ Options are: // attachIFC adds the IFC label to a successful tool result when // InsidersMode is enabled. If the visibility lookup fails the - // label is omitted rather than misclassifying the result. If - // only the collaborators lookup fails for a private repo we - // fall back to the owner so the reader set is never empty. The - // label matches list_issues semantics: per-repo visibility, - // integrity always untrusted. + // label is omitted rather than misclassifying the result. attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { return r @@ -311,19 +307,10 @@ Options are: if err != nil { return r } - var readers []string - if isPrivate { - if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil { - readers = collaborators - } - if len(readers) == 0 { - readers = []string{owner} - } - } if r.Meta == nil { r.Meta = mcp.Meta{} } - r.Meta["ifc"] = ifc.LabelListIssues(isPrivate, readers) + r.Meta["ifc"] = ifc.LabelListIssues(isPrivate) return r } @@ -1034,36 +1021,18 @@ func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn { uniqueRepos := uniqueSearchIssuesRepos(result) visibilities := make([]bool, 0, len(uniqueRepos)) - readerSets := make([][]string, 0, len(uniqueRepos)) for _, r := range uniqueRepos { isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo) if err != nil { return } visibilities = append(visibilities, isPrivate) - if !isPrivate { - readerSets = append(readerSets, nil) - continue - } - collaborators, err := FetchRepoCollaborators(ctx, client, r.owner, r.repo) - if err != nil { - return - } - // Preserve an empty collaborator set as-is. Substituting the - // owner here would corrupt the cross-repo intersection (the - // owner could appear in another repo's collaborator list and - // widen the joined reader set incorrectly). - readerSets = append(readerSets, collaborators) } - label, ok := ifc.LabelSearchIssues(visibilities, readerSets) - if !ok { - return - } if callResult.Meta == nil { callResult.Meta = mcp.Meta{} } - callResult.Meta["ifc"] = label + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) } } @@ -1728,22 +1697,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if result.Meta == nil { result.Meta = mcp.Meta{} } - var readers []string - if isPrivate { - restClient, err := deps.GetClient(ctx) - if err == nil { - if collaborators, err := FetchRepoCollaborators(ctx, restClient, owner, repo); err == nil { - readers = collaborators - } - } - // Fall back to the repository owner so the reader set is - // never empty for a private repository even if the - // collaborators lookup fails. - if len(readers) == 0 { - readers = []string{owner} - } - } - result.Meta["ifc"] = ifc.LabelListIssues(isPrivate, readers) + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) } return result, nil, nil }) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index ed92c49ab3..d23c22ed5c 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -297,10 +297,6 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { handlers := map[string]http.HandlerFunc{ GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), - GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ - {Login: github.Ptr("octocat")}, - {Login: github.Ptr("alice")}, - }), } if repoStatus != 0 && repoStatus != http.StatusOK { handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom") @@ -356,7 +352,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { @@ -374,7 +370,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"octocat", "alice"}, ifcMap["confidentiality"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) }) t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { @@ -829,12 +825,10 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { } type repoFixture struct { - owner string - repo string - isPrivate bool - collaborators []string - repoStatus int - collaboratorsStatus int + owner string + repo string + isPrivate bool + repoStatus int } repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc { @@ -842,10 +836,6 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { for _, r := range repos { repoByPath["/repos/"+r.owner+"/"+r.repo] = r } - collaboratorsByPath := map[string]repoFixture{} - for _, r := range repos { - collaboratorsByPath["/repos/"+r.owner+"/"+r.repo+"/collaborators"] = r - } return map[string]http.HandlerFunc{ GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { r, ok := repoByPath[req.URL.Path] @@ -864,25 +854,6 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write(body) }, - GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { - r, ok := collaboratorsByPath[req.URL.Path] - if !ok { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - return - } - if r.collaboratorsStatus != 0 && r.collaboratorsStatus != http.StatusOK { - w.WriteHeader(r.collaboratorsStatus) - return - } - users := make([]*github.User, len(r.collaborators)) - for i, login := range r.collaborators { - users[i] = &github.User{Login: github.Ptr(login)} - } - body, _ := json.Marshal(users) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }, } } @@ -909,7 +880,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { assert.Nil(t, result.Meta) }) - t.Run("insiders mode enabled with single public repo emits public untrusted", func(t *testing.T) { + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), @@ -925,17 +896,17 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode mixed public and private keeps the private readers", func(t *testing.T) { + t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ makeIssue("octocat", "private-repo", 1), makeIssue("octocat", "public-repo", 2), }} deps := BaseDeps{ Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ - {owner: "octocat", repo: "private-repo", isPrivate: true, collaborators: []string{"alice"}}, + {owner: "octocat", repo: "private-repo", isPrivate: true}, {owner: "octocat", repo: "public-repo"}, })), Flags: FeatureFlags{InsidersMode: true}, @@ -950,32 +921,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"alice"}, ifcMap["confidentiality"]) - }) - - t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) { - searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ - makeIssue("octocat", "repo-a", 1), - makeIssue("octocat", "repo-b", 2), - }} - deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ - {owner: "octocat", repo: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}}, - {owner: "octocat", repo: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}}, - })), - Flags: FeatureFlags{InsidersMode: true}, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(reqParams) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - require.NotNil(t, result.Meta) - ifcMap := unmarshalIFC(t, result.Meta["ifc"]) - assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) }) t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { @@ -999,27 +945,6 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { } }) - t.Run("insiders mode skips ifc label when collaborators lookup fails", func(t *testing.T) { - searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "private-repo", 1)}} - deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ - {owner: "octocat", repo: "private-repo", isPrivate: true, collaboratorsStatus: http.StatusInternalServerError}, - })), - Flags: FeatureFlags{InsidersMode: true}, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(reqParams) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, "tool call should still succeed when collaborators lookup fails") - - if result.Meta != nil { - _, hasIFC := result.Meta["ifc"] - assert.False(t, hasIFC, "ifc label should be omitted when collaborators lookup fails") - } - }) - t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} deps := BaseDeps{ @@ -1036,7 +961,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) } @@ -1804,24 +1729,13 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) assert.Equal(t, "untrusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - require.Len(t, confList, 1) - assert.Equal(t, "public", confList[0]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode enabled on private repo emits private untrusted label with collaborators", func(t *testing.T) { + t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) - restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ - {Login: github.Ptr("octocat")}, - {Login: github.Ptr("alice")}, - {Login: github.Ptr("bob")}, - }), - })) deps := BaseDeps{ - Client: restClient, GQLClient: gqlClient, Flags: FeatureFlags{InsidersMode: true}, } @@ -1842,36 +1756,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) assert.Equal(t, "untrusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - assert.Equal(t, []any{"octocat", "alice", "bob"}, confList) - }) - - t.Run("insiders mode enabled on private repo falls back to owner when collaborators lookup fails", func(t *testing.T) { - matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) - restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), - })) - deps := BaseDeps{ - Client: restClient, - GQLClient: gqlClient, - Flags: FeatureFlags{InsidersMode: true}, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(reqParams) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - require.NotNil(t, result.Meta) - ifcJSON, err := json.Marshal(result.Meta["ifc"]) - require.NoError(t, err) - var ifcMap map[string]any - require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - - assert.Equal(t, []any{"octocat"}, ifcMap["confidentiality"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) }) } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 507677ee57..c51516e297 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -654,34 +654,6 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } -// FetchRepoCollaborators returns the login names of all collaborators on a -// repository. It is provided as a shared helper for IFC label computation so -// tools can populate the reader set for private repositories. The full list -// is fetched eagerly via pagination; callers are expected to invoke this only -// when needed (e.g. private repos under InsidersMode). -func FetchRepoCollaborators(ctx context.Context, client *github.Client, owner, repo string) ([]string, error) { - opts := &github.ListCollaboratorsOptions{ - ListOptions: github.ListOptions{PerPage: 100}, - } - var logins []string - for { - page, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) - if err != nil { - return nil, err - } - for _, c := range page { - if login := c.GetLogin(); login != "" { - logins = append(logins, login) - } - } - if resp == nil || resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage - } - return logins, nil -} - // FetchRepoIsPrivate returns whether a repository is private. It is a thin // wrapper around the GitHub Repositories.Get endpoint provided as a shared // helper for IFC label computation across tools. @@ -769,17 +741,15 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } // attachIFC adds the IFC label to a successful tool result when - // InsidersMode is enabled. The visibility and (for private - // repositories) collaborators lookups are performed lazily on - // first use. If the visibility lookup fails we skip the label - // rather than misclassify the result; the failure is not cached - // so a later return path can retry. If only the collaborators - // lookup fails for a private repo we fall back to the owner so - // the reader set is never empty. + // InsidersMode is enabled. The visibility lookup is performed + // lazily on first use and cached because GetFileContents has + // many possible return paths and would otherwise re-fetch on + // each. If the visibility lookup fails we skip the label rather + // than misclassify the result; the failure is not cached so a + // later return path can retry. var ( ifcLabelKnown bool ifcIsPrivate bool - ifcReaders []string ) attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { @@ -791,20 +761,12 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return r } ifcIsPrivate = isPrivate - if ifcIsPrivate { - if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil { - ifcReaders = collaborators - } - if len(ifcReaders) == 0 { - ifcReaders = []string{owner} - } - } ifcLabelKnown = true } if r.Meta == nil { r.Meta = mcp.Meta{} } - r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate, ifcReaders) + r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate) return r } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index ceaa959019..913be5997c 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -492,10 +492,6 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { "default_branch": "main", "private": isPrivate, }), - GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ - {Login: github.Ptr("octocat")}, - {Login: github.Ptr("alice")}, - }), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) @@ -558,10 +554,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) assert.Equal(t, "untrusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - require.Len(t, confList, 1) - assert.Equal(t, "public", confList[0]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { @@ -586,9 +579,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) assert.Equal(t, "trusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - assert.Equal(t, []any{"octocat", "alice"}, confList) + assert.Equal(t, "private", ifcMap["confidentiality"]) }) t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { @@ -3351,6 +3342,7 @@ func Test_ListReleases(t *testing.T) { }) } } + func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(translations.NullTranslationHelper) tool := serverTool.Tool diff --git a/pkg/github/search.go b/pkg/github/search.go index 8edfc948a6..a44add8bb0 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -164,7 +164,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo callResult := utils.NewToolResultText(string(r)) if deps.GetFlags(ctx).InsidersMode { - attachSearchRepositoriesIFCLabel(ctx, client, result.Repositories, callResult) + attachSearchRepositoriesIFCLabel(result.Repositories, callResult) } return callResult, nil, nil }, @@ -173,47 +173,24 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo // attachSearchRepositoriesIFCLabel joins per-repository IFC labels across // every matched repository and attaches the result to callResult. Visibility -// is read directly from the search response (no extra API call); collaborators -// are fetched once per private repository. If any collaborators lookup fails -// the label is omitted to avoid misclassifying the result. The join math is -// shared with search_issues via ifc.LabelSearchIssues: integrity is always -// untrusted, and confidentiality is the intersection of the reader sets of -// the matched private repositories (public matches contribute the universe -// set and drop out without shrinking it). -func attachSearchRepositoriesIFCLabel(ctx context.Context, client *github.Client, repos []*github.Repository, callResult *mcp.CallToolResult) { +// is read directly from the search response — no extra API call. The join +// math is shared with search_issues via ifc.LabelSearchIssues: integrity is +// always untrusted; confidentiality is private if any matched repository is +// private, otherwise public. +func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) { if callResult == nil || callResult.IsError { return } visibilities := make([]bool, 0, len(repos)) - readerSets := make([][]string, 0, len(repos)) for _, repo := range repos { - isPrivate := repo.GetPrivate() - visibilities = append(visibilities, isPrivate) - if !isPrivate { - readerSets = append(readerSets, nil) - continue - } - owner := repo.GetOwner().GetLogin() - name := repo.GetName() - if owner == "" || name == "" { - return - } - collaborators, err := FetchRepoCollaborators(ctx, client, owner, name) - if err != nil { - return - } - readerSets = append(readerSets, collaborators) + visibilities = append(visibilities, repo.GetPrivate()) } - label, ok := ifc.LabelSearchIssues(visibilities, readerSets) - if !ok { - return - } if callResult.Meta == nil { callResult.Meta = mcp.Meta{} } - callResult.Meta["ifc"] = label + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) } // SearchCode creates a tool to search for code across GitHub repositories. diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 0c4a30c326..13e787a67c 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -173,11 +173,9 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { serverTool := SearchRepositories(translations.NullTranslationHelper) type repoFixture struct { - owner string - name string - isPrivate bool - collaborators []string - collaboratorsStatus int + owner string + name string + isPrivate bool } makeRepo := func(r repoFixture) *github.Repository { @@ -198,33 +196,8 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { for _, r := range repos { searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) } - - collaboratorsByPath := map[string]repoFixture{} - for _, r := range repos { - collaboratorsByPath["/repos/"+r.owner+"/"+r.name+"/collaborators"] = r - } - return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult), - GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { - r, ok := collaboratorsByPath[req.URL.Path] - if !ok { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - return - } - if r.collaboratorsStatus != 0 && r.collaboratorsStatus != http.StatusOK { - w.WriteHeader(r.collaboratorsStatus) - return - } - users := make([]*github.User, len(r.collaborators)) - for i, login := range r.collaborators { - users[i] = &github.User{Login: github.Ptr(login)} - } - body, _ := json.Marshal(users) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }, }) } @@ -262,13 +235,13 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode mixed public and private keeps the private readers", func(t *testing.T) { + t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { deps := BaseDeps{ Client: github.NewClient(makeMockClient([]repoFixture{ - {owner: "octocat", name: "private-repo", isPrivate: true, collaborators: []string{"alice"}}, + {owner: "octocat", name: "private-repo", isPrivate: true}, {owner: "octocat", name: "public-repo"}, })), Flags: FeatureFlags{InsidersMode: true}, @@ -283,48 +256,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"alice"}, ifcMap["confidentiality"]) - }) - - t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) { - deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ - {owner: "octocat", name: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}}, - {owner: "octocat", name: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}}, - })), - Flags: FeatureFlags{InsidersMode: true}, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(reqParams) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - require.NotNil(t, result.Meta) - ifcMap := unmarshalIFC(t, result.Meta["ifc"]) - assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"]) - }) - - t.Run("insiders mode skips ifc label when collaborators lookup fails", func(t *testing.T) { - deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ - {owner: "octocat", name: "private-repo", isPrivate: true, collaboratorsStatus: http.StatusInternalServerError}, - })), - Flags: FeatureFlags{InsidersMode: true}, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(reqParams) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, "tool call should still succeed when collaborators lookup fails") - - if result.Meta != nil { - _, hasIFC := result.Meta["ifc"] - assert.False(t, hasIFC, "ifc label should be omitted when collaborators lookup fails") - } + assert.Equal(t, "private", ifcMap["confidentiality"]) }) t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { @@ -342,7 +274,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) assert.Equal(t, "untrusted", ifcMap["integrity"]) - assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) } diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index 61c81e255e..e6eeb407bc 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -13,19 +13,20 @@ const ( type Confidentiality string const ( - ConfidentialityPublic Confidentiality = "public" + ConfidentialityPublic Confidentiality = "public" + ConfidentialityPrivate Confidentiality = "private" ) type SecurityLabel struct { - Integrity Integrity `json:"integrity"` - Confidentiality []Confidentiality `json:"confidentiality"` + Integrity Integrity `json:"integrity"` + Confidentiality Confidentiality `json:"confidentiality"` } // PublicTrusted returns a label for trusted, publicly readable data. func PublicTrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityTrusted, - Confidentiality: []Confidentiality{ConfidentialityPublic}, + Confidentiality: ConfidentialityPublic, } } @@ -33,45 +34,42 @@ func PublicTrusted() SecurityLabel { func PublicUntrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityUntrusted, - Confidentiality: []Confidentiality{ConfidentialityPublic}, + Confidentiality: ConfidentialityPublic, } } -// PrivateTrusted returns a label for trusted data restricted to the given readers. -func PrivateTrusted(readers []string) SecurityLabel { +// PrivateTrusted returns a label for trusted data restricted to the readers +// of the originating repository. The reader set is opaque on the wire (a +// single "private" marker); the client engine resolves the concrete readers +// from the GitHub API on demand at egress decision time. +func PrivateTrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityTrusted, - Confidentiality: toConfidentiality(readers), + Confidentiality: ConfidentialityPrivate, } } -// PrivateUntrusted returns a label for untrusted data restricted to the given readers. -func PrivateUntrusted(readers []string) SecurityLabel { +// PrivateUntrusted returns a label for untrusted data restricted to the +// readers of the originating repository. See PrivateTrusted for the reader +// resolution model. +func PrivateUntrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityUntrusted, - Confidentiality: toConfidentiality(readers), + Confidentiality: ConfidentialityPrivate, } } -func toConfidentiality(readers []string) []Confidentiality { - out := make([]Confidentiality, len(readers)) - for i, r := range readers { - out[i] = Confidentiality(r) - } - return out -} - func LabelGetMe() SecurityLabel { return PublicTrusted() } // LabelListIssues returns the IFC label for a list_issues result. // Public repositories are universally readable; private repositories are -// restricted to the provided reader set (typically repository collaborators). +// restricted to their collaborators (resolved client-side from the marker). // Issue contents are attacker-controllable, so integrity is always untrusted. -func LabelListIssues(isPrivate bool, readers []string) SecurityLabel { +func LabelListIssues(isPrivate bool) SecurityLabel { if isPrivate { - return PrivateUntrusted(readers) + return PrivateUntrusted() } return PublicUntrusted() } @@ -80,86 +78,31 @@ func LabelListIssues(isPrivate bool, readers []string) SecurityLabel { // Public repository file contents may be authored by anyone via pull requests // and are therefore untrusted. In private repositories only collaborators can // land changes, so contents are treated as trusted. -func LabelGetFileContents(isPrivate bool, readers []string) SecurityLabel { +func LabelGetFileContents(isPrivate bool) SecurityLabel { if isPrivate { - return PrivateTrusted(readers) + return PrivateTrusted() } return PublicUntrusted() } -// LabelSearchIssues returns the IFC label for a search_issues result, joining -// per-repository labels across all matched repositories. +// LabelSearchIssues returns the IFC label for a multi-repository search +// result, joining per-repository labels across all matched repositories. +// Used by both search_issues and search_repositories. // -// Integrity is always untrusted because issue contents are user-authored. +// Integrity is always untrusted because results expose user-authored content. // -// Confidentiality follows the IFC meet (greatest lower bound): the private -// side dominates because a reader of the combined result must be authorised -// to read every matched repository. Public repositories contribute the -// universe set and therefore drop out of the intersection without shrinking -// it. +// Confidentiality follows the IFC meet (greatest lower bound): if any matched +// repository is private the joined label is private; otherwise public. The +// reader set is opaque (the "private" marker); the client engine resolves +// concrete readers on demand at egress decision time. // -// - If no repositories matched (empty result set), the label is -// public-untrusted because no repository data is leaked. -// - If every matched repository is public, the joined readers are -// ["public"]. -// - Otherwise the joined readers are the intersection of the reader sets -// of the matched private repositories only. -// -// repoVisibilities[i] reports whether the i-th matched repository is private; -// readerSets[i] is that repository's reader set (only consulted for private -// repos). The two slices must have the same length; the second return value -// is false when they do not, in which case the caller should omit the label -// rather than emit one computed from inconsistent inputs. -func LabelSearchIssues(repoVisibilities []bool, readerSets [][]string) (SecurityLabel, bool) { - if len(repoVisibilities) != len(readerSets) { - return SecurityLabel{}, false - } - if len(repoVisibilities) == 0 { - return PublicUntrusted(), true - } - privateReaderSets := make([][]string, 0, len(repoVisibilities)) - for i, isPrivate := range repoVisibilities { +// An empty result set is treated as public-untrusted (no repository data is +// leaked). +func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + for _, isPrivate := range repoVisibilities { if isPrivate { - privateReaderSets = append(privateReaderSets, readerSets[i]) + return PrivateUntrusted() } } - if len(privateReaderSets) == 0 { - return PublicUntrusted(), true - } - return PrivateUntrusted(intersectReaders(privateReaderSets)), true -} - -// intersectReaders returns the readers present in every set, preserving the -// order from the first set. Empty input yields nil. -func intersectReaders(sets [][]string) []string { - if len(sets) == 0 { - return nil - } - counts := make(map[string]int, len(sets[0])) - for _, login := range sets[0] { - if _, seen := counts[login]; seen { - continue - } - counts[login] = 1 - } - for _, set := range sets[1:] { - seen := make(map[string]struct{}, len(set)) - for _, login := range set { - if _, dup := seen[login]; dup { - continue - } - seen[login] = struct{}{} - if _, ok := counts[login]; ok { - counts[login]++ - } - } - } - out := make([]string, 0, len(counts)) - for _, login := range sets[0] { - if counts[login] == len(sets) { - out = append(out, login) - delete(counts, login) - } - } - return out + return PublicUntrusted() } diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index 644244a52e..669f5ff0cc 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -12,86 +12,39 @@ func TestLabelSearchIssues(t *testing.T) { tests := []struct { name string visibilities []bool - readers [][]string - wantOK bool - wantIntegrity Integrity - wantConfidential []Confidentiality + wantConfidential Confidentiality }{ { name: "empty result is treated as public", - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{ConfidentialityPublic}, + wantConfidential: ConfidentialityPublic, }, { name: "single public repo", visibilities: []bool{false}, - readers: [][]string{nil}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{ConfidentialityPublic}, + wantConfidential: ConfidentialityPublic, }, { - name: "mixed public and private keeps the private reader set", - visibilities: []bool{true, false}, - readers: [][]string{{"alice"}, nil}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{"alice"}, + name: "all public repos stay public", + visibilities: []bool{false, false, false}, + wantConfidential: ConfidentialityPublic, }, { - name: "two private repos with intersecting collaborators", - visibilities: []bool{true, true}, - readers: [][]string{{"alice", "bob", "carol"}, {"bob", "carol", "dan"}}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{"bob", "carol"}, + name: "any private match flips to private", + visibilities: []bool{false, true, false}, + wantConfidential: ConfidentialityPrivate, }, { - name: "private repos with no overlap yield empty reader set", + name: "all private repos stay private", visibilities: []bool{true, true}, - readers: [][]string{{"alice"}, {"bob"}}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{}, - }, - { - name: "two private plus one public intersects only the private sets", - visibilities: []bool{true, false, true}, - readers: [][]string{{"alice", "bob", "carol"}, nil, {"bob", "carol", "dan"}}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{"bob", "carol"}, - }, - { - name: "intersection preserves first-set order and dedupes", - visibilities: []bool{true, true, true}, - readers: [][]string{{"alice", "bob", "alice"}, {"bob", "alice"}, {"alice", "bob"}}, - wantOK: true, - wantIntegrity: IntegrityUntrusted, - wantConfidential: []Confidentiality{"alice", "bob"}, - }, - { - name: "mismatched slice lengths return ok=false", - visibilities: []bool{true, true}, - readers: [][]string{{"alice"}}, - wantOK: false, + wantConfidential: ConfidentialityPrivate, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - label, ok := LabelSearchIssues(tc.visibilities, tc.readers) - assert.Equal(t, tc.wantOK, ok) - if !tc.wantOK { - return - } - assert.Equal(t, tc.wantIntegrity, label.Integrity) - if len(tc.wantConfidential) == 0 { - assert.Empty(t, label.Confidentiality) - return - } + label := LabelSearchIssues(tc.visibilities) + assert.Equal(t, IntegrityUntrusted, label.Integrity) assert.Equal(t, tc.wantConfidential, label.Confidentiality) }) } From b8be4c1748857b981d30a436a701a7a45115fba4 Mon Sep 17 00:00:00 2001 From: Chris Westra Date: Thu, 14 May 2026 14:35:48 -0400 Subject: [PATCH 10/17] Document Copilot Spaces PAT requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a12f1531ac..c3135407dc 100644 --- a/README.md +++ b/README.md @@ -1413,6 +1413,11 @@ The following sets of tools are available: Copilot Spaces +- **Authentication note** + - Fine-grained PATs are not hidden by classic PAT scope filtering, so these tools may still appear even when the token cannot use them. + - For org-owned spaces, fine-grained PATs must be installed on the owning organization and include `organization_copilot_spaces: read`. + - If an org-owned space contains repository-backed resources, the token must also have access to every referenced repository or the space may be treated as not found. + - **get_copilot_space** - Get Copilot Space - `owner`: The owner of the space. (string, required) - `name`: The name of the space. (string, required) From 46d220fba55ee08d15c9670acc6e8b9ad092ceee Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 15 May 2026 11:20:30 +0200 Subject: [PATCH 11/17] Add tool to list repo collaborators (#2477) * Add tool to list repo collaborators * Simplify tool description * Fix test * Return pagination info * Return page parameters * Update defaults --- README.md | 8 + .../list_repository_collaborators.snap | 45 ++++++ pkg/github/helper_test.go | 1 + pkg/github/minimal_types.go | 7 + pkg/github/repositories.go | 108 +++++++++++++ pkg/github/repositories_test.go | 146 ++++++++++++++++++ pkg/github/tools.go | 1 + 7 files changed, 316 insertions(+) create mode 100644 pkg/github/__toolsnaps__/list_repository_collaborators.snap diff --git a/README.md b/README.md index c3135407dc..a437f28cf8 100644 --- a/README.md +++ b/README.md @@ -1256,6 +1256,14 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **list_repository_collaborators** - List repository collaborators + - **Required OAuth Scopes**: `repo` + - `affiliation`: Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all' (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default 1, min 1) (number, optional) + - `perPage`: Results per page for pagination (default 30, min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - **list_tags** - List tags - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap new file mode 100644 index 0000000000..629e4bdf1c --- /dev/null +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List repository collaborators" + }, + "description": "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter.", + "inputSchema": { + "properties": { + "affiliation": { + "description": "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + "enum": [ + "outside", + "direct", + "all" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (default 1, min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (default 30, min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_repository_collaborators" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 2346e40ca9..892b3045c8 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -39,6 +39,7 @@ const ( GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" + ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b1e7c23573..9aa6c16325 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -154,6 +154,13 @@ type MinimalResponse struct { URL string `json:"url"` } +// MinimalCollaborator is the trimmed output type for repository collaborators. +type MinimalCollaborator struct { + Login string `json:"login"` + ID int64 `json:"id"` + RoleName string `json:"role_name"` +} + type MinimalProject struct { ID *int64 `json:"id,omitempty"` NodeID *string `json:"node_id,omitempty"` diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c51516e297..156df3dd34 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2202,3 +2202,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, ) } + +// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. +func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "list_repository_collaborators", + Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"), + ReadOnlyHint: true, + }, + InputSchema: func() *jsonschema.Schema { + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "affiliation": { + Type: "string", + Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + Enum: []any{"outside", "direct", "all"}, + }, + }, + Required: []string{"owner", "repo"}, + }) + schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)" + schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)" + return schema + }(), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + affiliation, err := OptionalParam[string](args, "affiliation") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.ListCollaboratorsOptions{ + Affiliation: affiliation, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list collaborators", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil + } + + result := make([]MinimalCollaborator, 0, len(collaborators)) + for _, c := range collaborators { + result = append(result, MinimalCollaborator{ + Login: c.GetLogin(), + ID: c.GetID(), + RoleName: c.GetRoleName(), + }) + } + + response := map[string]any{ + "items": result, + "nextPage": resp.NextPage, + "prevPage": resp.PrevPage, + "firstPage": resp.FirstPage, + "lastPage": resp.LastPage, + } + + return MarshalledTextResult(response), nil, nil + }, + ) +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 913be5997c..d90a010695 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -4368,3 +4368,149 @@ func Test_UnstarRepository(t *testing.T) { }) } } + +func Test_ListRepositoryCollaborators(t *testing.T) { + // Verify tool definition once + serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "list_repository_collaborators", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "affiliation") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockCollaborators := []*github.User{ + { + Login: github.Ptr("user1"), + ID: github.Ptr(int64(101)), + RoleName: github.Ptr("admin"), + }, + { + Login: github.Ptr("user2"), + ID: github.Ptr(int64(102)), + RoleName: github.Ptr("write"), + }, + } + + tests := []struct { + name string + args map[string]any + mockResponses []MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "success with affiliation filter", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "affiliation": "direct", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "missing owner", + args: map[string]any{ + "repo": "repo", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]any{ + "owner": "owner", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: repo", + }, + { + name: "empty collaborators returns empty array", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + []*github.User{}, + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + deps := BaseDeps{ + Client: mockClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tt.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + if tt.errContains != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.errContains) + return + } + + textContent := getTextResult(t, result) + require.NotEmpty(t, textContent.Text) + + var response struct { + Items []MinimalCollaborator `json:"items"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` + FirstPage int `json:"firstPage"` + LastPage int `json:"lastPage"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tt.name == "empty collaborators returns empty array" { + assert.Empty(t, response.Items) + return + } + + collaborators := response.Items + assert.Len(t, collaborators, 2) + assert.Equal(t, "user1", collaborators[0].Login) + assert.Equal(t, int64(101), collaborators[0].ID) + assert.Equal(t, "admin", collaborators[0].RoleName) + assert.Equal(t, "user2", collaborators[1].Login) + assert.Equal(t, int64(102), collaborators[1].ID) + assert.Equal(t, "write", collaborators[1].RoleName) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 559088f6d6..011ec9c9c1 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -199,6 +199,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListRepositoryCollaborators(t), // Git tools GetRepositoryTree(t), From 8a48d0749f8afbadaa66ef828fd424195fb7846f Mon Sep 17 00:00:00 2001 From: Ross Tarrant Date: Fri, 15 May 2026 15:16:36 +0100 Subject: [PATCH 12/17] feat: Add tool for discussion comment write operations (#2427) * Add discussion comment write operation tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address comments from Copilot review * Update includeReplies description to specify GitHub API maximum replies limit * Consolidate into single tool * add tests cases for checking param presence * Enhance validation on discussion comment operations * Enhance discussion_write tool description Co-authored-by: Roberto Nacu * Remove redundant param Co-authored-by: Roberto Nacu * Refactor tests * Fix failing build --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu --- README.md | 18 + .../discussion_comment_write.snap | 48 + .../get_discussion_comments.snap | 4 + pkg/github/discussions.go | 537 +++++++++- pkg/github/discussions_test.go | 918 +++++++++++++++++- pkg/github/minimal_types.go | 9 + pkg/github/tools.go | 1 + 7 files changed, 1497 insertions(+), 38 deletions(-) create mode 100644 pkg/github/__toolsnaps__/discussion_comment_write.snap diff --git a/README.md b/README.md index a437f28cf8..1030f83ca0 100644 --- a/README.md +++ b/README.md @@ -730,6 +730,23 @@ The following sets of tools are available: comment-discussion Discussions +- **discussion_comment_write** - Manage discussion comments + - **Required OAuth Scopes**: `repo` + - `body`: Comment content (required for 'add', 'reply', and 'update' methods) (string, optional) + - `commentNodeID`: The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting. (string, optional) + - `discussionNumber`: Discussion number (required for 'add' and 'reply' methods) (number, optional) + - `method`: Write operation to perform on a discussion comment. + Options are: + - 'add' - adds a new top-level comment to a discussion. + - 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). + - 'update' - updates an existing discussion comment. + - 'delete' - deletes a discussion comment. + - 'mark_answer' - marks a discussion comment as the answer (Q&A only). + - 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). + (string, required) + - `owner`: Repository owner (required for 'add' and 'reply' methods) (string, optional) + - `repo`: Repository name (required for 'add' and 'reply' methods) (string, optional) + - **get_discussion** - Get discussion - **Required OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) @@ -740,6 +757,7 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) + - `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap new file mode 100644 index 0000000000..5edadfaeaa --- /dev/null +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Manage discussion comments" + }, + "description": "Write operations for discussion comments.\nSupports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.", + "inputSchema": { + "properties": { + "body": { + "description": "Comment content (required for 'add', 'reply', and 'update' methods)", + "type": "string" + }, + "commentNodeID": { + "description": "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + "type": "string" + }, + "discussionNumber": { + "description": "Discussion number (required for 'add' and 'reply' methods)", + "type": "number" + }, + "method": { + "description": "Write operation to perform on a discussion comment.\nOptions are:\n- 'add' - adds a new top-level comment to a discussion.\n- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting).\n- 'update' - updates an existing discussion comment.\n- 'delete' - deletes a discussion comment.\n- 'mark_answer' - marks a discussion comment as the answer (Q\u0026A only).\n- 'unmark_answer' - unmarks a discussion comment as the answer (Q\u0026A only).\n", + "enum": [ + "add", + "reply", + "update", + "delete", + "mark_answer", + "unmark_answer" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for 'add' and 'reply' methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for 'add' and 'reply' methods)", + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "discussion_comment_write" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index f9e6095650..422fc40bf7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -14,6 +14,10 @@ "description": "Discussion Number", "type": "number" }, + "includeReplies": { + "description": "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + "type": "boolean" + }, "owner": { "description": "Repository owner", "type": "string" diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 700560b475..4ecf7e2905 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" @@ -405,6 +406,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Type: "number", Description: "Discussion Number", }, + "includeReplies": { + Type: "boolean", + Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + }, }, Required: []string{"owner", "repo", "discussionNumber"}, }), @@ -421,6 +426,11 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(err.Error()), nil, nil } + includeReplies, err := OptionalParam[bool](args, "includeReplies") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -447,24 +457,6 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } - var q struct { - Repository struct { - Discussion struct { - Comments struct { - Nodes []struct { - Body githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"comments(first: $first, after: $after)"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } vars := map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), @@ -476,25 +468,111 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } else { vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + + var comments []MinimalDiscussionComment + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String } + var totalCount int - var comments []*github.IssueComment - for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + if includeReplies { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + Replies struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + TotalCount int + } `graphql:"replies(first: 100)"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comment := MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + ReplyTotalCount: c.Replies.TotalCount, + } + for _, r := range c.Replies.Nodes { + comment.Replies = append(comment.Replies, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", r.ID), + Body: string(r.Body), + IsAnswer: bool(r.IsAnswer), + }) + } + comments = append(comments, comment) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount + } else { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + }) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount } // Create response with pagination info response := map[string]any{ "comments": comments, "pageInfo": map[string]any{ - "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), - "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), }, - "totalCount": q.Repository.Discussion.Comments.TotalCount, + "totalCount": totalCount, } out, err := json.Marshal(response) @@ -507,6 +585,409 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve ) } +func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "discussion_comment_write", + Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments. +Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a discussion comment. +Options are: +- 'add' - adds a new top-level comment to a discussion. +- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). +- 'update' - updates an existing discussion comment. +- 'delete' - deletes a discussion comment. +- 'mark_answer' - marks a discussion comment as the answer (Q&A only). +- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). +`, + Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"}, + }, + "owner": { + Type: "string", + Description: "Repository owner (required for 'add' and 'reply' methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for 'add' and 'reply' methods)", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion number (required for 'add' and 'reply' methods)", + }, + "body": { + Type: "string", + Description: "Comment content (required for 'add', 'reply', and 'update' methods)", + }, + "commentNodeID": { + Type: "string", + Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + }, + }, + Required: []string{"method"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + switch method { + case "add": + return addDiscussionComment(ctx, client, args) + case "reply": + return replyToDiscussionComment(ctx, client, args) + case "update": + return updateDiscussionComment(ctx, client, args) + case "delete": + return deleteDiscussionComment(ctx, client, args) + case "mark_answer": + return markDiscussionCommentAsAnswer(ctx, client, args) + case "unmark_answer": + return unmarkDiscussionCommentAsAnswer(ctx, client, args) + default: + return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil + } + }) +} + +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: q.Repository.Discussion.ID, + Body: githubv4.String(body), + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func requiredCommentNodeID(args map[string]any) (string, error) { + commentNodeID, err := RequiredParam[string](args, "commentNodeID") + if err != nil { + return "", err + } + if strings.TrimSpace(commentNodeID) == "" { + return "", fmt.Errorf("commentNodeID cannot be blank") + } + return commentNodeID, nil +} + +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // The GitHub API silently ignores an invalid ReplyToID and creates a top-level + // comment instead of returning an error, so we validate upfront that the node + // exists and is a DiscussionComment to give callers a clear failure. + var nodeQuery struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + } + if err := client.Query(ctx, &nodeQuery, map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }); err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil + } + if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID { + return utils.NewToolResultError( + fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo), + ), nil, nil + } + + replyToID := githubv4.ID(commentNodeID) + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID, + Body: githubv4.String(body), + ReplyToID: &replyToID, + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID(commentNodeID), + Body: githubv4.String(body), + } + + var mutation struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.UpdateDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID(commentNodeID), + } + + var mutation struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.DeleteDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 692ef2ec83..fb9d0c5649 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -647,10 +647,11 @@ func Test_GetDiscussionComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "includeReplies") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]any{ @@ -666,8 +667,8 @@ func Test_GetDiscussionComments(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "This is the first comment"}, - {"body": "This is the second comment"}, + {"id": "DC_id1", "body": "This is the first comment"}, + {"id": "DC_id2", "body": "This is the second comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -701,7 +702,10 @@ func Test_GetDiscussionComments(t *testing.T) { // (Lines removed) var response struct { - Comments []*github.IssueComment `json:"comments"` + Comments []struct { + ID string `json:"id"` + Body string `json:"body"` + } `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` @@ -713,17 +717,17 @@ func Test_GetDiscussionComments(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Len(t, response.Comments, 2) - expectedBodies := []string{"This is the first comment", "This is the second comment"} - for i, comment := range response.Comments { - assert.Equal(t, expectedBodies[i], *comment.Body) - } + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "This is the first comment", response.Comments[0].Body) + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Equal(t, "This is the second comment", response.Comments[1].Body) } func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { // Test that WeakDecode handles string discussionNumber from MCP clients toolDef := GetDiscussionComments(translations.NullTranslationHelper) - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" vars := map[string]any{ "owner": "owner", @@ -738,7 +742,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "First comment"}, + {"id": "DC_id3", "body": "First comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -777,6 +781,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { } require.NoError(t, json.Unmarshal([]byte(textContent.Text), &out)) assert.Len(t, out.Comments, 1) + assert.Equal(t, "DC_id3", out.Comments[0]["id"]) assert.Equal(t, "First comment", out.Comments[0]["body"]) } @@ -924,3 +929,896 @@ func Test_ListDiscussionCategories(t *testing.T) { }) } } + +func Test_DiscussionCommentWrite(t *testing.T) { + t.Parallel() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "discussion_comment_write", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "discussion_comment_write should not be read-only") + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint, "discussion_comment_write should be destructive") + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "method") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "commentNodeID") + assert.ElementsMatch(t, schema.Required, []string{"method"}) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "method: missing", + requestArgs: map[string]any{}, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "invalid method", + requestArgs: map[string]any{ + "method": "invalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'", + }, + }) +} + +func Test_DiscussionCommentWrite_Add(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "add: successful comment creation", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a test comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a test comment"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "add: discussion not found", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(999), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to a Discussion with the number of 999."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a Discussion with the number of 999.", + }, + { + name: "add: mutation failure", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a comment"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to comment on this discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to comment on this discussion", + }, + { + name: "add: missing body", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Reply(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "reply: successful reply to comment", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ), + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a reply"), + ReplyToID: githubv4ptr("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOReply789", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOReply789", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + { + name: "reply: missing commentNodeID", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "reply: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "reply: invalid commentNodeID returns error", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOInvalid", + githubv4mock.DataResponse(map[string]any{ + "node": nil, + }), + ), + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOInvalid" does not resolve to a valid discussion comment`, + }, + { + name: "reply: comment from another discussion is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOOtherDiscussion456", + }, + }, + }), + ), + discussionQueryMatcher, + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOComment456" does not belong to discussion #1 in owner/repo`, + }, + { + name: "reply: validation query failure", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOComment456'."), + ), + ), + expectToolError: true, + expectedErrMsg: "failed to validate commentNodeID: Could not resolve to a node with the global id of 'DC_kwDOComment456'.", + }, + }) +} + +func Test_DiscussionCommentWrite_Update(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "update: successful comment update", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "update: comment not found", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOInvalid", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOInvalid"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "update: insufficient permissions", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to update this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to update this discussion comment", + }, + { + name: "update: missing commentNodeID", + requestArgs: map[string]any{ + "method": "update", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "update: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": " ", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "update: missing body", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Delete(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "delete: successful comment delete", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "delete: comment not found", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOInvalid"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "delete: insufficient permissions", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to delete this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to delete this discussion comment", + }, + { + name: "delete: missing commentNodeID", + requestArgs: map[string]any{ + "method": "delete", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + }) +} + +func Test_DiscussionCommentWrite_MarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "mark_answer: successful mark as answer", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "markDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "mark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("discussion is not a Q&A discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "discussion is not a Q&A discussion", + }, + { + name: "mark_answer: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + }) +} + +func Test_DiscussionCommentWrite_UnmarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "unmark_answer: successful unmark as answer", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unmarkDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "unmark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions", + }, + }) +} + +type discussionCommentWriteTestCase struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedErrMsg string + expectedID string + expectedURL string + expectedDiscussionID string + expectedDiscussionURL string +} + +func runDiscussionCommentWriteTests(t *testing.T, tests []discussionCommentWriteTestCase) { + t.Helper() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClient := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + req := createMCPRequest(tc.requestArgs) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + + if tc.expectToolError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.expectedErrMsg) + return + } + + require.False(t, res.IsError) + + if tc.expectedDiscussionID != "" { + var response struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedDiscussionID, response.DiscussionID) + assert.Equal(t, tc.expectedDiscussionURL, response.DiscussionURL) + } else { + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedID, response.ID) + assert.Equal(t, tc.expectedURL, response.URL) + } + }) + } +} + +func discussionCommentWriteDiscussionQueryMatcher(discussionNumber int32, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(discussionNumber), + }, + response, + ) +} + +func discussionCommentWriteReplyValidationQueryMatcher(commentNodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + }{}, + map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }, + response, + ) +} + +func githubv4ptr(id githubv4.ID) *githubv4.ID { + return &id +} + +func Test_GetDiscussionCommentsWithReplies(t *testing.T) { + t.Parallel() + + toolDef := GetDiscussionComments(translations.NullTranslationHelper) + + qWithReplies := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer,replies(first: 100){nodes{id,body,isAnswer},totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), + } + + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + { + "id": "DC_id1", + "body": "Top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{ + {"id": "DC_reply1", "body": "Reply to first comment", "isAnswer": true}, + }, + "totalCount": 1, + }, + }, + { + "id": "DC_id2", + "body": "Another top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{}, + "totalCount": 0, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }, + }) + + matcher := githubv4mock.NewQueryMatcher(qWithReplies, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "includeReplies": true, + } + req := createMCPRequest(reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + require.False(t, res.IsError, "expected no error, got: %s", text) + + var response struct { + Comments []MinimalDiscussionComment `json:"comments"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Len(t, response.Comments, 2) + + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "Top-level comment", response.Comments[0].Body) + require.Len(t, response.Comments[0].Replies, 1) + assert.Equal(t, "DC_reply1", response.Comments[0].Replies[0].ID) + assert.Equal(t, "Reply to first comment", response.Comments[0].Replies[0].Body) + assert.True(t, response.Comments[0].Replies[0].IsAnswer) + assert.Equal(t, 1, response.Comments[0].ReplyTotalCount) + + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Empty(t, response.Comments[1].Replies) + assert.Equal(t, 0, response.Comments[1].ReplyTotalCount) +} diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 9aa6c16325..bc9e25d1ee 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -51,6 +51,15 @@ type MinimalSearchRepositoriesResult struct { Items []MinimalRepository `json:"items"` } +// MinimalDiscussionComment is the trimmed output type for discussion comment objects. +type MinimalDiscussionComment struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` + Replies []MinimalDiscussionComment `json:"replies,omitempty"` + ReplyTotalCount int `json:"replyTotalCount,omitempty"` +} + // MinimalCodeSearchResult is the trimmed output type for code search results. type MinimalCodeSearchResult struct { TotalCount int `json:"total_count"` diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 011ec9c9c1..4139553235 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -259,6 +259,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListDiscussions(t), GetDiscussion(t), GetDiscussionComments(t), + DiscussionCommentWrite(t), ListDiscussionCategories(t), // Actions tools From 1861a351f8b00ae5ff59bbd30a720545fdfc5f92 Mon Sep 17 00:00:00 2001 From: Iulia Bejan <64602043+iulia-b@users.noreply.github.com> Date: Mon, 18 May 2026 14:36:23 +0200 Subject: [PATCH 13/17] Upgrade go-github from v82 to v87 (#2452) Breaking changes addressed: - raw.NewClient: Use WithHTTPClient/WithEnterpriseURLs options, pass ctx to NewRequest, return (*Client, error) - internal/ghmcp/server.go: Use functional options for REST client creation, replace UserAgent field mutation with UserAgentTransport wrapper, add restUATransp field to githubClients struct - pkg/github/dependencies.go: Use functional options for REST client creation, handle raw.NewClient error return - pkg/github/actions.go: Handle new WorkflowDispatchRunDetails return value from CreateWorkflowDispatchEventByID/ByFileName - pkg/github/issues.go: Replace IssueListOptions with ListOptions for SubIssue.ListByIssue - pkg/github/notifications.go: MarkThreadDone now takes string instead of int64; remove ParseInt and strconv import - pkg/github/projects.go: Remove pointer indirection from ListProjectsPaginationOptions and ListProjectsOptions fields - pkg/github/issues_granular.go: Pass ctx to NewRequest, remove ctx from Do - Test files: Add mustNewGHClient helper, replace all NewClient calls, fix stubClientFnFromHTTP signature, fix lockdown_test.go BaseURL handling, fix raw_test.go, remove invalid threadID test case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/e2e_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/ghmcp/server.go | 51 +++++++++++------- pkg/errors/error.go | 2 +- pkg/errors/error_test.go | 2 +- pkg/github/actions.go | 6 +-- pkg/github/actions_test.go | 26 ++++----- pkg/github/code_scanning.go | 2 +- pkg/github/code_scanning_test.go | 6 +-- pkg/github/context_tools_test.go | 16 +++--- pkg/github/copilot.go | 2 +- pkg/github/copilot_test.go | 4 +- pkg/github/dependabot.go | 2 +- pkg/github/dependabot_test.go | 6 +-- pkg/github/dependencies.go | 19 ++++--- pkg/github/discussions.go | 2 +- pkg/github/discussions_test.go | 2 +- pkg/github/gists.go | 2 +- pkg/github/gists_test.go | 10 ++-- pkg/github/git.go | 2 +- pkg/github/git_test.go | 4 +- pkg/github/granular_tools_test.go | 28 +++++----- pkg/github/helper_test.go | 17 ++++++ pkg/github/issues.go | 10 ++-- pkg/github/issues_granular.go | 6 +-- pkg/github/issues_test.go | 48 ++++++++--------- pkg/github/minimal_types.go | 2 +- pkg/github/notifications.go | 11 +--- pkg/github/notifications_test.go | 30 +++-------- pkg/github/params.go | 2 +- pkg/github/params_test.go | 2 +- pkg/github/projects.go | 29 +++------- pkg/github/projects_test.go | 31 ++++++----- pkg/github/pullrequests.go | 2 +- pkg/github/pullrequests_granular.go | 2 +- pkg/github/pullrequests_test.go | 34 ++++++------ pkg/github/repositories.go | 2 +- pkg/github/repositories_helper.go | 2 +- pkg/github/repositories_test.go | 53 ++++++++++--------- pkg/github/repository_resource.go | 2 +- pkg/github/repository_resource_completions.go | 2 +- .../repository_resource_completions_test.go | 2 +- pkg/github/repository_resource_test.go | 11 ++-- pkg/github/search.go | 2 +- pkg/github/search_test.go | 20 +++---- pkg/github/search_utils.go | 2 +- pkg/github/secret_scanning.go | 2 +- pkg/github/secret_scanning_test.go | 6 +-- pkg/github/security_advisories.go | 2 +- pkg/github/security_advisories_test.go | 10 ++-- pkg/github/server_test.go | 9 ++-- pkg/github/tools.go | 2 +- pkg/lockdown/lockdown.go | 2 +- pkg/lockdown/lockdown_test.go | 7 ++- pkg/raw/raw.go | 22 ++++---- pkg/raw/raw_test.go | 14 +++-- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- .../go-github/{v82 => v87}/github/LICENSE | 0 61 files changed, 304 insertions(+), 304 deletions(-) rename third-party/github.com/google/go-github/{v82 => v87}/github/LICENSE (100%) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ad40ecad02..73d5f271c9 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 89cafc377d..3d7ad06a58 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v82 v82.0.0 + github.com/google/go-github/v87 v87.0.0 github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 diff --git a/go.sum b/go.sum index 615b4e9c0c..defedd4819 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= -github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= +github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index b1925bffd3..6c8c3934d5 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -24,18 +24,19 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { - rest *gogithub.Client - gql *githubv4.Client - gqlHTTP *http.Client // retained for middleware to modify transport - raw *raw.Client - repoAccess *lockdown.RepoAccessCache + rest *gogithub.Client + restUATransp *transport.UserAgentTransport + gql *githubv4.Client + gqlHTTP *http.Client // retained for middleware to modify transport + raw *raw.Client + repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. @@ -61,10 +62,18 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = restURL - restClient.UploadURL = uploadURL + restUATransport := &transport.UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + } + restClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host @@ -80,7 +89,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) - rawClient := raw.NewClient(restClient, rawURL) + rawClient, err := raw.NewClient(restClient, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } // Set up repo access cache for lockdown mode var repoAccessCache *lockdown.RepoAccessCache @@ -95,11 +107,12 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } return &githubClients{ - rest: restClient, - gql: gqlClient, - gqlHTTP: gqlHTTPClient, - raw: rawClient, - repoAccess: repoAccessCache, + rest: restClient, + restUATransp: restUATransport, + gql: gqlClient, + gqlHTTP: gqlHTTPClient, + raw: rawClient, + repoAccess: repoAccessCache, }, nil } @@ -170,7 +183,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se github.RegisterUIResources(ghServer) } - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) return ghServer, nil } @@ -345,7 +358,7 @@ func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory } } -func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { +func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { if method != "initialize" { @@ -368,7 +381,7 @@ func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Cl userAgent += " (insiders)" } - restClient.UserAgent = userAgent + restUATransp.Agent = userAgent gqlHTTPClient.Transport = &transport.UserAgentTransport{ Transport: gqlHTTPClient.Transport, diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d757651592..7c1f28e660 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index e33d5bd39e..7459569f2a 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 85afed6e1b..a7ce039d83 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -989,10 +989,10 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var workflowType string if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) workflowType = "workflow_id" } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) workflowType = "workflow_file" } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6eba71b8b3..371bbbe9dc 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -86,7 +86,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -136,7 +136,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -185,7 +185,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -241,7 +241,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -284,7 +284,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -412,7 +412,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -449,7 +449,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -480,7 +480,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -504,7 +504,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -556,7 +556,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -618,7 +618,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -668,7 +668,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 34249b2129..2deefd321c 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 7a3c16fd15..64c61766ed 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,7 +80,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -206,7 +206,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 510372cd9b..2b17be86d1 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -99,7 +99,7 @@ func Test_GetMe(t *testing.T) { deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()} } else { obs := stubExporters() - deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs} + deps = BaseDeps{Client: mustNewGHClient(t, tc.mockedClient), Obsv: obs} } handler := serverTool.Handler(deps) @@ -155,7 +155,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -170,7 +170,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled includes ifc label in result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -326,7 +326,7 @@ func Test_GetTeams(t *testing.T) { name: "successful get teams", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientForTestuser(), } }, @@ -351,7 +351,7 @@ func Test_GetTeams(t *testing.T) { name: "no teams found", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientNoTeams(), } }, @@ -372,7 +372,7 @@ func Test_GetTeams(t *testing.T) { name: "get user fails", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientUserFails()), + Client: mustNewGHClient(t, httpClientUserFails()), Obsv: stubExporters(), } }, @@ -384,7 +384,7 @@ func Test_GetTeams(t *testing.T) { name: "getting GraphQL client fails", makeDeps: func() ToolDependencies { return stubDeps{ - clientFn: stubClientFnFromHTTP(httpClientWithUser()), + clientFn: stubClientFnFromHTTP(t, httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters(), } diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index d95357e738..017bb98bc9 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 0a1d5ef3b6..b86f26f474 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -932,7 +932,7 @@ func Test_RequestCopilotReview(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := RequestCopilotReview(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 541cc5c1e7..ccb36f4839 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 6c9b95ca36..2196b6b13f 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,7 +89,7 @@ func Test_GetDependabotAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -243,7 +243,7 @@ func Test_ListDependabotAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index aad213e4e5..16be84efb4 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -320,10 +320,14 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version) - restClient.BaseURL = baseRestURL - restClient.UploadURL = uploadURL + restClient, err := gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithUserAgent(fmt.Sprintf("github-mcp-server/%s", d.version)), + gogithub.WithEnterpriseURLs(baseRestURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } return restClient, nil } @@ -370,7 +374,10 @@ func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - rawClient := raw.NewClient(client, rawURL) + rawClient, err := raw.NewClient(client, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } return rawClient, nil } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 4ecf7e2905..514a2d030d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index fb9d0c5649..36fdb6c43a 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/gists.go b/pkg/github/gists.go index a0bc1b0855..de577af04d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 74cd45d276..342cd0c8f5 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -141,7 +141,7 @@ func Test_ListGists(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -252,7 +252,7 @@ func Test_GetGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -392,7 +392,7 @@ func Test_CreateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -545,7 +545,7 @@ func Test_UpdateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/git.go b/pkg/github/git.go index 33a1f94efa..515d8b65f8 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index cef65c9ef4..1ad7147507 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,7 +125,7 @@ func Test_GetRepositoryTree(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 37a718f373..72ed1939d5 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -176,7 +176,7 @@ func TestGranularCreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} serverTool := GranularCreateIssue(translations.NullTranslationHelper) handler := serverTool.Handler(deps) @@ -196,7 +196,7 @@ func TestGranularCreateIssue(t *testing.T) { } func TestGranularUpdateIssueTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(42), Title: gogithub.Ptr("New Title"), @@ -218,7 +218,7 @@ func TestGranularUpdateIssueTitle(t *testing.T) { } func TestGranularUpdateIssueBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "body": "Updated body", }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ @@ -242,7 +242,7 @@ func TestGranularUpdateIssueBody(t *testing.T) { } func TestGranularUpdateIssueAssignees(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "assignees": []any{"user1", "user2"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -263,7 +263,7 @@ func TestGranularUpdateIssueAssignees(t *testing.T) { } func TestGranularUpdateIssueLabels(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "labels": []any{"bug", "enhancement"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -284,7 +284,7 @@ func TestGranularUpdateIssueLabels(t *testing.T) { } func TestGranularUpdateIssueMilestone(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "milestone": float64(5), }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -342,7 +342,7 @@ func TestGranularUpdateIssueType(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), })) @@ -390,7 +390,7 @@ func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - deps := BaseDeps{Client: gogithub.NewClient(MockHTTPClientWithHandlers(nil))} + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) handler := serverTool.Handler(deps) @@ -440,7 +440,7 @@ func TestGranularUpdateIssueState(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(1), @@ -462,7 +462,7 @@ func TestGranularUpdateIssueState(t *testing.T) { // --- Pull request granular tool handler tests --- func TestGranularUpdatePullRequestTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "title": "New PR Title", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -486,7 +486,7 @@ func TestGranularUpdatePullRequestTitle(t *testing.T) { } func TestGranularUpdatePullRequestBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "body": "Updated description", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -510,7 +510,7 @@ func TestGranularUpdatePullRequestBody(t *testing.T) { } func TestGranularUpdatePullRequestState(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "state": "closed", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -534,7 +534,7 @@ func TestGranularUpdatePullRequestState(t *testing.T) { } func TestGranularRequestPullRequestReviewers(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}), })) deps := BaseDeps{Client: client} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 892b3045c8..4181f102e4 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" @@ -179,6 +180,22 @@ type expectations struct { requestBody any } +// mustNewGHClient creates a new GitHub client for testing. +// If httpClient is nil, a client with no options is created. +// The test fails immediately if client creation fails. +func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client { + t.Helper() + var client *gogithub.Client + var err error + if httpClient == nil { + client, err = gogithub.NewClient() + } else { + client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient)) + } + require.NoError(t, err) + return client +} + // expect is a helper function to create a partial mock that expects various // request behaviors, such as path, query parameters, and request body. func expect(t *testing.T, e expectations) *partialMock { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 98585e291e..52a024c298 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -452,11 +452,9 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } featureFlags := deps.GetFlags(ctx) - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 973032c4ab..5b335bd443 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -410,13 +410,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser } apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) - req, err := client.NewRequest("PATCH", apiURL, body) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) if err != nil { return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } issue := &github.Issue{} - resp, err := client.Do(ctx, req, issue) + resp, err := client.Do(req, issue) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d23c22ed5c..6b4042bac5 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -225,7 +225,7 @@ func Test_GetIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.restPermission != "" { @@ -324,7 +324,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, 0)), + Client: mustNewGHClient(t, makeMockClient(false, 0)), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -339,7 +339,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, 0)), + Client: mustNewGHClient(t, makeMockClient(false, 0)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -357,7 +357,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(true, 0)), + Client: mustNewGHClient(t, makeMockClient(true, 0)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -375,7 +375,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, http.StatusInternalServerError)), + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -461,7 +461,7 @@ func Test_AddIssueComment(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -763,7 +763,7 @@ func Test_SearchIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -868,7 +868,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -883,7 +883,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -905,7 +905,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { makeIssue("octocat", "public-repo", 2), }} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ {owner: "octocat", repo: "private-repo", isPrivate: true}, {owner: "octocat", repo: "public-repo"}, })), @@ -927,7 +927,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, })), Flags: FeatureFlags{InsidersMode: true}, @@ -948,7 +948,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, nil)), + Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -1090,7 +1090,7 @@ func Test_CreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -1144,7 +1144,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { serverTool := IssueWrite(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) @@ -1226,7 +1226,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { }) completedReason := IssueClosedStateReasonCompleted - closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), })) closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( @@ -2191,7 +2191,7 @@ func Test_UpdateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup clients with mocks - restClient := github.NewClient(tc.mockedRESTClient) + restClient := mustNewGHClient(t, tc.mockedRESTClient) gqlClient := githubv4.NewClient(tc.mockedGQLClient) deps := BaseDeps{ Client: restClient, @@ -2417,7 +2417,7 @@ func Test_GetIssueComments(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2546,7 +2546,7 @@ func Test_GetIssueLabels(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) - client := github.NewClient(nil) + client := mustNewGHClient(t, nil) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -2753,7 +2753,7 @@ func Test_AddSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2974,7 +2974,7 @@ func Test_GetSubIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -3193,7 +3193,7 @@ func Test_RemoveSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3453,7 +3453,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3569,7 +3569,7 @@ func Test_ListIssueTypes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index bc9e25d1ee..65a18ade88 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -3,7 +3,7 @@ package github import ( "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/github/github-mcp-server/pkg/sanitize" ) diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index ddd3023932..61d8f40b2e 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -14,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +208,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT var resp *github.Response switch state { case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil, nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + resp, err = client.Activity.MarkThreadDone(ctx, threadID) case "read": resp, err = client.Activity.MarkThreadRead(ctx, threadID) default: diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 030367d067..bcfc28abc2 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,7 +108,7 @@ func Test_ListNotifications(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -232,7 +232,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +386,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -456,7 +456,6 @@ func Test_DismissNotification(t *testing.T) { expectError bool expectRead bool expectDone bool - expectInvalid bool expectedErrMsg string }{ { @@ -495,16 +494,6 @@ func Test_DismissNotification(t *testing.T) { expectError: false, expectDone: true, }, - { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), @@ -534,7 +523,7 @@ func Test_DismissNotification(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -552,8 +541,6 @@ func Test_DismissNotification(t *testing.T) { assert.Contains(t, text, "missing required parameter: threadID") case tc.requestArgs["state"] == nil: assert.Contains(t, text, "missing required parameter: state") - case tc.name == "invalid threadID format": - assert.Contains(t, text, "invalid threadID format") case tc.name == "invalid state value": assert.Contains(t, text, "Invalid state. Must be one of: read, done.") default: @@ -571,9 +558,6 @@ func Test_DismissNotification(t *testing.T) { if tc.expectDone { assert.Contains(t, textContent.Text, "Notification marked as done") } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "invalid threadID format") - } }) } } @@ -647,7 +631,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -725,7 +709,7 @@ func Test_GetNotificationDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/params.go b/pkg/github/params.go index 1b45d61bd8..ecdc8c3549 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 2254b737eb..b00efeb10c 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index dcb9193eca..a5953f3be5 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -618,16 +618,11 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } // If owner_type not provided, fetch from both user and org @@ -801,17 +796,12 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin var resp *github.Response var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } opts := &github.ListProjectItemsOptions{ Fields: fields, ListProjectsOptions: github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, }, } @@ -1387,16 +1377,9 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP } opts := github.ListProjectsPaginationOptions{ - PerPage: &perPage, - } - - // Only set After/Before if they have non-empty values - if after != "" { - opts.After = &after - } - - if before != "" { - opts.Before = &before + PerPage: perPage, + After: after, + Before: before, } return opts, nil diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 9b0e07292f..512506476c 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -9,7 +9,6 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - gh "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -100,7 +99,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -140,7 +139,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -167,7 +166,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -196,7 +195,7 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -249,7 +248,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -274,7 +273,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -304,7 +303,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -330,7 +329,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -360,7 +359,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +385,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -711,7 +710,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -741,7 +740,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { t.Run("missing updated_field", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -772,7 +771,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -795,7 +794,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -864,7 +863,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - Client: gh.NewClient(restClient), + Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, } handler := toolDef.Handler(deps) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 0065b25a92..3653c906ba 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 4a616f1b25..30d7f78d62 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 36a0207cc0..29339ee7db 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -95,7 +95,7 @@ func Test_GetPullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) deps := BaseDeps{ Client: client, @@ -327,7 +327,7 @@ func Test_UpdatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -511,7 +511,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), })) gqlClient := githubv4.NewClient(tc.mockedClient) @@ -641,7 +641,7 @@ func Test_ListPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := ListPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -759,7 +759,7 @@ func Test_MergePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := MergePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1038,7 +1038,7 @@ func Test_SearchPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := SearchPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1197,7 +1197,7 @@ func Test_GetPullRequestFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1357,7 +1357,7 @@ func Test_GetPullRequestStatus(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1513,7 +1513,7 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1641,7 +1641,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1949,7 +1949,7 @@ func Test_GetPullRequestComments(t *testing.T) { flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ - Client: github.NewClient(nil), + Client: mustNewGHClient(t, nil), GQLClient: gqlClient, RepoAccessCache: cache, Flags: flags, @@ -2133,7 +2133,7 @@ func Test_GetPullRequestReviews(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2300,7 +2300,7 @@ func Test_CreatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := CreatePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -2356,7 +2356,7 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { serverTool := CreatePullRequest(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), })) @@ -3372,7 +3372,7 @@ index 5d6e7b2..8a4f5c3 100644 t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -3609,7 +3609,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 156df3dd34..2ca1cf3a7a 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index a347ebdd6c..be377f773e 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index d90a010695..a44bad65b6 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -412,8 +412,9 @@ func Test_GetFileContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -519,7 +520,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false)), + Client: mustNewGHClient(t, makeMockClient(false)), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -534,7 +535,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false)), + Client: mustNewGHClient(t, makeMockClient(false)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -559,7 +560,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(true)), + Client: mustNewGHClient(t, makeMockClient(true)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -603,7 +604,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { }, }) deps := BaseDeps{ - Client: github.NewClient(mockedClient), + Client: mustNewGHClient(t, mockedClient), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -690,7 +691,7 @@ func Test_ForkRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -862,7 +863,7 @@ func Test_CreateBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -988,7 +989,7 @@ func Test_GetCommit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1279,7 +1280,7 @@ func Test_ListCommits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1636,7 +1637,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1825,7 +1826,7 @@ func Test_CreateRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2563,7 +2564,7 @@ func Test_PushFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2684,7 +2685,7 @@ func Test_ListBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create mock client - mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2872,7 +2873,7 @@ func Test_DeleteFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2999,7 +3000,7 @@ func Test_ListTags(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3190,7 +3191,7 @@ func Test_GetTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3316,7 +3317,7 @@ func Test_ListReleases(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3408,7 +3409,7 @@ func Test_GetLatestRelease(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3556,7 +3557,7 @@ func Test_GetReleaseByTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4001,7 +4002,7 @@ func Test_resolveGitReference(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockSetup()) + client := mustNewGHClient(t, tc.mockSetup()) opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) if tc.expectError { @@ -4147,7 +4148,7 @@ func Test_ListStarredRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4248,7 +4249,7 @@ func Test_StarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4339,7 +4340,7 @@ func Test_UnstarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4468,7 +4469,7 @@ func Test_ListRepositoryCollaborators(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index be86cc4519..3ab4cf3906 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index ff9e23398a..18e7eb5f01 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repository_resource_completions_test.go b/pkg/github/repository_resource_completions_test.go index e5f1a35f93..33df2761e6 100644 --- a/pkg/github/repository_resource_completions_test.go +++ b/pkg/github/repository_resource_completions_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index f0fba30dfb..cb57bae545 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/google/go-github/v82/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -246,8 +245,9 @@ func Test_repositoryResourceContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -290,8 +290,9 @@ func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { networkErr := errors.New("network error: connection refused") httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} - client := github.NewClient(httpClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, httpClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, diff --git a/pkg/github/search.go b/pkg/github/search.go index a44add8bb0..a4acc44489 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 13e787a67c..74a3ca52fc 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -123,7 +123,7 @@ func Test_SearchRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -205,7 +205,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -219,7 +219,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "public-a"}, {owner: "octocat", name: "public-b"}, })), @@ -240,7 +240,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "private-repo", isPrivate: true}, {owner: "octocat", name: "public-repo"}, })), @@ -261,7 +261,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(nil)), + Client: mustNewGHClient(t, makeMockClient(nil)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -304,7 +304,7 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { ), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -458,7 +458,7 @@ func Test_SearchCode(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -649,7 +649,7 @@ func Test_SearchUsers(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -812,7 +812,7 @@ func Test_SearchOrgs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index a0634d9795..ac3aec90c9 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 676c2c1625..5cbe52c42a 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 7c53de35c5..1aa451e053 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,7 +79,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -211,7 +211,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index e86e220eaf..ec84e27b15 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 3d4df43e63..f45c2e4210 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,7 +92,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -204,7 +204,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -337,7 +337,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -467,7 +467,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 264ffa50fe..7af388f731 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -80,9 +80,10 @@ func stubExporters() observability.Exporters { return obs } -func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { +func stubClientFnFromHTTP(t *testing.T, httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { + t.Helper() return func(_ context.Context) (*gogithub.Client, error) { - return gogithub.NewClient(httpClient), nil + return mustNewGHClient(t, httpClient), nil } } @@ -110,7 +111,7 @@ func stubRepoAccessCache(restClient *gogithub.Client, ttl time.Duration) *lockdo func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[string]string) *gogithub.Client { t.Helper() - return gogithub.NewClient(MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + return mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { perm := defaultPerm for user, p := range overrides { if strings.Contains(r.URL.Path, "/collaborators/"+user+"/") { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 4139553235..f4c653bf8d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -7,7 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 6edb4469d9..f787875b2e 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index 55e755a3ec..bb8887e709 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -4,13 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "net/url" "sync" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" ) @@ -81,8 +80,8 @@ func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, _ = json.NewEncoder(w).Encode(resp) })) t.Cleanup(restServer.Close) - restClient := gogithub.NewClient(nil) - restClient.BaseURL, _ = url.Parse(restServer.URL + "/") + restClient, err := gogithub.NewClient(gogithub.WithEnterpriseURLs(restServer.URL+"/", restServer.URL+"/")) + require.NoError(t, err) return NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl)), counting } diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index df9cd0ad11..4f794ac1f6 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" ) // GetRawClientFn is a function type that returns a RawClient instance. @@ -19,19 +19,19 @@ type Client struct { } // NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL. -func NewClient(client *gogithub.Client, rawURL *url.URL) *Client { - client = gogithub.NewClient(client.Client()) - client.BaseURL = rawURL - return &Client{client: client, url: rawURL} -} - -func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { - req, err := c.client.NewRequest(method, urlStr, body, opts...) +func NewClient(client *gogithub.Client, rawURL *url.URL) (*Client, error) { + newClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(client.Client()), + gogithub.WithEnterpriseURLs(rawURL.String(), rawURL.String()), + ) if err != nil { return nil, err } - req = req.WithContext(ctx) - return req, nil + return &Client{client: newClient, url: rawURL}, nil +} + +func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { + return c.client.NewRequest(ctx, method, urlStr, body, opts...) } func (c *Client) refURL(owner, repo, ref, path string) string { diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 6897f492f6..60137684d7 100644 --- a/pkg/raw/raw_test.go +++ b/pkg/raw/raw_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/require" ) @@ -108,8 +108,10 @@ func TestGetRawContent(t *testing.T) { body: tc.body, }, } - ghClient := github.NewClient(mockedClient) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(mockedClient)) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts) defer func() { _ = resp.Body.Close() @@ -133,8 +135,10 @@ func TestGetRawContent(t *testing.T) { func TestUrlFromOpts(t *testing.T) { base, _ := url.Parse("https://raw.example.com/") - ghClient := github.NewClient(nil) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(&http.Client{})) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) tests := []struct { name string diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 2e5ca59ec2..2aebd6fa0b 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -17,7 +17,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index d818469896..4e68656673 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6efed3338c..91b314dcb5 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party/github.com/google/go-github/v82/github/LICENSE b/third-party/github.com/google/go-github/v87/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v82/github/LICENSE rename to third-party/github.com/google/go-github/v87/github/LICENSE From f4b95e6acc42a4ad4d1e161c1b2b65e8874f4453 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:28:19 +0200 Subject: [PATCH 14/17] build(deps): bump golang from 1.25.9-alpine to 1.25.10-alpine (#2455) Bumps golang from 1.25.9-alpine to 1.25.10-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.10-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5036ba8b9d..70603aed03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS build +FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build ARG VERSION="dev" # Set the working directory From 8d81376a599bb83eb8c6b2a9186c673179b8c8fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:38:27 +0200 Subject: [PATCH 15/17] build(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.2.1 (#2396) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.2.1. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index f8eddc076c..4ce1c9d341 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,7 +35,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 with: distribution: goreleaser # GoReleaser version From 8af3431ed4db0118b682d1f718642c6b4b187eaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:45:49 +0200 Subject: [PATCH 16/17] build(deps): bump docker/login-action from 4.0.0 to 4.1.0 (#2395) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/b45d80f862d83dbcd57f89517bcf500b2ab88fb2...4907a6ddec9925e35a0a9e82d7399ccc52663121) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 638713c700..61cbf5e8a0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From c471ae94bb04059dc26e12c305e219c8fd4299e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:54:16 +0200 Subject: [PATCH 17/17] build(deps): bump sigstore/cosign-installer from 4.1.0 to 4.1.2 (#2394) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.0 to 4.1.2. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/ba7bc0a3fef59531c69a25acd34668d6d3fe6f22...6f9f17788090df1f26f669e9d70d6ae9567deba6) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 61cbf5e8a0..5e579aaafd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: cosign-release: "v2.2.4"