diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d28a9f..932560b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,136 @@ permissions: contents: write jobs: - release: + build-debian: + name: Build Debian 13 runs-on: ubuntu-latest + container: + image: debian:13-slim steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - name: Install dependencies + run: | + apt-get update && apt-get install -y git golang-go ca-certificates + - name: Checkout Code + uses: actions/checkout@v4 + - name: Build + run: go build -v -o gh-repo-bootstrap-linux-debian13-amd64 main.go + - name: Upload Artifact + uses: actions/upload-artifact@v4 with: - go-version: stable - - uses: cli/gh-extension-precompile@v2 + name: gh-repo-bootstrap-linux-debian13-amd64 + path: gh-repo-bootstrap-linux-debian13-amd64 + + build-arch: + name: Build Arch Linux + runs-on: ubuntu-latest + container: + image: archlinux:latest + steps: + - name: Install dependencies + run: | + pacman -Syu --noconfirm git go ca-certificates + - name: Checkout Code + uses: actions/checkout@v4 + - name: Build + run: go build -v -o gh-repo-bootstrap-linux-arch-amd64 main.go + - name: Upload Artifact + uses: actions/upload-artifact@v4 with: - go_version: stable + name: gh-repo-bootstrap-linux-arch-amd64 + path: gh-repo-bootstrap-linux-arch-amd64 + + build-fedora: + name: Build Fedora 44 + runs-on: ubuntu-latest + container: + image: fedora:44 + steps: + - name: Install dependencies + run: | + dnf install -y git golang ca-certificates + - name: Checkout Code + uses: actions/checkout@v4 + - name: Build + run: go build -v -o gh-repo-bootstrap-linux-fedora44-amd64 main.go + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: gh-repo-bootstrap-linux-fedora44-amd64 + path: gh-repo-bootstrap-linux-fedora44-amd64 + + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.3' + - name: Build + run: go build -v -o gh-repo-bootstrap-windows-amd64.exe main.go + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: gh-repo-bootstrap-windows-amd64.exe + path: gh-repo-bootstrap-windows-amd64.exe + + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.3' + - name: Build + run: go build -v -o gh-repo-bootstrap-darwin-arm64 main.go + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: gh-repo-bootstrap-darwin-arm64 + path: gh-repo-bootstrap-darwin-arm64 + + release: + name: Create Release + needs: [build-debian, build-arch, build-fedora, build-windows, build-macos] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: bin-artifacts + + - name: Prepare Release Assets and Checksums + run: | + mkdir release-assets + find bin-artifacts -type f -exec cp {} release-assets/ \; + + cd release-assets + echo "## SHA256 Checksums" > ../release_notes.txt + echo "" >> ../release_notes.txt + echo "| Filename | SHA256 Checksum |" >> ../release_notes.txt + echo "| --- | --- |" >> ../release_notes.txt + for file in *; do + sha=$(sha256sum "$file" | cut -d' ' -f1) + echo "| \`$file\` | \`$sha\` |" >> ../release_notes.txt + done + + cat ../release_notes.txt + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file release_notes.txt \ + release-assets/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..441ceb4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test Suite + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.3' + + - name: Run Tests + run: | + go test -v -coverprofile=coverage.txt -covermode=atomic ./... diff --git a/.gitignore b/.gitignore index 7fdd792..98eadcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ -# Pulumi -gh-repo-bootstrap - -# Editors / OS -.DS_Store -.idea/ -.vscode/ +coverage.out .gitignore diff --git a/internal/githubapi/resolve.go b/internal/githubapi/resolve.go index e527422..5ed5df5 100644 --- a/internal/githubapi/resolve.go +++ b/internal/githubapi/resolve.go @@ -78,9 +78,11 @@ func (r *Resolver) ResolveTeam(s string) (int, error) { return id, nil } +var ExecCommand = exec.Command + // ghAPIID runs `gh api ` and returns the `.id` field of the response. func ghAPIID(path string) (int, error) { - cmd := exec.Command("gh", "api", path) + cmd := ExecCommand("gh", "api", path) out, err := cmd.Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok { diff --git a/internal/githubapi/resolve_test.go b/internal/githubapi/resolve_test.go new file mode 100644 index 0000000..c9d2c10 --- /dev/null +++ b/internal/githubapi/resolve_test.go @@ -0,0 +1,173 @@ +package githubapi + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + args := os.Args + for i, arg := range args { + if arg == "--" { + args = args[i+1:] + break + } + } + if len(args) < 3 { + fmt.Fprintf(os.Stderr, "invalid args: %v\n", args) + os.Exit(2) + } + + command := args[0] + subCmd := args[1] + path := args[2] + + if command != "gh" || subCmd != "api" { + fmt.Fprintf(os.Stderr, "expected command 'gh api', got: %s %s\n", command, subCmd) + os.Exit(2) + } + + switch { + case path == "users/octocat": + fmt.Print(`{"id":583234}`) + case path == "users/error-user": + fmt.Fprint(os.Stderr, "http error 404") + os.Exit(1) + case path == "users/no-id-user": + fmt.Print(`{"login":"no-id-user"}`) + case path == "users/bad-json-user": + fmt.Print(`{invalid}`) + case path == "orgs/JMR-dev/teams/release-managers": + fmt.Print(`{"id":98765}`) + case path == "orgs/JMR-dev/teams/error-team": + fmt.Fprint(os.Stderr, "http error 404") + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "unknown path: %s\n", path) + os.Exit(2) + } +} + +func mockExec(command string, args ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", command} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + return cmd +} + +func TestResolveUser(t *testing.T) { + oldExec := ExecCommand + ExecCommand = mockExec + defer func() { ExecCommand = oldExec }() + + r := New() + + // 1. Numeric ID passes through + id, err := r.ResolveUser("12345") + if err != nil { + t.Fatalf("unexpected error for numeric user ID: %v", err) + } + if id != 12345 { + t.Errorf("expected 12345, got %d", id) + } + + // 2. Resolve login (with leading @) + id, err = r.ResolveUser("@octocat") + if err != nil { + t.Fatalf("unexpected error for user @octocat: %v", err) + } + if id != 583234 { + t.Errorf("expected 583234, got %d", id) + } + + // 3. Cached lookup + id, err = r.ResolveUser("octocat") + if err != nil { + t.Fatalf("unexpected error for user octocat (cached): %v", err) + } + if id != 583234 { + t.Errorf("expected 583234, got %d", id) + } + + // 4. API Error + _, err = r.ResolveUser("error-user") + if err == nil || !strings.Contains(err.Error(), "http error 404") { + t.Errorf("expected http error 404, got %v", err) + } + + // 5. No ID field in response + _, err = r.ResolveUser("no-id-user") + if err == nil || !strings.Contains(err.Error(), "returned no id") { + t.Errorf("expected 'returned no id' error, got %v", err) + } + + // 6. Bad JSON response + _, err = r.ResolveUser("bad-json-user") + if err == nil || !strings.Contains(err.Error(), "decoding gh api") { + t.Errorf("expected decoding error, got %v", err) + } +} + +func TestResolveTeam(t *testing.T) { + oldExec := ExecCommand + ExecCommand = mockExec + defer func() { ExecCommand = oldExec }() + + r := New() + + // 1. Numeric ID passes through + id, err := r.ResolveTeam("54321") + if err != nil { + t.Fatalf("unexpected error for numeric team ID: %v", err) + } + if id != 54321 { + t.Errorf("expected 54321, got %d", id) + } + + // 2. Bare slug rejected + _, err = r.ResolveTeam("release-managers") + if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") { + t.Errorf("expected format error, got %v", err) + } + + // 3. Invalid formats + for _, invalid := range []string{"org/", "/team"} { + _, err = r.ResolveTeam(invalid) + if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") { + t.Errorf("expected format error for %q, got %v", invalid, err) + } + } + + // 4. Resolve slug + id, err = r.ResolveTeam("JMR-dev/release-managers") + if err != nil { + t.Fatalf("unexpected error for team: %v", err) + } + if id != 98765 { + t.Errorf("expected 98765, got %d", id) + } + + // 5. Cached slug + id, err = r.ResolveTeam("JMR-dev/release-managers") + if err != nil { + t.Fatalf("unexpected error for team (cached): %v", err) + } + if id != 98765 { + t.Errorf("expected 98765, got %d", id) + } + + // 6. API Error + _, err = r.ResolveTeam("JMR-dev/error-team") + if err == nil || !strings.Contains(err.Error(), "http error 404") { + t.Errorf("expected http error 404, got %v", err) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index d693697..13b8027 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -12,9 +12,11 @@ import ( "golang.org/x/term" ) +var IsTerminal = term.IsTerminal + // IsInteractive reports whether stdin is a terminal. func IsInteractive() bool { - return term.IsTerminal(int(os.Stdin.Fd())) + return IsTerminal(int(os.Stdin.Fd())) } // Reader reads prompts from in and writes them to out. Use New() for the diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..5f9c00c --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,137 @@ +package prompt + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestIsInteractive(t *testing.T) { + oldIsTerminal := IsTerminal + defer func() { IsTerminal = oldIsTerminal }() + + // Test interactive mode + IsTerminal = func(fd int) bool { + return true + } + if !IsInteractive() { + t.Error("expected IsInteractive to be true") + } + + // Test non-interactive mode + IsTerminal = func(fd int) bool { + return false + } + if IsInteractive() { + t.Error("expected IsInteractive to be false") + } +} + +func TestNew(t *testing.T) { + reader := New() + if reader == nil { + t.Fatal("expected reader to not be nil") + } +} + +type errReader struct{} + +func (errReader) Read(p []byte) (n int, err error) { + return 0, errors.New("read error") +} + +func TestReader_Line(t *testing.T) { + // 1. Success path + in := bytes.NewBufferString("hello\n") + out := &bytes.Buffer{} + p := NewFromReader(in, out) + val, err := p.Line("Enter something: ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "hello" { + t.Errorf("expected 'hello', got %q", val) + } + if out.String() != "Enter something: " { + t.Errorf("expected prompt output, got %q", out.String()) + } + + // 2. EOF with input + in = bytes.NewBufferString("hello") + out = &bytes.Buffer{} + p = NewFromReader(in, out) + val, err = p.Line("Enter: ") + if err != nil { + t.Fatalf("unexpected error on EOF with content: %v", err) + } + if val != "hello" { + t.Errorf("expected 'hello', got %q", val) + } + + // 3. Reader error + out = &bytes.Buffer{} + p = NewFromReader(errReader{}, out) + _, err = p.Line("Enter: ") + if err == nil || !strings.Contains(err.Error(), "read error") { + t.Errorf("expected read error, got %v", err) + } +} + +func TestReader_Choice(t *testing.T) { + // 1. Valid choice first attempt + in := bytes.NewBufferString("public\n") + out := &bytes.Buffer{} + p := NewFromReader(in, out) + val, err := p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "public" { + t.Errorf("expected 'public', got %q", val) + } + + // 2. Use default choice on empty line + in = bytes.NewBufferString("\n") + out = &bytes.Buffer{} + p = NewFromReader(in, out) + val, err = p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "private" { + t.Errorf("expected 'private', got %q", val) + } + + // 3. Invalid choice followed by valid choice + in = bytes.NewBufferString("invalid\nprivate\n") + out = &bytes.Buffer{} + p = NewFromReader(in, out) + val, err = p.Choice("Visibility: ", "private", []string{"public", "private"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "private" { + t.Errorf("expected 'private', got %q", val) + } + if !strings.Contains(out.String(), "invalid value \"invalid\"") { + t.Errorf("expected warning in output, got %q", out.String()) + } + + // 4. Exceed maximum attempts + in = bytes.NewBufferString("invalid1\ninvalid2\ninvalid3\n") + out = &bytes.Buffer{} + p = NewFromReader(in, out) + _, err = p.Choice("Visibility: ", "private", []string{"public", "private"}) + if err == nil || !strings.Contains(err.Error(), "no valid answer after 3 attempts") { + t.Errorf("expected error, got %v", err) + } + + // 5. Line read error inside Choice + out = &bytes.Buffer{} + p = NewFromReader(errReader{}, out) + _, err = p.Choice("Visibility: ", "private", []string{"public", "private"}) + if err == nil || !strings.Contains(err.Error(), "read error") { + t.Errorf("expected read error, got %v", err) + } +} diff --git a/internal/pulumiprog/program_test.go b/internal/pulumiprog/program_test.go new file mode 100644 index 0000000..b5272e0 --- /dev/null +++ b/internal/pulumiprog/program_test.go @@ -0,0 +1,151 @@ +package pulumiprog + +import ( + "strings" + "testing" + + "github.com/JMR-dev/gh-repo-bootstrap/internal/cli" + "github.com/JMR-dev/gh-repo-bootstrap/internal/secrets" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +type mockResources struct { + t *testing.T +} + +func (m mockResources) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { + outputs := args.Inputs.Mappable() + return args.Name + "_id", resource.NewPropertyMapFromMap(outputs), nil +} + +func (m mockResources) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { + return args.Args, nil +} + +func TestBuild_CreateMode(t *testing.T) { + inputs := Inputs{ + Owner: "test-owner", + Repo: "test-repo", + Branch: "main", + Reviews: 2, + Signed: true, + RulesetName: "test-ruleset", + Environments: []ResolvedEnv{ + { + Name: "production", + WaitTimer: intPtr(10), + PreventSelfReview: boolPtr(true), + CanAdminsBypass: boolPtr(false), + ReviewerUserIDs: []int{123}, + ReviewerTeamIDs: []int{456}, + BranchPolicy: "custom", + BranchPatterns: []string{"release/*"}, + }, + { + Name: "staging", + BranchPolicy: "protected", + }, + { + Name: "dev", + BranchPolicy: "none", + }, + }, + Bypass: []cli.BypassActor{ + {ActorID: 99, ActorType: "Team", BypassMode: "pull_request"}, + {ActorID: 100, ActorType: "RepositoryRole", BypassMode: "always"}, + }, + RepoSecrets: []secrets.Pair{ + {Name: "REPO_SECRET", Value: "secret-val"}, + }, + EnvSecrets: []secrets.EnvFile{ + { + Env: "production", + Secrets: []secrets.Pair{ + {Name: "ENV_SECRET", Value: "env-secret-val"}, + }, + }, + }, + RepoMode: cli.RepoModeCreate, + RepoSettings: cli.RepoSettings{ + Visibility: "private", + Description: strPtr("hello description"), + DefaultBranch: "main", + Topics: []string{"go", "api"}, + AllowMergeCommit: boolPtr(false), + AllowSquashMerge: boolPtr(true), + AllowRebaseMerge: boolPtr(false), + DeleteBranchOnMerge: boolPtr(true), + AutoInit: boolPtr(true), + LicenseTemplate: "mit", + GitignoreTemplate: "Go", + }, + } + + err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t})) + if err != nil { + t.Fatalf("Pulumi program failed in Create mode: %v", err) + } +} + +func TestBuild_ManageMode(t *testing.T) { + inputs := Inputs{ + Owner: "test-owner", + Repo: "test-repo", + Branch: "main", + RepoMode: cli.RepoModeManage, + RepoSettings: cli.RepoSettings{}, + } + + err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t})) + if err != nil { + t.Fatalf("Pulumi program failed in Manage mode: %v", err) + } +} + +func TestBuild_DataMode(t *testing.T) { + inputs := Inputs{ + Owner: "test-owner", + Repo: "test-repo", + Branch: "main", + RepoMode: cli.RepoModeData, + } + + err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t})) + if err != nil { + t.Fatalf("Pulumi program failed in Data mode: %v", err) + } +} + +func TestBuild_EnvSecretsTargetMismatch(t *testing.T) { + inputs := Inputs{ + Owner: "test-owner", + Repo: "test-repo", + Branch: "main", + EnvSecrets: []secrets.EnvFile{ + { + Env: "non-existent-env", + Secrets: []secrets.Pair{ + {Name: "ENV_SECRET", Value: "val"}, + }, + }, + }, + } + + err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t})) + if err == nil || !strings.Contains(err.Error(), "has no matching environment") { + t.Fatalf("expected target mismatch error, got: %v", err) + } +} + +func intPtr(i int) *int { + return &i +} + +func boolPtr(b bool) *bool { + return &b +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 0015054..74682c8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -26,6 +26,7 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) const ( @@ -33,6 +34,23 @@ const ( stackName = "bootstrap" ) +var execCommand = exec.Command + +type stackInterface interface { + SetConfig(ctx context.Context, key string, val auto.ConfigValue) error + Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error) + Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error) + Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error) +} + +var upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + s, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program, opts...) + if err != nil { + return nil, err + } + return &s, nil +} + // Run executes the requested action against the GitHub repo described by opts. func Run(ctx context.Context, opts *cli.Options) error { // --- TOML config takes over the Options struct if --config was set --- @@ -67,7 +85,7 @@ func Run(ctx context.Context, opts *cli.Options) error { // --- Auth: prefer caller-supplied GITHUB_TOKEN, else borrow from gh --- if os.Getenv("GITHUB_TOKEN") == "" { - out, err := exec.Command("gh", "auth", "token").Output() + out, err := execCommand("gh", "auth", "token").Output() if err != nil { return fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first") } @@ -158,7 +176,7 @@ func Run(ctx context.Context, opts *cli.Options) error { fmt.Println(">>> Run with --plan first to review the import + any drift reconciliation.") } - stack, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program, + stack, err := upsertStack(ctx, stackName, projectName, program, auto.WorkDir(stateDir), auto.EnvVars(map[string]string{ "PULUMI_BACKEND_URL": backendURL, diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..cabbe51 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,394 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/JMR-dev/gh-repo-bootstrap/internal/cli" + "github.com/JMR-dev/gh-repo-bootstrap/internal/githubapi" + "github.com/JMR-dev/gh-repo-bootstrap/internal/prompt" + "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// Helper process for mocking gh CLI in runner tests. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + args := os.Args + for i, arg := range args { + if arg == "--" { + args = args[i+1:] + break + } + } + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "invalid args: %v\n", args) + os.Exit(2) + } + + command := args[0] + if command != "gh" { + fmt.Fprintf(os.Stderr, "expected 'gh', got: %s\n", command) + os.Exit(2) + } + + subCmd := args[1] + switch subCmd { + case "auth": + if len(args) >= 3 && args[2] == "token" { + fmt.Print("gh_mock_token\n") + } else { + fmt.Fprintf(os.Stderr, "unknown auth subcommand\n") + os.Exit(2) + } + case "api": + if len(args) >= 3 { + path := args[2] + switch { + case path == "users/octocat": + fmt.Print(`{"id":583234}`) + case path == "orgs/JMR-dev/teams/release": + fmt.Print(`{"id":98765}`) + default: + fmt.Fprintf(os.Stderr, "unknown path: %s\n", path) + os.Exit(2) + } + } + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", subCmd) + os.Exit(2) + } +} + +func mockExec(command string, args ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", command} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + return cmd +} + +// mockStack implements stackInterface. +type mockStack struct { + setConfigCalls map[string]string + actionCalled string + failAction bool +} + +func (m *mockStack) SetConfig(ctx context.Context, key string, val auto.ConfigValue) error { + m.setConfigCalls[key] = val.Value + return nil +} + +func (m *mockStack) Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error) { + m.actionCalled = "up" + if m.failAction { + return auto.UpResult{}, errors.New("up error") + } + return auto.UpResult{}, nil +} + +func (m *mockStack) Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error) { + m.actionCalled = "preview" + if m.failAction { + return auto.PreviewResult{}, errors.New("preview error") + } + return auto.PreviewResult{}, nil +} + +func (m *mockStack) Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error) { + m.actionCalled = "destroy" + if m.failAction { + return auto.DestroyResult{}, errors.New("destroy error") + } + return auto.DestroyResult{}, nil +} + +func TestRun_Apply(t *testing.T) { + oldExec := execCommand + oldGithubExec := githubapi.ExecCommand + oldUpsert := upsertStack + execCommand = mockExec + githubapi.ExecCommand = mockExec + defer func() { + execCommand = oldExec + githubapi.ExecCommand = oldGithubExec + upsertStack = oldUpsert + }() + + // Mock upsertStack + mStack := &mockStack{setConfigCalls: make(map[string]string)} + upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + return mStack, nil + } + + stateDir := t.TempDir() + + opts := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + Branch: "main", + Action: cli.ActionApply, + StateDir: stateDir, + Environments: []*cli.EnvSpec{ + { + Name: "production", + ReviewerUsers: []string{"octocat"}, + ReviewerTeams: []string{"JMR-dev/release"}, + }, + }, + } + + // Make sure GITHUB_TOKEN is cleared to exercise fallback. + oldToken := os.Getenv("GITHUB_TOKEN") + os.Unsetenv("GITHUB_TOKEN") + defer os.Setenv("GITHUB_TOKEN", oldToken) + + err := Run(context.Background(), opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if mStack.actionCalled != "up" { + t.Errorf("expected stack action 'up', got: %s", mStack.actionCalled) + } + + if val, ok := mStack.setConfigCalls["github:owner"]; !ok || val != "JMR-dev" { + t.Errorf("expected github:owner config to be JMR-dev, got %s", val) + } + + // Verify GITHUB_TOKEN is set after Run via fallback command. + if os.Getenv("GITHUB_TOKEN") != "gh_mock_token" { + t.Errorf("expected GITHUB_TOKEN to be set to 'gh_mock_token', got: %s", os.Getenv("GITHUB_TOKEN")) + } +} + +func TestRun_PlanAndDestroy(t *testing.T) { + oldExec := execCommand + oldUpsert := upsertStack + execCommand = mockExec + defer func() { + execCommand = oldExec + upsertStack = oldUpsert + }() + + mStack := &mockStack{setConfigCalls: make(map[string]string)} + upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + return mStack, nil + } + + stateDir := t.TempDir() + + // Test Plan + optsPlan := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + Branch: "main", + Action: cli.ActionPlan, + StateDir: stateDir, + } + err := Run(context.Background(), optsPlan) + if err != nil { + t.Fatalf("unexpected plan error: %v", err) + } + if mStack.actionCalled != "preview" { + t.Errorf("expected preview, got %s", mStack.actionCalled) + } + + // Test Destroy + optsDestroy := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + Branch: "main", + Action: cli.ActionDestroy, + StateDir: stateDir, + } + err = Run(context.Background(), optsDestroy) + if err != nil { + t.Fatalf("unexpected destroy error: %v", err) + } + if mStack.actionCalled != "destroy" { + t.Errorf("expected destroy, got %s", mStack.actionCalled) + } +} + +func TestRun_SecretsAndPassphrase(t *testing.T) { + oldExec := execCommand + oldUpsert := upsertStack + execCommand = mockExec + defer func() { + execCommand = oldExec + upsertStack = oldUpsert + }() + + mStack := &mockStack{setConfigCalls: make(map[string]string)} + upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + return mStack, nil + } + + stateDir := t.TempDir() + secretsDir := t.TempDir() + + // Create repo secrets file. + repoSecretsFile := filepath.Join(secretsDir, "repo.tfvars") + _ = os.WriteFile(repoSecretsFile, []byte("TOKEN = \"1234\"\n"), 0o600) + + // Create env secrets dir and file. + envSecretsDir := filepath.Join(secretsDir, "envs") + _ = os.MkdirAll(envSecretsDir, 0o700) + _ = os.WriteFile(filepath.Join(envSecretsDir, "production.tfvars"), []byte("DB_PW = \"prodpwd\"\n"), 0o600) + + opts := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + Branch: "main", + Action: cli.ActionPlan, + StateDir: stateDir, + RepoSecretsFile: repoSecretsFile, + EnvSecretsDir: envSecretsDir, + Environments: []*cli.EnvSpec{ + {Name: "production"}, + }, + } + + // Clear passphrase env var + oldPassphrase := os.Getenv("PULUMI_CONFIG_PASSPHRASE") + os.Unsetenv("PULUMI_CONFIG_PASSPHRASE") + defer os.Setenv("PULUMI_CONFIG_PASSPHRASE", oldPassphrase) + + err := Run(context.Background(), opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify passphrase file is generated. + passphraseFile := filepath.Join(stateDir, ".passphrase") + if _, err := os.Stat(passphraseFile); os.IsNotExist(err) { + t.Error("expected passphrase file to be created") + } + + // Run again to verify it reuses the existing passphrase. + err = Run(context.Background(), opts) + if err != nil { + t.Fatalf("unexpected error on second run: %v", err) + } +} + +func TestRun_TOMLConfigFile(t *testing.T) { + oldExec := execCommand + oldUpsert := upsertStack + execCommand = mockExec + defer func() { + execCommand = oldExec + upsertStack = oldUpsert + }() + + mStack := &mockStack{setConfigCalls: make(map[string]string)} + upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + return mStack, nil + } + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.toml") + tomlData := ` +owner = "JMR-dev" +name = "config-repo" +mode = "data" +` + _ = os.WriteFile(configFile, []byte(tomlData), 0o600) + + opts := &cli.Options{ + ConfigFile: configFile, + } + + err := Run(context.Background(), opts) + if err != nil { + t.Fatalf("unexpected error with TOML config: %v", err) + } +} + +func TestRun_CreateModeValidation(t *testing.T) { + stateDir := t.TempDir() + + // --create requires --visibility (or config file) + opts := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + RepoMode: cli.RepoModeCreate, + StateDir: stateDir, + } + + err := Run(context.Background(), opts) + if err == nil || !strings.Contains(err.Error(), "input is not a terminal") { + t.Fatalf("expected visibility validation error (non-interactive), got: %v", err) + } +} + +func TestRun_CreateModeInteractive(t *testing.T) { + oldExec := execCommand + oldGithubExec := githubapi.ExecCommand + oldUpsert := upsertStack + oldIsTerminal := prompt.IsTerminal + execCommand = mockExec + githubapi.ExecCommand = mockExec + prompt.IsTerminal = func(fd int) bool { return true } + defer func() { + execCommand = oldExec + githubapi.ExecCommand = oldGithubExec + upsertStack = oldUpsert + prompt.IsTerminal = oldIsTerminal + }() + + mStack := &mockStack{setConfigCalls: make(map[string]string)} + upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) { + return mStack, nil + } + + stateDir := t.TempDir() + + opts := &cli.Options{ + Owner: "JMR-dev", + Repo: "test-repo", + RepoMode: cli.RepoModeCreate, + StateDir: stateDir, + Action: cli.ActionApply, + } + + // Create pipe to feed stdin + r, w, _ := os.Pipe() + oldStdin := os.Stdin + os.Stdin = r + defer func() { + os.Stdin = oldStdin + r.Close() + w.Close() + }() + + // Feed mock input: "public" for visibility, and "my repo description" for description. + _, _ = w.Write([]byte("public\nmy repo description\n")) + + err := Run(context.Background(), opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opts.RepoSettings.Visibility != "public" { + t.Errorf("expected Visibility to be public, got: %s", opts.RepoSettings.Visibility) + } + if opts.RepoSettings.Description == nil || *opts.RepoSettings.Description != "my repo description" { + t.Errorf("expected Description to be 'my repo description', got: %v", opts.RepoSettings.Description) + } +}