diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4a70b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build ./... + - name: gofmt + run: test -z "$(gofmt -l .)" || (gofmt -l .; exit 1) + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test -v -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v7 + with: + version: v2.12.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea35fc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/app-green +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7793d15 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,8 @@ +version: "2" +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e75e3c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/ericdahl-dev/app-green + +go 1.25.0 + +toolchain go1.25.10 diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go new file mode 100644 index 0000000..83ce0d3 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,231 @@ +// Package e2e_test runs link.Link -> link.Slots -> rules.Evaluate on +// realistic synthetic chains: two accounts, each with a Test and a Production +// Env, deploying acme/app. +package e2e_test + +import ( + "slices" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/link" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +const repo = "acme/app" + +var ( + now = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + th = rules.Thresholds{StaleReview: 48 * time.Hour, DoneGrace: 24 * time.Hour, FadeAfter: 72 * time.Hour, PartialProd: 4 * time.Hour} + + stageTest = env("stage-acct", "Test", 0, false) + stageProd = env("stage-acct", "Production", 1, true) + prodTest = env("prod-acct", "Test", 2, false) + prodProd = env("prod-acct", "Production", 3, true) + + // done is Done in Jira an hour ago, well inside DoneGrace, so a chain that + // is not in prod yet raises no status mismatch and flags come only from + // what the PRs and deploys say. + done = model.Ticket{Key: "ABC-1", Title: "x", Status: "Done", StatusCategory: model.StatusDone, + Updated: now.Add(-time.Hour), StatusSince: now.Add(-time.Hour)} +) + +func env(acct, stage string, order int, prod bool) model.Env { + return model.Env{Account: acct, Pipeline: "app-pipe", Stage: stage, Order: order, Prod: prod, Repos: []string{repo}} +} + +func deploy(st model.DeployStatus, sha string, at time.Time) model.Deploy { + d := model.Deploy{ExecutionID: sha + at.Format("150405"), Status: st, Revisions: map[string]string{repo: sha}, FinishedAt: at} + if st == model.DeployAwaitingApproval { + d.ApprovalToken = "tok-" + sha + } + return d +} + +func ok(sha string, at time.Time) model.Deploy { return deploy(model.DeploySucceeded, sha, at) } + +// main's history, oldest first: z, a, b, c. A commit contains every older one. +var ancestry = map[string]int{"z": 0, "a": 1, "b": 2, "c": 3} + +func compare(t *testing.T) model.CompareFunc { + return func(r, base, head string) model.Inclusion { + bi, bok := ancestry[base] + hi, hok := ancestry[head] + if r != repo || !bok || !hok { + t.Fatalf("compare outside the fixture: %s %s...%s", r, base, head) + } + if bi <= hi { + return model.Included + } + return model.NotIncluded + } +} + +func merged(n int, head, base, sha string, at time.Time) model.PR { + return model.PR{Repo: repo, DefaultBranch: "main", Number: n, Title: "ABC-1: part", HeadRef: head, BaseRef: base, + State: model.PRMerged, MergeSHA: sha, MergedAt: at, OpenedAt: at.Add(-24 * time.Hour), + Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1} +} + +// squash is PR #1, squash-merged into main as a ten hours ago. +var squash = merged(1, "abc-1", "main", "a", now.Add(-10*time.Hour)) + +// everywhere is a deployed to all four Envs, prod last six hours ago. +func everywhere() []model.EnvHistory { + return []model.EnvHistory{ + {Env: stageTest, Deploys: []model.Deploy{ok("a", now.Add(-9*time.Hour)), ok("z", now.Add(-48*time.Hour))}}, + {Env: stageProd, Deploys: []model.Deploy{ok("a", now.Add(-7*time.Hour)), ok("z", now.Add(-47*time.Hour))}}, + {Env: prodTest, Deploys: []model.Deploy{ok("a", now.Add(-8*time.Hour)), ok("z", now.Add(-46*time.Hour))}}, + {Env: prodProd, Deploys: []model.Deploy{ok("a", now.Add(-6*time.Hour)), ok("z", now.Add(-45*time.Hour))}}, + } +} + +// run links prs to ticket, resolves its slots and evaluates the one row. ok +// is false when Evaluate dropped it. +func run(t *testing.T, ticket model.Ticket, prs []model.PR, hs []model.EnvHistory) (row model.Chain, ok bool) { + t.Helper() + chains, unlinked := link.Link([]model.Ticket{ticket}, prs, []string{"ABC"}) + if len(chains) != 1 || len(unlinked) != 0 { + t.Fatalf("link: chains %d, unlinked %d, want 1 and 0", len(chains), len(unlinked)) + } + rows := rules.Evaluate([]model.Chain{link.Slots(chains[0], hs, compare(t))}, now, th) + if len(rows) == 0 { + return model.Chain{}, false + } + return rows[0], true +} + +func kinds(fs []model.Flag) []model.FlagKind { + var out []model.FlagKind + for _, f := range fs { + out = append(out, f.Kind) + } + return out +} + +func slotState(c model.Chain, e model.Env) model.SlotState { + for _, s := range c.Slots { + if s.Env.ID() == e.ID() { + return s.State + } + } + return -1 +} + +// want checks a row's stage, level and first flag (none when kind is -1). +func want(t *testing.T, c model.Chain, stage model.Stage, level model.Level, kind model.FlagKind) { + t.Helper() + if c.Stage != stage || c.Level() != level { + t.Errorf("stage %v level %d flags %v, want stage %v level %d", c.Stage, c.Level(), kinds(c.Flags), stage, level) + } + switch { + case kind < 0 && len(c.Flags) != 0: + t.Errorf("flags %v, want none", kinds(c.Flags)) + case kind >= 0 && (len(c.Flags) == 0 || c.Flags[0].Kind != kind): + t.Errorf("flags %v, want %v first", kinds(c.Flags), kind) + } +} + +func TestSquashMergeDeployedEverywhereIsInProd(t *testing.T) { + c, ok := run(t, done, []model.PR{squash}, everywhere()) + if !ok { + t.Fatal("row dropped, want it kept: prod went live 6h ago, inside FadeAfter") + } + want(t, c, model.StageInProd, model.None, -1) +} + +func TestApprovalWaitInOneAccountIsAwaitingProd(t *testing.T) { + hs := everywhere() + hs[3].Deploys = []model.Deploy{deploy(model.DeployAwaitingApproval, "a", now.Add(-30*time.Minute)), ok("z", now.Add(-45*time.Hour))} + c, _ := run(t, done, []model.PR{squash}, hs) + want(t, c, model.StageAwaitingProd, model.Yellow, model.FlagAwaitingApproval) + if f := c.Flags[0]; f.Slot == nil || f.Slot.Env.ID() != prodProd.ID() || f.Slot.Deploy == nil || f.Slot.Deploy.ApprovalToken != "tok-a" { + t.Errorf("flag %+v, want it on prod-acct Production with the approval token", f) + } +} + +func TestProdHistoryMissingInOneAccountIsUnknown(t *testing.T) { + hs := everywhere() + hs[3].Deploys = nil + c, _ := run(t, done, []model.PR{squash}, hs) + if c.Stage == model.StageInProd { + t.Errorf("stage %v, want short of in prod: prod-acct Production history is missing", c.Stage) + } + if !slices.Contains(kinds(c.Flags), model.FlagDeployUnknown) || c.Level() != model.Yellow { + t.Errorf("level %d flags %v, want yellow with deploy unknown", c.Level(), kinds(c.Flags)) + } + if got := slotState(c, prodProd); got != model.SlotUnknown { + t.Errorf("prod-acct Production = %v, want unknown", got) + } +} + +func TestRollbackIsRed(t *testing.T) { + hs := everywhere() + hs[3].Deploys = []model.Deploy{ok("z", now.Add(-time.Hour)), ok("a", now.Add(-6*time.Hour))} + c, _ := run(t, done, []model.PR{squash}, hs) + want(t, c, model.StageAwaitingProd, model.Red, model.FlagRolledBack) + if got := slotState(c, prodProd); got != model.SlotRolledBack { + t.Errorf("prod-acct Production = %v, want rolled back", got) + } +} + +func TestBatchedRollbackIsRed(t *testing.T) { + // a went to prod-acct Production only inside b, then z replaced b. + hs := everywhere() + hs[3].Deploys = []model.Deploy{ok("z", now.Add(-time.Hour)), ok("b", now.Add(-6*time.Hour))} + c, _ := run(t, done, []model.PR{squash}, hs) + want(t, c, model.StageAwaitingProd, model.Red, model.FlagRolledBack) +} + +func TestStackedSeriesLandedIsInProd(t *testing.T) { + // main <- s1 (#1) <- s2 (#2) <- s3 (#3). #3 and #2 merged into their bases + // first, then #1 merged the whole stack into main as b. + prs := []model.PR{ + merged(3, "s3", "s2", "s3-merge", now.Add(-13*time.Hour)), + merged(2, "s2", "s1", "s2-merge", now.Add(-12*time.Hour)), + merged(1, "s1", "main", "b", now.Add(-10*time.Hour)), + } + hs := everywhere() + for i := range hs { + hs[i].Deploys = append([]model.Deploy{ok("b", now.Add(-time.Duration(6+i)*time.Hour))}, hs[i].Deploys...) + } + c, ok := run(t, done, prs, hs) + if !ok { + t.Fatal("row dropped, want it kept inside FadeAfter") + } + want(t, c, model.StageInProd, model.None, -1) +} + +func TestStrandedStackPRIsRed(t *testing.T) { + // #1 merged s1 into main as a; #2 merged into s1 an hour later, so its + // commits never reach main. + prs := []model.PR{merged(1, "s1", "main", "a", now.Add(-10*time.Hour)), merged(2, "s2", "s1", "q", now.Add(-9*time.Hour))} + c, _ := run(t, done, prs, everywhere()) + want(t, c, model.StageMerged, model.Red, model.FlagStranded) + if f := c.Flags[0]; f.PR == nil || f.PR.Number != 2 { + t.Errorf("flag %+v, want it on PR #2", f) + } +} + +func TestRepoCaseMismatchStillApplies(t *testing.T) { + // prod-acct's config and deploys spell the repo differently from GitHub. + hs := everywhere() + for _, i := range []int{2, 3} { + hs[i].Env.Repos = []string{"Acme/App"} + for j := range hs[i].Deploys { + d := &hs[i].Deploys[j] + d.Revisions = map[string]string{"ACME/app": d.Revisions[repo]} + } + } + c, ok := run(t, done, []model.PR{squash}, hs) + if !ok { + t.Fatal("row dropped, want it kept inside FadeAfter") + } + want(t, c, model.StageInProd, model.None, -1) + for _, s := range c.Slots { + if !s.Applies { + t.Errorf("%s does not apply, want every Env to apply despite the repo's case", s.Env.ID()) + } + } +} diff --git a/internal/link/keys.go b/internal/link/keys.go new file mode 100644 index 0000000..820d692 --- /dev/null +++ b/internal/link/keys.go @@ -0,0 +1,34 @@ +// Package link turns adapter snapshots into chains. Everything here is pure: +// no I/O, no clocks. Compare results come in through model.CompareFunc. +package link + +import ( + "regexp" + "slices" + "strings" +) + +// keyRe finds PROJ-123 at the start of a text or after a non-alphanumeric +// character ("feature/abc-12_fix", "feature_abc-12-x"). RE2 has no lookaround, +// so the leading boundary is matched as a group; there is no trailing boundary, +// so "abc-12fix" yields ABC-12. +var keyRe = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])([a-z][a-z0-9]+)-(\d+)`) + +// KeysIn returns the distinct ticket keys in texts whose project is one of +// projects (compared case-insensitively), uppercased, in first-seen order. +func KeysIn(projects []string, texts ...string) []string { + var keys []string + for _, t := range texts { + for _, m := range keyRe.FindAllStringSubmatch(t, -1) { + proj := strings.ToUpper(m[1]) + if !slices.ContainsFunc(projects, func(p string) bool { return strings.EqualFold(p, proj) }) { + continue + } + k := proj + "-" + m[2] + if !slices.Contains(keys, k) { + keys = append(keys, k) + } + } + } + return keys +} diff --git a/internal/link/keys_test.go b/internal/link/keys_test.go new file mode 100644 index 0000000..9af83e4 --- /dev/null +++ b/internal/link/keys_test.go @@ -0,0 +1,39 @@ +package link_test + +import ( + "reflect" + "testing" + + "github.com/ericdahl-dev/app-green/internal/link" +) + +func TestKeysIn(t *testing.T) { + projects := []string{"ABC"} + cases := []struct { + in []string + want []string + }{ + {[]string{"ABC-12: Fix the thing", "abc-12-fix-thing"}, []string{"ABC-12"}}, + {[]string{"[3/5] ABC-7: Step three", "abc-7-step"}, []string{"ABC-7"}}, + {[]string{"ABC-1, ABC-2: both", ""}, []string{"ABC-1", "ABC-2"}}, + {[]string{"XYZ-9: other project", "xyz-9"}, nil}, + {[]string{"Bump rails", "dependabot/bundler/rails-8"}, nil}, + {[]string{"UTF-8 handling", ""}, nil}, // not a configured project + {[]string{"", "feature/abc-12_fix"}, []string{"ABC-12"}}, + {[]string{"", "feature_abc-12-x"}, []string{"ABC-12"}}, + {[]string{"", "abc-12fix"}, []string{"ABC-12"}}, + {[]string{"", "xabc-12"}, nil}, + } + for _, c := range cases { + if got := link.KeysIn(projects, c.in...); !reflect.DeepEqual(got, c.want) { + t.Errorf("KeysIn(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestKeysInProjectCaseInsensitive(t *testing.T) { + got := link.KeysIn([]string{"abc"}, "abc-12: lower-case config") + if !reflect.DeepEqual(got, []string{"ABC-12"}) { + t.Errorf("KeysIn with project \"abc\" = %v, want [ABC-12]", got) + } +} diff --git a/internal/link/link.go b/internal/link/link.go new file mode 100644 index 0000000..1c1c9a4 --- /dev/null +++ b/internal/link/link.go @@ -0,0 +1,87 @@ +package link + +import "github.com/ericdahl-dev/app-green/internal/model" + +// Link groups prs under the tickets whose keys they name, in ticket order. +// A PR that names one of tickets links to it, even if it also names keys +// outside tickets. A PR that names no configured-project key is returned as +// unlinked. A PR whose keys are all outside tickets is dropped: it is not my +// work. Closed (unmerged) PRs are ignored everywhere, including stack walking. +func Link(tickets []model.Ticket, prs []model.PR, projects []string) ([]model.Chain, []model.PR) { + prs = withEffectiveSHAs(prs) + byKey := map[string][]model.PR{} + var unlinked []model.PR + for _, p := range prs { + if p.State == model.PRClosed { + continue + } + keys := KeysIn(projects, p.Title, p.HeadRef) + if len(keys) == 0 { + unlinked = append(unlinked, p) + continue + } + for _, k := range keys { + byKey[k] = append(byKey[k], p) + } + } + chains := make([]model.Chain, 0, len(tickets)) + for _, t := range tickets { + chains = append(chains, model.Chain{Ticket: t, PRs: byKey[t.Key]}) + } + return chains, unlinked +} + +// withEffectiveSHAs sets EffectiveSHA and StackPending on every merged PR by +// following BaseRef through other PRs' HeadRefs until it reaches the repo's +// default branch. Closed PRs are not followed, and when two PRs share a head +// branch the merged one is followed. A PR merged into a stack branch after +// that branch's own PR merged, or into a branch with no live PR (none, or only +// closed ones), is Stranded: StackPending too, no EffectiveSHA. A PR waiting +// on an open base PR, or caught in a cycle, is StackPending only. +func withEffectiveSHAs(prs []model.PR) []model.PR { + type rk struct{ repo, head string } + byHead := map[rk]model.PR{} + for _, p := range prs { + if p.State == model.PRClosed { + continue + } + k := rk{p.Repo, p.HeadRef} + if old, ok := byHead[k]; ok && old.State == model.PRMerged && p.State != model.PRMerged { + continue // a merged PR wins, whatever the input order + } + byHead[k] = p + } + out := make([]model.PR, len(prs)) + for i, p := range prs { + out[i] = p + if p.State != model.PRMerged { + continue + } + cur, seen, stranded := p, map[int]bool{p.Number: true}, false + for cur.BaseRef != cur.DefaultBranch { + next, ok := byHead[rk{cur.Repo, cur.BaseRef}] + if !ok { + stranded = true // base branch has no live PR: nothing brings it to main + break + } + if seen[next.Number] { + break // a cycle: treat as pending + } + if cur.State == model.PRMerged && next.State == model.PRMerged && + !cur.MergedAt.IsZero() && !next.MergedAt.IsZero() && cur.MergedAt.After(next.MergedAt) { + stranded = true // merged into a branch whose PR had already merged + break + } + seen[next.Number] = true + cur = next + } + switch { + case !stranded && cur.BaseRef == cur.DefaultBranch && cur.State == model.PRMerged: + out[i].EffectiveSHA = cur.MergeSHA + default: + out[i].StackPending = true + out[i].Stranded = stranded + } + } + return out +} diff --git a/internal/link/link_test.go b/internal/link/link_test.go new file mode 100644 index 0000000..2eb8cf3 --- /dev/null +++ b/internal/link/link_test.go @@ -0,0 +1,162 @@ +package link_test + +import ( + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/link" + "github.com/ericdahl-dev/app-green/internal/model" +) + +func pr(n int, title, head, base string, st model.PRState, sha string) model.PR { + return model.PR{Repo: "acme/app", DefaultBranch: "main", Number: n, Title: title, + HeadRef: head, BaseRef: base, State: st, MergeSHA: sha} +} + +func TestLinkGroupsPRsByKey(t *testing.T) { + tickets := []model.Ticket{{Key: "ABC-1"}, {Key: "ABC-2"}} + prs := []model.PR{ + pr(10, "ABC-1: first", "abc-1-a", "main", model.PRMerged, "aaaa111"), + pr(11, "ABC-1: follow-up", "abc-1-b", "main", model.PROpen, ""), + pr(12, "Bump deps", "dependabot/x", "main", model.PROpen, ""), + } + chains, unlinked := link.Link(tickets, prs, []string{"ABC"}) + if len(chains) != 2 || len(chains[0].PRs) != 2 || len(chains[1].PRs) != 0 { + t.Fatalf("chains = %+v", chains) + } + if chains[0].PRs[0].EffectiveSHA != "aaaa111" { + t.Errorf("merged into main: EffectiveSHA = %q", chains[0].PRs[0].EffectiveSHA) + } + if len(unlinked) != 1 || unlinked[0].Number != 12 { + t.Errorf("unlinked = %+v", unlinked) + } +} + +func TestLinkKeyWithoutTicketIsDropped(t *testing.T) { + // The PR names a key that is not one of my tickets (someone else's, or Done long ago). + chains, unlinked := link.Link(nil, []model.PR{pr(5, "ABC-99: x", "abc-99", "main", model.PROpen, "")}, []string{"ABC"}) + if len(chains) != 0 || len(unlinked) != 0 { + t.Errorf("a PR for a ticket outside my rows is ignored, not unlinked: %v %v", chains, unlinked) + } +} + +func TestLinkStackedPRs(t *testing.T) { + // main <- s1 (#1) <- s2 (#2) <- s3 (#3). #2 and #3 merged into their bases; + // #1 merged into main with sha "bbbb222". All three name ABC-5. + prs := []model.PR{ + pr(3, "[3/3] ABC-5: c", "s3", "s2", model.PRMerged, "cccc333"), + pr(2, "[2/3] ABC-5: b", "s2", "s1", model.PRMerged, "cccc333"), + pr(1, "[1/3] ABC-5: a", "s1", "main", model.PRMerged, "bbbb222"), + } + chains, _ := link.Link([]model.Ticket{{Key: "ABC-5"}}, prs, []string{"ABC"}) + for _, p := range chains[0].PRs { + if p.EffectiveSHA != "bbbb222" || p.StackPending { + t.Errorf("#%d EffectiveSHA=%q pending=%v, want bbbb222/false", p.Number, p.EffectiveSHA, p.StackPending) + } + } +} + +func TestLinkStackNotYetOnMain(t *testing.T) { + prs := []model.PR{ + pr(2, "ABC-6: b", "s2", "s1", model.PRMerged, "dddd444"), + pr(1, "ABC-6: a", "s1", "main", model.PROpen, ""), + } + chains, _ := link.Link([]model.Ticket{{Key: "ABC-6"}}, prs, []string{"ABC"}) + got := chains[0].PRs[0] // #2 + if got.Number != 2 || got.EffectiveSHA != "" || !got.StackPending { + t.Errorf("#2 = %+v, want pending with no EffectiveSHA", got) + } +} + +func TestLinkStackEdgeCases(t *testing.T) { + prs := []model.PR{ + // Base branch has no PR: pending and stranded. + pr(1, "ABC-7: orphan", "o1", "gone", model.PRMerged, "eeee555"), + // Cycle between two stack branches: pending (not stranded), and it terminates. + pr(2, "ABC-7: loop a", "la", "lb", model.PRMerged, "ffff666"), + pr(3, "ABC-7: loop b", "lb", "la", model.PRMerged, "ffff666"), + // Open PR: neither set. + pr(4, "ABC-7: open", "op", "main", model.PROpen, ""), + } + chains, _ := link.Link([]model.Ticket{{Key: "ABC-7"}}, prs, []string{"ABC"}) + for _, p := range chains[0].PRs { + wantPending, wantStranded := p.State == model.PRMerged, p.Number == 1 + if p.EffectiveSHA != "" || p.StackPending != wantPending || p.Stranded != wantStranded { + t.Errorf("#%d EffectiveSHA=%q pending=%v stranded=%v, want \"\"/%v/%v", p.Number, p.EffectiveSHA, p.StackPending, p.Stranded, wantPending, wantStranded) + } + } +} + +func TestLinkSharedHeadPrefersMerged(t *testing.T) { + // Head "s1" has #1 merged into main and #9 open (reopened on the same branch). + one := pr(1, "ABC-8: a", "s1", "main", model.PRMerged, "aaaa111") + nine := pr(9, "ABC-8: a again", "s1", "main", model.PROpen, "") + two := pr(2, "ABC-8: b", "s2", "s1", model.PRMerged, "bbbb222") + for _, prs := range [][]model.PR{{one, nine, two}, {nine, one, two}, {two, nine, one}} { + chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, prs, []string{"ABC"}) + for _, p := range chains[0].PRs { + if p.Number == 2 && (p.EffectiveSHA != "aaaa111" || p.StackPending) { + t.Errorf("order %d,%d,%d: #2 EffectiveSHA=%q pending=%v, want aaaa111/false", + prs[0].Number, prs[1].Number, prs[2].Number, p.EffectiveSHA, p.StackPending) + } + } + } +} + +func TestLinkSharedHeadIgnoresClosed(t *testing.T) { + one := pr(1, "ABC-8: a", "s1", "main", model.PRMerged, "aaaa111") + closed := pr(9, "ABC-8: a old", "s1", "main", model.PRClosed, "") + two := pr(2, "ABC-8: b", "s2", "s1", model.PRMerged, "bbbb222") + chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, []model.PR{one, two, closed}, []string{"ABC"}) + for _, p := range chains[0].PRs { + if p.Number == 2 && (p.EffectiveSHA != "aaaa111" || p.StackPending) { + t.Errorf("#2 EffectiveSHA=%q pending=%v, want aaaa111/false", p.EffectiveSHA, p.StackPending) + } + } +} + +func TestLinkMergedIntoStackAfterBaseMerged(t *testing.T) { + t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + cases := []struct { + name string + twoAt time.Time + wantSHA string + wantPending bool + wantStranded bool + }{ + {"normal order", t0, "aaaa111", false, false}, + {"stranded after base merged", t0.Add(2 * time.Hour), "", true, true}, + } + for _, c := range cases { + one := pr(1, "ABC-9: a", "s1", "main", model.PRMerged, "aaaa111") + one.MergedAt = t0.Add(time.Hour) + two := pr(2, "ABC-9: b", "s2", "s1", model.PRMerged, "bbbb222") + two.MergedAt = c.twoAt + chains, _ := link.Link([]model.Ticket{{Key: "ABC-9"}}, []model.PR{two, one}, []string{"ABC"}) + got := chains[0].PRs[0] + if got.Number != 2 || got.EffectiveSHA != c.wantSHA || got.StackPending != c.wantPending || got.Stranded != c.wantStranded { + t.Errorf("%s: #%d EffectiveSHA=%q pending=%v stranded=%v, want %q/%v/%v", + c.name, got.Number, got.EffectiveSHA, got.StackPending, got.Stranded, c.wantSHA, c.wantPending, c.wantStranded) + } + } +} + +func TestLinkStrandedWhenBaseBranchHasNoPR(t *testing.T) { + // #1 merged into "gone", which no PR (open or merged) will ever bring to main. + prs := []model.PR{pr(1, "ABC-10: a", "o1", "gone", model.PRMerged, "eeee555")} + chains, _ := link.Link([]model.Ticket{{Key: "ABC-10"}}, prs, []string{"ABC"}) + if got := chains[0].PRs[0]; !got.Stranded || !got.StackPending || got.EffectiveSHA != "" { + t.Errorf("#1 = %+v, want stranded and pending with no EffectiveSHA", got) + } +} + +func TestLinkWaitingOnOpenBaseIsNotStranded(t *testing.T) { + prs := []model.PR{ + pr(2, "ABC-11: b", "s2", "s1", model.PRMerged, "dddd444"), + pr(1, "ABC-11: a", "s1", "main", model.PROpen, ""), + } + chains, _ := link.Link([]model.Ticket{{Key: "ABC-11"}}, prs, []string{"ABC"}) + if got := chains[0].PRs[0]; got.Number != 2 || got.Stranded || !got.StackPending { + t.Errorf("#2 = %+v, want pending but not stranded: its base PR is still open", got) + } +} diff --git a/internal/link/slots.go b/internal/link/slots.go new file mode 100644 index 0000000..48754ce --- /dev/null +++ b/internal/link/slots.go @@ -0,0 +1,289 @@ +package link + +import ( + "slices" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Slot decides where prs stand in one Env right now. Only merged PRs in a +// repo this Env deploys count (Env.Repos when config set it, else the repos in +// its history as a last resort); repos match ignoring case, and a deploy +// carries a repo only with a non-empty SHA for it. Open and closed PRs never +// hold a slot back. A merged PR that +// is StackPending or has no EffectiveSHA (not on the default branch yet, or +// malformed input) counts as SlotNotYet. With Env.Repos set, a PR whose repo +// no deploy in the history carries counts as SlotUnknown. The chain's slot is +// its least advanced PR (see rank; Failed and RolledBack are the least +// advanced); for ties, the latest Deployed time and the newest +// AwaitingApproval deploy win. Applies is set when at least one merged PR is +// in a repo this Env deploys. +func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlot { + slot := model.EnvSlot{Env: h.Env, Health: h.Health, State: model.SlotNotYet} + var results []model.EnvSlot + for _, p := range prs { + if p.State != model.PRMerged || !deploysRepo(h, p.Repo) { + continue + } + if p.StackPending || p.EffectiveSHA == "" { + // Merged, but not on the default branch yet (or malformed input): + // it holds the chain back. + results = append(results, model.EnvSlot{Env: h.Env, State: model.SlotNotYet}) + continue + } + if !historyCarries(h, p.Repo) { + // Config says this Env deploys the repo, but no deploy in the + // history carries it: the history is missing or truncated. + results = append(results, model.EnvSlot{Env: h.Env, State: model.SlotUnknown}) + continue + } + results = append(results, slotFor(p, h, cmp)) + } + if len(results) == 0 { + return slot + } + // The chain is only as far as its least advanced PR. + worst := results[0] + for _, r := range results[1:] { + if behind(r, worst) { + worst = r + } + } + worst.Health = h.Health + worst.Applies = true + return worst +} + +// behind reports whether r should replace worst in the "worst of" across PRs. +// For two Deployed PRs the later one wins: the chain is fully deployed only +// when its last PR is. For two AwaitingApproval PRs the newer deploy wins. +func behind(r, worst model.EnvSlot) bool { + if rank(r.State) != rank(worst.State) { + return rank(r.State) < rank(worst.State) + } + switch r.State { + case model.SlotDeployed: + return r.At.After(worst.At) + case model.SlotAwaitingApproval: + // The newer run carries the token that moves the whole chain on. + return r.Deploy != nil && (worst.Deploy == nil || r.Deploy.FinishedAt.After(worst.Deploy.FinishedAt)) + } + return false +} + +// deploysRepo reports whether h's Env deploys repo: from Env.Repos when +// config set it, else from the repos seen in its deploys. +func deploysRepo(h model.EnvHistory, repo string) bool { + if len(h.Env.Repos) > 0 { + return slices.ContainsFunc(h.Env.Repos, func(r string) bool { return strings.EqualFold(r, repo) }) + } + return historyCarries(h, repo) +} + +// revision is the SHA revs holds for repo, matching the repo ignoring case, +// or "" when it holds none. +func revision(revs map[string]string, repo string) string { + if sha, ok := revs[repo]; ok { + return sha + } + for k, sha := range revs { + if strings.EqualFold(k, repo) { + return sha + } + } + return "" +} + +// historyCarries reports whether any deploy in h carries repo: holds a +// non-empty SHA for it. Every "carries" check in this package means that. +func historyCarries(h model.EnvHistory, repo string) bool { + for _, d := range h.Deploys { + if revision(d.Revisions, repo) != "" { + return true + } + } + return false +} + +// rank orders states from least to most advanced for "worst of" decisions. +// Failed and RolledBack rank lowest so a real failure is never hidden behind +// Unknown. +func rank(s model.SlotState) int { + switch s { + case model.SlotFailed, model.SlotRolledBack: + return 0 + case model.SlotUnknown: + return 1 + case model.SlotNotYet: + return 2 + case model.SlotInProgress: + return 3 + case model.SlotAwaitingApproval: + return 4 + case model.SlotDeployed: + return 5 + default: + return 0 // a state added later ranks lowest on purpose: never look further along than we know + } +} + +// slotFor decides where one PR stands in h. "Deployed" means running now: the +// newest succeeded deploy carrying p.Repo (N) contains p.EffectiveSHA, either +// as its exact SHA or by compare. At is then when it went live this time: the +// oldest success in the unbroken run of successes back from N that contain it, +// never older than the PR's own oldest exact-SHA success (see liveSince). An +// older exact success does not count when N does not contain the PR (a +// rollback). Otherwise the newest non-success run that contains the PR decides +// (failed, in progress, awaiting approval): exact SHA, or by compare for runs +// newer than N. When N does not contain the PR but an older success did (its +// exact SHA, or else, walking older successes newest to oldest, the first one +// whose compare says Included: a batched deploy), the slot is RolledBack +// (Deploy and At are N's) unless a run newer than N contains it. That walk +// stops at the first compare that cannot answer. With no such run: Unknown if +// N's compare or the walk could not answer, else NotYet. +func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlot { + s := model.EnvSlot{Env: h.Env, SHA: p.EffectiveSHA} + // 1. The PR's oldest exact-SHA success, where liveSince stops. + var firstExact *model.Deploy + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Status == model.DeploySucceeded && revision(d.Revisions, p.Repo) == p.EffectiveSHA { + firstExact = d // newest first, so the last one seen is the oldest + } + } + // 2. Does N, the newest succeeded deploy carrying p.Repo, contain the PR? + contains := func(d *model.Deploy) model.Inclusion { + if revision(d.Revisions, p.Repo) == p.EffectiveSHA { + return model.Included + } + return cmp(p.Repo, p.EffectiveSHA, revision(d.Revisions, p.Repo)) + } + unknown, rolledBack := false, false + ni := newestSuccess(h, p.Repo) + if ni >= 0 { + n := &h.Deploys[ni] + switch contains(n) { + case model.Included: + s.State, s.SHA, s.Deploy = model.SlotDeployed, revision(n.Revisions, p.Repo), n + s.At = liveSince(h, p.Repo, ni, firstExact, contains) + return s + case model.InclusionUnknown: + unknown = true + case model.NotIncluded: + // N is the newest success, so an exact success for the PR is older: + // it was live, and N replaced it. + rolledBack = firstExact != nil + if !rolledBack { + // It may have gone out inside a batch (an older success whose + // SHA contains it). The first answer from newest to oldest decides. + rolledBack, unknown = olderSuccessContains(h, p.Repo, ni, contains) + } + } + } + // 3. Not running now: the newest non-success run that contains the PR + // decides. A run contains it by exact SHA, or, when it is newer than N, + // by compare (a newer in-flight run supersedes an old failure). + var pending *model.Deploy + pi := -1 + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Status == model.DeploySucceeded || revision(d.Revisions, p.Repo) == "" { + continue + } + if revision(d.Revisions, p.Repo) == p.EffectiveSHA || ((ni < 0 || i < ni) && contains(d) == model.Included) { + pending, pi = d, i + break + } + } + if rolledBack && (pending == nil || pi > ni) { + // Only a run newer than the rollback can supersede it. + s.State, s.Deploy, s.At = model.SlotRolledBack, &h.Deploys[ni], h.Deploys[ni].FinishedAt + s.SHA = revision(h.Deploys[ni].Revisions, p.Repo) + return s + } + if pending == nil && unknown { + s.State = model.SlotUnknown + return s + } + if pending != nil { + s.Deploy, s.At, s.SHA = pending, pending.FinishedAt, revision(pending.Revisions, p.Repo) + switch pending.Status { + case model.DeployFailed: + s.State = model.SlotFailed + case model.DeployAwaitingApproval: + s.State = model.SlotAwaitingApproval + case model.DeployInProgress: + s.State = model.SlotInProgress + default: + s.State = model.SlotUnknown // a status the adapter should not send + } + return s + } + s.State = model.SlotNotYet + return s +} + +// olderSuccessContains walks the succeeded deploys older than index ni that +// carry repo, newest to oldest, and stops at the first that contains the PR +// (included) or whose compare cannot answer (unknown). +func olderSuccessContains(h model.EnvHistory, repo string, ni int, contains func(*model.Deploy) model.Inclusion) (included, unknown bool) { + for i := ni + 1; i < len(h.Deploys); i++ { + d := &h.Deploys[i] + if d.Status != model.DeploySucceeded || revision(d.Revisions, repo) == "" { + continue + } + switch contains(d) { + case model.Included: + return true, false + case model.InclusionUnknown: + return false, true + } + } + return false, false +} + +// newestSuccess is the index of the newest succeeded deploy carrying repo, or -1. +func newestSuccess(h model.EnvHistory, repo string) int { + for i, d := range h.Deploys { + if d.Status == model.DeploySucceeded && revision(d.Revisions, repo) != "" { + return i + } + } + return -1 +} + +// liveSince walks back from N (index ni) through the unbroken run of +// successes that contain the PR and returns the oldest one's FinishedAt. It +// stops at the first success that does not contain it (a rollback) and never +// walks past firstExact, the PR's oldest exact-SHA success. +func liveSince(h model.EnvHistory, repo string, ni int, firstExact *model.Deploy, contains func(*model.Deploy) model.Inclusion) time.Time { + at := h.Deploys[ni].FinishedAt + if &h.Deploys[ni] == firstExact { + return at + } + for i := ni + 1; i < len(h.Deploys); i++ { + d := &h.Deploys[i] + if d.Status != model.DeploySucceeded || revision(d.Revisions, repo) == "" { + continue + } + if contains(d) != model.Included { + break + } + at = d.FinishedAt + if d == firstExact { + break + } + } + return at +} + +// Slots fills c.Slots from histories, which are in Env.Order. +func Slots(c model.Chain, histories []model.EnvHistory, cmp model.CompareFunc) model.Chain { + c.Slots = make([]model.EnvSlot, len(histories)) + for i, h := range histories { + c.Slots[i] = Slot(c.PRs, h, cmp) + } + return c +} diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go new file mode 100644 index 0000000..17befa0 --- /dev/null +++ b/internal/link/slots_test.go @@ -0,0 +1,543 @@ +package link_test + +import ( + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/link" + "github.com/ericdahl-dev/app-green/internal/model" +) + +var ( + t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + test = model.Env{Account: "stage-acct", Pipeline: "p", Stage: "Test", Order: 0} + prod = model.Env{Account: "stage-acct", Pipeline: "p", Stage: "Production", Order: 1, Prod: true} +) + +func dep(st model.DeployStatus, sha string, at time.Time) model.Deploy { + return model.Deploy{Status: st, Revisions: map[string]string{"acme/app": sha}, FinishedAt: at} +} + +func merged(sha string) model.PR { + return model.PR{Repo: "acme/app", State: model.PRMerged, MergeSHA: sha, EffectiveSHA: sha} +} + +func noCompare(t *testing.T) model.CompareFunc { + return func(repo, base, head string) model.Inclusion { + t.Fatalf("unexpected compare %s %s...%s", repo, base, head) + return model.InclusionUnknown + } +} + +func TestSlotIncludedByNewerKeepsFirstExactAt(t *testing.T) { + // aaaa deployed at t-2h; the newer bbbb deploy still contains it, so it is + // running now, and At stays at the aaaa deploy. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "bbbb", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "bbbb" { + return model.Included + } + return model.NotIncluded + } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotDeployed || !s.At.Equal(t0.Add(-2*time.Hour)) { + t.Errorf("slot = %+v, want deployed at the aaaa deploy", s) + } +} + +func TestSlotCompareFallback(t *testing.T) { + // aaaa was superseded; the newest succeeded deploy is cccc, which includes it. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "cccc", t0)}} + cmp := func(repo, base, head string) model.Inclusion { + if base == "aaaa" && head == "cccc" { + return model.Included + } + return model.NotIncluded + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotDeployed || s.SHA != "cccc" { + t.Errorf("slot = %+v, want deployed via cccc", s) + } +} + +func TestSlotAwaitingApproval(t *testing.T) { + d := dep(model.DeployAwaitingApproval, "aaaa", t0) + d.ApprovalToken = "tok" + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{d, dep(model.DeploySucceeded, "zzzz", t0.Add(-time.Hour))}} + cmp := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotAwaitingApproval || s.Deploy == nil || s.Deploy.ApprovalToken != "tok" { + t.Errorf("slot = %+v, want awaiting approval with the token", s) + } +} + +func TestSlotFailedWithoutLaterSuccess(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeployFailed, "aaaa", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); s.State != model.SlotFailed { + t.Errorf("slot = %+v, want failed", s) + } +} + +func TestSlotNotIncludedIsNotYet(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "cccc", t0)}} + no := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + if s := link.Slot([]model.PR{merged("aaaa")}, h, no); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet", s) + } +} + +func TestSlotCompareUnknownIsUnknown(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "cccc", t0)}} + unk := func(_, _, _ string) model.Inclusion { return model.InclusionUnknown } + if s := link.Slot([]model.PR{merged("aaaa")}, h, unk); s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want unknown", s) + } +} + +func TestSlotNeedsEveryMergedPR(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + no := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + open := model.PR{Repo: "acme/app", State: model.PROpen} + if s := link.Slot([]model.PR{merged("aaaa"), open}, h, no); s.State != model.SlotDeployed { + t.Errorf("slot = %+v, want deployed; open PRs do not hold a slot back", s) + } + if s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, no); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet; bbbb is not deployed", s) + } +} + +func TestSlotNoMergedPRs(t *testing.T) { + h := model.EnvHistory{Env: test} + if s := link.Slot(nil, h, noCompare(t)); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet", s) + } +} + +func TestSlotOtherRepoNotInPipeline(t *testing.T) { + // The Env deploys acme/app only; a PR in acme/docs has no slot here. + p := model.PR{Repo: "acme/docs", State: model.PRMerged, EffectiveSHA: "dddd"} + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + if s := link.Slot([]model.PR{p}, h, noCompare(t)); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet; acme/docs is not in this pipeline", s) + } +} + +func TestSlotsOnePerEnv(t *testing.T) { + hs := []model.EnvHistory{{Env: test}, {Env: prod}} + c := link.Slots(model.Chain{}, hs, noCompare(t)) + if len(c.Slots) != 2 || c.Slots[1].Env.Stage != "Production" { + t.Errorf("slots = %+v, want two, Production second", c.Slots) + } +} + +func TestSlotCompareSkipsDeployWithoutRepo(t *testing.T) { + // The newest success does not carry acme/app (e.g. the pipeline's sources + // changed); compare against the newest success that does, never with "". + other := model.Deploy{Status: model.DeploySucceeded, Revisions: map[string]string{"acme/infra": "eeee"}, FinishedAt: t0} + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{other, dep(model.DeploySucceeded, "cccc", t0.Add(-time.Hour))}} + cmp := func(_, base, head string) model.Inclusion { + if head == "" { + t.Fatalf("compare called with empty head for %s", base) + } + if base == "aaaa" && head == "cccc" { + return model.Included + } + return model.NotIncluded + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotDeployed || s.SHA != "cccc" { + t.Errorf("slot = %+v, want deployed via cccc", s) + } +} + +func TestSlotRollbackIsRolledBack(t *testing.T) { + // aaaa deployed, then a rollback deployed zzzz, which does not contain it. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "zzzz" { + return model.NotIncluded + } + t.Fatalf("unexpected compare %s...%s", base, head) + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotRolledBack { + t.Errorf("slot = %+v, want rolled back: aaaa was live and zzzz replaced it", s) + } +} + +func TestSlotSameSHARedeployKeepsFirstTime(t *testing.T) { + // An infra-only redeploy of aaaa must not reset when aaaa first went out. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "aaaa", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-time.Hour)), + }} + s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)) + if s.State != model.SlotDeployed || !s.At.Equal(t0.Add(-time.Hour)) { + t.Errorf("slot = %+v, want deployed at the first aaaa deploy", s) + } +} + +func TestSlotNewestSuccessIsExactNoCompare(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "aaaa", t0), dep(model.DeploySucceeded, "bbbb", t0.Add(-time.Hour)), + }} + s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)) + if s.State != model.SlotDeployed || !s.At.Equal(t0) || s.SHA != "aaaa" { + t.Errorf("slot = %+v, want deployed at the newest deploy", s) + } +} + +func TestSlotStackPendingHoldsBack(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + stacked := model.PR{Repo: "acme/app", State: model.PRMerged, MergeSHA: "ssss", StackPending: true} + if s := link.Slot([]model.PR{merged("aaaa"), stacked}, h, noCompare(t)); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet while a merged PR waits in a stack", s) + } +} + +func TestSlotStackPendingOtherRepoIgnored(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + stacked := model.PR{Repo: "acme/docs", State: model.PRMerged, MergeSHA: "ssss", StackPending: true} + if s := link.Slot([]model.PR{merged("aaaa"), stacked}, h, noCompare(t)); s.State != model.SlotDeployed { + t.Errorf("slot = %+v, want deployed; acme/docs is not in this pipeline", s) + } +} + +func TestSlotMergedWithoutSHAHoldsBack(t *testing.T) { + // Malformed input (merged, no EffectiveSHA, not marked StackPending) fails safe. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + bare := model.PR{Repo: "acme/app", State: model.PRMerged} + if s := link.Slot([]model.PR{merged("aaaa"), bare}, h, noCompare(t)); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet", s) + } +} + +func TestSlotTwoDeployedKeepsLaterAt(t *testing.T) { + // The chain is fully deployed only when its last PR is. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "bbbb", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-time.Hour)), + }} + inc := func(_, _, _ string) model.Inclusion { return model.Included } + s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, inc) + if s.State != model.SlotDeployed || !s.At.Equal(t0) { + t.Errorf("slot = %+v, want deployed at t0, when bbbb went out", s) + } +} + +func TestSlotTwoAwaitingKeepsNewerToken(t *testing.T) { + older := dep(model.DeployAwaitingApproval, "aaaa", t0.Add(-time.Hour)) + older.ApprovalToken = "tok-old" + newer := dep(model.DeployAwaitingApproval, "bbbb", t0) + newer.ApprovalToken = "tok-new" + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{newer, older, dep(model.DeploySucceeded, "zzzz", t0.Add(-2*time.Hour))}} + no := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, no) + if s.State != model.SlotAwaitingApproval || s.Deploy == nil || s.Deploy.ApprovalToken != "tok-new" { + t.Errorf("slot = %+v, want awaiting approval with tok-new", s) + } +} + +func TestSlotUnknownDeployStatusIsUnknown(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeployStatus("Stopped"), "aaaa", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want unknown for an unmapped deploy status", s) + } +} + +func TestSlotInProgressRun(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeployInProgress, "aaaa", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); s.State != model.SlotInProgress { + t.Errorf("slot = %+v, want in progress", s) + } +} + +func TestSlotFailedWinsOverUnknown(t *testing.T) { + // aaaa's run failed; bbbb has no run and its compare fails. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeployFailed, "aaaa", t0), dep(model.DeploySucceeded, "zzzz", t0.Add(-time.Hour)), + }} + cmp := func(_, base, _ string) model.Inclusion { + if base == "aaaa" { + return model.NotIncluded + } + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, cmp); s.State != model.SlotFailed { + t.Errorf("slot = %+v, want failed", s) + } +} + +func TestSlotSinceBackLive(t *testing.T) { + // aaaa went out at t-3h, was rolled back by zzzz at t-2h, and came back + // inside cccc at t0. It has been live since t0, not t-3h. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "cccc", t0), + dep(model.DeploySucceeded, "zzzz", t0.Add(-2*time.Hour)), + dep(model.DeploySucceeded, "aaaa", t0.Add(-3*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "cccc" { + return model.Included + } + return model.NotIncluded + } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotDeployed || !s.At.Equal(t0) { + t.Errorf("slot = %+v, want deployed since t0", s) + } +} + +func TestSlotNewerInFlightSupersedesFailure(t *testing.T) { + // aaaa's own run failed; a newer run of bbbb, which contains aaaa, is in + // flight. N (zzzz) is older than both and does not contain aaaa. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeployInProgress, "bbbb", t0), + dep(model.DeployFailed, "aaaa", t0.Add(-2*time.Hour)), + dep(model.DeploySucceeded, "zzzz", t0.Add(-3*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "bbbb" { + return model.Included + } + return model.NotIncluded + } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotInProgress || s.Deploy == nil || s.Deploy.Revisions["acme/app"] != "bbbb" { + t.Errorf("slot = %+v, want in progress on the bbbb run", s) + } +} + +func TestSlotRollbackWithUnknownCompareIsUnknown(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + unk := func(_, _, _ string) model.Inclusion { return model.InclusionUnknown } + if s := link.Slot([]model.PR{merged("aaaa")}, h, unk); s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want unknown", s) + } +} + +func TestSlotDeployedAndUnknownIsUnknown(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + unk := func(_, _, _ string) model.Inclusion { return model.InclusionUnknown } + if s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, unk); s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want unknown", s) + } +} + +func TestSlotMultiRepoChainNeedsBoth(t *testing.T) { + // One pipeline deploys acme/app and acme/svc. The app PR is live, the svc PR is not. + d := model.Deploy{Status: model.DeploySucceeded, FinishedAt: t0, + Revisions: map[string]string{"acme/app": "aaaa", "acme/svc": "ssss"}} + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{d}} + svc := model.PR{Repo: "acme/svc", State: model.PRMerged, MergeSHA: "tttt", EffectiveSHA: "tttt"} + no := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + if s := link.Slot([]model.PR{merged("aaaa"), svc}, h, no); s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want not yet; the svc PR is not deployed", s) + } +} + +func TestSlotCompareCallCount(t *testing.T) { + calls := map[string]int{} + counting := func(_, base, _ string) model.Inclusion { + calls[base]++ + return model.Included // newer commits on main contain older ones + } + // N is exact: no compare at all. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + link.Slot([]model.PR{merged("aaaa")}, h, counting) + if len(calls) != 0 { + t.Errorf("calls = %v, want none when N is the exact SHA", calls) + } + // N (bbbb) is not exact for aaaa: one compare against N, and the walk back + // stops free at aaaa's own exact success. bbbb is exact at N: none. + h = model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "bbbb", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-time.Hour)), + }} + link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, counting) + if calls["aaaa"] != 1 || calls["bbbb"] != 0 { + t.Errorf("calls = %v, want aaaa:1 bbbb:0", calls) + } + // Each success between the PR's own run and N costs one more compare in + // the walk back (D1): aaaa compares cccc (N) and bbbb, then stops at its + // own run; bbbb compares cccc, then stops at its own run. + clear(calls) + h = model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "cccc", t0), + dep(model.DeploySucceeded, "bbbb", t0.Add(-time.Hour)), + dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, counting) + if calls["aaaa"] != 2 || calls["bbbb"] != 1 { + t.Errorf("calls = %v, want aaaa:2 bbbb:1", calls) + } +} + +func TestSlotHealthPassesThrough(t *testing.T) { + hl := model.Health{Known: true, Desired: 2, Healthy: 1} + h := model.EnvHistory{Env: test, Health: hl, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); s.Health != hl { + t.Errorf("slot = %+v, want health %+v", s, hl) + } + if s := link.Slot(nil, h, noCompare(t)); s.Health != hl { + t.Errorf("slot = %+v, want health %+v with no PRs", s, hl) + } +} + +func TestSlotAppliesOnlyWhenAMergedPRIsInARepoTheEnvDeploys(t *testing.T) { + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + docs := model.PR{Repo: "acme/docs", State: model.PRMerged, EffectiveSHA: "dddd"} + open := model.PR{Repo: "acme/app", State: model.PROpen} + if s := link.Slot([]model.PR{docs, open}, h, noCompare(t)); s.Applies { + t.Errorf("slot = %+v, want Applies false: no merged PR is in a repo this Env deploys", s) + } + if s := link.Slot([]model.PR{docs, merged("aaaa")}, h, noCompare(t)); !s.Applies { + t.Errorf("slot = %+v, want Applies true: acme/app is merged and deployed here", s) + } + pending := model.PR{Repo: "acme/app", State: model.PRMerged, StackPending: true} + if s := link.Slot([]model.PR{pending}, h, noCompare(t)); !s.Applies { + t.Errorf("slot = %+v, want Applies true: a stack-pending acme/app PR still belongs to this Env", s) + } +} + +func TestSlotNewerRunSupersedesRollback(t *testing.T) { + // aaaa was live, zzzz rolled it back, and a newer run of aaaa is in flight. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeployInProgress, "aaaa", t0.Add(time.Hour)), + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "zzzz" { + return model.NotIncluded + } + t.Fatalf("unexpected compare %s...%s", base, head) + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotInProgress { + t.Errorf("slot = %+v, want in progress: the newer aaaa run supersedes the rollback", s) + } +} + +func TestSlotOlderFailureDoesNotHideRollback(t *testing.T) { + // aaaa failed once, then went live, then zzzz rolled it back. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + dep(model.DeployFailed, "aaaa", t0.Add(-3*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "zzzz" { + return model.NotIncluded + } + t.Fatalf("unexpected compare %s...%s", base, head) + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotRolledBack { + t.Errorf("slot = %+v, want rolled back: the failure is older than the rollback", s) + } +} + +func TestSlotRolledBackWinsOverUnknown(t *testing.T) { + // aaaa was rolled back by zzzz; bbbb's compare against zzzz cannot answer. + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "zzzz" { + return model.NotIncluded + } + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("bbbb"), merged("aaaa")}, h, cmp); s.State != model.SlotRolledBack { + t.Errorf("slot = %+v, want rolled back: a real rollback is never hidden behind unknown", s) + } +} + +func TestSlotConfiguredRepoWithNoHistoryIsUnknown(t *testing.T) { + env := prod + env.Repos = []string{"acme/app"} + s := link.Slot([]model.PR{merged("aaaa")}, model.EnvHistory{Env: env}, noCompare(t)) + if !s.Applies || s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want Applies true and unknown: config says this Env deploys acme/app but history has none", s) + } +} + +func TestSlotConfiguredReposOverrideHistory(t *testing.T) { + env := prod + env.Repos = []string{"acme/svc"} + h := model.EnvHistory{Env: env, Deploys: []model.Deploy{dep(model.DeploySucceeded, "aaaa", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); s.Applies || s.State != model.SlotNotYet { + t.Errorf("slot = %+v, want Applies false and not yet: config says this Env deploys acme/svc only", s) + } +} + +func TestSlotRepoMatchIgnoresCase(t *testing.T) { + env := prod + env.Repos = []string{"O/R"} + d := model.Deploy{Status: model.DeploySucceeded, Revisions: map[string]string{"O/r": "aaaa"}, FinishedAt: t0} + p := model.PR{Repo: "o/r", State: model.PRMerged, MergeSHA: "aaaa", EffectiveSHA: "aaaa"} + s := link.Slot([]model.PR{p}, model.EnvHistory{Env: env, Deploys: []model.Deploy{d}}, noCompare(t)) + if !s.Applies || s.State != model.SlotDeployed || s.SHA != "aaaa" { + t.Errorf("slot = %+v, want Applies true and deployed: repos match ignoring case", s) + } +} + +func TestSlotEmptySHAIsNotCarried(t *testing.T) { + // Config says this Env deploys acme/app, but the only deploy holds an empty + // SHA for it: that is no evidence, so the slot is unknown, not not-yet. + env := prod + env.Repos = []string{"acme/app"} + h := model.EnvHistory{Env: env, Deploys: []model.Deploy{dep(model.DeploySucceeded, "", t0)}} + if s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)); !s.Applies || s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want Applies true and unknown: an empty SHA does not carry the repo", s) + } +} + +func TestSlotBatchedRollbackIsRolledBack(t *testing.T) { + // aaaa never deployed by its own SHA: it went out inside bbbb at t-2h, and + // then zzzz, which does not contain it, replaced bbbb at t0. + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "bbbb", t0.Add(-2*time.Hour)), + }} + calls := 0 + cmp := func(_, base, head string) model.Inclusion { + calls++ + switch { + case base == "aaaa" && head == "zzzz": + return model.NotIncluded + case base == "aaaa" && head == "bbbb": + return model.Included + } + t.Fatalf("unexpected compare %s...%s", base, head) + return model.InclusionUnknown + } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotRolledBack || s.SHA != "zzzz" || !s.At.Equal(t0) { + t.Errorf("slot = %+v, want rolled back by zzzz at t0: aaaa was live inside bbbb", s) + } + if calls != 2 { + t.Errorf("compare calls = %d, want 2 (aaaa vs zzzz, then aaaa vs bbbb)", calls) + } +} + +func TestSlotBatchedRollbackWalkStopsAtUnknown(t *testing.T) { + // zzzz does not contain aaaa; bbbb's compare cannot answer, so the walk + // stops there and never reaches cccc. + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "zzzz", t0), dep(model.DeploySucceeded, "bbbb", t0.Add(-2*time.Hour)), + dep(model.DeploySucceeded, "cccc", t0.Add(-3*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + switch { + case base == "aaaa" && head == "zzzz": + return model.NotIncluded + case base == "aaaa" && head == "bbbb": + return model.InclusionUnknown + } + t.Fatalf("unexpected compare %s...%s", base, head) + return model.InclusionUnknown + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotUnknown { + t.Errorf("slot = %+v, want unknown", s) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..da2e351 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,318 @@ +// Package model holds the types every other package shares. It has no logic +// beyond small helpers and imports nothing from the project. +package model + +import ( + "fmt" + "time" +) + +// Ticket is one tracker issue (a Jira ticket in v1). +type Ticket struct { + Key string // "ABC-1" + Title string + Status string // display name, e.g. "Code Review" + StatusCategory StatusCategory + URL string + Updated time.Time + StatusSince time.Time // when it entered Status; zero if unknown +} + +// StatusCategory is Jira's statusCategory.key, which stays the same whatever +// a site names its statuses. Adapters map statusCategory.key straight into it. +type StatusCategory string + +const ( + StatusToDo StatusCategory = "new" + StatusInProgress StatusCategory = "indeterminate" + StatusDone StatusCategory = "done" +) + +// Label is the category's display name: "To Do", "In Progress" or "Done", or +// the raw key for a category Jira adds later. +func (c StatusCategory) Label() string { + switch c { + case StatusToDo: + return "To Do" + case StatusInProgress: + return "In Progress" + case StatusDone: + return "Done" + } + return string(c) +} + +type PRState string + +const ( + PROpen PRState = "OPEN" + PRMerged PRState = "MERGED" + PRClosed PRState = "CLOSED" +) + +type ChecksState string + +const ( + ChecksNone ChecksState = "" + ChecksPending ChecksState = "PENDING" + ChecksPassing ChecksState = "SUCCESS" + ChecksFailing ChecksState = "FAILURE" +) + +type ReviewState string + +const ( + ReviewNone ReviewState = "" + ReviewRequired ReviewState = "REVIEW_REQUIRED" + ReviewApproved ReviewState = "APPROVED" + ReviewChangesRequested ReviewState = "CHANGES_REQUESTED" +) + +// Check is one failing check on a PR. +type Check struct { + Name string + RunID int64 // GitHub Actions run, 0 if not an Actions run + CodeScanning bool // a re-run cannot fix it + URL string +} + +// PR is one pull request. +type PR struct { + Repo string // "owner/name"; link compares repos ignoring case + DefaultBranch string // the branch pipelines deploy, usually "main" + Number int + Title string + HeadRef string + BaseRef string + URL string + State PRState + MergeSHA string // set only when State == PRMerged + MergedAt time.Time + OpenedAt time.Time + Checks ChecksState + Failing []Check + Review ReviewState + Reviewers int // requested reviewers + submitted reviews + // EffectiveSHA is the commit that carries this PR onto DefaultBranch. + // link sets it: MergeSHA for a PR merged into DefaultBranch, the stack + // bottom's MergeSHA for a stacked PR, "" while the stack is unmerged. + EffectiveSHA string + StackPending bool // merged into a stack branch that has not reached DefaultBranch + // Stranded is set by link on a merged PR whose commits will never reach + // DefaultBranch: it merged into a branch whose own PR had already merged, + // or into a branch with no PR. A stranded PR is StackPending too. A PR + // whose base branch's PR is still open is waiting, not stranded. Callers + // must pass the PR for each base branch (any author) or Stranded can be a + // false red. + Stranded bool +} + +// Env is one deploy environment: a stage of a pipeline in an account. +type Env struct { + Account string // config name, e.g. "prod-acct" + Pipeline string + Stage string // "Test" | "Production" + Order int // position in config; higher is further along + Prod bool + ReadOnly bool // profile cannot approve + // Repos are the "owner/name" repos this Env deploys, compared ignoring + // case. Required in practice: phase 2 fills it from the pipeline's source + // actions. link trusts it over what history shows, and rules uses it to + // spot a merged repo no Env deploys. When empty, link falls back to the + // repos seen in the Env's deploys; that is a last resort, since a missing + // or truncated history then hides the Env's repos. Env is not comparable + // with == because of this field; compare ID() instead. + Repos []string +} + +func (e Env) ID() string { return e.Account + "/" + e.Pipeline + "/" + e.Stage } + +type DeployStatus string + +const ( + DeploySucceeded DeployStatus = "Succeeded" + DeployFailed DeployStatus = "Failed" + DeployInProgress DeployStatus = "InProgress" + DeployAwaitingApproval DeployStatus = "AwaitingApproval" +) + +// Deploy is one pipeline execution as seen from one Env's stage, as the +// adapter builds it: Status is the stage's deploy action status, except that +// a run paused at the stage's approval action is DeployAwaitingApproval with +// that action's ApprovalToken. Adapters drop Abandoned and Superseded +// executions; they never deployed anything. +type Deploy struct { + ExecutionID string + Status DeployStatus + // Revisions maps repo "owner/name" to the commit this execution carries. + // link matches keys to PR.Repo ignoring case, and an empty SHA counts as + // not carrying the repo. + Revisions map[string]string + FinishedAt time.Time // last update time of the action + ApprovalToken string // set when Status == DeployAwaitingApproval +} + +// Health is the runtime health of an Env's service. +type Health struct { + Known bool + Desired int + Healthy int +} + +// OK reports whether the service is healthy. Unknown health counts as OK so a +// missing service never raises FlagUnhealthy; a service scaled to zero is OK. +func (h Health) OK() bool { return !h.Known || h.Healthy >= h.Desired } + +// EnvHistory is everything the AWS adapter knows about one Env. +type EnvHistory struct { + Env Env + Deploys []Deploy // newest first, as the adapter returns them + Health Health +} + +type SlotState int + +const ( + SlotNotYet SlotState = iota + SlotAwaitingApproval + SlotInProgress + SlotFailed + SlotDeployed + SlotUnknown // a compare call failed or returned 404 + SlotRolledBack // was live, then a newer deploy without it replaced it +) + +var slotStateNames = [...]string{"not yet", "awaiting approval", "in progress", "failed", "deployed", "unknown", "rolled back"} + +func (s SlotState) String() string { + if s < 0 || int(s) >= len(slotStateNames) { + return fmt.Sprintf("SlotState(%d)", int(s)) + } + return slotStateNames[s] +} + +// EnvSlot is where a chain's work stands in one Env. +type EnvSlot struct { + Env Env + State SlotState + SHA string // the deployed or pending commit + At time.Time + Health Health + Deploy *Deploy // the deploy that decided State, if any + // Applies is true when at least one of the chain's merged PRs is in a + // repo this Env deploys. rules ignores slots where it is false. + Applies bool +} + +type Stage int + +const ( + StageStarted Stage = iota + StagePROpen + StageMerged + StageInTest + StageAwaitingProd + StageInProd +) + +var stageNames = [...]string{"started", "PR open", "merged", "in test", "awaiting prod", "in prod"} + +func (s Stage) String() string { + if s < 0 || int(s) >= len(stageNames) { + return fmt.Sprintf("Stage(%d)", int(s)) + } + return stageNames[s] +} + +type Level int + +const ( + None Level = iota + Yellow + Red +) + +func (l Level) Worse(o Level) bool { return l > o } + +type FlagKind int + +// Order within a level is the order of these constants (design: Flags). +const ( + FlagPipelineFailed FlagKind = iota + FlagRolledBack + FlagStranded // a merged PR whose commits will never reach the default branch + FlagUnhealthy + FlagCheckFailed + FlagChangesRequested + FlagAwaitingApproval + FlagPartialProd // live in some prod Envs, not all, for too long + FlagReadyToMerge + FlagStaleReview + FlagStatusMismatch + FlagDeployUnknown // an Env's deploy status could not be decided +) + +var flagKindNames = [...]string{ + "pipeline failed", + "rolled back", + "stranded", + "unhealthy", + "check failed", + "changes requested", + "awaiting approval", + "partial prod", + "ready to merge", + "stale review", + "status mismatch", + "deploy unknown", +} + +func (k FlagKind) String() string { + if k < 0 || int(k) >= len(flagKindNames) { + return fmt.Sprintf("FlagKind(%d)", int(k)) + } + return flagKindNames[k] +} + +// Flag is one reason a chain needs attention. +type Flag struct { + Level Level + Kind FlagKind + Reason string + PR *PR // target for f / o, when the flag is about a PR + Slot *EnvSlot // target for a / x, when the flag is about an Env +} + +// Chain is one ticket and everything linked to it. +type Chain struct { + Ticket Ticket + PRs []PR + Slots []EnvSlot // one per configured Env, in Env.Order + Stage Stage + Flags []Flag // ordered; Flags[0] is the row's flag +} + +// Level is the chain's worst flag level, the max over all Flags. Flags[0] is +// still the row's flag; Level does not depend on Flags being sorted. +func (c Chain) Level() Level { + worst := None + for _, f := range c.Flags { + if f.Level.Worse(worst) { + worst = f.Level + } + } + return worst +} + +// Inclusion is the answer to "is commit A in commit B?". +type Inclusion int + +const ( + InclusionUnknown Inclusion = iota + Included + NotIncluded +) + +// CompareFunc answers whether base is included in head within repo. The +// resolver implements it with a cache; link only calls it. +type CompareFunc func(repo, base, head string) Inclusion diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..40d7135 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,115 @@ +package model_test + +import ( + "strings" + "testing" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +func TestEnvID(t *testing.T) { + e := model.Env{Account: "prod-acct", Pipeline: "app-pipe", Stage: "Production"} + if got, want := e.ID(), "prod-acct/app-pipe/Production"; got != want { + t.Fatalf("ID() = %q, want %q", got, want) + } +} + +func TestLevelWorse(t *testing.T) { + if !model.Red.Worse(model.Yellow) || model.Yellow.Worse(model.Red) || model.None.Worse(model.Yellow) { + t.Fatal("Red > Yellow > None") + } +} + +func TestStageString(t *testing.T) { + for s := model.StageStarted; s <= model.StageInProd; s++ { + if got := s.String(); got == "" || strings.HasPrefix(got, "Stage(") { + t.Errorf("Stage %d has no label: %q", int(s), got) + } + } + if got := model.Stage(99).String(); got != "Stage(99)" { + t.Errorf("Stage(99).String() = %q", got) + } +} + +func TestSlotStateString(t *testing.T) { + for s := model.SlotNotYet; s <= model.SlotRolledBack; s++ { + if got := s.String(); got == "" || strings.HasPrefix(got, "SlotState(") { + t.Errorf("SlotState %d has no label: %q", int(s), got) + } + } + if got := model.SlotState(99).String(); got != "SlotState(99)" { + t.Errorf("SlotState(99).String() = %q", got) + } +} + +func TestFlagKindString(t *testing.T) { + for k := model.FlagPipelineFailed; k <= model.FlagDeployUnknown; k++ { + if got := k.String(); got == "" || strings.HasPrefix(got, "FlagKind(") { + t.Errorf("FlagKind %d has no label: %q", int(k), got) + } + } + if got := model.FlagKind(99).String(); got != "FlagKind(99)" { + t.Errorf("FlagKind(99).String() = %q", got) + } +} + +func TestHealthOK(t *testing.T) { + cases := []struct { + name string + h model.Health + want bool + }{ + {"unknown", model.Health{Known: false}, true}, + {"all healthy", model.Health{Known: true, Desired: 2, Healthy: 2}, true}, + {"one down", model.Health{Known: true, Desired: 2, Healthy: 1}, false}, + {"scaled to zero", model.Health{Known: true, Desired: 0, Healthy: 0}, true}, + } + for _, c := range cases { + if got := c.h.OK(); got != c.want { + t.Errorf("%s: OK() = %v, want %v", c.name, got, c.want) + } + } +} + +func TestChainLevel(t *testing.T) { + c := model.Chain{Flags: []model.Flag{{Level: model.Yellow}, {Level: model.Red}}} + if got := c.Level(); got != model.Red { + t.Errorf("unsorted flags: Level() = %v, want Red", got) + } + if got := (model.Chain{}).Level(); got != model.None { + t.Errorf("no flags: Level() = %v, want None", got) + } +} + +func TestFlagDeployUnknownIsLastAndLabeled(t *testing.T) { + if model.FlagDeployUnknown <= model.FlagStatusMismatch { + t.Errorf("FlagDeployUnknown = %d, want it after FlagStatusMismatch (%d)", model.FlagDeployUnknown, model.FlagStatusMismatch) + } + if got := model.FlagDeployUnknown.String(); got != "deploy unknown" { + t.Errorf("FlagDeployUnknown.String() = %q, want %q", got, "deploy unknown") + } +} + +func TestRolledBackLabels(t *testing.T) { + if model.SlotRolledBack <= model.SlotUnknown || model.SlotRolledBack.String() != "rolled back" { + t.Errorf("SlotRolledBack = %d %q, want after SlotUnknown, labeled %q", model.SlotRolledBack, model.SlotRolledBack, "rolled back") + } + if model.FlagRolledBack != model.FlagPipelineFailed+1 || model.FlagRolledBack.String() != "rolled back" { + t.Errorf("FlagRolledBack = %d %q, want right after FlagPipelineFailed, labeled %q", model.FlagRolledBack, model.FlagRolledBack, "rolled back") + } +} + +func TestFlagPartialProdFollowsAwaitingApproval(t *testing.T) { + if model.FlagPartialProd != model.FlagAwaitingApproval+1 || model.FlagPartialProd.String() != "partial prod" { + t.Errorf("FlagPartialProd = %d %q, want right after FlagAwaitingApproval, labeled %q", model.FlagPartialProd, model.FlagPartialProd, "partial prod") + } +} + +func TestFlagStrandedFollowsRolledBack(t *testing.T) { + if model.FlagStranded != model.FlagRolledBack+1 || model.FlagStranded.String() != "stranded" { + t.Errorf("FlagStranded = %d %q, want right after FlagRolledBack, labeled %q", model.FlagStranded, model.FlagStranded, "stranded") + } + if model.FlagDeployUnknown.String() != "deploy unknown" { + t.Errorf("labels shifted: FlagDeployUnknown.String() = %q", model.FlagDeployUnknown) + } +} diff --git a/internal/rules/flags.go b/internal/rules/flags.go new file mode 100644 index 0000000..d23371a --- /dev/null +++ b/internal/rules/flags.go @@ -0,0 +1,216 @@ +package rules + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Thresholds are the only configurable parts of the rules. Each one at zero +// or below turns its check off. +type Thresholds struct { + // StaleReview: an open PR with no approval this long after it opened is + // yellow (a PR with no reviewer at all is flagged whatever this is). + StaleReview time.Duration + // DoneGrace: a ticket Done in Jira this long (since StatusSince, else + // Updated) without being in prod is yellow. + DoneGrace time.Duration + // FadeAfter: a row with no flags, in prod in every applicable prod Env, + // drops out of the list once the last of them went live this long ago. + FadeAfter time.Duration + // PartialProd: a row live in some applicable prod Envs but not all, this + // long after the earliest went live, is yellow. + PartialProd time.Duration +} + +// Flags returns c's flags, worst level first, then in FlagKind order. +// c.Stage must already be set (call Stage first). Each flag's PR or Slot +// points into the backing arrays of c.PRs or c.Slots, so it aliases the +// caller's chain; copy the chain's slices first if they will change. +func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { + var fs []model.Flag + add := func(l model.Level, k model.FlagKind, reason string, pr *model.PR, s *model.EnvSlot) { + fs = append(fs, model.Flag{Level: l, Kind: k, Reason: reason, PR: pr, Slot: s}) + } + for i := range c.Slots { + s := &c.Slots[i] + if !s.Applies { + continue // this Env deploys none of the chain's merged repos + } + switch { + case s.State == model.SlotFailed: + add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s failed", s.Env.Account, s.Env.Stage), nil, s) + case s.State == model.SlotRolledBack: + add(model.Red, model.FlagRolledBack, fmt.Sprintf("%s %s rolled back", s.Env.Account, s.Env.Stage), nil, s) + case s.State == model.SlotDeployed && !s.Health.OK(): + add(model.Red, model.FlagUnhealthy, fmt.Sprintf("%s %s %d/%d healthy", s.Env.Account, s.Env.Stage, s.Health.Healthy, s.Health.Desired), nil, s) + case s.State == model.SlotAwaitingApproval: + add(model.Yellow, model.FlagAwaitingApproval, fmt.Sprintf("%s %s awaiting approval", s.Env.Account, s.Env.Stage), nil, s) + case s.State == model.SlotUnknown: + add(model.Yellow, model.FlagDeployUnknown, fmt.Sprintf("%s %s deploy status unknown", s.Env.Account, s.Env.Stage), nil, s) + } + } + for i := range c.PRs { + p := &c.PRs[i] + if p.State == model.PRMerged && p.Stranded { + add(model.Red, model.FlagStranded, fmt.Sprintf("PR #%d merged into a dead branch", p.Number), p, nil) + } + if p.State != model.PROpen { + continue + } + switch { + case checksFailing(p.Checks): + add(model.Red, model.FlagCheckFailed, fmt.Sprintf("PR #%d %s failing", p.Number, checkNames(p.Failing)), p, nil) + case p.Review == model.ReviewChangesRequested: + add(model.Red, model.FlagChangesRequested, fmt.Sprintf("PR #%d changes requested", p.Number), p, nil) + case p.Review == model.ReviewApproved && (p.Checks == model.ChecksPassing || p.Checks == model.ChecksNone): + add(model.Yellow, model.FlagReadyToMerge, fmt.Sprintf("PR #%d approved, not merged", p.Number), p, nil) + case p.Reviewers == 0 && p.Checks != model.ChecksPending: + add(model.Yellow, model.FlagStaleReview, fmt.Sprintf("PR #%d has no reviewer", p.Number), p, nil) + case p.Review != model.ReviewApproved && th.StaleReview > 0 && now.Sub(p.OpenedAt) > th.StaleReview: + add(model.Yellow, model.FlagStaleReview, fmt.Sprintf("PR #%d no review %s", p.Number, days(now.Sub(p.OpenedAt))), p, nil) + } + } + for _, repo := range unclaimedRepos(c) { + add(model.Yellow, model.FlagDeployUnknown, repo+" not deployed by any configured environment", nil, nil) + } + if r, s := partialProd(c, now, th); r != "" { + add(model.Yellow, model.FlagPartialProd, r, nil, s) + } + if r := statusMismatch(c, now, th); r != "" { + add(model.Yellow, model.FlagStatusMismatch, r, nil, nil) + } + slices.SortStableFunc(fs, func(a, b model.Flag) int { + if a.Level != b.Level { + return int(b.Level - a.Level) + } + return int(a.Kind - b.Kind) + }) + return fs +} + +// unclaimedRepos lists, once each and in PR order, the repos of the chain's +// merged PRs (EffectiveSHA set) that no slot's Env.Repos names, compared +// ignoring case. Such a repo is deployed nowhere this app watches, so the +// chain must not look done. With no Env.Repos on any slot (link fell back to +// history) rules cannot tell which Env deploys which repo and returns nothing. +func unclaimedRepos(c model.Chain) []string { + configured := false + for _, s := range c.Slots { + configured = configured || len(s.Env.Repos) > 0 + } + if !configured { + return nil + } + var out []string + for _, p := range c.PRs { + if p.State != model.PRMerged || p.EffectiveSHA == "" { + continue + } + same := func(r string) bool { return strings.EqualFold(r, p.Repo) } + if slices.ContainsFunc(out, same) { + continue + } + claimed := slices.ContainsFunc(c.Slots, func(s model.EnvSlot) bool { return slices.ContainsFunc(s.Env.Repos, same) }) + if !claimed { + out = append(out, p.Repo) + } + } + return out +} + +// partialProd explains a chain live in some applicable prod Envs but not all, +// once the earliest of those went live more than th.PartialProd ago, and +// returns the first applicable prod slot (in Env order) that does not have it. +// Deployed slots with no time are skipped; with no times at all there is no +// flag. +func partialProd(c model.Chain, now time.Time, th Thresholds) (string, *model.EnvSlot) { + if th.PartialProd <= 0 || c.Stage != model.StageAwaitingProd { + return "", nil + } + var live []string + var earliest time.Time + var missing *model.EnvSlot + for i := range c.Slots { + s := &c.Slots[i] + if !s.Applies || !s.Env.Prod { + continue + } + if s.State != model.SlotDeployed { + if missing == nil { + missing = s + } + continue + } + live = append(live, s.Env.Account) + if !s.At.IsZero() && (earliest.IsZero() || s.At.Before(earliest)) { + earliest = s.At + } + } + if missing == nil || len(live) == 0 || earliest.IsZero() || now.Sub(earliest) <= th.PartialProd { + return "", nil + } + return "live in " + strings.Join(live, ", ") + " only", missing +} + +func statusMismatch(c model.Chain, now time.Time, th Thresholds) string { + since := c.Ticket.StatusSince + if since.IsZero() { + since = c.Ticket.Updated // a safe stand-in: it is never older than the status change + } + switch { + case (c.Ticket.StatusCategory == model.StatusInProgress || c.Ticket.StatusCategory == model.StatusToDo) && c.Stage >= model.StageMerged: + return "Jira still " + orDefault(c.Ticket.Status, c.Ticket.StatusCategory.Label()) + ", PR merged" + case c.Ticket.StatusCategory == model.StatusDone && c.Stage != model.StageInProd && th.DoneGrace > 0 && !nothingToShip(c) && + !since.IsZero() && now.Sub(since) > th.DoneGrace: + return "Jira Done, not in prod" + } + return "" +} + +// nothingToShip reports whether c has no PRs once closed ones are ignored. +func nothingToShip(c model.Chain) bool { + for _, p := range c.PRs { + if p.State != model.PRClosed { + return false + } + } + return true +} + +func checkNames(cs []model.Check) string { + if len(cs) == 0 { + return "checks" + } + if len(cs) == 1 { + return cs[0].Name + } + return fmt.Sprintf("%s +%d", cs[0].Name, len(cs)-1) +} + +func days(d time.Duration) string { + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +func orDefault(s, d string) string { + if s == "" { + return d + } + return s +} + +// checksFailing treats every state other than passing, pending or none as a +// failure: GitHub also sends ERROR and EXPECTED, and neither is green. +func checksFailing(s model.ChecksState) bool { + switch s { + case model.ChecksPassing, model.ChecksPending, model.ChecksNone: + return false + } + return true +} diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go new file mode 100644 index 0000000..04f7179 --- /dev/null +++ b/internal/rules/flags_test.go @@ -0,0 +1,210 @@ +package rules_test + +import ( + "slices" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +var now = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + +func kinds(fs []model.Flag) []model.FlagKind { + var out []model.FlagKind + for _, f := range fs { + out = append(out, f.Kind) + } + return out +} + +func TestFlags(t *testing.T) { + th := rules.Thresholds{StaleReview: 48 * time.Hour, DoneGrace: 2 * time.Hour} + failing := model.PR{State: model.PROpen, Checks: model.ChecksFailing, Failing: []model.Check{{Name: "rspec", RunID: 9}}} + shipped := []model.PR{{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}} + scanning := model.PR{State: model.PROpen, Checks: model.ChecksFailing, Failing: []model.Check{{Name: "CodeQL", CodeScanning: true}}} + cases := []struct { + name string + c model.Chain + want []model.FlagKind + }{ + {"an open PR with checks running and a reviewer needs nothing", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPending, Reviewers: 1, OpenedAt: now}}}, nil}, + {"a failing check flags the PR", model.Chain{PRs: []model.PR{failing}}, []model.FlagKind{model.FlagCheckFailed}}, + {"a failing code scan flags the PR like any check", model.Chain{PRs: []model.PR{scanning}}, []model.FlagKind{model.FlagCheckFailed}}, + {"checks in any other state (EXPECTED, ERROR) count as failing, even when approved", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: "EXPECTED", Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagCheckFailed}}, + {"requested changes flag the PR", model.Chain{PRs: []model.PR{{State: model.PROpen, Review: model.ReviewChangesRequested, Reviewers: 1}}}, + []model.FlagKind{model.FlagChangesRequested}}, + {"an approved, green PR that is not merged is ready to merge", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagReadyToMerge}}, + {"an approved PR in a repo with no checks is ready to merge", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksNone, Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagReadyToMerge}}, + {"a PR with checks done and no reviewer is waiting on review", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, OpenedAt: now}}}, + []model.FlagKind{model.FlagStaleReview}}, + {"a PR with no review after StaleReview is stale", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, Reviewers: 1, Review: model.ReviewRequired, OpenedAt: now.Add(-72 * time.Hour)}}}, + []model.FlagKind{model.FlagStaleReview}}, + {"a failed pipeline run flags the row", model.Chain{Slots: []model.EnvSlot{{Env: testEnv, Applies: true, State: model.SlotFailed}}}, []model.FlagKind{model.FlagPipelineFailed}}, + {"a rolled-back deploy flags the row", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotRolledBack}}}, []model.FlagKind{model.FlagRolledBack}}, + {"fewer healthy tasks than desired after a deploy flags the row", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2, Healthy: 1}}}}, + []model.FlagKind{model.FlagUnhealthy}}, + {"a pipeline paused for approval flags the row", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotAwaitingApproval}}}, []model.FlagKind{model.FlagAwaitingApproval}}, + {"Jira still In Progress after the PR merged is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusInProgress}, Stage: model.StageMerged, + PRs: []model.PR{{State: model.PRMerged}}}, []model.FlagKind{model.FlagStatusMismatch}}, + {"Jira still To Do after the PR merged is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusToDo, Status: "Backlog"}, Stage: model.StageMerged, + PRs: []model.PR{{State: model.PRMerged}}}, []model.FlagKind{model.FlagStatusMismatch}}, + {"Done but not in prod within DoneGrace is not flagged yet", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone, StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest, PRs: shipped}, nil}, + {"Done but not in prod past DoneGrace is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone, StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest, PRs: shipped}, + []model.FlagKind{model.FlagStatusMismatch}}, + {"Done with no status time falls back to the ticket's last update", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone, Updated: now.Add(-3 * time.Hour)}, Stage: model.StageInTest, PRs: shipped}, + []model.FlagKind{model.FlagStatusMismatch}}, + {"Done with neither a status time nor an update time is not flagged", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone}, Stage: model.StageInTest, PRs: shipped}, nil}, + {"red flags come before yellow ones", model.Chain{PRs: []model.PR{failing}, + Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotAwaitingApproval}}}, + []model.FlagKind{model.FlagCheckFailed, model.FlagAwaitingApproval}}, + {"within a level, flags follow FlagKind order, not PR order", model.Chain{PRs: []model.PR{ + {Number: 1, State: model.PROpen, Checks: model.ChecksPassing, OpenedAt: now}, + {Number: 2, State: model.PROpen, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagReadyToMerge, model.FlagStaleReview}}, + {"a deploy with unknown health raises nothing", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotDeployed}}}, nil}, + {"slots in Envs that do not deploy the chain's repos raise nothing", model.Chain{Slots: []model.EnvSlot{ + {Env: testEnv, State: model.SlotFailed}, {Env: prodEnv, State: model.SlotAwaitingApproval}, + {Env: otherProd, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2}}}}, nil}, + {"a deploy status that cannot be decided is flagged", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotUnknown}}}, + []model.FlagKind{model.FlagDeployUnknown}}, + {"an unknown deploy status on a test Env is flagged too", model.Chain{Slots: []model.EnvSlot{{Env: testEnv, Applies: true, State: model.SlotUnknown}}}, + []model.FlagKind{model.FlagDeployUnknown}}, + {"awaiting approval comes before deploy unknown", model.Chain{Slots: []model.EnvSlot{ + {Env: testEnv, Applies: true, State: model.SlotUnknown}, {Env: prodEnv, Applies: true, State: model.SlotAwaitingApproval}}}, + []model.FlagKind{model.FlagAwaitingApproval, model.FlagDeployUnknown}}, + } + for _, c := range cases { + if got := kinds(rules.Flags(c.c, now, th)); !slices.Equal(got, c.want) { + t.Errorf("%s: flags %v, want %v", c.name, got, c.want) + } + } +} + +func TestCheckFailedFlagTargetsItsPRAtRed(t *testing.T) { + c := model.Chain{PRs: []model.PR{{Number: 7, State: model.PROpen, Checks: model.ChecksFailing, Failing: []model.Check{{RunID: 1}}}}} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) == 0 { + t.Fatal("want a check-failed flag on PR #7, got no flags") + } + if fs[0].PR == nil || fs[0].PR.Number != 7 || fs[0].Level != model.Red { + t.Errorf("flag %+v should target PR #7 at red", fs[0]) + } +} + +func TestDeployUnknownFlagTargetsItsSlotAtYellow(t *testing.T) { + c := model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotUnknown}}} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 { + t.Fatalf("flags %v, want one deploy-unknown flag", kinds(fs)) + } + f := fs[0] + if f.Level != model.Yellow || f.Slot == nil || f.Slot.Env.ID() != prodEnv.ID() || f.Reason != "a Production deploy status unknown" { + t.Errorf("flag %+v, want yellow on the Production slot with reason %q", f, "a Production deploy status unknown") + } +} + +func TestRolledBackFlagTargetsItsSlotAtRed(t *testing.T) { + c := model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotRolledBack}}} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 { + t.Fatalf("flags %v, want one rolled-back flag", kinds(fs)) + } + if f := fs[0]; f.Level != model.Red || f.Slot == nil || f.Slot.Env.ID() != prodEnv.ID() || f.Reason != "a Production rolled back" { + t.Errorf("flag %+v, want red on the Production slot with reason %q", f, "a Production rolled back") + } +} + +func TestToDoMismatchNamesTheStatus(t *testing.T) { + c := model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusToDo, Status: "Backlog"}, Stage: model.StageInTest} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 || fs[0].Reason != "Jira still Backlog, PR merged" { + t.Errorf("flags %+v, want one mismatch with reason %q", fs, "Jira still Backlog, PR merged") + } +} + +// partialProd is a chain live in prodEnv since at and not yet in otherProd. +func partialProd(at time.Time) model.Chain { + return model.Chain{Stage: model.StageAwaitingProd, PRs: []model.PR{{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{ + {Env: prodEnv, Applies: true, State: model.SlotDeployed, At: at}, + {Env: otherProd, Applies: true, State: model.SlotNotYet}}} +} + +func TestPartialProdPastThresholdIsFlagged(t *testing.T) { + fs := rules.Flags(partialProd(now.Add(-5*time.Hour)), now, rules.Thresholds{PartialProd: 4 * time.Hour}) + if len(fs) != 1 || fs[0].Kind != model.FlagPartialProd || fs[0].Level != model.Yellow || fs[0].Reason != "live in a only" { + t.Errorf("flags %+v, want one yellow partial prod flag with reason %q", fs, "live in a only") + } +} + +func TestPartialProdIsNotFlaggedTooSoonOrWithoutData(t *testing.T) { + th := rules.Thresholds{PartialProd: 4 * time.Hour} + allLive := partialProd(now.Add(-5 * time.Hour)) + allLive.Stage, allLive.Slots[1].State, allLive.Slots[1].At = model.StageInProd, model.SlotDeployed, now.Add(-5*time.Hour) + cases := []struct { + name string + c model.Chain + th rules.Thresholds + }{ + {"within the threshold", partialProd(now.Add(-3 * time.Hour)), th}, + {"zero threshold", partialProd(now.Add(-5 * time.Hour)), rules.Thresholds{}}, + {"deploy with no time", partialProd(time.Time{}), th}, + {"live in every prod Env", allLive, th}, + } + for _, c := range cases { + if fs := rules.Flags(c.c, now, c.th); len(fs) != 0 { + t.Errorf("%s: flags %v, want none", c.name, kinds(fs)) + } + } +} + +func TestPartialProdFlagTargetsTheFirstMissingProdEnv(t *testing.T) { + c := partialProd(now.Add(-5 * time.Hour)) + third := model.Env{Account: "c", Stage: "Production", Prod: true} + c.Slots = append(c.Slots, model.EnvSlot{Env: third, Applies: true, State: model.SlotNotYet}) + fs := rules.Flags(c, now, rules.Thresholds{PartialProd: 4 * time.Hour}) + if len(fs) != 1 || fs[0].Slot == nil || fs[0].Slot.Env.ID() != otherProd.ID() { + t.Fatalf("flags %+v, want one partial prod flag targeting %s, the first prod Env without it", fs, otherProd.ID()) + } +} + +func TestMismatchUsesJiraStatusCategoryKeys(t *testing.T) { + // Adapters map Jira's stable statusCategory.key; with no status name the + // reason falls back to the category's display label, not the raw key. + c := model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusInProgress}, Stage: model.StageMerged} + fs := rules.Flags(c, now, rules.Thresholds{}) + if want := "Jira still In Progress, PR merged"; len(fs) != 1 || fs[0].Reason != want { + t.Errorf("flags %+v, want one mismatch with reason %q", fs, want) + } + c.Ticket.StatusCategory = model.StatusToDo + if fs := rules.Flags(c, now, rules.Thresholds{}); len(fs) != 1 || fs[0].Reason != "Jira still To Do, PR merged" { + t.Errorf("flags %+v, want one mismatch naming To Do", fs) + } +} + +func TestZeroDoneGraceDisablesTheDoneMismatch(t *testing.T) { + c := model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone, StatusSince: now.Add(-72 * time.Hour)}, Stage: model.StageInTest, + PRs: []model.PR{{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}}} + if fs := rules.Flags(c, now, rules.Thresholds{}); len(fs) != 0 { + t.Errorf("flags %v, want none: DoneGrace 0 turns the check off, like the other thresholds", kinds(fs)) + } +} + +func TestStrandedPRFlagTargetsItsPRAtRed(t *testing.T) { + c := model.Chain{Stage: model.StageMerged, PRs: []model.PR{ + {Repo: "acme/app", Number: 3, State: model.PRMerged, StackPending: true, Stranded: true}, + {Repo: "acme/app", Number: 4, State: model.PRMerged, StackPending: true}, // waiting, not stranded + }} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 { + t.Fatalf("flags %v, want one stranded flag", kinds(fs)) + } + if f := fs[0]; f.Kind != model.FlagStranded || f.Level != model.Red || f.PR == nil || f.PR.Number != 3 || f.Reason != "PR #3 merged into a dead branch" { + t.Errorf("flag %+v, want red stranded on PR #3 with reason %q", f, "PR #3 merged into a dead branch") + } +} diff --git a/internal/rules/order.go b/internal/rules/order.go new file mode 100644 index 0000000..10b61a0 --- /dev/null +++ b/internal/rules/order.go @@ -0,0 +1,90 @@ +package rules + +import ( + "cmp" + "slices" + "strconv" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Evaluate sets Stage and Flags on every chain, drops chains that have been +// in prod longer than FadeAfter and Done chains with nothing to ship (no PRs +// once closed ones are ignored), and sorts: red, yellow, none; then furthest +// stage first; then ticket key. +func Evaluate(chains []model.Chain, now time.Time, th Thresholds) []model.Chain { + out := make([]model.Chain, 0, len(chains)) + for _, c := range chains { + if c.Ticket.StatusCategory == model.StatusDone && nothingToShip(c) { + continue + } + c.Stage = Stage(c) + c.Flags = Flags(c, now, th) + if since, ok := prodSince(c); ok && c.Stage == model.StageInProd && c.Level() == model.None && + th.FadeAfter > 0 && now.Sub(since) > th.FadeAfter { + continue + } + out = append(out, c) + } + slices.SortStableFunc(out, func(a, b model.Chain) int { + if a.Level() != b.Level() { + return int(b.Level() - a.Level()) + } + if a.Stage != b.Stage { + return int(b.Stage - a.Stage) + } + return compareKeys(a.Ticket.Key, b.Ticket.Key) + }) + return out +} + +// prodSince is the latest time an applicable prod slot was deployed. ok is +// false when one has no time, so a row never fades on a missing timestamp. +func prodSince(c model.Chain) (since time.Time, ok bool) { + for _, s := range c.Slots { + if !s.Applies || !s.Env.Prod || s.State != model.SlotDeployed { + continue + } + if s.At.IsZero() { + return time.Time{}, false + } + if s.At.After(since) { + since = s.At + } + } + return since, !since.IsZero() +} + +// compareKeys orders ticket keys by project, then by number: ABC-9, ABC-10, +// XY-2. Keys without a numeric suffix sort after those with one, and plain +// text breaks every remaining tie (ABC-01 before ABC-1), so the order is total. +func compareKeys(a, b string) int { + ap, an, aok := splitKey(a) + bp, bn, bok := splitKey(b) + if aok != bok { + if aok { + return -1 + } + return 1 + } + if aok { + if c := strings.Compare(ap, bp); c != 0 { + return c + } + if c := cmp.Compare(an, bn); c != 0 { + return c + } + } + return strings.Compare(a, b) +} + +func splitKey(k string) (project string, n int, ok bool) { + i := strings.LastIndexByte(k, '-') + if i < 0 { + return "", 0, false + } + n, err := strconv.Atoi(k[i+1:]) + return k[:i], n, err == nil +} diff --git a/internal/rules/order_test.go b/internal/rules/order_test.go new file mode 100644 index 0000000..230d3a0 --- /dev/null +++ b/internal/rules/order_test.go @@ -0,0 +1,221 @@ +package rules_test + +import ( + "fmt" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +func TestEvaluateSetsStageAndFlags(t *testing.T) { + c := model.Chain{Ticket: model.Ticket{Key: "ABC-1"}, PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksFailing}}} + got := rules.Evaluate([]model.Chain{c}, now, rules.Thresholds{}) + if len(got) != 1 { + t.Fatalf("rows %s, want exactly ABC-1", describe(got)) + } + if got[0].Stage != model.StagePROpen || got[0].Level() != model.Red { + t.Errorf("rows %s, want ABC-1 (PR open, red)", describe(got)) + } +} + +func keysOf(cs []model.Chain) []string { + var keys []string + for _, c := range cs { + keys = append(keys, c.Ticket.Key) + } + return keys +} + +func TestEvaluateSortsRedThenYellowThenNone(t *testing.T) { + none := model.Chain{Ticket: model.Ticket{Key: "ABC-1"}, PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPending, Reviewers: 1, OpenedAt: now}}} + yellow := model.Chain{Ticket: model.Ticket{Key: "ABC-2"}, PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1}}} + red := model.Chain{Ticket: model.Ticket{Key: "ABC-3"}, PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksFailing}}} + rows := rules.Evaluate([]model.Chain{none, yellow, red}, now, rules.Thresholds{}) + got := keysOf(rows) + if want := []string{"ABC-3", "ABC-2", "ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %s, want %v (red, yellow, none)", describe(rows), want) + } +} + +func TestEvaluateSortsFurthestStageFirstWithinALevel(t *testing.T) { + started := model.Chain{Ticket: model.Ticket{Key: "ABC-1"}} + inTest := model.Chain{Ticket: model.Ticket{Key: "ABC-2"}, PRs: []model.PR{{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed)}} + rows := rules.Evaluate([]model.Chain{started, inTest}, now, rules.Thresholds{}) + got := keysOf(rows) + if want := []string{"ABC-2", "ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %s, want %v (in test before started)", describe(rows), want) + } +} + +func inProdSince(key string, at time.Time) model.Chain { + return model.Chain{Ticket: model.Ticket{Key: key}, PRs: []model.PR{{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotDeployed, At: at}}} +} + +func TestEvaluateFadesCleanRowsInProdLongerThanFadeAfter(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + rows := rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-30*time.Hour))}, now, th) + got := keysOf(rows) + if len(got) != 0 { + t.Errorf("rows %s, want none: a clean row in prod for 30h should fade after 24h", describe(rows)) + } +} + +func TestEvaluateKeepsRowsInProdWithinFadeAfter(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + rows := rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-time.Hour))}, now, th) + got := keysOf(rows) + if want := []string{"ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("rows %s, want %v: a row in prod for 1h should not fade yet", describe(rows), want) + } +} + +func TestEvaluateNeverFadesAFlaggedRowInProd(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + c.Slots[0].Health = model.Health{Known: true, Desired: 2, Healthy: 1} + got := rules.Evaluate([]model.Chain{c}, now, th) + if len(got) != 1 || got[0].Stage != model.StageInProd || got[0].Level() != model.Red { + t.Errorf("rows %s, want ABC-1 kept at %v with a red unhealthy flag", describe(got), model.StageInProd) + } +} + +func TestEvaluateSortsAndFades(t *testing.T) { + th := rules.Thresholds{StaleReview: 48 * time.Hour, FadeAfter: 24 * time.Hour} + inProdOld := inProdSince("ABC-1", now.Add(-30*time.Hour)) + inProdNew := inProdSince("ABC-2", now.Add(-time.Hour)) + red := model.Chain{Ticket: model.Ticket{Key: "ABC-3"}, PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksFailing}}} + started := model.Chain{Ticket: model.Ticket{Key: "ABC-4"}} + + rows := rules.Evaluate([]model.Chain{started, inProdOld, inProdNew, red}, now, th) + got := keysOf(rows) + want := []string{"ABC-3", "ABC-2", "ABC-4"} // red first; then furthest stage; ABC-1 faded + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %s, want %v", describe(rows), want) + } +} + +func TestEvaluateDoesNotFadeUntilEveryProdEnvHasIt(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + c.Slots = append(c.Slots, model.EnvSlot{Env: otherProd, Applies: true, State: model.SlotNotYet}) + got := rules.Evaluate([]model.Chain{c}, now, th) + if len(got) != 1 || got[0].Stage != model.StageAwaitingProd { + t.Errorf("got %s, want ABC-1 kept at %v: the second prod Env does not have it yet", describe(got), model.StageAwaitingProd) + } +} + +func TestEvaluateNeverFadesAProdDeployWithNoTime(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + rows := rules.Evaluate([]model.Chain{inProdSince("ABC-1", time.Time{})}, now, th) + got := keysOf(rows) + if want := []string{"ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("rows %s, want %v: with no deploy time we cannot know it has been in prod long enough", describe(rows), want) + } +} + +func TestEvaluateFadeIgnoresProdEnvsThatDoNotApply(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + c.Slots = append(c.Slots, model.EnvSlot{Env: otherProd, State: model.SlotDeployed, At: now.Add(-time.Hour)}) + if rows := rules.Evaluate([]model.Chain{c}, now, th); len(rows) != 0 { + t.Errorf("rows %s, want none: only the applicable prod Env's 30h counts, not another repo's 1h-old deploy", describe(rows)) + } +} + +func TestEvaluateBreaksTiesByProjectThenNumber(t *testing.T) { + chains := []model.Chain{{Ticket: model.Ticket{Key: "XY-2"}}, {Ticket: model.Ticket{Key: "ABC-10"}}, {Ticket: model.Ticket{Key: "ABC-9"}}} + rows := rules.Evaluate(chains, now, rules.Thresholds{}) + got := keysOf(rows) + if want := []string{"ABC-9", "ABC-10", "XY-2"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %s, want %v (project first, then number)", describe(rows), want) + } +} + +func TestEvaluateFadeCountsFromTheLatestProdDeploy(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + c.Slots = append(c.Slots, model.EnvSlot{Env: otherProd, Applies: true, State: model.SlotDeployed, At: now.Add(-time.Hour)}) + got := rules.Evaluate([]model.Chain{c}, now, th) + if len(got) != 1 { + t.Errorf("rows %s, want ABC-1 kept: the second prod Env went live 1h ago", describe(got)) + } +} + +func TestEvaluateNeverFadesAYellowRowInProd(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + c.Ticket.StatusCategory = model.StatusInProgress + got := rules.Evaluate([]model.Chain{c}, now, th) + if len(got) != 1 || got[0].Level() != model.Yellow { + t.Errorf("rows %s, want ABC-1 kept at yellow: Jira still In Progress", describe(got)) + } +} + +// describe renders rows as "KEY (stage, level)" for failure messages. +func describe(cs []model.Chain) string { + levels := map[model.Level]string{model.None: "none", model.Yellow: "yellow", model.Red: "red"} + var out []string + for _, c := range cs { + out = append(out, fmt.Sprintf("%s (%v, %s)", c.Ticket.Key, c.Stage, levels[c.Level()])) + } + return fmt.Sprint(out) +} + +func TestEvaluateKeyOrderIsTotal(t *testing.T) { + chains := []model.Chain{{Ticket: model.Ticket{Key: "AAA"}}, {Ticket: model.Ticket{Key: "ABC-1"}}, {Ticket: model.Ticket{Key: "ABC-01"}}, {Ticket: model.Ticket{Key: "XY-2"}}} + rows := rules.Evaluate(chains, now, rules.Thresholds{}) + got := keysOf(rows) + if want := []string{"ABC-01", "ABC-1", "XY-2", "AAA"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %s, want %v (parseable keys first; same project and number fall back to text)", describe(rows), want) + } +} + +func TestEvaluateFlagsAMergedRepoNoEnvDeploys(t *testing.T) { + // acme/app is live in prod long enough to fade, but the chain's acme/other + // PR is merged into a repo no configured Env deploys: it is live nowhere. + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + c := inProdSince("ABC-1", now.Add(-30*time.Hour)) + other := model.PR{Repo: "acme/other", Number: 2, State: model.PRMerged, EffectiveSHA: "b"} + c.PRs = append(c.PRs, other, other) // two PRs in one unclaimed repo raise one flag + rows := rules.Evaluate([]model.Chain{c}, now, th) + if len(rows) != 1 { + t.Fatalf("rows %s, want ABC-1 kept", describe(rows)) + } + fs := rows[0].Flags + want := "acme/other not deployed by any configured environment" + if len(fs) != 1 || fs[0].Kind != model.FlagDeployUnknown || fs[0].Level != model.Yellow || fs[0].Slot != nil || fs[0].Reason != want { + t.Errorf("flags %+v, want one yellow deploy-unknown flag with no slot and reason %q", fs, want) + } +} + +func TestUnclaimedRepoCheckNeedsConfiguredRepos(t *testing.T) { + // With no Env.Repos anywhere (history fallback), rules cannot tell which + // Env deploys which repo, so it raises nothing. + c := inProdSince("ABC-1", now.Add(-time.Hour)) + c.Slots[0].Env.Repos = nil + c.PRs = append(c.PRs, model.PR{Repo: "acme/other", State: model.PRMerged, EffectiveSHA: "b"}) + if rows := rules.Evaluate([]model.Chain{c}, now, rules.Thresholds{}); len(rows) != 1 || len(rows[0].Flags) != 0 { + t.Errorf("rows %s, want ABC-1 with no flags in history-fallback mode", describe(rows)) + } +} + +func TestEvaluateDropsDoneTicketsWithNoPRs(t *testing.T) { + // Done with nothing to ship (no PRs, or only closed ones): no mismatch, no row. + // A To Do ticket with no PRs stays. + th := rules.Thresholds{DoneGrace: time.Hour} + done := model.Chain{Ticket: model.Ticket{Key: "ABC-1", StatusCategory: model.StatusDone, StatusSince: now.Add(-30 * 24 * time.Hour)}} + closedOnly := done + closedOnly.Ticket.Key, closedOnly.PRs = "ABC-2", []model.PR{{State: model.PRClosed}} + todo := model.Chain{Ticket: model.Ticket{Key: "ABC-3", StatusCategory: model.StatusToDo}} + if fs := rules.Flags(done, now, th); len(fs) != 0 { + t.Errorf("flags %v, want none for a Done ticket with no PRs", kinds(fs)) + } + rows := rules.Evaluate([]model.Chain{done, closedOnly, todo}, now, th) + if got := keysOf(rows); fmt.Sprint(got) != "[ABC-3]" { + t.Errorf("rows %s, want only ABC-3", describe(rows)) + } +} diff --git a/internal/rules/stage.go b/internal/rules/stage.go new file mode 100644 index 0000000..671d7ba --- /dev/null +++ b/internal/rules/stage.go @@ -0,0 +1,68 @@ +// Package rules decides a chain's stage and flags. Pure functions only; the +// clock comes in as an argument. +package rules + +import "github.com/ericdahl-dev/app-green/internal/model" + +// Stage is the furthest point the chain's work has reached: the least +// advanced PR decides among PR states (any open PR means PR open; any +// stack-pending PR means merged), then the slots decide. Only slots that apply +// (the Env deploys one of the chain's merged repos) count. In prod means every +// applicable prod slot is Deployed; awaiting prod means at least one is +// Deployed, awaiting approval or in progress; in test means a test slot is +// Deployed. A chain with a merged repo no configured Env deploys (see +// unclaimedRepos) caps at awaiting prod. Unknown and failed slots never advance the stage; flags report +// what is wrong. +func Stage(c model.Chain) model.Stage { + live, stackPending := 0, false + for _, p := range c.PRs { + if p.State == model.PRClosed { + continue // closed without merging: not part of the work + } + live++ + if p.State == model.PROpen { + return model.StagePROpen + } + stackPending = stackPending || p.StackPending + } + if live == 0 { + return model.StageStarted + } + if stackPending { + // Merged into a stack branch that has not reached the default branch: + // nothing a slot says about sibling PRs can move the chain past merged. + return model.StageMerged + } + best := model.StageMerged + prod, prodDeployed, prodMoving := 0, 0, false + for _, s := range c.Slots { + if !s.Applies { + continue + } + if !s.Env.Prod { + if s.State == model.SlotDeployed { + best = model.StageInTest + } + continue + } + prod++ + switch s.State { + case model.SlotDeployed: + prodDeployed++ + prodMoving = true + case model.SlotAwaitingApproval, model.SlotInProgress: + prodMoving = true + } + } + switch { + case prod > 0 && prodDeployed == prod && len(unclaimedRepos(c)) > 0: + // Every prod Env has it, but a merged repo is deployed nowhere this + // app watches (see unclaimedRepos): not in prod. + return model.StageAwaitingProd + case prod > 0 && prodDeployed == prod: + return model.StageInProd + case prodMoving: + return model.StageAwaitingProd + } + return best +} diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go new file mode 100644 index 0000000..51eebbf --- /dev/null +++ b/internal/rules/stage_test.go @@ -0,0 +1,96 @@ +package rules_test + +import ( + "testing" + + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +var ( + testEnv = model.Env{Account: "a", Stage: "Test", Repos: []string{"acme/app"}} + prodEnv = model.Env{Account: "a", Stage: "Production", Prod: true, Repos: []string{"acme/app"}} + // otherProd is a second prod Env, e.g. another account's pipeline. + otherProd = model.Env{Account: "b", Stage: "Production", Prod: true, Repos: []string{"acme/app"}} +) + +func slot(e model.Env, st model.SlotState) model.EnvSlot { + return model.EnvSlot{Env: e, State: st, Applies: true} +} + +func TestStage(t *testing.T) { + open := model.PR{State: model.PROpen} + merged := model.PR{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"} + cases := []struct { + name string + c model.Chain + want model.Stage + }{ + {"a prod Env that deploys none of the chain's repos is ignored", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotAwaitingApproval), + {Env: otherProd, State: model.SlotDeployed}}}, model.StageAwaitingProd}, + {"in prod in one prod Env but not yet in another is awaiting prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed), slot(otherProd, model.SlotNotYet)}}, model.StageAwaitingProd}, + {"deployed in every prod Env is in prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed), slot(otherProd, model.SlotDeployed)}}, model.StageInProd}, + {"a deployed test Env that deploys none of the chain's repos does not give in test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{{Env: testEnv, State: model.SlotDeployed}}}, model.StageMerged}, + {"a ticket with no PRs has only started", model.Chain{}, model.StageStarted}, + {"a ticket whose PRs are all closed has only started", model.Chain{PRs: []model.PR{{State: model.PRClosed}}}, model.StageStarted}, + {"a closed PR beside a merged one does not count", model.Chain{PRs: []model.PR{{State: model.PRClosed}, merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed)}}, model.StageInTest}, + {"an open PR puts the ticket at PR open", model.Chain{PRs: []model.PR{open}}, model.StagePROpen}, + {"one open PR holds a merged one back at PR open", model.Chain{PRs: []model.PR{merged, open}}, model.StagePROpen}, + {"a merged PR not deployed anywhere yet is merged", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotNotYet), slot(prodEnv, model.SlotNotYet)}}, model.StageMerged}, + {"deployed to test, not prod, is in test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotNotYet)}}, model.StageInTest}, + {"prod waiting for approval is awaiting prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotAwaitingApproval)}}, model.StageAwaitingProd}, + {"a prod run in progress is awaiting prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotInProgress)}}, model.StageAwaitingProd}, + {"deployed to prod is in prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed)}}, model.StageInProd}, + {"unhealthy in prod is still in prod (a flag says what is wrong)", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), + {Env: prodEnv, Applies: true, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2, Healthy: 0}}}}, model.StageInProd}, + {"an unknown prod slot does not advance past test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotUnknown)}}, model.StageInTest}, + {"a failed prod slot does not advance past test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotFailed)}}, model.StageInTest}, + {"a rolled-back prod slot does not advance past test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotRolledBack)}}, model.StageInTest}, + {"a rolled-back test slot leaves a merged PR at merged", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotRolledBack)}}, model.StageMerged}, + {"unknown and failed slots leave a merged PR at merged", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotUnknown), slot(prodEnv, model.SlotFailed)}}, model.StageMerged}, + {"test awaiting approval or in progress is not in test yet", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotInProgress), {Env: model.Env{Account: "b", Stage: "Test"}, Applies: true, State: model.SlotAwaitingApproval}}}, model.StageMerged}, + {"a merged PR still pending in a stack counts as merged", model.Chain{PRs: []model.PR{{State: model.PRMerged, StackPending: true}}}, model.StageMerged}, + {"a stack-pending PR holds the stage at merged even when another PR is in prod", model.Chain{ + PRs: []model.PR{merged, {State: model.PRMerged, StackPending: true}}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed)}}, model.StageMerged}, + } + for _, c := range cases { + if got := rules.Stage(c.c); got != c.want { + t.Errorf("%s: Stage = %v, want %v", c.name, got, c.want) + } + } +} + +func TestStageUnclaimedRepoCapsAtAwaitingProd(t *testing.T) { + // acme/app is deployed in every prod Env, but acme/other is merged and no + // configured Env deploys it: the chain is not in prod. + c := model.Chain{PRs: []model.PR{ + {Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"}, + {Repo: "acme/other", State: model.PRMerged, EffectiveSHA: "b"}, + }, Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed)}} + if got := rules.Stage(c); got != model.StageAwaitingProd { + t.Errorf("Stage = %v, want %v: acme/other is deployed nowhere", got, model.StageAwaitingProd) + } + // With no Env.Repos anywhere (history fallback) rules cannot tell: in prod. + c.Slots[0].Env.Repos, c.Slots[1].Env.Repos = nil, nil + if got := rules.Stage(c); got != model.StageInProd { + t.Errorf("Stage = %v, want %v in history-fallback mode", got, model.StageInProd) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f96239d --- /dev/null +++ b/main.go @@ -0,0 +1,5 @@ +package main + +import "fmt" + +func main() { fmt.Println("app-green") }