v0.26.0 df1db36

cli_run.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close