From 7ca7645639b334dc0fb44ff8ac38e55dd1184a7b Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Thu, 9 Apr 2026 12:07:38 +1000 Subject: [PATCH] allow for stale branch cleanup (& dry run) --- cmd/preflight/cleanup_cmd.go | 99 +++++++++ cmd/preflight/cleanup_cmd_test.go | 318 +++++++++++++++++++++++++++++ cmd/preflight/preflight.go | 109 ++++++---- cmd/preflight/preflight_test.go | 22 +- internal/preflight/branch_build.go | 104 ++++++++++ internal/preflight/cleanup.go | 40 +++- internal/preflight/cleanup_test.go | 27 +++ main.go | 52 ++--- 8 files changed, 698 insertions(+), 73 deletions(-) create mode 100644 cmd/preflight/cleanup_cmd.go create mode 100644 cmd/preflight/cleanup_cmd_test.go create mode 100644 internal/preflight/branch_build.go diff --git a/cmd/preflight/cleanup_cmd.go b/cmd/preflight/cleanup_cmd.go new file mode 100644 index 00000000..d73e931b --- /dev/null +++ b/cmd/preflight/cleanup_cmd.go @@ -0,0 +1,99 @@ +package preflight + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/alecthomas/kong" + + "github.com/buildkite/cli/v3/internal/cli" + bkErrors "github.com/buildkite/cli/v3/internal/errors" + "github.com/buildkite/cli/v3/internal/preflight" +) + +// CleanupCmd deletes remote bk/preflight/* branches whose builds have completed. +type CleanupCmd struct { + Pipeline string `help:"The pipeline to check builds against. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + DryRun bool `help:"Show which branches would be deleted without actually deleting them." name:"dry-run"` + Text bool `help:"Use plain text output instead of interactive terminal UI." xor:"output"` + JSON bool `help:"Emit one JSON object per event (JSONL)." xor:"output"` +} + +func (c *CleanupCmd) Help() string { + return `Deletes remote bk/preflight/* branches whose builds have completed (passed, failed, canceled). Branches with in-progress builds are left untouched to avoid interrupting concurrent preflight runs.` +} + +func (c *CleanupCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + pCtx, err := setup(c.Pipeline, globals) + if err != nil { + return err + } + defer pCtx.Stop() + + ctx := pCtx.Ctx + repoRoot := pCtx.RepoRoot + resolvedPipeline := pCtx.Pipeline + + branches, err := preflight.ListRemotePreflightBranches(repoRoot, globals.EnableDebug()) + if err != nil { + return bkErrors.NewInternalError(err, "failed to list remote preflight branches") + } + + if len(branches) == 0 { + fmt.Fprintln(os.Stdout, "No preflight branches found") + return nil + } + + renderer := newRenderer(os.Stdout, c.JSON, c.Text, pCtx.Stop) + defer renderer.Close() + + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Found %d preflight branch(es), checking build status...", len(branches))}) + + if err := preflight.ResolveBuilds(ctx, pCtx.Factory.RestAPIClient, resolvedPipeline.Org, resolvedPipeline.Name, branches); err != nil { + if errors.Is(err, context.Canceled) { + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: "Cleanup interrupted"}) + return nil + } + return bkErrors.NewInternalError(err, "failed to check build status for preflight branches") + } + + var toDelete []string + var deleted, skipped int + for i := range branches { + bb := branches[i] + if !bb.IsCompleted() { + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Skipping %s (build state: %s)", bb.Branch, bb.Build.State)}) + skipped++ + continue + } + + state := "no build found" + if bb.Build != nil { + state = bb.Build.State + } + + if c.DryRun { + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Would delete %s (%s)", bb.Branch, state)}) + } else { + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Deleting %s (%s)", bb.Branch, state)}) + toDelete = append(toDelete, bb.Ref) + } + deleted++ + } + + if !c.DryRun && len(toDelete) > 0 { + if err := preflight.CleanupRefs(repoRoot, toDelete, globals.EnableDebug()); err != nil { + return bkErrors.NewInternalError(err, "failed to delete preflight branches from remote") + } + } + + verb := "deleted" + if c.DryRun { + verb = "would delete" + } + _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Cleanup complete: %d %s, %d skipped", deleted, verb, skipped)}) + return nil +} diff --git a/cmd/preflight/cleanup_cmd_test.go b/cmd/preflight/cleanup_cmd_test.go new file mode 100644 index 00000000..f55383db --- /dev/null +++ b/cmd/preflight/cleanup_cmd_test.go @@ -0,0 +1,318 @@ +package preflight + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/buildkite/cli/v3/internal/config" + bkErrors "github.com/buildkite/cli/v3/internal/errors" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + buildkite "github.com/buildkite/go-buildkite/v4" +) + +func TestCleanupCmd_Run(t *testing.T) { + t.Run("returns validation error when experiment disabled", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "") + + cmd := &CleanupCmd{} + err := cmd.Run(nil, stubGlobals{}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !bkErrors.IsValidationError(err) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + }) + + t.Run("deletes completed branches and skips running ones", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + // Create a test repo with two preflight branches. + worktree := initTestRepo(t) + t.Chdir(worktree) + + // Create two preflight branches by pushing commits. + createPreflightBranch(t, worktree, "bk/preflight/completed-one") + createPreflightBranch(t, worktree, "bk/preflight/still-running") + + // Verify both branches exist on remote. + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if !strings.Contains(refs, "bk/preflight/completed-one") { + t.Fatal("expected completed-one branch to exist") + } + if !strings.Contains(refs, "bk/preflight/still-running") { + t.Fatal("expected still-running branch to exist") + } + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { + branches := r.URL.Query()["branch[]"] + var builds []buildkite.Build + for _, branch := range branches { + switch branch { + case "bk/preflight/completed-one": + builds = append(builds, buildkite.Build{Number: 1, State: "passed", Branch: branch}) + case "bk/preflight/still-running": + builds = append(builds, buildkite.Build{Number: 2, State: "running", Branch: branch}) + } + } + json.NewEncoder(w).Encode(builds) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + err := cmd.Run(nil, stubGlobals{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Verify completed branch was deleted. + refs = runGit(t, worktree, "ls-remote", "--heads", "origin") + if strings.Contains(refs, "bk/preflight/completed-one") { + t.Error("expected completed-one branch to be deleted") + } + + // Verify running branch was preserved. + if !strings.Contains(refs, "bk/preflight/still-running") { + t.Error("expected still-running branch to be preserved") + } + }) + + t.Run("reports no branches when none exist", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + worktree := initTestRepo(t) + t.Chdir(worktree) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + err := cmd.Run(nil, stubGlobals{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + + t.Run("deletes orphaned branches with no builds", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + worktree := initTestRepo(t) + t.Chdir(worktree) + + createPreflightBranch(t, worktree, "bk/preflight/orphaned") + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { + json.NewEncoder(w).Encode([]buildkite.Build{}) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + err := cmd.Run(nil, stubGlobals{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if strings.Contains(refs, "bk/preflight/orphaned") { + t.Error("expected orphaned branch to be deleted") + } + }) + + t.Run("falls back to git cli when factory has no repository", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + originalNewFactory := newFactory + t.Cleanup(func() { newFactory = originalNewFactory }) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { + branches := r.URL.Query()["branch[]"] + var builds []buildkite.Build + for _, branch := range branches { + builds = append(builds, buildkite.Build{Number: 1, State: "failed", Branch: branch}) + } + json.NewEncoder(w).Encode(builds) + return + } + http.NotFound(w, r) + })) + defer s.Close() + + newFactory = func(...factory.FactoryOpt) (*factory.Factory, error) { + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + return nil, err + } + return &factory.Factory{ + Config: config.New(nil, nil), + RestAPIClient: client, + }, nil + } + + worktree := initTestRepo(t) + t.Chdir(worktree) + + createPreflightBranch(t, worktree, "bk/preflight/to-clean") + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + if err := cmd.Run(nil, stubGlobals{}); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if strings.Contains(refs, "bk/preflight/to-clean") { + t.Error("expected branch to be deleted") + } + }) + + t.Run("returns error when API fails", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + worktree := initTestRepo(t) + t.Chdir(worktree) + + createPreflightBranch(t, worktree, "bk/preflight/some-branch") + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { + http.Error(w, `{"message":"internal error"}`, http.StatusInternalServerError) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + err := cmd.Run(nil, stubGlobals{}) + if err == nil { + t.Fatal("expected error when API fails, got nil") + } + + // Branch should still exist since the error prevented cleanup. + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if !strings.Contains(refs, "bk/preflight/some-branch") { + t.Error("expected branch to be preserved when API fails") + } + }) + + t.Run("dry run shows branches without deleting them", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + worktree := initTestRepo(t) + t.Chdir(worktree) + + createPreflightBranch(t, worktree, "bk/preflight/dry-run-test") + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { + branches := r.URL.Query()["branch[]"] + var builds []buildkite.Build + for _, branch := range branches { + builds = append(builds, buildkite.Build{Number: 1, State: "passed", Branch: branch}) + } + json.NewEncoder(w).Encode(builds) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true, DryRun: true} + err := cmd.Run(nil, stubGlobals{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Branch should still exist after dry run. + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if !strings.Contains(refs, "bk/preflight/dry-run-test") { + t.Error("expected branch to be preserved during dry run") + } + }) + + t.Run("stops processing when context is cancelled", func(t *testing.T) { + t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") + + worktree := initTestRepo(t) + t.Chdir(worktree) + + createPreflightBranch(t, worktree, "bk/preflight/cancel-a") + createPreflightBranch(t, worktree, "bk/preflight/cancel-b") + + // Override notifyContext to return an already-cancelled context so + // the API call in ResolveBuilds returns context.Canceled immediately. + originalNotify := notifyContext + notifyContext = func(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + cancel() + return ctx, cancel + } + t.Cleanup(func() { notifyContext = originalNotify }) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + + cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} + err := cmd.Run(nil, stubGlobals{}) + if err != nil { + t.Fatalf("expected no error on cancellation, got: %v", err) + } + + // Both branches should still exist since cleanup was interrupted. + refs := runGit(t, worktree, "ls-remote", "--heads", "origin") + if !strings.Contains(refs, "bk/preflight/cancel-a") { + t.Error("expected cancel-a to be preserved after cancellation") + } + if !strings.Contains(refs, "bk/preflight/cancel-b") { + t.Error("expected cancel-b to be preserved after cancellation") + } + }) +} + +// createPreflightBranch creates a preflight branch on the remote by pushing a commit. +func createPreflightBranch(t *testing.T, worktree, branch string) { + t.Helper() + + // Create a file and commit it on a temporary local branch. + file := filepath.Join(worktree, "preflight-marker.txt") + if err := os.WriteFile(file, []byte(branch+"\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, worktree, "add", "preflight-marker.txt") + runGit(t, worktree, "commit", "-m", "preflight snapshot for "+branch) + + // Push HEAD to the preflight branch on origin, then reset back. + commit := runGit(t, worktree, "rev-parse", "HEAD") + runGit(t, worktree, "push", "origin", commit+":refs/heads/"+branch) + runGit(t, worktree, "reset", "--hard", "HEAD~1") +} diff --git a/cmd/preflight/preflight.go b/cmd/preflight/preflight.go index 1ac32dc1..79f363b3 100644 --- a/cmd/preflight/preflight.go +++ b/cmd/preflight/preflight.go @@ -18,13 +18,14 @@ import ( "github.com/buildkite/cli/v3/internal/build/watch" "github.com/buildkite/cli/v3/internal/cli" bkErrors "github.com/buildkite/cli/v3/internal/errors" + "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/internal/preflight" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) -type PreflightCmd struct { +type RunCmd struct { Pipeline string `help:"The pipeline to build. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Watch bool `help:"Watch the build until completion." default:"true" negatable:""` Interval float64 `help:"Polling interval in seconds when watching." default:"2"` @@ -38,30 +39,26 @@ var ( newFactory = factory.New ) -func (c *PreflightCmd) Help() string { +func (c *RunCmd) Help() string { return `Snapshots your working tree (uncommitted, staged, and untracked changes) and pushes it to a bk/preflight/ branch. If there are no local changes, pushes HEAD directly.` } -func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { - f, err := newFactory(factory.WithDebug(globals.EnableDebug())) - if err != nil { - return bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite") - } - - if !f.Config.HasExperiment("preflight") { - return bkErrors.NewValidationError( - fmt.Errorf("experiment not enabled"), - "the preflight command is under development and requires the 'preflight' experiment to opt in. Run: bk config set experiments preflight or set BUILDKITE_EXPERIMENTS=preflight") +func (c *RunCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + if c.Interval <= 0 { + return bkErrors.NewValidationError(fmt.Errorf("interval must be greater than 0"), "invalid polling interval") } - repoRoot, err := resolveRepositoryRoot(f, globals.EnableDebug()) + pCtx, err := setup(c.Pipeline, globals) if err != nil { - return bkErrors.NewValidationError( - fmt.Errorf("not in a git repository: %w", err), - "preflight must be run from a git repository", - "Run this command from inside a git repository", - ) + return err } + defer pCtx.Stop() + + f := pCtx.Factory + repoRoot := pCtx.RepoRoot + resolvedPipeline := pCtx.Pipeline + ctx := pCtx.Ctx + stop := pCtx.Stop preflightID, err := uuid.NewV7() if err != nil { @@ -69,25 +66,6 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error } startedAt := time.Now() - if c.Interval <= 0 { - return bkErrors.NewValidationError(fmt.Errorf("interval must be greater than 0"), "invalid polling interval") - } - // Resolve the pipeline to create a build against. - ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - resolvers := resolver.NewAggregateResolver( - resolver.ResolveFromFlag(c.Pipeline, f.Config), - resolver.ResolveFromConfig(f.Config, resolver.PickOneWithFactory(f)), - resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOneWithFactory(f))), - ) - - resolvedPipeline, err := resolvers.Resolve(ctx) - if err != nil { - return bkErrors.NewValidationError(err, "could not resolve a pipeline", - "Specify a pipeline in .bk.yaml or link your repository to a pipeline", - ) - } - renderer := newRenderer(os.Stdout, c.JSON, c.Text, stop) _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: "Pushing snapshot of working tree..."}) @@ -232,6 +210,63 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error return finalErr } +// preflightContext holds the common dependencies for preflight subcommands. +type preflightContext struct { + Factory *factory.Factory + RepoRoot string + Pipeline *pipeline.Pipeline + Ctx context.Context + Stop context.CancelFunc +} + +// setup initializes the common preflight dependencies: factory, experiment +// gate, repository root, signal context, and pipeline resolution. +func setup(pipelineFlag string, globals cli.GlobalFlags) (*preflightContext, error) { + f, err := newFactory(factory.WithDebug(globals.EnableDebug())) + if err != nil { + return nil, bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite") + } + + if !f.Config.HasExperiment("preflight") { + return nil, bkErrors.NewValidationError( + fmt.Errorf("experiment not enabled"), + "the preflight command is under development and requires the 'preflight' experiment to opt in. Run: bk config set experiments preflight or set BUILDKITE_EXPERIMENTS=preflight") + } + + repoRoot, err := resolveRepositoryRoot(f, globals.EnableDebug()) + if err != nil { + return nil, bkErrors.NewValidationError( + fmt.Errorf("not in a git repository: %w", err), + "preflight must be run from a git repository", + "Run this command from inside a git repository", + ) + } + + ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + + resolvers := resolver.NewAggregateResolver( + resolver.ResolveFromFlag(pipelineFlag, f.Config), + resolver.ResolveFromConfig(f.Config, resolver.PickOneWithFactory(f)), + resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOneWithFactory(f))), + ) + + resolvedPipeline, err := resolvers.Resolve(ctx) + if err != nil { + stop() + return nil, bkErrors.NewValidationError(err, "could not resolve a pipeline", + "Specify a pipeline with --pipeline or link your repository to a pipeline", + ) + } + + return &preflightContext{ + Factory: f, + RepoRoot: repoRoot, + Pipeline: resolvedPipeline, + Ctx: ctx, + Stop: stop, + }, nil +} + func resolveRepositoryRoot(f *factory.Factory, debug bool) (string, error) { if f.GitRepository != nil { wt, err := f.GitRepository.Worktree() diff --git a/cmd/preflight/preflight_test.go b/cmd/preflight/preflight_test.go index 33c11b7a..57ff3f32 100644 --- a/cmd/preflight/preflight_test.go +++ b/cmd/preflight/preflight_test.go @@ -34,11 +34,11 @@ func (s stubGlobals) EnableDebug() bool { return false } var _ cli.GlobalFlags = stubGlobals{} -func TestPreflightCmd_Run(t *testing.T) { +func TestRunCmd_Run(t *testing.T) { t.Run("returns validation error when experiment disabled", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "") - cmd := &PreflightCmd{} + cmd := &RunCmd{} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") @@ -59,7 +59,7 @@ func TestPreflightCmd_Run(t *testing.T) { // Run from a temp dir that is not a git repo. t.Chdir(t.TempDir()) - cmd := &PreflightCmd{} + cmd := &RunCmd{} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") @@ -107,7 +107,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: false, Interval: 2} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: false, Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) @@ -180,7 +180,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} if err := cmd.Run(nil, stubGlobals{}); err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -227,7 +227,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) @@ -276,7 +276,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, NoCleanup: true} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, NoCleanup: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) @@ -322,7 +322,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") @@ -377,7 +377,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") @@ -426,7 +426,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") @@ -457,7 +457,7 @@ func TestPreflightCmd_Run(t *testing.T) { t.Fatal(err) } - cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Interval: 2} + cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") diff --git a/internal/preflight/branch_build.go b/internal/preflight/branch_build.go new file mode 100644 index 00000000..c18cc99c --- /dev/null +++ b/internal/preflight/branch_build.go @@ -0,0 +1,104 @@ +package preflight + +import ( + "context" + "fmt" + "strings" + + buildstate "github.com/buildkite/cli/v3/internal/build/state" + buildkite "github.com/buildkite/go-buildkite/v4" +) + +// BranchBuild represents a preflight branch and its associated build status. +type BranchBuild struct { + Branch string + Ref string + Build *buildkite.Build +} + +// IsCompleted returns true if the associated build has reached a terminal state +// (passed, failed, canceled, etc.), or if no build was found for the branch. +func (bb BranchBuild) IsCompleted() bool { + if bb.Build == nil { + return true + } + return buildstate.IsTerminal(buildstate.State(bb.Build.State)) +} + +// ListRemotePreflightBranches returns all remote branches matching bk/preflight/*. +func ListRemotePreflightBranches(dir string, debug bool) ([]BranchBuild, error) { + out, err := gitOutput(dir, nil, debug, "ls-remote", "origin", "refs/heads/bk/preflight/*") + if err != nil { + return nil, fmt.Errorf("listing remote preflight branches: %w", err) + } + + if out == "" { + return nil, nil + } + + var results []BranchBuild + for line := range strings.SplitSeq(out, "\n") { + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + ref := parts[1] + branch := strings.TrimPrefix(ref, "refs/heads/") + results = append(results, BranchBuild{Branch: branch, Ref: ref}) + } + + return results, nil +} + +// maxResolveBuildPages is the maximum number of API pages to fetch when +// resolving builds. This prevents runaway pagination when orphaned branches +// have no matching builds. +const maxResolveBuildPages = 10 + +// ResolveBuilds looks up the most recent build for each preflight branch and +// populates the Build field. Branches with no matching build retain a nil Build. +func ResolveBuilds(ctx context.Context, client *buildkite.Client, org, pipeline string, branches []BranchBuild) error { + if len(branches) == 0 { + return nil + } + + branchNames := make([]string, len(branches)) + for i := range branches { + branchNames[i] = branches[i].Branch + } + + resolved := make(map[string]*buildkite.Build, len(branches)) + opts := &buildkite.BuildsListOptions{ + Branch: branchNames, + ListOptions: buildkite.ListOptions{PerPage: 100}, + } + + for page := 0; page < maxResolveBuildPages; page++ { + builds, resp, err := client.Builds.ListByPipeline(ctx, org, pipeline, opts) + if err != nil { + return fmt.Errorf("listing builds for preflight branches: %w", err) + } + + for i := range builds { + if _, exists := resolved[builds[i].Branch]; !exists { + resolved[builds[i].Branch] = &builds[i] + } + } + + if len(builds) == 0 || len(resolved) >= len(branches) || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + for i := range branches { + branches[i].Build = resolved[branches[i].Branch] + } + + return nil +} diff --git a/internal/preflight/cleanup.go b/internal/preflight/cleanup.go index 2b9e29dd..7f1a24b9 100644 --- a/internal/preflight/cleanup.go +++ b/internal/preflight/cleanup.go @@ -1,6 +1,9 @@ package preflight -import "fmt" +import ( + "fmt" + "strings" +) // Cleanup deletes the preflight branch from the remote. // If the branch no longer exists on the remote, it is treated as success. @@ -16,3 +19,38 @@ func Cleanup(dir string, ref string, debug bool) error { refspec := fmt.Sprintf(":%s", ref) return gitRun(dir, nil, debug, "push", "origin", refspec) } + +// CleanupRefs deletes multiple refs from the remote in a single git push. +// Refs that no longer exist on the remote are silently ignored. +func CleanupRefs(dir string, refs []string, debug bool) error { + if len(refs) == 0 { + return nil + } + + out, err := gitOutput(dir, nil, debug, "ls-remote", "origin", "refs/heads/bk/preflight/*") + if err != nil { + return err + } + + remote := make(map[string]struct{}) + for line := range strings.SplitSeq(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 { + remote[parts[1]] = struct{}{} + } + } + + args := make([]string, 0, 2+len(refs)) + args = append(args, "push", "origin") + for _, ref := range refs { + if _, exists := remote[ref]; exists { + args = append(args, fmt.Sprintf(":%s", ref)) + } + } + + if len(args) == 2 { + return nil + } + + return gitRun(dir, nil, debug, args...) +} diff --git a/internal/preflight/cleanup_test.go b/internal/preflight/cleanup_test.go index 451dd821..217401cf 100644 --- a/internal/preflight/cleanup_test.go +++ b/internal/preflight/cleanup_test.go @@ -3,9 +3,36 @@ package preflight import ( "testing" + buildkite "github.com/buildkite/go-buildkite/v4" "github.com/google/uuid" ) +func TestBranchBuild_IsCompleted(t *testing.T) { + tests := []struct { + name string + build *buildkite.Build + want bool + }{ + {"nil build", nil, true}, + {"passed", &buildkite.Build{State: "passed"}, true}, + {"failed", &buildkite.Build{State: "failed"}, true}, + {"canceled", &buildkite.Build{State: "canceled"}, true}, + {"running", &buildkite.Build{State: "running"}, false}, + {"scheduled", &buildkite.Build{State: "scheduled"}, false}, + {"failing", &buildkite.Build{State: "failing"}, false}, + {"blocked", &buildkite.Build{State: "blocked"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bb := BranchBuild{Branch: "bk/preflight/test", Build: tt.build} + if got := bb.IsCompleted(); got != tt.want { + t.Errorf("IsCompleted() = %v, want %v", got, tt.want) + } + }) + } +} + func TestCleanup(t *testing.T) { worktree := initTestRepo(t) diff --git a/main.go b/main.go index 1b0c7091..519a2bb4 100644 --- a/main.go +++ b/main.go @@ -36,30 +36,30 @@ import ( // Kong CLI structure, with base commands defined as additional commands are defined in their respective files type CLI struct { // Global flags - Yes bool `help:"Skip all confirmation prompts" short:"y"` - NoInput bool `help:"Disable all interactive prompts" name:"no-input"` - Quiet bool `help:"Suppress progress output" short:"q"` - NoPager bool `help:"Disable pager for text output" name:"no-pager"` - Debug bool `help:"Enable debug output for REST API calls"` - Agent AgentCmd `cmd:"" help:"Manage agents"` - Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` - Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` - Auth AuthCmd `cmd:"" help:"Authenticate with Buildkite"` - Build BuildCmd `cmd:"" help:"Manage pipeline builds"` - Cluster ClusterCmd `cmd:"" help:"Manage organization clusters"` - Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` - Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` - Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` - Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` - Job JobCmd `cmd:"" help:"Manage jobs within a build"` - Organization OrganizationCmd `cmd:"" help:"Manage organizations" aliases:"org"` - Pipeline PipelineCmd `cmd:"" help:"Manage pipelines"` - Package PackageCmd `cmd:"" help:"Manage packages"` - Preflight preflight.PreflightCmd `cmd:"" help:"Validate pre-commit changes"` - Use use.UseCmd `cmd:"" help:"Select an organization" hidden:""` - User UserCmd `cmd:"" help:"Invite users to the organization"` - Version VersionCmd `cmd:"" help:"Print the version of the CLI being used"` - Whoami whoami.WhoAmICmd `cmd:"" help:"Print the current user and organization" hidden:""` + Yes bool `help:"Skip all confirmation prompts" short:"y"` + NoInput bool `help:"Disable all interactive prompts" name:"no-input"` + Quiet bool `help:"Suppress progress output" short:"q"` + NoPager bool `help:"Disable pager for text output" name:"no-pager"` + Debug bool `help:"Enable debug output for REST API calls"` + Agent AgentCmd `cmd:"" help:"Manage agents"` + Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` + Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` + Auth AuthCmd `cmd:"" help:"Authenticate with Buildkite"` + Build BuildCmd `cmd:"" help:"Manage pipeline builds"` + Cluster ClusterCmd `cmd:"" help:"Manage organization clusters"` + Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` + Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` + Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` + Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` + Job JobCmd `cmd:"" help:"Manage jobs within a build"` + Organization OrganizationCmd `cmd:"" help:"Manage organizations" aliases:"org"` + Pipeline PipelineCmd `cmd:"" help:"Manage pipelines"` + Package PackageCmd `cmd:"" help:"Manage packages"` + Preflight PreflightCmd `cmd:"" help:"Validate pre-commit changes"` + Use use.UseCmd `cmd:"" help:"Select an organization" hidden:""` + User UserCmd `cmd:"" help:"Invite users to the organization"` + Version VersionCmd `cmd:"" help:"Print the version of the CLI being used"` + Whoami whoami.WhoAmICmd `cmd:"" help:"Print the current user and organization" hidden:""` } type ( @@ -131,6 +131,10 @@ type ( Validate pipeline.ValidateCmd `cmd:"" help:"Validate a pipeline YAML file."` View pipeline.ViewCmd `cmd:"" help:"View a pipeline."` } + PreflightCmd struct { + Run preflight.RunCmd `cmd:"" default:"withargs" help:"Run a preflight check"` + Cleanup preflight.CleanupCmd `cmd:"" help:"Clean up completed preflight branches"` + } UserCmd struct { Invite user.InviteCmd `cmd:"" help:"Invite users to your organization."` }