From 5298285417c6f388cb20644ce41a092e865fa026 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:00:24 -0400 Subject: [PATCH 01/23] chore: scaffold app-green module Assisted-by: Claude Code:claude-opus-5-5 --- .gitignore | 2 ++ go.mod | 5 +++++ main.go | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 main.go 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/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/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") } From 2d4cbb2b790067c9119ff2dd1a652ff840e567d6 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:00:50 -0400 Subject: [PATCH 02/23] feat(model): shared types for tickets, PRs, envs and chains Assisted-by: Claude Code:claude-opus-5-5 --- internal/model/model.go | 219 +++++++++++++++++++++++++++++++++++ internal/model/model_test.go | 20 ++++ 2 files changed, 239 insertions(+) create mode 100644 internal/model/model.go create mode 100644 internal/model/model_test.go diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5d65831 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,219 @@ +// 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 "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 string // "To Do" | "In Progress" | "Done" + URL string + Updated time.Time + StatusSince time.Time // when it entered Status; zero if unknown +} + +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" + 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 +} + +// 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 +} + +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 execution of an Env's deploy action. +type Deploy struct { + ExecutionID string + Status DeployStatus + Revisions map[string]string // repo "owner/name" → commit SHA + 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 +} + +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 + Health Health +} + +type SlotState int + +const ( + SlotNotYet SlotState = iota + SlotAwaitingApproval + SlotInProgress + SlotFailed + SlotDeployed + SlotUnknown // a compare call failed or returned 404 +) + +// 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 +} + +type Stage int + +const ( + StageStarted Stage = iota + StagePROpen + StageMerged + StageInTest + StageAwaitingProd + StageInProd +) + +func (s Stage) String() string { + return [...]string{"started", "PR open", "merged", "in test", "awaiting prod", "in prod"}[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 + FlagUnhealthy + FlagCheckFailed + FlagChangesRequested + FlagAwaitingApproval + FlagReadyToMerge + FlagStaleReview + FlagStatusMismatch +) + +// 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. +func (c Chain) Level() Level { + if len(c.Flags) == 0 { + return None + } + return c.Flags[0].Level +} + +// 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..1090e8e --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,20 @@ +package model_test + +import ( + "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") + } +} From 86df35c00dc7f8ffad03c01ffbd6ce4c3ceeb929 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:03:41 -0400 Subject: [PATCH 03/23] fix(model): review fixes for String, Health.OK and Level Assisted-by: Claude Code:claude-opus-5-5 --- internal/model/model.go | 57 ++++++++++++++++++++++++++++----- internal/model/model_test.go | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index 5d65831..ca2d0f3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,7 +2,10 @@ // beyond small helpers and imports nothing from the project. package model -import "time" +import ( + "fmt" + "time" +) // Ticket is one tracker issue (a Jira ticket in v1). type Ticket struct { @@ -51,7 +54,7 @@ type Check struct { // PR is one pull request. type PR struct { - Repo string // "owner/name" + Repo string // "owner/name", lowercased by the adapter; must equal Deploy.Revisions keys DefaultBranch string // the branch pipelines deploy, usually "main" Number int Title string @@ -98,7 +101,7 @@ const ( type Deploy struct { ExecutionID string Status DeployStatus - Revisions map[string]string // repo "owner/name" → commit SHA + Revisions map[string]string // repo "owner/name" → commit SHA; keys lowercased by the adapter, must equal PR.Repo FinishedAt time.Time // last update time of the action ApprovalToken string // set when Status == DeployAwaitingApproval } @@ -110,6 +113,8 @@ type Health struct { 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. @@ -130,6 +135,15 @@ const ( SlotUnknown // a compare call failed or returned 404 ) +var slotStateNames = [...]string{"not yet", "awaiting approval", "in progress", "failed", "deployed", "unknown"} + +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 @@ -151,8 +165,13 @@ const ( StageInProd ) +var stageNames = [...]string{"started", "PR open", "merged", "in test", "awaiting prod", "in prod"} + func (s Stage) String() string { - return [...]string{"started", "PR open", "merged", "in test", "awaiting prod", "in prod"}[s] + if s < 0 || int(s) >= len(stageNames) { + return fmt.Sprintf("Stage(%d)", int(s)) + } + return stageNames[s] } type Level int @@ -179,6 +198,24 @@ const ( FlagStatusMismatch ) +var flagKindNames = [...]string{ + "pipeline failed", + "unhealthy", + "check failed", + "changes requested", + "awaiting approval", + "ready to merge", + "stale review", + "status mismatch", +} + +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 @@ -197,12 +234,16 @@ type Chain struct { Flags []Flag // ordered; Flags[0] is the row's flag } -// Level is the chain's worst flag level. +// 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 { - if len(c.Flags) == 0 { - return None + worst := None + for _, f := range c.Flags { + if f.Level.Worse(worst) { + worst = f.Level + } } - return c.Flags[0].Level + return worst } // Inclusion is the answer to "is commit A in commit B?". diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 1090e8e..2504bbc 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -1,6 +1,7 @@ package model_test import ( + "strings" "testing" "github.com/ericdahl-dev/app-green/internal/model" @@ -18,3 +19,64 @@ func TestLevelWorse(t *testing.T) { 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.SlotUnknown; 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.FlagStatusMismatch; 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) + } +} From 5bc502585ee2800b501cf642b46e181109541929 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:04:55 -0400 Subject: [PATCH 04/23] feat(link): find ticket keys in PR titles and branches Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/keys.go | 30 ++++++++++++++++++++++++++++++ internal/link/keys_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 internal/link/keys.go create mode 100644 internal/link/keys_test.go diff --git a/internal/link/keys.go b/internal/link/keys.go new file mode 100644 index 0000000..ba3e55f --- /dev/null +++ b/internal/link/keys.go @@ -0,0 +1,30 @@ +// 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" +) + +var keyRe = regexp.MustCompile(`(?i)\b([a-z][a-z0-9]+)-(\d+)\b`) + +// KeysIn returns the distinct ticket keys in texts whose project is one of +// projects, 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.Contains(projects, 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..29c8ecd --- /dev/null +++ b/internal/link/keys_test.go @@ -0,0 +1,28 @@ +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 + } + 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) + } + } +} From 07edad03d87834263db13b92db2f639e5894d4b7 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:05:31 -0400 Subject: [PATCH 05/23] feat(link): link PRs to tickets, follow stacks to the default branch Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/link.go | 65 ++++++++++++++++++++++++++++ internal/link/link_test.go | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 internal/link/link.go create mode 100644 internal/link/link_test.go diff --git a/internal/link/link.go b/internal/link/link.go new file mode 100644 index 0000000..c29afd3 --- /dev/null +++ b/internal/link/link.go @@ -0,0 +1,65 @@ +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 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 dropped. +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. +func withEffectiveSHAs(prs []model.PR) []model.PR { + type rk struct{ repo, head string } + byHead := map[rk]model.PR{} + for _, p := range prs { + byHead[rk{p.Repo, p.HeadRef}] = p + } + out := make([]model.PR, len(prs)) + for i, p := range prs { + out[i] = p + if p.State != model.PRMerged { + continue + } + cur, seen := p, map[int]bool{} + for cur.BaseRef != cur.DefaultBranch { + next, ok := byHead[rk{cur.Repo, cur.BaseRef}] + if !ok || seen[next.Number] { + break // base branch has no PR (or a cycle): treat as pending + } + seen[next.Number] = true + cur = next + } + switch { + case cur.BaseRef == cur.DefaultBranch && cur.State == model.PRMerged: + out[i].EffectiveSHA = cur.MergeSHA + default: + out[i].StackPending = true + } + } + return out +} diff --git a/internal/link/link_test.go b/internal/link/link_test.go new file mode 100644 index 0000000..b5b02f9 --- /dev/null +++ b/internal/link/link_test.go @@ -0,0 +1,87 @@ +package link_test + +import ( + "testing" + + "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 TestLinkKeyWithoutTicketIsUnlinked(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. + pr(1, "ABC-7: orphan", "o1", "gone", model.PRMerged, "eeee555"), + // Cycle between two stack branches: pending, 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 := p.State == model.PRMerged + if p.EffectiveSHA != "" || p.StackPending != wantPending { + t.Errorf("#%d EffectiveSHA=%q pending=%v, want \"\"/%v", p.Number, p.EffectiveSHA, p.StackPending, wantPending) + } + } +} From 8d6709f983d38c2dd581c71cf22858cfd9f239c9 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:09:57 -0400 Subject: [PATCH 06/23] fix(link): review fixes for key matching and stack walking Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/keys.go | 10 +++++-- internal/link/keys_test.go | 11 ++++++++ internal/link/link.go | 29 +++++++++++++++----- internal/link/link_test.go | 56 +++++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/internal/link/keys.go b/internal/link/keys.go index ba3e55f..820d692 100644 --- a/internal/link/keys.go +++ b/internal/link/keys.go @@ -8,16 +8,20 @@ import ( "strings" ) -var keyRe = regexp.MustCompile(`(?i)\b([a-z][a-z0-9]+)-(\d+)\b`) +// 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, uppercased, in first-seen order. +// 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.Contains(projects, proj) { + if !slices.ContainsFunc(projects, func(p string) bool { return strings.EqualFold(p, proj) }) { continue } k := proj + "-" + m[2] diff --git a/internal/link/keys_test.go b/internal/link/keys_test.go index 29c8ecd..9af83e4 100644 --- a/internal/link/keys_test.go +++ b/internal/link/keys_test.go @@ -19,6 +19,10 @@ func TestKeysIn(t *testing.T) { {[]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) { @@ -26,3 +30,10 @@ func TestKeysIn(t *testing.T) { } } } + +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 index c29afd3..95c4361 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -3,9 +3,10 @@ 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 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 dropped. +// 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{} @@ -32,12 +33,21 @@ func Link(tickets []model.Ticket, prs []model.PR, projects []string) ([]model.Ch // withEffectiveSHAs sets EffectiveSHA and StackPending on every merged PR by // following BaseRef through other PRs' HeadRefs until it reaches the repo's -// default branch. +// 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 is stranded: StackPending, no EffectiveSHA. func withEffectiveSHAs(prs []model.PR) []model.PR { type rk struct{ repo, head string } byHead := map[rk]model.PR{} for _, p := range prs { - byHead[rk{p.Repo, p.HeadRef}] = p + 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 { @@ -45,17 +55,22 @@ func withEffectiveSHAs(prs []model.PR) []model.PR { if p.State != model.PRMerged { continue } - cur, seen := p, map[int]bool{} + 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 || seen[next.Number] { break // base branch has no PR (or 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 cur.BaseRef == cur.DefaultBranch && cur.State == model.PRMerged: + case !stranded && cur.BaseRef == cur.DefaultBranch && cur.State == model.PRMerged: out[i].EffectiveSHA = cur.MergeSHA default: out[i].StackPending = true diff --git a/internal/link/link_test.go b/internal/link/link_test.go index b5b02f9..db1e33b 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -2,6 +2,7 @@ package link_test import ( "testing" + "time" "github.com/ericdahl-dev/app-green/internal/link" "github.com/ericdahl-dev/app-green/internal/model" @@ -31,7 +32,7 @@ func TestLinkGroupsPRsByKey(t *testing.T) { } } -func TestLinkKeyWithoutTicketIsUnlinked(t *testing.T) { +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 { @@ -85,3 +86,56 @@ func TestLinkStackEdgeCases(t *testing.T) { } } } + +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 + }{ + {"normal order", t0, "aaaa111", false}, + {"stranded after base merged", t0.Add(2 * time.Hour), "", 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 { + t.Errorf("%s: #%d EffectiveSHA=%q pending=%v, want %q/%v", + c.name, got.Number, got.EffectiveSHA, got.StackPending, c.wantSHA, c.wantPending) + } + } +} From 4f64fd2c3a95be8ef2e1ae38990640eab4a1c4b1 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:11:45 -0400 Subject: [PATCH 07/23] feat(link): resolve env slots, exact match before compare Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 106 +++++++++++++++++++++++++++ internal/link/slots_test.go | 139 ++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 internal/link/slots.go create mode 100644 internal/link/slots_test.go diff --git a/internal/link/slots.go b/internal/link/slots.go new file mode 100644 index 0000000..9033da4 --- /dev/null +++ b/internal/link/slots.go @@ -0,0 +1,106 @@ +package link + +import "github.com/ericdahl-dev/app-green/internal/model" + +// Slot decides where prs stand in one Env. Only merged PRs with an +// EffectiveSHA in a repo this Env deploys count. +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 || p.EffectiveSHA == "" || !deploysRepo(h, p.Repo) { + 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 rank(r.State) < rank(worst.State) { + worst = r + } + } + worst.Health = h.Health + return worst +} + +func deploysRepo(h model.EnvHistory, repo string) bool { + for _, d := range h.Deploys { + if _, ok := d.Revisions[repo]; ok { + return true + } + } + return false +} + +// rank orders states from least to most advanced for "worst of" decisions. +func rank(s model.SlotState) int { + return map[model.SlotState]int{ + model.SlotUnknown: 0, model.SlotFailed: 1, model.SlotNotYet: 2, + model.SlotInProgress: 3, model.SlotAwaitingApproval: 4, model.SlotDeployed: 5, + }[s] +} + +func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlot { + s := model.EnvSlot{Env: h.Env, SHA: p.EffectiveSHA} + // 1. Exact match, newest first. A success wins; otherwise the newest + // non-success carrying the SHA decides (failed, in progress, approval). + var pending *model.Deploy + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Revisions[p.Repo] != p.EffectiveSHA { + continue + } + if d.Status == model.DeploySucceeded { + s.State, s.At, s.Deploy = model.SlotDeployed, d.FinishedAt, d + return s + } + if pending == nil { + pending = d + } + } + // 2. Compare against the newest succeeded deploy that carries p.Repo. + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Status != model.DeploySucceeded || d.Revisions[p.Repo] == "" { + continue + } + switch cmp(p.Repo, p.EffectiveSHA, d.Revisions[p.Repo]) { + case model.Included: + s.State, s.At, s.SHA, s.Deploy = model.SlotDeployed, d.FinishedAt, d.Revisions[p.Repo], d + return s + case model.InclusionUnknown: + if pending == nil { + s.State = model.SlotUnknown + return s + } + } + break // only the newest success is compared + } + if pending != nil { + s.Deploy, s.At = pending, pending.FinishedAt + switch pending.Status { + case model.DeployFailed: + s.State = model.SlotFailed + case model.DeployAwaitingApproval: + s.State = model.SlotAwaitingApproval + default: + s.State = model.SlotInProgress + } + return s + } + s.State = model.SlotNotYet + return s +} + +// 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..1fbe148 --- /dev/null +++ b/internal/link/slots_test.go @@ -0,0 +1,139 @@ +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 TestSlotExactMatch(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{ + dep(model.DeploySucceeded, "bbbb", 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 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 TestSlotNotYetAndUnknown(t *testing.T) { + h := model.EnvHistory{Env: test, Deploys: []model.Deploy{dep(model.DeploySucceeded, "cccc", t0)}} + no := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + unk := func(_, _, _ string) model.Inclusion { return model.InclusionUnknown } + if s := link.Slot([]model.PR{merged("aaaa")}, h, no); s.State != model.SlotNotYet { + t.Errorf("NotIncluded: %+v", s) + } + if s := link.Slot([]model.PR{merged("aaaa")}, h, unk); s.State != model.SlotUnknown { + t.Errorf("Unknown: %+v", 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("open PRs do not hold a slot back: %+v", s) + } + if s := link.Slot([]model.PR{merged("aaaa"), merged("bbbb")}, h, no); s.State != model.SlotNotYet { + t.Errorf("bbbb not deployed, so the chain is not: %+v", 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("%+v", 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("%+v", 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("%+v", 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) + } +} From cb60ca8987ebb93d75ad2504df72311fcb0c96da Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:18:33 -0400 Subject: [PATCH 08/23] fix(link): slots mean running now; stack-pending PRs hold a slot back Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 50 ++++++++++++++++++++------- internal/link/slots_test.go | 68 +++++++++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/internal/link/slots.go b/internal/link/slots.go index 9033da4..e36c39e 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -2,13 +2,23 @@ package link import "github.com/ericdahl-dev/app-green/internal/model" -// Slot decides where prs stand in one Env. Only merged PRs with an -// EffectiveSHA in a repo this Env deploys count. +// Slot decides where prs stand in one Env right now. Only merged PRs in a +// repo this Env deploys count; open and closed PRs never hold a slot back. A +// merged PR that is StackPending (not on the default branch yet) counts as +// SlotNotYet, so the chain is never ahead of its least advanced merged PR. 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 || p.EffectiveSHA == "" || !deploysRepo(h, p.Repo) { + if p.State != model.PRMerged || !deploysRepo(h, p.Repo) { + continue + } + if p.StackPending { + // Merged, but not on the default branch yet: it holds the chain back. + results = append(results, model.EnvSlot{Env: h.Env, State: model.SlotNotYet}) + continue + } + if p.EffectiveSHA == "" { continue } results = append(results, slotFor(p, h, cmp)) @@ -44,33 +54,47 @@ func rank(s model.SlotState) int { }[s] } +// 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 the oldest exact-SHA success, so a +// redeploy of the same SHA does not reset it, or N's time when there is none. +// An older exact success does not count when N does not contain the PR (a +// rollback). Otherwise the newest non-success carrying the exact SHA decides +// (failed, in progress, awaiting approval); with none, NotYet, or Unknown when +// the compare could not answer. func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlot { s := model.EnvSlot{Env: h.Env, SHA: p.EffectiveSHA} - // 1. Exact match, newest first. A success wins; otherwise the newest - // non-success carrying the SHA decides (failed, in progress, approval). - var pending *model.Deploy + // 1. Scan for the exact SHA: the oldest success and the newest non-success. + var pending, firstExact *model.Deploy for i := range h.Deploys { d := &h.Deploys[i] if d.Revisions[p.Repo] != p.EffectiveSHA { continue } if d.Status == model.DeploySucceeded { - s.State, s.At, s.Deploy = model.SlotDeployed, d.FinishedAt, d - return s - } - if pending == nil { + firstExact = d // newest first, so the last one seen is the oldest + } else if pending == nil { pending = d } } - // 2. Compare against the newest succeeded deploy that carries p.Repo. + // 2. Does N, the newest succeeded deploy carrying p.Repo, contain the PR? for i := range h.Deploys { d := &h.Deploys[i] if d.Status != model.DeploySucceeded || d.Revisions[p.Repo] == "" { continue } - switch cmp(p.Repo, p.EffectiveSHA, d.Revisions[p.Repo]) { + head := d.Revisions[p.Repo] + if head == p.EffectiveSHA { + // firstExact is set: d itself is an exact success. + s.State, s.At, s.Deploy = model.SlotDeployed, firstExact.FinishedAt, d + return s + } + switch cmp(p.Repo, p.EffectiveSHA, head) { case model.Included: - s.State, s.At, s.SHA, s.Deploy = model.SlotDeployed, d.FinishedAt, d.Revisions[p.Repo], d + s.State, s.At, s.SHA, s.Deploy = model.SlotDeployed, d.FinishedAt, head, d + if firstExact != nil { + s.At = firstExact.FinishedAt + } return s case model.InclusionUnknown: if pending == nil { diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index 1fbe148..1c07410 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -30,11 +30,19 @@ func noCompare(t *testing.T) model.CompareFunc { } func TestSlotExactMatch(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(-time.Hour)), + dep(model.DeploySucceeded, "bbbb", t0), dep(model.DeploySucceeded, "aaaa", t0.Add(-2*time.Hour)), }} - s := link.Slot([]model.PR{merged("aaaa")}, h, noCompare(t)) - if s.State != model.SlotDeployed || !s.At.Equal(t0.Add(-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) } } @@ -137,3 +145,57 @@ func TestSlotCompareSkipsDeployWithoutRepo(t *testing.T) { t.Errorf("slot = %+v, want deployed via cccc", s) } } + +func TestSlotRollbackIsNotDeployed(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.SlotNotYet { + t.Errorf("slot = %+v, want not yet after rollback", 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) + } +} From 7d8bc11d53ba027cb1c3e6129398a1046e096655 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:30:09 -0400 Subject: [PATCH 09/23] fix(link): slot review fixes and user decisions on since, failed and in-flight runs Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 177 ++++++++++++++++++++++--------- internal/link/slots_test.go | 205 ++++++++++++++++++++++++++++++++++-- 2 files changed, 323 insertions(+), 59 deletions(-) diff --git a/internal/link/slots.go b/internal/link/slots.go index e36c39e..86a3b04 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -1,11 +1,17 @@ package link -import "github.com/ericdahl-dev/app-green/internal/model" +import ( + "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; open and closed PRs never hold a slot back. A -// merged PR that is StackPending (not on the default branch yet) counts as -// SlotNotYet, so the chain is never ahead of its least advanced merged PR. +// merged PR that is StackPending or has no EffectiveSHA (not on the default +// branch yet, or malformed input) counts as SlotNotYet. The chain's slot is +// its least advanced PR (see rank; Failed is the least advanced of all); for +// ties, the latest Deployed time and the newest AwaitingApproval deploy win. 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 @@ -13,14 +19,12 @@ func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSl if p.State != model.PRMerged || !deploysRepo(h, p.Repo) { continue } - if p.StackPending { - // Merged, but not on the default branch yet: it holds the chain back. + 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 p.EffectiveSHA == "" { - continue - } results = append(results, slotFor(p, h, cmp)) } if len(results) == 0 { @@ -29,7 +33,7 @@ func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSl // The chain is only as far as its least advanced PR. worst := results[0] for _, r := range results[1:] { - if rank(r.State) < rank(worst.State) { + if behind(r, worst) { worst = r } } @@ -37,6 +41,23 @@ func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSl 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 +} + func deploysRepo(h model.EnvHistory, repo string) bool { for _, d := range h.Deploys { if _, ok := d.Revisions[repo]; ok { @@ -47,72 +68,95 @@ func deploysRepo(h model.EnvHistory, repo string) bool { } // rank orders states from least to most advanced for "worst of" decisions. +// Failed ranks lowest so a real failure is never hidden behind Unknown. func rank(s model.SlotState) int { - return map[model.SlotState]int{ - model.SlotUnknown: 0, model.SlotFailed: 1, model.SlotNotYet: 2, - model.SlotInProgress: 3, model.SlotAwaitingApproval: 4, model.SlotDeployed: 5, - }[s] + switch s { + case model.SlotFailed: + 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 the oldest exact-SHA success, so a -// redeploy of the same SHA does not reset it, or N's time when there is none. -// An older exact success does not count when N does not contain the PR (a -// rollback). Otherwise the newest non-success carrying the exact SHA decides -// (failed, in progress, awaiting approval); with none, NotYet, or Unknown when -// the compare could not answer. +// 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. With no such run: Unknown if N's compare 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. Scan for the exact SHA: the oldest success and the newest non-success. - var pending, firstExact *model.Deploy + // 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.Revisions[p.Repo] != p.EffectiveSHA { - continue - } - if d.Status == model.DeploySucceeded { + if d.Status == model.DeploySucceeded && d.Revisions[p.Repo] == p.EffectiveSHA { firstExact = d // newest first, so the last one seen is the oldest - } else if pending == nil { - pending = d } } // 2. Does N, the newest succeeded deploy carrying p.Repo, contain the PR? - for i := range h.Deploys { - d := &h.Deploys[i] - if d.Status != model.DeploySucceeded || d.Revisions[p.Repo] == "" { - continue - } - head := d.Revisions[p.Repo] - if head == p.EffectiveSHA { - // firstExact is set: d itself is an exact success. - s.State, s.At, s.Deploy = model.SlotDeployed, firstExact.FinishedAt, d - return s + contains := func(d *model.Deploy) model.Inclusion { + if d.Revisions[p.Repo] == p.EffectiveSHA { + return model.Included } - switch cmp(p.Repo, p.EffectiveSHA, head) { + return cmp(p.Repo, p.EffectiveSHA, d.Revisions[p.Repo]) + } + unknown := false + ni := newestSuccess(h, p.Repo) + if ni >= 0 { + n := &h.Deploys[ni] + switch contains(n) { case model.Included: - s.State, s.At, s.SHA, s.Deploy = model.SlotDeployed, d.FinishedAt, head, d - if firstExact != nil { - s.At = firstExact.FinishedAt - } + s.State, s.SHA, s.Deploy = model.SlotDeployed, n.Revisions[p.Repo], n + s.At = liveSince(h, p.Repo, ni, firstExact, contains) return s case model.InclusionUnknown: - if pending == nil { - s.State = model.SlotUnknown - return s - } + unknown = true + } + } + // 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 + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Status == model.DeploySucceeded || d.Revisions[p.Repo] == "" { + continue + } + if d.Revisions[p.Repo] == p.EffectiveSHA || ((ni < 0 || i < ni) && contains(d) == model.Included) { + pending = d + break } - break // only the newest success is compared + } + if pending == nil && unknown { + s.State = model.SlotUnknown + return s } if pending != nil { - s.Deploy, s.At = pending, pending.FinishedAt + s.Deploy, s.At, s.SHA = pending, pending.FinishedAt, pending.Revisions[p.Repo] switch pending.Status { case model.DeployFailed: s.State = model.SlotFailed case model.DeployAwaitingApproval: s.State = model.SlotAwaitingApproval - default: + case model.DeployInProgress: s.State = model.SlotInProgress + default: + s.State = model.SlotUnknown // a status the adapter should not send } return s } @@ -120,6 +164,41 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo return s } +// 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 && 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 || 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)) diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index 1c07410..efc34e2 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -29,7 +29,7 @@ func noCompare(t *testing.T) model.CompareFunc { } } -func TestSlotExactMatch(t *testing.T) { +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{ @@ -79,15 +79,19 @@ func TestSlotFailedWithoutLaterSuccess(t *testing.T) { } } -func TestSlotNotYetAndUnknown(t *testing.T) { +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 } - unk := func(_, _, _ string) model.Inclusion { return model.InclusionUnknown } if s := link.Slot([]model.PR{merged("aaaa")}, h, no); s.State != model.SlotNotYet { - t.Errorf("NotIncluded: %+v", s) + 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("Unknown: %+v", s) + t.Errorf("slot = %+v, want unknown", s) } } @@ -96,17 +100,17 @@ func TestSlotNeedsEveryMergedPR(t *testing.T) { 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("open PRs do not hold a slot back: %+v", s) + 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("bbbb not deployed, so the chain is not: %+v", s) + 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("%+v", s) + t.Errorf("slot = %+v, want not yet", s) } } @@ -115,7 +119,7 @@ func TestSlotOtherRepoNotInPipeline(t *testing.T) { 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("%+v", s) + t.Errorf("slot = %+v, want not yet; acme/docs is not in this pipeline", s) } } @@ -123,7 +127,7 @@ 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("%+v", c.Slots) + t.Errorf("slots = %+v, want two, Production second", c.Slots) } } @@ -199,3 +203,184 @@ func TestSlotStackPendingOtherRepoIgnored(t *testing.T) { 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) + } +} From d168147814820f741e479618fcd7c01d3ccda361 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:36:47 -0400 Subject: [PATCH 10/23] feat(rules): chain stage Assisted-by: Claude Code:claude-opus-5-5 --- internal/rules/stage.go | 46 ++++++++++++++++++++++++++++ internal/rules/stage_test.go | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 internal/rules/stage.go create mode 100644 internal/rules/stage_test.go diff --git a/internal/rules/stage.go b/internal/rules/stage.go new file mode 100644 index 0000000..5f41e80 --- /dev/null +++ b/internal/rules/stage.go @@ -0,0 +1,46 @@ +// 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 furthest slot in any Env decides. +// Unknown and failed slots, and test slots still in progress or awaiting +// approval, never advance the stage; flags report what is wrong. +func Stage(c model.Chain) model.Stage { + if len(c.PRs) == 0 { + return model.StageStarted + } + stackPending := false + for _, p := range c.PRs { + if p.State == model.PROpen { + return model.StagePROpen + } + stackPending = stackPending || p.StackPending + } + 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 + for _, s := range c.Slots { + var st model.Stage + switch { + case s.Env.Prod && s.State == model.SlotDeployed: + st = model.StageInProd + case s.Env.Prod && (s.State == model.SlotAwaitingApproval || s.State == model.SlotInProgress): + st = model.StageAwaitingProd + case !s.Env.Prod && s.State == model.SlotDeployed: + st = model.StageInTest + default: + continue + } + if st > best { + best = st + } + } + return best +} diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go new file mode 100644 index 0000000..969fbb9 --- /dev/null +++ b/internal/rules/stage_test.go @@ -0,0 +1,59 @@ +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"} + prodEnv = model.Env{Account: "a", Stage: "Production", Prod: true} +) + +func slot(e model.Env, st model.SlotState) model.EnvSlot { return model.EnvSlot{Env: e, State: st} } + +func TestStage(t *testing.T) { + open := model.PR{State: model.PROpen} + merged := model.PR{State: model.PRMerged, EffectiveSHA: "a"} + cases := []struct { + name string + c model.Chain + want model.Stage + }{ + {"a ticket with no PRs has only started", model.Chain{}, model.StageStarted}, + {"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, 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}, + {"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"}, 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) + } + } +} From f4c5bac84f5e7570f6e47c916ebc520eda49cfe7 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:40:25 -0400 Subject: [PATCH 11/23] feat(rules): needs-action flags in fixed order Assisted-by: Claude Code:claude-opus-5-5 --- internal/rules/flags.go | 98 ++++++++++++++++++++++++++++++++++++ internal/rules/flags_test.go | 76 ++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 internal/rules/flags.go create mode 100644 internal/rules/flags_test.go diff --git a/internal/rules/flags.go b/internal/rules/flags.go new file mode 100644 index 0000000..62ccbfb --- /dev/null +++ b/internal/rules/flags.go @@ -0,0 +1,98 @@ +package rules + +import ( + "fmt" + "slices" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Thresholds are the only configurable parts of the rules. +type Thresholds struct { + StaleReview time.Duration // no review for this long → yellow + DoneGrace time.Duration // Done but not in prod for this long → yellow +} + +// Flags returns c's flags, worst level first, then in FlagKind order. +// c.Stage must already be set (call Stage first). +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] + 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.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) + } + } + for i := range c.PRs { + p := &c.PRs[i] + if p.State != model.PROpen { + continue + } + switch { + case p.Checks == model.ChecksFailing: + 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: + 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) + } + } + 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 +} + +func statusMismatch(c model.Chain, now time.Time, th Thresholds) string { + switch { + case c.Ticket.StatusCategory == "In Progress" && c.Stage >= model.StageMerged: + return "Jira still " + orDefault(c.Ticket.Status, "In Progress") + ", PR merged" + case c.Ticket.StatusCategory == "Done" && c.Stage != model.StageInProd && + !c.Ticket.StatusSince.IsZero() && now.Sub(c.Ticket.StatusSince) > th.DoneGrace: + return "Jira Done, not in prod" + } + return "" +} + +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 +} diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go new file mode 100644 index 0000000..2304aa7 --- /dev/null +++ b/internal/rules/flags_test.go @@ -0,0 +1,76 @@ +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}}} + 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}}, + {"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}}, + {"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, State: model.SlotFailed}}}, []model.FlagKind{model.FlagPipelineFailed}}, + {"fewer healthy tasks than desired after a deploy flags the row", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, 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, 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: "In Progress"}, 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: "Done", StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest}, nil}, + {"Done but not in prod past DoneGrace is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, + []model.FlagKind{model.FlagStatusMismatch}}, + {"red flags come before yellow ones", model.Chain{PRs: []model.PR{failing}, + Slots: []model.EnvSlot{{Env: prodEnv, 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, State: model.SlotDeployed}}}, nil}, + } + 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]) + } +} From 3cd0f76ff1522563d3d38fa5a79e0ab7569c71d6 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:41:59 -0400 Subject: [PATCH 12/23] feat(rules): evaluate, sort and fade rows Assisted-by: Claude Code:claude-opus-5-5 --- internal/rules/flags.go | 1 + internal/rules/order.go | 58 ++++++++++++++++++++ internal/rules/order_test.go | 102 +++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 internal/rules/order.go create mode 100644 internal/rules/order_test.go diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 62ccbfb..35aed8b 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -12,6 +12,7 @@ import ( type Thresholds struct { StaleReview time.Duration // no review for this long → yellow DoneGrace time.Duration // Done but not in prod for this long → yellow + FadeAfter time.Duration // a clean row in prod this long drops out } // Flags returns c's flags, worst level first, then in FlagKind order. diff --git a/internal/rules/order.go b/internal/rules/order.go new file mode 100644 index 0000000..0f1d9a4 --- /dev/null +++ b/internal/rules/order.go @@ -0,0 +1,58 @@ +package rules + +import ( + "slices" + "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 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 { + c.Stage = Stage(c) + c.Flags = Flags(c, now, th) + if c.Stage == model.StageInProd && c.Level() == model.None && th.FadeAfter > 0 && + now.Sub(prodSince(c)) > 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 a prod slot was deployed. +func prodSince(c model.Chain) time.Time { + var t time.Time + for _, s := range c.Slots { + if s.Env.Prod && s.State == model.SlotDeployed && s.At.After(t) { + t = s.At + } + } + return t +} + +func compareKeys(a, b string) int { + if len(a) != len(b) { + return len(a) - len(b) // ABC-9 before ABC-10 + } + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} diff --git a/internal/rules/order_test.go b/internal/rules/order_test.go new file mode 100644 index 0000000..238b59a --- /dev/null +++ b/internal/rules/order_test.go @@ -0,0 +1,102 @@ +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("want 1 row, got %d", len(got)) + } + if got[0].Stage != model.StagePROpen || got[0].Level() != model.Red { + t.Errorf("want stage %v at %v, got stage %v at %v", model.StagePROpen, model.Red, got[0].Stage, got[0].Level()) + } +} + +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}}} + got := keysOf(rules.Evaluate([]model.Chain{none, yellow, red}, now, rules.Thresholds{})) + if want := []string{"ABC-3", "ABC-2", "ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %v, want %v (red, yellow, none)", got, 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{{State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed)}} + got := keysOf(rules.Evaluate([]model.Chain{started, inTest}, now, rules.Thresholds{})) + if want := []string{"ABC-2", "ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %v, want %v (in test before started)", got, want) + } +} + +func TestEvaluateBreaksTiesByTicketKeyInNumberOrder(t *testing.T) { + chains := []model.Chain{{Ticket: model.Ticket{Key: "ABC-10"}}, {Ticket: model.Ticket{Key: "ABC-9"}}, {Ticket: model.Ticket{Key: "ABC-2"}}} + got := keysOf(rules.Evaluate(chains, now, rules.Thresholds{})) + if want := []string{"ABC-2", "ABC-9", "ABC-10"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %v, want %v (ABC-9 before ABC-10)", got, want) + } +} + +func inProdSince(key string, at time.Time) model.Chain { + return model.Chain{Ticket: model.Ticket{Key: key}, PRs: []model.PR{{State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, At: at}}} +} + +func TestEvaluateFadesCleanRowsInProdLongerThanFadeAfter(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-30*time.Hour))}, now, th)) + if len(got) != 0 { + t.Errorf("rows %v, want none: a clean row in prod for 30h should fade after 24h", got) + } +} + +func TestEvaluateKeepsRowsInProdWithinFadeAfter(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-time.Hour))}, now, th)) + if want := []string{"ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("rows %v, want %v: a row in prod for 1h should not fade yet", got, 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("got %d rows, want ABC-1 kept at %v with a red unhealthy flag", len(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"}} + + got := keysOf(rules.Evaluate([]model.Chain{started, inProdOld, inProdNew, red}, now, th)) + 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 %v, want %v", got, want) + } +} From e7004796fb51c2d2d5faf9ce49b0bb79cad6640c Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 13:59:42 -0400 Subject: [PATCH 13/23] feat(rules): all prod envs, unknown flag, no-check merges, timestamp fallbacks Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 2 ++ internal/link/slots_test.go | 16 ++++++++++++ internal/model/model.go | 5 ++++ internal/model/model_test.go | 11 +++++++- internal/rules/flags.go | 13 ++++++++-- internal/rules/flags_test.go | 32 +++++++++++++++++++---- internal/rules/order.go | 49 +++++++++++++++++++++++++----------- internal/rules/order_test.go | 37 ++++++++++++++++++++++++++- internal/rules/stage.go | 41 +++++++++++++++++++----------- internal/rules/stage_test.go | 17 ++++++++++--- 10 files changed, 182 insertions(+), 41 deletions(-) diff --git a/internal/link/slots.go b/internal/link/slots.go index 86a3b04..00f6b61 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -12,6 +12,7 @@ import ( // branch yet, or malformed input) counts as SlotNotYet. The chain's slot is // its least advanced PR (see rank; Failed is the least advanced of all); 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 @@ -38,6 +39,7 @@ func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSl } } worst.Health = h.Health + worst.Applies = true return worst } diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index efc34e2..87774f8 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -384,3 +384,19 @@ func TestSlotHealthPassesThrough(t *testing.T) { 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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index ca2d0f3..1c9e3e3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -152,6 +152,9 @@ type EnvSlot struct { 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 @@ -196,6 +199,7 @@ const ( FlagReadyToMerge FlagStaleReview FlagStatusMismatch + FlagDeployUnknown // an Env's deploy status could not be decided ) var flagKindNames = [...]string{ @@ -207,6 +211,7 @@ var flagKindNames = [...]string{ "ready to merge", "stale review", "status mismatch", + "deploy unknown", } func (k FlagKind) String() string { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 2504bbc..dc1fba3 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -43,7 +43,7 @@ func TestSlotStateString(t *testing.T) { } func TestFlagKindString(t *testing.T) { - for k := model.FlagPipelineFailed; k <= model.FlagStatusMismatch; k++ { + 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) } @@ -80,3 +80,12 @@ func TestChainLevel(t *testing.T) { 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") + } +} diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 35aed8b..9699120 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -24,6 +24,9 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { } 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) @@ -31,6 +34,8 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { 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 { @@ -43,7 +48,7 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { 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: + 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) @@ -64,11 +69,15 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { } 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 == "In Progress" && c.Stage >= model.StageMerged: return "Jira still " + orDefault(c.Ticket.Status, "In Progress") + ", PR merged" case c.Ticket.StatusCategory == "Done" && c.Stage != model.StageInProd && - !c.Ticket.StatusSince.IsZero() && now.Sub(c.Ticket.StatusSince) > th.DoneGrace: + !since.IsZero() && now.Sub(since) > th.DoneGrace: return "Jira Done, not in prod" } return "" diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index 2304aa7..a122080 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -35,27 +35,37 @@ func TestFlags(t *testing.T) { []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, State: model.SlotFailed}}}, []model.FlagKind{model.FlagPipelineFailed}}, - {"fewer healthy tasks than desired after a deploy flags the row", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2, Healthy: 1}}}}, + {"a failed pipeline run flags the row", model.Chain{Slots: []model.EnvSlot{{Env: testEnv, Applies: true, State: model.SlotFailed}}}, []model.FlagKind{model.FlagPipelineFailed}}, + {"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, State: model.SlotAwaitingApproval}}}, []model.FlagKind{model.FlagAwaitingApproval}}, + {"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: "In Progress"}, 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: "Done", StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest}, nil}, {"Done but not in prod past DoneGrace is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, []model.FlagKind{model.FlagStatusMismatch}}, + {"Done with no status time falls back to the ticket's last update", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", Updated: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, + []model.FlagKind{model.FlagStatusMismatch}}, + {"Done with neither a status time nor an update time is not flagged", model.Chain{Ticket: model.Ticket{StatusCategory: "Done"}, Stage: model.StageInTest}, nil}, {"red flags come before yellow ones", model.Chain{PRs: []model.PR{failing}, - Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotAwaitingApproval}}}, + 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, State: model.SlotDeployed}}}, nil}, + {"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}}, } for _, c := range cases { if got := kinds(rules.Flags(c.c, now, th)); !slices.Equal(got, c.want) { @@ -74,3 +84,15 @@ func TestCheckFailedFlagTargetsItsPRAtRed(t *testing.T) { 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 != prodEnv || 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") + } +} diff --git a/internal/rules/order.go b/internal/rules/order.go index 0f1d9a4..f9faaa3 100644 --- a/internal/rules/order.go +++ b/internal/rules/order.go @@ -1,7 +1,10 @@ package rules import ( + "cmp" "slices" + "strconv" + "strings" "time" "github.com/ericdahl-dev/app-green/internal/model" @@ -15,8 +18,8 @@ func Evaluate(chains []model.Chain, now time.Time, th Thresholds) []model.Chain for _, c := range chains { c.Stage = Stage(c) c.Flags = Flags(c, now, th) - if c.Stage == model.StageInProd && c.Level() == model.None && th.FadeAfter > 0 && - now.Sub(prodSince(c)) > th.FadeAfter { + 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) @@ -33,26 +36,42 @@ func Evaluate(chains []model.Chain, now time.Time, th Thresholds) []model.Chain return out } -// prodSince is the latest time a prod slot was deployed. -func prodSince(c model.Chain) time.Time { - var t time.Time +// 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.Env.Prod && s.State == model.SlotDeployed && s.At.After(t) { - t = s.At + 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 t + return since, !since.IsZero() } +// compareKeys orders ticket keys by project, then by number: ABC-9, ABC-10, +// XY-2. A key without a numeric suffix compares as plain text. func compareKeys(a, b string) int { - if len(a) != len(b) { - return len(a) - len(b) // ABC-9 before ABC-10 + ap, an, aok := splitKey(a) + bp, bn, bok := splitKey(b) + if !aok || !bok { + return strings.Compare(a, b) } - if a < b { - return -1 + if c := strings.Compare(ap, bp); c != 0 { + return c } - if a > b { - return 1 + return cmp.Compare(an, bn) +} + +func splitKey(k string) (project string, n int, ok bool) { + i := strings.LastIndexByte(k, '-') + if i < 0 { + return "", 0, false } - return 0 + 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 index 238b59a..c0e519d 100644 --- a/internal/rules/order_test.go +++ b/internal/rules/order_test.go @@ -58,7 +58,7 @@ func TestEvaluateBreaksTiesByTicketKeyInNumberOrder(t *testing.T) { func inProdSince(key string, at time.Time) model.Chain { return model.Chain{Ticket: model.Ticket{Key: key}, PRs: []model.PR{{State: model.PRMerged, EffectiveSHA: "a"}}, - Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, At: at}}} + Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotDeployed, At: at}}} } func TestEvaluateFadesCleanRowsInProdLongerThanFadeAfter(t *testing.T) { @@ -100,3 +100,38 @@ func TestEvaluateSortsAndFades(t *testing.T) { t.Errorf("order %v, want %v", got, 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 %v, want ABC-1 kept at %v: the second prod Env does not have it yet", keysOf(got), model.StageAwaitingProd) + } +} + +func TestEvaluateNeverFadesAProdDeployWithNoTime(t *testing.T) { + th := rules.Thresholds{FadeAfter: 24 * time.Hour} + got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", time.Time{})}, now, th)) + if want := []string{"ABC-1"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("rows %v, want %v: with no deploy time we cannot know it has been in prod long enough", got, 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 got := keysOf(rules.Evaluate([]model.Chain{c}, now, th)); len(got) != 0 { + t.Errorf("rows %v, want none: only the applicable prod Env's 30h counts, not another repo's 1h-old deploy", got) + } +} + +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"}}} + got := keysOf(rules.Evaluate(chains, now, rules.Thresholds{})) + if want := []string{"ABC-9", "ABC-10", "XY-2"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("order %v, want %v (project first, then number)", got, want) + } +} diff --git a/internal/rules/stage.go b/internal/rules/stage.go index 5f41e80..321dece 100644 --- a/internal/rules/stage.go +++ b/internal/rules/stage.go @@ -6,9 +6,12 @@ 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 furthest slot in any Env decides. -// Unknown and failed slots, and test slots still in progress or awaiting -// approval, never advance the stage; flags report what is wrong. +// 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. Unknown and failed slots never advance the stage; flags report +// what is wrong. func Stage(c model.Chain) model.Stage { if len(c.PRs) == 0 { return model.StageStarted @@ -26,21 +29,31 @@ func Stage(c model.Chain) model.Stage { return model.StageMerged } best := model.StageMerged + prod, prodDeployed, prodMoving := 0, 0, false for _, s := range c.Slots { - var st model.Stage - switch { - case s.Env.Prod && s.State == model.SlotDeployed: - st = model.StageInProd - case s.Env.Prod && (s.State == model.SlotAwaitingApproval || s.State == model.SlotInProgress): - st = model.StageAwaitingProd - case !s.Env.Prod && s.State == model.SlotDeployed: - st = model.StageInTest - default: + if !s.Applies { continue } - if st > best { - best = st + 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: + return model.StageInProd + case prodMoving: + return model.StageAwaitingProd + } return best } diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go index 969fbb9..58fa825 100644 --- a/internal/rules/stage_test.go +++ b/internal/rules/stage_test.go @@ -10,9 +10,13 @@ import ( var ( testEnv = model.Env{Account: "a", Stage: "Test"} prodEnv = model.Env{Account: "a", Stage: "Production", Prod: true} + // otherProd is a second prod Env, e.g. another account's pipeline. + otherProd = model.Env{Account: "b", Stage: "Production", Prod: true} ) -func slot(e model.Env, st model.SlotState) model.EnvSlot { return model.EnvSlot{Env: e, State: st} } +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} @@ -22,6 +26,13 @@ func TestStage(t *testing.T) { 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 ticket with no PRs has only started", model.Chain{}, model.StageStarted}, {"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}, @@ -37,7 +48,7 @@ func TestStage(t *testing.T) { 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, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2, Healthy: 0}}}}, model.StageInProd}, + {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}, @@ -45,7 +56,7 @@ func TestStage(t *testing.T) { {"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"}, State: model.SlotAwaitingApproval}}}, model.StageMerged}, + 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}}, From da394556a24d76ce3854dc7cc35f198414b22e3b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:08:40 -0400 Subject: [PATCH 14/23] fix(rules): review fixes Assisted-by: Claude Code:claude-opus-5-5 --- internal/rules/flags.go | 16 +++++- internal/rules/flags_test.go | 7 +++ internal/rules/order.go | 21 +++++--- internal/rules/order_test.go | 94 +++++++++++++++++++++++++----------- internal/rules/stage.go | 12 +++-- internal/rules/stage_test.go | 5 ++ 6 files changed, 115 insertions(+), 40 deletions(-) diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 9699120..8a1b0b2 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -16,7 +16,9 @@ type Thresholds struct { } // Flags returns c's flags, worst level first, then in FlagKind order. -// c.Stage must already be set (call Stage first). +// 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) { @@ -44,7 +46,7 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { continue } switch { - case p.Checks == model.ChecksFailing: + 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) @@ -106,3 +108,13 @@ func orDefault(s, d string) string { } 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 index a122080..4ff0985 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -31,6 +31,8 @@ func TestFlags(t *testing.T) { {"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}}}, @@ -66,6 +68,11 @@ func TestFlags(t *testing.T) { {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) { diff --git a/internal/rules/order.go b/internal/rules/order.go index f9faaa3..2aef6a8 100644 --- a/internal/rules/order.go +++ b/internal/rules/order.go @@ -54,17 +54,26 @@ func prodSince(c model.Chain) (since time.Time, ok bool) { } // compareKeys orders ticket keys by project, then by number: ABC-9, ABC-10, -// XY-2. A key without a numeric suffix compares as plain text. +// 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 { - return strings.Compare(a, b) + if aok != bok { + if aok { + return -1 + } + return 1 } - if c := strings.Compare(ap, bp); c != 0 { - return c + if aok { + if c := strings.Compare(ap, bp); c != 0 { + return c + } + if c := cmp.Compare(an, bn); c != 0 { + return c + } } - return cmp.Compare(an, bn) + return strings.Compare(a, b) } func splitKey(k string) (project string, n int, ok bool) { diff --git a/internal/rules/order_test.go b/internal/rules/order_test.go index c0e519d..a829d4a 100644 --- a/internal/rules/order_test.go +++ b/internal/rules/order_test.go @@ -13,10 +13,10 @@ 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("want 1 row, got %d", len(got)) + t.Fatalf("rows %s, want exactly ABC-1", describe(got)) } if got[0].Stage != model.StagePROpen || got[0].Level() != model.Red { - t.Errorf("want stage %v at %v, got stage %v at %v", model.StagePROpen, model.Red, got[0].Stage, got[0].Level()) + t.Errorf("rows %s, want ABC-1 (PR open, red)", describe(got)) } } @@ -32,9 +32,10 @@ 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}}} - got := keysOf(rules.Evaluate([]model.Chain{none, yellow, red}, now, rules.Thresholds{})) + 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 %v, want %v (red, yellow, none)", got, want) + t.Errorf("order %s, want %v (red, yellow, none)", describe(rows), want) } } @@ -42,17 +43,10 @@ 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{{State: model.PRMerged, EffectiveSHA: "a"}}, Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed)}} - got := keysOf(rules.Evaluate([]model.Chain{started, inTest}, now, rules.Thresholds{})) + 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 %v, want %v (in test before started)", got, want) - } -} - -func TestEvaluateBreaksTiesByTicketKeyInNumberOrder(t *testing.T) { - chains := []model.Chain{{Ticket: model.Ticket{Key: "ABC-10"}}, {Ticket: model.Ticket{Key: "ABC-9"}}, {Ticket: model.Ticket{Key: "ABC-2"}}} - got := keysOf(rules.Evaluate(chains, now, rules.Thresholds{})) - if want := []string{"ABC-2", "ABC-9", "ABC-10"}; fmt.Sprint(got) != fmt.Sprint(want) { - t.Errorf("order %v, want %v (ABC-9 before ABC-10)", got, want) + t.Errorf("order %s, want %v (in test before started)", describe(rows), want) } } @@ -63,17 +57,19 @@ func inProdSince(key string, at time.Time) model.Chain { func TestEvaluateFadesCleanRowsInProdLongerThanFadeAfter(t *testing.T) { th := rules.Thresholds{FadeAfter: 24 * time.Hour} - got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-30*time.Hour))}, now, th)) + 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 %v, want none: a clean row in prod for 30h should fade after 24h", got) + 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} - got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", now.Add(-time.Hour))}, now, th)) + 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 %v, want %v: a row in prod for 1h should not fade yet", got, want) + t.Errorf("rows %s, want %v: a row in prod for 1h should not fade yet", describe(rows), want) } } @@ -83,7 +79,7 @@ func TestEvaluateNeverFadesAFlaggedRowInProd(t *testing.T) { 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("got %d rows, want ABC-1 kept at %v with a red unhealthy flag", len(got), model.StageInProd) + t.Errorf("rows %s, want ABC-1 kept at %v with a red unhealthy flag", describe(got), model.StageInProd) } } @@ -94,10 +90,11 @@ func TestEvaluateSortsAndFades(t *testing.T) { 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"}} - got := keysOf(rules.Evaluate([]model.Chain{started, inProdOld, inProdNew, red}, now, th)) + 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 %v, want %v", got, want) + t.Errorf("order %s, want %v", describe(rows), want) } } @@ -107,15 +104,16 @@ func TestEvaluateDoesNotFadeUntilEveryProdEnvHasIt(t *testing.T) { 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 %v, want ABC-1 kept at %v: the second prod Env does not have it yet", keysOf(got), 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} - got := keysOf(rules.Evaluate([]model.Chain{inProdSince("ABC-1", time.Time{})}, now, th)) + 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 %v, want %v: with no deploy time we cannot know it has been in prod long enough", got, want) + t.Errorf("rows %s, want %v: with no deploy time we cannot know it has been in prod long enough", describe(rows), want) } } @@ -123,15 +121,55 @@ 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 got := keysOf(rules.Evaluate([]model.Chain{c}, now, th)); len(got) != 0 { - t.Errorf("rows %v, want none: only the applicable prod Env's 30h counts, not another repo's 1h-old deploy", got) + 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"}}} - got := keysOf(rules.Evaluate(chains, now, rules.Thresholds{})) + 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 %v, want %v (project first, then number)", got, 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 = "In Progress" + 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) } } diff --git a/internal/rules/stage.go b/internal/rules/stage.go index 321dece..4994059 100644 --- a/internal/rules/stage.go +++ b/internal/rules/stage.go @@ -13,16 +13,20 @@ import "github.com/ericdahl-dev/app-green/internal/model" // Deployed. Unknown and failed slots never advance the stage; flags report // what is wrong. func Stage(c model.Chain) model.Stage { - if len(c.PRs) == 0 { - return model.StageStarted - } - stackPending := false + 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. diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go index 58fa825..812aa84 100644 --- a/internal/rules/stage_test.go +++ b/internal/rules/stage_test.go @@ -33,7 +33,12 @@ func TestStage(t *testing.T) { 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}, From 75817e26813b6a613e401183c5eca4159c90fea6 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:13:17 -0400 Subject: [PATCH 15/23] feat: rolled-back state, repos per env, To Do mismatch, partial prod flag Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 58 +++++++++++++++++++++------ internal/link/slots_test.go | 76 ++++++++++++++++++++++++++++++++++-- internal/model/model.go | 14 ++++++- internal/model/model_test.go | 17 +++++++- internal/rules/flags.go | 40 ++++++++++++++++++- internal/rules/flags_test.go | 60 +++++++++++++++++++++++++++- internal/rules/stage_test.go | 4 ++ 7 files changed, 248 insertions(+), 21 deletions(-) diff --git a/internal/link/slots.go b/internal/link/slots.go index 00f6b61..3461f2e 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -1,18 +1,22 @@ package link import ( + "slices" "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; 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. The chain's slot is -// its least advanced PR (see rank; Failed is the least advanced of all); 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. +// repo this Env deploys count (Env.Repos when config set it, else the repos in +// its history); 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 @@ -26,6 +30,12 @@ func Slot(prs []model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSl 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 { @@ -60,7 +70,17 @@ func behind(r, worst model.EnvSlot) bool { 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.Contains(h.Env.Repos, repo) + } + return historyCarries(h, repo) +} + +// historyCarries reports whether any deploy in h carries repo. +func historyCarries(h model.EnvHistory, repo string) bool { for _, d := range h.Deploys { if _, ok := d.Revisions[repo]; ok { return true @@ -70,10 +90,11 @@ func deploysRepo(h model.EnvHistory, repo string) bool { } // rank orders states from least to most advanced for "worst of" decisions. -// Failed ranks lowest so a real failure is never hidden behind Unknown. +// 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: + case model.SlotFailed, model.SlotRolledBack: return 0 case model.SlotUnknown: return 1 @@ -98,8 +119,10 @@ func rank(s model.SlotState) int { // 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. With no such run: Unknown if N's compare could not answer, -// else NotYet. +// newer than N. When N does not contain the PR but an older success carried +// its exact SHA, the slot is RolledBack (Deploy and At are N's) unless a run +// newer than N contains it. With no such run: Unknown if N's compare 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. @@ -117,7 +140,7 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo } return cmp(p.Repo, p.EffectiveSHA, d.Revisions[p.Repo]) } - unknown := false + unknown, rolledBack := false, false ni := newestSuccess(h, p.Repo) if ni >= 0 { n := &h.Deploys[ni] @@ -128,22 +151,33 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo 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 } } // 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 || d.Revisions[p.Repo] == "" { continue } if d.Revisions[p.Repo] == p.EffectiveSHA || ((ni < 0 || i < ni) && contains(d) == model.Included) { - pending = d + 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 = h.Deploys[ni].Revisions[p.Repo] + return s + } if pending == nil && unknown { s.State = model.SlotUnknown return s diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index 87774f8..1968192 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -150,7 +150,7 @@ func TestSlotCompareSkipsDeployWithoutRepo(t *testing.T) { } } -func TestSlotRollbackIsNotDeployed(t *testing.T) { +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)), @@ -162,8 +162,8 @@ func TestSlotRollbackIsNotDeployed(t *testing.T) { t.Fatalf("unexpected compare %s...%s", base, head) return model.InclusionUnknown } - if s := link.Slot([]model.PR{merged("aaaa")}, h, cmp); s.State != model.SlotNotYet { - t.Errorf("slot = %+v, want not yet after rollback", s) + 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) } } @@ -400,3 +400,73 @@ func TestSlotAppliesOnlyWhenAMergedPRIsInARepoTheEnvDeploys(t *testing.T) { 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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 1c9e3e3..d057f0b 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -84,6 +84,11 @@ type Env struct { Order int // position in config; higher is further along Prod bool ReadOnly bool // profile cannot approve + // Repos are the lowercased "owner/name" repos this Env deploys, filled + // from config. When set, link trusts it over what history shows; when + // empty, link falls back to the repos seen in the Env's deploys. 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 } @@ -132,10 +137,11 @@ const ( SlotInProgress SlotFailed SlotDeployed - SlotUnknown // a compare call failed or returned 404 + 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"} +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) { @@ -192,10 +198,12 @@ type FlagKind int // Order within a level is the order of these constants (design: Flags). const ( FlagPipelineFailed FlagKind = iota + FlagRolledBack FlagUnhealthy FlagCheckFailed FlagChangesRequested FlagAwaitingApproval + FlagPartialProd // live in some prod Envs, not all, for too long FlagReadyToMerge FlagStaleReview FlagStatusMismatch @@ -204,10 +212,12 @@ const ( var flagKindNames = [...]string{ "pipeline failed", + "rolled back", "unhealthy", "check failed", "changes requested", "awaiting approval", + "partial prod", "ready to merge", "stale review", "status mismatch", diff --git a/internal/model/model_test.go b/internal/model/model_test.go index dc1fba3..617b946 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -32,7 +32,7 @@ func TestStageString(t *testing.T) { } func TestSlotStateString(t *testing.T) { - for s := model.SlotNotYet; s <= model.SlotUnknown; s++ { + 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) } @@ -89,3 +89,18 @@ func TestFlagDeployUnknownIsLastAndLabeled(t *testing.T) { 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") + } +} diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 8a1b0b2..23e2f33 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -3,6 +3,7 @@ package rules import ( "fmt" "slices" + "strings" "time" "github.com/ericdahl-dev/app-green/internal/model" @@ -13,6 +14,7 @@ type Thresholds struct { StaleReview time.Duration // no review for this long → yellow DoneGrace time.Duration // Done but not in prod for this long → yellow FadeAfter time.Duration // a clean row in prod this long drops out + PartialProd time.Duration // live in some prod Envs, not all, this long → yellow } // Flags returns c's flags, worst level first, then in FlagKind order. @@ -32,6 +34,8 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { 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: @@ -58,6 +62,9 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { add(model.Yellow, model.FlagStaleReview, fmt.Sprintf("PR #%d no review %s", p.Number, days(now.Sub(p.OpenedAt))), p, nil) } } + if r := partialProd(c, now, th); r != "" { + add(model.Yellow, model.FlagPartialProd, r, nil, nil) + } if r := statusMismatch(c, now, th); r != "" { add(model.Yellow, model.FlagStatusMismatch, r, nil, nil) } @@ -70,14 +77,43 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { return fs } +// 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. 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 { + if th.PartialProd <= 0 || c.Stage != model.StageAwaitingProd { + return "" + } + var live []string + var earliest time.Time + missing := false + for _, s := range c.Slots { + if !s.Applies || !s.Env.Prod { + continue + } + if s.State != model.SlotDeployed { + missing = true + continue + } + live = append(live, s.Env.Account) + if !s.At.IsZero() && (earliest.IsZero() || s.At.Before(earliest)) { + earliest = s.At + } + } + if !missing || len(live) == 0 || earliest.IsZero() || now.Sub(earliest) <= th.PartialProd { + return "" + } + return "live in " + strings.Join(live, ", ") + " only" +} + 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 == "In Progress" && c.Stage >= model.StageMerged: - return "Jira still " + orDefault(c.Ticket.Status, "In Progress") + ", PR merged" + case (c.Ticket.StatusCategory == "In Progress" || c.Ticket.StatusCategory == "To Do") && c.Stage >= model.StageMerged: + return "Jira still " + orDefault(c.Ticket.Status, c.Ticket.StatusCategory) + ", PR merged" case c.Ticket.StatusCategory == "Done" && c.Stage != model.StageInProd && !since.IsZero() && now.Sub(since) > th.DoneGrace: return "Jira Done, not in prod" diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index 4ff0985..ff27505 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -44,11 +44,14 @@ func TestFlags(t *testing.T) { {"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: "In Progress"}, 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: "To Do", 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: "Done", StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest}, nil}, {"Done but not in prod past DoneGrace is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, []model.FlagKind{model.FlagStatusMismatch}}, @@ -99,7 +102,62 @@ func TestDeployUnknownFlagTargetsItsSlotAtYellow(t *testing.T) { 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 != prodEnv || f.Reason != "a Production deploy status unknown" { + 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: "To Do", 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{{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)) + } + } +} diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go index 812aa84..ef6e925 100644 --- a/internal/rules/stage_test.go +++ b/internal/rules/stage_test.go @@ -58,6 +58,10 @@ func TestStage(t *testing.T) { 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}, From 985951303ce3323f1b88100751e0ba3cd13f3688 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:29:23 -0400 Subject: [PATCH 16/23] feat(rules): partial prod flag targets the missing env Assisted-by: Claude Code:claude-opus-5-5 --- internal/rules/flags.go | 29 +++++++++++++++++------------ internal/rules/flags_test.go | 10 ++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 23e2f33..e51a839 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -62,8 +62,8 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { add(model.Yellow, model.FlagStaleReview, fmt.Sprintf("PR #%d no review %s", p.Number, days(now.Sub(p.OpenedAt))), p, nil) } } - if r := partialProd(c, now, th); r != "" { - add(model.Yellow, model.FlagPartialProd, r, 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) @@ -78,21 +78,26 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { } // 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. 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 { +// 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 "" + return "", nil } var live []string var earliest time.Time - missing := false - for _, s := range c.Slots { + 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 { - missing = true + if missing == nil { + missing = s + } continue } live = append(live, s.Env.Account) @@ -100,10 +105,10 @@ func partialProd(c model.Chain, now time.Time, th Thresholds) string { earliest = s.At } } - if !missing || len(live) == 0 || earliest.IsZero() || now.Sub(earliest) <= th.PartialProd { - return "" + if missing == nil || len(live) == 0 || earliest.IsZero() || now.Sub(earliest) <= th.PartialProd { + return "", nil } - return "live in " + strings.Join(live, ", ") + " only" + return "live in " + strings.Join(live, ", ") + " only", missing } func statusMismatch(c model.Chain, now time.Time, th Thresholds) string { diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index ff27505..db45701 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -161,3 +161,13 @@ func TestPartialProdIsNotFlaggedTooSoonOrWithoutData(t *testing.T) { } } } + +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()) + } +} From d2ce67f0c2e652772e1c1a58635b7306482e44bf Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:50:57 -0400 Subject: [PATCH 17/23] fix: repo case, unclaimed repos, typed status category - link compares repos ignoring case everywhere (Env.Repos, PR.Repo, Deploy.Revisions keys) through one revision lookup. - A deploy carries a repo only with a non-empty SHA for it. - rules flags a merged repo that no Env.Repos names: yellow deploy unknown, " not deployed by any configured environment", no slot target. It is skipped when no Env sets Repos (history fallback). - Ticket.StatusCategory is typed and holds Jira's statusCategory.key (new, indeterminate, done). - DoneGrace <= 0 turns the Done check off, like the other thresholds. - Docs: what a Deploy is, adapter ordering and filtering, Env.Repos required in practice. Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/slots.go | 46 ++++++++++++++++++--------- internal/link/slots_test.go | 22 +++++++++++++ internal/model/model.go | 60 ++++++++++++++++++++++++++++-------- internal/rules/flags.go | 58 +++++++++++++++++++++++++++++----- internal/rules/flags_test.go | 38 ++++++++++++++++++----- internal/rules/order_test.go | 35 +++++++++++++++++++-- internal/rules/stage_test.go | 8 ++--- 7 files changed, 217 insertions(+), 50 deletions(-) diff --git a/internal/link/slots.go b/internal/link/slots.go index 3461f2e..648fd58 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -2,6 +2,7 @@ package link import ( "slices" + "strings" "time" "github.com/ericdahl-dev/app-green/internal/model" @@ -9,7 +10,9 @@ import ( // 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); open and closed PRs never hold a slot back. A merged PR that +// 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 @@ -74,15 +77,30 @@ func behind(r, worst model.EnvSlot) bool { // 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.Contains(h.Env.Repos, repo) + return slices.ContainsFunc(h.Env.Repos, func(r string) bool { return strings.EqualFold(r, repo) }) } return historyCarries(h, repo) } -// historyCarries reports whether any deploy in h carries 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 _, ok := d.Revisions[repo]; ok { + if revision(d.Revisions, repo) != "" { return true } } @@ -129,16 +147,16 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo var firstExact *model.Deploy for i := range h.Deploys { d := &h.Deploys[i] - if d.Status == model.DeploySucceeded && d.Revisions[p.Repo] == p.EffectiveSHA { + 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 d.Revisions[p.Repo] == p.EffectiveSHA { + if revision(d.Revisions, p.Repo) == p.EffectiveSHA { return model.Included } - return cmp(p.Repo, p.EffectiveSHA, d.Revisions[p.Repo]) + return cmp(p.Repo, p.EffectiveSHA, revision(d.Revisions, p.Repo)) } unknown, rolledBack := false, false ni := newestSuccess(h, p.Repo) @@ -146,7 +164,7 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo n := &h.Deploys[ni] switch contains(n) { case model.Included: - s.State, s.SHA, s.Deploy = model.SlotDeployed, n.Revisions[p.Repo], n + 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: @@ -164,10 +182,10 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo pi := -1 for i := range h.Deploys { d := &h.Deploys[i] - if d.Status == model.DeploySucceeded || d.Revisions[p.Repo] == "" { + if d.Status == model.DeploySucceeded || revision(d.Revisions, p.Repo) == "" { continue } - if d.Revisions[p.Repo] == p.EffectiveSHA || ((ni < 0 || i < ni) && contains(d) == model.Included) { + if revision(d.Revisions, p.Repo) == p.EffectiveSHA || ((ni < 0 || i < ni) && contains(d) == model.Included) { pending, pi = d, i break } @@ -175,7 +193,7 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo 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 = h.Deploys[ni].Revisions[p.Repo] + s.SHA = revision(h.Deploys[ni].Revisions, p.Repo) return s } if pending == nil && unknown { @@ -183,7 +201,7 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo return s } if pending != nil { - s.Deploy, s.At, s.SHA = pending, pending.FinishedAt, pending.Revisions[p.Repo] + s.Deploy, s.At, s.SHA = pending, pending.FinishedAt, revision(pending.Revisions, p.Repo) switch pending.Status { case model.DeployFailed: s.State = model.SlotFailed @@ -203,7 +221,7 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo // 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 && d.Revisions[repo] != "" { + if d.Status == model.DeploySucceeded && revision(d.Revisions, repo) != "" { return i } } @@ -221,7 +239,7 @@ func liveSince(h model.EnvHistory, repo string, ni int, firstExact *model.Deploy } for i := ni + 1; i < len(h.Deploys); i++ { d := &h.Deploys[i] - if d.Status != model.DeploySucceeded || d.Revisions[repo] == "" { + if d.Status != model.DeploySucceeded || revision(d.Revisions, repo) == "" { continue } if contains(d) != model.Included { diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index 1968192..dbec9e0 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -470,3 +470,25 @@ func TestSlotConfiguredReposOverrideHistory(t *testing.T) { 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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index d057f0b..3a52ada 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -12,12 +12,36 @@ type Ticket struct { Key string // "ABC-1" Title string Status string // display name, e.g. "Code Review" - StatusCategory string // "To Do" | "In Progress" | "Done" + 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 ( @@ -54,7 +78,7 @@ type Check struct { // PR is one pull request. type PR struct { - Repo string // "owner/name", lowercased by the adapter; must equal Deploy.Revisions keys + Repo string // "owner/name"; link compares repos ignoring case DefaultBranch string // the branch pipelines deploy, usually "main" Number int Title string @@ -84,10 +108,13 @@ type Env struct { Order int // position in config; higher is further along Prod bool ReadOnly bool // profile cannot approve - // Repos are the lowercased "owner/name" repos this Env deploys, filled - // from config. When set, link trusts it over what history shows; when - // empty, link falls back to the repos seen in the Env's deploys. Env is - // not comparable with == because of this field; compare ID() instead. + // 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 } @@ -102,13 +129,20 @@ const ( DeployAwaitingApproval DeployStatus = "AwaitingApproval" ) -// Deploy is one execution of an Env's deploy action. +// 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 map[string]string // repo "owner/name" → commit SHA; keys lowercased by the adapter, must equal PR.Repo - FinishedAt time.Time // last update time of the action - ApprovalToken string // set when Status == DeployAwaitingApproval + 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. @@ -125,7 +159,7 @@ 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 + Deploys []Deploy // newest first, as the adapter returns them Health Health } diff --git a/internal/rules/flags.go b/internal/rules/flags.go index e51a839..d9305e8 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -9,12 +9,21 @@ import ( "github.com/ericdahl-dev/app-green/internal/model" ) -// Thresholds are the only configurable parts of the rules. +// Thresholds are the only configurable parts of the rules. Each one at zero +// or below turns its check off. type Thresholds struct { - StaleReview time.Duration // no review for this long → yellow - DoneGrace time.Duration // Done but not in prod for this long → yellow - FadeAfter time.Duration // a clean row in prod this long drops out - PartialProd time.Duration // live in some prod Envs, not all, this long → yellow + // 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. @@ -62,6 +71,9 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { 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) } @@ -77,6 +89,36 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { 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. @@ -117,9 +159,9 @@ func statusMismatch(c model.Chain, now time.Time, th Thresholds) string { since = c.Ticket.Updated // a safe stand-in: it is never older than the status change } switch { - case (c.Ticket.StatusCategory == "In Progress" || c.Ticket.StatusCategory == "To Do") && c.Stage >= model.StageMerged: - return "Jira still " + orDefault(c.Ticket.Status, c.Ticket.StatusCategory) + ", PR merged" - case c.Ticket.StatusCategory == "Done" && c.Stage != model.StageInProd && + 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 && !since.IsZero() && now.Sub(since) > th.DoneGrace: return "Jira Done, not in prod" } diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index db45701..43ae701 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -48,16 +48,16 @@ func TestFlags(t *testing.T) { {"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: "In Progress"}, Stage: model.StageMerged, + {"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: "To Do", Status: "Backlog"}, Stage: model.StageMerged, + {"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: "Done", StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest}, nil}, - {"Done but not in prod past DoneGrace is a status mismatch", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, + {"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}, 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}, []model.FlagKind{model.FlagStatusMismatch}}, - {"Done with no status time falls back to the ticket's last update", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", Updated: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, + {"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}, []model.FlagKind{model.FlagStatusMismatch}}, - {"Done with neither a status time nor an update time is not flagged", model.Chain{Ticket: model.Ticket{StatusCategory: "Done"}, Stage: model.StageInTest}, nil}, + {"Done with neither a status time nor an update time is not flagged", model.Chain{Ticket: model.Ticket{StatusCategory: model.StatusDone}, Stage: model.StageInTest}, 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}}, @@ -119,7 +119,7 @@ func TestRolledBackFlagTargetsItsSlotAtRed(t *testing.T) { } func TestToDoMismatchNamesTheStatus(t *testing.T) { - c := model.Chain{Ticket: model.Ticket{StatusCategory: "To Do", Status: "Backlog"}, Stage: model.StageInTest} + 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") @@ -128,7 +128,7 @@ func TestToDoMismatchNamesTheStatus(t *testing.T) { // 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{{State: model.PRMerged, EffectiveSHA: "a"}}, + 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}}} @@ -171,3 +171,25 @@ func TestPartialProdFlagTargetsTheFirstMissingProdEnv(t *testing.T) { 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)) + } +} diff --git a/internal/rules/order_test.go b/internal/rules/order_test.go index a829d4a..de49353 100644 --- a/internal/rules/order_test.go +++ b/internal/rules/order_test.go @@ -41,7 +41,7 @@ func TestEvaluateSortsRedThenYellowThenNone(t *testing.T) { 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{{State: model.PRMerged, EffectiveSHA: "a"}}, + 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) @@ -51,7 +51,7 @@ func TestEvaluateSortsFurthestStageFirstWithinALevel(t *testing.T) { } func inProdSince(key string, at time.Time) model.Chain { - return model.Chain{Ticket: model.Ticket{Key: key}, PRs: []model.PR{{State: model.PRMerged, EffectiveSHA: "a"}}, + 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}}} } @@ -148,7 +148,7 @@ func TestEvaluateFadeCountsFromTheLatestProdDeploy(t *testing.T) { func TestEvaluateNeverFadesAYellowRowInProd(t *testing.T) { th := rules.Thresholds{FadeAfter: 24 * time.Hour} c := inProdSince("ABC-1", now.Add(-30*time.Hour)) - c.Ticket.StatusCategory = "In Progress" + 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)) @@ -173,3 +173,32 @@ func TestEvaluateKeyOrderIsTotal(t *testing.T) { 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)) + } +} diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go index ef6e925..c477d8e 100644 --- a/internal/rules/stage_test.go +++ b/internal/rules/stage_test.go @@ -8,10 +8,10 @@ import ( ) var ( - testEnv = model.Env{Account: "a", Stage: "Test"} - prodEnv = model.Env{Account: "a", Stage: "Production", Prod: true} + 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} + otherProd = model.Env{Account: "b", Stage: "Production", Prod: true, Repos: []string{"acme/app"}} ) func slot(e model.Env, st model.SlotState) model.EnvSlot { @@ -20,7 +20,7 @@ func slot(e model.Env, st model.SlotState) model.EnvSlot { func TestStage(t *testing.T) { open := model.PR{State: model.PROpen} - merged := model.PR{State: model.PRMerged, EffectiveSHA: "a"} + merged := model.PR{Repo: "acme/app", State: model.PRMerged, EffectiveSHA: "a"} cases := []struct { name string c model.Chain From ea0f60f2f6b8c77177b5f8a38e5cb72d93d6e331 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:54:09 -0400 Subject: [PATCH 18/23] feat: stranded PRs, batched rollback, hide Done tickets with no PRs - PR.Stranded: link sets it (with StackPending) on a PR merged into a branch whose own PR had already merged, or into a branch with no live PR. Waiting on an open base PR is not stranded. rules raises a red FlagStranded, "PR #n merged into a dead branch", targeting the PR. - Batched rollback: when the newest success does not contain a PR and no older success carried its exact SHA, link walks older successes newest to oldest; the first that contains it makes the slot RolledBack (unless a newer run contains it). The walk stops at a compare that cannot answer. - A Done ticket with no PRs (closed ones ignored) gets no status mismatch and Evaluate drops it: nothing to ship. Assisted-by: Claude Code:claude-opus-5-5 --- internal/link/link.go | 13 +++++++--- internal/link/link_test.go | 49 +++++++++++++++++++++++++----------- internal/link/slots.go | 34 ++++++++++++++++++++++--- internal/link/slots_test.go | 49 ++++++++++++++++++++++++++++++++++++ internal/model/model.go | 7 ++++++ internal/model/model_test.go | 9 +++++++ internal/rules/flags.go | 15 ++++++++++- internal/rules/flags_test.go | 23 ++++++++++++++--- internal/rules/order.go | 6 ++++- internal/rules/order_test.go | 17 +++++++++++++ 10 files changed, 195 insertions(+), 27 deletions(-) diff --git a/internal/link/link.go b/internal/link/link.go index 95c4361..1c1c9a4 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -35,7 +35,9 @@ func Link(tickets []model.Ticket, prs []model.PR, projects []string) ([]model.Ch // 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 is stranded: StackPending, no EffectiveSHA. +// 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{} @@ -58,8 +60,12 @@ func withEffectiveSHAs(prs []model.PR) []model.PR { 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 || seen[next.Number] { - break // base branch has no PR (or a cycle): treat as pending + 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) { @@ -74,6 +80,7 @@ func withEffectiveSHAs(prs []model.PR) []model.PR { 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 index db1e33b..2eb8cf3 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -70,9 +70,9 @@ func TestLinkStackNotYetOnMain(t *testing.T) { func TestLinkStackEdgeCases(t *testing.T) { prs := []model.PR{ - // Base branch has no PR: pending. + // Base branch has no PR: pending and stranded. pr(1, "ABC-7: orphan", "o1", "gone", model.PRMerged, "eeee555"), - // Cycle between two stack branches: pending, and it terminates. + // 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. @@ -80,9 +80,9 @@ func TestLinkStackEdgeCases(t *testing.T) { } chains, _ := link.Link([]model.Ticket{{Key: "ABC-7"}}, prs, []string{"ABC"}) for _, p := range chains[0].PRs { - wantPending := p.State == model.PRMerged - if p.EffectiveSHA != "" || p.StackPending != wantPending { - t.Errorf("#%d EffectiveSHA=%q pending=%v, want \"\"/%v", p.Number, p.EffectiveSHA, p.StackPending, wantPending) + 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) } } } @@ -118,13 +118,14 @@ func TestLinkSharedHeadIgnoresClosed(t *testing.T) { 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 + name string + twoAt time.Time + wantSHA string + wantPending bool + wantStranded bool }{ - {"normal order", t0, "aaaa111", false}, - {"stranded after base merged", t0.Add(2 * time.Hour), "", true}, + {"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") @@ -133,9 +134,29 @@ func TestLinkMergedIntoStackAfterBaseMerged(t *testing.T) { 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 { - t.Errorf("%s: #%d EffectiveSHA=%q pending=%v, want %q/%v", - c.name, got.Number, got.EffectiveSHA, got.StackPending, c.wantSHA, c.wantPending) + 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 index 648fd58..48754ce 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -137,10 +137,12 @@ func rank(s model.SlotState) int { // 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 carried -// its exact SHA, the slot is RolledBack (Deploy and At are N's) unless a run -// newer than N contains it. With no such run: Unknown if N's compare could not -// answer, else NotYet. +// 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. @@ -173,6 +175,11 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo // 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 @@ -218,6 +225,25 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo 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 { diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index dbec9e0..17befa0 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -492,3 +492,52 @@ func TestSlotEmptySHAIsNotCarried(t *testing.T) { 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 index 3a52ada..2764c25 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -98,6 +98,11 @@ type PR struct { // 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. + Stranded bool } // Env is one deploy environment: a stage of a pipeline in an account. @@ -233,6 +238,7 @@ type FlagKind int const ( FlagPipelineFailed FlagKind = iota FlagRolledBack + FlagStranded // a merged PR whose commits will never reach the default branch FlagUnhealthy FlagCheckFailed FlagChangesRequested @@ -247,6 +253,7 @@ const ( var flagKindNames = [...]string{ "pipeline failed", "rolled back", + "stranded", "unhealthy", "check failed", "changes requested", diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 617b946..40d7135 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -104,3 +104,12 @@ func TestFlagPartialProdFollowsAwaitingApproval(t *testing.T) { 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 index d9305e8..d23371a 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -55,6 +55,9 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { } 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 } @@ -161,13 +164,23 @@ func statusMismatch(c model.Chain, now time.Time, th Thresholds) string { 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 && + 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" diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index 43ae701..04f7179 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -22,6 +22,7 @@ func kinds(fs []model.Flag) []model.FlagKind { 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 @@ -52,12 +53,12 @@ func TestFlags(t *testing.T) { 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}, 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}, + {"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}, + {"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}, nil}, + {"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}}, @@ -193,3 +194,17 @@ func TestZeroDoneGraceDisablesTheDoneMismatch(t *testing.T) { 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 index 2aef6a8..10b61a0 100644 --- a/internal/rules/order.go +++ b/internal/rules/order.go @@ -11,11 +11,15 @@ import ( ) // Evaluate sets Stage and Flags on every chain, drops chains that have been -// in prod longer than FadeAfter, and sorts: red, yellow, none; then furthest +// 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 && diff --git a/internal/rules/order_test.go b/internal/rules/order_test.go index de49353..230d3a0 100644 --- a/internal/rules/order_test.go +++ b/internal/rules/order_test.go @@ -202,3 +202,20 @@ func TestUnclaimedRepoCheckNeedsConfiguredRepos(t *testing.T) { 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)) + } +} From 6f3b70599bef8cbf93aff58fd8b78624bc158b19 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:55:28 -0400 Subject: [PATCH 19/23] test: end-to-end link to rules Runs link.Link, link.Slots and rules.Evaluate on synthetic chains across two accounts (stage-acct, prod-acct), each with Test and Production Envs: squash merge everywhere, approval wait, missing prod history, rollback, batched rollback, a landed stack, a stranded stack PR and a repo case mismatch. Assisted-by: Claude Code:claude-opus-5-5 --- internal/e2e/e2e_test.go | 231 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 internal/e2e/e2e_test.go 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()) + } + } +} From 7891e566df6c51bdf07783c04ca28753b1c34323 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:59:36 -0400 Subject: [PATCH 20/23] fix(rules): unclaimed repo caps stage at awaiting prod A chain with a merged repo that no configured Env deploys no longer reads "in prod" even when every applicable prod Env has the rest. Same guard as the unclaimed-repo flag: skipped when no Env sets Repos. PR.Stranded doc now says callers must pass the PR for each base branch. Assisted-by: Claude Code:claude-opus-5-5 --- internal/model/model.go | 4 +++- internal/rules/stage.go | 7 ++++++- internal/rules/stage_test.go | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index 2764c25..da2e351 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -101,7 +101,9 @@ type PR struct { // 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. + // 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 } diff --git a/internal/rules/stage.go b/internal/rules/stage.go index 4994059..671d7ba 100644 --- a/internal/rules/stage.go +++ b/internal/rules/stage.go @@ -10,7 +10,8 @@ import "github.com/ericdahl-dev/app-green/internal/model" // (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. Unknown and failed slots never advance the stage; flags report +// 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 @@ -54,6 +55,10 @@ func Stage(c model.Chain) model.Stage { } } 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: diff --git a/internal/rules/stage_test.go b/internal/rules/stage_test.go index c477d8e..51eebbf 100644 --- a/internal/rules/stage_test.go +++ b/internal/rules/stage_test.go @@ -77,3 +77,20 @@ func TestStage(t *testing.T) { } } } + +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) + } +} From e3d58cbe71324c541b12c9f06f7d91a564c7e981 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:02:34 -0400 Subject: [PATCH 21/23] ci: build, gofmt, race tests and golangci-lint on PRs Same workflow and linters as jira-green, plus a gofmt check. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ .golangci.yml | 8 ++++++++ 2 files changed, 46 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml 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/.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 From f8bf59acd2694bed91c54f13c510a11a38f52fd0 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:39:09 -0400 Subject: [PATCH 22/23] ci: run CI on self-hosted ger3 runners Same change as the other studio repos; the runner image has build-essential, so go test -race works. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4a70b4..fb61e75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: [self-hosted, ger3] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -18,7 +18,7 @@ jobs: run: test -z "$(gofmt -l .)" || (gofmt -l .; exit 1) test: - runs-on: ubuntu-latest + runs-on: [self-hosted, ger3] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -27,7 +27,7 @@ jobs: - run: go test -v -race ./... lint: - runs-on: ubuntu-latest + runs-on: [self-hosted, ger3] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 From b45da49900d2bb29b48cb2f6b9d4a9ced17078ac Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:48:09 -0400 Subject: [PATCH 23/23] ci: back to GitHub-hosted runners The repo is public, and the ger3 runner group is private-repos only. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb61e75..e4a70b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: [self-hosted, ger3] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -18,7 +18,7 @@ jobs: run: test -z "$(gofmt -l .)" || (gofmt -l .; exit 1) test: - runs-on: [self-hosted, ger3] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -27,7 +27,7 @@ jobs: - run: go test -v -race ./... lint: - runs-on: [self-hosted, ger3] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5