diff --git a/.scratch/prd-git-green-tui.md b/.scratch/prd-git-green-tui.md index a35157f..279f00e 100644 --- a/.scratch/prd-git-green-tui.md +++ b/.scratch/prd-git-green-tui.md @@ -10,7 +10,7 @@ Developers working across multiple GitHub repositories have no fast, at-a-glance ## Solution -A terminal dashboard (TUI) that displays live GitHub CI status for a user-configured set of repos. Each repo is represented as a single stoplight row — green, red, yellow, or grey — aggregated across all its workflows. Users can drill into a repo to see individual workflow jobs, force a refresh, or open a run in the browser, all without leaving the terminal. Data is fetched by polling the GitHub API every 15 seconds (configurable). +A terminal dashboard (TUI) that displays live GitHub CI status for a user-configured set of repos. Each repo is represented as a single stoplight row — green, red, yellow, or gray — aggregated across all its workflows. Users can drill into a repo to see individual workflow jobs, force a refresh, or open a run in the browser, all without leaving the terminal. Data is fetched by polling the GitHub API every 15 seconds (configurable). ## User Stories @@ -19,7 +19,7 @@ A terminal dashboard (TUI) that displays live GitHub CI status for a user-config 3. As a developer, I want a red stoplight when any workflow in a repo is failing or timed out, so that I immediately know something needs attention. 4. As a developer, I want a yellow stoplight when a workflow is queued or running, so that I know work is in progress. 5. As a developer, I want a green stoplight when all workflows succeed, so that I can confidently move on. -6. As a developer, I want a grey stoplight when a run is cancelled or no runs exist yet, so that the dashboard doesn't misrepresent ambiguous states as failures. +6. As a developer, I want a gray stoplight when a run is canceled or no runs exist yet, so that the dashboard doesn't misrepresent ambiguous states as failures. 7. As a developer, I want to navigate repos with arrow keys and select one with enter, so that I can explore details without touching the mouse. 8. As a developer, I want to expand a repo row inline to see its branch CI and each open PR with its own stoplight, so that I can assess the full state without leaving the dashboard. 9. As a developer, I want to press `o` to open the current run in the browser, so that I can read full logs when needed. @@ -48,7 +48,7 @@ A terminal dashboard (TUI) that displays live GitHub CI status for a user-config - **Poller** — Owns the polling ticker. On each tick, calls the GitHub client for every configured Repo concurrently, collects results, and publishes a new State snapshot. On client error, retains the last known result for that Repo and sets a staleness timestamp. -- **Aggregator** — Pure function: maps a slice of GitHub run statuses → a Stoplight color using worst-case aggregation across Workflows. Status mapping: `success`/`neutral`/`skipped` → green; `failure`/`timed_out`/`action_required` → red; `queued`/`in_progress` → yellow; `cancelled`/no runs → grey. +- **Aggregator** — Pure function: maps a slice of GitHub run statuses → a Stoplight color using worst-case aggregation across Workflows. Status mapping: `success`/`neutral`/`skipped` → green; `failure`/`timed_out`/`action_required` → red; `queued`/`in_progress` → yellow; `cancelled`/no runs → gray. - **State / Model** — Immutable snapshot of all Repo statuses (Stoplight, latest Run, Jobs, staleness). The Poller produces a new State on every poll tick; the UI renders from the current State. @@ -87,11 +87,11 @@ name = "git-green" ## Testing Decisions -- **What makes a good test:** Test external behaviour through the module's public interface, not implementation details. A good test describes a scenario ("given a run with status `failure`, the Aggregator returns red") rather than asserting on internal state or private functions. +- **What makes a good test:** Test external behavior through the module's public interface, not implementation details. A good test describes a scenario ("given a run with status `failure`, the Aggregator returns red") rather than asserting on internal state or private functions. -- **Config** — Test parsing of valid TOML, validation errors (missing required fields, invalid poll interval), token resolution (explicit token, env var reference, missing env var), and defaulting behaviour (no branch → nil, no workflows → nil). +- **Config** — Test parsing of valid TOML, validation errors (missing required fields, invalid poll interval), token resolution (explicit token, env var reference, missing env var), and defaulting behavior (no branch → nil, no workflows → nil). -- **Aggregator** — Test every status → Stoplight mapping, worst-case aggregation across multiple workflows, and edge cases (no runs, all cancelled, mixed green/yellow). +- **Aggregator** — Test every status → Stoplight mapping, worst-case aggregation across multiple workflows, and edge cases (no runs, all canceled, mixed green/yellow). - **State / Model** — Test that a new State snapshot correctly reflects updated Repo data from the Poller, staleness is set on error and cleared on success, and immutability (new snapshot doesn't mutate previous). diff --git a/CLAUDE.md b/CLAUDE.md index 7e629cb..d0dd721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Go + Bubble Tea (ADR 0001). ## Workflow - Branch off `main`, open a PR, squash-merge. Commit messages follow Conventional Commits and PRs close their issue (`Closes #N`). -- Keep `CONTEXT.md` in sync when behaviour or domain language changes. +- Keep `CONTEXT.md` in sync when behavior or domain language changes. ## Agent skills diff --git a/CONTEXT.md b/CONTEXT.md index f2868d3..e52a8a1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,7 @@ _Avoid_: project, service GitHub's built-in CodeQL scanning (its default setup) counts as a Workflow named `CodeQL`, since it is often a Repo's only one. Its other built-ins — Dependabot's update jobs and Copilot's PR reviews — are not CI and are never shown. _Avoid_: pipeline, action -**Run**: A single execution of a Workflow, triggered by a push, PR, or manual dispatch. Has a status (queued, in_progress, success, failure, cancelled). +**Run**: A single execution of a Workflow, triggered by a push, PR, or manual dispatch. Has a status (queued, in_progress, success, failure, cancelled). _Avoid_: build, execution **Job**: A named unit of work within a Run. Drill-down target below Workflow. @@ -19,8 +19,8 @@ _Avoid_: step (Step is a lower-level concept within a Job) **Step**: The lowest-level unit within a Job. Not a primary display target. -**Org**: A GitHub organisation. Token configuration is scoped to Org, not Repo. -_Avoid_: organisation, account +**Org**: A GitHub organization. Token configuration is scoped to Org, not Repo. +_Avoid_: organization, account **PR**: An open GitHub Pull Request within a Repo. The dashboard shows each open PR as an expandable row with its own Stoplight, derived from Workflow Runs on that PR's head SHA. _Avoid_: pull request (use PR), change, diff @@ -64,7 +64,7 @@ The visual health indicator for a Repo or PR. Aggregates across all Workflows us | 🟢 Green | Healthy | `success`, `neutral`, `skipped` | | 🔴 Red | Broken or blocked | `failure`, `timed_out`, `action_required`, `startup_failure` | | 🟡 Yellow | In progress | `queued`, `in_progress`, `requested`, `waiting`, `pending` | -| ⚪ Grey | No signal | `cancelled`, no runs yet | +| ⚪ Gray | No signal | `cancelled`, no runs yet | A Run's effective status is its conclusion once it has one, and its status until then. This one table drives the Stoplight, the Run and Job icons, which Runs `f` can re-run (a finished 🔴 Run), and stuck alerts (🔴 is a prolonged failure, 🟡 is prolonged in progress). @@ -258,5 +258,5 @@ _Avoid_: fix, retry ## Flagged ambiguities - "monitor" — resolved: means live dashboard display, not notification/alerting -- "detail view" — resolved: removed in favour of inline expand/collapse tree on the Dashboard +- "detail view" — resolved: removed in favor of inline expand/collapse tree on the Dashboard - "enabled/disabled" — resolved: `enabled = false` in config suppresses all API calls for that Repo; the Repo manager toggles this at runtime diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index 2d94f0e..708ff05 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -1,5 +1,5 @@ // Package aggregator is the one place that interprets a Run's GitHub status. -// Everything that colours, sorts, re-runs or alerts on a Run asks it, rather +// Everything that colors, sorts, re-runs or alerts on a Run asks it, rather // than reading status strings itself. package aggregator @@ -9,7 +9,7 @@ import githubclient "github.com/ericdahl-dev/git-green/internal/github" type Stoplight int const ( - StoplightGrey Stoplight = iota // no runs, or all cancelled + StoplightGray Stoplight = iota // no runs, or all canceled StoplightGreen // all passing StoplightYellow // in progress StoplightRed // failing or blocked @@ -54,14 +54,14 @@ func Of(status string) Stoplight { case "queued", "in_progress", "requested", "waiting", "pending": return StoplightYellow default: - return StoplightGrey + return StoplightGray } } // Runs returns the worst-case Stoplight across runs: Red > Yellow > Green > -// Grey. No runs is Grey. +// Gray. No runs is Gray. func Runs(runs []githubclient.WorkflowRun) Stoplight { - result := StoplightGrey + result := StoplightGray for _, r := range runs { if light := Of(r.Effective()); light > result { result = light diff --git a/internal/aggregator/aggregator_test.go b/internal/aggregator/aggregator_test.go index 34a67fa..d822fb3 100644 --- a/internal/aggregator/aggregator_test.go +++ b/internal/aggregator/aggregator_test.go @@ -21,9 +21,9 @@ func TestOfMapsEveryStatus(t *testing.T) { "requested": StoplightYellow, "waiting": StoplightYellow, "pending": StoplightYellow, - "cancelled": StoplightGrey, - "stale": StoplightGrey, - "": StoplightGrey, + "cancelled": StoplightGray, // spelling: ok: GitHub API conclusion value + "stale": StoplightGray, + "": StoplightGray, } for status, want := range cases { if got := Of(status); got != want { @@ -42,8 +42,8 @@ func TestRunsIsWorstCase(t *testing.T) { runs []githubclient.WorkflowRun want Stoplight }{ - {"no runs", nil, StoplightGrey}, - {"all cancelled", []githubclient.WorkflowRun{run("completed", "cancelled"), run("completed", "cancelled")}, StoplightGrey}, + {"no runs", nil, StoplightGray}, + {"all canceled", []githubclient.WorkflowRun{run("completed", "cancelled"), run("completed", "cancelled")}, StoplightGray}, // spelling: ok: GitHub API conclusion value {"green and running", []githubclient.WorkflowRun{run("completed", "success"), run("in_progress", "")}, StoplightYellow}, {"red beats everything", []githubclient.WorkflowRun{run("completed", "success"), run("in_progress", ""), run("completed", "failure")}, StoplightRed}, {"red beats queued", []githubclient.WorkflowRun{run("queued", ""), run("completed", "timed_out")}, StoplightRed}, @@ -66,9 +66,9 @@ func TestRunsReadsConclusionBeforeStatus(t *testing.T) { } func TestActiveFirstOrder(t *testing.T) { - lights := []Stoplight{StoplightGrey, StoplightGreen, StoplightRed, StoplightYellow} + lights := []Stoplight{StoplightGray, StoplightGreen, StoplightRed, StoplightYellow} sort.Slice(lights, func(a, b int) bool { return lights[a].ActiveFirst() < lights[b].ActiveFirst() }) - want := []Stoplight{StoplightYellow, StoplightRed, StoplightGreen, StoplightGrey} + want := []Stoplight{StoplightYellow, StoplightRed, StoplightGreen, StoplightGray} for i := range want { if lights[i] != want[i] { t.Fatalf("got %v, want %v", lights, want) diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 7c347c0..aba6568 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -152,7 +152,7 @@ func TestRunsStuckReasonFollowsStoplight(t *testing.T) { {githubclient.WorkflowRun{Status: "in_progress"}, true, "prolonged_in_progress"}, {githubclient.WorkflowRun{Status: "queued"}, true, "prolonged_in_progress"}, {githubclient.WorkflowRun{Status: "completed", Conclusion: "success"}, false, ""}, - {githubclient.WorkflowRun{Status: "completed", Conclusion: "cancelled"}, false, ""}, + {githubclient.WorkflowRun{Status: "completed", Conclusion: "cancelled"}, false, ""}, // spelling: ok: GitHub API conclusion value } for _, tc := range cases { stuck, reason := runsStuckReason([]githubclient.WorkflowRun{tc.run}) diff --git a/internal/app/app.go b/internal/app/app.go index 619201f..9a2a220 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,7 +36,7 @@ type Options struct { Poller Poller Initial state.Snapshot // shown until the first poll lands Snapshots <-chan state.Snapshot // one per poll cycle; closed on stop - // Ctx bounds re-runs, so they are cancelled when the app exits. + // Ctx bounds re-runs, so they are canceled when the app exits. Ctx context.Context // Stop shuts the Poller down on quit. Stop func() diff --git a/internal/github/client_test.go b/internal/github/client_test.go index ff485e6..0992cea 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -69,7 +69,7 @@ func TestCodeQLRunsAreNamedCodeQL(t *testing.T) { } } -func TestKeepRunHonoursWorkflowFilter(t *testing.T) { +func TestKeepRunHonorsWorkflowFilter(t *testing.T) { filter := newFilterSet([]string{"CI"}) if !keepRun(run("CI", "push", 1), filter) { t.Error("expected CI to be kept") diff --git a/internal/poller/poller.go b/internal/poller/poller.go index af63639..d2983bc 100644 --- a/internal/poller/poller.go +++ b/internal/poller/poller.go @@ -66,7 +66,7 @@ func New(cfg *config.Config, factory ClientFactory) *Poller { Owner: r.Owner, Name: r.Name, Branch: r.Branch, - Stoplight: aggregator.StoplightGrey, + Stoplight: aggregator.StoplightGray, } } cfg = cfg.Clone() @@ -114,7 +114,7 @@ func (p *Poller) Snapshot() state.Snapshot { return state.New(p.current) } -// Start runs the fetch loop until ctx is cancelled or the returned cancel is +// Start runs the fetch loop until ctx is canceled or the returned cancel is // called, sending a Snapshot after every cycle. The loop is the only fetcher // and the only sender, so cycles never overlap and the channel closes only // once nothing can send on it. diff --git a/internal/ratelimit/pacer_test.go b/internal/ratelimit/pacer_test.go index a64977b..4b37afd 100644 --- a/internal/ratelimit/pacer_test.go +++ b/internal/ratelimit/pacer_test.go @@ -78,7 +78,7 @@ func TestPacerHoldsARateLimitedTokenUntilReset(t *testing.T) { p, c := newTestPacer() reset := c.t.Add(30 * time.Minute) if !p.Failed("tok", &github.RateLimitError{Rate: github.Rate{Reset: github.Timestamp{Time: reset}}}) { - t.Fatal("a RateLimitError must be recognised") + t.Fatal("a RateLimitError must be recognized") } if p.Due("tok") { @@ -97,7 +97,7 @@ func TestPacerHoldsARateLimitedTokenUntilReset(t *testing.T) { } } -func TestPacerHonoursRetryAfter(t *testing.T) { +func TestPacerHonorsRetryAfter(t *testing.T) { p, c := newTestPacer() wait := 90 * time.Second p.Failed("tok", &github.AbuseRateLimitError{RetryAfter: &wait}) @@ -121,7 +121,7 @@ func TestPacerIgnoresOtherErrors(t *testing.T) { func TestPacerBacksOffASecondaryLimitWithoutRetryAfter(t *testing.T) { p, c := newTestPacer() if !p.Failed("tok", &github.AbuseRateLimitError{}) { - t.Fatal("a secondary limit must be recognised even without Retry-After") + t.Fatal("a secondary limit must be recognized even without Retry-After") } if until, ok := p.LimitedUntil("tok"); !ok || !until.Equal(c.t.Add(SecondaryBackoff)) { t.Errorf("LimitedUntil = %v, %v; want %v", until, ok, c.t.Add(SecondaryBackoff)) diff --git a/internal/ui/autoexpand_test.go b/internal/ui/autoexpand_test.go index 1c2f413..e09b5d0 100644 --- a/internal/ui/autoexpand_test.go +++ b/internal/ui/autoexpand_test.go @@ -23,10 +23,10 @@ func TestAutoExpandOpensActiveAndFailingRepos(t *testing.T) { repoState("green", aggregator.StoplightGreen), repoState("running", aggregator.StoplightYellow), repoState("failing", aggregator.StoplightRed), - repoState("idle", aggregator.StoplightGrey), + repoState("idle", aggregator.StoplightGray), })) if isOpen(d, "green") || isOpen(d, "idle") { - t.Error("green/grey repos should stay collapsed") + t.Error("green/gray repos should stay collapsed") } if !isOpen(d, "running") || !isOpen(d, "failing") { t.Error("yellow/red repos should open") diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index f283c09..9a29d98 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -77,7 +77,7 @@ type Dashboard struct { } // WithRerunner returns a copy of the dashboard wired to re-run failed runs -// through r. Re-runs fire against ctx so they are cancelled when the app exits. +// through r. Re-runs fire against ctx so they are canceled when the app exits. func (d Dashboard) WithRerunner(ctx context.Context, r Rerunner) Dashboard { d.rerunCtx = ctx d.rerunner = r diff --git a/internal/ui/icons.go b/internal/ui/icons.go index 4b2892a..c8bb09e 100644 --- a/internal/ui/icons.go +++ b/internal/ui/icons.go @@ -14,7 +14,7 @@ var ( jobFaint = lipgloss.NewStyle().Faint(true) ) -// runIcon marks a Run or Job by its effective status, in its Stoplight colour. +// runIcon marks a Run or Job by its effective status, in its Stoplight color. func runIcon(status string) string { switch aggregator.Of(status) { case aggregator.StoplightGreen: diff --git a/internal/ui/title.go b/internal/ui/title.go index 5b266c5..a104bdc 100644 --- a/internal/ui/title.go +++ b/internal/ui/title.go @@ -29,7 +29,7 @@ func TitleLine(fetching bool, spinView string, throttles []state.Throttle) strin return line + "\n" } -// ThrottleNotice summarises why polling has slowed, or "" while every token is +// ThrottleNotice summarizes why polling has slowed, or "" while every token is // healthy. It names the budget, the pace it bought, and when it recovers, so a // slow dashboard reads as throttled rather than broken. func ThrottleNotice(throttles []state.Throttle) string { diff --git a/internal/ui/title_test.go b/internal/ui/title_test.go index d66eb52..0777ada 100644 --- a/internal/ui/title_test.go +++ b/internal/ui/title_test.go @@ -44,7 +44,7 @@ func TestThrottleNoticeTrimsLongOrgLists(t *testing.T) { Interval: time.Hour, }}) if !strings.Contains(got, "+2") { - t.Errorf("got %q, want the extra orgs summarised as +2", got) + t.Errorf("got %q, want the extra orgs summarized as +2", got) } if strings.Contains(got, "d") && strings.Contains(got, "c") { t.Errorf("got %q, want only the first two orgs named", got) diff --git a/internal/ui/tree_test.go b/internal/ui/tree_test.go index 72dd784..a1a1331 100644 --- a/internal/ui/tree_test.go +++ b/internal/ui/tree_test.go @@ -27,7 +27,7 @@ func selectKind(t *testing.T, tr *tree, kind rowKind, num int) { } // PR expansion follows the PR, not its position: when a PR above it closes, -// the expanded one stays expanded and its neighbour does not open. +// the expanded one stays expanded and its neighbor does not open. func TestTreePRExpansionFollowsThePR(t *testing.T) { tr := newTree(state.New([]state.RepoState{ treeRepo("a", aggregator.StoplightGreen, lonePR(1, aggregator.StoplightGreen), lonePR(2, aggregator.StoplightGreen)), diff --git a/main.go b/main.go index f112561..8aae67c 100644 --- a/main.go +++ b/main.go @@ -62,7 +62,7 @@ Usage: // runCommand handles the non-TUI invocations. It reports whether args were // handled here, along with the exit code to use when they were. Anything it -// does not recognise falls through to launching the dashboard. +// does not recognize falls through to launching the dashboard. func runCommand(args []string, out io.Writer) (int, bool) { if len(args) == 0 { return 0, false