rickub/clipublic Fork
⑂ 7073514
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: `run logs --job` and `--failed`, so one red job is not six jobs of noise (#8)Unverified

`run logs <n>` printed the whole run. On a six-job run that means pulling a
multi-megabyte log and grepping for the ~5 interesting lines — every time, and the
cost of skipping that triage is worse: it trains reviewers to blind-re-run red MRs.

The information was already there; the API returns logs per job with a name and a
status. This is pure client-side selection:

- `--job NAME` prints one job, matched case-insensitively (job names come from
  workflow YAML; `SSH` vs `ssh` is a typo worth forgiving).
- `--failed` prints every job whose status is not success — deliberately wider
  than "failure", because a cancelled job is also not green, and "what isn't
  green?" is the actual question ~90% of the time.
- Both compose with `--json`: the filter is applied BEFORE the --json branch, so
  `--job X --json` cannot silently return the whole run.

The contract that matters is the failure one. An unknown `--job` is an ERROR that
names the jobs the run actually has, never a fall-through to everything: a typo
that returns the full log looks like it worked, and a filter you cannot trust is
worse than no filter, since you stop believing the short output and re-run without
it. An empty `--failed` result says "Every job succeeded" rather than ending on
silence, for the same reason.

`--job` with `--failed` is refused: intersecting them prints nothing whenever the
named job passed, which is indistinguishable from the typo case `--job` exists to
rule out.

The no-flag behaviour is untouched, and a test pins that.

Closes #8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPPhRdTiUiiSvLjthotkT1
Olivier Girardot committed 2026-09-08T00:41:48+02:00 Browse files
7073514 parent: 8e1b39c
modified README.md +5 -1
@@ -183,7 +183,11 @@ rickub pr delete-comment <number> <comment-id> [-R owner/repo]
183183
184184 rickub run list [-R owner/repo]
185185 rickub run view <number> [-R owner/repo]
186-rickub run logs <number> [-R owner/repo]
186+rickub run logs <number> [-R owner/repo] [--job NAME | --failed]
187+ # --job prints one job (case-insensitive; an unknown name is an
188+ # error listing the run's real jobs, never the whole log);
189+ # --failed prints every job whose status is not success.
190+ # Both compose with --json.
187191 rickub run rerun <number> [-R owner/repo]
188192 rickub run cancel <number> [-R owner/repo]
189193 rickub run dispatch [-R owner/repo] [--ref B]
@@ -183,7 +183,11 @@ rickub pr delete-comment <number> <comment-id> [-R owner/repo]
183 183
184 rickub run list [-R owner/repo]184 rickub run list [-R owner/repo]
185 rickub run view <number> [-R owner/repo]185 rickub run view <number> [-R owner/repo]
186-rickub run logs <number> [-R owner/repo]186+rickub run logs <number> [-R owner/repo] [--job NAME | --failed]
187+ # --job prints one job (case-insensitive; an unknown name is an
188+ # error listing the run's real jobs, never the whole log);
189+ # --failed prints every job whose status is not success.
190+ # Both compose with --json.
187 rickub run rerun <number> [-R owner/repo]191 rickub run rerun <number> [-R owner/repo]
188 rickub run cancel <number> [-R owner/repo]192 rickub run cancel <number> [-R owner/repo]
189 rickub run dispatch [-R owner/repo] [--ref B]193 rickub run dispatch [-R owner/repo] [--ref B]
modified cmd/repo_test.go +2 -0
@@ -39,6 +39,8 @@ func execute(t *testing.T, args ...string) (string, string, error) {
3939 runWatchInterval = "2s"
4040 runWatchTimeout = "30m"
4141 runWatchLogs = false
42+ runLogsJob = ""
43+ runLogsFailed = false
4244 authLoginScope = "all"
4345 authLoginNoBrowser = false
4446 prRepo = ""
@@ -39,6 +39,8 @@ func execute(t *testing.T, args ...string) (string, string, error) {
39 runWatchInterval = "2s"39 runWatchInterval = "2s"
40 runWatchTimeout = "30m"40 runWatchTimeout = "30m"
41 runWatchLogs = false41 runWatchLogs = false
42+ runLogsJob = ""
43+ runLogsFailed = false
42 authLoginScope = "all"44 authLoginScope = "all"
43 authLoginNoBrowser = false45 authLoginNoBrowser = false
44 prRepo = ""46 prRepo = ""
modified cmd/run.go +89 -4
@@ -17,6 +17,8 @@ var (
1717 runWatchInterval string
1818 runWatchTimeout string
1919 runWatchLogs bool
20+ runLogsJob string
21+ runLogsFailed bool
2022 )
2123
2224 func init() {
@@ -43,10 +45,24 @@ func init() {
4345
4446 logsCmd := &cobra.Command{
4547 Use: "logs <number>",
46- Short: "Print a run's accumulated logs",
47- Args: cobra.ExactArgs(1),
48- RunE: runRunLogs,
49- }
48+ Short: "Print a run's accumulated logs, optionally one job's or only the ones that failed",
49+ Long: "Print a run's accumulated logs.\n\n" +
50+ "With no flags this prints every job, which on a six-job run means five jobs of\n" +
51+ "noise around the one you care about. --job narrows it to one; --failed narrows it\n" +
52+ "to every job whose status is not success, which is the actual question most of\n" +
53+ "the time. Both compose with --json.\n\n" +
54+ "A --job name that matches nothing is an ERROR listing the run's real job names:\n" +
55+ "a typo that quietly printed the whole log would look like it worked, which is\n" +
56+ "worse than having no flag at all.",
57+ Example: " rickub run logs 30\n" +
58+ " rickub run logs 30 --job ssh\n" +
59+ " rickub run logs 30 --failed\n" +
60+ " rickub run logs 30 --job web --json",
61+ Args: cobra.ExactArgs(1),
62+ RunE: runRunLogs,
63+ }
64+ logsCmd.Flags().StringVar(&runLogsJob, "job", "", "print only this job's logs (case-insensitive)")
65+ logsCmd.Flags().BoolVar(&runLogsFailed, "failed", false, "print only jobs whose status is not success")
5066
5167 rerunCmd := &cobra.Command{
5268 Use: "rerun <number>",
@@ -179,15 +195,38 @@ func runRunLogs(cmd *cobra.Command, args []string) error {
179195 if err != nil {
180196 return err
181197 }
198+ if runLogsJob != "" && runLogsFailed {
199+ // Intersecting them would print nothing whenever the named job passed,
200+ // which is indistinguishable from the typo case --job exists to rule out.
201+ return fmt.Errorf("--job and --failed select different things: pass one or the other")
202+ }
182203 logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n)
183204 if err != nil {
184205 return err
185206 }
207+ // Filter BEFORE the --json branch: a filter that only applied to human output
208+ // would make `--job X --json` silently return the whole run.
209+ jobs, err := selectRunLogJobs(logs.Jobs, runLogsJob, runLogsFailed)
210+ if err != nil {
211+ return err
212+ }
213+ logs.Jobs = jobs
214+
186215 if flagJSON {
187216 return printJSON(cmd.OutOrStdout(), logs)
188217 }
189218 out := cmd.OutOrStdout()
190219 fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status)
220+ if len(logs.Jobs) == 0 {
221+ // Never end on silence: an empty result and a broken filter look identical
222+ // otherwise, and the whole point of the flag is to be trustworthy.
223+ if runLogsFailed {
224+ fmt.Fprintf(out, "\nEvery job succeeded — nothing to show.\n")
225+ } else {
226+ fmt.Fprintf(out, "\nThis run has no jobs.\n")
227+ }
228+ return nil
229+ }
191230 for _, j := range logs.Jobs {
192231 fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status)
193232 fmt.Fprintln(out, j.Log)
@@ -195,6 +234,52 @@ func runRunLogs(cmd *cobra.Command, args []string) error {
195234 return nil
196235 }
197236
237+// selectRunLogJobs narrows a run's per-job logs to what the flags asked for.
238+//
239+// The contract that matters is the failure one: an unknown --job is an error that
240+// names the jobs the run actually has, never a silent fall-through to everything.
241+// A typo that returns the full log looks like it worked, and a filter you cannot
242+// trust is worse than no filter — you stop believing the short output and re-run
243+// without it, which is the cost the flag was added to remove (issue #8).
244+func selectRunLogJobs(jobs []api.RunLogsJob, name string, failedOnly bool) ([]api.RunLogsJob, error) {
245+ switch {
246+ case name != "":
247+ for _, j := range jobs {
248+ // Case-insensitive: job names come from workflow YAML, and matching
249+ // `SSH` against `ssh` is a typo worth forgiving rather than reporting.
250+ if strings.EqualFold(j.Name, name) {
251+ return []api.RunLogsJob{j}, nil
252+ }
253+ }
254+ if len(jobs) == 0 {
255+ return nil, fmt.Errorf("no job named %q: this run has no jobs", name)
256+ }
257+ return nil, fmt.Errorf("no job named %q; this run has: %s", name, strings.Join(runLogJobNames(jobs), ", "))
258+ case failedOnly:
259+ // "not success" is deliberately wider than "failure": a cancelled job, and
260+ // one still running when the logs were fetched, are both things you want to
261+ // see when asking what is not green.
262+ out := make([]api.RunLogsJob, 0, len(jobs))
263+ for _, j := range jobs {
264+ if j.Status != "success" {
265+ out = append(out, j)
266+ }
267+ }
268+ return out, nil
269+ default:
270+ return jobs, nil
271+ }
272+}
273+
274+// runLogJobNames lists a run's job names, for the unknown-job error.
275+func runLogJobNames(jobs []api.RunLogsJob) []string {
276+ names := make([]string, 0, len(jobs))
277+ for _, j := range jobs {
278+ names = append(names, j.Name)
279+ }
280+ return names
281+}
282+
198283 func runRunRerun(cmd *cobra.Command, args []string) error {
199284 client, err := newClient()
200285 if err != nil {
@@ -17,6 +17,8 @@ var (
17 runWatchInterval string17 runWatchInterval string
18 runWatchTimeout string18 runWatchTimeout string
19 runWatchLogs bool19 runWatchLogs bool
20+ runLogsJob string
21+ runLogsFailed bool
20 )22 )
21 23
22 func init() {24 func init() {
@@ -43,10 +45,24 @@ func init() {
43 45
44 logsCmd := &cobra.Command{46 logsCmd := &cobra.Command{
45 Use: "logs <number>",47 Use: "logs <number>",
46- Short: "Print a run's accumulated logs",48+ Short: "Print a run's accumulated logs, optionally one job's or only the ones that failed",
47- Args: cobra.ExactArgs(1),49+ Long: "Print a run's accumulated logs.\n\n" +
48- RunE: runRunLogs,50+ "With no flags this prints every job, which on a six-job run means five jobs of\n" +
49- }51+ "noise around the one you care about. --job narrows it to one; --failed narrows it\n" +
52+ "to every job whose status is not success, which is the actual question most of\n" +
53+ "the time. Both compose with --json.\n\n" +
54+ "A --job name that matches nothing is an ERROR listing the run's real job names:\n" +
55+ "a typo that quietly printed the whole log would look like it worked, which is\n" +
56+ "worse than having no flag at all.",
57+ Example: " rickub run logs 30\n" +
58+ " rickub run logs 30 --job ssh\n" +
59+ " rickub run logs 30 --failed\n" +
60+ " rickub run logs 30 --job web --json",
61+ Args: cobra.ExactArgs(1),
62+ RunE: runRunLogs,
63+ }
64+ logsCmd.Flags().StringVar(&runLogsJob, "job", "", "print only this job's logs (case-insensitive)")
65+ logsCmd.Flags().BoolVar(&runLogsFailed, "failed", false, "print only jobs whose status is not success")
50 66
51 rerunCmd := &cobra.Command{67 rerunCmd := &cobra.Command{
52 Use: "rerun <number>",68 Use: "rerun <number>",
@@ -179,15 +195,38 @@ func runRunLogs(cmd *cobra.Command, args []string) error {
179 if err != nil {195 if err != nil {
180 return err196 return err
181 }197 }
198+ if runLogsJob != "" && runLogsFailed {
199+ // Intersecting them would print nothing whenever the named job passed,
200+ // which is indistinguishable from the typo case --job exists to rule out.
201+ return fmt.Errorf("--job and --failed select different things: pass one or the other")
202+ }
182 logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n)203 logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n)
183 if err != nil {204 if err != nil {
184 return err205 return err
185 }206 }
207+ // Filter BEFORE the --json branch: a filter that only applied to human output
208+ // would make `--job X --json` silently return the whole run.
209+ jobs, err := selectRunLogJobs(logs.Jobs, runLogsJob, runLogsFailed)
210+ if err != nil {
211+ return err
212+ }
213+ logs.Jobs = jobs
214+
186 if flagJSON {215 if flagJSON {
187 return printJSON(cmd.OutOrStdout(), logs)216 return printJSON(cmd.OutOrStdout(), logs)
188 }217 }
189 out := cmd.OutOrStdout()218 out := cmd.OutOrStdout()
190 fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status)219 fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status)
220+ if len(logs.Jobs) == 0 {
221+ // Never end on silence: an empty result and a broken filter look identical
222+ // otherwise, and the whole point of the flag is to be trustworthy.
223+ if runLogsFailed {
224+ fmt.Fprintf(out, "\nEvery job succeeded — nothing to show.\n")
225+ } else {
226+ fmt.Fprintf(out, "\nThis run has no jobs.\n")
227+ }
228+ return nil
229+ }
191 for _, j := range logs.Jobs {230 for _, j := range logs.Jobs {
192 fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status)231 fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status)
193 fmt.Fprintln(out, j.Log)232 fmt.Fprintln(out, j.Log)
@@ -195,6 +234,52 @@ func runRunLogs(cmd *cobra.Command, args []string) error {
195 return nil234 return nil
196 }235 }
197 236
237+// selectRunLogJobs narrows a run's per-job logs to what the flags asked for.
238+//
239+// The contract that matters is the failure one: an unknown --job is an error that
240+// names the jobs the run actually has, never a silent fall-through to everything.
241+// A typo that returns the full log looks like it worked, and a filter you cannot
242+// trust is worse than no filter — you stop believing the short output and re-run
243+// without it, which is the cost the flag was added to remove (issue #8).
244+func selectRunLogJobs(jobs []api.RunLogsJob, name string, failedOnly bool) ([]api.RunLogsJob, error) {
245+ switch {
246+ case name != "":
247+ for _, j := range jobs {
248+ // Case-insensitive: job names come from workflow YAML, and matching
249+ // `SSH` against `ssh` is a typo worth forgiving rather than reporting.
250+ if strings.EqualFold(j.Name, name) {
251+ return []api.RunLogsJob{j}, nil
252+ }
253+ }
254+ if len(jobs) == 0 {
255+ return nil, fmt.Errorf("no job named %q: this run has no jobs", name)
256+ }
257+ return nil, fmt.Errorf("no job named %q; this run has: %s", name, strings.Join(runLogJobNames(jobs), ", "))
258+ case failedOnly:
259+ // "not success" is deliberately wider than "failure": a cancelled job, and
260+ // one still running when the logs were fetched, are both things you want to
261+ // see when asking what is not green.
262+ out := make([]api.RunLogsJob, 0, len(jobs))
263+ for _, j := range jobs {
264+ if j.Status != "success" {
265+ out = append(out, j)
266+ }
267+ }
268+ return out, nil
269+ default:
270+ return jobs, nil
271+ }
272+}
273+
274+// runLogJobNames lists a run's job names, for the unknown-job error.
275+func runLogJobNames(jobs []api.RunLogsJob) []string {
276+ names := make([]string, 0, len(jobs))
277+ for _, j := range jobs {
278+ names = append(names, j.Name)
279+ }
280+ return names
281+}
282+
198 func runRunRerun(cmd *cobra.Command, args []string) error {283 func runRunRerun(cmd *cobra.Command, args []string) error {
199 client, err := newClient()284 client, err := newClient()
200 if err != nil {285 if err != nil {
added cmd/run_logs_filter_test.go +167 -0
new file mode 100644
@@ -0,0 +1,167 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "rickub.com/rickub/cli/internal/api"
11+)
12+
13+// sixJobRun is the shape issue #8 is about: one red job among five that are noise.
14+const sixJobRun = `{"number":30,"status":"failure","jobs":[
15+ {"name":"engine","status":"success","log":"engine ok\n"},
16+ {"name":"registry","status":"success","log":"registry ok\n"},
17+ {"name":"web","status":"success","log":"web ok\n"},
18+ {"name":"ssh","status":"failure","log":"##[error]getaddrinfo EAI_AGAIN golang.org\n"},
19+ {"name":"lint","status":"cancelled","log":"lint cancelled\n"},
20+ {"name":"coverage","status":"success","log":"coverage ok\n"}
21+]}`
22+
23+func runLogsServer(t *testing.T, body string) *httptest.Server {
24+ t.Helper()
25+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+ w.Header().Set("Content-Type", "application/json")
27+ _, _ = w.Write([]byte(body))
28+ }))
29+ t.Cleanup(srv.Close)
30+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
31+ t.Setenv("RICKUB_HOST", srv.URL)
32+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
33+ return srv
34+}
35+
36+// TestRunLogsJobPrintsOnlyThatJob is issue #8's acceptance criterion: the five
37+// other jobs must not appear at all.
38+func TestRunLogsJobPrintsOnlyThatJob(t *testing.T) {
39+ runLogsServer(t, sixJobRun)
40+
41+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh")
42+ if err != nil {
43+ t.Fatalf("execute: %v", err)
44+ }
45+ if !strings.Contains(out, "EAI_AGAIN") || !strings.Contains(out, "job: ssh") {
46+ t.Errorf("the requested job is missing:\n%s", out)
47+ }
48+ for _, other := range []string{"engine ok", "registry ok", "web ok", "coverage ok", "lint cancelled"} {
49+ if strings.Contains(out, other) {
50+ t.Errorf("output still contains another job's log (%q):\n%s", other, out)
51+ }
52+ }
53+}
54+
55+// TestRunLogsUnknownJobErrorsAndNamesTheRealOnes: a typo must NOT fall through to
56+// the full log. That would look like it worked, which is worse than no flag.
57+func TestRunLogsUnknownJobErrorsAndNamesTheRealOnes(t *testing.T) {
58+ runLogsServer(t, sixJobRun)
59+
60+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "shh")
61+ if err == nil {
62+ t.Fatal("an unknown job name was accepted")
63+ }
64+ if strings.Contains(out, "engine ok") || strings.Contains(out, "EAI_AGAIN") {
65+ t.Errorf("a bad --job printed logs anyway:\n%s", out)
66+ }
67+ for _, name := range []string{"engine", "registry", "web", "ssh", "lint", "coverage"} {
68+ if !strings.Contains(err.Error(), name) {
69+ t.Errorf("error should list the run's real job names, missing %q: %v", name, err)
70+ }
71+ }
72+}
73+
74+// TestRunLogsJobIsCaseInsensitive: matching `SSH` against `ssh` is a typo worth
75+// forgiving, not reporting.
76+func TestRunLogsJobIsCaseInsensitive(t *testing.T) {
77+ runLogsServer(t, sixJobRun)
78+
79+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "SSH")
80+ if err != nil {
81+ t.Fatalf("execute: %v", err)
82+ }
83+ if !strings.Contains(out, "EAI_AGAIN") {
84+ t.Errorf("case-insensitive match failed:\n%s", out)
85+ }
86+}
87+
88+// TestRunLogsFailedSelectsEverythingNotGreen: "not success" is wider than
89+// "failure" on purpose — a cancelled job is also not green.
90+func TestRunLogsFailedSelectsEverythingNotGreen(t *testing.T) {
91+ runLogsServer(t, sixJobRun)
92+
93+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--failed")
94+ if err != nil {
95+ t.Fatalf("execute: %v", err)
96+ }
97+ if !strings.Contains(out, "EAI_AGAIN") || !strings.Contains(out, "lint cancelled") {
98+ t.Errorf("--failed should show the failed AND the cancelled job:\n%s", out)
99+ }
100+ for _, green := range []string{"engine ok", "registry ok", "web ok", "coverage ok"} {
101+ if strings.Contains(out, green) {
102+ t.Errorf("--failed leaked a successful job (%q):\n%s", green, out)
103+ }
104+ }
105+}
106+
107+// TestRunLogsFailedOnAGreenRunSaysSo: never end on silence — an empty result and
108+// a broken filter look identical otherwise.
109+func TestRunLogsFailedOnAGreenRunSaysSo(t *testing.T) {
110+ runLogsServer(t, `{"number":31,"status":"success","jobs":[{"name":"check","status":"success","log":"ok\n"}]}`)
111+
112+ out, _, err := execute(t, "run", "logs", "31", "-R", "o/r", "--failed")
113+ if err != nil {
114+ t.Fatalf("execute: %v", err)
115+ }
116+ if !strings.Contains(out, "Every job succeeded") {
117+ t.Errorf("an empty --failed result should say so:\n%s", out)
118+ }
119+}
120+
121+// TestRunLogsJobComposesWithJSON: the filter must apply BEFORE the --json branch,
122+// or `--job X --json` silently returns the whole run.
123+func TestRunLogsJobComposesWithJSON(t *testing.T) {
124+ runLogsServer(t, sixJobRun)
125+
126+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh", "--json")
127+ if err != nil {
128+ t.Fatalf("execute: %v", err)
129+ }
130+ var decoded api.RunLogs
131+ if err := json.Unmarshal([]byte(out), &decoded); err != nil {
132+ t.Fatalf("output is not JSON: %v\n%s", err, out)
133+ }
134+ if len(decoded.Jobs) != 1 || decoded.Jobs[0].Name != "ssh" {
135+ t.Errorf("--json should carry only the selected job, got %+v", decoded.Jobs)
136+ }
137+ if decoded.Number != 30 || decoded.Status != "failure" {
138+ t.Errorf("the run's own fields should survive the filter: %+v", decoded)
139+ }
140+}
141+
142+// TestRunLogsJobAndFailedAreRefused: intersecting them prints nothing whenever the
143+// named job passed, which is indistinguishable from the typo case --job exists to
144+// rule out.
145+func TestRunLogsJobAndFailedAreRefused(t *testing.T) {
146+ runLogsServer(t, sixJobRun)
147+
148+ if _, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh", "--failed"); err == nil {
149+ t.Fatal("--job with --failed was accepted")
150+ }
151+}
152+
153+// TestRunLogsUnfilteredIsUnchanged: the no-flag behaviour every existing script
154+// depends on still prints every job.
155+func TestRunLogsUnfilteredIsUnchanged(t *testing.T) {
156+ runLogsServer(t, sixJobRun)
157+
158+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r")
159+ if err != nil {
160+ t.Fatalf("execute: %v", err)
161+ }
162+ for _, name := range []string{"engine", "registry", "web", "ssh", "lint", "coverage"} {
163+ if !strings.Contains(out, "job: "+name) {
164+ t.Errorf("unfiltered output is missing job %q:\n%s", name, out)
165+ }
166+ }
167+}
new file mode 100644
@@ -0,0 +1,167 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "rickub.com/rickub/cli/internal/api"
11+)
12+
13+// sixJobRun is the shape issue #8 is about: one red job among five that are noise.
14+const sixJobRun = `{"number":30,"status":"failure","jobs":[
15+ {"name":"engine","status":"success","log":"engine ok\n"},
16+ {"name":"registry","status":"success","log":"registry ok\n"},
17+ {"name":"web","status":"success","log":"web ok\n"},
18+ {"name":"ssh","status":"failure","log":"##[error]getaddrinfo EAI_AGAIN golang.org\n"},
19+ {"name":"lint","status":"cancelled","log":"lint cancelled\n"},
20+ {"name":"coverage","status":"success","log":"coverage ok\n"}
21+]}`
22+
23+func runLogsServer(t *testing.T, body string) *httptest.Server {
24+ t.Helper()
25+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+ w.Header().Set("Content-Type", "application/json")
27+ _, _ = w.Write([]byte(body))
28+ }))
29+ t.Cleanup(srv.Close)
30+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
31+ t.Setenv("RICKUB_HOST", srv.URL)
32+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
33+ return srv
34+}
35+
36+// TestRunLogsJobPrintsOnlyThatJob is issue #8's acceptance criterion: the five
37+// other jobs must not appear at all.
38+func TestRunLogsJobPrintsOnlyThatJob(t *testing.T) {
39+ runLogsServer(t, sixJobRun)
40+
41+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh")
42+ if err != nil {
43+ t.Fatalf("execute: %v", err)
44+ }
45+ if !strings.Contains(out, "EAI_AGAIN") || !strings.Contains(out, "job: ssh") {
46+ t.Errorf("the requested job is missing:\n%s", out)
47+ }
48+ for _, other := range []string{"engine ok", "registry ok", "web ok", "coverage ok", "lint cancelled"} {
49+ if strings.Contains(out, other) {
50+ t.Errorf("output still contains another job's log (%q):\n%s", other, out)
51+ }
52+ }
53+}
54+
55+// TestRunLogsUnknownJobErrorsAndNamesTheRealOnes: a typo must NOT fall through to
56+// the full log. That would look like it worked, which is worse than no flag.
57+func TestRunLogsUnknownJobErrorsAndNamesTheRealOnes(t *testing.T) {
58+ runLogsServer(t, sixJobRun)
59+
60+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "shh")
61+ if err == nil {
62+ t.Fatal("an unknown job name was accepted")
63+ }
64+ if strings.Contains(out, "engine ok") || strings.Contains(out, "EAI_AGAIN") {
65+ t.Errorf("a bad --job printed logs anyway:\n%s", out)
66+ }
67+ for _, name := range []string{"engine", "registry", "web", "ssh", "lint", "coverage"} {
68+ if !strings.Contains(err.Error(), name) {
69+ t.Errorf("error should list the run's real job names, missing %q: %v", name, err)
70+ }
71+ }
72+}
73+
74+// TestRunLogsJobIsCaseInsensitive: matching `SSH` against `ssh` is a typo worth
75+// forgiving, not reporting.
76+func TestRunLogsJobIsCaseInsensitive(t *testing.T) {
77+ runLogsServer(t, sixJobRun)
78+
79+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "SSH")
80+ if err != nil {
81+ t.Fatalf("execute: %v", err)
82+ }
83+ if !strings.Contains(out, "EAI_AGAIN") {
84+ t.Errorf("case-insensitive match failed:\n%s", out)
85+ }
86+}
87+
88+// TestRunLogsFailedSelectsEverythingNotGreen: "not success" is wider than
89+// "failure" on purpose — a cancelled job is also not green.
90+func TestRunLogsFailedSelectsEverythingNotGreen(t *testing.T) {
91+ runLogsServer(t, sixJobRun)
92+
93+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--failed")
94+ if err != nil {
95+ t.Fatalf("execute: %v", err)
96+ }
97+ if !strings.Contains(out, "EAI_AGAIN") || !strings.Contains(out, "lint cancelled") {
98+ t.Errorf("--failed should show the failed AND the cancelled job:\n%s", out)
99+ }
100+ for _, green := range []string{"engine ok", "registry ok", "web ok", "coverage ok"} {
101+ if strings.Contains(out, green) {
102+ t.Errorf("--failed leaked a successful job (%q):\n%s", green, out)
103+ }
104+ }
105+}
106+
107+// TestRunLogsFailedOnAGreenRunSaysSo: never end on silence — an empty result and
108+// a broken filter look identical otherwise.
109+func TestRunLogsFailedOnAGreenRunSaysSo(t *testing.T) {
110+ runLogsServer(t, `{"number":31,"status":"success","jobs":[{"name":"check","status":"success","log":"ok\n"}]}`)
111+
112+ out, _, err := execute(t, "run", "logs", "31", "-R", "o/r", "--failed")
113+ if err != nil {
114+ t.Fatalf("execute: %v", err)
115+ }
116+ if !strings.Contains(out, "Every job succeeded") {
117+ t.Errorf("an empty --failed result should say so:\n%s", out)
118+ }
119+}
120+
121+// TestRunLogsJobComposesWithJSON: the filter must apply BEFORE the --json branch,
122+// or `--job X --json` silently returns the whole run.
123+func TestRunLogsJobComposesWithJSON(t *testing.T) {
124+ runLogsServer(t, sixJobRun)
125+
126+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh", "--json")
127+ if err != nil {
128+ t.Fatalf("execute: %v", err)
129+ }
130+ var decoded api.RunLogs
131+ if err := json.Unmarshal([]byte(out), &decoded); err != nil {
132+ t.Fatalf("output is not JSON: %v\n%s", err, out)
133+ }
134+ if len(decoded.Jobs) != 1 || decoded.Jobs[0].Name != "ssh" {
135+ t.Errorf("--json should carry only the selected job, got %+v", decoded.Jobs)
136+ }
137+ if decoded.Number != 30 || decoded.Status != "failure" {
138+ t.Errorf("the run's own fields should survive the filter: %+v", decoded)
139+ }
140+}
141+
142+// TestRunLogsJobAndFailedAreRefused: intersecting them prints nothing whenever the
143+// named job passed, which is indistinguishable from the typo case --job exists to
144+// rule out.
145+func TestRunLogsJobAndFailedAreRefused(t *testing.T) {
146+ runLogsServer(t, sixJobRun)
147+
148+ if _, _, err := execute(t, "run", "logs", "30", "-R", "o/r", "--job", "ssh", "--failed"); err == nil {
149+ t.Fatal("--job with --failed was accepted")
150+ }
151+}
152+
153+// TestRunLogsUnfilteredIsUnchanged: the no-flag behaviour every existing script
154+// depends on still prints every job.
155+func TestRunLogsUnfilteredIsUnchanged(t *testing.T) {
156+ runLogsServer(t, sixJobRun)
157+
158+ out, _, err := execute(t, "run", "logs", "30", "-R", "o/r")
159+ if err != nil {
160+ t.Fatalf("execute: %v", err)
161+ }
162+ for _, name := range []string{"engine", "registry", "web", "ssh", "lint", "coverage"} {
163+ if !strings.Contains(out, "job: "+name) {
164+ t.Errorf("unfiltered output is missing job %q:\n%s", name, out)
165+ }
166+ }
167+}