diff --git a/command/issues/list/list.go b/command/issues/list/list.go index fe89d117..47583e61 100644 --- a/command/issues/list/list.go +++ b/command/issues/list/list.go @@ -23,6 +23,7 @@ type IssuesListOptions struct { FileArg []string RepoArg string AnalyzerArg []string + SeverityArg []string LimitArg int OutputFilenameArg string JSONArg bool @@ -39,34 +40,36 @@ func NewCmdIssuesList() *cobra.Command { RepoArg: "", LimitArg: 30, } - doc := heredoc.Docf(` - List issues reported by DeepSource. + List issues reported by DeepSource. + + To list issues for the current repository: + %[1]s - To list issues for the current repository: - %[1]s + To list issues for a specific repository, use the %[2]s flag: + %[3]s - To list issues for a specific repository, use the %[2]s flag: - %[3]s + To list issues for a specific analyzer, use the %[4]s flag: + %[5]s - To list issues for a specific analyzer, use the %[4]s flag: - %[5]s + To limit the number of issues reported, use the %[6]s flag: + %[7]s - To limit the number of issues reported, use the %[6]s flag: - %[7]s + To export listed issues to a file, use the %[8]s flag: + %[9]s - To export listed issues to a file, use the %[8]s flag: - %[9]s + To export listed issues to a JSON file, use the %[10]s flag: + %[11]s - To export listed issues to a JSON file, use the %[10]s flag: - %[11]s + To export listed issues to a CSV file, use the %[12]s flag: + %[13]s - To export listed issues to a CSV file, use the %[12]s flag: - %[13]s + To export listed issues to a SARIF file, use the %[14]s flag: + %[15]s - To export listed issues to a SARIF file, use the %[14]s flag: - %[15]s - `, utils.Cyan("deepsource issues list"), utils.Yellow("--repo"), utils.Cyan("deepsource issues list --repo repo_name"), utils.Yellow("--analyzer"), utils.Cyan("deepsource issues list --analyzer python"), utils.Yellow("--limit"), utils.Cyan("deepsource issues list --limit 100"), utils.Yellow("--output-file"), utils.Cyan("deepsource issues list --output-file file_name"), utils.Yellow("--json"), utils.Cyan("deepsource issues list --json --output-file example.json"), utils.Yellow("--csv"), utils.Cyan("deepsource issues list --csv --output-file example.csv"), utils.Yellow("--sarif"), utils.Cyan("deepsource issues list --sarif --output-file example.sarif")) + To list issues for specific severities, use the %[16]s flag: + %[17]s + `, utils.Cyan("deepsource issues list"), utils.Yellow("--repo"), utils.Cyan("deepsource issues list --repo repo_name"), utils.Yellow("--analyzer"), utils.Cyan("deepsource issues list --analyzer python"), utils.Yellow("--limit"), utils.Cyan("deepsource issues list --limit 100"), utils.Yellow("--output-file"), utils.Cyan("deepsource issues list --output-file file_name"), utils.Yellow("--json"), utils.Cyan("deepsource issues list --json --output-file example.json"), utils.Yellow("--csv"), utils.Cyan("deepsource issues list --csv --output-file example.csv"), utils.Yellow("--sarif"), utils.Cyan("deepsource issues list --sarif --output-file example.sarif"), utils.Yellow("--severity"), utils.Cyan("deepsource issues list --severity critical --severity major")) cmd := &cobra.Command{ Use: "list", @@ -81,6 +84,9 @@ func NewCmdIssuesList() *cobra.Command { // --repo, -r flag cmd.Flags().StringVarP(&opts.RepoArg, "repo", "r", "", "List the issues of the specified repository") + // --severity -s flag + cmd.Flags().StringArrayVarP(&opts.SeverityArg, "severity", "s", nil, "List issues for specified severity (CRITICAL, MAJOR, MINOR)") + // --analyzer, -a flag cmd.Flags().StringArrayVarP(&opts.AnalyzerArg, "analyzer", "a", nil, "List the issues for the specified analyzer") @@ -198,6 +204,19 @@ func (opts *IssuesListOptions) getIssuesData(ctx context.Context) (err error) { opts.issuesData = getUniqueIssues(fetchedIssues) } + if len(opts.SeverityArg) != 0 { + var fetchedIssues []issues.Issue + //Filter issues based on the severity option specified + filteredIssues, err = filterIssuesBySeverity(opts.SeverityArg, opts.issuesData) + if err != nil { + return err + } + fetchedIssues = append(fetchedIssues, filteredIssues...) + // set fetched issues as issue data + opts.issuesData = getUniqueIssues(fetchedIssues) + + } + return nil } diff --git a/command/issues/list/list_test.go b/command/issues/list/list_test.go index b3fe79dd..0711bd03 100644 --- a/command/issues/list/list_test.go +++ b/command/issues/list/list_test.go @@ -157,3 +157,75 @@ func TestFilterIssuesByAnalyzer(t *testing.T) { } }) } + +func TestFilterIssuesBySeverity(t *testing.T) { + // Path to the dedicated severity test data + testDataPath := "./testdata/dummy/issues_severity.json" + + // Case 1: Filter by a single severity + t.Run("must work with a single severity", func(t *testing.T) { + issues_data := ReadIssues(testDataPath) + // Testing lowercase "critical" to verify the ToUpper normalization logic + got, err := filterIssuesBySeverity([]string{"critical"}, issues_data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Expecting only the one CRITICAL issue defined in our JSON + if len(got) != 1 || strings.ToUpper(got[0].IssueSeverity) != "CRITICAL" { + t.Errorf("got: %v; expected 1 CRITICAL issue", got) + } + }) + + // Case 2: Filter by multiple severities simultaneously (Logical OR) + t.Run("must work with multiple severities", func(t *testing.T) { + issues_data := ReadIssues(testDataPath) + + // Should return both MAJOR and MINOR issues + got, err := filterIssuesBySeverity([]string{"MAJOR", "MINOR"}, issues_data) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != 2 { + t.Errorf("got: %d issues; want 2", len(got)) + } + }) + + // Case 3: Handle invalid severity strings + t.Run("must return error for invalid severity input", func(t *testing.T) { + issues_data := ReadIssues(testDataPath) + + // Verifying that the validator catches illegal entries + _, err := filterIssuesBySeverity([]string{"invalid_level"}, issues_data) + if err == nil { + t.Error("expected error for invalid severity 'invalid_level', got nil") + } + }) + + // Case 4: Handle valid severity that has no matches in the data + t.Run("must return empty list when no matches exist", func(t *testing.T) { + // Create a subset with only MINOR issues + subset := []issues.Issue{{IssueSeverity: "MINOR"}} + + // Filtering for CRITICAL should yield 0 results but NO error + got, err := filterIssuesBySeverity([]string{"CRITICAL"}, subset) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != 0 { + t.Errorf("expected 0 issues, got %d", len(got)) + } + }) + + t.Run("should handle duplicate severity flags gracefully", func(t *testing.T) { + issues_data := ReadIssues(testDataPath) + + got, _ := filterIssuesBySeverity([]string{"critical", "critical"}, issues_data) + if len(got) != 1 { + t.Errorf("expected 1 issue despite duplicate flag, got %d", len(got)) + } + }) +} diff --git a/command/issues/list/testdata/dummy/issues_severity.json b/command/issues/list/testdata/dummy/issues_severity.json new file mode 100644 index 00000000..2f1a220a --- /dev/null +++ b/command/issues/list/testdata/dummy/issues_severity.json @@ -0,0 +1,23 @@ +[ + { + "issue_title": "Critical Security Bug", + "issue_code": "SEC-001", + "issue_severity": "CRITICAL", + "location": { "path": "main.go", "position": { "begin": 10, "end": 10 } }, + "Analyzer": { "analyzer": "go" } + }, + { + "issue_title": "Major Performance Issue", + "issue_code": "PERF-002", + "issue_severity": "MAJOR", + "location": { "path": "utils.go", "position": { "begin": 20, "end": 20 } }, + "Analyzer": { "analyzer": "go" } + }, + { + "issue_title": "Minor Style Nitpick", + "issue_code": "STYLE-003", + "issue_severity": "MINOR", + "location": { "path": "list.go", "position": { "begin": 30, "end": 30 } }, + "Analyzer": { "analyzer": "go" } + } +] diff --git a/command/issues/list/utils.go b/command/issues/list/utils.go index cbdd86f3..b5da2e01 100644 --- a/command/issues/list/utils.go +++ b/command/issues/list/utils.go @@ -33,6 +33,7 @@ func filterIssuesByPath(path string, issuesData []issues.Issue) ([]issues.Issue, // get relative path rel, err := filepath.Rel(path, issue.Location.Path) if err != nil { + return nil, err } @@ -50,6 +51,39 @@ func filterIssuesByPath(path string, issuesData []issues.Issue) ([]issues.Issue, return getUniqueIssues(filteredIssues), nil } +//Filters issues based on the severity of issue specified + +func filterIssuesBySeverity(severity []string, issuesData []issues.Issue) ([]issues.Issue, error) { + var filteredIssues []issues.Issue + + // valid options for severity + validSeverities := map[string]bool{ + "CRITICAL": true, + "MAJOR": true, + "MINOR": true, + } + + // Validate user input and normalize to uppercase + severityMap := make(map[string]bool) + for _, s := range severity { + upperS := strings.ToUpper(s) + if !validSeverities[upperS] { + return nil, fmt.Errorf("invalid severity level: %s (valid options: CRITICAL, MAJOR, MINOR)", s) + } + severityMap[upperS] = true + } + + // Filter the issues list + for _, issue := range issuesData { + //match against the IssueSeverity field from the SDK Issue Struct + if severityMap[strings.ToUpper(issue.IssueSeverity)] { + filteredIssues = append(filteredIssues, issue) + } + } + + return getUniqueIssues(filteredIssues), nil +} + // Filters issues based on the analyzer shortcode. func filterIssuesByAnalyzer(analyzer []string, issuesData []issues.Issue) ([]issues.Issue, error) { var filteredIssues []issues.Issue