From 0364bed87dc6f720fb91e45fe7ff191cb8176a01 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 12:38:26 -0400 Subject: [PATCH 1/7] docs: revise app-green design after review Environments are (account, pipeline, stage); deployed SHA comes from action executions; app-owned GitHub query; copy-now sharing; both work accounts tracked; synthetic fixtures; spike first; write-safety rules. Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-design.md | 233 +++++++++++++++------- 1 file changed, 163 insertions(+), 70 deletions(-) diff --git a/docs/plans/2026-10-06-app-green-design.md b/docs/plans/2026-10-06-app-green-design.md index 39d4e26..d3e2a44 100644 --- a/docs/plans/2026-10-06-app-green-design.md +++ b/docs/plans/2026-10-06-app-green-design.md @@ -1,7 +1,7 @@ # app-green design Date: 2026-10-06 -Status: approved design, not yet built +Status: approved design, revised after review; not yet built ## Problem @@ -20,57 +20,104 @@ whether that row needs action. | Main view | One row per ticket (a Chain), drill-down for detail | | v1 scope | The day-job chain: Jira (example) → acme PRs → AWS CodePipeline/ECS | | Later targets | Coolify (personal apps), Azure (possible future employer). Hence pluggable adapters | -| Existing apps | Keep them. Move their clients from `internal/` to `pkg/` and import those | +| Existing apps | Keep them unchanged. v1 copies the small pieces it needs; a shared library is extracted only once a second adapter shows what is really shared | | Actions | Flag + quick actions (re-run checks, approve/reject pipeline step, change ticket status, open). Every write asks first. No automatic Jira updates | | Rows | Tickets assigned to me from In Progress onward, plus tickets with my open PRs, until the change is in prod (fades after ~24h) | -| Architecture | One binary with built-in adapters (option A). A shared background service (option C) stays possible later, since the adapter interfaces are what it would expose | +| Environments | Both work accounts are tracked: stage-acct and prod-acct each have their own pipeline, each with Test and Production stages, so a ticket has four environment slots | +| Architecture | One binary with built-in adapters. A shared background service stays possible later, since the adapter interfaces are what it would expose | +| work time | Unknown. Do the linking spike first, while access exists | +| Test fixtures | Synthetic only: real response shapes, invented IDs and names. No work data leaves work | + +## What the review found (2026-10-06) + +Four reviewers (architecture, backend/API, UX, scope) checked the first draft +against the sibling repos and live read-only AWS calls. Confirmed by hand: + +- Each work account runs one app pipeline with stages Source → + ClusterCreation → Test (Deploy, SmokeTests, ManualApprovalOfTestEnvironment) + → Production. Test and prod are stages, not separate pipelines. +- Each pipeline has three GitHub sources: AppCode (app), InfraCode + (infra), ReportsCode (reports). +- git-green lists only open PRs (50, no pagination) and has no merge SHAs, + branch names or compare, so it cannot supply ticket → PR data. + +The sections below incorporate these findings. ## Architecture ``` app-green ├── adapters (interfaces; work implementations in v1) -│ ├── Tracker → jira (jira-green pkg/jira) -│ ├── CodeHost → github (git-green pkg/github) -│ └── DeployTarget → aws (aws-green pkg/{aws,cfn,ecs}) -├── linker joins the three sources into one Chain per ticket -├── rules decides each Chain's stage and flag -├── poller polls each adapter on its own interval, emits snapshots -└── ui ticket list + detail (Bubble Tea) +│ ├── Tracker → jira (copied from jira-green internal/jira) +│ ├── CodeHost → github (new GraphQL query; ETag transport, rate limiter +│ │ and rerun copied from git-green) +│ └── DeployTarget → aws (CodePipeline action executions, ECS health) +├── resolver polls adapters, runs compare calls, keeps the SHA-pair cache +├── link pure: snapshots + compare results → []Chain +├── rules pure: Chain → stage + flags +└── ui ticket list + detail (Bubble Tea) ``` +`resolver` does all I/O. `link` and `rules` are pure functions, so they are +table-tested without fakes. app-green has its own poller; the sibling pollers +pull in webhooks and app config and are not reused. + ### Chain ``` -Ticket{key, title, status} - → []PR{repo, number, state, checks, review, mergeSHA} - → []Deploy{env: test|prod, pipeline, run, deployedSHA, ecsHealth} +Ticket{key, title, status, statusCategory} + → []PR{repo, number, state, merged, mergeSHA, headRef, checks, review} + → []EnvSlot{env, state, deployedSHA, finishedAt, health} ``` -### Linking +An environment is `(account, pipeline, stage, source action)`, kept as an +ordered list in config, not a test/prod enum. For work: -- **Ticket → PR:** the Jira key appears in the PR title or branch name. A - ticket can have several PRs; a PR can name several keys. -- **PR → deploy:** a merged PR is in an environment when its merge commit is - an ancestor of that environment's last successful deployed revision - (GitHub compare API). Results are cached by SHA pair, since they never - change. -- **Repo → pipeline:** a map in config (`app` → prod-acct and stage-acct - pipelines), seeded from the aws-green config. +``` +stage-acct / app / Test stage-acct / app / Production +prod-acct / app / Test prod-acct / app / Production +``` -New data the existing clients do not fetch yet: PR merge commit SHAs -(git-green) and pipeline execution source revisions (aws-green). +The same shape covers Coolify later (one application per environment, +deployed SHA from the deployment's commit). -### Shared code +### Linking -Each existing app moves its API client from `internal/x` to `pkg/x`, a pure -move with no behavior change, released as a tag. app-green imports those -tags. Adapters convert each client's types into the shared model. +- **Ticket → PR:** the Jira key in the PR title or branch name. One GitHub + GraphQL query per repo, `states: [OPEN, MERGED]`, returning `headRefName`, + `mergeCommit`, `merged` and `statusCheckRollup`. A ticket can have several + PRs; a PR can name several keys. +- **Repo → pipeline source:** read from `GetPipeline` at startup: each source + action's repo name maps to (pipeline, source action). Not hand-written. +- **What an environment is running:** `ListActionExecutions` for the stage's + deploy action (Test/Deploy, Production/deploy). Take the latest succeeded + execution, ordered by finish time, not the latest pipeline execution: + executions awaiting approval stay InProgress, others are SUPERSEDED, and + rollbacks redeploy older revisions. The deployed SHA for a repo comes from + that execution's `sourceRevisions` entry for the repo's source action + (via `ListPipelineExecutions`). +- **PR → environment:** use `mergeSHA` only when `merged = true`. Compare + `mergeSHA...deployedSHA` in the PR's own repo. `ahead` or `identical` means + included. A 404 (for example a force-pushed or deleted commit) means + "unlinked" and is never cached. Results are cached in memory by SHA pair. +- **In prod since:** the Production deploy action's finish time. No disk cache + in v1. +- **Health:** ECS task definitions use CDK asset hashes, not git SHAs, so ECS + is used only for the health flag (healthy tasks vs desired), never to decide + what is deployed. + +### Rows (Jira query) + +My tickets in In Progress or later statuses, plus recently finished ones so a +row survives until its change reaches prod: +`statusCategory != Done OR (statusCategory = Done AND statusCategoryChangedDate >= -14d)`, +plus any ticket key found on my open PRs. ### Config `~/.config/app-green/config.toml`, using the same credential sources as today: Jira `token_env`, GitHub tokens or `gh auth token`, AWS SSO profiles. +Environments are listed in order; pipeline sources are discovered. ## Stages @@ -78,82 +125,128 @@ today: Jira `token_env`, GitHub tokens or `gh auth token`, AWS SSO profiles. |---|---| | Started | Ticket In Progress, no PR | | PR open | At least one open PR | -| Merged | All PRs merged, not yet in test | -| In test | Merge commit deployed to test | -| Awaiting prod | In test, prod pipeline waiting (approval or not yet run) | -| In prod | Merge commit live in prod, ECS healthy; row fades after ~24h | +| Merged | All PRs merged, not in any environment yet | +| In test | Merge commit deployed by a Test stage | +| Awaiting prod | In test, Production not yet run or waiting for approval | +| In prod | Merge commit deployed by a Production stage and healthy; fades after ~24h | + +With PRs in mixed states, the row shows the least advanced PR's stage. With +two accounts, the row shows the furthest stage reached in any account; the +detail screen shows all four environment slots. ## Flags -🔴 needs me now: -- A check failed on my PR (`f` re-run) -- A pipeline run failed or rolled back for a commit in this Chain -- ECS unhealthy after this Chain's deploy (fewer healthy tasks than desired, crash loop) -- Changes requested on my PR +Within each color, flags have a fixed order (listed top to bottom below). A +row shows its first flag; the detail screen lists all of them. + +Red, needs me now: +1. A pipeline stage failed or rolled back for a commit in this Chain +2. ECS unhealthy after this Chain's deploy (fewer healthy tasks than desired, crash loop) +3. A check failed on my PR (`f` re-run; code-scanning results are shown as an + alert with `o` open, since a re-run cannot fix them) +4. Changes requested on my PR + +Yellow, waiting: +1. Pipeline paused for manual approval (`a` / `x`) +2. PR approved and green but not merged +3. In review with no reviewer, or no review for `stale_review_after` (default 2d) +4. Jira status disagrees with reality: In Progress but merged, or Done but not + in prod after a grace period (`t`) -🟡 waiting: -- Pipeline paused for manual approval (`a` / `x`) -- PR approved and green but not merged -- In review with no reviewer, or no review for `stale_review_after` (default 2d) -- Jira status disagrees with reality (In Progress but merged; Done but not in prod) (`t`) -- Unlinked: PR or deploy that could not be matched (never silently dropped) +Unlinked PRs and deploys go in a pinned "Unlinked (n)" group at the bottom, +never silently dropped. No flag: checks running, deploy in progress, review pending within the threshold. -A row shows its worst flag; the detail screen lists all of them. Rules are -plain functions of a Chain in the `rules` package. Only thresholds are -configurable. +Rules are plain functions of a Chain in the `rules` package. Only thresholds +are configurable. ## Screens Main screen, sorted red, yellow, then stage (furthest first): ``` -app-green jira ✓ github ✓ aws ✓ · 12s ago -🔴 ABC-2108 Item count on review page PR #339 CodeQL failing f -🟡 ABC-1972 Form opt-outs test ✓ prod awaiting approval a/x -🟡 ABC-2035 Remove legacy task path PR #331 no review 4d -🟡 ABC-2096 Uptime monitors /up in prod Jira still Code Review t - ABC-2042 S3 access logging started no PR yet - ABC-2103 Health checks nginx PR #338 checks running - ABC-2091 Item count on add page in prod ✓ 3h ago +app-green jira ✓ 12s github ✓ 8s aws prod-acct ✓ stage-acct ✓ +● ABC-2108 Item count on review page PR #339 CodeQL failing f +◐ ABC-1972 Form opt-outs test ✓ prod awaiting approval a/x +◐ ABC-2035 Remove legacy task path PR #331 no review 4d +◐ ABC-2096 Uptime monitors /up in prod Jira still Code Review t + ABC-2042 S3 access logging started no PR yet + ABC-2103 Health checks nginx PR #338 checks running + ABC-2091 Item count on add page in prod ✓ 3h ago +── Unlinked (1) ── + app PR #341 no ticket key ↑/↓ move enter details f re-run a/x approve/reject t status o open r refresh q quit ``` +Status markers are single-width glyphs, not emoji, so layout is stable on +every terminal (the exo-mini font issue showed emoji widths and colors vary). +`NO_COLOR` is honored. The header shows staleness per adapter and per AWS +account, including SSO expiry. + Detail screen: ``` ABC-1972 Drop the form-level opt-outs Jira: Code Review PR #330 app merged 2d ago ✓ checks ✓ approved - test app-test pipeline ✓ deployed 1d ago (a1b2c3d) ECS 2/2 - prod app pipeline ⏸ awaiting approval since 1d a/x + stage-acct Test ✓ deployed 1d ago (a1b2c3d) ECS 2/2 + stage-acct Production ⏸ awaiting approval since 1d a/x + prod-acct Test ✓ deployed 1d ago (a1b2c3d) ECS 2/2 + prod-acct Production not yet ``` -Keys match the existing apps. Every write action confirms first. +### Write safety + +- Confirm with `y`; cancel with `n` or `esc`. `enter` never confirms a write, + because in app-green it opens the detail screen. +- A row action runs only when it has exactly one target (one failing PR, one + waiting approval). Otherwise the key opens the detail screen to choose. +- The approve dialog shows the ticket, the SHA, other tickets bundled in that + SHA, the AWS account, and the Test stage's health. +- If an account's profile is read-only (prod-acct uses `prod-view`), approve is + not offered there and the dialog says why. +- Jira status changes pick from the ticket's available `Transitions()`. +- `f` on a red pipeline row shows a hint and `o` opens the console; no + automatic fix in v1. + +### States -Out of v1: kanban, mute/manage, notifications, webhooks (these stay in the -standalone apps). v2 candidates: desktop notification on red, Coolify target. +Defined screens for: first load, no rows, an adapter failing (rows keep their +last data, marked stale), and expired AWS SSO (header names the command: +`aws sso login --sso-session `). ## Errors and limits - Adapters fail independently. A failed poll keeps the last good snapshot, marks it stale in the header, and retries with backoff; 429 `Retry-After` is honored. -- An expired AWS SSO session gets a specific message naming the login command. -- Poll intervals: Jira 60s, GitHub 60s, AWS 30s. Merged PRs and deployed SHAs - are cached permanently; only open PRs and in-flight pipelines are re-polled. +- Poll intervals: Jira 60s, GitHub 60s, AWS 30s. Compare results are cached + by SHA pair; only open PRs and in-flight pipelines are re-polled. ## Testing -- `linker` and `rules`: table-driven tests over fixtures modeled on real - cases (failing CodeQL on an open PR, merged PR awaiting prod approval). -- Adapters: tested against fake interfaces, as jira-green's poller is. +- `link` and `rules`: table-driven tests over synthetic fixtures built from + real response shapes (failing CodeQL on an open PR, merged PR awaiting + prod approval, squash merge, rollback, superseded execution, three-source + pipeline). Invented IDs and names only. +- `resolver` and adapters: tested against fake interfaces, as jira-green's + poller is. - One manual end-to-end check against the real work board before release. ## Build order -1. Move clients in jira-green, git-green, aws-green to `pkg/`; tag releases. -2. app-green repo: adapter interfaces, Chain model, linker, with tests. -3. Rules and the main screen, read-only (useful from here). -4. Detail screen and actions (re-run, approve/reject, status change). -5. Setup wizard, config, Homebrew cask and Linux build; install on Mac and exo-mini. +0. **Spike (about a day).** A throwaway command in app-green that, for one + ticket, lists its merged PRs, finds each environment's deployed SHA from + action executions, and runs the compare. Check it on a squash merge and a + rollback. Record the response shapes for synthetic fixtures. Stop and + revise this design if the linking does not hold. +1. Model, `link` and `rules` with table tests. +2. Adapters and `resolver`, read-only (Jira copy, new GitHub query, AWS + action executions, ECS health). +3. Main and detail screens, read-only. Useful from here. +4. Actions with the write-safety rules above. +5. Config, release build (Homebrew cask, Linux), install on the Mac and + exo-mini (both work SSO profiles work there). + +Later: extract a shared client library once Coolify is the second adapter; +coolify-green already has a working client. From 42f468d46f1f5532b86088b51fbeed80036583d9 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 12:52:52 -0400 Subject: [PATCH 2/7] docs: record linking spike results PR merge SHA equals the pipeline's AppCode sourceRevision in both accounts; compare resolves superseded runs. Adds exact-match-first, stacked-PR and approval-wait rules. No real rollbacks to test. Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-design.md | 10 ++--- docs/plans/2026-10-06-linking-spike.md | 51 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-10-06-linking-spike.md diff --git a/docs/plans/2026-10-06-app-green-design.md b/docs/plans/2026-10-06-app-green-design.md index d3e2a44..2ca4cd8 100644 --- a/docs/plans/2026-10-06-app-green-design.md +++ b/docs/plans/2026-10-06-app-green-design.md @@ -235,11 +235,11 @@ last data, marked stale), and expired AWS SSO (header names the command: ## Build order -0. **Spike (about a day).** A throwaway command in app-green that, for one - ticket, lists its merged PRs, finds each environment's deployed SHA from - action executions, and runs the compare. Check it on a squash merge and a - rollback. Record the response shapes for synthetic fixtures. Stop and - revise this design if the linking does not hold. +0. **Spike. Done 2026-10-06: the linking holds.** See + `2026-10-06-linking-spike.md`. It adds: exact SHA match before compare, + stacked PRs linked through the PR that reached `main`, and approval waits + as a common stage. Rollbacks had no real cases; synthetic fixtures cover + them. 1. Model, `link` and `rules` with table tests. 2. Adapters and `resolver`, read-only (Jira copy, new GitHub query, AWS action executions, ECS health). diff --git a/docs/plans/2026-10-06-linking-spike.md b/docs/plans/2026-10-06-linking-spike.md new file mode 100644 index 0000000..fd47052 --- /dev/null +++ b/docs/plans/2026-10-06-linking-spike.md @@ -0,0 +1,51 @@ +# Linking spike (build step 0) + +Date: 2026-10-06 +Result: **the linking holds.** Build can proceed with the rules below. + +Run by hand with `gh api` (GraphQL and REST) and the AWS CLI against the real +work repo and both accounts' pipelines, read-only. Identifiers are left out on +purpose (fixtures are synthetic only). + +## What was checked + +| Question | Result | +|---|---| +| Ticket → PR by key | Every recent merged PR has the key in both title and branch (`ABC-2099: ...`, `abc-2099-...`) | +| Merge style | Squash, merge and rebase are all allowed. Each merged PR has a distinct `mergeCommit.oid` | +| Does the pipeline record the deployed commit? | Yes. `ListPipelineExecutions.sourceRevisions[actionName=AppCode].revisionId` is the PR's merge commit, exactly | +| One run per merge? | Yes, in both accounts. Each merge to `main` starts a V2 execution in each account at the same moment | +| Stage history | `ListActionExecutions` gives Test/Deploy, Test/ManualApprovalOfTestEnvironment and Production/deploy per execution, with status and `lastUpdateTime` | +| Compare, included | `compare/...` → `ahead` when the PR is in the deploy | +| Compare, not yet | Reversed pair → `behind` | +| Superseded execution | Its merge commit compares `ahead` against the next succeeded execution's commit, so it still resolves as deployed | +| Other sources | Infra-only changes start runs that redeploy the same AppCode SHA. Harmless: the SHA is already linked | +| Rollbacks | None in the last 100 executions of either account. Covered by synthetic fixtures only | +| Failed / cancelled runs | Present (mostly in stage-acct). Need a red flag only when the failed run carries a commit from a live Chain | + +## Rules this adds to the design + +1. **Exact match first, compare second.** Look for the PR's merge SHA in the + recent action history of each stage. Only when it is not there (superseded + or batched) call compare against that stage's latest succeeded SHA. Most + rows then need no compare call at all. +2. **Count only PRs merged into the deploying branch.** Stacked PRs merge into + each other's branches and can share one merge SHA. A PR whose base is not + `main` is linked through the PR that finally merged the stack into `main` + (follow `baseRefName` down the stack), or shown under its ticket as + "merged into stack, waiting for `main`". +3. **Approval waits are visible in the timing.** Test/Deploy finishing and the + ManualApproval action still InProgress is the "awaiting approval" state. + The gap between Test/Deploy and Production/deploy was 30 to 60 minutes + today, so "Awaiting prod" is a real, common stage worth its own row state. +4. **Both accounts move together.** stage-acct and prod-acct track the same `main` + and ran the same merges today. The detail screen shows both; the main row + shows the furthest stage reached, as designed. + +## Not answered by the spike + +- Rollback behavior (no real cases). Synthetic fixture: a Production execution + whose revision is older than the previous one; rows already past that SHA + must drop back a stage. +- Repos other than app (the other repos). Same pipeline pattern + is expected from the aws-green config but not checked here. From 8dc693ce9064cc245bd0582d1d37a7ae748093d5 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 12:58:37 -0400 Subject: [PATCH 3/7] docs: app-green v1 implementation plan 20 tasks in 5 phases: pure core (model, link, rules) with full code and tests, then adapters and resolver, read-only screens, actions, release. Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-v1.md | 1699 +++++++++++++++++++++++++ 1 file changed, 1699 insertions(+) create mode 100644 docs/plans/2026-10-06-app-green-v1.md diff --git a/docs/plans/2026-10-06-app-green-v1.md b/docs/plans/2026-10-06-app-green-v1.md new file mode 100644 index 0000000..53aff62 --- /dev/null +++ b/docs/plans/2026-10-06-app-green-v1.md @@ -0,0 +1,1699 @@ +# app-green v1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** A terminal app that shows one row per Jira ticket, traces its work through GitHub PRs to the AWS pipeline stages running it, and flags the rows that need action. + +**Architecture:** Three adapters (Jira, GitHub, AWS) feed a `resolver`, which does all I/O and caches compare results. Two pure packages, `link` (snapshots → chains) and `rules` (chain → stage and flags), hold the logic and carry most of the tests. A Bubble Tea UI renders a ticket list and a detail screen. Read `docs/plans/2026-10-06-app-green-design.md` and `docs/plans/2026-10-06-linking-spike.md` before starting. + +**Tech Stack:** Go 1.25, Bubble Tea v1.3 / Bubbles / Lip Gloss (same versions as jira-green), aws-sdk-go-v2 (codepipeline, ecs, config), net/http for Jira and GitHub (no go-github), BurntSushi/toml. + +--- + +## Conventions for every task + +- Module path: `github.com/ericdahl-dev/app-green`. Packages live under `internal/`. +- TDD: write the failing test, run it, implement, run it, commit. Run `go test ./...` and `go vet ./...` before each commit. +- Commits use conventional prefixes and end with the trailer + `Assisted-by: Claude Code:`. Never add a `Co-Authored-By: Claude` line. +- The org requires PRs to `main` and an American-spelling check. Work on a branch, open a PR per phase. +- Test fixtures are **synthetic**: invented repo names (`acme/app`), ticket keys (`ABC-1`), SHAs (`aaaa111`), account names (`stage-acct`, `prod-acct`). No day-job identifiers in the repo. +- Copied code keeps a one-line header comment naming its source file in the sibling repo. + +## File map (end state) + +``` +main.go +internal/model/model.go types shared by every package +internal/link/keys.go Jira keys in PR titles/branches +internal/link/link.go tickets + PRs → chains, stacks, unlinked +internal/link/slots.go PR merge SHAs + env history → env slots +internal/rules/stage.go chain → stage +internal/rules/flags.go chain → ordered flags +internal/rules/order.go row sorting and fading +internal/jira/ copied + trimmed from jira-green +internal/github/ new GraphQL client, compare, rerun +internal/aws/ pipeline discovery, stage history, ECS health, approval +internal/resolver/ polling, compare cache, snapshots +internal/config/ config.toml load + validate +internal/ui/ list, detail, confirm dialog, header +``` + +--- + +# Phase 1: core model and logic (design build step 1) + +Branch: `feat/core`. No network code in this phase. + +### Task 1: Scaffold the module + +**Files:** +- Create: `go.mod`, `main.go`, `.gitignore` + +**Step 1:** Initialize. + +```bash +cd ~/Documents/GitHub/ericdahl.dev/app-green +git switch main && git pull && git switch -c feat/core +go mod init github.com/ericdahl-dev/app-green +go mod edit -go=1.25.0 -toolchain=go1.25.10 +``` + +**Step 2:** Write `main.go`: + +```go +package main + +import "fmt" + +func main() { fmt.Println("app-green") } +``` + +`.gitignore`: + +``` +/app-green +/dist/ +``` + +**Step 3:** Run `go build ./... && go vet ./...`. Expected: no output. + +**Step 4:** Commit: `chore: scaffold app-green module`. + +### Task 2: Model types + +**Files:** +- Create: `internal/model/model.go` +- Test: `internal/model/model_test.go` + +**Step 1: Write the failing test** + +```go +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") + } +} +``` + +**Step 2:** Run `go test ./internal/model/`. Expected: FAIL, package does not exist. + +**Step 3: Implement** + +```go +// 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 +``` + +**Step 4:** Run `go test ./internal/model/`. Expected: PASS. + +**Step 5:** Commit: `feat(model): shared types for tickets, PRs, envs and chains`. + +### Task 3: Find ticket keys in PRs + +**Files:** +- Create: `internal/link/keys.go` +- Test: `internal/link/keys_test.go` + +**Step 1: Write the failing test** + +```go +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) + } + } +} +``` + +**Step 2:** Run `go test ./internal/link/`. Expected: FAIL, undefined `link.KeysIn`. + +**Step 3: Implement** + +```go +// 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 +} +``` + +**Step 4:** Run `go test ./internal/link/`. Expected: PASS. + +**Step 5:** Commit: `feat(link): find ticket keys in PR titles and branches`. + +### Task 4: Link tickets to PRs, with stacks and unlinked PRs + +**Files:** +- Create: `internal/link/link.go` +- Test: `internal/link/link_test.go` + +**Step 1: Write the failing tests** + +```go +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) + } +} +``` + +**Step 2:** Run `go test ./internal/link/`. Expected: FAIL, undefined `link.Link`. + +**Step 3: Implement** + +```go +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 +} +``` + +**Step 4:** Run `go test ./internal/link/`. Expected: PASS. + +**Step 5:** Commit: `feat(link): link PRs to tickets, follow stacks to the default branch`. + +### Task 5: Resolve env slots + +**Files:** +- Create: `internal/link/slots.go` +- Test: `internal/link/slots_test.go` + +Rules (from the spike): exact SHA match in the Env's deploy history first; compare against the newest succeeded deploy second. A chain is deployed in an Env only when every merged PR is. Open PRs do not count against a slot. + +**Step 1: Write the failing tests** + +```go +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) + } +} +``` + +**Step 2:** Run `go test ./internal/link/`. Expected: FAIL, undefined `link.Slot`. + +**Step 3: Implement** + +```go +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. + for i := range h.Deploys { + d := &h.Deploys[i] + if d.Status != model.DeploySucceeded { + 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 +} +``` + +**Step 4:** Run `go test ./internal/link/`. Expected: PASS. If `TestSlotAwaitingApproval` fails, check that the exact-match loop keeps the newest non-success (`pending`) and that the compare branch falls through to it on `NotIncluded`. + +**Step 5:** Add `Slots`, which builds one slot per Env for a chain, plus its test: + +```go +// 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 +} +``` + +```go +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) + } +} +``` + +Run `go test ./internal/link/`. Expected: PASS. + +**Step 6:** Commit: `feat(link): resolve env slots, exact match before compare`. + +### Task 6: Stage + +**Files:** +- Create: `internal/rules/stage.go` +- Test: `internal/rules/stage_test.go` + +Rules: furthest stage reached in any account, but the least advanced PR decides among PR states (design: Stages). + +**Step 1: Write the failing test** + +```go +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 + }{ + {"no PRs", model.Chain{}, model.StageStarted}, + {"open PR", model.Chain{PRs: []model.PR{open}}, model.StagePROpen}, + {"one open one merged", model.Chain{PRs: []model.PR{merged, open}}, model.StagePROpen}, + {"merged, nowhere yet", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotNotYet), slot(prodEnv, model.SlotNotYet)}}, model.StageMerged}, + {"in test", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotNotYet)}}, model.StageInTest}, + {"awaiting prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotAwaitingApproval)}}, model.StageAwaitingProd}, + {"in prod", model.Chain{PRs: []model.PR{merged}, + Slots: []model.EnvSlot{slot(testEnv, model.SlotDeployed), slot(prodEnv, model.SlotDeployed)}}, model.StageInProd}, + {"stack pending counts as merged", model.Chain{PRs: []model.PR{{State: model.PRMerged, StackPending: true}}}, 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) + } + } +} +``` + +**Step 2:** Run `go test ./internal/rules/`. Expected: FAIL. + +**Step 3: Implement** + +```go +// 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. +func Stage(c model.Chain) model.Stage { + if len(c.PRs) == 0 { + return model.StageStarted + } + for _, p := range c.PRs { + if p.State == model.PROpen { + return model.StagePROpen + } + } + 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 +} +``` + +**Step 4:** Run `go test ./internal/rules/`. Expected: PASS. + +**Step 5:** Commit: `feat(rules): chain stage`. + +### Task 7: Flags + +**Files:** +- Create: `internal/rules/flags.go` +- Test: `internal/rules/flags_test.go` + +**Step 1: Write the failing tests** (one table, one row per flag rule in the design, plus ordering) + +```go +package rules_test + +import ( + "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 + }{ + {"clean", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPending, Reviewers: 1, OpenedAt: now}}}, nil}, + {"check failed", model.Chain{PRs: []model.PR{failing}}, []model.FlagKind{model.FlagCheckFailed}}, + {"code scanning", model.Chain{PRs: []model.PR{scanning}}, []model.FlagKind{model.FlagCheckFailed}}, + {"changes requested", model.Chain{PRs: []model.PR{{State: model.PROpen, Review: model.ReviewChangesRequested, Reviewers: 1}}}, + []model.FlagKind{model.FlagChangesRequested}}, + {"ready to merge", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagReadyToMerge}}, + {"no reviewer", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, OpenedAt: now}}}, + []model.FlagKind{model.FlagStaleReview}}, + {"stale review", 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}}, + {"pipeline failed", model.Chain{Slots: []model.EnvSlot{{Env: testEnv, State: model.SlotFailed}}}, []model.FlagKind{model.FlagPipelineFailed}}, + {"unhealthy after deploy", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, Health: model.Health{Known: true, Desired: 2, Healthy: 1}}}}, + []model.FlagKind{model.FlagUnhealthy}}, + {"awaiting approval", model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotAwaitingApproval}}}, []model.FlagKind{model.FlagAwaitingApproval}}, + {"in progress but merged", 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 grace", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-time.Hour)}, Stage: model.StageInTest}, nil}, + {"done but not in prod, past grace", model.Chain{Ticket: model.Ticket{StatusCategory: "Done", StatusSince: now.Add(-3 * time.Hour)}, Stage: model.StageInTest}, + []model.FlagKind{model.FlagStatusMismatch}}, + {"ordered red before yellow", model.Chain{PRs: []model.PR{failing}, + Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotAwaitingApproval}}}, + []model.FlagKind{model.FlagCheckFailed, model.FlagAwaitingApproval}}, + } + for _, c := range cases { + got := kinds(rules.Flags(c.c, now, th)) + if len(got) != len(c.want) { + t.Errorf("%s: flags %v, want %v", c.name, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("%s: flags %v, want %v", c.name, got, c.want) + } + } + } +} + +func TestFlagTargets(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 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]) + } +} +``` + +**Step 2:** Run `go test ./internal/rules/`. Expected: FAIL. + +**Step 3: Implement** + +```go +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 +} +``` + +**Step 4:** Run `go test ./internal/rules/`. Expected: PASS. + +**Step 5:** Commit: `feat(rules): needs-action flags in fixed order`. + +### Task 8: Evaluate, sort and fade rows + +**Files:** +- Create: `internal/rules/order.go` +- Test: `internal/rules/order_test.go` + +**Step 1: Write the failing test** + +```go +func TestEvaluateSortsAndFades(t *testing.T) { + th := rules.Thresholds{StaleReview: 48 * time.Hour, FadeAfter: 24 * time.Hour} + inProdOld := model.Chain{Ticket: model.Ticket{Key: "ABC-1"}, PRs: []model.PR{{State: model.PRMerged, EffectiveSHA: "a"}}, + Slots: []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, At: now.Add(-30 * time.Hour)}}} + inProdNew := inProdOld + inProdNew.Ticket.Key, inProdNew.Slots = "ABC-2", []model.EnvSlot{{Env: prodEnv, State: model.SlotDeployed, At: 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 := rules.Evaluate([]model.Chain{started, inProdOld, inProdNew, red}, now, th) + var keys []string + for _, c := range got { + keys = append(keys, c.Ticket.Key) + } + want := []string{"ABC-3", "ABC-2", "ABC-4"} // red first; then furthest stage; ABC-1 faded + if fmt.Sprint(keys) != fmt.Sprint(want) { + t.Errorf("order %v, want %v", keys, want) + } +} +``` + +(Add `"fmt"` to the test imports.) + +**Step 2:** Run. Expected: FAIL. + +**Step 3: Implement** (add `FadeAfter time.Duration` to `Thresholds`) + +```go +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 +} +``` + +**Step 4:** Run `go test ./...`. Expected: PASS. + +**Step 5:** Commit: `feat(rules): evaluate, sort and fade rows`. Open PR `feat/core` → `main`. + +--- + +# Phase 2: adapters and resolver, read-only (design build step 2) + +Branch: `feat/adapters`. Each adapter converts its API into `model` types and exposes a small interface so the resolver can be tested with fakes. + +### Task 9: Jira adapter (copy and trim) + +**Files:** +- Create: `internal/jira/{client.go,errors.go,time.go,search.go,transitions.go}` copied from `../jira-green/internal/jira/` +- Create: `internal/jira/tickets.go` +- Test: `internal/jira/tickets_test.go`, `internal/jira/testdata/search.json` (synthetic) + +**Step 1:** Copy the files, add the header comment `// Copied from jira-green internal/jira/ (2026-10-06).` to each, and fix imports to `github.com/ericdahl-dev/app-green/internal/jira`. + +**Step 2:** Trim. jira-green's `Search` returns `[]model.Issue` from jira-green's model. Replace that with a local unexported `issue` struct holding only what app-green needs: `Key, Summary, StatusName, StatusCategory, Updated, StatusCategoryChangedDate`. Delete comments, changelog, board, epic, ADF and parent code. Keep `do()`, `APIError`, `IsAuth`, 429 handling, pagination, `Transitions`, `DoTransition`, `BrowseURL`, `Myself`. + +Request these fields in the search call: `summary,status,updated,statuscategorychangedate`. The status object includes `statusCategory.name`. + +**Step 3: Write the failing test** for the public entry point: + +```go +func TestMyTickets(t *testing.T) { + var gotJQL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct{ JQL string `json:"jql"` } + _ = json.NewDecoder(r.Body).Decode(&body) + gotJQL = body.JQL + http.ServeFile(w, r, "testdata/search.json") + })) + defer srv.Close() + c := jira.New(srv.URL, "me@example.com", "tok") + ts, err := c.MyTickets(context.Background(), []string{"ABC"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(gotJQL, "assignee = currentUser()") || !strings.Contains(gotJQL, "statusCategoryChangedDate >= -14d") { + t.Errorf("jql = %s", gotJQL) + } + if len(ts) != 2 || ts[0].Key != "ABC-1" || ts[0].StatusCategory != "In Progress" || ts[0].URL != srv.URL+"/browse/ABC-1" { + t.Errorf("tickets = %+v", ts) + } +} +``` + +`testdata/search.json`: a synthetic `/rest/api/3/search/jql` response with two issues (ABC-1 In Progress, ABC-2 Done with `statuscategorychangedate`). Copy the shape from jira-green's `internal/jira/testdata`, replacing every value. + +**Step 4: Implement** `tickets.go`: + +```go +// MyTickets returns my tickets in projects that are not To Do: everything in +// progress, plus Done tickets that finished in the last 14 days (their +// changes may still be on the way to prod). +func (c *Client) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) { + jql := fmt.Sprintf(`assignee = currentUser() AND project in (%s) AND `+ + `(statusCategory = "In Progress" OR (statusCategory = Done AND statusCategoryChangedDate >= -14d)) `+ + `ORDER BY updated DESC`, strings.Join(projects, ",")) + issues, err := c.search(ctx, jql) + if err != nil { + return nil, err + } + out := make([]model.Ticket, len(issues)) + for i, is := range issues { + out[i] = model.Ticket{Key: is.Key, Title: is.Summary, Status: is.StatusName, + StatusCategory: is.StatusCategory, URL: c.BrowseURL(is.Key), Updated: is.Updated, + StatusSince: is.StatusCategoryChanged} + } + return out, nil +} +``` + +Tickets named on my open PRs but not assigned to me are fetched separately in the resolver with `key in (...)` (Task 13); add `TicketsByKey(ctx, keys)` using the same `search` with `key in (ABC-1,ABC-2)` and a test for its JQL. + +**Step 5:** Run `go test ./internal/jira/`. Expected: PASS. Commit: `feat(jira): adapter copied from jira-green, trimmed to tickets and transitions`. + +### Task 10: GitHub adapter + +**Files:** +- Create: `internal/github/client.go`, `internal/github/prs.go`, `internal/github/compare.go`, `internal/github/rerun.go` +- Create: `internal/github/etag.go` (copied from `../git-green/internal/github/etag.go`, transport only; drop `Pool`) +- Test: `prs_test.go`, `compare_test.go`, `testdata/prs.json` (synthetic) + +**Step 1:** `client.go`: a `Client{http *http.Client; token string; api string}` with `New(token string)` wrapping `newETagTransport(http.DefaultTransport, 500)`, and a `do(ctx, method, path, body, out)` that sets `Authorization: Bearer`, decodes JSON, and returns a typed `*APIError{Status int; Message string}`. `api` defaults to `https://api.github.com` and is overridable for tests. + +**Step 2: Write the failing PR test.** The fixture is a GraphQL response for this query (put the query in `prs.go` as a const): + +```graphql +query($owner:String!,$name:String!,$after:String){ + repository(owner:$owner,name:$name){ + defaultBranchRef{name} + pullRequests(first:50,after:$after,states:[OPEN,MERGED],orderBy:{field:UPDATED_AT,direction:DESC}){ + pageInfo{hasNextPage endCursor} + nodes{ + number title url headRefName baseRefName state createdAt mergedAt + author{login} + mergeCommit{oid} + reviewDecision + reviewRequests{totalCount} + reviews{totalCount} + commits(last:1){nodes{commit{statusCheckRollup{state + contexts(first:50){nodes{ + __typename + ... on CheckRun{name conclusion detailsUrl checkSuite{workflowRun{databaseId}} app{slug}} + ... on StatusContext{context state targetUrl} + }}}}}} + } + } + } +} +``` + +```go +func TestRecentPRs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/prs.json") + })) + defer srv.Close() + c := github.New("tok") + c.SetAPI(srv.URL) + prs, err := c.RecentPRs(context.Background(), "acme", "app", "me", t0.Add(-30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + // fixture: #3 open by me failing CodeQL (code scanning) + rspec (Actions run 77), + // #2 merged by me, #1 open by someone else (dropped). + if len(prs) != 2 { + t.Fatalf("prs = %+v", prs) + } + p := prs[0] + if p.Number != 3 || p.Checks != model.ChecksFailing || len(p.Failing) != 2 || p.DefaultBranch != "main" { + t.Errorf("#3 = %+v", p) + } + if !p.Failing[0].CodeScanning || p.Failing[1].RunID != 77 { + t.Errorf("failing = %+v", p.Failing) + } + if prs[1].State != model.PRMerged || prs[1].MergeSHA == "" { + t.Errorf("#2 = %+v", prs[1]) + } +} +``` + +**Step 3: Implement** `RecentPRs(ctx, owner, name, author string, since time.Time) ([]model.PR, error)`: POST the query to `/graphql`, page while `hasNextPage` and the last node's `createdAt` is after `since`, keep only `author.login == author`, map fields: + +- `Checks` from `statusCheckRollup.state` (`SUCCESS`, `FAILURE`/`ERROR` → failing, `PENDING`/`EXPECTED` → pending). +- `Failing`: CheckRuns with conclusion `FAILURE`, `TIMED_OUT`, `CANCELLED`, or `ACTION_REQUIRED`, plus StatusContexts with state `FAILURE` or `ERROR`. `CodeScanning` is true when the check's `app.slug` is `github-code-scanning` or its name starts with "Code scanning". `RunID` from `checkSuite.workflowRun.databaseId`. +- `Reviewers = reviewRequests.totalCount + reviews.totalCount`; `Review` from `reviewDecision`. +- `MergeSHA` only when `state == MERGED`. + +**Step 4: Compare.** Test first: a server returning `{"status":"ahead"}`, `{"status":"identical"}`, `{"status":"behind"}`, `{"status":"diverged"}`, and a 404. Expect `Included, Included, NotIncluded, NotIncluded, InclusionUnknown` from `c.Compare(ctx, "acme/app", "aaaa", "bbbb")`. GET `/repos/{repo}/compare/{base}...{head}?per_page=1`. Return `(model.Inclusion, error)`; a 404 returns `InclusionUnknown, nil`; other errors return the error. + +**Step 5: Rerun.** `RerunFailedJobs(ctx, repo string, runID int64) error` → POST `/repos/{repo}/actions/runs/{runID}/rerun-failed-jobs`. Port the logic from `../git-green/internal/github/client.go:406`. Test with httptest that the path and method are right and a 201 returns nil. + +**Step 6:** Run `go test ./internal/github/`. Expected: PASS. Commit: `feat(github): recent PRs via GraphQL, compare, rerun`. + +### Task 11: AWS adapter + +**Files:** +- Create: `internal/aws/client.go`, `internal/aws/discover.go`, `internal/aws/history.go`, `internal/aws/health.go`, `internal/aws/approve.go` +- Test: one `_test.go` per file, using a fake of the SDK interface + +**Step 1:** Add dependencies (same versions as aws-green): + +```bash +go get github.com/aws/aws-sdk-go-v2@v1.41.7 github.com/aws/aws-sdk-go-v2/config@v1.29.14 \ + github.com/aws/aws-sdk-go-v2/service/codepipeline@v1.40.1 github.com/aws/aws-sdk-go-v2/service/ecs@v1.79.1 +``` + +**Step 2:** `client.go`: define the narrow interfaces the adapter uses, so tests pass fakes: + +```go +type pipelineAPI interface { + GetPipeline(context.Context, *codepipeline.GetPipelineInput, ...func(*codepipeline.Options)) (*codepipeline.GetPipelineOutput, error) + ListPipelineExecutions(context.Context, *codepipeline.ListPipelineExecutionsInput, ...func(*codepipeline.Options)) (*codepipeline.ListPipelineExecutionsOutput, error) + ListActionExecutions(context.Context, *codepipeline.ListActionExecutionsInput, ...func(*codepipeline.Options)) (*codepipeline.ListActionExecutionsOutput, error) + GetPipelineState(context.Context, *codepipeline.GetPipelineStateInput, ...func(*codepipeline.Options)) (*codepipeline.GetPipelineStateOutput, error) + PutApprovalResult(context.Context, *codepipeline.PutApprovalResultInput, ...func(*codepipeline.Options)) (*codepipeline.PutApprovalResultOutput, error) +} + +type ecsAPI interface { + DescribeServices(context.Context, *ecs.DescribeServicesInput, ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) +} + +// Client talks to one account (one SSO profile). +type Client struct { + Account string + cp pipelineAPI + ecs ecsAPI +} + +func New(ctx context.Context, account, profile, region string) (*Client, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(profile), config.WithRegion(region)) + if err != nil { + return nil, err + } + return &Client{Account: account, cp: codepipeline.NewFromConfig(cfg), ecs: ecs.NewFromConfig(cfg)}, nil +} +``` + +Add `IsSSOExpired(err error) bool`, matching the SDK's SSO token errors (`*ssocreds.InvalidTokenError` and messages containing "the SSO session has expired" or "token has expired"), with a test over sample errors. + +**Step 3: Discover sources.** Test first: a fake `GetPipeline` returning a Source stage with three `CodeStarSourceConnection` actions whose configuration has `FullRepositoryId` `acme/app`, `acme/infra`, `acme/reports`. Expect `Sources(ctx, pipeline)` to return `map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"}` (repo → source action name). Implement by reading `Stages[].Actions[]` where `ActionTypeId.Category == Source` and `Configuration["FullRepositoryId"]`. + +**Step 4: Stage history.** Test first with fakes: +- `ListPipelineExecutions` returns three executions with `SourceRevisions` (actionName + revisionId). +- `ListActionExecutions` returns, for stage "Production", action "deploy": exec3 InProgress, exec2 Succeeded, exec1 Failed; and for stage "Test", action "ManualApprovalOfTestEnvironment": exec3 InProgress (approval pending). +- `GetPipelineState` returns the approval action's `LatestExecution.Token` for exec3. + +Expect `History(ctx, pipeline, stage, deployAction, sources)` for Production to return `[]model.Deploy` newest first: exec3 `AwaitingApproval` with the token and revisions mapped to repo names via `sources`; exec2 `Succeeded`; exec1 `Failed`. For the Test stage, exec3's deploy has succeeded, so Test shows it `Succeeded`. + +Implement: +1. `ListPipelineExecutions` (max 50) → `map[execID]map[repo]sha` using `sources` (invert to action → repo). +2. `ListActionExecutions` filtered by `PipelineExecutionId` is per execution; instead call it once with `MaxResults: 100` and filter client-side by `StageName` and `ActionName`. Map status: Succeeded, Failed/Abandoned → Failed, InProgress → InProgress. +3. Approval: an execution whose deploy action is not yet run in the Production stage but whose Test approval action is InProgress becomes a Production deploy with `AwaitingApproval`. Take the token from `GetPipelineState` (only the current pending approval has one). The approval action name and its stage come from config (`approval_action`, default `ManualApprovalOfTestEnvironment` in the Test stage). +4. Sort by `LastUpdateTime` descending; set `FinishedAt` from it. + +**Step 5: Health.** Test first: fake `DescribeServices` returning desired 2, running 1 → `model.Health{Known: true, Desired: 2, Healthy: 1}`. Services come from config per env (cluster + service names, copied from the aws-green config shape). Several services: sum desired and running. A missing service → `Known: false`. + +**Step 6: Approve.** `Approve(ctx, pipeline, stage, action, token string, ok bool, summary string) error` → `PutApprovalResult` with `Status` Approved/Rejected. Test that the fake receives the right input. Port wording and summary handling from `../aws-green/internal/fix` (`PlanApproval`). + +**Step 7:** Run `go test ./internal/aws/`. Expected: PASS. Commit: `feat(aws): pipeline sources, stage history with approvals, ECS health, approve`. + +### Task 12: Config + +**Files:** +- Create: `internal/config/config.go` +- Test: `internal/config/config_test.go`, `internal/config/testdata/ok.toml`, `bad-*.toml` + +**Step 1:** Add `github.com/BurntSushi/toml` (the version in jira-green's go.mod). + +**Step 2: Write the failing test** loading `testdata/ok.toml`: + +```toml +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ManualApprovalOfTestEnvironment" + ecs = [{ cluster = "c1", services = ["s1"] }] + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + prod = true +``` + +Assert: durations parse; envs get `Order` 0,1 in file order; `ReadOnly` copies from the account; `Load` of each `bad-*.toml` returns an error naming the field (unknown account, no envs, no projects, unknown stage order). + +**Step 3: Implement** `Load(path) (*Config, error)`, `DefaultPath()` (`$XDG_CONFIG_HOME/app-green/config.toml`, else `~/.config/app-green/config.toml`), `Envs() []model.Env`, `Thresholds() rules.Thresholds`, and token resolution: `token_env` reads the variable; `token_command` runs it with `sh -c` and trims output. Copy the style of `../jira-green/internal/config/config.go` (validation messages, `orDefault`). + +**Step 4:** Run tests, PASS. Commit: `feat(config): config.toml with accounts, envs and thresholds`. + +### Task 13: Resolver + +**Files:** +- Create: `internal/resolver/resolver.go`, `internal/resolver/cache.go` +- Test: `internal/resolver/resolver_test.go` + +The resolver is the only place that does I/O. It holds the adapters behind interfaces: + +```go +type Tracker interface { + MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) + TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, error) +} +type CodeHost interface { + RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, error) + Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) +} +type Deployer interface { + History(ctx context.Context, env model.Env) (model.EnvHistory, error) // sources + history + health +} + +type AdapterStatus struct { + Name string // "jira", "github", "aws prod-acct" + OK bool + At time.Time // last success + Err string // short, for the header + SSO bool // the error is an expired SSO session +} + +type Snapshot struct { + Chains []model.Chain // evaluated and sorted + Unlinked []model.PR + Status []AdapterStatus + At time.Time +} +``` + +(Add a thin `aws.EnvSource` that implements `Deployer` over the per-account clients, caching `Sources` per pipeline for the session.) + +**Step 1: Write the failing tests** with fakes: + +1. `TestPollBuildsChains`: tracker returns ABC-1; code host returns a merged PR "ABC-1: x" with sha `aaaa`; deployer returns Test history with an exact `aaaa` success. Expect one chain at `StageInTest` and zero compare calls. +2. `TestCompareCached`: history's newest success is `cccc`; compare returns Included. Poll twice. Expect exactly one compare call. +3. `TestUnknownNotCached`: compare returns `InclusionUnknown`. Poll twice. Expect two compare calls. +4. `TestAdapterFailureKeepsLastGood`: second poll the tracker errors. Expect the snapshot to keep ABC-1, `Status["jira"].OK == false` with the error text, the others OK. +5. `TestTicketsFromOpenPRs`: a PR names ABC-9, which `MyTickets` did not return. Expect `TicketsByKey(["ABC-9"])` to be called and ABC-9 to get a row. + +**Step 2:** Run, FAIL. + +**Step 3: Implement** + +- `cache.go`: `type compareCache struct{ mu sync.Mutex; m map[[3]string]model.Inclusion }`; `get`/`put`; only `Included` and `NotIncluded` are stored. `NotIncluded` is still re-checked when the head changes, which is automatic because the head is part of the key. +- `resolver.go`: `New(cfg, tracker, host, deployer, now func() time.Time)`; `Poll(ctx) Snapshot` does, in order: tickets (MyTickets), PRs per repo (since 30 days ago), extra tickets from PR keys, histories per env, then `link.Link`, `link.Slots` per chain with a `CompareFunc` that consults the cache and calls `host.Compare` on a miss, then `rules.Evaluate`. Each source failure keeps that source's previous result and records the error in `Status`. Run the three source fetches concurrently with `errgroup`-style goroutines (no new dependency: a `sync.WaitGroup`). +- `Run(ctx, out chan<- Snapshot, refresh <-chan struct{})`: poll now, then every `poll_interval` (default 60s; AWS histories every 30s are not worth a second loop in v1, so poll everything at 30s if any env is in progress, else 60s). A 429 from GitHub or Jira backs off to its `Retry-After`. + +**Step 4:** Run `go test ./internal/resolver/ -race`. Expected: PASS. + +**Step 5:** Commit: `feat(resolver): polling, compare cache, snapshots`. Open PR `feat/adapters` → `main`. + +--- + +# Phase 3: screens, read-only (design build step 3) + +Branch: `feat/ui`. Copy Bubble Tea/Bubbles/Lip Gloss versions from jira-green's go.mod. + +### Task 14: Header and row rendering + +**Files:** +- Create: `internal/ui/styles.go`, `internal/ui/row.go`, `internal/ui/header.go` +- Test: `internal/ui/row_test.go`, `internal/ui/header_test.go` + +**Step 1: Failing tests** render plain strings (set `lipgloss.SetColorProfile(termenv.Ascii)` in `TestMain`) and assert exact output at width 100: + +- `RenderRow(chain, width)` for a red chain with flag "PR #3 rspec failing" → + `"● ABC-1 Short title PR #3 rspec failing f"` (marker, key padded to the longest key, title truncated with "…", stage column, reason, key hint). +- Yellow uses `◐`, none uses a space. Stage column text: `started`, `PR #n`, `merged`, `test ✓`, `prod ⏸`, `in prod ✓ 3h ago`. +- Hint column: `f` for a re-runnable failed check with one target, `o` for code scanning, `a/x` for one awaiting approval, `t` for status mismatch, blank otherwise. +- `RenderHeader(statuses, now, width)`: `"app-green jira ✓ 12s github ✓ 8s aws stage-acct ✓ aws prod-acct ✗ sso expired"`; an SSO failure renders `aws ✗ sso expired`. +- `NO_COLOR` set → no ANSI codes in output (test with the default profile and the env var set). + +**Step 2:** Run, FAIL. **Step 3:** Implement with Lip Gloss; markers are single-width (`●`, `◐`), never emoji. **Step 4:** PASS. **Step 5:** Commit: `feat(ui): header and row rendering`. + +### Task 15: List screen + +**Files:** +- Create: `internal/ui/list.go`, `internal/ui/app.go` +- Test: `internal/ui/list_test.go` + +**Step 1: Failing tests** drive the Bubble Tea model directly (`m, _ = m.Update(msg)`): + +- A `resolver.Snapshot` message with 3 chains and 1 unlinked PR → `View()` contains the 3 rows in order, then `── Unlinked (1) ──` and the PR line. +- `down`/`j` and `up`/`k` move the cursor; the cursor does not move into the unlinked group header. +- First load (no snapshot yet) shows `loading…`; a snapshot with no chains shows `nothing in flight`. +- `r` sends on the refresh channel. `q` and `ctrl+c` return `tea.Quit`. +- `o` calls the injected `openURL` with the row's target URL (the flagged PR, else the ticket). +- Window resize re-renders at the new width. + +**Step 2–4:** Implement `App` (top model: holds the snapshot, cursor, width/height, `openURL func(string) error`, `refresh chan<- struct{}`), and a `waitForSnapshot` command like jira-green's `main.go:184`. **Step 5:** Commit: `feat(ui): ticket list screen`. + +### Task 16: Detail screen + +**Files:** +- Create: `internal/ui/detail.go` +- Test: `internal/ui/detail_test.go` + +**Step 1: Failing test:** `enter` on a row opens the detail view; its `View()` for a synthetic chain equals the design's detail layout: the ticket line, one line per PR, one line per env slot (`stage-acct Test ✓ deployed 1d ago (aaaa111) ECS 2/2`), then every flag reason. `esc` returns to the list with the cursor unchanged. `↑/↓` in detail moves between the PR and slot lines, and `o` opens the selected line's URL (PR URL; env slot opens the pipeline console URL `https://.console.aws.amazon.com/codesuite/codepipeline/pipelines//view`). + +**Step 2–4:** Implement. **Step 5:** Commit: `feat(ui): detail screen`. + +### Task 17: Wire main.go and check against real data + +**Files:** +- Modify: `main.go` +- Test: `main_test.go` (flag parsing and `no config` message only) + +**Step 1:** `main.go`: load config (`no config at ; see README` on missing file), resolve tokens, build the three adapters and the resolver, start `resolver.Run` in a goroutine, run the Bubble Tea program in the alt screen, cancel the resolver on quit. Log to `$XDG_STATE_HOME/app-green/app-green.log` (copy `logx` setup from jira-green; never log tokens). + +**Step 2:** Write `~/.config/app-green/config.toml` on the Mac for the real work setup (not committed). Run `go run .` and check by hand against the spike: today's merged PRs should show their real stage in both accounts; an open PR with failing CodeQL shows `● … o`; `r` refreshes; `o` opens the right page; signing out of SSO (`aws sso logout`) shows `sso expired` in the header within one poll. + +**Step 3:** Commit: `feat: wire config, adapters, resolver and UI`. Open PR `feat/ui` → `main`. app-green is now useful read-only. + +--- + +# Phase 4: actions (design build step 4) + +Branch: `feat/actions`. + +### Task 18: Confirm dialog and single-target rule + +**Files:** +- Create: `internal/ui/confirm.go`, `internal/ui/actions.go` +- Test: `internal/ui/confirm_test.go`, `internal/ui/actions_test.go` + +**Step 1: Failing tests:** + +- `f` on a row whose flags have exactly one re-runnable failed check opens a confirm dialog naming the repo, PR and check. `y` emits an `actionMsg`; `n` and `esc` close it with no action; **`enter` does nothing** in the dialog. +- `f` on a row with two failing PRs opens the detail screen instead of a dialog. +- `f` on a code-scanning-only failure shows the hint `code scanning results cannot be re-run; o to open` and opens no dialog. +- `a` on an awaiting-approval slot in a `ReadOnly` env shows `prod-acct uses a read-only profile; approve in the console (o)` and opens no dialog. +- The approve dialog body lists: ticket key and title, env (`account / pipeline / stage`), the SHA, other tickets whose chains share that SHA (from the snapshot), and the Test slot's health. + +**Step 2–4:** Implement. `targets(chain, kind) []target` is a pure helper with its own table test. **Step 5:** Commit: `feat(ui): confirm dialog and single-target actions`. + +### Task 19: Run the actions + +**Files:** +- Modify: `internal/ui/actions.go`, `main.go` +- Create: `internal/ui/transition.go` (status picker; port `../jira-green/internal/ui/transition.go`) +- Test: `internal/ui/actions_test.go` + +**Step 1: Failing tests** with injected action funcs (`rerun`, `approve`, `transitions`, `doTransition`) recording calls: + +- Confirming `f` calls `rerun("acme/app", 77)` and then triggers a refresh; an error shows in a flash line and the row is unchanged. +- Confirming `a` calls `approve(env, token, true, summary)`; `x` calls it with `false`. +- `t` loads `transitions(key)`, shows a picker; choosing one and pressing `y` calls `doTransition(key, id)`. + +**Step 2–4:** Implement; wire the real adapter methods in `main.go`. **Step 5:** Manual check against a real failing re-runnable check, if one exists; otherwise rely on the tests and say so in the PR. Commit: `feat: re-run, approve/reject and ticket status actions`. Open PR `feat/actions` → `main`. + +--- + +# Phase 5: release (design build step 5) + +Branch: `feat/release`. + +### Task 20: Build and install + +**Files:** +- Create: `.goreleaser.yaml` and `.github/workflows/release.yml`, copied from `../jira-green/` and renamed; `README.md` + +**Step 1:** Copy jira-green's release setup; change names to app-green; keep darwin/arm64 and linux/amd64; Homebrew cask in the same tap. + +**Step 2:** README: what it shows, config example (synthetic values), the credentials it reads, keys. + +**Step 3:** Tag `v0.1.0`, let the workflow publish. Install on the Mac (`brew install --cask app-green`) and on exo-mini (download the linux binary to `~/.local/bin`). Copy `~/.config/app-green/config.toml` to exo-mini with `scp`, mode 600 (exo-mini's AWS profiles were set up on 2026-10-06 and both work). + +**Step 4:** Run app-green on both machines and confirm the header shows all adapters OK. + +**Step 5:** Commit and PR: `chore: release setup and README`. + +--- + +## Out of scope for v1 + +Kanban view, mute/manage, notifications, webhooks, Coolify adapter (next), Azure adapter (issue #2), shared client library extraction (after Coolify). From 64d898e41447ed3c72fd57c73a6d3e0421c7c5ba Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:30:28 -0400 Subject: [PATCH 4/7] docs: record phase 1 decisions in design and plan All prod envs for in prod, running-now slots, rollback and partial prod flags, deploy-unknown, repos per env in config, timestamp fallbacks. Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-design.md | 53 +++++++++++++++-------- docs/plans/2026-10-06-app-green-v1.md | 7 ++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-10-06-app-green-design.md b/docs/plans/2026-10-06-app-green-design.md index 2ca4cd8..6008aa5 100644 --- a/docs/plans/2026-10-06-app-green-design.md +++ b/docs/plans/2026-10-06-app-green-design.md @@ -125,14 +125,22 @@ Environments are listed in order; pipeline sources are discovered. |---|---| | Started | Ticket In Progress, no PR | | PR open | At least one open PR | -| Merged | All PRs merged, not in any environment yet | -| In test | Merge commit deployed by a Test stage | -| Awaiting prod | In test, Production not yet run or waiting for approval | -| In prod | Merge commit deployed by a Production stage and healthy; fades after ~24h | - -With PRs in mixed states, the row shows the least advanced PR's stage. With -two accounts, the row shows the furthest stage reached in any account; the -detail screen shows all four environment slots. +| Merged | All PRs merged, not in any environment yet (or a PR is still in an unmerged stack) | +| In test | Merge commit running in a Test stage | +| Awaiting prod | Running in prod somewhere, or prod waiting for approval / in progress, but not yet in every prod environment | +| In prod | Running in **every** applicable Production environment; fades after ~24h with no flags | + +With PRs in mixed states, the row shows the least advanced PR's stage. Closed +PRs are ignored. "Running" means the environment's newest successful deploy +contains the change (exact commit, else GitHub compare); an older deploy that +has since been rolled back does not count. A prod deploy with an unhealthy +service is still "in prod", with a red flag. The detail screen shows every +environment slot. + +An environment applies to a chain when it deploys one of the chain's repos: +from the environment's `repos` list in config, or, when that is empty, from +its deploy history. A configured environment with no history for the repo +shows "deploy unknown", never a silent pass. ## Flags @@ -140,18 +148,29 @@ Within each color, flags have a fixed order (listed top to bottom below). A row shows its first flag; the detail screen lists all of them. Red, needs me now: -1. A pipeline stage failed or rolled back for a commit in this Chain -2. ECS unhealthy after this Chain's deploy (fewer healthy tasks than desired, crash loop) -3. A check failed on my PR (`f` re-run; code-scanning results are shown as an - alert with `o` open, since a re-run cannot fix them) -4. Changes requested on my PR +1. A pipeline stage failed for a commit in this Chain (a failure is superseded + by a newer run that contains the change) +2. Rolled back: the change was running, and the newest deploy no longer has it +3. ECS unhealthy after this Chain's deploy (fewer healthy tasks than desired, crash loop) +4. A check failed on my PR, including GitHub's ERROR and EXPECTED states (`f` + re-run; code-scanning results are shown as an alert with `o` open, since a + re-run cannot fix them) +5. Changes requested on my PR Yellow, waiting: 1. Pipeline paused for manual approval (`a` / `x`) -2. PR approved and green but not merged -3. In review with no reviewer, or no review for `stale_review_after` (default 2d) -4. Jira status disagrees with reality: In Progress but merged, or Done but not - in prod after a grace period (`t`) +2. Partial prod: running in some prod environments but not all for longer + than `partial_prod_after` (default 4h); targets the first missing one +3. PR approved and green (or with no checks) but not merged +4. In review with no reviewer, or no review for `stale_review_after` (default 2d) +5. Jira status disagrees with reality: To Do or In Progress but merged, or + Done but not in prod after a grace period (`t`). A Done ticket with no + status-change time uses its last-updated time +6. Deploy unknown: a compare call failed or the environment has no history + for the repo + +When one environment has both a failure and an unknown, the failure shows. +A prod deploy with no timestamp never fades. Unlinked PRs and deploys go in a pinned "Unlinked (n)" group at the bottom, never silently dropped. diff --git a/docs/plans/2026-10-06-app-green-v1.md b/docs/plans/2026-10-06-app-green-v1.md index 53aff62..9af5243 100644 --- a/docs/plans/2026-10-06-app-green-v1.md +++ b/docs/plans/2026-10-06-app-green-v1.md @@ -1235,6 +1235,8 @@ func compareKeys(a, b string) int { # Phase 2: adapters and resolver, read-only (design build step 2) +> **Phase 1 changed the model (2026-10-06).** Read `internal/model/model.go` and the doc comments in `internal/link/slots.go` and `internal/rules/*.go` before starting: they supersede the Phase 1 code above. New since the plan: `Env.Repos`, `EnvSlot.Applies`, `SlotRolledBack`, `FlagRolledBack`, `FlagPartialProd`, `FlagDeployUnknown`, `rules.Thresholds.PartialProd`. `model.Env` holds a slice, so compare Envs with `ID()`. Adapters must lowercase repo names (`PR.Repo`, `Deploy.Revisions` keys, `Env.Repos`) and return `Deploys` newest first. + Branch: `feat/adapters`. Each adapter converts its API into `model` types and exposes a small interface so the resolver can be tested with fakes. ### Task 9: Jira adapter (copy and trim) @@ -1467,6 +1469,7 @@ Implement: stale_review_after = "48h" done_grace = "2h" fade_after = "24h" + partial_prod_after = "4h" [jira] site = "https://example.atlassian.net" @@ -1497,6 +1500,7 @@ Implement: stage = "Test" deploy_action = "Deploy" approval_action = "ManualApprovalOfTestEnvironment" + repos = ["acme/app"] # repos this env deploys; decides EnvSlot.Applies ecs = [{ cluster = "c1", services = ["s1"] }] [[envs]] @@ -1505,9 +1509,10 @@ Implement: stage = "Production" deploy_action = "deploy" prod = true + repos = ["acme/app"] ``` -Assert: durations parse; envs get `Order` 0,1 in file order; `ReadOnly` copies from the account; `Load` of each `bad-*.toml` returns an error naming the field (unknown account, no envs, no projects, unknown stage order). +Assert: durations parse (including `partial_prod_after`, default 4h, into `rules.Thresholds.PartialProd`); envs get `Order` 0,1 in file order; `ReadOnly` copies from the account; `repos` are lowercased into `model.Env.Repos`; an env with no `repos` is allowed (history decides) but `app-green` logs a warning; the setup wizard later fills `repos` from the pipeline's discovered sources; `Load` of each `bad-*.toml` returns an error naming the field (unknown account, no envs, no projects, unknown stage order). **Step 3: Implement** `Load(path) (*Config, error)`, `DefaultPath()` (`$XDG_CONFIG_HOME/app-green/config.toml`, else `~/.config/app-green/config.toml`), `Envs() []model.Env`, `Thresholds() rules.Thresholds`, and token resolution: `token_env` reads the variable; `token_command` runs it with `sh -c` and trims output. Copy the style of `../jira-green/internal/config/config.go` (validation messages, `orDefault`). From 74269469ce620033a448f5d90b875d8437c6e779 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 14:59:16 -0400 Subject: [PATCH 5/7] docs: GitHub adapter fetches base-branch PRs; draft PR question Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-v1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/plans/2026-10-06-app-green-v1.md b/docs/plans/2026-10-06-app-green-v1.md index 9af5243..ba5125a 100644 --- a/docs/plans/2026-10-06-app-green-v1.md +++ b/docs/plans/2026-10-06-app-green-v1.md @@ -1380,6 +1380,11 @@ func TestRecentPRs(t *testing.T) { - `Failing`: CheckRuns with conclusion `FAILURE`, `TIMED_OUT`, `CANCELLED`, or `ACTION_REQUIRED`, plus StatusContexts with state `FAILURE` or `ERROR`. `CodeScanning` is true when the check's `app.slug` is `github-code-scanning` or its name starts with "Code scanning". `RunID` from `checkSuite.workflowRun.databaseId`. - `Reviewers = reviewRequests.totalCount + reviews.totalCount`; `Review` from `reviewDecision`. - `MergeSHA` only when `state == MERGED`. +- `Repo` lowercased (`nameWithOwner`), and `StatusCategory`-style values mapped from GitHub's raw enums. + +**Step 3b: Base-branch PRs (user decision 2026-10-06).** `link` marks a stacked PR `Stranded` when its base branch has no PR in the input, so the adapter must supply the PR for every base branch, whoever wrote it. After `RecentPRs`, collect base branches that are not the default branch and have no PR in the result; fetch them with `pullRequests(headRefName: $base, states: [OPEN, MERGED], first: 1)` per branch (batched as aliases in one GraphQL query). Include them in the PR list passed to `link.Link` (they link to no ticket unless they name one). Test with a fixture where #2 (mine) is based on `s1` and #1 on `s1` is someone else's merged PR: #1 must be returned. + +**Open question for the phase 2 checkpoint:** draft PRs. `model.PR` has no `IsDraft`, so drafts get the yellow "no reviewer" flag as soon as they open. Ask the user (multiple choice) whether drafts should skip review flags. **Step 4: Compare.** Test first: a server returning `{"status":"ahead"}`, `{"status":"identical"}`, `{"status":"behind"}`, `{"status":"diverged"}`, and a 404. Expect `Included, Included, NotIncluded, NotIncluded, InclusionUnknown` from `c.Compare(ctx, "acme/app", "aaaa", "bbbb")`. GET `/repos/{repo}/compare/{base}...{head}?per_page=1`. Return `(model.Inclusion, error)`; a 404 returns `InclusionUnknown, nil`; other errors return the error. From 16c43f99c53973ec3173e5f7df8e4ea60aea57bc Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:00:46 -0400 Subject: [PATCH 6/7] chore: allow GitHub's CANCELLED value in the spelling check Assisted-by: Claude Code:claude-opus-5-5 --- .spelling-dialect | 2 ++ docs/plans/2026-10-06-linking-spike.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .spelling-dialect diff --git a/.spelling-dialect b/.spelling-dialect new file mode 100644 index 0000000..f35d7bc --- /dev/null +++ b/.spelling-dialect @@ -0,0 +1,2 @@ +# GitHub API values (check conclusion CANCELLED) +cancelled diff --git a/docs/plans/2026-10-06-linking-spike.md b/docs/plans/2026-10-06-linking-spike.md index fd47052..a6ad24f 100644 --- a/docs/plans/2026-10-06-linking-spike.md +++ b/docs/plans/2026-10-06-linking-spike.md @@ -21,7 +21,7 @@ purpose (fixtures are synthetic only). | Superseded execution | Its merge commit compares `ahead` against the next succeeded execution's commit, so it still resolves as deployed | | Other sources | Infra-only changes start runs that redeploy the same AppCode SHA. Harmless: the SHA is already linked | | Rollbacks | None in the last 100 executions of either account. Covered by synthetic fixtures only | -| Failed / cancelled runs | Present (mostly in stage-acct). Need a red flag only when the failed run carries a commit from a live Chain | +| Failed / canceled runs | Present (mostly in stage-acct). Need a red flag only when the failed run carries a commit from a live Chain | ## Rules this adds to the design From c41d80c5a5aaf0076c929a9429f81de7f998836c Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:10:13 -0400 Subject: [PATCH 7/7] docs: neutral pipeline action names Assisted-by: Claude Code:claude-opus-5-5 --- docs/plans/2026-10-06-app-green-design.md | 6 +++--- docs/plans/2026-10-06-app-green-v1.md | 8 ++++---- docs/plans/2026-10-06-linking-spike.md | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-10-06-app-green-design.md b/docs/plans/2026-10-06-app-green-design.md index 6008aa5..3be2eba 100644 --- a/docs/plans/2026-10-06-app-green-design.md +++ b/docs/plans/2026-10-06-app-green-design.md @@ -34,10 +34,10 @@ Four reviewers (architecture, backend/API, UX, scope) checked the first draft against the sibling repos and live read-only AWS calls. Confirmed by hand: - Each work account runs one app pipeline with stages Source → - ClusterCreation → Test (Deploy, SmokeTests, ManualApprovalOfTestEnvironment) + Provision → Test (Deploy, Smoke, ApproveTest) → Production. Test and prod are stages, not separate pipelines. -- Each pipeline has three GitHub sources: AppCode (app), InfraCode - (infra), ReportsCode (reports). +- Each pipeline has three GitHub sources: AppSource (app), InfraSource + (infra), ReportsSource (reports). - git-green lists only open PRs (50, no pagination) and has no merge SHAs, branch names or compare, so it cannot supply ticket → PR data. diff --git a/docs/plans/2026-10-06-app-green-v1.md b/docs/plans/2026-10-06-app-green-v1.md index ba5125a..2832071 100644 --- a/docs/plans/2026-10-06-app-green-v1.md +++ b/docs/plans/2026-10-06-app-green-v1.md @@ -1438,11 +1438,11 @@ func New(ctx context.Context, account, profile, region string) (*Client, error) Add `IsSSOExpired(err error) bool`, matching the SDK's SSO token errors (`*ssocreds.InvalidTokenError` and messages containing "the SSO session has expired" or "token has expired"), with a test over sample errors. -**Step 3: Discover sources.** Test first: a fake `GetPipeline` returning a Source stage with three `CodeStarSourceConnection` actions whose configuration has `FullRepositoryId` `acme/app`, `acme/infra`, `acme/reports`. Expect `Sources(ctx, pipeline)` to return `map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"}` (repo → source action name). Implement by reading `Stages[].Actions[]` where `ActionTypeId.Category == Source` and `Configuration["FullRepositoryId"]`. +**Step 3: Discover sources.** Test first: a fake `GetPipeline` returning a Source stage with three `CodeStarSourceConnection` actions whose configuration has `FullRepositoryId` `acme/app`, `acme/infra`, `acme/reports`. Expect `Sources(ctx, pipeline)` to return `map[string]string{"acme/app": "AppSource", "acme/infra": "InfraSource", "acme/reports": "ReportsSource"}` (repo → source action name). Implement by reading `Stages[].Actions[]` where `ActionTypeId.Category == Source` and `Configuration["FullRepositoryId"]`. **Step 4: Stage history.** Test first with fakes: - `ListPipelineExecutions` returns three executions with `SourceRevisions` (actionName + revisionId). -- `ListActionExecutions` returns, for stage "Production", action "deploy": exec3 InProgress, exec2 Succeeded, exec1 Failed; and for stage "Test", action "ManualApprovalOfTestEnvironment": exec3 InProgress (approval pending). +- `ListActionExecutions` returns, for stage "Production", action "deploy": exec3 InProgress, exec2 Succeeded, exec1 Failed; and for stage "Test", action "ApproveTest": exec3 InProgress (approval pending). - `GetPipelineState` returns the approval action's `LatestExecution.Token` for exec3. Expect `History(ctx, pipeline, stage, deployAction, sources)` for Production to return `[]model.Deploy` newest first: exec3 `AwaitingApproval` with the token and revisions mapped to repo names via `sources`; exec2 `Succeeded`; exec1 `Failed`. For the Test stage, exec3's deploy has succeeded, so Test shows it `Succeeded`. @@ -1450,7 +1450,7 @@ Expect `History(ctx, pipeline, stage, deployAction, sources)` for Production to Implement: 1. `ListPipelineExecutions` (max 50) → `map[execID]map[repo]sha` using `sources` (invert to action → repo). 2. `ListActionExecutions` filtered by `PipelineExecutionId` is per execution; instead call it once with `MaxResults: 100` and filter client-side by `StageName` and `ActionName`. Map status: Succeeded, Failed/Abandoned → Failed, InProgress → InProgress. -3. Approval: an execution whose deploy action is not yet run in the Production stage but whose Test approval action is InProgress becomes a Production deploy with `AwaitingApproval`. Take the token from `GetPipelineState` (only the current pending approval has one). The approval action name and its stage come from config (`approval_action`, default `ManualApprovalOfTestEnvironment` in the Test stage). +3. Approval: an execution whose deploy action is not yet run in the Production stage but whose Test approval action is InProgress becomes a Production deploy with `AwaitingApproval`. Take the token from `GetPipelineState` (only the current pending approval has one). The approval action name and its stage come from config (`approval_action`, default `ApproveTest` in the Test stage). 4. Sort by `LastUpdateTime` descending; set `FinishedAt` from it. **Step 5: Health.** Test first: fake `DescribeServices` returning desired 2, running 1 → `model.Health{Known: true, Desired: 2, Healthy: 1}`. Services come from config per env (cluster + service names, copied from the aws-green config shape). Several services: sum desired and running. A missing service → `Known: false`. @@ -1504,7 +1504,7 @@ Implement: pipeline = "app-pipeline" stage = "Test" deploy_action = "Deploy" - approval_action = "ManualApprovalOfTestEnvironment" + approval_action = "ApproveTest" repos = ["acme/app"] # repos this env deploys; decides EnvSlot.Applies ecs = [{ cluster = "c1", services = ["s1"] }] diff --git a/docs/plans/2026-10-06-linking-spike.md b/docs/plans/2026-10-06-linking-spike.md index a6ad24f..00c7623 100644 --- a/docs/plans/2026-10-06-linking-spike.md +++ b/docs/plans/2026-10-06-linking-spike.md @@ -13,13 +13,13 @@ purpose (fixtures are synthetic only). |---|---| | Ticket → PR by key | Every recent merged PR has the key in both title and branch (`ABC-2099: ...`, `abc-2099-...`) | | Merge style | Squash, merge and rebase are all allowed. Each merged PR has a distinct `mergeCommit.oid` | -| Does the pipeline record the deployed commit? | Yes. `ListPipelineExecutions.sourceRevisions[actionName=AppCode].revisionId` is the PR's merge commit, exactly | +| Does the pipeline record the deployed commit? | Yes. `ListPipelineExecutions.sourceRevisions[actionName=AppSource].revisionId` is the PR's merge commit, exactly | | One run per merge? | Yes, in both accounts. Each merge to `main` starts a V2 execution in each account at the same moment | -| Stage history | `ListActionExecutions` gives Test/Deploy, Test/ManualApprovalOfTestEnvironment and Production/deploy per execution, with status and `lastUpdateTime` | +| Stage history | `ListActionExecutions` gives Test/Deploy, Test/ApproveTest and Production/deploy per execution, with status and `lastUpdateTime` | | Compare, included | `compare/...` → `ahead` when the PR is in the deploy | | Compare, not yet | Reversed pair → `behind` | | Superseded execution | Its merge commit compares `ahead` against the next succeeded execution's commit, so it still resolves as deployed | -| Other sources | Infra-only changes start runs that redeploy the same AppCode SHA. Harmless: the SHA is already linked | +| Other sources | Infra-only changes start runs that redeploy the same AppSource SHA. Harmless: the SHA is already linked | | Rollbacks | None in the last 100 executions of either account. Covered by synthetic fixtures only | | Failed / canceled runs | Present (mostly in stage-acct). Need a red flag only when the failed run carries a commit from a live Chain |