@@ -102,7 +102,7 @@ func NewCmdIssuesWithDeps(deps *cmddeps.Deps) *cobra.Command {
102102 cmd .Flags ().StringVar (& opts .OutputFile , "output-file" , "" , "Write output to a file instead of stdout" )
103103
104104 // --verbose, -v flag
105- cmd .Flags ().BoolVarP (& opts .Verbose , "verbose" , "v" , false , "Show issue code, analyzer, and description" )
105+ cmd .Flags ().BoolVarP (& opts .Verbose , "verbose" , "v" , false , "Show issue description" )
106106
107107 // Scoping flags
108108 cmd .Flags ().StringVar (& opts .CommitOid , "commit" , "" , "Scope to a specific analysis run by commit OID" )
@@ -312,6 +312,68 @@ func matchesPathFilters(path string, filters []string) bool {
312312
313313// --- Human output ---
314314
315+ type codeGroup struct {
316+ issue issues.Issue
317+ locations []issues.Location
318+ }
319+
320+ type categoryGroup struct {
321+ category string
322+ codes []codeGroup
323+ total int
324+ }
325+
326+ // groupByCategoryAndCode groups issues first by category (preserving first-seen order),
327+ // then by issue code within each category.
328+ func groupByCategoryAndCode (list []issues.Issue ) []categoryGroup {
329+ catOrder := []string {}
330+ catMap := map [string ]* categoryGroup {}
331+
332+ for _ , issue := range list {
333+ cat := issue .IssueCategory
334+ cg , catExists := catMap [cat ]
335+ if ! catExists {
336+ catOrder = append (catOrder , cat )
337+ cg = & categoryGroup {category : cat }
338+ catMap [cat ] = cg
339+ }
340+
341+ found := false
342+ for i := range cg .codes {
343+ if cg .codes [i ].issue .IssueCode == issue .IssueCode {
344+ cg .codes [i ].locations = append (cg .codes [i ].locations , issue .Location )
345+ found = true
346+ break
347+ }
348+ }
349+ if ! found {
350+ cg .codes = append (cg .codes , codeGroup {
351+ issue : issue ,
352+ locations : []issues.Location {issue .Location },
353+ })
354+ }
355+ cg .total ++
356+ }
357+
358+ result := make ([]categoryGroup , 0 , len (catOrder ))
359+ for _ , cat := range catOrder {
360+ result = append (result , * catMap [cat ])
361+ }
362+ return result
363+ }
364+
365+ const ruledLineWidth = 55
366+
367+ func severityRuledHeader (sev string , count int ) string {
368+ label := fmt .Sprintf ("── %s (%d) " , humanizeSeverity (sev ), count )
369+ pad := ruledLineWidth - len (label )
370+ if pad < 3 {
371+ pad = 3
372+ }
373+ line := label + strings .Repeat ("─" , pad )
374+ return colorSeverity (sev , line )
375+ }
376+
315377func (opts * IssuesOptions ) outputHuman () error {
316378 if len (opts .issues ) == 0 {
317379 if opts .hasFilters () {
@@ -332,44 +394,56 @@ func (opts *IssuesOptions) outputHuman() error {
332394 }
333395
334396 for _ , sev := range order {
335- group , ok := groups [sev ]
336- if ! ok || len (group ) == 0 {
397+ sevGroup , ok := groups [sev ]
398+ if ! ok || len (sevGroup ) == 0 {
337399 continue
338400 }
339401
340- header := fmt .Sprintf ("%s (%d)" , humanizeSeverity (sev ), len (group ))
341- fmt .Println (colorSeverity (sev , header ))
402+ fmt .Println (severityRuledHeader (sev , len (sevGroup )))
342403 fmt .Println ()
343404
344- for _ , issue := range group {
345- location := formatLocation (issue , cwd )
346- category := humanizeCategory (issue .IssueCategory )
347- fmt .Printf (" %s: %s (%s)\n " , category , issue .IssueText , location )
405+ catGroups := groupByCategoryAndCode (sevGroup )
406+ for _ , cg := range catGroups {
407+ fmt .Printf (" %s (%d)\n \n " , humanizeCategory (cg .category ), cg .total )
408+
409+ for _ , ig := range cg .codes {
410+ fmt .Printf (" %s %s\n " , pterm .Bold .Sprint ("✗" ), pterm .Bold .Sprint (ig .issue .IssueText ))
411+
412+ analyzer := analyzerDisplayName (ig .issue .Analyzer )
413+ meta := fmt .Sprintf ("%s · %s" , ig .issue .IssueCode , analyzer )
414+ if len (ig .locations ) > 1 {
415+ meta += fmt .Sprintf (" (%d occurrences)" , len (ig .locations ))
416+ }
417+ fmt .Printf (" %s\n " , pterm .Gray (meta ))
348418
349- if opts .Verbose {
350- analyzer := analyzerDisplayName (issue .Analyzer )
351- fmt .Printf (" %s · %s\n " , issue .IssueCode , analyzer )
352- if issue .Description != "" {
353- fmt .Printf (" %s\n " , issue .Description )
419+ for _ , loc := range ig .locations {
420+ fmt .Printf (" %s\n " , pterm .Gray (formatLocationFromParts (loc , cwd )))
354421 }
422+
423+ if opts .Verbose && ig .issue .Description != "" {
424+ desc := ig .issue .Description
425+ if idx := strings .IndexByte (desc , '\n' ); idx != - 1 {
426+ desc = desc [:idx ]
427+ }
428+ fmt .Printf (" %s\n " , pterm .Gray (desc ))
429+ }
430+
355431 fmt .Println ()
356432 }
357433 }
358-
359- if ! opts .Verbose {
360- fmt .Println ()
361- }
362434 }
363435
364- fmt .Printf ("Showing %d issue(s) in %s" , len (opts .issues ), opts .repoSlug )
436+ fmt .Println (strings .Repeat ("─" , ruledLineWidth ))
437+
438+ scope := "default branch"
365439 switch {
366440 case opts .CommitOid != "" :
367- fmt . Printf ( " from commit %s \n " , opts .CommitOid )
441+ scope = " commit " + opts .CommitOid
368442 case opts .PRNumber > 0 :
369- fmt .Printf (" from PR #%d\n " , opts .PRNumber )
370- default :
371- fmt .Println (" from default branch" )
443+ scope = fmt .Sprintf ("PR #%d" , opts .PRNumber )
372444 }
445+ fmt .Printf ("%d issues · %s · %s\n " , len (opts .issues ), opts .repoSlug , scope )
446+
373447 return nil
374448}
375449
@@ -569,14 +643,19 @@ func formatSeverity(severity string) string {
569643 }
570644}
571645
572- func formatLocation ( issue issues.Issue , cwd string ) string {
573- filePath := issue . Location .Path
646+ func formatLocationFromParts ( loc issues.Location , cwd string ) string {
647+ filePath := loc .Path
574648 if cwd != "" && strings .HasPrefix (filePath , cwd ) {
575649 filePath = strings .TrimPrefix (filePath , cwd + "/" )
576650 }
577- if issue . Location . Position .BeginLine == issue . Location .Position .EndLine {
578- return fmt .Sprintf ("%s:%d" , filePath , issue . Location .Position .BeginLine )
651+ if loc . Position .BeginLine == loc .Position .EndLine {
652+ return fmt .Sprintf ("%s:%d" , filePath , loc .Position .BeginLine )
579653 }
580- return fmt .Sprintf ("%s:%d-%d" , filePath , issue .Location .Position .BeginLine , issue .Location .Position .EndLine )
654+ return fmt .Sprintf ("%s:%d-%d" , filePath , loc .Position .BeginLine , loc .Position .EndLine )
655+ }
656+
657+
658+ func formatLocation (issue issues.Issue , cwd string ) string {
659+ return formatLocationFromParts (issue .Location , cwd )
581660}
582661
0 commit comments