- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
234 lines
6.3 KiB
Go
234 lines
6.3 KiB
Go
package worker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// GitOperations provides git clone, commit, and push functionality
|
|
// for the build executor. It uses os/exec to run git commands.
|
|
type GitOperations struct {
|
|
giteaToken string
|
|
gitUser string
|
|
gitEmail string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// GitOperationsConfig configures git operations.
|
|
type GitOperationsConfig struct {
|
|
// GiteaToken is the token for HTTPS clone/push authentication.
|
|
GiteaToken string
|
|
|
|
// GitUser is the git commit author name.
|
|
GitUser string
|
|
|
|
// GitEmail is the git commit author email.
|
|
GitEmail string
|
|
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewGitOperations creates a new git operations helper.
|
|
func NewGitOperations(cfg GitOperationsConfig) *GitOperations {
|
|
if cfg.GitUser == "" {
|
|
cfg.GitUser = "rdev-worker"
|
|
}
|
|
if cfg.GitEmail == "" {
|
|
cfg.GitEmail = "worker@threesix.ai"
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = slog.Default()
|
|
}
|
|
return &GitOperations{
|
|
giteaToken: cfg.GiteaToken,
|
|
gitUser: cfg.GitUser,
|
|
gitEmail: cfg.GitEmail,
|
|
logger: cfg.Logger.With("component", "git-ops"),
|
|
}
|
|
}
|
|
|
|
// CloneToTemp clones a repository to a temporary directory.
|
|
// Returns the clone directory and a cleanup function.
|
|
func (g *GitOperations) CloneToTemp(ctx context.Context, gitURL string) (string, func(), error) {
|
|
tmpDir, err := os.MkdirTemp("", "rdev-build-*")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
g.logger.Warn("failed to cleanup temp dir", "dir", tmpDir, "error", err)
|
|
}
|
|
}
|
|
|
|
// Inject token into clone URL for authentication
|
|
authURL := g.injectToken(gitURL)
|
|
|
|
if err := g.runGit(ctx, tmpDir, "clone", authURL, "."); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("git clone: %w", err)
|
|
}
|
|
|
|
// Configure git user for commits
|
|
if err := g.runGit(ctx, tmpDir, "config", "user.name", g.gitUser); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("git config user.name: %w", err)
|
|
}
|
|
if err := g.runGit(ctx, tmpDir, "config", "user.email", g.gitEmail); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("git config user.email: %w", err)
|
|
}
|
|
|
|
g.logger.Info("cloned repository", "url", gitURL, "dir", tmpDir)
|
|
return tmpDir, cleanup, nil
|
|
}
|
|
|
|
// CommitAndPush stages all changes, commits, and optionally pushes.
|
|
// Returns the commit SHA and list of changed files.
|
|
func (g *GitOperations) CommitAndPush(ctx context.Context, dir, message string, push bool) (string, []string, error) {
|
|
// Stage all changes
|
|
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
|
|
return "", nil, fmt.Errorf("git add: %w", err)
|
|
}
|
|
|
|
// Check if there are changes to commit
|
|
status, err := g.runGitOutput(ctx, dir, "status", "--porcelain")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("git status: %w", err)
|
|
}
|
|
if strings.TrimSpace(status) == "" {
|
|
g.logger.Info("no changes to commit", "dir", dir)
|
|
return "", nil, nil
|
|
}
|
|
|
|
// Get list of changed files
|
|
diffOutput, err := g.runGitOutput(ctx, dir, "diff", "--cached", "--name-only")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("git diff: %w", err)
|
|
}
|
|
var filesChanged []string
|
|
for _, f := range strings.Split(strings.TrimSpace(diffOutput), "\n") {
|
|
if f != "" {
|
|
filesChanged = append(filesChanged, f)
|
|
}
|
|
}
|
|
|
|
// Commit
|
|
if err := g.runGit(ctx, dir, "commit", "-m", message); err != nil {
|
|
return "", nil, fmt.Errorf("git commit: %w", err)
|
|
}
|
|
|
|
// Get commit SHA
|
|
sha, err := g.runGitOutput(ctx, dir, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("git rev-parse: %w", err)
|
|
}
|
|
sha = strings.TrimSpace(sha)
|
|
|
|
g.logger.Info("committed changes",
|
|
"sha", sha,
|
|
"files", len(filesChanged),
|
|
)
|
|
|
|
// Push if requested
|
|
if push {
|
|
if err := g.runGit(ctx, dir, "push"); err != nil {
|
|
return sha, filesChanged, fmt.Errorf("git push: %w", err)
|
|
}
|
|
g.logger.Info("pushed changes", "sha", sha)
|
|
}
|
|
|
|
return sha, filesChanged, nil
|
|
}
|
|
|
|
// injectToken adds the Gitea token to an HTTPS git URL for authentication.
|
|
// Converts "https://git.example.com/org/repo.git" to
|
|
// "https://token@git.example.com/org/repo.git".
|
|
func (g *GitOperations) injectToken(gitURL string) string {
|
|
if g.giteaToken == "" {
|
|
return gitURL
|
|
}
|
|
// Handle https:// URLs
|
|
if strings.HasPrefix(gitURL, "https://") {
|
|
return "https://" + g.giteaToken + "@" + gitURL[len("https://"):]
|
|
}
|
|
if strings.HasPrefix(gitURL, "http://") {
|
|
return "http://" + g.giteaToken + "@" + gitURL[len("http://"):]
|
|
}
|
|
return gitURL
|
|
}
|
|
|
|
// gitEnv returns a minimal environment for git subprocesses.
|
|
// Only PATH and HOME are inherited; all other host env vars are excluded
|
|
// to prevent credential or config leakage.
|
|
func gitEnv() []string {
|
|
env := []string{"GIT_TERMINAL_PROMPT=0"}
|
|
for _, key := range []string{"PATH", "HOME"} {
|
|
if v := os.Getenv(key); v != "" {
|
|
env = append(env, key+"="+v)
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
// runGit executes a git command in the given directory.
|
|
func (g *GitOperations) runGit(ctx context.Context, dir string, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = gitEnv()
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Redact token from error messages
|
|
errMsg := g.redactToken(stderr.String())
|
|
return fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runGitOutput executes a git command and returns its stdout.
|
|
func (g *GitOperations) runGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = gitEnv()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
return "", fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// redactToken removes the Gitea token from log/error output.
|
|
func (g *GitOperations) redactToken(s string) string {
|
|
if g.giteaToken == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
|
|
}
|
|
|
|
// EnsureGitDir verifies that the given path is a valid git repository.
|
|
func (g *GitOperations) EnsureGitDir(dir string) error {
|
|
gitDir := filepath.Join(dir, ".git")
|
|
info, err := os.Stat(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("not a git repository: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("not a git repository: .git is not a directory")
|
|
}
|
|
return nil
|
|
}
|