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:
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user