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(), r.GetEvent(),
257 refText(r.GetRef()), shortSha(r.GetSha()),
258 r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
259 })
260 }
261 return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "EVENT", "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 {"event", run.GetEvent()},
361 {"ref", run.GetRef()},
362 {"commit", run.GetSha()},
363 {"title", run.GetTitle()},
364 {"actor", run.GetActor()},
365 {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
366 {"created", shortTime(run.GetCreatedAt(), "-")},
367 }
368 if run.GetError() != "" {
369 pairs = append(pairs, [2]string{"error", run.GetError()})
370 }
371 if err := fields(w, pairs); err != nil {
372 return err
373 }
374 if len(r.GetJobs()) == 0 {
375 return nil
376 }
377 _, _ = fmt.Fprintln(w)
378 for _, j := range r.GetJobs() {
379 line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
380 if j.GetRunnerName() != "" {
381 line += " [" + j.GetRunnerName() + "]"
382 }
383 _, _ = fmt.Fprintln(w, clean(line))
384 if j.GetError() != "" {
385 _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
386 }
387 for _, s := range j.GetSteps() {
388 res := stepStateText(s.GetState())
389 if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
390 res = conclusionText(s.GetConclusion())
391 }
392 _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
393 }
394 }
395 return nil
396}
397
398// runNotes are the annotations of a run as read from the server.
399type runNotes struct {
400 items []*ermite.JobAnnotation
401 truncated bool
402 // jobs maps a job id to the job.
403 jobs map[string]*ermite.RunJob
404}
405
406// runAnnotations reads the annotations of the attempt of a run that was read.
407// A server without the feature has none: it answers unimplemented, which is
408// not an error here.
409func runAnnotations(ctx context.Context, c *client.Client, repo string, r *ermite.GetRunResponse) (runNotes, error) {
410 out := runNotes{jobs: map[string]*ermite.RunJob{}}
411 for _, j := range r.GetJobs() {
412 out.jobs[j.GetId()] = j
413 }
414 res, err := c.Runs().ListAnnotations(ctx, connect.NewRequest(&ermite.ListAnnotationsRequest{
415 Repo: repo, RunId: r.GetRun().GetId(), Attempt: r.GetRun().GetAttempt(),
416 }))
417 if connect.CodeOf(err) == connect.CodeUnimplemented {
418 return out, nil
419 }
420 if err != nil {
421 return runNotes{}, err
422 }
423 out.items, out.truncated = res.Msg.GetAnnotations(), res.Msg.GetTruncated()
424 return out, nil
425}
426
427func annotationLevelText(l ermite.AnnotationLevel) string {
428 switch l {
429 case ermite.AnnotationLevel_ANNOTATION_LEVEL_NOTICE:
430 return "notice"
431 case ermite.AnnotationLevel_ANNOTATION_LEVEL_WARNING:
432 return "warning"
433 case ermite.AnnotationLevel_ANNOTATION_LEVEL_FAILURE:
434 return "error"
435 }
436 return ""
437}
438
439// annotationPlace is "file:line" (or "file:line-endline"), "" without a file.
440func annotationPlace(a *ermite.Annotation) string {
441 if a.GetFile() == "" {
442 return ""
443 }
444 p := a.GetFile()
445 if a.GetStartLine() > 0 {
446 p += ":" + strconv.Itoa(int(a.GetStartLine()))
447 if a.GetEndLine() > a.GetStartLine() {
448 p += "-" + strconv.Itoa(int(a.GetEndLine()))
449 }
450 }
451 return p
452}
453
454func toJSONAnnotation(ja *ermite.JobAnnotation, jobs map[string]*ermite.RunJob) jsonAnnotation {
455 a := ja.GetAnnotation()
456 level := annotationLevelText(a.GetLevel())
457 if level == "error" {
458 level = "failure" // the API's name, as GitHub's annotation_level
459 }
460 return jsonAnnotation{
461 Job: jobs[ja.GetJobId()].GetName(), JobID: ja.GetJobId(), Step: a.GetStepNumber(), Level: level, Title: a.GetTitle(),
462 Message: a.GetMessage(), File: a.GetFile(), StartLine: a.GetStartLine(), EndLine: a.GetEndLine(),
463 StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
464 }
465}
466
467// printAnnotations prints the ANNOTATIONS section of `run view`, like `gh run
468// view` does: one line per annotation with its level, its job, its place and
469// its message. Nothing is printed for a run without annotation.
470func printAnnotations(w io.Writer, n runNotes) error {
471 if len(n.items) == 0 {
472 return nil
473 }
474 _, _ = fmt.Fprint(w, "\nANNOTATIONS\n")
475 rows := make([][]string, 0, len(n.items))
476 for _, ja := range n.items {
477 a := ja.GetAnnotation()
478 msg := a.GetMessage()
479 if a.GetTitle() != "" {
480 msg = a.GetTitle() + ": " + msg
481 }
482 rows = append(rows, []string{annotationLevelText(a.GetLevel()), n.jobs[ja.GetJobId()].GetName(), annotationPlace(a), msg})
483 }
484 if err := table(w, []string{"LEVEL", "JOB", "FILE", "MESSAGE"}, rows); err != nil {
485 return err
486 }
487 if n.truncated {
488 _, _ = fmt.Fprintf(w, "(more annotations exist than the %d shown)\n", len(n.items))
489 }
490 return nil
491}
492
493// printRunSummaries prints the job summaries of the chosen jobs as the Markdown
494// the steps wrote to w; notes go to errw. With several of them, each is preceded
495// by a line naming its job and step. Control characters are replaced, as everywhere the CLI prints
496// text it did not write (a tab becomes a space); line breaks are kept.
497func printRunSummaries(ctx context.Context, w, errw io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string) error {
498 jobs, err := pickJobs(r.GetJobs(), sel)
499 if err != nil {
500 return err
501 }
502 type found struct {
503 job *ermite.RunJob
504 sum *ermite.StepSummary
505 }
506 var all []found
507 truncated := false
508 for _, j := range jobs {
509 if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED || j.GetStatus() == ermite.CiStatus_CI_STATUS_PENDING {
510 continue
511 }
512 res, err := c.Runs().GetStepSummaries(ctx, connect.NewRequest(&ermite.GetStepSummariesRequest{Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt()}))
513 if connect.CodeOf(err) == connect.CodeUnimplemented {
514 return fmt.Errorf("the server does not keep job summaries (it lacks the ci.summaries feature)")
515 }
516 if err != nil {
517 return err
518 }
519 for _, s := range res.Msg.GetSummaries() {
520 all = append(all, found{j, s})
521 }
522 truncated = truncated || res.Msg.GetTruncated()
523 }
524 if len(all) == 0 {
525 noticef2(errw, "the run has no job summary")
526 return nil
527 }
528 for i, f := range all {
529 if len(all) > 1 {
530 if i > 0 {
531 _, _ = fmt.Fprintln(w)
532 }
533 _, _ = fmt.Fprintf(w, "== %s: %s ==\n", clean(f.job.GetName()), clean(stepName(f.job, f.sum.GetStepNumber())))
534 }
535 text := rawText(f.sum.GetMarkdown())
536 _, _ = fmt.Fprint(w, text)
537 if !strings.HasSuffix(text, "\n") {
538 _, _ = fmt.Fprintln(w)
539 }
540 }
541 if truncated {
542 noticef2(errw, "some jobs have more summaries than the 20 the server returns")
543 }
544 return nil
545}
546
547// stepName is the name of a step of a job, or "step N" when it is not known.
548func stepName(j *ermite.RunJob, n int32) string {
549 for _, s := range j.GetSteps() {
550 if s.GetNumber() == n && s.GetName() != "" {
551 return s.GetName()
552 }
553 }
554 return "step " + strconv.Itoa(int(n))
555}
556
557// rawText replaces the control characters of a text that may come from the
558// workflow (clean), line by line, so that its line breaks stay.
559func rawText(s string) string {
560 lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
561 for i, l := range lines {
562 lines[i] = clean(l)
563 }
564 return strings.Join(lines, "\n")
565}
566
567// pickJobs selects the jobs --job names (an id or a name), or all of them.
568func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
569 if sel == "" {
570 return jobs, nil
571 }
572 var out []*ermite.RunJob
573 for _, j := range jobs {
574 if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
575 out = append(out, j)
576 }
577 }
578 if len(out) == 0 {
579 return nil, fmt.Errorf("the run has no job %q", sel)
580 }
581 return out, nil
582}
583
584// printRunLog prints the log of the chosen jobs; with follow it waits for the
585// lines of a job that is running.
586func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
587 jobs, err := pickJobs(r.GetJobs(), sel)
588 if err != nil {
589 return err
590 }
591 for _, j := range jobs {
592 if len(jobs) > 1 {
593 _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
594 }
595 if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
596 return err
597 }
598 }
599 return nil
600}
601
602// streamJobLog writes the lines of one job. Continued pieces are joined to the
603// line before them, as the raw log has them.
604func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
605 if (j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED || j.GetStatus() == ermite.CiStatus_CI_STATUS_PENDING) && !follow {
606 noticef2(w, "job %q has not started: no log yet", j.GetName())
607 return nil
608 }
609 stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
610 Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
611 }))
612 if err != nil {
613 return err
614 }
615 defer func() { _ = stream.Close() }()
616 open := false
617 for stream.Receive() {
618 for _, l := range stream.Msg().GetLines() {
619 if l.GetContinued() && open {
620 _, _ = fmt.Fprint(w, clean(l.GetText()))
621 continue
622 }
623 if open {
624 _, _ = fmt.Fprintln(w)
625 }
626 _, _ = fmt.Fprint(w, clean(l.GetText()))
627 open = true
628 }
629 }
630 if open {
631 _, _ = fmt.Fprintln(w)
632 }
633 if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
634 return err
635 }
636 return nil
637}
638
639func noticef2(w io.Writer, format string, args ...any) {
640 _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
641}
642
643func newRunWatchCmd(env *cliEnv) *cobra.Command {
644 var interval time.Duration
645 var logFlag bool
646 cmd := &cobra.Command{
647 Use: "watch <repo> <run id>",
648 Short: "Wait for a run to complete and follow its jobs",
649 Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
650 "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
651 "`ermite run watch` can gate a script.",
652 Args: cobra.ExactArgs(2),
653 ValidArgsFunction: completeRepoNames(env),
654 }
655 cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
656 cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
657 cmd.RunE = func(cmd *cobra.Command, args []string) error {
658 id, err := parseRunID(args[1])
659 if err != nil {
660 return err
661 }
662 c, err := env.client(cmd)
663 if err != nil {
664 return err
665 }
666 ctx := env.ctx(cmd)
667 w := cmd.OutOrStdout()
668 last := ""
669 for {
670 res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
671 if err != nil {
672 return err
673 }
674 if line := watchLine(res.Msg); line != last {
675 _, _ = fmt.Fprintln(w, line)
676 last = line
677 }
678 if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
679 if logFlag {
680 if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
681 return err
682 }
683 }
684 if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
685 return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
686 }
687 return nil
688 }
689 select {
690 case <-ctx.Done():
691 return ctx.Err()
692 case <-time.After(interval):
693 }
694 }
695 }
696 return cmd
697}
698
699// watchLine is a one-line state of a run for `run watch`.
700func watchLine(r *ermite.GetRunResponse) string {
701 var parts []string
702 for _, j := range r.GetJobs() {
703 parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
704 }
705 return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
706}
707
708func newRunCancelCmd(env *cliEnv) *cobra.Command {
709 cmd := &cobra.Command{
710 Use: "cancel <repo> <run id>",
711 Short: "Cancel a run",
712 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.",
713 Args: cobra.ExactArgs(2),
714 ValidArgsFunction: completeRepoNames(env),
715 }
716 asJSON := addJSONFlag(cmd)
717 cmd.RunE = func(cmd *cobra.Command, args []string) error {
718 id, err := parseRunID(args[1])
719 if err != nil {
720 return err
721 }
722 c, err := env.client(cmd)
723 if err != nil {
724 return err
725 }
726 res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
727 if err != nil {
728 return err
729 }
730 if *asJSON {
731 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
732 }
733 noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
734 return nil
735 }
736 return cmd
737}
738
739func newRunRerunCmd(env *cliEnv) *cobra.Command {
740 var failed bool
741 cmd := &cobra.Command{
742 Use: "rerun <repo> <run id>",
743 Short: "Run a completed run again",
744 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,
745 Args: cobra.ExactArgs(2),
746 ValidArgsFunction: completeRepoNames(env),
747 }
748 cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
749 asJSON := addJSONFlag(cmd)
750 cmd.RunE = func(cmd *cobra.Command, args []string) error {
751 id, err := parseRunID(args[1])
752 if err != nil {
753 return err
754 }
755 c, err := env.client(cmd)
756 if err != nil {
757 return err
758 }
759 res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
760 if err != nil {
761 return err
762 }
763 if *asJSON {
764 return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
765 }
766 noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
767 return nil
768 }
769 return cmd
770}
771
772// refText is a ref as the run list shows it: the short name of a branch or a
773// tag, "!<n>" for the merge ref of a merge request (a pull_request run).
774func refText(ref string) string {
775 if rest, ok := strings.CutPrefix(ref, "refs/pull/"); ok {
776 if n, _, ok := strings.Cut(rest, "/"); ok {
777 return "!" + n
778 }
779 }
780 return strings.TrimPrefix(strings.TrimPrefix(ref, "refs/heads/"), "refs/tags/")
781}