diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 1b68b4222..c8ea1edc1 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 @@ -681,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 } @@ -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 fallbackUsed { + 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 @@ -1271,28 +1279,75 @@ 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 + } + } + + if !repositoryIsEmpty && !branchNotFound { + 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 + } + + 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 { + 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 { @@ -1320,7 +1375,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, @@ -1329,9 +1384,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, @@ -1345,7 +1402,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 @@ -1372,6 +1431,80 @@ 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( @@ -1876,15 +2009,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. @@ -1893,16 +2026,16 @@ 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. - 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, false, 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,19 +2061,26 @@ 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, 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) } } } @@ -1949,15 +2089,48 @@ 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, 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) } - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, 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) { + 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. diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 6c56d104e..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" @@ -69,6 +70,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 +292,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 +424,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) @@ -1810,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", @@ -1823,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", @@ -1889,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 { @@ -3288,7 +3761,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)