Skip to content

Commit 65a9ac2

Browse files
authored
Merge pull request #17 from github/output-features
feat: output improvements
2 parents 28d98bb + c0604c3 commit 65a9ac2

File tree

1 file changed

+211
-14
lines changed

1 file changed

+211
-14
lines changed

internal/cmd/root.go

Lines changed: 211 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package cmd
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
8+
"strings"
79
"time"
810

911
"github.com/cli/go-gh/v2/pkg/api"
@@ -38,6 +40,7 @@ var (
3840
caseSensitiveLabels bool
3941
noColor bool
4042
noStats bool
43+
outputFormat string
4144
)
4245

4346
// StatsCollector tracks stats for the CLI run
@@ -59,6 +62,7 @@ type RepoStats struct {
5962
SkippedCriteria int
6063
CombinedPRLink string
6164
NotEnoughPRs bool
65+
TotalPRs int
6266
}
6367

6468
// NewRootCmd creates the root command for the gh-combine CLI
@@ -155,6 +159,7 @@ func NewRootCmd() *cobra.Command {
155159
rootCmd.Flags().BoolVar(&caseSensitiveLabels, "case-sensitive-labels", false, "Use case-sensitive label matching")
156160
rootCmd.Flags().BoolVar(&noColor, "no-color", false, "Disable color output")
157161
rootCmd.Flags().BoolVar(&noStats, "no-stats", false, "Disable stats summary display")
162+
rootCmd.Flags().StringVar(&outputFormat, "output", "table", "Output format: table, plain, or json")
158163

159164
// Add deprecated flags for backward compatibility
160165
// rootCmd.Flags().IntVar(&minimum, "min-combine", 2, "Minimum number of PRs to combine (deprecated, use --minimum)")
@@ -213,7 +218,7 @@ func runCombine(cmd *cobra.Command, args []string) error {
213218

214219
if !noStats {
215220
spinner.Stop()
216-
displayStatsSummary(stats)
221+
displayStatsSummary(stats, outputFormat)
217222
}
218223

219224
return nil
@@ -287,6 +292,8 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien
287292
return fmt.Errorf("failed to fetch open pull requests: %w", err)
288293
}
289294

295+
repoStats.TotalPRs = len(pulls)
296+
290297
// Check for cancellation again
291298
select {
292299
case <-ctx.Done():
@@ -400,24 +407,214 @@ func fetchOpenPullRequests(ctx context.Context, client *api.RESTClient, repo git
400407
return allPulls, nil
401408
}
402409

403-
func displayStatsSummary(stats *StatsCollector) {
404-
elapsed := stats.EndTime.Sub(stats.StartTime)
405-
if noColor {
406-
fmt.Println("Stats Summary (Color Disabled):")
407-
} else {
408-
fmt.Println("\033[1;34mStats Summary:\033[0m")
410+
func displayStatsSummary(stats *StatsCollector, outputFormat string) {
411+
switch outputFormat {
412+
case "table":
413+
displayTableStats(stats)
414+
case "json":
415+
displayJSONStats(stats)
416+
case "plain":
417+
fallthrough
418+
default:
419+
displayPlainStats(stats)
420+
}
421+
}
422+
423+
func displayTableStats(stats *StatsCollector) {
424+
// ANSI color helpers
425+
green := "\033[32m"
426+
yellow := "\033[33m"
427+
reset := "\033[0m"
428+
colorize := func(s, color string) string {
429+
if noColor {
430+
return s
431+
}
432+
return color + s + reset
433+
}
434+
435+
// Find max repo name length
436+
maxRepoLen := len("Repository")
437+
for _, repoStat := range stats.PerRepoStats {
438+
if l := len(repoStat.RepoName); l > maxRepoLen {
439+
maxRepoLen = l
440+
}
409441
}
442+
if maxRepoLen > 40 {
443+
maxRepoLen = 40 // hard cap for very long repo names
444+
}
445+
446+
repoCol := maxRepoLen
447+
colWidths := []int{repoCol, 14, 20, 12}
448+
449+
// Table border helpers
450+
top := "╭"
451+
sep := "├"
452+
bot := "╰"
453+
for i, w := range colWidths {
454+
top += pad("─", w+2) // +2 for padding spaces
455+
sep += pad("─", w+2)
456+
bot += pad("─", w+2)
457+
if i < len(colWidths)-1 {
458+
top += "┬"
459+
sep += "┼"
460+
bot += "┴"
461+
} else {
462+
top += "╮"
463+
sep += "┤"
464+
bot += "╯"
465+
}
466+
}
467+
468+
headRepo := fmt.Sprintf("%-*s", repoCol, "Repository")
469+
headCombined := fmt.Sprintf("%*s", colWidths[1], "PRs Combined")
470+
headSkipped := fmt.Sprintf("%-*s", colWidths[2], "Skipped")
471+
headStatus := fmt.Sprintf("%-*s", colWidths[3], "Status")
472+
head := fmt.Sprintf(
473+
"│ %-*s │ %s │ %s │ %s │",
474+
repoCol, headRepo,
475+
headCombined,
476+
headSkipped,
477+
headStatus,
478+
)
479+
480+
fmt.Println(top)
481+
fmt.Println(head)
482+
fmt.Println(sep)
483+
484+
for _, repoStat := range stats.PerRepoStats {
485+
status := "OK"
486+
statusColor := green
487+
if repoStat.TotalPRs == 0 {
488+
status = "NO OPEN PRs"
489+
statusColor = green
490+
} else if repoStat.NotEnoughPRs {
491+
status = "NOT ENOUGH"
492+
statusColor = yellow
493+
}
494+
495+
mcColor := green
496+
dnmColor := green
497+
if repoStat.SkippedMergeConf > 0 {
498+
mcColor = yellow
499+
}
500+
if repoStat.SkippedCriteria > 0 {
501+
dnmColor = yellow
502+
}
503+
mcRaw := fmt.Sprintf("%d", repoStat.SkippedMergeConf)
504+
dnmRaw := fmt.Sprintf("%d", repoStat.SkippedCriteria)
505+
skippedRaw := fmt.Sprintf("%s (MC), %s (DNM)", mcRaw, dnmRaw)
506+
507+
repoName := truncate(repoStat.RepoName, repoCol)
508+
combined := fmt.Sprintf("%*d", colWidths[1], repoStat.CombinedCount)
509+
// Pad skippedRaw to colWidths[2] before coloring
510+
skippedPadded := fmt.Sprintf("%-*s", colWidths[2], skippedRaw)
511+
// Colorize only the numbers in the padded string
512+
mcIdx := strings.Index(skippedPadded, mcRaw)
513+
dnmIdx := strings.Index(skippedPadded, dnmRaw)
514+
skippedColored := skippedPadded
515+
if mcIdx != -1 {
516+
skippedColored = skippedColored[:mcIdx] + colorize(mcRaw, mcColor) + skippedColored[mcIdx+len(mcRaw):]
517+
}
518+
if dnmIdx != -1 {
519+
dnmIdx = strings.Index(skippedColored, dnmRaw) // recalc in case mcRaw and dnmRaw overlap
520+
skippedColored = skippedColored[:dnmIdx] + colorize(dnmRaw, dnmColor) + skippedColored[dnmIdx+len(dnmRaw):]
521+
}
522+
statusColored := colorize(status, statusColor)
523+
statusColored = fmt.Sprintf("%-*s", colWidths[3]+len(statusColored)-len(status), statusColored)
524+
525+
fmt.Printf(
526+
"│ %-*s │ %s │ %s │ %s │\n",
527+
repoCol, repoName,
528+
combined,
529+
skippedColored,
530+
statusColored,
531+
)
532+
}
533+
fmt.Println(bot)
534+
535+
// Print summary mini-table with proper padding
536+
summaryTop := "╭───────────────┬───────────────┬───────────────────────┬───────────────╮"
537+
summaryHead := "│ Repos │ Combined PRs │ Skipped │ Total PRs │"
538+
summarySep := "├───────────────┼───────────────┼───────────────────────┼───────────────┤"
539+
skippedRaw := fmt.Sprintf("%d (MC), %d (DNM)", stats.PRsSkippedMergeConflict, stats.PRsSkippedCriteria)
540+
summaryRow := fmt.Sprintf(
541+
"│ %-13d │ %-13d │ %-21s │ %-13d │",
542+
stats.ReposProcessed,
543+
stats.PRsCombined,
544+
skippedRaw,
545+
len(stats.CombinedPRLinks),
546+
)
547+
summaryBot := "╰───────────────┴───────────────┴───────────────────────┴───────────────╯"
548+
fmt.Println()
549+
fmt.Println(summaryTop)
550+
fmt.Println(summaryHead)
551+
fmt.Println(summarySep)
552+
fmt.Println(summaryRow)
553+
fmt.Println(summaryBot)
554+
555+
// Print PR links block (blue color)
556+
if len(stats.CombinedPRLinks) > 0 {
557+
blue := "\033[34m"
558+
reset := "\033[0m"
559+
fmt.Println("\nLinks to Combined PRs:")
560+
for _, link := range stats.CombinedPRLinks {
561+
if noColor {
562+
fmt.Println("-", link)
563+
} else {
564+
fmt.Printf("- %s%s%s\n", blue, link, reset)
565+
}
566+
}
567+
}
568+
fmt.Println()
569+
}
570+
571+
// pad returns a string of n runes of s (usually "─")
572+
func pad(s string, n int) string {
573+
if n <= 0 {
574+
return ""
575+
}
576+
out := ""
577+
for i := 0; i < n; i++ {
578+
out += s
579+
}
580+
return out
581+
}
582+
583+
// truncate shortens a string to maxLen runes, adding … if truncated
584+
func truncate(s string, maxLen int) string {
585+
runes := []rune(s)
586+
if len(runes) <= maxLen {
587+
return s
588+
}
589+
if maxLen <= 1 {
590+
return string(runes[:maxLen])
591+
}
592+
return string(runes[:maxLen-1]) + "…"
593+
}
594+
595+
func displayJSONStats(stats *StatsCollector) {
596+
output := map[string]interface{}{
597+
"reposProcessed": stats.ReposProcessed,
598+
"prsCombined": stats.PRsCombined,
599+
"prsSkippedMergeConflict": stats.PRsSkippedMergeConflict,
600+
"prsSkippedCriteria": stats.PRsSkippedCriteria,
601+
"executionTime": stats.EndTime.Sub(stats.StartTime).String(),
602+
"combinedPRLinks": stats.CombinedPRLinks,
603+
"perRepoStats": stats.PerRepoStats,
604+
}
605+
jsonData, _ := json.MarshalIndent(output, "", " ")
606+
fmt.Println(string(jsonData))
607+
}
608+
609+
func displayPlainStats(stats *StatsCollector) {
610+
elapsed := stats.EndTime.Sub(stats.StartTime)
410611
fmt.Printf("Repositories Processed: %d\n", stats.ReposProcessed)
411612
fmt.Printf("PRs Combined: %d\n", stats.PRsCombined)
412613
fmt.Printf("PRs Skipped (Merge Conflicts): %d\n", stats.PRsSkippedMergeConflict)
413-
fmt.Printf("PRs Skipped (Criteria Not Met): %d\n", stats.PRsSkippedCriteria)
614+
fmt.Printf("PRs Skipped (Did Not Match): %d\n", stats.PRsSkippedCriteria)
414615
fmt.Printf("Execution Time: %s\n", elapsed.Round(time.Second))
415616

416-
if !noColor {
417-
fmt.Println("\033[1;32mLinks to Combined PRs:\033[0m")
418-
} else {
419-
fmt.Println("Links to Combined PRs:")
420-
}
617+
fmt.Println("Links to Combined PRs:")
421618
for _, link := range stats.CombinedPRLinks {
422619
fmt.Println("-", link)
423620
}
@@ -431,7 +628,7 @@ func displayStatsSummary(stats *StatsCollector) {
431628
}
432629
fmt.Printf(" Combined: %d\n", repoStat.CombinedCount)
433630
fmt.Printf(" Skipped (Merge Conflicts): %d\n", repoStat.SkippedMergeConf)
434-
fmt.Printf(" Skipped (Criteria): %d\n", repoStat.SkippedCriteria)
631+
fmt.Printf(" Skipped (Did Not Match): %d\n", repoStat.SkippedCriteria)
435632
if repoStat.CombinedPRLink != "" {
436633
fmt.Printf(" Combined PR: %s\n", repoStat.CombinedPRLink)
437634
}

0 commit comments

Comments
 (0)