rickub/clipublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git
run.go · 356 lines · 8.8 KBGo Blame HistoryRaw
Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 9h ago1package cmd
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8
9 "rickub.com/rickub/cli/internal/api"
10
11 "github.com/spf13/cobra"
12)
13
14var (
15 runRepo string
16 runDispatchRef string
17 runWatchInterval string
18 runWatchTimeout string
19 runWatchLogs bool
20)
21
22func init() {
23 runCmd := &cobra.Command{
24 Use: "run",
25 Short: "Work with CI (Actions) workflow runs",
26 }
27 runCmd.PersistentFlags().StringVarP(&runRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)")
28
29 listCmd := &cobra.Command{
30 Use: "list",
31 Short: "List workflow runs",
32 Args: cobra.NoArgs,
33 RunE: runRunList,
34 }
35 addPaging(listCmd)
36
37 viewCmd := &cobra.Command{
38 Use: "view <number>",
39 Short: "Show a run with its jobs and steps",
40 Args: cobra.ExactArgs(1),
41 RunE: runRunView,
42 }
43
44 logsCmd := &cobra.Command{
45 Use: "logs <number>",
46 Short: "Print a run's accumulated logs",
47 Args: cobra.ExactArgs(1),
48 RunE: runRunLogs,
49 }
50
51 rerunCmd := &cobra.Command{
52 Use: "rerun <number>",
53 Short: "Re-run a finished run at the same commit",
54 Args: cobra.ExactArgs(1),
55 RunE: runRunRerun,
56 }
57
58 cancelCmd := &cobra.Command{
59 Use: "cancel <number>",
60 Short: "Cancel an in-flight run",
61 Args: cobra.ExactArgs(1),
62 RunE: runRunCancel,
63 }
64
65 dispatchCmd := &cobra.Command{
66 Use: "dispatch",
67 Short: "Trigger workflow_dispatch workflows",
68 Args: cobra.NoArgs,
69 RunE: runRunDispatch,
70 }
71 dispatchCmd.Flags().StringVar(&runDispatchRef, "ref", "", "branch to dispatch on (default: default branch)")
72
73 watchCmd := &cobra.Command{
74 Use: "watch <number>",
75 Short: "Follow a run until it finishes (exit 0 on success, 1 otherwise)",
76 Args: cobra.ExactArgs(1),
77 RunE: runRunWatch,
78 }
79 watchCmd.Flags().StringVar(&runWatchInterval, "interval", "2s", "poll interval (e.g. 2s, 5s)")
80 watchCmd.Flags().StringVar(&runWatchTimeout, "timeout", "30m", "give up after this duration")
81 watchCmd.Flags().BoolVar(&runWatchLogs, "logs", false, "print the accumulated logs when the run finishes")
82
83 runCmd.AddCommand(listCmd, viewCmd, logsCmd, rerunCmd, cancelCmd, dispatchCmd, watchCmd)
84 rootCmd.AddCommand(runCmd)
85}
86
87func runNumber(arg string) (int, error) {
88 n, err := strconv.Atoi(arg)
89 if err != nil || n <= 0 {
90 return 0, fmt.Errorf("invalid run number %q", arg)
91 }
92 return n, nil
93}
94
95func runRunList(cmd *cobra.Command, _ []string) error {
96 client, err := newClient()
97 if err != nil {
98 return err
99 }
100 owner, repo, err := resolveRepo(runRepo)
101 if err != nil {
102 return err
103 }
104 page, err := client.ListRuns(cmd.Context(), owner, repo, pageFlag, perPageFlag)
105 if err != nil {
106 return err
107 }
108 if flagJSON {
109 return printJSON(cmd.OutOrStdout(), page)
110 }
111 if len(page.Items) == 0 {
112 fmt.Fprintln(cmd.OutOrStdout(), "No workflow runs.")
113 return nil
114 }
115 tw := newTabw(cmd.OutOrStdout())
116 fmt.Fprintln(tw, "#\tWORKFLOW\tEVENT\tBRANCH\tSTATUS\tCOMMIT")
117 for _, r := range page.Items {
118 fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA))
119 }
120 tw.Flush()
121 printPageFooter(cmd, page.Page)
122 return nil
123}
124
125func short(sha string) string {
126 if len(sha) > 8 {
127 return sha[:8]
128 }
129 return dash(sha)
130}
131
132func runRunView(cmd *cobra.Command, args []string) error {
133 client, err := newClient()
134 if err != nil {
135 return err
136 }
137 owner, repo, err := resolveRepo(runRepo)
138 if err != nil {
139 return err
140 }
141 n, err := runNumber(args[0])
142 if err != nil {
143 return err
144 }
145 r, err := client.GetRun(cmd.Context(), owner, repo, n)
146 if err != nil {
147 return err
148 }
149 if flagJSON {
150 return printJSON(cmd.OutOrStdout(), r)
151 }
152 out := cmd.OutOrStdout()
153 fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status)
154 fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA))
155 if r.SecretsWithheld {
156 fmt.Fprintln(out, "note: secrets were withheld from this run")
157 }
158 for _, j := range r.Jobs {
159 fmt.Fprintf(out, "\nJob %s [%s]\n", j.Name, j.Status)
160 for _, s := range j.Steps {
161 fmt.Fprintf(out, " %2d. %-30s %s\n", s.Ordinal, s.Name, s.Status)
162 }
163 }
164 return nil
165}
166
167func runRunLogs(cmd *cobra.Command, args []string) error {
168 client, err := newClient()
169 if err != nil {
170 return err
171 }
172 owner, repo, err := resolveRepo(runRepo)
173 if err != nil {
174 return err
175 }
176 n, err := runNumber(args[0])
177 if err != nil {
178 return err
179 }
180 logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n)
181 if err != nil {
182 return err
183 }
184 if flagJSON {
185 return printJSON(cmd.OutOrStdout(), logs)
186 }
187 out := cmd.OutOrStdout()
188 fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status)
189 for _, j := range logs.Jobs {
190 fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status)
191 fmt.Fprintln(out, j.Log)
192 }
193 return nil
194}
195
196func runRunRerun(cmd *cobra.Command, args []string) error {
197 client, err := newClient()
198 if err != nil {
199 return err
200 }
201 owner, repo, err := resolveRepo(runRepo)
202 if err != nil {
203 return err
204 }
205 n, err := runNumber(args[0])
206 if err != nil {
207 return err
208 }
209 r, err := client.RerunRun(cmd.Context(), owner, repo, n)
210 if err != nil {
211 return err
212 }
213 if flagJSON {
214 return printJSON(cmd.OutOrStdout(), r)
215 }
216 fmt.Fprintf(cmd.OutOrStdout(), "Re-ran; new run #%d [%s]\n", r.Number, r.Status)
217 return nil
218}
219
220func runRunCancel(cmd *cobra.Command, args []string) error {
221 client, err := newClient()
222 if err != nil {
223 return err
224 }
225 owner, repo, err := resolveRepo(runRepo)
226 if err != nil {
227 return err
228 }
229 n, err := runNumber(args[0])
230 if err != nil {
231 return err
232 }
233 r, err := client.CancelRun(cmd.Context(), owner, repo, n)
234 if err != nil {
235 return err
236 }
237 if flagJSON {
238 return printJSON(cmd.OutOrStdout(), r)
239 }
240 fmt.Fprintf(cmd.OutOrStdout(), "Cancelled run #%d [%s]\n", r.Number, r.Status)
241 return nil
242}
243
244func runRunDispatch(cmd *cobra.Command, _ []string) error {
245 client, err := newClient()
246 if err != nil {
247 return err
248 }
249 owner, repo, err := resolveRepo(runRepo)
250 if err != nil {
251 return err
252 }
253 res, err := client.Dispatch(cmd.Context(), owner, repo, runDispatchRef)
254 if err != nil {
255 return err
256 }
257 if flagJSON {
258 return printJSON(cmd.OutOrStdout(), res)
259 }
260 fmt.Fprintf(cmd.OutOrStdout(), "Dispatched %d run(s).\n", res.Dispatched)
261 for _, r := range res.Runs {
262 fmt.Fprintf(cmd.OutOrStdout(), " #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status)
263 }
264 return nil
265}
266
267// ---- watch -------------------------------------------------------------------
268
269// runTerminalStatuses are the statuses a run never leaves.
270var runTerminalStatuses = map[string]bool{"success": true, "failure": true, "cancelled": true}
271
272// runWatchExitError is returned (non-zero exit) when the watched run ends in a
273// state other than success — CI-friendly for scripting.
274type runWatchExitError struct{ status string }
275
276func (e *runWatchExitError) Error() string { return "run finished with status " + e.status }
277
278func runRunWatch(cmd *cobra.Command, args []string) error {
279 client, err := newClient()
280 if err != nil {
281 return err
282 }
283 owner, repo, err := resolveRepo(runRepo)
284 if err != nil {
285 return err
286 }
287 n, err := runNumber(args[0])
288 if err != nil {
289 return err
290 }
291 interval, err := time.ParseDuration(runWatchInterval)
292 if err != nil || interval <= 0 {
293 return fmt.Errorf("invalid --interval %q", runWatchInterval)
294 }
295 timeout, err := time.ParseDuration(runWatchTimeout)
296 if err != nil || timeout <= 0 {
297 return fmt.Errorf("invalid --timeout %q", runWatchTimeout)
298 }
299
300 out := cmd.OutOrStdout()
301 deadline := time.Now().Add(timeout)
302 var last string
303 tick := time.NewTicker(interval)
304 defer tick.Stop()
305 for {
306 r, err := client.GetRun(cmd.Context(), owner, repo, n)
307 if err != nil {
308 return err
309 }
310 if snap := runWatchSnapshot(r); snap != last {
311 last = snap
312 fmt.Fprintln(out, snap)
313 }
314 if runTerminalStatuses[r.Status] {
315 if runWatchLogs {
316 logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n)
317 if err != nil {
318 return err
319 }
320 for _, j := range logs.Jobs {
321 fmt.Fprintf(out, "\n===== job: %s [%s] =====\n%s\n", j.Name, j.Status, j.Log)
322 }
323 }
324 if flagJSON {
325 return printJSON(out, r)
326 }
327 fmt.Fprintf(out, "Run #%d finished: %s\n", r.Number, r.Status)
328 if r.Status != "success" {
329 return &runWatchExitError{status: r.Status}
330 }
331 return nil
332 }
333 if time.Now().After(deadline) {
334 return fmt.Errorf("timed out after %s; run #%d is still %s", runWatchTimeout, r.Number, r.Status)
335 }
336 select {
337 case <-cmd.Context().Done():
338 return cmd.Context().Err()
339 case <-tick.C:
340 }
341 }
342}
343
344// runWatchSnapshot renders one compact status line per poll, only when
345// something changed: "#12 CI [running] · build:success · test:running".
346func runWatchSnapshot(r *api.Run) string {
347 parts := make([]string, 0, len(r.Jobs))
348 for _, j := range r.Jobs {
349 parts = append(parts, j.Name+":"+j.Status)
350 }
351 snap := fmt.Sprintf("#%d %s [%s]", r.Number, dash(r.Workflow), r.Status)
352 if len(parts) > 0 {
353 snap += " · " + strings.Join(parts, " · ")
354 }
355 return snap
356}