1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "fmt"
8 "io"
9 "strconv"
10 "strings"
11 "time"
12
13 "connectrpc.com/connect"
14 "github.com/spf13/cobra"
15
16 "bnema.dev/ermite-api/gen/ermite"
17 "bnema.dev/ermite/internal/adapters/client"
18)
19
20// jsonRun is the stable JSON shape of a run.
21type jsonRun struct {
22 ID int64 `json:"id"`
23 Repo string `json:"repo"`
24 Number int64 `json:"number"`
25 Attempt int32 `json:"attempt"`
26 Workflow string `json:"workflow"`
27 WorkflowPath string `json:"workflow_path"`
28 Title string `json:"title"`
29 Event string `json:"event"`
30 Ref string `json:"ref"`
31 Sha string `json:"sha"`
32 Actor string `json:"actor"`
33 Status string `json:"status"`
34 Conclusion string `json:"conclusion"`
35 Error string `json:"error"`
36 CreatedAt *string `json:"created_at"`
37 StartedAt *string `json:"started_at"`
38 CompletedAt *string `json:"completed_at"`
39}
40
41type jsonStep struct {
42 Number int32 `json:"number"`
43 Name string `json:"name"`
44 State string `json:"state"`
45 Conclusion string `json:"conclusion"`
46 StartedAt *string `json:"started_at"`
47 CompletedAt *string `json:"completed_at"`
48 LogStart int64 `json:"log_start"`
49 LogEnd int64 `json:"log_end"`
50}
51
52type jsonRunJob struct {
53 ID string `json:"id"`
54 Attempt int32 `json:"attempt"`
55 Key string `json:"key"`
56 Name string `json:"name"`
57 Status string `json:"status"`
58 Conclusion string `json:"conclusion"`
59 Error string `json:"error"`
60 Labels []string `json:"labels"`
61 Needs []string `json:"needs"`
62 Runner string `json:"runner"`
63 Isolation string `json:"isolation"`
64 Steps []jsonStep `json:"steps"`
65 StartedAt *string `json:"started_at"`
66 CompletedAt *string `json:"completed_at"`
67}
68
69type jsonRunDetail struct {
70 jsonRun
71 LatestAttempt int32 `json:"latest_attempt"`
72 Jobs []jsonRunJob `json:"jobs"`
73}
74
75const jsonRunHelp = `
76JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
77ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
78skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
79started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
80jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
81with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
82
83func statusText(s ermite.CiStatus) string {
84 return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
85}
86
87func conclusionText(c ermite.CiConclusion) string {
88 if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
89 return ""
90 }
91 return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
92}
93
94func isolationText(i ermite.RunnerIsolation) string {
95 if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
96 return ""
97 }
98 return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
99}
100
101func stepStateText(s ermite.StepState) string {
102 return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
103}
104
105// runResult is "status" while the run is going, the conclusion once over.
106func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
107 if status == ermite.CiStatus_CI_STATUS_COMPLETED {
108 return conclusionText(c)
109 }
110 return statusText(status)
111}
112
113func toJSONRun(r *ermite.Run) jsonRun {
114 return jsonRun{
115 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
116 WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117 Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
118 CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
119 }
120}
121
122func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
123 steps := make([]jsonStep, 0, len(j.GetSteps()))
124 for _, s := range j.GetSteps() {
125 steps = append(steps, jsonStep{
126 Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
127 StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
128 })
129 }
130 labels, needs := j.GetLabels(), j.GetNeeds()
131 if labels == nil {
132 labels = []string{}
133 }
134 if needs == nil {
135 needs = []string{}
136 }
137 return jsonRunJob{
138 ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
139 Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
140 Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
141 StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
142 }
143}
144
145func newRunCmd(env *cliEnv) *cobra.Command {
146 run := &cobra.Command{
147 Use: "run",
148 Short: "List, read, watch, cancel and re-run CI runs",
149 Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
150 "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
151 }
152 run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
153 return run
154}
155
156// parseRunID reads a run id argument.
157func parseRunID(s string) (int64, error) {
158 id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
159 if err != nil || id < 1 {
160 return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
161 }
162 return id, nil
163}
164
165func newRunListCmd(env *cliEnv) *cobra.Command {
166 var workflow, branch, status string
167 var limit int
168 cmd := &cobra.Command{
169 Use: "list <repo>",
170 Short: "List the runs of a repository",
171 Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
172 "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
173 Args: cobra.ExactArgs(1),
174 ValidArgsFunction: completeRepoNames(env),
175 }
176 cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
177 cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
178 cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
179 cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
180 asJSON := addJSONFlag(cmd)
181 cmd.RunE = func(cmd *cobra.Command, args []string) error {
182 st, err := parseStatusFlag(status)
183 if err != nil {
184 return err
185 }
186 if limit < 1 {
187 return fmt.Errorf("--limit must be at least 1")
188 }
189 c, err := env.client(cmd)
190 if err != nil {
191 return err
192 }
193 ctx := env.ctx(cmd)
194 var runs []*ermite.Run
195 token := ""
196 for len(runs) < limit {
197 res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
198 Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
199 Workflow: workflow, Branch: branch, Status: st,
200 }))
201 if err != nil {
202 return err
203 }
204 runs = append(runs, res.Msg.GetRuns()...)
205 if token = res.Msg.GetNextPageToken(); token == "" {
206 break
207 }
208 }
209 if *asJSON {
210 out := make([]jsonRun, 0, len(runs))
211 for _, r := range runs {
212 out = append(out, toJSONRun(r))
213 }
214 return writeJSON(cmd.OutOrStdout(), out)
215 }
216 if len(runs) == 0 {
217 noticef(cmd, "no runs")
218 return nil
219 }
220 rows := make([][]string, 0, len(runs))
221 for _, r := range runs {
222 rows = append(rows, []string{
223 strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
224 strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
225 r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
226 })
227 }
228 return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
229 }
230 return cmd
231}
232
233func shortSha(s string) string {
234 if len(s) > 8 {
235 return s[:8]
236 }
237 return s
238}
239
240func parseStatusFlag(s string) (ermite.CiStatus, error) {
241 switch strings.ToLower(s) {
242 case "":
243 return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
244 case "queued":
245 return ermite.CiStatus_CI_STATUS_QUEUED, nil
246 case "in_progress", "in-progress":
247 return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
248 case "completed":
249 return ermite.CiStatus_CI_STATUS_COMPLETED, nil
250 }
251 return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
252}
253
254func newRunViewCmd(env *cliEnv) *cobra.Command {
255 var attempt int32
256 var logFlag bool
257 var jobFlag string
258 cmd := &cobra.Command{
259 Use: "view <repo> <run id>",
260 Short: "Show a run with its jobs and steps, or the log of a job",
261 Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
262 "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
263 "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
264 Args: cobra.ExactArgs(2),
265 ValidArgsFunction: completeRepoNames(env),
266 }
267 cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
268 cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
269 cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
270 asJSON := addJSONFlag(cmd)
271 cmd.RunE = func(cmd *cobra.Command, args []string) error {
272 id, err := parseRunID(args[1])
273 if err != nil {
274 return err
275 }
276 c, err := env.client(cmd)
277 if err != nil {
278 return err
279 }
280 ctx := env.ctx(cmd)
281 res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
282 if err != nil {
283 return err
284 }
285 if logFlag {
286 return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
287 }
288 if *asJSON {
289 d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
290 for _, j := range res.Msg.GetJobs() {
291 d.Jobs = append(d.Jobs, toJSONRunJob(j))
292 }
293 return writeJSON(cmd.OutOrStdout(), d)
294 }
295 return printRun(cmd.OutOrStdout(), res.Msg)
296 }
297 return cmd
298}
299
300func printRun(w io.Writer, r *ermite.GetRunResponse) error {
301 run := r.GetRun()
302 pairs := [][2]string{
303 {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
304 {"result", runResult(run.GetStatus(), run.GetConclusion())},
305 {"ref", run.GetRef()},
306 {"commit", run.GetSha()},
307 {"title", run.GetTitle()},
308 {"actor", run.GetActor()},
309 {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
310 {"created", shortTime(run.GetCreatedAt(), "-")},
311 }
312 if run.GetError() != "" {
313 pairs = append(pairs, [2]string{"error", run.GetError()})
314 }
315 if err := fields(w, pairs); err != nil {
316 return err
317 }
318 if len(r.GetJobs()) == 0 {
319 return nil
320 }
321 _, _ = fmt.Fprintln(w)
322 for _, j := range r.GetJobs() {
323 line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
324 if j.GetRunnerName() != "" {
325 line += " [" + j.GetRunnerName() + "]"
326 }
327 _, _ = fmt.Fprintln(w, clean(line))
328 if j.GetError() != "" {
329 _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
330 }
331 for _, s := range j.GetSteps() {
332 res := stepStateText(s.GetState())
333 if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
334 res = conclusionText(s.GetConclusion())
335 }
336 _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
337 }
338 }
339 return nil
340}
341
342// pickJobs selects the jobs --job names (an id or a name), or all of them.
343func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
344 if sel == "" {
345 return jobs, nil
346 }
347 var out []*ermite.RunJob
348 for _, j := range jobs {
349 if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
350 out = append(out, j)
351 }
352 }
353 if len(out) == 0 {
354 return nil, fmt.Errorf("the run has no job %q", sel)
355 }
356 return out, nil
357}
358
359// printRunLog prints the log of the chosen jobs; with follow it waits for the
360// lines of a job that is running.
361func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
362 jobs, err := pickJobs(r.GetJobs(), sel)
363 if err != nil {
364 return err
365 }
366 for _, j := range jobs {
367 if len(jobs) > 1 {
368 _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
369 }
370 if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
371 return err
372 }
373 }
374 return nil
375}
376
377// streamJobLog writes the lines of one job. Continued pieces are joined to the
378// line before them, as the raw log has them.
379func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
380 if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
381 noticef2(w, "job %q has not started: no log yet", j.GetName())
382 return nil
383 }
384 stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
385 Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
386 }))
387 if err != nil {
388 return err
389 }
390 defer func() { _ = stream.Close() }()
391 open := false
392 for stream.Receive() {
393 for _, l := range stream.Msg().GetLines() {
394 if l.GetContinued() && open {
395 _, _ = fmt.Fprint(w, clean(l.GetText()))
396 continue
397 }
398 if open {
399 _, _ = fmt.Fprintln(w)
400 }
401 _, _ = fmt.Fprint(w, clean(l.GetText()))
402 open = true
403 }
404 }
405 if open {
406 _, _ = fmt.Fprintln(w)
407 }
408 if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
409 return err
410 }
411 return nil
412}
413
414func noticef2(w io.Writer, format string, args ...any) {
415 _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
416}
417
418func newRunWatchCmd(env *cliEnv) *cobra.Command {
419 var interval time.Duration
420 var logFlag bool
421 cmd := &cobra.Command{
422 Use: "watch <repo> <run id>",
423 Short: "Wait for a run to complete and follow its jobs",
424 Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
425 "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
426 "`ermite run watch` can gate a script.",
427 Args: cobra.ExactArgs(2),
428 ValidArgsFunction: completeRepoNames(env),
429 }
430 cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
431 cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
432 cmd.RunE = func(cmd *cobra.Command, args []string) error {
433 id, err := parseRunID(args[1])
434 if err != nil {
435 return err
436 }
437 c, err := env.client(cmd)
438 if err != nil {
439 return err
440 }
441 ctx := env.ctx(cmd)
442 w := cmd.OutOrStdout()
443 last := ""
444 for {
445 res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
446 if err != nil {
447 return err
448 }
449 if line := watchLine(res.Msg); line != last {
450 _, _ = fmt.Fprintln(w, line)
451 last = line
452 }
453 if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
454 if logFlag {
455 if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
456 return err
457 }
458 }
459 if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
460 return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
461 }
462 return nil
463 }
464 select {
465 case <-ctx.Done():
466 return ctx.Err()
467 case <-time.After(interval):
468 }
469 }
470 }
471 return cmd
472}
473
474// watchLine is a one-line state of a run for `run watch`.
475func watchLine(r *ermite.GetRunResponse) string {
476 var parts []string
477 for _, j := range r.GetJobs() {
478 parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
479 }
480 return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
481}
482
483func newRunCancelCmd(env *cliEnv) *cobra.Command {
484 cmd := &cobra.Command{
485 Use: "cancel <repo> <run id>",
486 Short: "Cancel a run",
487 Long: "Ask the runners to stop the jobs of a run that is not over; jobs that no runner has yet are cancelled at once. Needs the ci:write scope.",
488 Args: cobra.ExactArgs(2),
489 ValidArgsFunction: completeRepoNames(env),
490 }
491 asJSON := addJSONFlag(cmd)
492 cmd.RunE = func(cmd *cobra.Command, args []string) error {
493 id, err := parseRunID(args[1])
494 if err != nil {
495 return err
496 }
497 c, err := env.client(cmd)
498 if err != nil {
499 return err
500 }
501 res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
502 if err != nil {
503 return err
504 }
505 if *asJSON {
506 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
507 }
508 noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
509 return nil
510 }
511 return cmd
512}
513
514func newRunRerunCmd(env *cliEnv) *cobra.Command {
515 var failed bool
516 cmd := &cobra.Command{
517 Use: "rerun <repo> <run id>",
518 Short: "Run a completed run again",
519 Long: "Start a new attempt of a completed run, with the same commit and workflow file. With --failed, only the jobs that did not succeed (and the jobs that need them) run again. Needs the ci:write scope." + jsonRunHelp,
520 Args: cobra.ExactArgs(2),
521 ValidArgsFunction: completeRepoNames(env),
522 }
523 cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
524 asJSON := addJSONFlag(cmd)
525 cmd.RunE = func(cmd *cobra.Command, args []string) error {
526 id, err := parseRunID(args[1])
527 if err != nil {
528 return err
529 }
530 c, err := env.client(cmd)
531 if err != nil {
532 return err
533 }
534 res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
535 if err != nil {
536 return err
537 }
538 if *asJSON {
539 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
540 }
541 noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
542 return nil
543 }
544 return cmd
545}