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