Skip to content

Commit 777772b

Browse files
Add dependency injection and golden-file tests for all commands
- Rename telemetry → sentry across adapters, interfaces, and container - Introduce cmddeps.Deps struct for injectable config, client, and stdout - Add NewCmd*WithDeps constructors to all commands for testability - Add --output json flag to analysis command - Add golden-file-based tests for analysis, issues, metrics, vulnerabilities, repo status, repo analyzers, and auth status commands - Add NewWithGraphQLClient and NewTestService test helpers - Always show auth URL in login flow regardless of browser open result - Update justfile with new test paths
1 parent c9a4c79 commit 777772b

61 files changed

Lines changed: 2245 additions & 108 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

command/analysis/analysis.go

Lines changed: 145 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package analysis
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
7+
"io"
8+
"os"
69
"strings"
710
"time"
811

912
"github.com/MakeNowJust/heredoc"
13+
"github.com/deepsourcelabs/cli/command/cmddeps"
1014
"github.com/deepsourcelabs/cli/config"
1115
"github.com/deepsourcelabs/cli/deepsource"
1216
"github.com/deepsourcelabs/cli/deepsource/runs"
@@ -19,14 +23,28 @@ import (
1923
)
2024

2125
type AnalysisOptions struct {
22-
RepoArg string
23-
LimitArg int
24-
commitOid string
26+
RepoArg string
27+
LimitArg int
28+
OutputFormat string
29+
commitOid string
30+
deps *cmddeps.Deps
31+
}
32+
33+
func (opts *AnalysisOptions) stdout() io.Writer {
34+
if opts.deps != nil && opts.deps.Stdout != nil {
35+
return opts.deps.Stdout
36+
}
37+
return os.Stdout
2538
}
2639

2740
func NewCmdAnalysis() *cobra.Command {
41+
return NewCmdAnalysisWithDeps(nil)
42+
}
43+
44+
func NewCmdAnalysisWithDeps(deps *cmddeps.Deps) *cobra.Command {
2845
opts := AnalysisOptions{
2946
LimitArg: 20,
47+
deps: deps,
3048
}
3149

3250
doc := heredoc.Docf(`
@@ -63,17 +81,29 @@ func NewCmdAnalysis() *cobra.Command {
6381
cmd.Flags().StringVarP(&opts.RepoArg, "repo", "r", "", "List history for the specified repository")
6482
cmd.Flags().IntVarP(&opts.LimitArg, "limit", "l", 20, "Number of analysis runs to fetch")
6583
cmd.Flags().StringVar(&opts.commitOid, "commit", "", "Show metadata and issues summary for a specific commit")
84+
cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "pretty", "Output format: pretty, json")
6685

6786
_ = cmd.RegisterFlagCompletionFunc("repo", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
6887
return completion.RepoCompletionCandidates(), cobra.ShellCompDirectiveNoFileComp
6988
})
89+
_ = cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
90+
return []string{
91+
"pretty\tPretty-printed output",
92+
"json\tJSON output",
93+
}, cobra.ShellCompDirectiveNoFileComp
94+
})
7095

7196
return cmd
7297
}
7398

7499
// runList fetches and displays a table of recent analysis runs.
75100
func (opts *AnalysisOptions) runList() error {
76-
cfgMgr := config.DefaultManager()
101+
var cfgMgr *config.Manager
102+
if opts.deps != nil && opts.deps.ConfigMgr != nil {
103+
cfgMgr = opts.deps.ConfigMgr
104+
} else {
105+
cfgMgr = config.DefaultManager()
106+
}
77107
cfg, err := cfgMgr.Load()
78108
if err != nil {
79109
return clierrors.NewCLIError(clierrors.ErrInvalidConfig, "Error reading DeepSource CLI config", err)
@@ -87,13 +117,18 @@ func (opts *AnalysisOptions) runList() error {
87117
return err
88118
}
89119

90-
client, err := deepsource.New(deepsource.ClientOpts{
91-
Token: cfg.Token,
92-
HostName: cfg.Host,
93-
OnTokenRefreshed: cfgMgr.TokenRefreshCallback(),
94-
})
95-
if err != nil {
96-
return err
120+
var client *deepsource.Client
121+
if opts.deps != nil && opts.deps.Client != nil {
122+
client = opts.deps.Client
123+
} else {
124+
client, err = deepsource.New(deepsource.ClientOpts{
125+
Token: cfg.Token,
126+
HostName: cfg.Host,
127+
OnTokenRefreshed: cfgMgr.TokenRefreshCallback(),
128+
})
129+
if err != nil {
130+
return err
131+
}
97132
}
98133

99134
ctx := context.Background()
@@ -107,13 +142,22 @@ func (opts *AnalysisOptions) runList() error {
107142
return nil
108143
}
109144

145+
if opts.OutputFormat == "json" {
146+
return opts.outputRunsJSON(analysisRuns)
147+
}
148+
110149
showRunsTable(analysisRuns)
111150
return nil
112151
}
113152

114153
// runDetail fetches and displays metadata + issues summary for a single commit.
115154
func (opts *AnalysisOptions) runDetail(ctx context.Context) error {
116-
cfgMgr := config.DefaultManager()
155+
var cfgMgr *config.Manager
156+
if opts.deps != nil && opts.deps.ConfigMgr != nil {
157+
cfgMgr = opts.deps.ConfigMgr
158+
} else {
159+
cfgMgr = config.DefaultManager()
160+
}
117161
cfg, err := cfgMgr.Load()
118162
if err != nil {
119163
return clierrors.NewCLIError(clierrors.ErrInvalidConfig, "Error reading DeepSource CLI config", err)
@@ -122,13 +166,18 @@ func (opts *AnalysisOptions) runDetail(ctx context.Context) error {
122166
return err
123167
}
124168

125-
client, err := deepsource.New(deepsource.ClientOpts{
126-
Token: cfg.Token,
127-
HostName: cfg.Host,
128-
OnTokenRefreshed: cfgMgr.TokenRefreshCallback(),
129-
})
130-
if err != nil {
131-
return err
169+
var client *deepsource.Client
170+
if opts.deps != nil && opts.deps.Client != nil {
171+
client = opts.deps.Client
172+
} else {
173+
client, err = deepsource.New(deepsource.ClientOpts{
174+
Token: cfg.Token,
175+
HostName: cfg.Host,
176+
OnTokenRefreshed: cfgMgr.TokenRefreshCallback(),
177+
})
178+
if err != nil {
179+
return err
180+
}
132181
}
133182

134183
commitOid := opts.commitOid
@@ -137,6 +186,10 @@ func (opts *AnalysisOptions) runDetail(ctx context.Context) error {
137186
return clierrors.NewCLIError(clierrors.ErrAPIError, "Failed to fetch run details", err)
138187
}
139188

189+
if opts.OutputFormat == "json" {
190+
return opts.outputRunDetailJSON(runWithIssues)
191+
}
192+
140193
commitShort := commitOid
141194
if len(commitShort) > 8 {
142195
commitShort = commitShort[:8]
@@ -162,6 +215,79 @@ func (opts *AnalysisOptions) runDetail(ctx context.Context) error {
162215
return nil
163216
}
164217

218+
// --- JSON output ---
219+
220+
type AnalysisRunJSON struct {
221+
CommitOid string `json:"commit_oid"`
222+
BranchName string `json:"branch_name"`
223+
Status string `json:"status"`
224+
OccurrencesIntroduced int `json:"occurrences_introduced"`
225+
OccurrencesResolved int `json:"occurrences_resolved"`
226+
OccurrencesSuppressed int `json:"occurrences_suppressed"`
227+
FinishedAt *time.Time `json:"finished_at"`
228+
}
229+
230+
type RunDetailJSON struct {
231+
CommitOid string `json:"commit_oid"`
232+
BranchName string `json:"branch_name"`
233+
Status string `json:"status"`
234+
Issues []RunIssueJSON `json:"issues"`
235+
}
236+
237+
type RunIssueJSON struct {
238+
Path string `json:"path"`
239+
Title string `json:"title"`
240+
Code string `json:"code"`
241+
Category string `json:"category"`
242+
Severity string `json:"severity"`
243+
}
244+
245+
func (opts *AnalysisOptions) outputRunsJSON(analysisRuns []runs.AnalysisRun) error {
246+
result := make([]AnalysisRunJSON, 0, len(analysisRuns))
247+
for _, run := range analysisRuns {
248+
result = append(result, AnalysisRunJSON{
249+
CommitOid: run.CommitOid,
250+
BranchName: run.BranchName,
251+
Status: run.Status,
252+
OccurrencesIntroduced: run.OccurrencesIntroduced,
253+
OccurrencesResolved: run.OccurrencesResolved,
254+
OccurrencesSuppressed: run.OccurrencesSuppressed,
255+
FinishedAt: run.FinishedAt,
256+
})
257+
}
258+
data, err := json.MarshalIndent(result, "", " ")
259+
if err != nil {
260+
return clierrors.NewCLIError(clierrors.ErrAPIError, "Failed to format JSON output", err)
261+
}
262+
fmt.Fprintln(opts.stdout(), string(data))
263+
return nil
264+
}
265+
266+
func (opts *AnalysisOptions) outputRunDetailJSON(runWithIssues *runs.RunWithIssues) error {
267+
issuesJSON := make([]RunIssueJSON, 0, len(runWithIssues.Issues))
268+
for _, issue := range runWithIssues.Issues {
269+
issuesJSON = append(issuesJSON, RunIssueJSON{
270+
Path: issue.Path,
271+
Title: issue.Title,
272+
Code: issue.IssueCode,
273+
Category: issue.Category,
274+
Severity: issue.Severity,
275+
})
276+
}
277+
result := RunDetailJSON{
278+
CommitOid: runWithIssues.CommitOid,
279+
BranchName: runWithIssues.BranchName,
280+
Status: runWithIssues.Status,
281+
Issues: issuesJSON,
282+
}
283+
data, err := json.MarshalIndent(result, "", " ")
284+
if err != nil {
285+
return clierrors.NewCLIError(clierrors.ErrAPIError, "Failed to format JSON output", err)
286+
}
287+
fmt.Fprintln(opts.stdout(), string(data))
288+
return nil
289+
}
290+
165291
// --- Display helpers ---
166292

167293
func showRunsTable(analysisRuns []runs.AnalysisRun) {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package tests
2+
3+
import (
4+
"bytes"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/deepsourcelabs/cli/command/cmddeps"
11+
analysisCmd "github.com/deepsourcelabs/cli/command/analysis"
12+
"github.com/deepsourcelabs/cli/deepsource"
13+
"github.com/deepsourcelabs/cli/internal/testutil"
14+
)
15+
16+
func goldenPath(name string) string {
17+
_, callerFile, _, _ := runtime.Caller(0)
18+
return filepath.Join(filepath.Dir(callerFile), "golden_files", name)
19+
}
20+
21+
func TestAnalysisListRuns(t *testing.T) {
22+
cfgMgr := testutil.CreateTestConfigManager(t, "test-token", "deepsource.com", "test@example.com")
23+
mock := testutil.MockQueryFunc(t, map[string]string{
24+
"GetAnalysisRuns": goldenPath("runs_list_response.json"),
25+
})
26+
client := deepsource.NewWithGraphQLClient(mock)
27+
28+
var buf bytes.Buffer
29+
deps := &cmddeps.Deps{
30+
Client: client,
31+
ConfigMgr: cfgMgr,
32+
Stdout: &buf,
33+
}
34+
35+
cmd := analysisCmd.NewCmdAnalysisWithDeps(deps)
36+
cmd.SetArgs([]string{"--repo", "gh/testowner/testrepo", "--output", "json"})
37+
38+
if err := cmd.Execute(); err != nil {
39+
t.Fatalf("unexpected error: %v", err)
40+
}
41+
42+
expected := string(testutil.LoadGoldenFile(t, goldenPath("runs_list_output.json")))
43+
got := buf.String()
44+
45+
if strings.TrimSpace(got) != strings.TrimSpace(expected) {
46+
t.Errorf("output mismatch.\nExpected:\n%s\nGot:\n%s", expected, got)
47+
}
48+
}
49+
50+
func TestAnalysisRunDetail(t *testing.T) {
51+
cfgMgr := testutil.CreateTestConfigManager(t, "test-token", "deepsource.com", "test@example.com")
52+
mock := testutil.MockQueryFunc(t, map[string]string{
53+
"GetRunIssues": goldenPath("run_detail_response.json"),
54+
})
55+
client := deepsource.NewWithGraphQLClient(mock)
56+
57+
var buf bytes.Buffer
58+
deps := &cmddeps.Deps{
59+
Client: client,
60+
ConfigMgr: cfgMgr,
61+
Stdout: &buf,
62+
}
63+
64+
cmd := analysisCmd.NewCmdAnalysisWithDeps(deps)
65+
cmd.SetArgs([]string{"--repo", "gh/testowner/testrepo", "--commit", "abc123f", "--output", "json"})
66+
67+
if err := cmd.Execute(); err != nil {
68+
t.Fatalf("unexpected error: %v", err)
69+
}
70+
71+
expected := string(testutil.LoadGoldenFile(t, goldenPath("run_detail_output.json")))
72+
got := buf.String()
73+
74+
if strings.TrimSpace(got) != strings.TrimSpace(expected) {
75+
t.Errorf("output mismatch.\nExpected:\n%s\nGot:\n%s", expected, got)
76+
}
77+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"commit_oid": "abc123f",
3+
"branch_name": "main",
4+
"status": "SUCCESS",
5+
"issues": [
6+
{
7+
"path": "cmd/server/main.go",
8+
"title": "Error return value not checked",
9+
"code": "GO-R1005",
10+
"category": "BUG_RISK",
11+
"severity": "MAJOR"
12+
},
13+
{
14+
"path": "internal/handler/auth.go",
15+
"title": "Potential SQL injection",
16+
"code": "GO-S1001",
17+
"category": "SECURITY",
18+
"severity": "CRITICAL"
19+
}
20+
]
21+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"run": {
3+
"runUid": "run-uid-001",
4+
"commitOid": "abc123f",
5+
"branchName": "main",
6+
"status": "SUCCESS",
7+
"checks": {
8+
"edges": [
9+
{
10+
"node": {
11+
"analyzer": {
12+
"name": "Go",
13+
"shortcode": "go"
14+
},
15+
"issues": {
16+
"edges": [
17+
{
18+
"node": {
19+
"source": "static",
20+
"path": "cmd/server/main.go",
21+
"beginLine": 42,
22+
"beginColumn": 5,
23+
"endLine": 42,
24+
"endColumn": 30,
25+
"title": "Error return value not checked",
26+
"shortcode": "GO-R1005",
27+
"category": "BUG_RISK",
28+
"severity": "MAJOR"
29+
}
30+
},
31+
{
32+
"node": {
33+
"source": "ai",
34+
"path": "internal/handler/auth.go",
35+
"beginLine": 15,
36+
"beginColumn": 1,
37+
"endLine": 20,
38+
"endColumn": 2,
39+
"title": "Potential SQL injection",
40+
"shortcode": "GO-S1001",
41+
"category": "SECURITY",
42+
"severity": "CRITICAL"
43+
}
44+
}
45+
]
46+
}
47+
}
48+
}
49+
]
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)