Skip to content

Commit c23b1f9

Browse files
authored
get_file_content Match Paths in Git Tree if Full Path Unknown (github#650)
* add contingency to match path in git tree * resolveGitReference helper * fix: handling of directories * Test_filterPaths * filterPaths - trailing slashes * fix: close response body, improve error messages, docs * update tool result message about resolved git ref * unit test cases for filterPaths maxResults param * resolveGitReference - NewGitHubAPIErrorToCtx
1 parent 42e5ce9 commit c23b1f9

File tree

2 files changed

+317
-45
lines changed

2 files changed

+317
-45
lines changed

pkg/github/repositories.go

Lines changed: 114 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"io"
99
"net/http"
1010
"net/url"
11-
"strconv"
1211
"strings"
1312

1413
ghErrors "github.com/github/github-mcp-server/pkg/errors"
@@ -495,33 +494,18 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
495494
return mcp.NewToolResultError(err.Error()), nil
496495
}
497496

498-
rawOpts := &raw.ContentOpts{}
499-
500-
if strings.HasPrefix(ref, "refs/pull/") {
501-
prNumber := strings.TrimSuffix(strings.TrimPrefix(ref, "refs/pull/"), "/head")
502-
if len(prNumber) > 0 {
503-
// fetch the PR from the API to get the latest commit and use SHA
504-
githubClient, err := getClient(ctx)
505-
if err != nil {
506-
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
507-
}
508-
prNum, err := strconv.Atoi(prNumber)
509-
if err != nil {
510-
return nil, fmt.Errorf("invalid pull request number: %w", err)
511-
}
512-
pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum)
513-
if err != nil {
514-
return nil, fmt.Errorf("failed to get pull request: %w", err)
515-
}
516-
sha = pr.GetHead().GetSHA()
517-
ref = ""
518-
}
497+
client, err := getClient(ctx)
498+
if err != nil {
499+
return mcp.NewToolResultError("failed to get GitHub client"), nil
519500
}
520501

521-
rawOpts.SHA = sha
522-
rawOpts.Ref = ref
502+
rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
503+
if err != nil {
504+
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil
505+
}
523506

524-
// If the path is (most likely) not to be a directory, we will first try to get the raw content from the GitHub raw content API.
507+
// If the path is (most likely) not to be a directory, we will
508+
// first try to get the raw content from the GitHub raw content API.
525509
if path != "" && !strings.HasSuffix(path, "/") {
526510

527511
rawClient, err := getRawClient(ctx)
@@ -580,36 +564,51 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
580564
}
581565
}
582566

583-
client, err := getClient(ctx)
584-
if err != nil {
585-
return mcp.NewToolResultError("failed to get GitHub client"), nil
586-
}
587-
588-
if sha != "" {
589-
ref = sha
567+
if rawOpts.SHA != "" {
568+
ref = rawOpts.SHA
590569
}
591570
if strings.HasSuffix(path, "/") {
592571
opts := &github.RepositoryContentGetOptions{Ref: ref}
593572
_, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
594-
if err != nil {
595-
return mcp.NewToolResultError("failed to get file contents"), nil
596-
}
597-
defer func() { _ = resp.Body.Close() }()
598-
599-
if resp.StatusCode != 200 {
600-
body, err := io.ReadAll(resp.Body)
573+
if err == nil && resp.StatusCode == http.StatusOK {
574+
defer func() { _ = resp.Body.Close() }()
575+
r, err := json.Marshal(dirContent)
601576
if err != nil {
602-
return mcp.NewToolResultError("failed to read response body"), nil
577+
return mcp.NewToolResultError("failed to marshal response"), nil
603578
}
604-
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
579+
return mcp.NewToolResultText(string(r)), nil
605580
}
581+
}
582+
583+
// The path does not point to a file or directory.
584+
// Instead let's try to find it in the Git Tree by matching the end of the path.
585+
586+
// Step 1: Get Git Tree recursively
587+
tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true)
588+
if err != nil {
589+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
590+
"failed to get git tree",
591+
resp,
592+
err,
593+
), nil
594+
}
595+
defer func() { _ = resp.Body.Close() }()
606596

607-
r, err := json.Marshal(dirContent)
597+
// Step 2: Filter tree for matching paths
598+
const maxMatchingFiles = 3
599+
matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles)
600+
if len(matchingFiles) > 0 {
601+
matchingFilesJSON, err := json.Marshal(matchingFiles)
602+
if err != nil {
603+
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil
604+
}
605+
resolvedRefs, err := json.Marshal(rawOpts)
608606
if err != nil {
609-
return mcp.NewToolResultError("failed to marshal response"), nil
607+
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil
610608
}
611-
return mcp.NewToolResultText(string(r)), nil
609+
return mcp.NewToolResultText(fmt.Sprintf("Path did not point to a file or directory, but resolved git ref to %s with possible path matches: %s", resolvedRefs, matchingFilesJSON)), nil
612610
}
611+
613612
return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil
614613
}
615614
}
@@ -1293,3 +1292,74 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
12931292
return mcp.NewToolResultText(string(r)), nil
12941293
}
12951294
}
1295+
1296+
// filterPaths filters the entries in a GitHub tree to find paths that
1297+
// match the given suffix.
1298+
// maxResults limits the number of results returned to first maxResults entries,
1299+
// a maxResults of -1 means no limit.
1300+
// It returns a slice of strings containing the matching paths.
1301+
// Directories are returned with a trailing slash.
1302+
func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string {
1303+
// Remove trailing slash for matching purposes, but flag whether we
1304+
// only want directories.
1305+
dirOnly := false
1306+
if strings.HasSuffix(path, "/") {
1307+
dirOnly = true
1308+
path = strings.TrimSuffix(path, "/")
1309+
}
1310+
1311+
matchedPaths := []string{}
1312+
for _, entry := range entries {
1313+
if len(matchedPaths) == maxResults {
1314+
break // Limit the number of results to maxResults
1315+
}
1316+
if dirOnly && entry.GetType() != "tree" {
1317+
continue // Skip non-directory entries if dirOnly is true
1318+
}
1319+
entryPath := entry.GetPath()
1320+
if entryPath == "" {
1321+
continue // Skip empty paths
1322+
}
1323+
if strings.HasSuffix(entryPath, path) {
1324+
if entry.GetType() == "tree" {
1325+
entryPath += "/" // Return directories with a trailing slash
1326+
}
1327+
matchedPaths = append(matchedPaths, entryPath)
1328+
}
1329+
}
1330+
return matchedPaths
1331+
}
1332+
1333+
// resolveGitReference resolves git references with the following logic:
1334+
// 1. If SHA is provided, it takes precedence
1335+
// 2. If neither is provided, use the default branch as ref
1336+
// 3. Get commit SHA from the ref
1337+
// Refs can look like `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`
1338+
// The function returns the resolved ref, commit SHA and any error.
1339+
func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) {
1340+
// 1. If SHA is provided, use it directly
1341+
if sha != "" {
1342+
return &raw.ContentOpts{Ref: "", SHA: sha}, nil
1343+
}
1344+
1345+
// 2. If neither provided, use the default branch as ref
1346+
if ref == "" {
1347+
repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo)
1348+
if err != nil {
1349+
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err)
1350+
return nil, fmt.Errorf("failed to get repository info: %w", err)
1351+
}
1352+
ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch())
1353+
}
1354+
1355+
// 3. Get the SHA from the ref
1356+
reference, resp, err := githubClient.Git.GetRef(ctx, owner, repo, ref)
1357+
if err != nil {
1358+
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference", resp, err)
1359+
return nil, fmt.Errorf("failed to get reference: %w", err)
1360+
}
1361+
sha = reference.GetObject().GetSHA()
1362+
1363+
// Use provided ref, or it will be empty which defaults to the default branch
1364+
return &raw.ContentOpts{Ref: ref, SHA: sha}, nil
1365+
}

0 commit comments

Comments
 (0)