9ad99c52c8
* 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.
144 lines
4.1 KiB
Go
144 lines
4.1 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
|
|
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/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func hasFilter(query, filterType string) bool {
|
|
// Match filter at start of string, after whitespace, or after non-word characters like '('
|
|
pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType))
|
|
matched, _ := regexp.MatchString(pattern, query)
|
|
return matched
|
|
}
|
|
|
|
func hasSpecificFilter(query, filterType, filterValue string) bool {
|
|
// Match specific filter:value at start, after whitespace, or after non-word characters
|
|
// End with word boundary, whitespace, or non-word characters like ')'
|
|
pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue))
|
|
matched, _ := regexp.MatchString(pattern, query)
|
|
return matched
|
|
}
|
|
|
|
func hasRepoFilter(query string) bool {
|
|
return hasFilter(query, "repo")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if !hasSpecificFilter(query, "is", searchType) {
|
|
query = fmt.Sprintf("is:%s %s", searchType, query)
|
|
}
|
|
|
|
owner, err := OptionalParam[string](args, "owner")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
repo, err := OptionalParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
if owner != "" && repo != "" && !hasRepoFilter(query) {
|
|
query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query)
|
|
}
|
|
|
|
sort, err := OptionalParam[string](args, "sort")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil
|
|
}
|
|
order, err := OptionalParam[string](args, "order")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil
|
|
}
|
|
pagination, err := OptionalPaginationParams(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
opts := &github.SearchOptions{
|
|
// Default to "created" if no sort is provided, as it's a common use case.
|
|
Sort: sort,
|
|
Order: order,
|
|
ListOptions: github.ListOptions{
|
|
Page: pagination.Page,
|
|
PerPage: pagination.PerPage,
|
|
},
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil
|
|
}
|
|
result, resp, err := client.Search.Issues(ctx, query, opts)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr(errorPrefix, err), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil
|
|
}
|
|
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
|
|
}
|
|
|
|
r, err := json.Marshal(result)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil
|
|
}
|
|
|
|
callResult := utils.NewToolResultText(string(r))
|
|
if cfg.postProcess != nil {
|
|
cfg.postProcess(ctx, result, callResult)
|
|
}
|
|
return callResult, nil
|
|
}
|