Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5298285
chore: scaffold app-green module
Skeyelab Oct 6, 2026
2d4cbb2
feat(model): shared types for tickets, PRs, envs and chains
Skeyelab Oct 6, 2026
86df35c
fix(model): review fixes for String, Health.OK and Level
Skeyelab Oct 6, 2026
5bc5025
feat(link): find ticket keys in PR titles and branches
Skeyelab Oct 6, 2026
07edad0
feat(link): link PRs to tickets, follow stacks to the default branch
Skeyelab Oct 6, 2026
8d6709f
fix(link): review fixes for key matching and stack walking
Skeyelab Oct 6, 2026
4f64fd2
feat(link): resolve env slots, exact match before compare
Skeyelab Oct 6, 2026
cb60ca8
fix(link): slots mean running now; stack-pending PRs hold a slot back
Skeyelab Oct 6, 2026
7d8bc11
fix(link): slot review fixes and user decisions on since, failed and …
Skeyelab Oct 6, 2026
d168147
feat(rules): chain stage
Skeyelab Oct 6, 2026
f4c5bac
feat(rules): needs-action flags in fixed order
Skeyelab Oct 6, 2026
3cd0f76
feat(rules): evaluate, sort and fade rows
Skeyelab Oct 6, 2026
e700479
feat(rules): all prod envs, unknown flag, no-check merges, timestamp …
Skeyelab Oct 6, 2026
da39455
fix(rules): review fixes
Skeyelab Oct 6, 2026
75817e2
feat: rolled-back state, repos per env, To Do mismatch, partial prod …
Skeyelab Oct 6, 2026
9859513
feat(rules): partial prod flag targets the missing env
Skeyelab Oct 6, 2026
d2ce67f
fix: repo case, unclaimed repos, typed status category
Skeyelab Oct 6, 2026
ea0f60f
feat: stranded PRs, batched rollback, hide Done tickets with no PRs
Skeyelab Oct 6, 2026
6f3b705
test: end-to-end link to rules
Skeyelab Oct 6, 2026
7891e56
fix(rules): unclaimed repo caps stage at awaiting prod
Skeyelab Oct 6, 2026
e3d58cb
ci: build, gofmt, race tests and golangci-lint on PRs
Skeyelab Oct 6, 2026
f8bf59a
ci: run CI on self-hosted ger3 runners
Skeyelab Oct 6, 2026
b45da49
ci: back to GitHub-hosted runners
EricDahl-NotreDame Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/app-green
/dist/
8 changes: 8 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/ericdahl-dev/app-green

go 1.25.0

toolchain go1.25.10
231 changes: 231 additions & 0 deletions internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
34 changes: 34 additions & 0 deletions internal/link/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Package link turns adapter snapshots into chains. Everything here is pure:
// no I/O, no clocks. Compare results come in through model.CompareFunc.
package link

import (
"regexp"
"slices"
"strings"
)

// keyRe finds PROJ-123 at the start of a text or after a non-alphanumeric
// character ("feature/abc-12_fix", "feature_abc-12-x"). RE2 has no lookaround,
// so the leading boundary is matched as a group; there is no trailing boundary,
// so "abc-12fix" yields ABC-12.
var keyRe = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])([a-z][a-z0-9]+)-(\d+)`)

// KeysIn returns the distinct ticket keys in texts whose project is one of
// projects (compared case-insensitively), uppercased, in first-seen order.
func KeysIn(projects []string, texts ...string) []string {
var keys []string
for _, t := range texts {
for _, m := range keyRe.FindAllStringSubmatch(t, -1) {
proj := strings.ToUpper(m[1])
if !slices.ContainsFunc(projects, func(p string) bool { return strings.EqualFold(p, proj) }) {
continue
}
k := proj + "-" + m[2]
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
}
return keys
}
39 changes: 39 additions & 0 deletions internal/link/keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package link_test

import (
"reflect"
"testing"

"github.com/ericdahl-dev/app-green/internal/link"
)

func TestKeysIn(t *testing.T) {
projects := []string{"ABC"}
cases := []struct {
in []string
want []string
}{
{[]string{"ABC-12: Fix the thing", "abc-12-fix-thing"}, []string{"ABC-12"}},
{[]string{"[3/5] ABC-7: Step three", "abc-7-step"}, []string{"ABC-7"}},
{[]string{"ABC-1, ABC-2: both", ""}, []string{"ABC-1", "ABC-2"}},
{[]string{"XYZ-9: other project", "xyz-9"}, nil},
{[]string{"Bump rails", "dependabot/bundler/rails-8"}, nil},
{[]string{"UTF-8 handling", ""}, nil}, // not a configured project
{[]string{"", "feature/abc-12_fix"}, []string{"ABC-12"}},
{[]string{"", "feature_abc-12-x"}, []string{"ABC-12"}},
{[]string{"", "abc-12fix"}, []string{"ABC-12"}},
{[]string{"", "xabc-12"}, nil},
}
for _, c := range cases {
if got := link.KeysIn(projects, c.in...); !reflect.DeepEqual(got, c.want) {
t.Errorf("KeysIn(%q) = %v, want %v", c.in, got, c.want)
}
}
}

func TestKeysInProjectCaseInsensitive(t *testing.T) {
got := link.KeysIn([]string{"abc"}, "abc-12: lower-case config")
if !reflect.DeepEqual(got, []string{"ABC-12"}) {
t.Errorf("KeysIn with project \"abc\" = %v, want [ABC-12]", got)
}
}
Loading
Loading