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:
co-authored by
Jordan Montgomery
parent
43bbb4686a
commit
6ebbef874b
@@ -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 := ""
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user