What’s in this PR 1) Smarter default sorting for issues (used by the TUI) New ghapi.SortIssuesForDisplay helper that orders issues by: Priority label (P0 → P1 → P2 → none) Presence of customer-* / prospect-* labels Type labels (story → bug → ~sub-task → others) Issue number (descending) This is applied before filtering so views start in a meaningful order. [GitHub](https://github.com/fleetdm/fleet/pull/32694/files) Implementation lives in tools/github-manage/pkg/ghapi/sort.go. Comprehensive tests cover all combinations, tie-breakers, and stability. GitHub +1 2) Estimates: show the sum for the current selection The header now displays Σest sel=<sum> for the currently selected issues, both in filtered and unfiltered views, making quick capacity checks easier. [GitHub](https://github.com/fleetdm/fleet/pull/32694/files) 3) Better progress UI for workflows Task list is now windowed (last ~10 items) with auto-scroll to the currently running or most recently finished task, plus “earlier/more tasks” ellipses and a progress counter at the bottom. This keeps the view focused during long runs. [GitHub](https://github.com/fleetdm/fleet/pull/32694/files) 4) Project estimates fetch now includes total count Switched from GetEstimatedTicketsForProject to GetEstimatedTicketsForProjectWithTotal, so we can show totalAvailable alongside rawFetched/limit. [GitHub](https://github.com/fleetdm/fleet/pull/32694/files) --------- Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
157 lines
3.8 KiB
Go
157 lines
3.8 KiB
Go
package ghapi
|
|
|
|
import (
|
|
"math/rand"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// helper to make an Issue with given number and label names
|
|
func mkIssue(num int, labels ...string) Issue {
|
|
ls := make([]Label, 0, len(labels))
|
|
for _, n := range labels {
|
|
if n == "" {
|
|
continue
|
|
}
|
|
ls = append(ls, Label{Name: n})
|
|
}
|
|
return Issue{Number: num, Labels: ls}
|
|
}
|
|
|
|
// local rank helpers (must mirror sort.go logic, but kept independent in tests)
|
|
func tPriorityRank(it Issue) int {
|
|
rank := 3
|
|
for _, l := range it.Labels {
|
|
switch l.Name {
|
|
case "P0":
|
|
if rank > 0 {
|
|
rank = 0
|
|
}
|
|
case "P1":
|
|
if rank > 1 {
|
|
rank = 1
|
|
}
|
|
case "P2":
|
|
if rank > 2 {
|
|
rank = 2
|
|
}
|
|
}
|
|
}
|
|
return rank
|
|
}
|
|
|
|
func tCustProspectRank(it Issue) int {
|
|
for _, l := range it.Labels {
|
|
n := strings.ToLower(l.Name)
|
|
if strings.HasPrefix(n, "customer-") || strings.HasPrefix(n, "prospect-") {
|
|
return 0
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func tTypeRank(it Issue) int {
|
|
r := 3
|
|
for _, l := range it.Labels {
|
|
switch l.Name {
|
|
case "story":
|
|
if r > 0 {
|
|
r = 0
|
|
}
|
|
case "bug":
|
|
if r > 1 {
|
|
r = 1
|
|
}
|
|
case "~sub-task":
|
|
if r > 2 {
|
|
r = 2
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func testLess(a, b Issue) bool {
|
|
pa, pb := tPriorityRank(a), tPriorityRank(b)
|
|
if pa != pb {
|
|
return pa < pb
|
|
}
|
|
ca, cb := tCustProspectRank(a), tCustProspectRank(b)
|
|
if ca != cb {
|
|
return ca < cb
|
|
}
|
|
ta, tb := tTypeRank(a), tTypeRank(b)
|
|
if ta != tb {
|
|
return ta < tb
|
|
}
|
|
// number desc
|
|
return a.Number > b.Number
|
|
}
|
|
|
|
func TestSortIssuesForDisplay_AllCombinations_ShuffleAndSort(t *testing.T) {
|
|
priorities := [][]string{{"P0"}, {"P1"}, {"P2"}, {""}}
|
|
custs := [][]string{{"customer-alpha"}, {""}}
|
|
types := [][]string{{"story"}, {"bug"}, {"~sub-task"}, {"otherlabel"}}
|
|
|
|
// generate all 32 combinations
|
|
issues := make([]Issue, 0, len(priorities)*len(custs)*len(types))
|
|
num := 1
|
|
for _, p := range priorities {
|
|
for _, c := range custs {
|
|
for _, ty := range types {
|
|
labels := append([]string{}, p...)
|
|
labels = append(labels, c...)
|
|
labels = append(labels, ty...)
|
|
issues = append(issues, mkIssue(num, labels...))
|
|
num++
|
|
}
|
|
}
|
|
}
|
|
|
|
// expected order via independent comparator
|
|
expected := make([]Issue, len(issues))
|
|
copy(expected, issues)
|
|
sort.SliceStable(expected, func(i, j int) bool { return testLess(expected[i], expected[j]) })
|
|
|
|
// shuffle original and sort using production
|
|
shuffled := make([]Issue, len(issues))
|
|
copy(shuffled, issues)
|
|
r := rand.New(rand.NewSource(42))
|
|
r.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
|
|
SortIssuesForDisplay(shuffled)
|
|
|
|
// compare number sequences
|
|
for i := range expected {
|
|
if expected[i].Number != shuffled[i].Number {
|
|
t.Fatalf("mismatch at %d: expected #%d got #%d", i, expected[i].Number, shuffled[i].Number)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSortIssuesForDisplay_TieBreaker_NumberDesc(t *testing.T) {
|
|
a := mkIssue(10, "story")
|
|
b := mkIssue(20, "story")
|
|
// same ranks (no P*, no customer/prospect, same type), number desc => 20 then 10
|
|
items := []Issue{a, b}
|
|
SortIssuesForDisplay(items)
|
|
if items[0].Number != 20 || items[1].Number != 10 {
|
|
t.Fatalf("expected order [20,10], got [%d,%d]", items[0].Number, items[1].Number)
|
|
}
|
|
}
|
|
|
|
func TestSortIssuesForDisplay_Stable(t *testing.T) {
|
|
// identical ranks and numbers; ensure stability preserves original order
|
|
a := mkIssue(100, "bug")
|
|
b := mkIssue(100, "bug")
|
|
// Embed an index via label to assert stability (since Issue contains slices and isn't comparable)
|
|
a.Labels = append(a.Labels, Label{Name: "idx-a"})
|
|
b.Labels = append(b.Labels, Label{Name: "idx-b"})
|
|
items := []Issue{a, b}
|
|
SortIssuesForDisplay(items)
|
|
// when fully equal keys, order should be unchanged
|
|
if items[0].Labels[len(items[0].Labels)-1].Name != "idx-a" || items[1].Labels[len(items[1].Labels)-1].Name != "idx-b" {
|
|
t.Fatalf("expected stable order to be preserved")
|
|
}
|
|
}
|