adding sum of estimates and fixing workflow progress menu (#32694)

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>
This commit is contained in:
George Karr
2025-09-11 13:47:46 -05:00
committed by GitHub
co-authored by Jordan Montgomery
parent 43bbb4686a
commit 6ebbef874b
4 changed files with 328 additions and 13 deletions
+57 -8
View File
@@ -339,6 +339,8 @@ func (m *model) generateIssueContent(issue ghapi.Issue) string {
func (m *model) applyFilter() {
if m.filterInput == "" {
// No filter, show all issues
// Ensure base list is sorted
ghapi.SortIssuesForDisplay(m.choices)
m.filteredChoices = m.choices
m.originalIndices = make([]int, len(m.choices))
for i := range m.originalIndices {
@@ -351,6 +353,8 @@ func (m *model) applyFilter() {
m.filteredChoices = nil
m.originalIndices = nil
// Ensure base list is sorted before applying filter
ghapi.SortIssuesForDisplay(m.choices)
for i, issue := range m.choices {
if m.matchesFilter(issue, filter) {
m.filteredChoices = append(m.filteredChoices, issue)
@@ -364,6 +368,11 @@ func (m *model) applyFilter() {
}
}
// --- Sorting helpers for Normal view ordering ---
// labelNamesLower returns a set of lowercase label names for fast lookup.
// Sorting moved to ghapi.SortIssuesForDisplay
func (m *model) matchesFilter(issue ghapi.Issue, filter string) bool {
// Check issue number
if strings.Contains(strings.ToLower(fmt.Sprintf("#%d", issue.Number)), filter) {
@@ -447,13 +456,13 @@ func fetchProjectItems(projectID, limit int) tea.Cmd {
func fetchEstimatedItems(projectID, limit int) tea.Cmd {
return func() tea.Msg {
items, err := ghapi.GetEstimatedTicketsForProject(projectID, limit)
items, total, err := ghapi.GetEstimatedTicketsForProjectWithTotal(projectID, limit)
if err != nil {
return err
}
issues := ghapi.ConvertItemsToIssues(items)
// Estimated issues are filtered from drafting project so available equals fetched length
return issuesLoadedMsg{issues: issues, totalAvailable: len(items), rawFetched: len(items)}
// totalAvailable reflects total items in drafting project; rawFetched is the fetch limit
return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit}
}
}
@@ -1094,8 +1103,38 @@ func (m model) View() string {
// Show overall progress
s += fmt.Sprintf("Overall Progress: %s\n\n", m.overallProgress.View())
// Show individual task progress
for i, task := range m.tasks {
// Determine window of tasks to display (show most recent 10, auto-scroll)
totalTasks := len(m.tasks)
lastFinished := -1
for i := range m.tasks {
if m.tasks[i].Status == TaskSuccess || m.tasks[i].Status == TaskError {
lastFinished = i
}
}
// Prefer to keep the currently running task in view
lastIdx := lastFinished
if m.currentTask > lastIdx {
lastIdx = m.currentTask
}
if lastIdx < 0 {
lastIdx = 0
}
windowSize := 10
start := lastIdx - windowSize + 1
if start < 0 {
start = 0
}
end := start + windowSize
if end > totalTasks {
end = totalTasks
}
// Show individual task progress (windowed)
if start > 0 {
s += fmt.Sprintf("... %d earlier task(s) above ...\n", start)
}
for i := start; i < end; i++ {
task := m.tasks[i]
var statusIcon string
var statusText string
@@ -1126,10 +1165,12 @@ func (m model) View() string {
}
s += "\n"
}
if end < totalTasks {
s += fmt.Sprintf("... %d more task(s) below ...\n", totalTasks-end)
}
// Add progress counter at the bottom
completedTasks := 0
totalTasks := len(m.tasks)
for _, task := range m.tasks {
if task.Status == TaskSuccess {
completedTasks++
@@ -1196,12 +1237,20 @@ func (m model) View() string {
currentPos := m.cursor + 1
totalFiltered := len(currentChoices)
// Compute sum of estimates for currently selected issues
sumSelectedEstimates := 0
for idx := range m.selected {
if idx >= 0 && idx < len(m.choices) {
sumSelectedEstimates += m.choices[idx].Estimate
}
}
// Header with filter info
headerText := ""
if m.filterInput != "" {
headerText = fmt.Sprintf("GitHub Issues (%d/%d) - Filtered by: '%s':\n\n", currentPos, totalFiltered, m.filterInput)
headerText = fmt.Sprintf("GitHub Issues (%d/%d, Σest sel=%d) - Filtered by: '%s':\n\n", currentPos, totalFiltered, sumSelectedEstimates, m.filterInput)
} else {
headerText = fmt.Sprintf("GitHub Issues (%d/%d):\n\n", currentPos, m.totalCount)
headerText = fmt.Sprintf("GitHub Issues (%d/%d, Σest sel=%d):\n\n", currentPos, m.totalCount, sumSelectedEstimates)
}
warningBanner := ""
+101
View File
@@ -0,0 +1,101 @@
package ghapi
import (
"sort"
"strings"
)
// labelNamesLower returns a set of lowercase label names for fast lookup.
func labelNamesLower(issue Issue) map[string]struct{} {
names := make(map[string]struct{}, len(issue.Labels))
for _, l := range issue.Labels {
names[strings.ToLower(strings.TrimSpace(l.Name))] = struct{}{}
}
return names
}
// hasAnyLabelPrefix checks if any label starts with given prefixes (case-insensitive).
func hasAnyLabelPrefix(issue Issue, prefixes ...string) bool {
for _, l := range issue.Labels {
ln := strings.ToLower(strings.TrimSpace(l.Name))
for _, p := range prefixes {
if strings.HasPrefix(ln, strings.ToLower(p)) {
return true
}
}
}
return false
}
// priorityRank returns 0 for P0, 1 for P1, 2 for P2, 3 for none.
func priorityRank(issue Issue) int {
rank := 3
for _, l := range issue.Labels {
ln := strings.ToUpper(strings.TrimSpace(l.Name))
switch ln {
case "P0":
if rank > 0 {
rank = 0
}
case "P1":
if rank > 1 {
rank = 1
}
case "P2":
if rank > 2 {
rank = 2
}
}
}
return rank
}
// custProspectRank returns 0 if any label starts with customer- or prospect-, else 1.
func custProspectRank(issue Issue) int {
if hasAnyLabelPrefix(issue, "customer-", "prospect-") {
return 0
}
return 1
}
// typeRank returns 0 for story, 1 for bug, 2 for ~sub-task, 3 otherwise.
func typeRank(issue Issue) int {
names := labelNamesLower(issue)
if _, ok := names["story"]; ok {
return 0
}
if _, ok := names["bug"]; ok {
return 1
}
if _, ok := names["~sub-task"]; ok {
return 2
}
return 3
}
// SortIssuesForDisplay sorts issues in-place using the following precedence:
// 1) Priority labels: P0, P1, P2, then none
// 2) Presence of labels starting with customer- or prospect-
// 3) Type labels: story, bug, ~sub-task, then others
// 4) Issue number descending
func SortIssuesForDisplay(items []Issue) {
sort.SliceStable(items, func(i, j int) bool {
// 1) Priority P0/P1/P2/none
pi, pj := priorityRank(items[i]), priorityRank(items[j])
if pi != pj {
return pi < pj
}
// 2) Customer/Prospect present first
ci, cj := custProspectRank(items[i]), custProspectRank(items[j])
if ci != cj {
return ci < cj
}
// 3) Type story, bug, ~sub-task, others
ti, tj := typeRank(items[i]), typeRank(items[j])
if ti != tj {
return ti < tj
}
// 4) Number descending
return items[i].Number > items[j].Number
})
}
+156
View File
@@ -0,0 +1,156 @@
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")
}
}
+14 -5
View File
@@ -37,22 +37,31 @@ func GetMDMTicketsEstimated() ([]ProjectItem, error) {
// GetEstimatedTicketsForProject gets estimated tickets from the drafting project filtered by the project's label.
func GetEstimatedTicketsForProject(projectID, limit int) ([]ProjectItem, error) {
items, _, err := GetEstimatedTicketsForProjectWithTotal(projectID, limit)
return items, err
}
// GetEstimatedTicketsForProjectWithTotal returns filtered estimated issues and the total
// number of items in the drafting project (unfiltered). This allows callers to warn when
// the drafting project's total exceeds the fetch limit (some estimated items might be
// beyond the first page).
func GetEstimatedTicketsForProjectWithTotal(projectID, limit int) ([]ProjectItem, int, error) {
// Get the label for this project
label, exists := ProjectLabels[projectID]
if !exists {
return nil, fmt.Errorf("no label mapping found for project ID %d. Available projects: %v", projectID, getProjectIDsWithLabels())
return nil, 0, fmt.Errorf("no label mapping found for project ID %d. Available projects: %v", projectID, getProjectIDsWithLabels())
}
// Grab issues from Drafting project
draftingProjectID := Aliases["draft"]
estimatedName, err := FindFieldValueByName(draftingProjectID, "Status", "estimated")
if err != nil {
return nil, err
return nil, 0, err
}
issues, err := GetProjectItems(draftingProjectID, limit)
issues, total, err := GetProjectItemsWithTotal(draftingProjectID, limit)
if err != nil {
return nil, err
return nil, 0, err
}
// filter down to issues that are estimated with the specified label
@@ -69,7 +78,7 @@ func GetEstimatedTicketsForProject(projectID, limit int) ([]ProjectItem, error)
}
}
}
return estimatedIssues, nil
return estimatedIssues, total, nil
}
// getProjectIDsWithLabels returns a slice of project IDs that have label mappings.