v0.12.1 7328df5

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

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