From e377b9e366d195824ccb5ba91d0926f6c341400e Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Mon, 28 Sep 2026 22:50:54 -0400 Subject: [PATCH] refactor: deepen Run status into one module The effective Run status (conclusion, else status) was resolved by hand in 10 places and read through four status tables that disagreed. The aggregator is now the one place that interprets it: Of, Runs, and Stoplight.ActiveFirst. Icons, re-run eligibility, the repo summary and stuck detection all go through it. Behaviour changes that fall out of the single table: - startup_failure is red, matching what f already offered to re-run - requested, waiting and pending are yellow, not grey - stuck alerts cover action_required/startup_failure and queued Also deletes the dead ui/detail.go (BackMsg moves to manage.go). Closes #49 --- CONTEXT.md | 6 +- internal/aggregator/aggregator.go | 58 +++++++------- internal/aggregator/aggregator_test.go | 104 ++++++++++++++----------- internal/github/client.go | 16 ++++ internal/poller/poller.go | 47 ++++------- internal/poller/poller_test.go | 22 ++++++ internal/ui/dashboard.go | 76 ++++-------------- internal/ui/dashboard_test.go | 18 ----- internal/ui/detail.go | 81 ------------------- internal/ui/icons.go | 16 ++-- internal/ui/manage.go | 3 + internal/ui/stacks.go | 4 +- 12 files changed, 172 insertions(+), 279 deletions(-) delete mode 100644 internal/ui/detail.go diff --git a/CONTEXT.md b/CONTEXT.md index a7970bb..69f4d75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,10 +61,12 @@ The visual health indicator for a Repo or PR. Aggregates across all Workflows us | Color | Meaning | GitHub statuses | |---|---|---| | ๐ŸŸข Green | Healthy | `success`, `neutral`, `skipped` | -| ๐Ÿ”ด Red | Broken or blocked | `failure`, `timed_out`, `action_required` | -| ๐ŸŸก Yellow | In progress | `queued`, `in_progress` | +| ๐Ÿ”ด 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 | +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). + _Avoid_: badge, indicator, light ## Review diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index ab472da..2d94f0e 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -1,6 +1,11 @@ +// 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 +// than reading status strings itself. package aggregator -// Stoplight represents the health color for a Repo. +import githubclient "github.com/ericdahl-dev/git-green/internal/github" + +// Stoplight represents the health color for a Repo, PR or Run. type Stoplight int const ( @@ -23,41 +28,42 @@ func (s Stoplight) String() string { } } -// RunStatus mirrors the relevant GitHub API status/conclusion values. -type RunStatus string - -const ( - StatusSuccess RunStatus = "success" - StatusNeutral RunStatus = "neutral" - StatusSkipped RunStatus = "skipped" - StatusFailure RunStatus = "failure" - StatusTimedOut RunStatus = "timed_out" - StatusActionRequired RunStatus = "action_required" - StatusQueued RunStatus = "queued" - StatusInProgress RunStatus = "in_progress" - StatusCancelled RunStatus = "cancelled" -) - -func statusToStoplight(s RunStatus) Stoplight { +// ActiveFirst ranks a Stoplight for Active-first sorting: in progress, then +// failing, then passing, then no signal. Lower sorts first. +func (s Stoplight) ActiveFirst() int { switch s { - case StatusSuccess, StatusNeutral, StatusSkipped: + case StoplightYellow: + return 0 + case StoplightRed: + return 1 + case StoplightGreen: + return 2 + default: + return 3 + } +} + +// Of maps one effective GitHub status (a Run or Job's conclusion, or its +// status while it has none) to a Stoplight. +func Of(status string) Stoplight { + switch status { + case "success", "neutral", "skipped": return StoplightGreen - case StatusFailure, StatusTimedOut, StatusActionRequired: + case "failure", "timed_out", "action_required", "startup_failure": return StoplightRed - case StatusQueued, StatusInProgress: + case "queued", "in_progress", "requested", "waiting", "pending": return StoplightYellow default: return StoplightGrey } } -// Aggregate returns the worst-case Stoplight across all provided run statuses. -// Red > Yellow > Green > Grey. -func Aggregate(statuses []RunStatus) Stoplight { +// Runs returns the worst-case Stoplight across runs: Red > Yellow > Green > +// Grey. No runs is Grey. +func Runs(runs []githubclient.WorkflowRun) Stoplight { result := StoplightGrey - for _, s := range statuses { - light := statusToStoplight(s) - if light > result { + 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 6794979..34a67fa 100644 --- a/internal/aggregator/aggregator_test.go +++ b/internal/aggregator/aggregator_test.go @@ -1,67 +1,77 @@ package aggregator -import "testing" +import ( + "sort" + "testing" -func TestStatusMappings(t *testing.T) { - cases := []struct { - status RunStatus - expected Stoplight - }{ - {StatusSuccess, StoplightGreen}, - {StatusNeutral, StoplightGreen}, - {StatusSkipped, StoplightGreen}, - {StatusFailure, StoplightRed}, - {StatusTimedOut, StoplightRed}, - {StatusActionRequired, StoplightRed}, - {StatusQueued, StoplightYellow}, - {StatusInProgress, StoplightYellow}, - {StatusCancelled, StoplightGrey}, + githubclient "github.com/ericdahl-dev/git-green/internal/github" +) + +func TestOfMapsEveryStatus(t *testing.T) { + cases := map[string]Stoplight{ + "success": StoplightGreen, + "neutral": StoplightGreen, + "skipped": StoplightGreen, + "failure": StoplightRed, + "timed_out": StoplightRed, + "action_required": StoplightRed, + "startup_failure": StoplightRed, + "queued": StoplightYellow, + "in_progress": StoplightYellow, + "requested": StoplightYellow, + "waiting": StoplightYellow, + "pending": StoplightYellow, + "cancelled": StoplightGrey, + "stale": StoplightGrey, + "": StoplightGrey, } - for _, tc := range cases { - got := Aggregate([]RunStatus{tc.status}) - if got != tc.expected { - t.Errorf("status %q: expected %v, got %v", tc.status, tc.expected, got) + for status, want := range cases { + if got := Of(status); got != want { + t.Errorf("Of(%q) = %v, want %v", status, got, want) } } } -func TestEmptyReturnsGrey(t *testing.T) { - if got := Aggregate(nil); got != StoplightGrey { - t.Errorf("expected grey for empty, got %v", got) - } +func run(status, conclusion string) githubclient.WorkflowRun { + return githubclient.WorkflowRun{Status: status, Conclusion: conclusion} } -func TestAllCancelledReturnsGrey(t *testing.T) { - got := Aggregate([]RunStatus{StatusCancelled, StatusCancelled}) - if got != StoplightGrey { - t.Errorf("expected grey, got %v", got) +func TestRunsIsWorstCase(t *testing.T) { + cases := []struct { + name string + runs []githubclient.WorkflowRun + want Stoplight + }{ + {"no runs", nil, StoplightGrey}, + {"all cancelled", []githubclient.WorkflowRun{run("completed", "cancelled"), run("completed", "cancelled")}, StoplightGrey}, + {"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}, } -} - -func TestMixedGreenYellowReturnsYellow(t *testing.T) { - got := Aggregate([]RunStatus{StatusSuccess, StatusInProgress}) - if got != StoplightYellow { - t.Errorf("expected yellow, got %v", got) + for _, tc := range cases { + if got := Runs(tc.runs); got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } } } -func TestAnyRedOverridesAll(t *testing.T) { - got := Aggregate([]RunStatus{StatusSuccess, StatusInProgress, StatusFailure}) - if got != StoplightRed { - t.Errorf("expected red, got %v", got) +// A finished run reports its conclusion; one still going only has a status. +func TestRunsReadsConclusionBeforeStatus(t *testing.T) { + if got := Runs([]githubclient.WorkflowRun{run("completed", "failure")}); got != StoplightRed { + t.Errorf("completed/failure: got %v, want red", got) } -} - -func TestRedBeatsYellow(t *testing.T) { - got := Aggregate([]RunStatus{StatusQueued, StatusTimedOut}) - if got != StoplightRed { - t.Errorf("expected red, got %v", got) + if got := Runs([]githubclient.WorkflowRun{run("in_progress", "")}); got != StoplightYellow { + t.Errorf("in_progress: got %v, want yellow", got) } } -func TestGreenOnlyReturnsGreen(t *testing.T) { - got := Aggregate([]RunStatus{StatusSuccess, StatusNeutral, StatusSkipped}) - if got != StoplightGreen { - t.Errorf("expected green, got %v", got) +func TestActiveFirstOrder(t *testing.T) { + lights := []Stoplight{StoplightGrey, StoplightGreen, StoplightRed, StoplightYellow} + sort.Slice(lights, func(a, b int) bool { return lights[a].ActiveFirst() < lights[b].ActiveFirst() }) + want := []Stoplight{StoplightYellow, StoplightRed, StoplightGreen, StoplightGrey} + for i := range want { + if lights[i] != want[i] { + t.Fatalf("got %v, want %v", lights, want) + } } } diff --git a/internal/github/client.go b/internal/github/client.go index 54ca1cd..9a500fc 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -21,6 +21,14 @@ type Job struct { Conclusion string } +// Effective is the job's conclusion once it has one, and its status until then. +func (j Job) Effective() string { + if j.Conclusion != "" { + return j.Conclusion + } + return j.Status +} + // WorkflowRun holds the latest run for a single workflow. type WorkflowRun struct { WorkflowName string @@ -31,6 +39,14 @@ type WorkflowRun struct { Jobs []Job } +// Effective is the run's conclusion once it has one, and its status until then. +func (r WorkflowRun) Effective() string { + if r.Conclusion != "" { + return r.Conclusion + } + return r.Status +} + // PR represents an open pull request. type PR struct { Number int diff --git a/internal/poller/poller.go b/internal/poller/poller.go index 3ea4a09..5a8664d 100644 --- a/internal/poller/poller.go +++ b/internal/poller/poller.go @@ -428,33 +428,14 @@ func (p *Poller) fetchRepo(ctx context.Context, repo config.Repo, prev state.Rep runs := data.BranchRuns prRuns := data.PRRuns - // Aggregate stoplight from default-branch runs. - statuses := make([]aggregator.RunStatus, 0, len(runs)) - for _, r := range runs { - s := r.Conclusion - if s == "" { - s = r.Status - } - statuses = append(statuses, aggregator.RunStatus(s)) - } - // Build PRStates. prStates := make([]state.PRState, 0, len(prRuns)) for _, pr := range prRuns { - prStatuses := make([]aggregator.RunStatus, 0, len(pr.Runs)) - for _, r := range pr.Runs { - s := r.Conclusion - if s == "" { - s = r.Status - } - prStatuses = append(prStatuses, aggregator.RunStatus(s)) - } - prStates = append(prStates, state.PRState{ Number: pr.PR.Number, Title: pr.PR.Title, HTMLURL: pr.PR.HTMLURL, - Stoplight: aggregator.Aggregate(prStatuses), + Stoplight: aggregator.Runs(pr.Runs), Runs: pr.Runs, Mergeable: pr.PR.Mergeable, Stack: pr.PR.Stack, @@ -466,7 +447,7 @@ func (p *Poller) fetchRepo(ctx context.Context, repo config.Repo, prev state.Rep Owner: repo.Owner, Name: repo.Name, Branch: data.ResolvedBranch, - Stoplight: aggregator.Aggregate(statuses), + Stoplight: aggregator.Runs(runs), Runs: runs, PRs: prStates, StaleAt: nil, @@ -587,15 +568,7 @@ func (p *Poller) evaluateStuck(repos []state.RepoState) []webhooks.Event { // branchStuckReason returns whether the branch is stuck and why. func (p *Poller) branchStuckReason(rs *state.RepoState) (bool, string) { - for _, run := range rs.Runs { - if run.Conclusion == "failure" || run.Conclusion == "timed_out" { - return true, "prolonged_failure" - } - if run.Status == "in_progress" { - return true, "prolonged_in_progress" - } - } - return false, "" + return runsStuckReason(rs.Runs) } // prStuckReason returns whether the PR is stuck and why. @@ -603,11 +576,17 @@ func (p *Poller) prStuckReason(pr *state.PRState) (bool, string) { if pr.Mergeable == "dirty" || pr.Mergeable == "conflicting" { return true, "conflict" } - for _, run := range pr.Runs { - if run.Conclusion == "failure" || run.Conclusion == "timed_out" { + return runsStuckReason(pr.Runs) +} + +// runsStuckReason reports the first Run that is failing or still going, which +// is what "stuck" means once it has lasted past the threshold. +func runsStuckReason(runs []githubclient.WorkflowRun) (bool, string) { + for _, run := range runs { + switch aggregator.Of(run.Effective()) { + case aggregator.StoplightRed: return true, "prolonged_failure" - } - if run.Status == "in_progress" { + case aggregator.StoplightYellow: return true, "prolonged_in_progress" } } diff --git a/internal/poller/poller_test.go b/internal/poller/poller_test.go index fceed51..3ce1295 100644 --- a/internal/poller/poller_test.go +++ b/internal/poller/poller_test.go @@ -593,3 +593,25 @@ func TestRateLimitBacksOffUntilReset(t *testing.T) { t.Error("did not resume polling after the reset time") } } + +// Stuck follows the Stoplight: every red conclusion is a failure and every +// yellow status is still going, not just failure/timed_out and in_progress. +func TestRunsStuckReasonFollowsStoplight(t *testing.T) { + cases := []struct { + run githubclient.WorkflowRun + stuck bool + reason string + }{ + {githubclient.WorkflowRun{Status: "completed", Conclusion: "action_required"}, true, "prolonged_failure"}, + {githubclient.WorkflowRun{Status: "completed", Conclusion: "startup_failure"}, true, "prolonged_failure"}, + {githubclient.WorkflowRun{Status: "queued"}, true, "prolonged_in_progress"}, + {githubclient.WorkflowRun{Status: "completed", Conclusion: "success"}, false, ""}, + {githubclient.WorkflowRun{Status: "completed", Conclusion: "cancelled"}, false, ""}, + } + for _, tc := range cases { + stuck, reason := runsStuckReason([]githubclient.WorkflowRun{tc.run}) + if stuck != tc.stuck || reason != tc.reason { + t.Errorf("%s/%s: got (%v, %q), want (%v, %q)", tc.run.Status, tc.run.Conclusion, stuck, reason, tc.stuck, tc.reason) + } + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index e8f16ff..dac3b58 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -64,17 +64,6 @@ type rerunTarget struct { func (t rerunTarget) fullName() string { return t.owner + "/" + t.name } -// runFailed reports whether a run finished in a state worth re-running. It -// covers the conclusions the dashboard paints red plus startup_failure, which -// is a failure GitHub reports without ever starting a job. -func runFailed(run githubclient.WorkflowRun) bool { - switch run.Conclusion { - case "failure", "timed_out", "action_required", "startup_failure": - return true - } - return false -} - type rowKind int const ( @@ -147,20 +136,6 @@ func NewDashboard(snap state.Snapshot) Dashboard { return d } -// stoplightPriority returns sort order: yellow (active) first, then red, green, grey. -func stoplightPriority(s aggregator.Stoplight) int { - switch s { - case aggregator.StoplightYellow: - return 0 - case aggregator.StoplightRed: - return 1 - case aggregator.StoplightGreen: - return 2 - default: - return 3 - } -} - func (d *Dashboard) buildRows() []flatRow { // Build sorted repo index order: yellow first, then red, green, grey. repoOrder := make([]int, len(d.snapshot.Repos)) @@ -168,8 +143,8 @@ func (d *Dashboard) buildRows() []flatRow { repoOrder[i] = i } sort.SliceStable(repoOrder, func(a, b int) bool { - pa := stoplightPriority(d.snapshot.Repos[repoOrder[a]].Stoplight) - pb := stoplightPriority(d.snapshot.Repos[repoOrder[b]].Stoplight) + pa := d.snapshot.Repos[repoOrder[a]].Stoplight.ActiveFirst() + pb := d.snapshot.Repos[repoOrder[b]].Stoplight.ActiveFirst() return pa < pb }) @@ -477,7 +452,8 @@ func (d Dashboard) selectedRerunTarget() *rerunTarget { row := d.rows[d.cursor] repo := d.snapshot.Repos[row.repoIdx] for _, run := range d.rowRuns(row) { - if !runFailed(run) || run.RunID == 0 { + // Only a finished, red Run can be re-run; one still going is yellow. + if aggregator.Of(run.Conclusion) != aggregator.StoplightRed || run.RunID == 0 { continue } return &rerunTarget{ @@ -658,17 +634,9 @@ func renderBranchSection(r state.RepoState) string { branch := r.BranchName() out := staleStyle.Render(branchIndent+"branch: "+branch) + "\n" for _, run := range r.Runs { - status := run.Conclusion - if status == "" { - status = run.Status - } - out += wfStyle.Render(fmt.Sprintf("%s%s %s", wfIndent, workflowStatusIcon(status), run.WorkflowName)) + "\n" + out += wfStyle.Render(fmt.Sprintf("%s%s %s", wfIndent, runIcon(run.Effective()), run.WorkflowName)) + "\n" for _, job := range run.Jobs { - jobStatus := job.Conclusion - if jobStatus == "" { - jobStatus = job.Status - } - out += fmt.Sprintf("%s%s %s\n", jobIndent, jobStatusIcon(jobStatus), job.Name) + out += fmt.Sprintf("%s%s %s\n", jobIndent, runIcon(job.Effective()), job.Name) } } return out @@ -681,17 +649,9 @@ func renderPRRuns(pr state.PRState, indent string) string { jobIndent := indent + " " out := "" for _, run := range pr.Runs { - status := run.Conclusion - if status == "" { - status = run.Status - } - out += wfStyle.Render(fmt.Sprintf("%s%s %s", indent, workflowStatusIcon(status), run.WorkflowName)) + "\n" + out += wfStyle.Render(fmt.Sprintf("%s%s %s", indent, runIcon(run.Effective()), run.WorkflowName)) + "\n" for _, job := range run.Jobs { - jobStatus := job.Conclusion - if jobStatus == "" { - jobStatus = job.Status - } - out += fmt.Sprintf("%s%s %s\n", jobIndent, jobStatusIcon(jobStatus), job.Name) + out += fmt.Sprintf("%s%s %s\n", jobIndent, runIcon(job.Effective()), job.Name) } } return out @@ -724,21 +684,17 @@ func workflowSummary(r state.RepoState) string { return "no runs" } for _, run := range r.Runs { - s := run.Conclusion - if s == "" { - s = run.Status - } - if aggregator.Aggregate([]aggregator.RunStatus{aggregator.RunStatus(s)}) == r.Stoplight { - if s == "" { - s = "unknown" - } - return fmt.Sprintf("%s ยท %s", run.WorkflowName, s) + if aggregator.Of(run.Effective()) == r.Stoplight { + return runSummary(run) } } - run := r.Runs[0] - s := run.Conclusion + return runSummary(r.Runs[0]) +} + +func runSummary(run githubclient.WorkflowRun) string { + s := run.Effective() if s == "" { - s = run.Status + s = "unknown" } return fmt.Sprintf("%s ยท %s", run.WorkflowName, s) } diff --git a/internal/ui/dashboard_test.go b/internal/ui/dashboard_test.go index 91dd42c..ad89cde 100644 --- a/internal/ui/dashboard_test.go +++ b/internal/ui/dashboard_test.go @@ -87,24 +87,6 @@ func send(t *testing.T, d Dashboard, msg tea.Msg) Dashboard { return d } -func TestRunFailed(t *testing.T) { - cases := map[string]bool{ - "failure": true, - "timed_out": true, - "action_required": true, - "startup_failure": true, - "success": false, - "skipped": false, - "cancelled": false, - "": false, - } - for conclusion, want := range cases { - if got := runFailed(wfRun("CI", conclusion, 1)); got != want { - t.Errorf("runFailed(%q) = %v, want %v", conclusion, got, want) - } - } -} - func TestRerunIgnoredWhenRunIsGreen(t *testing.T) { fake := &fakeRerunner{} d := dashboardWith(fake, wfRun("CI", "success", 7)) diff --git a/internal/ui/detail.go b/internal/ui/detail.go deleted file mode 100644 index b2f3c14..0000000 --- a/internal/ui/detail.go +++ /dev/null @@ -1,81 +0,0 @@ -package ui - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/ericdahl-dev/git-green/internal/state" -) - -type BackMsg struct{} - -var detailTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) - -type Detail struct { - repo state.RepoState -} - -func NewDetail(repo state.RepoState) Detail { - return Detail{repo: repo} -} - -func (d Detail) CurrentRepo() state.RepoState { - return d.repo -} - -func (d Detail) Init() tea.Cmd { return nil } - -func (d Detail) Update(msg tea.Msg) (Detail, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc", "backspace": - return d, func() tea.Msg { return BackMsg{} } - } - case state.Snapshot: - for _, r := range msg.Repos { - if r.Owner == d.repo.Owner && r.Name == d.repo.Name { - d.repo = r - break - } - } - } - return d, nil -} - -func (d Detail) View() string { - header := fmt.Sprintf("%s %s", d.repo.Stoplight.String(), d.repo.FullName()) - out := detailTitleStyle.Render(header) + "\n" - - if d.repo.Err != nil { - out += jobRed.Render(fmt.Sprintf(" โš  Error: %v", d.repo.Err)) + "\n\n" - } - - if len(d.repo.Runs) == 0 { - out += jobFaint.Render(" No runs found.") + "\n" - } - - for _, run := range d.repo.Runs { - status := run.Conclusion - if status == "" { - status = run.Status - } - wfLine := fmt.Sprintf(" %s %s", workflowStatusIcon(status), run.WorkflowName) - out += wfLine + "\n" - for _, job := range run.Jobs { - jobStatus := job.Conclusion - if jobStatus == "" { - jobStatus = job.Status - } - jobLine := fmt.Sprintf(" %s %s", jobStatusIcon(jobStatus), job.Name) - out += jobLine + "\n" - } - out += "\n" - } - - out += hintStyle.Render("esc/backspace back o open r refresh ? help") - return out -} - - diff --git a/internal/ui/icons.go b/internal/ui/icons.go index 2fd339f..4b2892a 100644 --- a/internal/ui/icons.go +++ b/internal/ui/icons.go @@ -3,6 +3,7 @@ package ui import ( "github.com/charmbracelet/lipgloss" + "github.com/ericdahl-dev/git-green/internal/aggregator" githubclient "github.com/ericdahl-dev/git-green/internal/github" ) @@ -13,23 +14,20 @@ var ( jobFaint = lipgloss.NewStyle().Faint(true) ) -func workflowStatusIcon(status string) string { - switch status { - case "success", "neutral", "skipped": +// runIcon marks a Run or Job by its effective status, in its Stoplight colour. +func runIcon(status string) string { + switch aggregator.Of(status) { + case aggregator.StoplightGreen: return jobGreen.Render("โœ“") - case "failure", "timed_out", "action_required": + case aggregator.StoplightRed: return jobRed.Render("โœ—") - case "queued", "in_progress": + case aggregator.StoplightYellow: return jobYellow.Render("โ—") default: return jobFaint.Render("โ—‹") } } -func jobStatusIcon(status string) string { - return workflowStatusIcon(status) -} - // reviewGlyph marks a PR's review state after its number, with a leading // space, or returns nothing when there is no review signal. func reviewGlyph(r githubclient.Review) string { diff --git a/internal/ui/manage.go b/internal/ui/manage.go index 48e34b2..2e934a9 100644 --- a/internal/ui/manage.go +++ b/internal/ui/manage.go @@ -10,6 +10,9 @@ import ( ) // ConfigChangedMsg is sent when the config has been mutated (add/edit/delete/toggle). +// BackMsg is sent when a sub-screen wants to return to the Dashboard. +type BackMsg struct{} + type ConfigChangedMsg struct { Config *config.Config } diff --git a/internal/ui/stacks.go b/internal/ui/stacks.go index 4bd94a8..c58bf47 100644 --- a/internal/ui/stacks.go +++ b/internal/ui/stacks.go @@ -67,7 +67,7 @@ func groupPRs(prs []state.PRState) []prGroup { } g := &groups[gi] g.prIdxs = append(g.prIdxs, i) - if stoplightPriority(pr.Stoplight) < stoplightPriority(g.stoplight) { + if pr.Stoplight.ActiveFirst() < g.stoplight.ActiveFirst() { g.stoplight = pr.Stoplight } } @@ -88,7 +88,7 @@ func groupPRs(prs []state.PRState) []prGroup { } sort.SliceStable(groups, func(a, b int) bool { - return stoplightPriority(groups[a].stoplight) < stoplightPriority(groups[b].stoplight) + return groups[a].stoplight.ActiveFirst() < groups[b].stoplight.ActiveFirst() }) return groups }