@@ -2,8 +2,10 @@ package cmd
2
2
3
3
import (
4
4
"context"
5
+ "encoding/json"
5
6
"errors"
6
7
"fmt"
8
+ "strings"
7
9
"time"
8
10
9
11
"github.com/cli/go-gh/v2/pkg/api"
38
40
caseSensitiveLabels bool
39
41
noColor bool
40
42
noStats bool
43
+ outputFormat string
41
44
)
42
45
43
46
// StatsCollector tracks stats for the CLI run
@@ -59,6 +62,7 @@ type RepoStats struct {
59
62
SkippedCriteria int
60
63
CombinedPRLink string
61
64
NotEnoughPRs bool
65
+ TotalPRs int
62
66
}
63
67
64
68
// NewRootCmd creates the root command for the gh-combine CLI
@@ -155,6 +159,7 @@ func NewRootCmd() *cobra.Command {
155
159
rootCmd .Flags ().BoolVar (& caseSensitiveLabels , "case-sensitive-labels" , false , "Use case-sensitive label matching" )
156
160
rootCmd .Flags ().BoolVar (& noColor , "no-color" , false , "Disable color output" )
157
161
rootCmd .Flags ().BoolVar (& noStats , "no-stats" , false , "Disable stats summary display" )
162
+ rootCmd .Flags ().StringVar (& outputFormat , "output" , "table" , "Output format: table, plain, or json" )
158
163
159
164
// Add deprecated flags for backward compatibility
160
165
// 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 {
213
218
214
219
if ! noStats {
215
220
spinner .Stop ()
216
- displayStatsSummary (stats )
221
+ displayStatsSummary (stats , outputFormat )
217
222
}
218
223
219
224
return nil
@@ -287,6 +292,8 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien
287
292
return fmt .Errorf ("failed to fetch open pull requests: %w" , err )
288
293
}
289
294
295
+ repoStats .TotalPRs = len (pulls )
296
+
290
297
// Check for cancellation again
291
298
select {
292
299
case <- ctx .Done ():
@@ -400,24 +407,214 @@ func fetchOpenPullRequests(ctx context.Context, client *api.RESTClient, repo git
400
407
return allPulls , nil
401
408
}
402
409
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
+ }
409
441
}
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 ("\n Links 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 )
410
611
fmt .Printf ("Repositories Processed: %d\n " , stats .ReposProcessed )
411
612
fmt .Printf ("PRs Combined: %d\n " , stats .PRsCombined )
412
613
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 )
414
615
fmt .Printf ("Execution Time: %s\n " , elapsed .Round (time .Second ))
415
616
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:" )
421
618
for _ , link := range stats .CombinedPRLinks {
422
619
fmt .Println ("-" , link )
423
620
}
@@ -431,7 +628,7 @@ func displayStatsSummary(stats *StatsCollector) {
431
628
}
432
629
fmt .Printf (" Combined: %d\n " , repoStat .CombinedCount )
433
630
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 )
435
632
if repoStat .CombinedPRLink != "" {
436
633
fmt .Printf (" Combined PR: %s\n " , repoStat .CombinedPRLink )
437
634
}
0 commit comments