v0.15.0 8a6cfec

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",
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}

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 45

↑↓ moveenter openesc close