diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 4a3fc368..5789968d 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -12,6 +12,7 @@ import ( "github.com/stablekernel/cascade/internal/external" "github.com/stablekernel/cascade/internal/generate" "github.com/stablekernel/cascade/internal/globals" + "github.com/stablekernel/cascade/internal/hotfix" "github.com/stablekernel/cascade/internal/log" "github.com/stablekernel/cascade/internal/orchestrate" "github.com/stablekernel/cascade/internal/promote" @@ -69,6 +70,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(changelog.NewCommand()) rootCmd.AddCommand(external.NewCommand()) rootCmd.AddCommand(generate.NewCommand()) + rootCmd.AddCommand(hotfix.NewCommand()) rootCmd.AddCommand(orchestrate.NewCommand()) rootCmd.AddCommand(promote.NewCommand()) rootCmd.AddCommand(release.NewCommand()) diff --git a/internal/hotfix/command.go b/internal/hotfix/command.go new file mode 100644 index 00000000..1a23d197 --- /dev/null +++ b/internal/hotfix/command.go @@ -0,0 +1,202 @@ +package hotfix + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/ghaoutput" +) + +// NewCommand creates the `cascade hotfix` command and its subcommands. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "hotfix", + Short: "Apply a trunk commit onto an environment pinned to an older base", + Long: `Manage per-environment hotfixes. + +A hotfix applies a single trunk commit onto an environment whose state is pinned +to an older trunk base, without dragging in the intervening commits. The fix must +already be on trunk; cascade refuses to apply a commit that is not an ancestor of +trunk tip. + +Subcommands compute and validate the hotfix; the cherry-pick, build, deploy, and +state write run in the generated workflow.`, + } + + cmd.AddCommand(newPlanCommand()) + return cmd +} + +// newPlanCommand creates the `cascade hotfix plan` subcommand. +func newPlanCommand() *cobra.Command { + var ( + configPath string + manifestKey string + commitRef string + targetEnv string + actor string + remote string + repo string + dryRun bool + jsonOutput bool + ghaOutput bool + ) + + cmd := &cobra.Command{ + Use: "plan", + Short: "Validate and plan a hotfix to an environment", + Long: `Validate and plan a hotfix. + +This command: + 1. Verifies the fix commit is an ancestor of trunk tip (trunk-first gate) + 2. Checks the target is a configured env and not the first env (prod is allowed) + 3. Reports a no-op when the fix is already contained in the target + 4. Reconciles the env/ integration branch at the recorded state SHA + 5. Refuses to proceed when a cascade-hotfix PR already targets env/ + +It computes the env branch, base SHA, hotfix version candidate, and ready-to-run +branch-protection command suggestions. The cherry-pick, build, deploy, and state +write happen in the generated workflow; this verb computes and validates only. + +With --dry-run nothing is mutated (the env branch is planned but not created).`, + RunE: func(cmd *cobra.Command, args []string) error { + opts := []Option{ + WithDryRun(dryRun), + WithRemote(remote), + } + if repo != "" { + opts = append(opts, WithPRChecker(newGHPRChecker(repo))) + } + + planner, err := NewPlanner(PlannerOptions{ + ConfigPath: configPath, + ManifestKey: manifestKey, + Actor: actor, + }, opts...) + if err != nil { + return err + } + + result, err := planner.Plan(commitRef, targetEnv) + if err != nil { + return err + } + + switch { + case ghaOutput: + return writePlanGHAOutput(result) + case jsonOutput: + return outputJSON(result) + default: + printPlan(result) + return nil + } + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)") + cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key") + cmd.Flags().StringVar(&commitRef, "commit", "", "Trunk commit (SHA or ref) carrying the fix (required)") + cmd.Flags().StringVar(&targetEnv, "target-env", "", "Environment to hotfix (required)") + cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the plan (default: $GITHUB_ACTOR)") + cmd.Flags().StringVar(&remote, "remote", defaultRemote, "Git remote env branches live on") + cmd.Flags().StringVar(&repo, "repo", "", "owner/repo for single-flight PR lookup via gh (default: skip the check)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Compute the plan without mutating anything") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output the plan as JSON") + cmd.Flags().BoolVar(&ghaOutput, "gha-output", false, "Write outputs to $GITHUB_OUTPUT for workflow consumption") + + _ = cmd.MarkFlagRequired("commit") + _ = cmd.MarkFlagRequired("target-env") + + return cmd +} + +// ghPRChecker implements PRChecker by shelling out to the gh CLI. +type ghPRChecker struct { + repo string +} + +func newGHPRChecker(repo string) *ghPRChecker { + return &ghPRChecker{repo: repo} +} + +// OpenHotfixPRs lists open PRs labeled cascade-hotfix whose base is baseBranch. +func (g *ghPRChecker) OpenHotfixPRs(baseBranch string) ([]OpenPR, error) { + out, err := exec.Command("gh", "pr", "list", + "--repo", g.repo, + "--state", "open", + "--base", baseBranch, + "--label", hotfixPRLabel, + "--json", "number,url", + ).Output() + if err != nil { + return nil, fmt.Errorf("gh pr list: %w", err) + } + + var prs []OpenPR + if err := json.Unmarshal(out, &prs); err != nil { + return nil, fmt.Errorf("parsing gh pr list output: %w", err) + } + return prs, nil +} + +func writePlanGHAOutput(result *PlanResult) error { + w := ghaoutput.New() + w.Set("target_env", result.TargetEnv) + w.Set("fix_sha", result.FixSHA) + w.Set("branch", result.Branch) + w.Set("base_sha", result.BaseSHA) + w.SetBool("no_op", result.NoOp) + w.SetBool("branch_created", result.BranchCreated) + w.Set("hotfix_version_candidate", result.HotfixVersionCandidate) + w.SetBool("conflict_expected", result.ConflictExpected) + w.SetBool("dry_run", result.DryRun) + if err := w.SetJSON("protection_suggestions", result.ProtectionSuggestions); err != nil { + return err + } + w.SetMultiline("protection_suggestions_text", strings.Join(result.ProtectionSuggestions, "\n")) + return w.Flush() +} + +func outputJSON(v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func printPlan(result *PlanResult) { + fmt.Printf("Target env: %s\n", result.TargetEnv) + fmt.Printf("Fix commit: %s\n", short(result.FixSHA)) + if result.NoOp { + fmt.Printf("Result: no-op (fix is already in %s)\n", result.TargetEnv) + return + } + fmt.Printf("Env branch: %s (base %s)\n", result.Branch, short(result.BaseSHA)) + if result.BranchCreated { + if result.DryRun { + fmt.Printf(" would create %s at %s\n", result.Branch, short(result.BaseSHA)) + } else { + fmt.Printf(" created %s at %s\n", result.Branch, short(result.BaseSHA)) + } + } else { + fmt.Printf(" %s already present at the recorded SHA\n", result.Branch) + } + fmt.Printf("Version: %s\n", result.HotfixVersionCandidate) + if result.DryRun { + fmt.Println("Mode: dry-run (no mutations)") + } + + fmt.Println() + fmt.Println("Suggested env/* branch protection (cascade does not apply these):") + for _, s := range result.ProtectionSuggestions { + fmt.Printf(" %s\n", s) + } +} diff --git a/internal/hotfix/plan.go b/internal/hotfix/plan.go new file mode 100644 index 00000000..b4ae8723 --- /dev/null +++ b/internal/hotfix/plan.go @@ -0,0 +1,371 @@ +// Package hotfix plans and validates per-environment hotfixes that apply a +// trunk commit onto an environment pinned to an older trunk base. +// +// The plan verb computes and validates only; the cherry-pick, build, deploy, +// and state write happen in the generated workflow. A plan with --dry-run +// mutates nothing. +package hotfix + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/version" +) + +// defaultRemote is the git remote env branches live on. +const defaultRemote = "origin" + +// hotfixPRLabel is the label that identifies an in-flight hotfix resolution PR. +const hotfixPRLabel = "cascade-hotfix" + +// OpenPR is a minimal view of an open pull request returned by a PRChecker. +type OpenPR struct { + Number int `json:"number"` + URL string `json:"url"` +} + +// PRChecker reports open hotfix PRs targeting a given base branch. It is the +// single-flight gate: the plan verb refuses to proceed while a hotfix PR is +// already open against the target env branch. The default implementation is a +// no-op that reports no open PRs, so callers without GitHub context (and unit +// tests) are not forced to provide one. +type PRChecker interface { + // OpenHotfixPRs returns open PRs labeled cascade-hotfix whose base is baseBranch. + OpenHotfixPRs(baseBranch string) ([]OpenPR, error) +} + +// noopPRChecker reports no open PRs. +type noopPRChecker struct{} + +func (noopPRChecker) OpenHotfixPRs(string) ([]OpenPR, error) { return nil, nil } + +// gitRunner abstracts the few git operations the planner performs so tests can +// observe and so dry-run can suppress mutation. The default implementation +// shells out to git in the current working directory. +type gitRunner interface { + // ResolveSHA resolves a ref or short SHA to a full commit SHA. + ResolveSHA(ref string) (string, error) + // LocalBranchExists reports whether a local branch exists. + LocalBranchExists(name string) (bool, error) + // LocalBranchSHA returns the tip SHA of a local branch. + LocalBranchSHA(name string) (string, error) + // CreateBranch creates a branch pointing at sha. + CreateBranch(name, sha string) error +} + +type execGitRunner struct{} + +func (execGitRunner) ResolveSHA(ref string) (string, error) { + out, err := exec.Command("git", "rev-parse", "--verify", ref+"^{commit}").Output() + if err != nil { + return "", fmt.Errorf("git rev-parse %s: %w", ref, err) + } + return strings.TrimSpace(string(out)), nil +} + +func (execGitRunner) LocalBranchExists(name string) (bool, error) { + err := exec.Command("git", "rev-parse", "--verify", "--quiet", "refs/heads/"+name).Run() + if err == nil { + return true, nil + } + if _, ok := err.(*exec.ExitError); ok { + return false, nil + } + return false, fmt.Errorf("git rev-parse refs/heads/%s: %w", name, err) +} + +func (execGitRunner) LocalBranchSHA(name string) (string, error) { + out, err := exec.Command("git", "rev-parse", "refs/heads/"+name).Output() + if err != nil { + return "", fmt.Errorf("git rev-parse refs/heads/%s: %w", name, err) + } + return strings.TrimSpace(string(out)), nil +} + +func (execGitRunner) CreateBranch(name, sha string) error { + if out, err := exec.Command("git", "branch", name, sha).CombinedOutput(); err != nil { + return fmt.Errorf("git branch %s %s: %w\n%s", name, sha, err, out) + } + return nil +} + +// Planner validates and computes a hotfix plan for one environment. +type Planner struct { + cicd *config.CICDFile + actor string + dryRun bool + remote string + prChecker PRChecker + gitRunner gitRunner +} + +// PlannerOptions carries the required inputs for NewPlanner. +type PlannerOptions struct { + ConfigPath string + ManifestKey string + Actor string +} + +// Option configures optional, additive Planner behavior. +type Option func(*Planner) + +// WithDryRun controls whether the planner mutates anything. When true the env +// branch is computed but not created. +func WithDryRun(dryRun bool) Option { + return func(p *Planner) { p.dryRun = dryRun } +} + +// WithPRChecker injects the single-flight PR lookup. The default reports no +// open PRs. +func WithPRChecker(c PRChecker) Option { + return func(p *Planner) { + if c != nil { + p.prChecker = c + } + } +} + +// WithRemote overrides the git remote env branches live on (default "origin"). +func WithRemote(remote string) Option { + return func(p *Planner) { + if remote != "" { + p.remote = remote + } + } +} + +// NewPlanner constructs a Planner from the manifest at opts.ConfigPath. +func NewPlanner(opts PlannerOptions, options ...Option) (*Planner, error) { + key := opts.ManifestKey + if key == "" { + key = config.DefaultManifestKey + } + + cicd, err := config.ParseManifestFile(opts.ConfigPath, key) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + actor := opts.Actor + if actor == "" { + actor = "github-actions[bot]" + } + + p := &Planner{ + cicd: cicd, + actor: actor, + remote: defaultRemote, + prChecker: noopPRChecker{}, + gitRunner: execGitRunner{}, + } + for _, o := range options { + o(p) + } + return p, nil +} + +// PlanResult is the computed, validated hotfix plan emitted as JSON and GHA +// outputs. It records only what the workflow needs; no mutation has happened +// beyond the optional env-branch creation. +type PlanResult struct { + TargetEnv string `json:"target_env"` + FixSHA string `json:"fix_sha"` + Branch string `json:"branch"` + BaseSHA string `json:"base_sha"` + + // NoOp is true when the fix is already contained in the target state SHA. + NoOp bool `json:"no_op"` + + // BranchCreated is true when env/ was (or, in dry-run, would be) + // created at BaseSHA. False when it already existed at the expected tip. + BranchCreated bool `json:"branch_created"` + + // HotfixVersionCandidate is the next free hotfix version over the target + // env's current version base (e.g. v1.0.0-rc.1 -> v1.0.0-rc.1.hotfix.1). + HotfixVersionCandidate string `json:"hotfix_version_candidate"` + + // ConflictExpected hints whether the cherry-pick is likely to conflict. + // The plan verb does not run the cherry-pick, so this is best-effort and + // false by default; the workflow is authoritative. + ConflictExpected bool `json:"conflict_expected"` + + // ProtectionSuggestions are ready-to-run gh/gh api commands an operator can + // paste to establish env/* branch protection. cascade never applies these. + ProtectionSuggestions []string `json:"protection_suggestions"` + + DryRun bool `json:"dry_run"` +} + +// Plan validates the hotfix request and computes the env-branch plan. +// +// fixRef is the trunk commit (or ref/short SHA) to apply; targetEnv is the +// environment to hotfix. It enforces, in order: trunk ancestry of the fix, +// target-env eligibility, no-op detection, the single-flight open-PR gate, and +// env-branch reconciliation. The single-flight gate runs before any branch +// mutation, so a blocked plan leaves no git state changes. +func (p *Planner) Plan(fixRef, targetEnv string) (*PlanResult, error) { + cfg := p.cicd.Config + if cfg == nil { + return nil, fmt.Errorf("manifest has no config block") + } + + // Resolve the fix to a full SHA up front. + fixSHA, err := p.gitRunner.ResolveSHA(fixRef) + if err != nil { + return nil, fmt.Errorf("resolving fix commit %q: %w", fixRef, err) + } + + // 1. Trunk-ancestry gate: the fix must be an ancestor of trunk tip. + trunkSHA, err := p.gitRunner.ResolveSHA("HEAD") + if err != nil { + return nil, fmt.Errorf("resolving trunk tip: %w", err) + } + onTrunk, err := git.IsAncestor(fixSHA, trunkSHA) + if err != nil { + return nil, fmt.Errorf("checking trunk ancestry: %w", err) + } + if !onTrunk { + return nil, fmt.Errorf("commit %s is not on trunk: a hotfix must apply a commit that is already an ancestor of trunk; merge it to trunk first", short(fixSHA)) + } + + // 2. Target-env eligibility. + if cfg.GetEnvironmentIndex(targetEnv) == -1 { + return nil, fmt.Errorf("%q is not a configured environment", targetEnv) + } + if cfg.IsFirstEnvironment(targetEnv) { + return nil, fmt.Errorf("%q is the first environment; a fix reaches it by merging to trunk, not by hotfix", targetEnv) + } + // Prod IS eligible here; prod gating happens at the workflow layer. + + state := p.cicd.State[targetEnv] + if state == nil || state.SHA == "" { + return nil, fmt.Errorf("environment %q has no recorded state SHA", targetEnv) + } + baseSHA := state.SHA + + branch := envBranch(targetEnv) + result := &PlanResult{ + TargetEnv: targetEnv, + FixSHA: fixSHA, + Branch: branch, + BaseSHA: baseSHA, + ProtectionSuggestions: protectionSuggestions(branch), + DryRun: p.dryRun, + } + + // 3. No-op check: fix already contained in the target state SHA. + already, err := git.IsAncestor(fixSHA, baseSHA) + if err != nil { + return nil, fmt.Errorf("checking whether fix is already in %q: %w", targetEnv, err) + } + if already { + result.NoOp = true + return result, nil + } + + // Compute the hotfix version candidate from the env's current version. + candidate, err := hotfixVersionCandidate(state.Version) + if err != nil { + return nil, err + } + result.HotfixVersionCandidate = candidate + + // 4. Single-flight: refuse if a hotfix PR already targets env/. This + // runs before any branch mutation so a blocked plan leaves no git state. + openPRs, err := p.prChecker.OpenHotfixPRs(branch) + if err != nil { + return nil, fmt.Errorf("checking for open hotfix PRs: %w", err) + } + if len(openPRs) > 0 { + pr := openPRs[0] + return nil, fmt.Errorf("a hotfix PR (#%d %s) labeled %q already targets %s; resolve and finalize it, then re-dispatch this hotfix", + pr.Number, pr.URL, hotfixPRLabel, branch) + } + + // 5. env/ branch reconciliation. Only after the single-flight gate + // passes do we create or validate the env branch. + created, err := p.reconcileBranch(branch, baseSHA) + if err != nil { + return nil, err + } + result.BranchCreated = created + + return result, nil +} + +// reconcileBranch ensures env/ exists at baseSHA. If absent it is +// created at baseSHA (unless dry-run, where creation is only reported). If +// present its tip must equal baseSHA, otherwise the run is aborted with replay +// guidance. Returns whether the branch was (or would be) created. +func (p *Planner) reconcileBranch(branch, baseSHA string) (bool, error) { + exists, err := p.gitRunner.LocalBranchExists(branch) + if err != nil { + return false, fmt.Errorf("checking branch %s: %w", branch, err) + } + + if exists { + tip, err := p.gitRunner.LocalBranchSHA(branch) + if err != nil { + return false, fmt.Errorf("reading tip of %s: %w", branch, err) + } + if tip != baseSHA { + return false, fmt.Errorf( + "branch %s tip %s does not match recorded state SHA %s; this indicates an interrupted hotfix or manual edits: replay the hotfix workflow for the open PR, or reset %s to %s, before re-running", + branch, short(tip), short(baseSHA), branch, short(baseSHA)) + } + return false, nil + } + + // Branch absent: it will be created at baseSHA. + if p.dryRun { + return true, nil + } + if err := p.gitRunner.CreateBranch(branch, baseSHA); err != nil { + return false, fmt.Errorf("creating %s at %s: %w", branch, short(baseSHA), err) + } + return true, nil +} + +// hotfixVersionCandidate returns the next free hotfix version over the base of +// envVersion. An rc version yields its first nested hotfix segment. +func hotfixVersionCandidate(envVersion string) (string, error) { + if envVersion == "" { + return "", fmt.Errorf("target environment has no recorded version; cannot compute hotfix version") + } + v, err := version.Parse(envVersion) + if err != nil { + return "", fmt.Errorf("parsing target version %q: %w", envVersion, err) + } + return v.NextHotfix().String(), nil +} + +// envBranch returns the integration branch name for an environment. +func envBranch(env string) string { + return "env/" + env +} + +// protectionSuggestions returns ready-to-run gh CLI commands an operator can +// paste to protect the env branch. cascade only prints these; it never applies +// branch protection itself. +func protectionSuggestions(branch string) []string { + return []string{ + fmt.Sprintf("# Protect %s so hotfix resolution PRs merge through review:", branch), + fmt.Sprintf("gh api -X PUT repos/{owner}/{repo}/branches/%s/protection "+ + "-f required_pull_request_reviews.required_approving_review_count=1 "+ + "-F enforce_admins=true "+ + "-F required_status_checks=null "+ + "-F restrictions=null", branch), + fmt.Sprintf("gh label create %s --color B60205 --description \"Cascade hotfix resolution PR\" || true", hotfixPRLabel), + } +} + +func short(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/internal/hotfix/plan_integration_test.go b/internal/hotfix/plan_integration_test.go new file mode 100644 index 00000000..aad48e85 --- /dev/null +++ b/internal/hotfix/plan_integration_test.go @@ -0,0 +1,101 @@ +package hotfix + +import ( + "os/exec" + "strings" + "testing" +) + +// TestPlan_Integration_FullFlow exercises the plan verb end to end against a +// real temporary git repository: branch reconciliation creates env/ at +// the recorded state SHA, every emitted output is populated, and a subsequent +// --dry-run run over a fresh repo mutates nothing. This is the scratch-repo +// integration coverage that complements the focused unit tests; full act/gitea +// e2e for the plan verb through the generated workflow is owned by the e2e +// harness unit. +func TestPlan_Integration_FullFlow(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + // fix lands on trunk after the env's recorded base, so it is a real hotfix. + fix := commitFile(t, "b.txt", "two", "fix on trunk") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + + // Reconciliation created env/test at the recorded state SHA. + if !res.BranchCreated { + t.Error("expected BranchCreated=true") + } + if got := gitOut(t, "rev-parse", "env/test"); got != base { + t.Errorf("env/test tip = %q, want recorded base %q", got, base) + } + + // Every emitted output the workflow consumes is populated. + if res.Branch != "env/test" { + t.Errorf("branch = %q, want env/test", res.Branch) + } + if res.BaseSHA != base { + t.Errorf("base_sha = %q, want %q", res.BaseSHA, base) + } + if res.FixSHA != fix { + t.Errorf("fix_sha = %q, want %q", res.FixSHA, fix) + } + if res.HotfixVersionCandidate != "v1.0.0-rc.1.hotfix.1" { + t.Errorf("hotfix_version_candidate = %q, want v1.0.0-rc.1.hotfix.1", res.HotfixVersionCandidate) + } + if len(res.ProtectionSuggestions) == 0 { + t.Error("expected protection_suggestions to be populated") + } + if joined := strings.Join(res.ProtectionSuggestions, "\n"); !strings.Contains(joined, "env/test") { + t.Errorf("protection_suggestions should target env/test:\n%s", joined) + } + if res.NoOp { + t.Error("expected non-noop plan") + } +} + +// TestPlan_Integration_DryRunMutatesNothing confirms a full dry-run plan over a +// real repository computes the same outputs but creates no env branch. +func TestPlan_Integration_DryRunMutatesNothing(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix on trunk") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest, WithDryRun(true)) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + + if !res.DryRun { + t.Error("expected DryRun=true on the result") + } + if !res.BranchCreated { + t.Error("dry-run should still report it WOULD create the branch") + } + if res.BaseSHA != base { + t.Errorf("base_sha = %q, want %q", res.BaseSHA, base) + } + if res.HotfixVersionCandidate != "v1.0.0-rc.1.hotfix.1" { + t.Errorf("hotfix_version_candidate = %q, want v1.0.0-rc.1.hotfix.1", res.HotfixVersionCandidate) + } + // The defining dry-run property: no env branch on disk. + if err := exec.Command("git", "rev-parse", "--verify", "env/test").Run(); err == nil { + t.Error("dry-run created env/test; a dry-run plan must mutate nothing") + } +} diff --git a/internal/hotfix/plan_test.go b/internal/hotfix/plan_test.go new file mode 100644 index 00000000..252d5118 --- /dev/null +++ b/internal/hotfix/plan_test.go @@ -0,0 +1,431 @@ +package hotfix + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// newScratchRepo initializes a git repository in a temp directory, chdirs into +// it for the duration of the test, and returns the repo path. The original +// working directory is restored via t.Cleanup. +func newScratchRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + + runGit(t, "init", "-b", "main") + runGit(t, "config", "user.email", "test@example.com") + runGit(t, "config", "user.name", "Test User") + runGit(t, "config", "commit.gpgsign", "false") + + return dir +} + +func runGit(t *testing.T, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func gitOut(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("git", args...).Output() + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)) +} + +func commitFile(t *testing.T, name, content, message string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(".", name), []byte(content), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + runGit(t, "add", name) + runGit(t, "commit", "-m", message) + return gitOut(t, "rev-parse", "HEAD") +} + +// writeManifest writes a manifest file with the given environments and state map +// and returns its path. State entries are "env:sha" pairs. +func writeManifest(t *testing.T, envs []string, state map[string]string) string { + t.Helper() + + var b strings.Builder + b.WriteString("ci:\n") + b.WriteString(" config:\n") + b.WriteString(" environments:\n") + for _, e := range envs { + b.WriteString(" - " + e + "\n") + } + b.WriteString(" state:\n") + for e, sha := range state { + b.WriteString(" " + e + ":\n") + b.WriteString(" sha: " + sha + "\n") + b.WriteString(" version: v1.0.0-rc.1\n") + } + + path := filepath.Join(".", "manifest.yaml") + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + return path +} + +// stubPRChecker records lookups and returns a fixed list of open PRs. +type stubPRChecker struct { + prs []OpenPR + calledWith string +} + +func (s *stubPRChecker) OpenHotfixPRs(baseBranch string) ([]OpenPR, error) { + s.calledWith = baseBranch + return s.prs, nil +} + +func newPlanner(t *testing.T, manifest string, opts ...Option) *Planner { + t.Helper() + p, err := NewPlanner(PlannerOptions{ConfigPath: manifest, ManifestKey: "ci", Actor: "tester"}, opts...) + if err != nil { + t.Fatalf("NewPlanner: %v", err) + } + return p +} + +func TestPlan_RejectsNonTrunkCommit(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + // Side commit that is not on trunk. + runGit(t, "checkout", "-b", "side") + side := commitFile(t, "b.txt", "two", "side fix") + runGit(t, "checkout", "main") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": base, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest) + _, err := p.Plan(side, "test") + if err == nil { + t.Fatal("expected error for non-trunk commit, got nil") + } + if !strings.Contains(err.Error(), "trunk") { + t.Errorf("error %q should mention trunk", err.Error()) + } +} + +func TestPlan_RejectsFirstEnv(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix on trunk") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": base, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest) + _, err := p.Plan(fix, "dev") + if err == nil { + t.Fatal("expected error for first env, got nil") + } + if !strings.Contains(err.Error(), "first environment") { + t.Errorf("error %q should mention first environment", err.Error()) + } +} + +func TestPlan_RejectsUnknownEnv(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": base, "test": base, "prod": base, + }) + + p := newPlanner(t, manifest) + if _, err := p.Plan(fix, "staging"); err == nil { + t.Fatal("expected error for unknown env") + } +} + +func TestPlan_NoOpWhenFixAlreadyInTarget(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + tip := commitFile(t, "c.txt", "three", "later") + + // test points at tip, which already contains the fix. + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": base, + "test": tip, + "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if !res.NoOp { + t.Errorf("expected NoOp=true, got %+v", res) + } +} + +func TestPlan_CreatesEnvBranchAtStateSHA(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if res.NoOp { + t.Fatal("expected non-noop plan") + } + if res.Branch != "env/test" { + t.Errorf("branch = %q, want env/test", res.Branch) + } + if res.BaseSHA != base { + t.Errorf("base sha = %q, want %q", res.BaseSHA, base) + } + if !res.BranchCreated { + t.Error("expected BranchCreated=true when branch absent") + } + // Verify the local branch was actually created at base SHA (not dry-run). + got := gitOut(t, "rev-parse", "env/test") + if got != base { + t.Errorf("env/test tip = %q, want %q", got, base) + } +} + +func TestPlan_DryRunMutatesNothing(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + p := newPlanner(t, manifest, WithDryRun(true)) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if res.NoOp { + t.Fatal("expected non-noop plan") + } + // In dry-run the branch must NOT be created. + if err := exec.Command("git", "rev-parse", "--verify", "env/test").Run(); err == nil { + t.Error("dry-run created env/test branch; should mutate nothing") + } + if !res.BranchCreated { + t.Error("dry-run should still report it WOULD create the branch") + } +} + +func TestPlan_ExistingBranchTipMismatch_FailsWithReplayGuidance(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + other := commitFile(t, "b.txt", "two", "other") + fix := commitFile(t, "c.txt", "three", "fix") + + // env/test exists but its tip is "other", not the recorded state SHA (base). + runGit(t, "branch", "env/test", other) + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, + "test": base, + "prod": base, + }) + + p := newPlanner(t, manifest) + _, err := p.Plan(fix, "test") + if err == nil { + t.Fatal("expected tip-mismatch error") + } + if !strings.Contains(strings.ToLower(err.Error()), "replay") { + t.Errorf("error %q should include replay guidance", err.Error()) + } +} + +func TestPlan_ExistingBranchTipMatch_OK(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + runGit(t, "branch", "env/test", base) + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if res.BranchCreated { + t.Error("branch already existed; BranchCreated should be false") + } +} + +func TestPlan_SingleFlight_OpenPRBlocks(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + checker := &stubPRChecker{prs: []OpenPR{{Number: 42, URL: "https://example.test/pr/42"}}} + p := newPlanner(t, manifest, WithPRChecker(checker)) + _, err := p.Plan(fix, "test") + if err == nil { + t.Fatal("expected single-flight error") + } + if checker.calledWith != "env/test" { + t.Errorf("PR checker queried %q, want env/test", checker.calledWith) + } + if !strings.Contains(err.Error(), "42") { + t.Errorf("error %q should reference the open PR number", err.Error()) + } + // A blocked plan must leave no git state: env/test must NOT have been + // created by the aborted reconciliation. + if err := exec.Command("git", "rev-parse", "--verify", "env/test").Run(); err == nil { + t.Error("single-flight block created env/test branch; a blocked plan must mutate nothing") + } +} + +func TestPlan_SingleFlight_NoOpenPRAllows(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + checker := &stubPRChecker{prs: nil} + p := newPlanner(t, manifest, WithPRChecker(checker)) + if _, err := p.Plan(fix, "test"); err != nil { + t.Fatalf("Plan: %v", err) + } +} + +func TestPlan_ProdTargetAllowed(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": fix, "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "prod") + if err != nil { + t.Fatalf("prod target should be eligible: %v", err) + } + if res.Branch != "env/prod" { + t.Errorf("branch = %q, want env/prod", res.Branch) + } +} + +func TestPlan_VersionCandidateAndProtectionSuggestions(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + p := newPlanner(t, manifest) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + + // state[test].Version is v1.0.0-rc.1, so the candidate is the first hotfix. + if res.HotfixVersionCandidate != "v1.0.0-rc.1.hotfix.1" { + t.Errorf("version candidate = %q, want v1.0.0-rc.1.hotfix.1", res.HotfixVersionCandidate) + } + + if len(res.ProtectionSuggestions) == 0 { + t.Fatal("expected protection_suggestions to be populated (Q6)") + } + joined := strings.Join(res.ProtectionSuggestions, "\n") + if !strings.Contains(joined, "gh api") { + t.Errorf("protection suggestions should include a gh api command:\n%s", joined) + } + if !strings.Contains(joined, "env/test") { + t.Errorf("protection suggestions should target env/test:\n%s", joined) + } +} + +func TestPlan_JSONOutputGolden(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeManifest(t, []string{"dev", "test", "prod"}, map[string]string{ + "dev": fix, "test": base, "prod": base, + }) + + p := newPlanner(t, manifest, WithDryRun(true)) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + + data, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(data) + + for _, want := range []string{ + `"target_env":"test"`, + `"branch":"env/test"`, + `"no_op":false`, + `"dry_run":true`, + `"hotfix_version_candidate":"v1.0.0-rc.1.hotfix.1"`, + `"protection_suggestions":`, + } { + if !strings.Contains(s, want) { + t.Errorf("JSON output missing %s\ngot: %s", want, s) + } + } +}