From 6d3051d0ae1b0597d5d20e5338a952e9114db2ca Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Mon, 22 Dec 2025 12:38:57 +0100 Subject: [PATCH 1/6] Fallback to default branch in get_file_contents when main doesn't exist --- pkg/github/repositories.go | 61 +++++++++++++++++++++------ pkg/github/repositories_test.go | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 1b68b4222..94d0a84b7 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -671,6 +671,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + originalRef := ref + sha, err := OptionalParam[string](args, "sha") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -747,6 +749,12 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } } + // main branch ref passed in ref parameter but it doesn't exist - default branch was used + var successNote string + if !strings.HasSuffix(rawOpts.Ref, originalRef) { + successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) + } + // Determine if content is text or binary isTextContent := strings.HasPrefix(contentType, "text/") || contentType == "application/json" || @@ -762,9 +770,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } // Include SHA in the result metadata if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil, nil + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA)+successNote, result), nil, nil } - return utils.NewToolResultResource("successfully downloaded text file", result), nil, nil + return utils.NewToolResultResource("successfully downloaded text file"+successNote, result), nil, nil } result := &mcp.ResourceContents{ @@ -774,9 +782,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } // Include SHA in the result metadata if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil, nil + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA)+successNote, result), nil, nil } - return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil + return utils.NewToolResultResource("successfully downloaded binary file"+successNote, result), nil, nil } // Raw API call failed @@ -1897,12 +1905,11 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner switch { case originalRef == "": // 2a) If ref is empty, determine the default branch. - repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) - return nil, fmt.Errorf("failed to get repository info: %w", err) + return nil, err // Error is already wrapped in resolveDefaultBranch. } - ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) + ref = reference.GetRef() case strings.HasPrefix(originalRef, "refs/"): // 2b) Already fully qualified. The reference will be fetched at the end. case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): @@ -1928,7 +1935,13 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner ghErr2, isGhErr2 := err.(*github.ErrorResponse) if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { if originalRef == "main" { - return nil, fmt.Errorf("could not find branch or tag 'main'. Some repositories use 'master' as the default branch name") + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + break } return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) } @@ -1949,10 +1962,16 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) if err != nil { if ref == "refs/heads/main" { - return nil, fmt.Errorf("could not find branch 'main'. Some repositories use 'master' as the default branch name") + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + } else { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) } - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) } } @@ -1960,6 +1979,24 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner return &raw.ContentOpts{Ref: ref, SHA: sha}, nil } +func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + defaultBranch := repoInfo.GetDefaultBranch() + + defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) + defer func() { _ = resp.Body.Close() }() + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) + return nil, fmt.Errorf("failed to get default branch reference: %w", err) + } + + return defaultRef, nil +} + // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 6c56d104e..2e885fd7f 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -69,6 +69,7 @@ func Test_GetFileContents(t *testing.T) { expectedResult interface{} expectedErrMsg string expectStatus int + expectedMsg string // optional: expected message text to verify in result }{ { name: "successful text content fetch", @@ -290,6 +291,70 @@ func Test_GetFileContents(t *testing.T) { MIMEType: "text/markdown", }, }, + { + name: "successful text content fetch with note when ref falls back to default branch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "develop"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Request for "refs/heads/main" -> 404 (doesn't exist) + // Request for "refs/heads/develop" (default branch) -> 200 + switch { + case strings.Contains(r.URL.Path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(r.URL.Path, "heads/develop"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoBySHAByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "README.md", + "ref": "main", + }, + expectError: false, + expectedResult: mcp.ResourceContents{ + URI: "repo://owner/repo/abc123def456/contents/README.md", + Text: "# Test Repository\n\nThis is a test repository.", + MIMEType: "text/markdown", + }, + expectedMsg: " Note: the provided ref 'main' does not exist, default branch 'refs/heads/develop' was used instead.", + }, { name: "content fetch fails", mockedClient: mock.NewMockedHTTPClient( @@ -358,6 +423,14 @@ func Test_GetFileContents(t *testing.T) { // Handle both text and blob resources resource := getResourceResult(t, result) assert.Equal(t, expected, *resource) + + // If expectedMsg is set, verify the message text + if tc.expectedMsg != "" { + require.Len(t, result.Content, 2) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected Content[0] to be TextContent") + assert.Contains(t, textContent.Text, tc.expectedMsg) + } case []*github.RepositoryContent: // Directory content fetch returns a text result (JSON array) textContent := getTextResult(t, result) From fda28d2acb578b6f8b03356c273a646d6ec5411e Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Tue, 23 Dec 2025 12:45:31 +0100 Subject: [PATCH 2/6] Addressing review comments --- pkg/github/repositories.go | 39 +++++++++++++++++++++------------ pkg/github/repositories_test.go | 2 +- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 94d0a84b7..c31bb7df2 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -683,7 +683,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get GitHub client"), nil, nil } - rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) + 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 } @@ -751,7 +751,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // main branch ref passed in ref parameter but it doesn't exist - default branch was used var successNote string - if !strings.HasSuffix(rawOpts.Ref, originalRef) { + if fallbackUsed { successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) } @@ -1884,15 +1884,15 @@ func looksLikeSHA(s string) bool { // // Any unexpected (non-404) errors during the resolution process are returned // immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { // 1) If SHA explicitly provided, it's the highest priority. if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, nil + return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil } // 1a) If sha is empty but ref looks like a SHA, return it without changes if looksLikeSHA(ref) { - return &raw.ContentOpts{Ref: "", SHA: ref}, nil + return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil } originalRef := ref // Keep original ref for clearer error messages down the line. @@ -1901,13 +1901,14 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner var reference *github.Reference var resp *github.Response var err error + var fallbackUsed bool switch { case originalRef == "": // 2a) If ref is empty, determine the default branch. reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) if err != nil { - return nil, err // Error is already wrapped in resolveDefaultBranch. + return nil, false, err // Error is already wrapped in resolveDefaultBranch. } ref = reference.GetRef() case strings.HasPrefix(originalRef, "refs/"): @@ -1937,23 +1938,24 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner if originalRef == "main" { reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) if err != nil { - return nil, err // Error is already wrapped in resolveDefaultBranch. + return nil, false, err // Error is already wrapped in resolveDefaultBranch. } // Update ref to the actual default branch ref so the note can be generated ref = reference.GetRef() + fallbackUsed = true break } - return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) } // The tag lookup failed for a different reason. _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) - return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) } } else { // The branch lookup failed for a different reason. _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) - return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) } } } @@ -1964,19 +1966,20 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner if ref == "refs/heads/main" { reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) if err != nil { - return nil, err // Error is already wrapped in resolveDefaultBranch. + return nil, false, err // Error is already wrapped in resolveDefaultBranch. } // Update ref to the actual default branch ref so the note can be generated ref = reference.GetRef() + fallbackUsed = true } else { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) } } } sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, nil + return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil } func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { @@ -1985,15 +1988,23 @@ func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owne _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) return nil, fmt.Errorf("failed to get repository info: %w", err) } + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + defaultBranch := repoInfo.GetDefaultBranch() defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) - defer func() { _ = resp.Body.Close() }() if err != nil { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) return nil, fmt.Errorf("failed to get default branch reference: %w", err) } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + return defaultRef, nil } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 2e885fd7f..8b5dab098 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -3361,7 +3361,7 @@ func Test_resolveGitReference(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockSetup()) - opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) + opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) if tc.expectError { require.Error(t, err) From bf282763c9ebc7b9b7de93df724ac0b9cd450449 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Tue, 23 Dec 2025 01:31:42 +0100 Subject: [PATCH 3/6] Improvements to push_files tool --- pkg/github/repositories.go | 163 +++++++++++-- pkg/github/repositories_test.go | 404 +++++++++++++++++++++++++++++++- 2 files changed, 545 insertions(+), 22 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c31bb7df2..e3126650d 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1279,28 +1279,72 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Get the reference for the branch + var repositoryIsEmpty bool + var branchNotFound bool ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get branch reference", - resp, - err, - ), nil, nil + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr { + if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." { + repositoryIsEmpty = true + } else if ghErr.Response.StatusCode == http.StatusNotFound { + branchNotFound = true + } + } else { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch reference", + resp, + err, + ), nil, nil + } + } + // Only close resp if it's not nil and not an error case where resp might be nil + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() } - defer func() { _ = resp.Body.Close() }() - // Get the commit object that the branch points to - baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get base commit", - resp, - err, - ), nil, nil + var baseCommit *github.Commit + if !repositoryIsEmpty { + if branchNotFound { + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + // Get the commit object that the branch points to + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + resp, + err, + ), nil, nil + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + } else { + // Repository is empty, need to initialize it first + defaultRef, base, err := initializeRepository(ctx, client, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil + } + + if branch != (*defaultRef.Ref)[len("refs/heads/"):] { + // Create the requested branch from the default branch + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } else { + ref = defaultRef + } + + baseCommit = base } - defer func() { _ = resp.Body.Close() }() - // Create tree entries for all files + // Create tree entries for all files (or remaining files if empty repo) var entries []*github.TreeEntry for _, file := range filesObj { @@ -1328,7 +1372,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { }) } - // Create a new tree with the file entries + // Create a new tree with the file entries (baseCommit is now guaranteed to exist) newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1337,9 +1381,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } - // Create a new commit + // Create a new commit (baseCommit always has a value now) commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, @@ -1353,7 +1399,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } // Update the reference to point to the new commit ref.Object.SHA = newCommit.SHA @@ -1380,6 +1428,81 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { ) } +func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { + // First, we need to check what's the default branch in this empty repo should be: + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) + return nil, nil, fmt.Errorf("failed to get repository: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + defaultBranch := repository.GetDefaultBranch() + + fileOpts := &github.RepositoryContentFileOptions{ + Message: github.Ptr("Initial commit"), + Content: []byte(""), + Branch: github.Ptr(defaultBranch), + } + + // Create an initial empty commit to create the default branch + createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) + return nil, nil, fmt.Errorf("failed to create initial file: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Get the commit that was just created to use as base for remaining files + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) + return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Update ref to point to the new commit + ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return ref, baseCommit, nil +} + +func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { + defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) + return nil, fmt.Errorf("failed to resolve default branch: %w", err) + } + + // Create the new branch reference + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ + Ref: *github.Ptr("refs/heads/" + branch), + SHA: *defaultRef.Object.SHA, + }) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) + return nil, fmt.Errorf("failed to create new branch reference: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return createdRef, nil +} + // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b5dab098..1e81d8c53 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -1883,6 +1884,11 @@ func Test_PushFiles(t *testing.T) { mock.GetReposGitRefByOwnerByRepoByRef, mockResponse(t, http.StatusNotFound, nil), ), + // Mock Repositories.Get to fail when trying to create branch from default + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + mockResponse(t, http.StatusNotFound, nil), + ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -1896,8 +1902,8 @@ func Test_PushFiles(t *testing.T) { }, "message": "Update file", }, - expectError: true, - expectedErrMsg: "failed to get branch reference", + expectError: false, + expectedErrMsg: "failed to create branch from default", }, { name: "fails to get base commit", @@ -1962,6 +1968,400 @@ func Test_PushFiles(t *testing.T) { expectError: true, expectedErrMsg: "failed to create tree", }, + { + name: "successful push to empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - first returns 409 for empty repo, second returns success after init + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + // First call: empty repo + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: return the created reference + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(mockRef) + } + } + }(), + ), + // Mock Repositories.Get to return default branch for initialization + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Commit: github.Commit{SHA: github.Ptr("abc123")}, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit after initialization + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Create tree + mock.WithRequestMatch( + mock.PostReposGitTreesByOwnerByRepo, + mockTree, + ), + // Create commit + mock.WithRequestMatch( + mock.PostReposGitCommitsByOwnerByRepo, + mockNewCommit, + ), + // Update reference + mock.WithRequestMatch( + mock.PatchReposGitRefsByOwnerByRepoByRef, + mockUpdatedRef, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Initial README\n\nFirst commit to empty repository.", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "successful push multiple files to empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - called twice: first for empty check, second after file creation + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: returns the updated reference after first file creation + w.WriteHeader(http.StatusOK) + b, _ := json.Marshal(&github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{SHA: github.Ptr("init456")}, + }) + _, _ = w.Write(b) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch for initialization + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial empty README.md file using Contents API to initialize repo + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + // Verify it's an empty file + expectedContent := base64.StdEncoding.EncodeToString([]byte("")) + require.Equal(t, expectedContent, body["content"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{ + SHA: github.Ptr("readme123"), + }, + Commit: github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit to retrieve parent SHA + mock.WithRequestMatchHandler( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + response := &github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Create tree with all user files + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "tree456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "mode": "100644", + "type": "blob", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "mode": "100644", + "type": "blob", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "mode": "100644", + "type": "blob", + "content": "console.log('Hello World');\n", + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit with all user files + mock.WithRequestMatchHandler( + mock.PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Initial project setup", + "tree": "ghi789", + "parents": []interface{}{"init456"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + mock.WithRequestMatchHandler( + mock.PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "content": "console.log('Hello World');\n", + }, + }, + "message": "Initial project setup", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "fails to create initial file in empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Fail to create initial file using Contents API + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get reference after creating initial file in empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - called twice + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: fails + w.WriteHeader(http.StatusInternalServerError) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatch( + mock.PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get commit in empty repository with multiple files", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatch( + mock.PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + // Fail to get commit + mock.WithRequestMatchHandler( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + map[string]interface{}{ + "path": "LICENSE", + "content": "MIT", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, } for _, tc := range tests { From 7ea39cc0d299692d5758a38dee2b2e46edb1d366 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Tue, 23 Dec 2025 12:58:54 +0100 Subject: [PATCH 4/6] Fixed copilot comments --- pkg/github/repositories.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index e3126650d..c8ea1edc1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1290,7 +1290,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } else if ghErr.Response.StatusCode == http.StatusNotFound { branchNotFound = true } - } else { + } + + if !repositoryIsEmpty && !branchNotFound { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get branch reference", resp, @@ -1331,7 +1333,8 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil } - if branch != (*defaultRef.Ref)[len("refs/heads/"):] { + defaultBranch := strings.TrimPrefix(*defaultRef.Ref, "refs/heads/") + if branch != defaultBranch { // Create the requested branch from the default branch ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) if err != nil { @@ -1467,7 +1470,6 @@ func initializeRepository(ctx context.Context, client *github.Client, owner, rep defer func() { _ = resp.Body.Close() }() } - // Update ref to point to the new commit ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) if err != nil { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) @@ -1489,7 +1491,7 @@ func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client // Create the new branch reference createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ - Ref: *github.Ptr("refs/heads/" + branch), + Ref: "refs/heads/" + branch, SHA: *defaultRef.Object.SHA, }) if err != nil { From da498907b3e7898cbc007bf7f84e3a372387340f Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Mon, 5 Jan 2026 13:05:20 +0100 Subject: [PATCH 5/6] Addressing review comments --- pkg/github/repositories.go | 321 +---------------------------- pkg/github/repositories_helper.go | 329 ++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 317 deletions(-) create mode 100644 pkg/github/repositories_helper.go diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c8ea1edc1..262f71a62 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,7 +12,6 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "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/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -1327,21 +1326,20 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { defer func() { _ = resp.Body.Close() }() } } else { + var base *github.Commit // Repository is empty, need to initialize it first - defaultRef, base, err := initializeRepository(ctx, client, owner, repo) + ref, base, err = initializeRepository(ctx, client, owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil } - defaultBranch := strings.TrimPrefix(*defaultRef.Ref, "refs/heads/") + defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/") if branch != defaultBranch { // Create the requested branch from the default branch ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil } - } else { - ref = defaultRef } baseCommit = base @@ -1407,6 +1405,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Update the reference to point to the new commit + fmt.Printf("=================== ref: %v", ref) ref.Object.SHA = newCommit.SHA updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, @@ -1431,80 +1430,6 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { ) } -func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { - // First, we need to check what's the default branch in this empty repo should be: - repository, resp, err := client.Repositories.Get(ctx, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) - return nil, nil, fmt.Errorf("failed to get repository: %w", err) - } - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - defaultBranch := repository.GetDefaultBranch() - - fileOpts := &github.RepositoryContentFileOptions{ - Message: github.Ptr("Initial commit"), - Content: []byte(""), - Branch: github.Ptr(defaultBranch), - } - - // Create an initial empty commit to create the default branch - createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) - return nil, nil, fmt.Errorf("failed to create initial file: %w", err) - } - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - // Get the commit that was just created to use as base for remaining files - baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) - return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) - } - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) - } - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return ref, baseCommit, nil -} - -func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { - defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) - return nil, fmt.Errorf("failed to resolve default branch: %w", err) - } - - // Create the new branch reference - createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ - Ref: "refs/heads/" + branch, - SHA: *defaultRef.Object.SHA, - }) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) - return nil, fmt.Errorf("failed to create new branch reference: %w", err) - } - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return createdRef, nil -} - // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -1895,244 +1820,6 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } -// matchFiles searches for files in the Git tree that match the given path. -// It's used when GetContents fails or returns unexpected results. -func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { - // Step 1: Get Git Tree recursively - tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get git tree", - response, - err, - ), nil, nil - } - defer func() { _ = response.Body.Close() }() - - // Step 2: Filter tree for matching paths - const maxMatchingFiles = 3 - matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) - if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil - } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil - } - if rawAPIResponseCode > 0 { - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil - } - return utils.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, nil -} - -// filterPaths filters the entries in a GitHub tree to find paths that -// match the given suffix. -// maxResults limits the number of results returned to first maxResults entries, -// a maxResults of -1 means no limit. -// It returns a slice of strings containing the matching paths. -// Directories are returned with a trailing slash. -func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { - // Remove trailing slash for matching purposes, but flag whether we - // only want directories. - dirOnly := false - if strings.HasSuffix(path, "/") { - dirOnly = true - path = strings.TrimSuffix(path, "/") - } - - matchedPaths := []string{} - for _, entry := range entries { - if len(matchedPaths) == maxResults { - break // Limit the number of results to maxResults - } - if dirOnly && entry.GetType() != "tree" { - continue // Skip non-directory entries if dirOnly is true - } - entryPath := entry.GetPath() - if entryPath == "" { - continue // Skip empty paths - } - if strings.HasSuffix(entryPath, path) { - if entry.GetType() == "tree" { - entryPath += "/" // Return directories with a trailing slash - } - matchedPaths = append(matchedPaths, entryPath) - } - } - return matchedPaths -} - -// looksLikeSHA returns true if the string appears to be a Git commit SHA. -// A SHA is a 40-character hexadecimal string. -func looksLikeSHA(s string) bool { - if len(s) != 40 { - return false - } - for _, c := range s { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { - return false - } - } - return true -} - -// resolveGitReference takes a user-provided ref and sha and resolves them into a -// definitive commit SHA and its corresponding fully-qualified reference. -// -// The resolution logic follows a clear priority: -// -// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, -// and all reference resolution is skipped. -// -// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), -// it is returned as-is without any API calls or reference resolution. -// -// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves -// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying -// the following steps in order: -// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// qualified and used as-is. -// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// prefixed with "refs/" to make it fully-qualified. -// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// first attempts to resolve it as a branch ("refs/heads/"). If that -// returns a 404 Not Found error, it then attempts to resolve it as a tag -// ("refs/tags/"). -// -// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// is made to fetch that reference's definitive commit SHA. -// -// Any unexpected (non-404) errors during the resolution process are returned -// immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { - // 1) If SHA explicitly provided, it's the highest priority. - if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil - } - - // 1a) If sha is empty but ref looks like a SHA, return it without changes - if looksLikeSHA(ref) { - return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil - } - - originalRef := ref // Keep original ref for clearer error messages down the line. - - // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. - var reference *github.Reference - var resp *github.Response - var err error - var fallbackUsed bool - - switch { - case originalRef == "": - // 2a) If ref is empty, determine the default branch. - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - ref = reference.GetRef() - case strings.HasPrefix(originalRef, "refs/"): - // 2b) Already fully qualified. The reference will be fetched at the end. - case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): - // 2c) Partially qualified. Make it fully qualified. - ref = "refs/" + originalRef - default: - // 2d) It's a short name, so we try to resolve it to either a branch or a tag. - branchRef := "refs/heads/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) - - if err == nil { - ref = branchRef // It's a branch. - } else { - // The branch lookup failed. Check if it was a 404 Not Found error. - ghErr, isGhErr := err.(*github.ErrorResponse) - if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { - tagRef := "refs/tags/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) - if err == nil { - ref = tagRef // It's a tag. - } else { - // The tag lookup also failed. Check if it was a 404 Not Found error. - ghErr2, isGhErr2 := err.(*github.ErrorResponse) - if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { - if originalRef == "main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - break - } - return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) - } - - // The tag lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) - } - } else { - // The branch lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) - } - } - } - - if reference == nil { - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) - if err != nil { - if ref == "refs/heads/main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - } else { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) - } - } - } - - sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil -} - -func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { - repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) - return nil, fmt.Errorf("failed to get repository info: %w", err) - } - - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - - defaultBranch := repoInfo.GetDefaultBranch() - - defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) - return nil, fmt.Errorf("failed to get default branch reference: %w", err) - } - - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return defaultRef, nil -} - // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go new file mode 100644 index 000000000..de5065d48 --- /dev/null +++ b/pkg/github/repositories_helper.go @@ -0,0 +1,329 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + 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/v79/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// initializeRepository creates an initial commit in an empty repository and returns the default branch ref and base commit +func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { + // First, we need to check what the default branch in this empty repo should be: + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) + return nil, nil, fmt.Errorf("failed to get repository: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + defaultBranch := repository.GetDefaultBranch() + + fileOpts := &github.RepositoryContentFileOptions{ + Message: github.Ptr("Initial commit"), + Content: []byte(""), + Branch: github.Ptr(defaultBranch), + } + + // Create an initial empty commit to create the default branch + createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) + return nil, nil, fmt.Errorf("failed to create initial file: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Get the commit that was just created to use as base for remaining files + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) + return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return ref, baseCommit, nil +} + +// createReferenceFromDefaultBranch creates a new branch reference from the repository's default branch +func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { + defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) + return nil, fmt.Errorf("failed to resolve default branch: %w", err) + } + + // Create the new branch reference + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ + Ref: "refs/heads/" + branch, + SHA: *defaultRef.Object.SHA, + }) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) + return nil, fmt.Errorf("failed to create new branch reference: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return createdRef, nil +} + +// matchFiles searches for files in the Git tree that match the given path. +// It's used when GetContents fails or returns unexpected results. +func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { + // Step 1: Get Git Tree recursively + tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + response, + err, + ), nil, nil + } + defer func() { _ = response.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + } + resolvedRefs, err := json.Marshal(rawOpts) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil + } + if rawAPIResponseCode > 0 { + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil + } + return utils.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, nil +} + +// filterPaths filters the entries in a GitHub tree to find paths that +// match the given suffix. +// maxResults limits the number of results returned to first maxResults entries, +// a maxResults of -1 means no limit. +// It returns a slice of strings containing the matching paths. +// Directories are returned with a trailing slash. +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { + // Remove trailing slash for matching purposes, but flag whether we + // only want directories. + dirOnly := false + if strings.HasSuffix(path, "/") { + dirOnly = true + path = strings.TrimSuffix(path, "/") + } + + matchedPaths := []string{} + for _, entry := range entries { + if len(matchedPaths) == maxResults { + break // Limit the number of results to maxResults + } + if dirOnly && entry.GetType() != "tree" { + continue // Skip non-directory entries if dirOnly is true + } + entryPath := entry.GetPath() + if entryPath == "" { + continue // Skip empty paths + } + if strings.HasSuffix(entryPath, path) { + if entry.GetType() == "tree" { + entryPath += "/" // Return directories with a trailing slash + } + matchedPaths = append(matchedPaths, entryPath) + } + } + return matchedPaths +} + +// looksLikeSHA returns true if the string appears to be a Git commit SHA. +// A SHA is a 40-character hexadecimal string. +func looksLikeSHA(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// resolveGitReference takes a user-provided ref and sha and resolves them into a +// definitive commit SHA and its corresponding fully-qualified reference. +// +// The resolution logic follows a clear priority: +// +// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// and all reference resolution is skipped. +// +// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), +// it is returned as-is without any API calls or reference resolution. +// +// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves +// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { + // 1) If SHA explicitly provided, it's the highest priority. + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil + } + + // 1a) If sha is empty but ref looks like a SHA, return it without changes + if looksLikeSHA(ref) { + return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil + } + + originalRef := ref // Keep original ref for clearer error messages down the line. + + // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. + var reference *github.Reference + var resp *github.Response + var err error + var fallbackUsed bool + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + ref = reference.GetRef() + case strings.HasPrefix(originalRef, "refs/"): + // 2b) Already fully qualified. The reference will be fetched at the end. + case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): + // 2c) Partially qualified. Make it fully qualified. + ref = "refs/" + originalRef + default: + // 2d) It's a short name, so we try to resolve it to either a branch or a tag. + branchRef := "refs/heads/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + + if err == nil { + ref = branchRef // It's a branch. + } else { + // The branch lookup failed. Check if it was a 404 Not Found error. + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { + tagRef := "refs/tags/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) + if err == nil { + ref = tagRef // It's a tag. + } else { + // The tag lookup also failed. Check if it was a 404 Not Found error. + ghErr2, isGhErr2 := err.(*github.ErrorResponse) + if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { + if originalRef == "main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + break + } + return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + } + + // The tag lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + } + } else { + // The branch lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + } + } + } + + if reference == nil { + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + if ref == "refs/heads/main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + } else { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } + } + } + + sha = reference.GetObject().GetSHA() + return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil +} + +func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + defaultBranch := repoInfo.GetDefaultBranch() + + defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) + return nil, fmt.Errorf("failed to get default branch reference: %w", err) + } + + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return defaultRef, nil +} From 61748ab30db1473b4d52ac3f63d0b397b523c863 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Mon, 5 Jan 2026 13:22:44 +0100 Subject: [PATCH 6/6] Remove debug statement --- pkg/github/repositories.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 262f71a62..1ab33a57c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1405,7 +1405,6 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Update the reference to point to the new commit - fmt.Printf("=================== ref: %v", ref) ref.Object.SHA = newCommit.SHA updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA,