mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-16 20:54:50 +00:00
238 lines
No EOL
5.9 KiB
Go
238 lines
No EOL
5.9 KiB
Go
package cifixer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chainguard-dev/clog"
|
|
"github.com/go-git/go-git/v5"
|
|
gitconfig "github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// GitManager manages git operations for CI fixing.
|
|
// It handles cloning PR branches, making changes, and pushing commits.
|
|
type GitManager struct {
|
|
tokenSource oauth2.TokenSource
|
|
identity string
|
|
}
|
|
|
|
// NewGitManager creates a new GitManager.
|
|
// tokenSource provides GitHub authentication.
|
|
// identity is used as the commit author name.
|
|
func NewGitManager(tokenSource oauth2.TokenSource, identity string) (*GitManager, error) {
|
|
if tokenSource == nil {
|
|
return nil, errors.New("token source cannot be nil")
|
|
}
|
|
identity = strings.TrimSpace(identity)
|
|
if identity == "" {
|
|
return nil, errors.New("identity cannot be empty")
|
|
}
|
|
return &GitManager{
|
|
tokenSource: tokenSource,
|
|
identity: identity,
|
|
}, nil
|
|
}
|
|
|
|
// PRClone represents a cloned PR branch ready for modifications.
|
|
type PRClone struct {
|
|
manager *GitManager
|
|
dir string
|
|
repo *git.Repository
|
|
branch string
|
|
sha string
|
|
}
|
|
|
|
// ClonePRBranch clones a repository and checks out the PR's head branch.
|
|
func (m *GitManager) ClonePRBranch(ctx context.Context, owner, repo, branch string) (*PRClone, error) {
|
|
log := clog.FromContext(ctx)
|
|
|
|
// Create temp directory
|
|
dir, err := os.MkdirTemp("", "cifixer-clone-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating temp dir: %w", err)
|
|
}
|
|
|
|
// Get auth
|
|
auth, err := m.authForRemote()
|
|
if err != nil {
|
|
if removeErr := os.RemoveAll(dir); removeErr != nil {
|
|
log.Warnf("failed to clean up temp dir after auth error: %v", removeErr)
|
|
}
|
|
return nil, fmt.Errorf("getting auth: %w", err)
|
|
}
|
|
|
|
// Clone the repository
|
|
repoURL := fmt.Sprintf("https://github.com/%s/%s", owner, repo)
|
|
log.Infof("Cloning %s branch %s into %s", repoURL, branch, dir)
|
|
|
|
gitRepo, err := git.PlainClone(dir, false, &git.CloneOptions{
|
|
URL: repoURL,
|
|
ReferenceName: plumbing.NewBranchReferenceName(branch),
|
|
SingleBranch: true,
|
|
Auth: auth,
|
|
Depth: 50, // Shallow clone for speed
|
|
})
|
|
if err != nil {
|
|
if removeErr := os.RemoveAll(dir); removeErr != nil {
|
|
log.Warnf("failed to clean up temp dir after clone error: %v", removeErr)
|
|
}
|
|
return nil, fmt.Errorf("cloning repository: %w", err)
|
|
}
|
|
|
|
// Get the current HEAD SHA
|
|
head, err := gitRepo.Head()
|
|
if err != nil {
|
|
if removeErr := os.RemoveAll(dir); removeErr != nil {
|
|
log.Warnf("failed to clean up temp dir after HEAD error: %v", removeErr)
|
|
}
|
|
return nil, fmt.Errorf("getting HEAD: %w", err)
|
|
}
|
|
|
|
log.Infof("Cloned successfully at %s", head.Hash().String()[:8])
|
|
|
|
return &PRClone{
|
|
manager: m,
|
|
dir: dir,
|
|
repo: gitRepo,
|
|
branch: branch,
|
|
sha: head.Hash().String(),
|
|
}, nil
|
|
}
|
|
|
|
// Dir returns the path to the cloned repository.
|
|
func (c *PRClone) Dir() string {
|
|
return c.dir
|
|
}
|
|
|
|
// SHA returns the current HEAD commit SHA.
|
|
func (c *PRClone) SHA() string {
|
|
return c.sha
|
|
}
|
|
|
|
// CommitAndPush stages all changes, commits them, and pushes to the remote.
|
|
func (c *PRClone) CommitAndPush(ctx context.Context, message string) error {
|
|
log := clog.FromContext(ctx)
|
|
|
|
worktree, err := c.repo.Worktree()
|
|
if err != nil {
|
|
return fmt.Errorf("getting worktree: %w", err)
|
|
}
|
|
|
|
// Stage all changes
|
|
if err := worktree.AddWithOptions(&git.AddOptions{All: true}); err != nil {
|
|
return fmt.Errorf("staging changes: %w", err)
|
|
}
|
|
|
|
// Check if there are any changes to commit
|
|
status, err := worktree.Status()
|
|
if err != nil {
|
|
return fmt.Errorf("getting status: %w", err)
|
|
}
|
|
|
|
if status.IsClean() {
|
|
log.Infof("No changes to commit")
|
|
return nil
|
|
}
|
|
|
|
// Build author email
|
|
email := c.manager.identity
|
|
if !strings.Contains(email, "@") {
|
|
email = fmt.Sprintf("%s@users.noreply.github.com", email)
|
|
}
|
|
|
|
// Commit
|
|
commit, err := worktree.Commit(message, &git.CommitOptions{
|
|
Author: &object.Signature{
|
|
Name: c.manager.identity,
|
|
Email: email,
|
|
When: time.Now(),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("committing: %w", err)
|
|
}
|
|
|
|
log.Infof("Committed %s: %s", commit.String()[:8], message)
|
|
|
|
// Get auth for push
|
|
auth, err := c.manager.authForRemote()
|
|
if err != nil {
|
|
return fmt.Errorf("getting auth for push: %w", err)
|
|
}
|
|
|
|
// Push to remote
|
|
refSpec := gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", c.branch, c.branch))
|
|
log.Infof("Pushing to %s", refSpec)
|
|
|
|
if err := c.repo.Push(&git.PushOptions{
|
|
RemoteName: "origin",
|
|
Auth: auth,
|
|
RefSpecs: []gitconfig.RefSpec{refSpec},
|
|
}); err != nil {
|
|
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
log.Infof("Branch already up to date")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("pushing: %w", err)
|
|
}
|
|
|
|
// Update our SHA
|
|
head, err := c.repo.Head()
|
|
if err == nil {
|
|
c.sha = head.Hash().String()
|
|
}
|
|
|
|
log.Infof("Push successful")
|
|
return nil
|
|
}
|
|
|
|
// Close cleans up the cloned repository.
|
|
func (c *PRClone) Close() error {
|
|
if c.dir != "" {
|
|
return os.RemoveAll(c.dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *GitManager) authForRemote() (*githttp.BasicAuth, error) {
|
|
token, err := m.tokenSource.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &githttp.BasicAuth{
|
|
Username: "x-access-token",
|
|
Password: token.AccessToken,
|
|
}, nil
|
|
}
|
|
|
|
// GitHubAppTokenSource implements oauth2.TokenSource using a GitHub App installation.
|
|
type GitHubAppTokenSource struct {
|
|
getToken func() (string, error)
|
|
}
|
|
|
|
// NewGitHubAppTokenSource creates a token source that uses a function to get tokens.
|
|
// This allows integration with ghinstallation transports.
|
|
func NewGitHubAppTokenSource(getToken func() (string, error)) *GitHubAppTokenSource {
|
|
return &GitHubAppTokenSource{getToken: getToken}
|
|
}
|
|
|
|
// Token returns an OAuth2 token.
|
|
func (s *GitHubAppTokenSource) Token() (*oauth2.Token, error) {
|
|
token, err := s.getToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &oauth2.Token{
|
|
AccessToken: token,
|
|
TokenType: "Bearer",
|
|
}, nil
|
|
} |