Skip to content

Commit d2b2def

Browse files
Group identical issues in human output, raise pagination cap to 1000
- Cluster issues by title/severity/code and show occurrence count with compact file:line locations instead of repeating full blocks - Bump MaxResults from 500 to 1000 and warn when results are capped - Fix version flag init order in root command (InitDefaultVersionFlag before Lookup, add nil guard) - Add unit tests for grouping, line range formatting, and location collapsing, plus integration test for the cap warning
1 parent 326fb7b commit d2b2def

5 files changed

Lines changed: 299 additions & 17 deletions

File tree

command/issues/issues.go

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
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, "\nShowing %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+

command/issues/issues_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package issues
22

33
import (
44
"testing"
5+
6+
"github.com/deepsourcelabs/cli/deepsource/issues"
57
)
68

79
func TestNormalizeEnumValue(t *testing.T) {
@@ -25,3 +27,101 @@ func TestNormalizeEnumValue(t *testing.T) {
2527
}
2628
}
2729
}
30+
31+
func TestFormatLineRange(t *testing.T) {
32+
tests := []struct {
33+
pos issues.Position
34+
want string
35+
}{
36+
{issues.Position{BeginLine: 42, EndLine: 42}, "42"},
37+
{issues.Position{BeginLine: 42, EndLine: 96}, "42-96"},
38+
{issues.Position{BeginLine: 1, EndLine: 1}, "1"},
39+
}
40+
for _, tt := range tests {
41+
got := formatLineRange(tt.pos)
42+
if got != tt.want {
43+
t.Errorf("formatLineRange(%+v) = %q, want %q", tt.pos, got, tt.want)
44+
}
45+
}
46+
}
47+
48+
func TestGroupIdenticalIssues(t *testing.T) {
49+
input := []issues.Issue{
50+
{IssueText: "Lines not covered", IssueSeverity: "CRITICAL", IssueCode: "COV-001", Location: issues.Location{Path: "a.go"}},
51+
{IssueText: "Unused variable", IssueSeverity: "MAJOR", IssueCode: "GO-W1007", Location: issues.Location{Path: "b.go"}},
52+
{IssueText: "Lines not covered", IssueSeverity: "CRITICAL", IssueCode: "COV-001", Location: issues.Location{Path: "c.go"}},
53+
{IssueText: "Lines not covered", IssueSeverity: "CRITICAL", IssueCode: "COV-001", Location: issues.Location{Path: "d.go"}},
54+
}
55+
56+
groups := groupIdenticalIssues(input)
57+
58+
if len(groups) != 2 {
59+
t.Fatalf("expected 2 groups, got %d", len(groups))
60+
}
61+
62+
// First group: "Lines not covered" with 3 occurrences
63+
if groups[0].Key.IssueText != "Lines not covered" {
64+
t.Errorf("group[0] text = %q, want %q", groups[0].Key.IssueText, "Lines not covered")
65+
}
66+
if len(groups[0].Issues) != 3 {
67+
t.Errorf("group[0] count = %d, want 3", len(groups[0].Issues))
68+
}
69+
70+
// Second group: "Unused variable" with 1 occurrence
71+
if groups[1].Key.IssueText != "Unused variable" {
72+
t.Errorf("group[1] text = %q, want %q", groups[1].Key.IssueText, "Unused variable")
73+
}
74+
if len(groups[1].Issues) != 1 {
75+
t.Errorf("group[1] count = %d, want 1", len(groups[1].Issues))
76+
}
77+
}
78+
79+
func TestGroupIdenticalIssues_DifferentSeverity(t *testing.T) {
80+
// Same text and code but different severity should be separate groups
81+
input := []issues.Issue{
82+
{IssueText: "Lines not covered", IssueSeverity: "CRITICAL", IssueCode: "COV-001"},
83+
{IssueText: "Lines not covered", IssueSeverity: "MAJOR", IssueCode: "COV-001"},
84+
}
85+
86+
groups := groupIdenticalIssues(input)
87+
if len(groups) != 2 {
88+
t.Fatalf("expected 2 groups for different severities, got %d", len(groups))
89+
}
90+
}
91+
92+
func TestFormatGroupLocations(t *testing.T) {
93+
input := []issues.Issue{
94+
{Location: issues.Location{Path: "command/root.go", Position: issues.Position{BeginLine: 23, EndLine: 39}}},
95+
{Location: issues.Location{Path: "command/root.go", Position: issues.Position{BeginLine: 42, EndLine: 96}}},
96+
{Location: issues.Location{Path: "command/root.go", Position: issues.Position{BeginLine: 155, EndLine: 155}}},
97+
{Location: issues.Location{Path: "internal/vcs/remotes.go", Position: issues.Position{BeginLine: 10, EndLine: 20}}},
98+
}
99+
100+
result := formatGroupLocations(input, "")
101+
102+
if len(result) != 2 {
103+
t.Fatalf("expected 2 file entries, got %d", len(result))
104+
}
105+
106+
if result[0] != "command/root.go:23-39, 42-96, 155" {
107+
t.Errorf("result[0] = %q, want %q", result[0], "command/root.go:23-39, 42-96, 155")
108+
}
109+
if result[1] != "internal/vcs/remotes.go:10-20" {
110+
t.Errorf("result[1] = %q, want %q", result[1], "internal/vcs/remotes.go:10-20")
111+
}
112+
}
113+
114+
func TestFormatGroupLocations_CwdStripped(t *testing.T) {
115+
input := []issues.Issue{
116+
{Location: issues.Location{Path: "/home/user/project/cmd/main.go", Position: issues.Position{BeginLine: 5, EndLine: 5}}},
117+
}
118+
119+
result := formatGroupLocations(input, "/home/user/project")
120+
121+
if len(result) != 1 {
122+
t.Fatalf("expected 1 entry, got %d", len(result))
123+
}
124+
if result[0] != "cmd/main.go:5" {
125+
t.Errorf("result[0] = %q, want %q", result[0], "cmd/main.go:5")
126+
}
127+
}

command/issues/tests/issues_test.go

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -831,13 +831,74 @@ func TestIssuesPaginationHardCap(t *testing.T) {
831831
t.Fatalf("failed to parse JSON output: %v", err)
832832
}
833833

834-
// The hard cap is 500. Each page returns 1 issue, so we expect exactly 500 issues
835-
// and 500 API calls (not infinite).
836-
if len(issues) != 500 {
837-
t.Errorf("expected 500 issues (hard cap), got %d", len(issues))
834+
// The hard cap is 1000. Each page returns 1 issue, so we expect exactly 1000 issues
835+
// and 1000 API calls (not infinite).
836+
if len(issues) != 1000 {
837+
t.Errorf("expected 1000 issues (hard cap), got %d", len(issues))
838838
}
839-
if callCount != 500 {
840-
t.Errorf("expected 500 API calls, got %d", callCount)
839+
if callCount != 1000 {
840+
t.Errorf("expected 1000 API calls, got %d", callCount)
841+
}
842+
}
843+
844+
func TestIssuesPaginationHardCapWarning(t *testing.T) {
845+
cfgMgr := testutil.CreateTestConfigManager(t, "test-token", "deepsource.com", "test@example.com")
846+
847+
// Same infinite-page mock as TestIssuesPaginationHardCap.
848+
pageJSON := []byte(`{
849+
"repository": {
850+
"issues": {
851+
"edges": [{
852+
"node": {
853+
"occurrences": {
854+
"edges": [{
855+
"node": {
856+
"path": "cmd/deepsource/main.go",
857+
"beginLine": 1,
858+
"endLine": 1,
859+
"issue": {
860+
"title": "Test issue",
861+
"shortcode": "GO-W1007",
862+
"shortDescription": "desc",
863+
"category": "BUG_RISK",
864+
"severity": "MAJOR",
865+
"isRecommended": false,
866+
"analyzer": {"name": "Go", "shortcode": "go"}
867+
}
868+
}
869+
}]
870+
}
871+
}
872+
}],
873+
"pageInfo": {"hasNextPage": true, "endCursor": "cursor-next"}
874+
}
875+
}
876+
}`)
877+
878+
mock := graphqlclient.NewMockClient()
879+
mock.QueryFunc = func(_ context.Context, query string, vars map[string]any, result any) error {
880+
return json.Unmarshal(pageJSON, result)
881+
}
882+
client := deepsource.NewWithGraphQLClient(mock)
883+
884+
var buf bytes.Buffer
885+
deps := &cmddeps.Deps{
886+
Client: client,
887+
ConfigMgr: cfgMgr,
888+
Stdout: &buf,
889+
}
890+
891+
cmd := issuesCmd.NewCmdIssuesWithDeps(deps)
892+
// Pretty output (no --output json) to exercise renderHumanIssues.
893+
cmd.SetArgs([]string{"--repo", "gh/testowner/testrepo", "--default-branch"})
894+
895+
if err := cmd.Execute(); err != nil {
896+
t.Fatalf("unexpected error: %v", err)
897+
}
898+
899+
output := buf.String()
900+
if !strings.Contains(output, "Results capped at 1000") {
901+
t.Errorf("expected truncation warning in pretty output, got:\n%s", output[max(0, len(output)-300):])
841902
}
842903
}
843904

command/root.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,11 @@ func NewCmdRoot() *cobra.Command {
9090
cmd.AddCommand(completionC)
9191

9292
cmd.InitDefaultHelpFlag()
93+
cmd.InitDefaultVersionFlag()
9394
cmd.Flags().Lookup("help").Usage = "Show usage and available commands"
94-
cmd.Flags().Lookup("version").Usage = "Print version and build info"
95+
if f := cmd.Flags().Lookup("version"); f != nil {
96+
f.Usage = "Print version and build info"
97+
}
9598

9699
return cmd
97100
}

deepsource/pagination/pagination.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const DefaultPageSize = 100
88
const NestedPageSize = 500
99

1010
// MaxResults is the hard cap on total results fetched across all pages.
11-
const MaxResults = 500
11+
const MaxResults = 1000
1212

1313
// PageInfo holds Relay-style cursor pagination state.
1414
type PageInfo struct {

0 commit comments

Comments
 (0)