Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .scratch/prd-git-green-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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. <!-- spelling: ok: `cancelled` is the GitHub API conclusion value -->

- **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.

Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ _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). <!-- spelling: ok: GitHub API status values -->
_Avoid_: build, execution

**Job**: A named unit of work within a Run. Drill-down target below Workflow.
_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
Expand Down Expand Up @@ -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 <!-- spelling: ok: GitHub API conclusion value --> |

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).

Expand Down Expand Up @@ -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
10 changes: 5 additions & 5 deletions internal/aggregator/aggregator.go
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions internal/aggregator/aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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},
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/alerts/alerts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions internal/poller/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions internal/ratelimit/pacer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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})
Expand All @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/autoexpand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/icons.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/title.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/title_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading