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 // Outcome is the result before continue-on-error; for a step stored without
48 // one it is the conclusion.
49 Outcome string `json:"outcome"`
50 StartedAt *string `json:"started_at"`
51 CompletedAt *string `json:"completed_at"`
52 LogStart int64 `json:"log_start"`
53 LogEnd int64 `json:"log_end"`
54}
55
56type jsonRunJob struct {
57 ID string `json:"id"`
58 Attempt int32 `json:"attempt"`
59 Key string `json:"key"`
60 Name string `json:"name"`
61 Status string `json:"status"`
62 Conclusion string `json:"conclusion"`
63 Error string `json:"error"`
64 Labels []string `json:"labels"`
65 Needs []string `json:"needs"`
66 Runner string `json:"runner"`
67 Isolation string `json:"isolation"`
68 Steps []jsonStep `json:"steps"`
69 StartedAt *string `json:"started_at"`
70 CompletedAt *string `json:"completed_at"`
71}
72
73// jsonAnnotation is an annotation of a run, with the job and the step it belongs to.
74type jsonAnnotation struct {
75 Job string `json:"job"`
76 JobID string `json:"job_id"`
77 Step int32 `json:"step"`
78 Level string `json:"level"`
79 Title string `json:"title"`
80 Message string `json:"message"`
81 File string `json:"file"`
82 StartLine int32 `json:"start_line"`
83 EndLine int32 `json:"end_line"`
84 StartColumn int32 `json:"start_column"`
85 EndColumn int32 `json:"end_column"`
86}
87
88type jsonRunDetail struct {
89 jsonRun
90 LatestAttempt int32 `json:"latest_attempt"`
91 Jobs []jsonRunJob `json:"jobs"`
92 Annotations []jsonAnnotation `json:"annotations"`
93 // AnnotationsTruncated is true when the server holds more annotations than it returned.
94 AnnotationsTruncated bool `json:"annotations_truncated"`
95}
96
97const jsonRunHelp = `
98JSON 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,
99ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
100skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
101started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
102jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
103with number, name, state, conclusion, outcome (the result before continue-on-error), log_start and log_end,
104started_at, completed_at), annotations (job, job_id, step, level notice|warning|failure, title, message,
105file, start_line, end_line, start_column, end_column) and annotations_truncated.`
106
107func statusText(s ermite.CiStatus) string {
108 return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
109}
110
111func conclusionText(c ermite.CiConclusion) string {
112 if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
113 return ""
114 }
115 return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
116}
117
118func isolationText(i ermite.RunnerIsolation) string {
119 if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
120 return ""
121 }
122 return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
123}
124
125func stepStateText(s ermite.StepState) string {
126 return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
127}
128
129// runResult is "status" while the run is going, the conclusion once over.
130func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
131 if status == ermite.CiStatus_CI_STATUS_COMPLETED {
132 return conclusionText(c)
133 }
134 return statusText(status)
135}
136
137// stepOutcomeText is the outcome of a step; a completed step stored without one
138// has the outcome of its conclusion (Step.outcome).
139func stepOutcomeText(s *ermite.Step) string {
140 if s.GetOutcome() == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED && s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
141 return conclusionText(s.GetConclusion())
142 }
143 return conclusionText(s.GetOutcome())
144}
145
146func toJSONRun(r *ermite.Run) jsonRun {
147 return jsonRun{
148 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
149 Name: r.GetName(), WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
150 Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
151 CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
152 }
153}
154
155func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
156 steps := make([]jsonStep, 0, len(j.GetSteps()))
157 for _, s := range j.GetSteps() {
158 steps = append(steps, jsonStep{
159 Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
160 Outcome: stepOutcomeText(s), StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
161 })
162 }
163 labels, needs := j.GetLabels(), j.GetNeeds()
164 if labels == nil {
165 labels = []string{}
166 }
167 if needs == nil {
168 needs = []string{}
169 }
170 return jsonRunJob{
171 ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
172 Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
173 Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
174 StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
175 }
176}
177
178func newRunCmd(env *cliEnv) *cobra.Command {
179 run := &cobra.Command{
180 Use: "run",
181 Short: "List, read, watch, cancel and re-run CI runs",
182 Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
183 "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
184 }
185 run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
186 return run
187}
188
189// parseRunID reads a run id argument.
190func parseRunID(s string) (int64, error) {
191 id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
192 if err != nil || id < 1 {
193 return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
194 }
195 return id, nil
196}
197
198func newRunListCmd(env *cliEnv) *cobra.Command {
199 var workflow, branch, status string
200 var limit int
201 cmd := &cobra.Command{
202 Use: "list <repo>",
203 Short: "List the runs of a repository",
204 Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
205 "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
206 Args: cobra.ExactArgs(1),
207 ValidArgsFunction: completeRepoNames(env),
208 }
209 cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
210 cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
211 cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
212 cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
213 asJSON := addJSONFlag(cmd)
214 cmd.RunE = func(cmd *cobra.Command, args []string) error {
215 st, err := parseStatusFlag(status)
216 if err != nil {
217 return err
218 }
219 if limit < 1 {
220 return fmt.Errorf("--limit must be at least 1")
221 }
222 c, err := env.client(cmd)
223 if err != nil {
224 return err
225 }
226 ctx := env.ctx(cmd)
227 var runs []*ermite.Run
228 token := ""
229 for len(runs) < limit {
230 res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
231 Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
232 Workflow: workflow, Branch: branch, Status: st,
233 }))
234 if err != nil {
235 return err
236 }
237 runs = append(runs, res.Msg.GetRuns()...)
238 if token = res.Msg.GetNextPageToken(); token == "" {
239 break
240 }
241 }
242 if *asJSON {
243 out := make([]jsonRun, 0, len(runs))
244 for _, r := range runs {
245 out = append(out, toJSONRun(r))
246 }
247 return writeJSON(cmd.OutOrStdout(), out)
248 }
249 if len(runs) == 0 {
250 noticef(cmd, "no runs")
251 return nil
252 }
253 rows := make([][]string, 0, len(runs))
254 for _, r := range runs {
255 rows = append(rows, []string{
256 strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
257 strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
258 r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
259 })
260 }
261 return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
262 }
263 return cmd
264}
265
266func shortSha(s string) string {
267 if len(s) > 8 {
268 return s[:8]
269 }
270 return s
271}
272
273func parseStatusFlag(s string) (ermite.CiStatus, error) {
274 switch strings.ToLower(s) {
275 case "":
276 return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
277 case "queued":
278 return ermite.CiStatus_CI_STATUS_QUEUED, nil
279 case "in_progress", "in-progress":
280 return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
281 case "completed":
282 return ermite.CiStatus_CI_STATUS_COMPLETED, nil
283 }
284 return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
285}
286
287func newRunViewCmd(env *cliEnv) *cobra.Command {
288 var attempt int32
289 var logFlag, summaryFlag bool
290 var jobFlag string
291 cmd := &cobra.Command{
292 Use: "view <repo> <run id>",
293 Short: "Show a run with its jobs, steps and annotations, or the log or summaries of its jobs",
294 Long: "Show a run: its state, its jobs and their steps, then its ANNOTATIONS (the notices, warnings and\n" +
295 "errors that the steps produced, with their job and file:line). --attempt reads an earlier attempt.\n" +
296 "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
297 "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing.\n" +
298 "With --summary, print the job summaries (the Markdown the steps wrote to GITHUB_STEP_SUMMARY) of\n" +
299 "the run's jobs, or of the --job, as Markdown, not rendered." + jsonRunHelp,
300 Args: cobra.ExactArgs(2),
301 ValidArgsFunction: completeRepoNames(env),
302 }
303 cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
304 cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
305 cmd.Flags().BoolVar(&summaryFlag, "summary", false, "print the job summaries (Markdown) instead of the summary of the run")
306 cmd.Flags().StringVar(&jobFlag, "job", "", "with --log or --summary, only this job (its id or its name)")
307 asJSON := addJSONFlag(cmd)
308 cmd.MarkFlagsMutuallyExclusive("log", "summary")
309 cmd.MarkFlagsMutuallyExclusive("json", "summary")
310 cmd.RunE = func(cmd *cobra.Command, args []string) error {
311 id, err := parseRunID(args[1])
312 if err != nil {
313 return err
314 }
315 c, err := env.client(cmd)
316 if err != nil {
317 return err
318 }
319 ctx := env.ctx(cmd)
320 res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
321 if err != nil {
322 return err
323 }
324 if logFlag {
325 return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
326 }
327 if summaryFlag {
328 return printRunSummaries(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), c, args[0], res.Msg, jobFlag)
329 }
330 anns, err := runAnnotations(ctx, c, args[0], res.Msg)
331 if err != nil {
332 return err
333 }
334 if *asJSON {
335 d := jsonRunDetail{
336 jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{},
337 Annotations: []jsonAnnotation{}, AnnotationsTruncated: anns.truncated,
338 }
339 for _, j := range res.Msg.GetJobs() {
340 d.Jobs = append(d.Jobs, toJSONRunJob(j))
341 }
342 for _, a := range anns.items {
343 d.Annotations = append(d.Annotations, toJSONAnnotation(a, anns.jobs))
344 }
345 return writeJSON(cmd.OutOrStdout(), d)
346 }
347 if err := printRun(cmd.OutOrStdout(), res.Msg); err != nil {
348 return err
349 }
350 return printAnnotations(cmd.OutOrStdout(), anns)
351 }
352 return cmd
353}
354
355func printRun(w io.Writer, r *ermite.GetRunResponse) error {
356 run := r.GetRun()
357 pairs := [][2]string{
358 {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
359 {"result", runResult(run.GetStatus(), run.GetConclusion())},
360 {"ref", run.GetRef()},
361 {"commit", run.GetSha()},
362 {"title", run.GetTitle()},
363 {"actor", run.GetActor()},
364 {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
365 {"created", shortTime(run.GetCreatedAt(), "-")},
366 }
367 if run.GetError() != "" {
368 pairs = append(pairs, [2]string{"error", run.GetError()})
369 }
370 if err := fields(w, pairs); err != nil {
371 return err
372 }
373 if len(r.GetJobs()) == 0 {
374 return nil
375 }
376 _, _ = fmt.Fprintln(w)
377 for _, j := range r.GetJobs() {
378 line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
379 if j.GetRunnerName() != "" {
380 line += " [" + j.GetRunnerName() + "]"
381 }
382 _, _ = fmt.Fprintln(w, clean(line))
383 if j.GetError() != "" {
384 _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
385 }
386 for _, s := range j.GetSteps() {
387 res := stepStateText(s.GetState())
388 if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
389 res = conclusionText(s.GetConclusion())
390 }
391 _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
392 }
393 }
394 return nil
395}
396
397// runNotes are the annotations of a run as read from the server.
398type runNotes struct {
399 items []*ermite.JobAnnotation
400 truncated bool
401 // jobs maps a job id to the job.
402 jobs map[string]*ermite.RunJob
403}
404
405// runAnnotations reads the annotations of the attempt of a run that was read.
406// A server without the feature has none: it answers unimplemented, which is
407// not an error here.
408func runAnnotations(ctx context.Context, c *client.Client, repo string, r *ermite.GetRunResponse) (runNotes, error) {
409 out := runNotes{jobs: map[string]*ermite.RunJob{}}
410 for _, j := range r.GetJobs() {
411 out.jobs[j.GetId()] = j
412 }
413 res, err := c.Runs().ListAnnotations(ctx, connect.NewRequest(&ermite.ListAnnotationsRequest{
414 Repo: repo, RunId: r.GetRun().GetId(), Attempt: r.GetRun().GetAttempt(),
415 }))
416 if connect.CodeOf(err) == connect.CodeUnimplemented {
417 return out, nil
418 }
419 if err != nil {
420 return runNotes{}, err
421 }
422 out.items, out.truncated = res.Msg.GetAnnotations(), res.Msg.GetTruncated()
423 return out, nil
424}
425
426func annotationLevelText(l ermite.AnnotationLevel) string {
427 switch l {
428 case ermite.AnnotationLevel_ANNOTATION_LEVEL_NOTICE:
429 return "notice"
430 case ermite.AnnotationLevel_ANNOTATION_LEVEL_WARNING:
431 return "warning"
432 case ermite.AnnotationLevel_ANNOTATION_LEVEL_FAILURE:
433 return "error"
434 }
435 return ""
436}
437
438// annotationPlace is "file:line" (or "file:line-endline"), "" without a file.
439func annotationPlace(a *ermite.Annotation) string {
440 if a.GetFile() == "" {
441 return ""
442 }
443 p := a.GetFile()
444 if a.GetStartLine() > 0 {
445 p += ":" + strconv.Itoa(int(a.GetStartLine()))
446 if a.GetEndLine() > a.GetStartLine() {
447 p += "-" + strconv.Itoa(int(a.GetEndLine()))
448 }
449 }
450 return p
451}
452
453func toJSONAnnotation(ja *ermite.JobAnnotation, jobs map[string]*ermite.RunJob) jsonAnnotation {
454 a := ja.GetAnnotation()
455 level := annotationLevelText(a.GetLevel())
456 if level == "error" {
457 level = "failure" // the API's name, as GitHub's annotation_level
458 }
459 return jsonAnnotation{
460 Job: jobs[ja.GetJobId()].GetName(), JobID: ja.GetJobId(), Step: a.GetStepNumber(), Level: level, Title: a.GetTitle(),
461 Message: a.GetMessage(), File: a.GetFile(), StartLine: a.GetStartLine(), EndLine: a.GetEndLine(),
462 StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
463 }
464}
465
466// printAnnotations prints the ANNOTATIONS section of `run view`, like `gh run
467// view` does: one line per annotation with its level, its job, its place and
468// its message. Nothing is printed for a run without annotation.
469func printAnnotations(w io.Writer, n runNotes) error {
470 if len(n.items) == 0 {
471 return nil
472 }
473 _, _ = fmt.Fprint(w, "\nANNOTATIONS\n")
474 rows := make([][]string, 0, len(n.items))
475 for _, ja := range n.items {
476 a := ja.GetAnnotation()
477 msg := a.GetMessage()
478 if a.GetTitle() != "" {
479 msg = a.GetTitle() + ": " + msg
480 }
481 rows = append(rows, []string{annotationLevelText(a.GetLevel()), n.jobs[ja.GetJobId()].GetName(), annotationPlace(a), msg})
482 }
483 if err := table(w, []string{"LEVEL", "JOB", "FILE", "MESSAGE"}, rows); err != nil {
484 return err
485 }
486 if n.truncated {
487 _, _ = fmt.Fprintf(w, "(more annotations exist than the %d shown)\n", len(n.items))
488 }
489 return nil
490}
491
492// printRunSummaries prints the job summaries of the chosen jobs as the Markdown
493// the steps wrote to w; notes go to errw. With several of them, each is preceded
494// by a line naming its job and step. Control characters are replaced, as everywhere the CLI prints
495// text it did not write (a tab becomes a space); line breaks are kept.
496func printRunSummaries(ctx context.Context, w, errw io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string) error {
497 jobs, err := pickJobs(r.GetJobs(), sel)
498 if err != nil {
499 return err
500 }
501 type found struct {
502 job *ermite.RunJob
503 sum *ermite.StepSummary
504 }
505 var all []found
506 truncated := false
507 for _, j := range jobs {
508 if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED || j.GetStatus() == ermite.CiStatus_CI_STATUS_PENDING {
509 continue
510 }
511 res, err := c.Runs().GetStepSummaries(ctx, connect.NewRequest(&ermite.GetStepSummariesRequest{Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt()}))
512 if connect.CodeOf(err) == connect.CodeUnimplemented {
513 return fmt.Errorf("the server does not keep job summaries (it lacks the ci.summaries feature)")
514 }
515 if err != nil {
516 return err
517 }
518 for _, s := range res.Msg.GetSummaries() {
519 all = append(all, found{j, s})
520 }
521 truncated = truncated || res.Msg.GetTruncated()
522 }
523 if len(all) == 0 {
524 noticef2(errw, "the run has no job summary")
525 return nil
526 }
527 for i, f := range all {
528 if len(all) > 1 {
529 if i > 0 {
530 _, _ = fmt.Fprintln(w)
531 }
532 _, _ = fmt.Fprintf(w, "== %s: %s ==\n", clean(f.job.GetName()), clean(stepName(f.job, f.sum.GetStepNumber())))
533 }
534 text := rawText(f.sum.GetMarkdown())
535 _, _ = fmt.Fprint(w, text)
536 if !strings.HasSuffix(text, "\n") {
537 _, _ = fmt.Fprintln(w)
538 }
539 }
540 if truncated {
541 noticef2(errw, "some jobs have more summaries than the 20 the server returns")
542 }
543 return nil
544}
545
546// stepName is the name of a step of a job, or "step N" when it is not known.
547func stepName(j *ermite.RunJob, n int32) string {
548 for _, s := range j.GetSteps() {
549 if s.GetNumber() == n && s.GetName() != "" {
550 return s.GetName()
551 }
552 }
553 return "step " + strconv.Itoa(int(n))
554}
555
556// rawText replaces the control characters of a text that may come from the
557// workflow (clean), line by line, so that its line breaks stay.
558func rawText(s string) string {
559 lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
560 for i, l := range lines {
561 lines[i] = clean(l)
562 }
563 return strings.Join(lines, "\n")
564}
565
566// pickJobs selects the jobs --job names (an id or a name), or all of them.
567func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
568 if sel == "" {
569 return jobs, nil
570 }
571 var out []*ermite.RunJob
572 for _, j := range jobs {
573 if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
574 out = append(out, j)
575 }
576 }
577 if len(out) == 0 {
578 return nil, fmt.Errorf("the run has no job %q", sel)
579 }
580 return out, nil
581}
582
583// printRunLog prints the log of the chosen jobs; with follow it waits for the
584// lines of a job that is running.
585func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
586 jobs, err := pickJobs(r.GetJobs(), sel)
587 if err != nil {
588 return err
589 }
590 for _, j := range jobs {
591 if len(jobs) > 1 {
592 _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
593 }
594 if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
595 return err
596 }
597 }
598 return nil
599}
600
601// streamJobLog writes the lines of one job. Continued pieces are joined to the
602// line before them, as the raw log has them.
603func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
604 if (j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED || j.GetStatus() == ermite.CiStatus_CI_STATUS_PENDING) && !follow {
605 noticef2(w, "job %q has not started: no log yet", j.GetName())
606 return nil
607 }
608 stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
609 Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
610 }))
611 if err != nil {
612 return err
613 }
614 defer func() { _ = stream.Close() }()
615 open := false
616 for stream.Receive() {
617 for _, l := range stream.Msg().GetLines() {
618 if l.GetContinued() && open {
619 _, _ = fmt.Fprint(w, clean(l.GetText()))
620 continue
621 }
622 if open {
623 _, _ = fmt.Fprintln(w)
624 }
625 _, _ = fmt.Fprint(w, clean(l.GetText()))
626 open = true
627 }
628 }
629 if open {
630 _, _ = fmt.Fprintln(w)
631 }
632 if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
633 return err
634 }
635 return nil
636}
637
638func noticef2(w io.Writer, format string, args ...any) {
639 _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
640}
641
642func newRunWatchCmd(env *cliEnv) *cobra.Command {
643 var interval time.Duration
644 var logFlag bool
645 cmd := &cobra.Command{
646 Use: "watch <repo> <run id>",
647 Short: "Wait for a run to complete and follow its jobs",
648 Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
649 "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
650 "`ermite run watch` can gate a script.",
651 Args: cobra.ExactArgs(2),
652 ValidArgsFunction: completeRepoNames(env),
653 }
654 cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
655 cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
656 cmd.RunE = func(cmd *cobra.Command, args []string) error {
657 id, err := parseRunID(args[1])
658 if err != nil {
659 return err
660 }
661 c, err := env.client(cmd)
662 if err != nil {
663 return err
664 }
665 ctx := env.ctx(cmd)
666 w := cmd.OutOrStdout()
667 last := ""
668 for {
669 res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
670 if err != nil {
671 return err
672 }
673 if line := watchLine(res.Msg); line != last {
674 _, _ = fmt.Fprintln(w, line)
675 last = line
676 }
677 if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
678 if logFlag {
679 if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
680 return err
681 }
682 }
683 if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
684 return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
685 }
686 return nil
687 }
688 select {
689 case <-ctx.Done():
690 return ctx.Err()
691 case <-time.After(interval):
692 }
693 }
694 }
695 return cmd
696}
697
698// watchLine is a one-line state of a run for `run watch`.
699func watchLine(r *ermite.GetRunResponse) string {
700 var parts []string
701 for _, j := range r.GetJobs() {
702 parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
703 }
704 return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
705}
706
707func newRunCancelCmd(env *cliEnv) *cobra.Command {
708 cmd := &cobra.Command{
709 Use: "cancel <repo> <run id>",
710 Short: "Cancel a run",
711 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.",
712 Args: cobra.ExactArgs(2),
713 ValidArgsFunction: completeRepoNames(env),
714 }
715 asJSON := addJSONFlag(cmd)
716 cmd.RunE = func(cmd *cobra.Command, args []string) error {
717 id, err := parseRunID(args[1])
718 if err != nil {
719 return err
720 }
721 c, err := env.client(cmd)
722 if err != nil {
723 return err
724 }
725 res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
726 if err != nil {
727 return err
728 }
729 if *asJSON {
730 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
731 }
732 noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
733 return nil
734 }
735 return cmd
736}
737
738func newRunRerunCmd(env *cliEnv) *cobra.Command {
739 var failed bool
740 cmd := &cobra.Command{
741 Use: "rerun <repo> <run id>",
742 Short: "Run a completed run again",
743 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,
744 Args: cobra.ExactArgs(2),
745 ValidArgsFunction: completeRepoNames(env),
746 }
747 cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
748 asJSON := addJSONFlag(cmd)
749 cmd.RunE = func(cmd *cobra.Command, args []string) error {
750 id, err := parseRunID(args[1])
751 if err != nil {
752 return err
753 }
754 c, err := env.client(cmd)
755 if err != nil {
756 return err
757 }
758 res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
759 if err != nil {
760 return err
761 }
762 if *asJSON {
763 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
764 }
765 noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
766 return nil
767 }
768 return cmd
769}