- 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>
416 lines
12 KiB
Go
416 lines
12 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func testGitOps(token string) *GitOperations {
|
|
return NewGitOperations(GitOperationsConfig{
|
|
GiteaToken: token,
|
|
GitUser: "test-user",
|
|
GitEmail: "test@example.com",
|
|
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})),
|
|
})
|
|
}
|
|
|
|
func TestNewGitOperations_Defaults(t *testing.T) {
|
|
g := NewGitOperations(GitOperationsConfig{})
|
|
if g.gitUser != "rdev-worker" {
|
|
t.Errorf("expected default gitUser 'rdev-worker', got %q", g.gitUser)
|
|
}
|
|
if g.gitEmail != "worker@threesix.ai" {
|
|
t.Errorf("expected default gitEmail 'worker@threesix.ai', got %q", g.gitEmail)
|
|
}
|
|
if g.logger == nil {
|
|
t.Error("expected non-nil logger")
|
|
}
|
|
}
|
|
|
|
func TestNewGitOperations_CustomValues(t *testing.T) {
|
|
g := NewGitOperations(GitOperationsConfig{
|
|
GiteaToken: "my-token",
|
|
GitUser: "custom-user",
|
|
GitEmail: "custom@example.com",
|
|
})
|
|
if g.giteaToken != "my-token" {
|
|
t.Errorf("expected token 'my-token', got %q", g.giteaToken)
|
|
}
|
|
if g.gitUser != "custom-user" {
|
|
t.Errorf("expected gitUser 'custom-user', got %q", g.gitUser)
|
|
}
|
|
if g.gitEmail != "custom@example.com" {
|
|
t.Errorf("expected gitEmail 'custom@example.com', got %q", g.gitEmail)
|
|
}
|
|
}
|
|
|
|
func TestInjectToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
url string
|
|
expect string
|
|
}{
|
|
{
|
|
name: "https URL with token",
|
|
token: "ghp_abc123",
|
|
url: "https://git.example.com/org/repo.git",
|
|
expect: "https://ghp_abc123@git.example.com/org/repo.git",
|
|
},
|
|
{
|
|
name: "http URL with token",
|
|
token: "ghp_abc123",
|
|
url: "http://git.example.com/org/repo.git",
|
|
expect: "http://ghp_abc123@git.example.com/org/repo.git",
|
|
},
|
|
{
|
|
name: "no token",
|
|
token: "",
|
|
url: "https://git.example.com/org/repo.git",
|
|
expect: "https://git.example.com/org/repo.git",
|
|
},
|
|
{
|
|
name: "ssh URL unchanged",
|
|
token: "ghp_abc123",
|
|
url: "git@git.example.com:org/repo.git",
|
|
expect: "git@git.example.com:org/repo.git",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := testGitOps(tt.token)
|
|
got := g.injectToken(tt.url)
|
|
if got != tt.expect {
|
|
t.Errorf("injectToken(%q) = %q, want %q", tt.url, got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
input string
|
|
expect string
|
|
}{
|
|
{
|
|
name: "redacts token from message",
|
|
token: "secret123",
|
|
input: "fatal: Authentication failed for 'https://secret123@git.example.com/repo.git'",
|
|
expect: "fatal: Authentication failed for 'https://[REDACTED]@git.example.com/repo.git'",
|
|
},
|
|
{
|
|
name: "no token to redact",
|
|
token: "",
|
|
input: "fatal: repository not found",
|
|
expect: "fatal: repository not found",
|
|
},
|
|
{
|
|
name: "token not present in message",
|
|
token: "secret123",
|
|
input: "fatal: repository not found",
|
|
expect: "fatal: repository not found",
|
|
},
|
|
{
|
|
name: "multiple occurrences",
|
|
token: "tok",
|
|
input: "tok appears twice: tok",
|
|
expect: "[REDACTED] appears twice: [REDACTED]",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := testGitOps(tt.token)
|
|
got := g.redactToken(tt.input)
|
|
if got != tt.expect {
|
|
t.Errorf("redactToken(%q) = %q, want %q", tt.input, got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureGitDir(t *testing.T) {
|
|
g := testGitOps("")
|
|
|
|
t.Run("valid git directory", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.EnsureGitDir(dir); err != nil {
|
|
t.Errorf("expected no error for valid git dir, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("no .git directory", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
err := g.EnsureGitDir(dir)
|
|
if err == nil {
|
|
t.Error("expected error for non-git directory")
|
|
}
|
|
})
|
|
|
|
t.Run(".git is a file not directory", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: .."), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := g.EnsureGitDir(dir)
|
|
if err == nil {
|
|
t.Error("expected error when .git is a file")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCommitAndPush_NoChanges tests that CommitAndPush returns nil when
|
|
// there are no staged changes in the repository.
|
|
func TestCommitAndPush_NoChanges(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx := context.Background()
|
|
|
|
// Create a real git repo with an initial commit
|
|
dir := t.TempDir()
|
|
if err := g.runGit(ctx, dir, "init"); err != nil {
|
|
t.Fatal("git init:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
|
|
t.Fatal("git config user.name:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
|
|
t.Fatal("git config user.email:", err)
|
|
}
|
|
// Create initial commit so HEAD exists
|
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
|
|
t.Fatal("git add:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
|
|
t.Fatal("git commit:", err)
|
|
}
|
|
|
|
// No new changes — should return empty with no error
|
|
sha, files, err := g.CommitAndPush(ctx, dir, "no changes", false)
|
|
if err != nil {
|
|
t.Errorf("expected no error, got: %v", err)
|
|
}
|
|
if sha != "" {
|
|
t.Errorf("expected empty SHA, got: %q", sha)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected no files, got: %v", files)
|
|
}
|
|
}
|
|
|
|
// TestCommitAndPush_WithChanges tests that CommitAndPush correctly stages,
|
|
// commits, and returns SHA and changed file list.
|
|
func TestCommitAndPush_WithChanges(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx := context.Background()
|
|
|
|
// Create a real git repo
|
|
dir := t.TempDir()
|
|
if err := g.runGit(ctx, dir, "init"); err != nil {
|
|
t.Fatal("git init:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
|
|
t.Fatal("git config user.name:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
|
|
t.Fatal("git config user.email:", err)
|
|
}
|
|
// Initial commit
|
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
|
|
t.Fatal("git add:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
|
|
t.Fatal("git commit:", err)
|
|
}
|
|
|
|
// Create new files to commit
|
|
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// CommitAndPush without push (no remote)
|
|
sha, files, err := g.CommitAndPush(ctx, dir, "add go files", false)
|
|
if err != nil {
|
|
t.Errorf("expected no error, got: %v", err)
|
|
}
|
|
if sha == "" {
|
|
t.Error("expected non-empty SHA")
|
|
}
|
|
if len(sha) < 7 {
|
|
t.Errorf("expected SHA to be at least 7 chars, got: %q", sha)
|
|
}
|
|
if len(files) != 2 {
|
|
t.Errorf("expected 2 changed files, got %d: %v", len(files), files)
|
|
}
|
|
|
|
// Verify the files are in the list
|
|
fileSet := make(map[string]bool)
|
|
for _, f := range files {
|
|
fileSet[f] = true
|
|
}
|
|
if !fileSet["main.go"] {
|
|
t.Error("expected main.go in changed files")
|
|
}
|
|
if !fileSet["go.mod"] {
|
|
t.Error("expected go.mod in changed files")
|
|
}
|
|
}
|
|
|
|
// TestCommitAndPush_PushWithoutRemote tests that push fails gracefully
|
|
// when there's no remote configured.
|
|
func TestCommitAndPush_PushWithoutRemote(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx := context.Background()
|
|
|
|
dir := t.TempDir()
|
|
if err := g.runGit(ctx, dir, "init"); err != nil {
|
|
t.Fatal("git init:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
|
|
t.Fatal("git config:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
|
|
t.Fatal("git config:", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("init"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
|
|
t.Fatal("git add:", err)
|
|
}
|
|
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
|
|
t.Fatal("git commit:", err)
|
|
}
|
|
|
|
// Add a new file
|
|
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Push should fail (no remote) but commit succeeds — SHA is returned
|
|
sha, files, err := g.CommitAndPush(ctx, dir, "test push", true)
|
|
if err == nil {
|
|
t.Error("expected push error when no remote configured")
|
|
}
|
|
// Even though push failed, commit should have succeeded
|
|
if sha == "" {
|
|
t.Error("expected SHA from successful commit before push failure")
|
|
}
|
|
if len(files) != 1 || files[0] != "new.txt" {
|
|
t.Errorf("expected [new.txt], got: %v", files)
|
|
}
|
|
}
|
|
|
|
// TestCloneToTemp_InvalidURL tests that CloneToTemp fails on a bad URL.
|
|
func TestCloneToTemp_InvalidURL(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx := context.Background()
|
|
|
|
_, _, err := g.CloneToTemp(ctx, "https://invalid.example.com/no-such-repo.git")
|
|
if err == nil {
|
|
t.Error("expected error cloning invalid URL")
|
|
}
|
|
}
|
|
|
|
// TestCloneToTemp_LocalRepo tests cloning a local bare repository.
|
|
func TestCloneToTemp_LocalRepo(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx := context.Background()
|
|
|
|
// Create a bare repo to clone from
|
|
bareDir := t.TempDir()
|
|
if err := g.runGit(ctx, bareDir, "init", "--bare"); err != nil {
|
|
t.Fatal("git init --bare:", err)
|
|
}
|
|
|
|
// Create a source repo and push to the bare repo
|
|
srcDir := t.TempDir()
|
|
if err := g.runGit(ctx, srcDir, "init"); err != nil {
|
|
t.Fatal("git init:", err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "config", "user.name", "test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "config", "user.email", "test@test.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcDir, "hello.txt"), []byte("hello"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "add", "-A"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "commit", "-m", "initial"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "remote", "add", "origin", bareDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g.runGit(ctx, srcDir, "push", "origin", "master"); err != nil {
|
|
// Some git versions use "main" as default branch
|
|
if err2 := g.runGit(ctx, srcDir, "push", "origin", "main"); err2 != nil {
|
|
t.Fatalf("push failed for both master and main: master=%v, main=%v", err, err2)
|
|
}
|
|
}
|
|
|
|
// Clone the bare repo using file:// protocol
|
|
cloneDir, cleanup, err := g.CloneToTemp(ctx, "file://"+bareDir)
|
|
if err != nil {
|
|
t.Fatalf("CloneToTemp failed: %v", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
// Verify the cloned file exists
|
|
content, err := os.ReadFile(filepath.Join(cloneDir, "hello.txt"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read cloned file: %v", err)
|
|
}
|
|
if string(content) != "hello" {
|
|
t.Errorf("expected file content 'hello', got %q", string(content))
|
|
}
|
|
|
|
// Verify .git dir exists
|
|
if err := g.EnsureGitDir(cloneDir); err != nil {
|
|
t.Errorf("cloned dir should be a git repo: %v", err)
|
|
}
|
|
|
|
// Verify git config was set
|
|
userName, err := g.runGitOutput(ctx, cloneDir, "config", "user.name")
|
|
if err != nil {
|
|
t.Fatalf("failed to get user.name: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(userName); got != "test-user" {
|
|
t.Errorf("expected user.name 'test-user', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunGit_ContextCancellation(t *testing.T) {
|
|
g := testGitOps("")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
dir := t.TempDir()
|
|
err := g.runGit(ctx, dir, "status")
|
|
if err == nil {
|
|
t.Error("expected error when context is cancelled")
|
|
}
|
|
}
|