Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
50 changes: 45 additions & 5 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -130,6 +131,7 @@ type IssueFragment struct {
// Common interface for all issue query types
type IssueQueryResult interface {
GetIssueFragment() IssueQueryFragment
GetIsPrivate() bool
}

type IssueQueryFragment struct {
Expand All @@ -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)"`
}

Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment thread
gokhanarkan marked this conversation as resolved.
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
})
}

Expand Down
173 changes: 171 additions & 2 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,7 @@ func Test_ListIssues(t *testing.T) {
},
"totalCount": 2,
},
"isPrivate": false,
},
})

Expand All @@ -1132,6 +1133,7 @@ func Test_ListIssues(t *testing.T) {
},
"totalCount": 2,
},
"isPrivate": false,
},
})

Expand All @@ -1147,6 +1149,7 @@ func Test_ListIssues(t *testing.T) {
},
"totalCount": 1,
},
"isPrivate": false,
},
})

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading