66 "fmt"
77 "io"
88 "os"
9+ "path/filepath"
910 "slices"
1011 "strings"
1112
@@ -16,6 +17,7 @@ import (
1617 "github.com/deepsourcelabs/cli/deepsource"
1718 "github.com/deepsourcelabs/cli/deepsource/issues"
1819 issuesQuery "github.com/deepsourcelabs/cli/deepsource/issues/queries"
20+ "github.com/deepsourcelabs/cli/deepsource/pagination"
1921 "github.com/deepsourcelabs/cli/internal/cli/completion"
2022 "github.com/deepsourcelabs/cli/internal/cli/style"
2123 clierrors "github.com/deepsourcelabs/cli/internal/errors"
@@ -596,23 +598,41 @@ func (opts *IssuesOptions) renderHumanIssues() error {
596598
597599 fmt .Fprintln (w , pterm .Bold .Sprintf (" ── %s ──" , humanizeCategory (cat )))
598600
599- for i , issue := range group {
600- location := formatLocation (issue , cwd )
601- severity := humanizeSeverity (issue .IssueSeverity )
602- sevTag := style .IssueSeverityColor (issue .IssueSeverity , "[" + severity + "]" )
603- fmt .Fprintf (w , " %s %s\n " , sevTag , issue .IssueText )
604- if opts .Verbose && issue .Description != "" {
605- fmt .Fprintf (w , " %s\n " , pterm .Gray (issue .Description ))
601+ grouped := groupIdenticalIssues (group )
602+ for i , g := range grouped {
603+ severity := humanizeSeverity (g .Key .IssueSeverity )
604+ sevTag := style .IssueSeverityColor (g .Key .IssueSeverity , "[" + severity + "]" )
605+
606+ if len (g .Issues ) == 1 {
607+ // Single occurrence: render exactly as before
608+ fmt .Fprintf (w , " %s %s\n " , sevTag , g .Key .IssueText )
609+ if opts .Verbose && g .Description != "" {
610+ fmt .Fprintf (w , " %s\n " , pterm .Gray (g .Description ))
611+ }
612+ fmt .Fprintf (w , " %s\n " , pterm .Gray (formatLocation (g .Issues [0 ], cwd )))
613+ } else {
614+ // Multi-occurrence: show count + compact locations
615+ fmt .Fprintf (w , " %s %s (%d occurrences)\n " , sevTag , g .Key .IssueText , len (g .Issues ))
616+ if opts .Verbose && g .Description != "" {
617+ fmt .Fprintf (w , " %s\n " , pterm .Gray (g .Description ))
618+ }
619+ for _ , loc := range formatGroupLocations (g .Issues , cwd ) {
620+ fmt .Fprintf (w , " %s\n " , pterm .Gray (loc ))
621+ }
606622 }
607- fmt . Fprintf ( w , " %s \n " , pterm . Gray ( location ))
608- if i < len (group )- 1 {
623+
624+ if i < len (grouped )- 1 {
609625 fmt .Fprintln (w )
610626 }
611627 }
612628 }
613629
614630 fmt .Fprintf (w , "\n Showing %d %s in %s from %s\n " , len (opts .issues ), style .Pluralize (len (opts .issues ), "issue" , "issues" ), opts .repoSlug , scopeLabel )
615631
632+ if len (opts .issues ) >= pagination .MaxResults {
633+ style .Warnf (w , "Results capped at %d. Use --limit, filters, or scoping flags to narrow results." , pagination .MaxResults )
634+ }
635+
616636 return nil
617637}
618638
@@ -736,3 +756,101 @@ func formatLocation(issue issues.Issue, cwd string) string {
736756 return formatLocationFromParts (issue .Location , cwd )
737757}
738758
759+ // --- Issue grouping ---
760+
761+ // issueGroupKey is the composite key used to group identical issues.
762+ type issueGroupKey struct {
763+ IssueText string
764+ IssueSeverity string
765+ IssueCode string
766+ }
767+
768+ // issueGroup holds a set of issues that share the same title, severity, and code.
769+ type issueGroup struct {
770+ Key issueGroupKey
771+ Description string
772+ Issues []issues.Issue
773+ }
774+
775+ // groupIdenticalIssues clusters issues by (IssueText, IssueSeverity, IssueCode),
776+ // preserving the order in which each group is first encountered.
777+ func groupIdenticalIssues (issuesList []issues.Issue ) []issueGroup {
778+ seen := map [issueGroupKey ]int {} // key -> index into groups
779+ var groups []issueGroup
780+
781+ for _ , issue := range issuesList {
782+ key := issueGroupKey {
783+ IssueText : issue .IssueText ,
784+ IssueSeverity : issue .IssueSeverity ,
785+ IssueCode : issue .IssueCode ,
786+ }
787+ if idx , ok := seen [key ]; ok {
788+ groups [idx ].Issues = append (groups [idx ].Issues , issue )
789+ } else {
790+ seen [key ] = len (groups )
791+ groups = append (groups , issueGroup {
792+ Key : key ,
793+ Description : issue .Description ,
794+ Issues : []issues.Issue {issue },
795+ })
796+ }
797+ }
798+ return groups
799+ }
800+
801+ // formatLineRange returns "42" for single-line or "42-96" for multi-line positions.
802+ func formatLineRange (pos issues.Position ) string {
803+ if pos .BeginLine == pos .EndLine {
804+ return fmt .Sprintf ("%d" , pos .BeginLine )
805+ }
806+ return fmt .Sprintf ("%d-%d" , pos .BeginLine , pos .EndLine )
807+ }
808+
809+ // formatGroupLocations returns one string per file, with line ranges joined by ", ".
810+ // e.g. "command/root.go:23-39, 42-96, 155"
811+ func formatGroupLocations (groupIssues []issues.Issue , cwd string ) []string {
812+ type fileEntry struct {
813+ displayPath string
814+ ranges []string
815+ }
816+
817+ seen := map [string ]int {} // raw path -> index into files
818+ var files []fileEntry
819+
820+ for _ , issue := range groupIssues {
821+ rawPath := issue .Location .Path
822+ displayPath := rawPath
823+ if cwd != "" && strings .HasPrefix (displayPath , cwd ) {
824+ displayPath = strings .TrimPrefix (displayPath , cwd + "/" )
825+ }
826+
827+ lineRange := formatLineRange (issue .Location .Position )
828+
829+ if idx , ok := seen [rawPath ]; ok {
830+ files [idx ].ranges = append (files [idx ].ranges , lineRange )
831+ } else {
832+ seen [rawPath ] = len (files )
833+ files = append (files , fileEntry {
834+ displayPath : displayPath ,
835+ ranges : []string {lineRange },
836+ })
837+ }
838+ }
839+
840+ // Sort by directory depth then alphabetically for stable output
841+ slices .SortStableFunc (files , func (a , b fileEntry ) int {
842+ aDepth := strings .Count (a .displayPath , string (filepath .Separator ))
843+ bDepth := strings .Count (b .displayPath , string (filepath .Separator ))
844+ if aDepth != bDepth {
845+ return aDepth - bDepth
846+ }
847+ return strings .Compare (a .displayPath , b .displayPath )
848+ })
849+
850+ result := make ([]string , len (files ))
851+ for i , f := range files {
852+ result [i ] = f .displayPath + ":" + strings .Join (f .ranges , ", " )
853+ }
854+ return result
855+ }
856+
0 commit comments