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 }