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