Adding progress bar to milestone report and outfile so you can watch the progress bar easier and still manipulate w/ grep after (#46354)

This commit is contained in:
George Karr
2026-05-28 12:46:16 -05:00
committed by GitHub
parent d8c6f96033
commit 887dcf363f
4 changed files with 145 additions and 23 deletions
+55 -20
View File
@@ -1,7 +1,9 @@
package main
import (
"bytes"
"fmt"
"os"
"sort"
"strconv"
"strings"
@@ -26,6 +28,7 @@ var (
milestoneIncludeClosed bool
milestoneIgnoreProject string
milestoneFilterLabels string
milestoneOutfile string
)
var milestoneReportCmd = &cobra.Command{
@@ -120,8 +123,13 @@ var milestoneReportCmd = &cobra.Command{
issueNums = append(issueNums, it.Number)
}
// Determine all projects across these issues, dynamically
projects, _ := ghapi.GetProjectsForIssues(issueNums)
// Determine all projects across these issues, dynamically.
// This makes one API call per issue, so show progress on stderr.
discoverBar := util.NewProgressBar("Discovering projects", len(issueNums))
projects, _ := ghapi.GetProjectsForIssues(issueNums, func(current, total, issueNumber int) {
discoverBar.Update(current, fmt.Sprintf("#%d", issueNumber))
})
discoverBar.Done()
// Apply ignore-project filtering if provided
if strings.TrimSpace(milestoneIgnoreProject) != "" {
// build tokens (case-insensitive substring match)
@@ -179,23 +187,28 @@ var milestoneReportCmd = &cobra.Command{
format = "tsv"
}
// Build the full report into a buffer so progress bars (stderr) are the
// only thing on screen while we fetch, then emit the report once to
// stdout and/or the --outfile at the end.
var report bytes.Buffer
switch format {
case "tsv":
// Print header as TSV
// Heading line first
fmt.Printf("Milestone\treport\t%s\n", milestoneName)
fmt.Println(strings.Join(headers, "\t"))
fmt.Fprintf(&report, "Milestone\treport\t%s\n", milestoneName)
fmt.Fprintln(&report, strings.Join(headers, "\t"))
case "md", "markdown":
// Heading line first
fmt.Printf("|Milestone report %s|\n", milestoneName)
fmt.Fprintf(&report, "|Milestone report %s|\n", milestoneName)
// Markdown header
fmt.Printf("| %s |\n", strings.Join(headers, " | "))
fmt.Fprintf(&report, "| %s |\n", strings.Join(headers, " | "))
// Separator row
seps := make([]string, len(headers))
for i := range seps {
seps[i] = "---"
}
fmt.Printf("| %s |\n", strings.Join(seps, " | "))
fmt.Fprintf(&report, "| %s |\n", strings.Join(seps, " | "))
default:
return fmt.Errorf("unsupported --format %q (use: tsv or md)", milestoneFormat)
}
@@ -203,9 +216,12 @@ var milestoneReportCmd = &cobra.Command{
// Aggregator: projectID -> status -> count (only when Present)
agg := make(map[int]map[string]int, len(projects))
// For each issue, gather statuses per project
for _, mi := range msIssues {
// For each issue, gather statuses per project. This makes one API call
// per issue, so show progress on stderr.
statusBar := util.NewProgressBar("Fetching statuses", len(msIssues))
for i, mi := range msIssues {
num := mi.Number
statusBar.Update(i+1, fmt.Sprintf("#%d", num))
// Build the list of project IDs in header order
pids := make([]int, 0, len(projects))
for _, p := range projects {
@@ -248,11 +264,12 @@ var milestoneReportCmd = &cobra.Command{
}
row = append(row, util.TruncateTitle(title, 25))
if format == "tsv" {
fmt.Println(strings.Join(row, "\t"))
fmt.Fprintln(&report, strings.Join(row, "\t"))
} else {
fmt.Printf("| %s |\n", strings.Join(row, " | "))
fmt.Fprintf(&report, "| %s |\n", strings.Join(row, " | "))
}
}
statusBar.Done()
// Build and print summary rows: Project, Status, Count
type sumRow struct {
@@ -317,9 +334,9 @@ var milestoneReportCmd = &cobra.Command{
})
if format == "tsv" {
fmt.Println()
fmt.Println("Summary")
fmt.Println(strings.Join([]string{"Project", "Status", "Count"}, "\t"))
fmt.Fprintln(&report)
fmt.Fprintln(&report, "Summary")
fmt.Fprintln(&report, strings.Join([]string{"Project", "Status", "Count"}, "\t"))
for _, r := range rows {
proj := r.Project
stat := r.Status
@@ -327,13 +344,13 @@ var milestoneReportCmd = &cobra.Command{
proj = util.StripEmojis(proj)
stat = util.StripEmojis(stat)
}
fmt.Println(strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t"))
fmt.Fprintln(&report, strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t"))
}
} else {
fmt.Println()
fmt.Println("Summary")
fmt.Printf("| %s |\n", strings.Join([]string{"Project", "Status", "Count"}, " | "))
fmt.Printf("| %s |\n", strings.Join([]string{"---", "---", "---"}, " | "))
fmt.Fprintln(&report)
fmt.Fprintln(&report, "Summary")
fmt.Fprintf(&report, "| %s |\n", strings.Join([]string{"Project", "Status", "Count"}, " | "))
fmt.Fprintf(&report, "| %s |\n", strings.Join([]string{"---", "---", "---"}, " | "))
for _, r := range rows {
proj := r.Project
stat := r.Status
@@ -341,10 +358,27 @@ var milestoneReportCmd = &cobra.Command{
proj = util.StripEmojis(proj)
stat = util.StripEmojis(stat)
}
fmt.Printf("| %s | %s | %d |\n", proj, stat, r.Count)
fmt.Fprintf(&report, "| %s | %s | %d |\n", proj, stat, r.Count)
}
}
}
// Emit the report: always to stdout, and to --outfile if provided.
if _, err := os.Stdout.Write(report.Bytes()); err != nil {
return fmt.Errorf("failed to write report: %v", err)
}
if outfile := strings.TrimSpace(milestoneOutfile); outfile != "" {
if err := os.WriteFile(outfile, report.Bytes(), 0o644); err != nil {
return fmt.Errorf("failed to write report to %q: %v", outfile, err)
}
fmt.Fprintf(os.Stderr, "\nReport written to %s\n", outfile)
// Tab-separated reports view best aligned via `column` on macOS/Linux.
if format == "tsv" {
fmt.Fprintf(os.Stderr, "Tip: view aligned columns with:\n cat %s | column -ts $'\\t'\n", outfile)
}
} else if format == "tsv" {
fmt.Fprintf(os.Stderr, "\nTip: save with -o <file>, then view aligned columns with:\n cat <file> | column -ts $'\\t'\n")
}
return nil
},
}
@@ -398,4 +432,5 @@ func init() {
milestoneReportCmd.Flags().BoolVar(&milestoneIncludeClosed, "include-closed", false, "Include closed milestones when listing available milestones")
milestoneReportCmd.Flags().StringVar(&milestoneIgnoreProject, "ignore-project", "", "Comma-separated substrings to exclude matching project titles (case-insensitive). Example: 'qa,cust' excludes ':help-qa', ':help-customers', and 'Customer requests (open)'.")
milestoneReportCmd.Flags().StringVar(&milestoneFilterLabels, "filter-labels", "", "Comma-separated list of labels; only issues containing ALL of these labels are included (case-insensitive). Example: 'story,customer-numa'.")
milestoneReportCmd.Flags().StringVarP(&milestoneOutfile, "outfile", "o", "", "Also write the report (without progress output) to this file.")
}
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
)
@@ -26,7 +27,6 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+7 -2
View File
@@ -124,9 +124,14 @@ func GetIssueProjects(issueNumber int) (map[int]string, error) {
}
// GetProjectsForIssues gathers the union of projects across the provided issues.
func GetProjectsForIssues(issueNumbers []int) ([]ProjectInfo, error) {
// If progress is non-nil it is called once per issue (1-based current, total,
// issue number) so callers can render progress for this per-issue fetch.
func GetProjectsForIssues(issueNumbers []int, progress func(current, total, issueNumber int)) ([]ProjectInfo, error) {
seen := make(map[int]string)
for _, num := range issueNumbers {
for i, num := range issueNumbers {
if progress != nil {
progress(i+1, len(issueNumbers), num)
}
prjs, err := GetIssueProjects(num)
if err != nil {
// tolerate errors per-issue, continue accumulating from others
+82
View File
@@ -0,0 +1,82 @@
package util
import (
"fmt"
"os"
"strings"
"time"
"github.com/mattn/go-isatty"
)
// ProgressBar renders an inline progress bar to stderr for long per-item loops
// (e.g. fetching metadata for each issue in a milestone report). It writes only
// to stderr, so it never corrupts report output sent to stdout or a file. When
// stderr is not a terminal the bar stays silent to avoid polluting piped or
// redirected output.
type ProgressBar struct {
label string
total int
width int
start time.Time
enabled bool
}
// NewProgressBar creates a progress bar for total steps with the given label.
func NewProgressBar(label string, total int) *ProgressBar {
fd := os.Stderr.Fd()
return &ProgressBar{
label: label,
total: total,
width: 30,
start: time.Now(),
enabled: isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd),
}
}
// Update redraws the bar at the given 1-based step. detail is shown after the
// counter, e.g. the issue number currently being fetched ("#46029").
func (p *ProgressBar) Update(current int, detail string) {
if p == nil || !p.enabled || p.total <= 0 {
return
}
if current < 0 {
current = 0
}
if current > p.total {
current = p.total
}
frac := float64(current) / float64(p.total)
filled := int(frac * float64(p.width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", p.width-filled)
line := fmt.Sprintf("%s [%s] %d/%d (%.0f%%)", p.label, bar, current, p.total, frac*100)
if detail != "" {
line += " " + detail
}
if eta := p.eta(current); eta != "" {
line += " " + eta
}
// \r returns to column 0; \033[K clears leftovers from a longer prior line.
fmt.Fprintf(os.Stderr, "\r\033[K%s", line)
}
// eta estimates remaining time from the average duration per completed step.
func (p *ProgressBar) eta(current int) string {
if current <= 0 || current >= p.total {
return ""
}
per := time.Since(p.start) / time.Duration(current)
remaining := (per * time.Duration(p.total-current)).Round(time.Second)
return fmt.Sprintf("ETA %s", remaining)
}
// Done terminates the bar's line so subsequent output starts cleanly. It is a
// no-op when the bar is disabled.
func (p *ProgressBar) Done() {
if p == nil || !p.enabled {
return
}
fmt.Fprintln(os.Stderr)
}