Skip to content

Commit d7b1939

Browse files
Rename analysis command to runs, add report-card command
- Rename `analysis` command to `runs` with grade column and richer issue summary (by category and analyzer) - Add `report-card` command to view repo report card (grades, coverage, focus area) with pretty/json/yaml output - Add pagination support to `GetAnalysisRuns` (cursor-based, up to 5 pages) - Fetch `reportCard` data in `GetRunIssues` query - Add `GetPRBranch` GraphQL query for resolving PR branch names - Add `ResolveCommitOid` helper to expand short SHAs via git rev-parse - Add `ResolveLatestRunForBranch` and `GetDefaultBranch` helpers - Fall back to repo-level data when on default branch with no matching runs - Remove `--code` filter from issues command - Rename "commit OID" references to "commit SHA" in flag descriptions
1 parent 56d3c32 commit d7b1939

26 files changed

Lines changed: 1148 additions & 137 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Usage:
7474
7575
Available commands are:
7676
auth Authentication commands (login, logout, refresh, status)
77-
analysis View analysis runs
77+
runs View analysis runs
7878
issues View issues in a repository
7979
repo Operations related to the project repository (status, view)
8080
report Report artifacts to DeepSource

command/analysis/tests/golden_files/run_detail_output.json

Lines changed: 0 additions & 21 deletions
This file was deleted.

command/cmdutil/reportcard.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package cmdutil
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/deepsourcelabs/cli/deepsource/runs"
8+
"github.com/pterm/pterm"
9+
)
10+
11+
// ShowReportCard renders a report card as a pterm table.
12+
func ShowReportCard(rc *runs.ReportCard) {
13+
if rc == nil {
14+
return
15+
}
16+
17+
pterm.Println()
18+
pterm.DefaultSection.Println("Report Card")
19+
20+
header := []string{"Dimension", "Grade", "Score", "Issues"}
21+
data := [][]string{header}
22+
23+
if rc.Security != nil {
24+
data = append(data, []string{"Security", gradeColor(rc.Security.Grade), fmt.Sprintf("%d", rc.Security.Score), fmt.Sprintf("%d", rc.Security.IssuesCount)})
25+
}
26+
if rc.Reliability != nil {
27+
data = append(data, []string{"Reliability", gradeColor(rc.Reliability.Grade), fmt.Sprintf("%d", rc.Reliability.Score), fmt.Sprintf("%d", rc.Reliability.IssuesCount)})
28+
}
29+
if rc.Complexity != nil {
30+
data = append(data, []string{"Complexity", gradeColor(rc.Complexity.Grade), fmt.Sprintf("%d", rc.Complexity.Score), fmt.Sprintf("%d", rc.Complexity.IssuesCount)})
31+
}
32+
if rc.Hygiene != nil {
33+
data = append(data, []string{"Hygiene", gradeColor(rc.Hygiene.Grade), fmt.Sprintf("%d", rc.Hygiene.Score), fmt.Sprintf("%d", rc.Hygiene.IssuesCount)})
34+
}
35+
36+
pterm.DefaultTable.WithHasHeader().WithData(data).Render()
37+
38+
if rc.Coverage != nil {
39+
parts := []string{}
40+
if rc.Coverage.Grade != "" {
41+
parts = append(parts, fmt.Sprintf("Grade: %s", gradeColor(rc.Coverage.Grade)))
42+
}
43+
if rc.Coverage.LineCoverage != nil {
44+
parts = append(parts, fmt.Sprintf("Line: %.1f%%", *rc.Coverage.LineCoverage))
45+
}
46+
if rc.Coverage.BranchCoverage != nil {
47+
parts = append(parts, fmt.Sprintf("Branch: %.1f%%", *rc.Coverage.BranchCoverage))
48+
}
49+
if len(parts) > 0 {
50+
pterm.Println()
51+
pterm.Printf(" %s %s\n", pterm.Bold.Sprint("Coverage:"), strings.Join(parts, " | "))
52+
}
53+
}
54+
55+
if rc.Aggregate != nil {
56+
pterm.Printf(" %s %s (score: %d)\n", pterm.Bold.Sprint("Aggregate:"), gradeColor(rc.Aggregate.Grade), rc.Aggregate.Score)
57+
}
58+
59+
if rc.FocusArea != nil && rc.FocusArea.Dimension != "" {
60+
pterm.Printf(" %s %s — %s\n", pterm.Bold.Sprint("Focus Area:"), FormatCategory(rc.FocusArea.Dimension), rc.FocusArea.Action)
61+
}
62+
}
63+
64+
func gradeColor(grade string) string {
65+
switch {
66+
case strings.HasPrefix(grade, "A"):
67+
return pterm.Green(grade)
68+
case strings.HasPrefix(grade, "B"):
69+
return pterm.LightGreen(grade)
70+
case strings.HasPrefix(grade, "C"):
71+
return pterm.Yellow(grade)
72+
case strings.HasPrefix(grade, "D"):
73+
return pterm.LightRed(grade)
74+
default:
75+
return pterm.Red(grade)
76+
}
77+
}
78+
79+
// FormatCategory converts "BUG_RISK" to "Bug Risk", "SECURITY" to "Security", etc.
80+
func FormatCategory(s string) string {
81+
parts := strings.Split(strings.ToLower(s), "_")
82+
for i, p := range parts {
83+
if len(p) > 0 {
84+
parts[i] = strings.ToUpper(p[:1]) + p[1:]
85+
}
86+
}
87+
return strings.Join(parts, " ")
88+
}
89+
90+
// --- JSON types for report card output ---
91+
92+
type ReportCardJSON struct {
93+
Status string `json:"status"`
94+
Security *ReportDimensionJSON `json:"security,omitempty"`
95+
Reliability *ReportDimensionJSON `json:"reliability,omitempty"`
96+
Complexity *ReportDimensionJSON `json:"complexity,omitempty"`
97+
Hygiene *ReportDimensionJSON `json:"hygiene,omitempty"`
98+
Coverage *ReportCoverageJSON `json:"coverage,omitempty"`
99+
Aggregate *ReportAggregateJSON `json:"aggregate,omitempty"`
100+
FocusArea *ReportFocusAreaJSON `json:"focus_area,omitempty"`
101+
}
102+
103+
type ReportDimensionJSON struct {
104+
Grade string `json:"grade"`
105+
Score int `json:"score"`
106+
IssuesCount int `json:"issues_count"`
107+
}
108+
109+
type ReportCoverageJSON struct {
110+
Grade string `json:"grade,omitempty"`
111+
Score *int `json:"score,omitempty"`
112+
LineCoverage *float64 `json:"line_coverage,omitempty"`
113+
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
114+
}
115+
116+
type ReportAggregateJSON struct {
117+
Grade string `json:"grade"`
118+
Score int `json:"score"`
119+
}
120+
121+
type ReportFocusAreaJSON struct {
122+
Dimension string `json:"dimension,omitempty"`
123+
Action string `json:"action,omitempty"`
124+
}
125+
126+
// ToReportCardJSON converts a runs.ReportCard to its JSON representation.
127+
func ToReportCardJSON(rc *runs.ReportCard) *ReportCardJSON {
128+
if rc == nil {
129+
return nil
130+
}
131+
result := &ReportCardJSON{
132+
Status: rc.Status,
133+
}
134+
if rc.Security != nil {
135+
result.Security = &ReportDimensionJSON{Grade: rc.Security.Grade, Score: rc.Security.Score, IssuesCount: rc.Security.IssuesCount}
136+
}
137+
if rc.Reliability != nil {
138+
result.Reliability = &ReportDimensionJSON{Grade: rc.Reliability.Grade, Score: rc.Reliability.Score, IssuesCount: rc.Reliability.IssuesCount}
139+
}
140+
if rc.Complexity != nil {
141+
result.Complexity = &ReportDimensionJSON{Grade: rc.Complexity.Grade, Score: rc.Complexity.Score, IssuesCount: rc.Complexity.IssuesCount}
142+
}
143+
if rc.Hygiene != nil {
144+
result.Hygiene = &ReportDimensionJSON{Grade: rc.Hygiene.Grade, Score: rc.Hygiene.Score, IssuesCount: rc.Hygiene.IssuesCount}
145+
}
146+
if rc.Coverage != nil {
147+
result.Coverage = &ReportCoverageJSON{
148+
Grade: rc.Coverage.Grade,
149+
Score: rc.Coverage.Score,
150+
LineCoverage: rc.Coverage.LineCoverage,
151+
BranchCoverage: rc.Coverage.BranchCoverage,
152+
}
153+
}
154+
if rc.Aggregate != nil {
155+
result.Aggregate = &ReportAggregateJSON{Grade: rc.Aggregate.Grade, Score: rc.Aggregate.Score}
156+
}
157+
if rc.FocusArea != nil {
158+
result.FocusArea = &ReportFocusAreaJSON{Dimension: rc.FocusArea.Dimension, Action: rc.FocusArea.Action}
159+
}
160+
return result
161+
}

command/cmdutil/resolve_run.go

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/deepsourcelabs/cli/deepsource"
11+
"github.com/deepsourcelabs/cli/deepsource/runs"
1112
"github.com/deepsourcelabs/cli/internal/vcs"
1213
)
1314

@@ -28,23 +29,87 @@ func ResolveLatestRun(
2829
return "", "", fmt.Errorf("failed to detect current branch: %w", err)
2930
}
3031

31-
runs, err := client.GetAnalysisRuns(ctx, remote.Owner, remote.RepoName, remote.VCSProvider, 30)
32-
if err != nil {
33-
return "", "", fmt.Errorf("failed to fetch analysis runs: %w", err)
34-
}
32+
const maxPages = 5
33+
var after *string
3534

36-
for _, run := range runs {
37-
if run.BranchName == branchName && run.Status == "SUCCESS" {
38-
return run.CommitOid, branchName, nil
35+
for page := 0; page < maxPages; page++ {
36+
runs, pageInfo, err := client.GetAnalysisRuns(ctx, remote.Owner, remote.RepoName, remote.VCSProvider, 30, after)
37+
if err != nil {
38+
return "", "", fmt.Errorf("failed to fetch analysis runs: %w", err)
3939
}
40+
41+
for _, run := range runs {
42+
if run.BranchName == branchName && run.Status == "SUCCESS" {
43+
return run.CommitOid, branchName, nil
44+
}
45+
}
46+
47+
if !pageInfo.HasNextPage || pageInfo.EndCursor == nil {
48+
break
49+
}
50+
after = pageInfo.EndCursor
4051
}
4152

4253
return "", branchName, fmt.Errorf(
43-
"no analysis runs found for branch %q.\nTry: --default-branch, --commit <oid>, or push and analyze this branch first",
54+
"no analysis runs found for branch %q.\nTry: --default-branch, --commit <sha>, or push and analyze this branch first",
4455
branchName,
4556
)
4657
}
4758

59+
// ResolveLatestRunForBranch finds the latest successful analysis run for a given branch name.
60+
// Returns the full AnalysisRun (which includes the ReportCard).
61+
func ResolveLatestRunForBranch(
62+
ctx context.Context,
63+
client *deepsource.Client,
64+
remote *vcs.RemoteData,
65+
branchName string,
66+
) (*runs.AnalysisRun, error) {
67+
const maxPages = 5
68+
var after *string
69+
70+
for page := 0; page < maxPages; page++ {
71+
analysisRuns, pageInfo, err := client.GetAnalysisRuns(ctx, remote.Owner, remote.RepoName, remote.VCSProvider, 30, after)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to fetch analysis runs: %w", err)
74+
}
75+
76+
for i := range analysisRuns {
77+
if analysisRuns[i].BranchName == branchName && analysisRuns[i].Status == "SUCCESS" {
78+
return &analysisRuns[i], nil
79+
}
80+
}
81+
82+
if !pageInfo.HasNextPage || pageInfo.EndCursor == nil {
83+
break
84+
}
85+
after = pageInfo.EndCursor
86+
}
87+
88+
return nil, fmt.Errorf(
89+
"no successful analysis runs found for branch %q",
90+
branchName,
91+
)
92+
}
93+
94+
// ResolveCommitOid expands a short commit SHA to a full 40-char SHA using git rev-parse.
95+
// If the input is already 40 chars or git rev-parse fails, returns the original value.
96+
func ResolveCommitOid(commitOid string) string {
97+
if len(commitOid) == 40 {
98+
return commitOid
99+
}
100+
cmd := exec.Command("git", "--no-pager", "rev-parse", commitOid)
101+
var stdout bytes.Buffer
102+
cmd.Stdout = &stdout
103+
if err := cmd.Run(); err != nil {
104+
return commitOid
105+
}
106+
resolved := strings.TrimSpace(stdout.String())
107+
if len(resolved) == 40 {
108+
return resolved
109+
}
110+
return commitOid
111+
}
112+
48113
func getCurrentBranch() (string, error) {
49114
cmd := exec.Command("git", "--no-pager", "rev-parse", "--abbrev-ref", "HEAD")
50115
var stdout, stderr bytes.Buffer
@@ -55,3 +120,16 @@ func getCurrentBranch() (string, error) {
55120
}
56121
return strings.TrimSuffix(stdout.String(), "\n"), nil
57122
}
123+
124+
// GetDefaultBranch returns the default branch name of the origin remote
125+
// (e.g. "master" or "main"). Returns an empty string if it cannot be determined.
126+
func GetDefaultBranch() string {
127+
cmd := exec.Command("git", "--no-pager", "symbolic-ref", "refs/remotes/origin/HEAD")
128+
var stdout bytes.Buffer
129+
cmd.Stdout = &stdout
130+
if err := cmd.Run(); err != nil {
131+
return ""
132+
}
133+
ref := strings.TrimSpace(stdout.String())
134+
return strings.TrimPrefix(ref, "refs/remotes/origin/")
135+
}

command/flags_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package command
33
import (
44
"testing"
55

6-
analysisCmd "github.com/deepsourcelabs/cli/command/analysis"
6+
runsCmd "github.com/deepsourcelabs/cli/command/runs"
77
issuesCmd "github.com/deepsourcelabs/cli/command/issues"
88
metricsCmd "github.com/deepsourcelabs/cli/command/metrics"
99
vulnsCmd "github.com/deepsourcelabs/cli/command/vulnerabilities"
@@ -35,7 +35,6 @@ func TestFlagDefaults(t *testing.T) {
3535
{name: "severity", defaultValue: "[]"},
3636
{name: "category", defaultValue: "[]"},
3737
{name: "analyzer", defaultValue: "[]"},
38-
{name: "code", defaultValue: "[]"},
3938
{name: "path", defaultValue: "[]"},
4039
{name: "source", defaultValue: "[]"},
4140
{name: "default-branch", defaultValue: "false"},
@@ -69,8 +68,8 @@ func TestFlagDefaults(t *testing.T) {
6968
},
7069
},
7170
{
72-
name: "analysis",
73-
buildCmd: analysisCmd.NewCmdAnalysis,
71+
name: "runs",
72+
buildCmd: runsCmd.NewCmdRuns,
7473
expected: []flagExpectation{
7574
{name: "limit", defaultValue: "20"},
7675
{name: "output", defaultValue: "pretty"},

0 commit comments

Comments
 (0)