diff --git a/tools/github-manage/cmd/gm/milestone.go b/tools/github-manage/cmd/gm/milestone.go index 2ef490c06d..d2a57fe79d 100644 --- a/tools/github-manage/cmd/gm/milestone.go +++ b/tools/github-manage/cmd/gm/milestone.go @@ -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 , then view aligned columns with:\n cat | 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.") } diff --git a/tools/github-manage/go.mod b/tools/github-manage/go.mod index 6eebbffa98..f7b0deffe6 100644 --- a/tools/github-manage/go.mod +++ b/tools/github-manage/go.mod @@ -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 diff --git a/tools/github-manage/pkg/ghapi/milestone.go b/tools/github-manage/pkg/ghapi/milestone.go index 85669aed75..8ce0db8020 100644 --- a/tools/github-manage/pkg/ghapi/milestone.go +++ b/tools/github-manage/pkg/ghapi/milestone.go @@ -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 diff --git a/tools/github-manage/pkg/util/progress.go b/tools/github-manage/pkg/util/progress.go new file mode 100644 index 0000000000..15d4f3a92c --- /dev/null +++ b/tools/github-manage/pkg/util/progress.go @@ -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) +}