v0.7.0 45bfc5b

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	WorkflowPath string  `json:"workflow_path"`
28	Title        string  `json:"title"`
29	Event        string  `json:"event"`
30	Ref          string  `json:"ref"`
31	Sha          string  `json:"sha"`
32	Actor        string  `json:"actor"`
33	Status       string  `json:"status"`
34	Conclusion   string  `json:"conclusion"`
35	Error        string  `json:"error"`
36	CreatedAt    *string `json:"created_at"`
37	StartedAt    *string `json:"started_at"`
38	CompletedAt  *string `json:"completed_at"`
39}
40
41type jsonStep struct {
42	Number      int32   `json:"number"`
43	Name        string  `json:"name"`
44	State       string  `json:"state"`
45	Conclusion  string  `json:"conclusion"`
46	StartedAt   *string `json:"started_at"`
47	CompletedAt *string `json:"completed_at"`
48	LogStart    int64   `json:"log_start"`
49	LogEnd      int64   `json:"log_end"`
50}
51
52type jsonRunJob struct {
53	ID          string     `json:"id"`
54	Attempt     int32      `json:"attempt"`
55	Key         string     `json:"key"`
56	Name        string     `json:"name"`
57	Status      string     `json:"status"`
58	Conclusion  string     `json:"conclusion"`
59	Error       string     `json:"error"`
60	Labels      []string   `json:"labels"`
61	Needs       []string   `json:"needs"`
62	Runner      string     `json:"runner"`
63	Isolation   string     `json:"isolation"`
64	Steps       []jsonStep `json:"steps"`
65	StartedAt   *string    `json:"started_at"`
66	CompletedAt *string    `json:"completed_at"`
67}
68
69type jsonRunDetail struct {
70	jsonRun
71	LatestAttempt int32        `json:"latest_attempt"`
72	Jobs          []jsonRunJob `json:"jobs"`
73}
74
75const jsonRunHelp = `
76JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
77ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
78skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
79started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
80jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
81with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
82
83func statusText(s ermite.CiStatus) string {
84	return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
85}
86
87func conclusionText(c ermite.CiConclusion) string {
88	if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
89		return ""
90	}
91	return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
92}
93
94func isolationText(i ermite.RunnerIsolation) string {
95	if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
96		return ""
97	}
98	return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
99}
100
101func stepStateText(s ermite.StepState) string {
102	return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
103}
104
105// runResult is "status" while the run is going, the conclusion once over.
106func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
107	if status == ermite.CiStatus_CI_STATUS_COMPLETED {
108		return conclusionText(c)
109	}
110	return statusText(status)
111}
112
113func toJSONRun(r *ermite.Run) jsonRun {
114	return jsonRun{
115		ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
116		WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117		Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
118		CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
119	}
120}
121
122func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
123	steps := make([]jsonStep, 0, len(j.GetSteps()))
124	for _, s := range j.GetSteps() {
125		steps = append(steps, jsonStep{
126			Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
127			StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
128		})
129	}
130	labels, needs := j.GetLabels(), j.GetNeeds()
131	if labels == nil {
132		labels = []string{}
133	}
134	if needs == nil {
135		needs = []string{}
136	}
137	return jsonRunJob{
138		ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
139		Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
140		Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
141		StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
142	}
143}
144
145func newRunCmd(env *cliEnv) *cobra.Command {
146	run := &cobra.Command{
147		Use:   "run",
148		Short: "List, read, watch, cancel and re-run CI runs",
149		Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
150			"Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
151	}
152	run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
153	return run
154}
155
156// parseRunID reads a run id argument.
157func parseRunID(s string) (int64, error) {
158	id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
159	if err != nil || id < 1 {
160		return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
161	}
162	return id, nil
163}
164
165func newRunListCmd(env *cliEnv) *cobra.Command {
166	var workflow, branch, status string
167	var limit int
168	cmd := &cobra.Command{
169		Use:   "list <repo>",
170		Short: "List the runs of a repository",
171		Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
172			"file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
173		Args:              cobra.ExactArgs(1),
174		ValidArgsFunction: completeRepoNames(env),
175	}
176	cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
177	cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
178	cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
179	cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
180	asJSON := addJSONFlag(cmd)
181	cmd.RunE = func(cmd *cobra.Command, args []string) error {
182		st, err := parseStatusFlag(status)
183		if err != nil {
184			return err
185		}
186		if limit < 1 {
187			return fmt.Errorf("--limit must be at least 1")
188		}
189		c, err := env.client(cmd)
190		if err != nil {
191			return err
192		}
193		ctx := env.ctx(cmd)
194		var runs []*ermite.Run
195		token := ""
196		for len(runs) < limit {
197			res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
198				Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
199				Workflow: workflow, Branch: branch, Status: st,
200			}))
201			if err != nil {
202				return err
203			}
204			runs = append(runs, res.Msg.GetRuns()...)
205			if token = res.Msg.GetNextPageToken(); token == "" {
206				break
207			}
208		}
209		if *asJSON {
210			out := make([]jsonRun, 0, len(runs))
211			for _, r := range runs {
212				out = append(out, toJSONRun(r))
213			}
214			return writeJSON(cmd.OutOrStdout(), out)
215		}
216		if len(runs) == 0 {
217			noticef(cmd, "no runs")
218			return nil
219		}
220		rows := make([][]string, 0, len(runs))
221		for _, r := range runs {
222			rows = append(rows, []string{
223				strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
224				strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
225				r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
226			})
227		}
228		return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
229	}
230	return cmd
231}
232
233func shortSha(s string) string {
234	if len(s) > 8 {
235		return s[:8]
236	}
237	return s
238}
239
240func parseStatusFlag(s string) (ermite.CiStatus, error) {
241	switch strings.ToLower(s) {
242	case "":
243		return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
244	case "queued":
245		return ermite.CiStatus_CI_STATUS_QUEUED, nil
246	case "in_progress", "in-progress":
247		return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
248	case "completed":
249		return ermite.CiStatus_CI_STATUS_COMPLETED, nil
250	}
251	return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
252}
253
254func newRunViewCmd(env *cliEnv) *cobra.Command {
255	var attempt int32
256	var logFlag bool
257	var jobFlag string
258	cmd := &cobra.Command{
259		Use:   "view <repo> <run id>",
260		Short: "Show a run with its jobs and steps, or the log of a job",
261		Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
262			"With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
263			"or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
264		Args:              cobra.ExactArgs(2),
265		ValidArgsFunction: completeRepoNames(env),
266	}
267	cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
268	cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
269	cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
270	asJSON := addJSONFlag(cmd)
271	cmd.RunE = func(cmd *cobra.Command, args []string) error {
272		id, err := parseRunID(args[1])
273		if err != nil {
274			return err
275		}
276		c, err := env.client(cmd)
277		if err != nil {
278			return err
279		}
280		ctx := env.ctx(cmd)
281		res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
282		if err != nil {
283			return err
284		}
285		if logFlag {
286			return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
287		}
288		if *asJSON {
289			d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
290			for _, j := range res.Msg.GetJobs() {
291				d.Jobs = append(d.Jobs, toJSONRunJob(j))
292			}
293			return writeJSON(cmd.OutOrStdout(), d)
294		}
295		return printRun(cmd.OutOrStdout(), res.Msg)
296	}
297	return cmd
298}
299
300func printRun(w io.Writer, r *ermite.GetRunResponse) error {
301	run := r.GetRun()
302	pairs := [][2]string{
303		{"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
304		{"result", runResult(run.GetStatus(), run.GetConclusion())},
305		{"ref", run.GetRef()},
306		{"commit", run.GetSha()},
307		{"title", run.GetTitle()},
308		{"actor", run.GetActor()},
309		{"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
310		{"created", shortTime(run.GetCreatedAt(), "-")},
311	}
312	if run.GetError() != "" {
313		pairs = append(pairs, [2]string{"error", run.GetError()})
314	}
315	if err := fields(w, pairs); err != nil {
316		return err
317	}
318	if len(r.GetJobs()) == 0 {
319		return nil
320	}
321	_, _ = fmt.Fprintln(w)
322	for _, j := range r.GetJobs() {
323		line := fmt.Sprintf("%s  %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
324		if j.GetRunnerName() != "" {
325			line += "  [" + j.GetRunnerName() + "]"
326		}
327		_, _ = fmt.Fprintln(w, clean(line))
328		if j.GetError() != "" {
329			_, _ = fmt.Fprintln(w, "    "+clean(j.GetError()))
330		}
331		for _, s := range j.GetSteps() {
332			res := stepStateText(s.GetState())
333			if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
334				res = conclusionText(s.GetConclusion())
335			}
336			_, _ = fmt.Fprintln(w, clean(fmt.Sprintf("    %-9s %2d  %s", res, s.GetNumber(), s.GetName())))
337		}
338	}
339	return nil
340}
341
342// pickJobs selects the jobs --job names (an id or a name), or all of them.
343func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
344	if sel == "" {
345		return jobs, nil
346	}
347	var out []*ermite.RunJob
348	for _, j := range jobs {
349		if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
350			out = append(out, j)
351		}
352	}
353	if len(out) == 0 {
354		return nil, fmt.Errorf("the run has no job %q", sel)
355	}
356	return out, nil
357}
358
359// printRunLog prints the log of the chosen jobs; with follow it waits for the
360// lines of a job that is running.
361func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
362	jobs, err := pickJobs(r.GetJobs(), sel)
363	if err != nil {
364		return err
365	}
366	for _, j := range jobs {
367		if len(jobs) > 1 {
368			_, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
369		}
370		if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
371			return err
372		}
373	}
374	return nil
375}
376
377// streamJobLog writes the lines of one job. Continued pieces are joined to the
378// line before them, as the raw log has them.
379func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
380	if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
381		noticef2(w, "job %q has not started: no log yet", j.GetName())
382		return nil
383	}
384	stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
385		Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
386	}))
387	if err != nil {
388		return err
389	}
390	defer func() { _ = stream.Close() }()
391	open := false
392	for stream.Receive() {
393		for _, l := range stream.Msg().GetLines() {
394			if l.GetContinued() && open {
395				_, _ = fmt.Fprint(w, clean(l.GetText()))
396				continue
397			}
398			if open {
399				_, _ = fmt.Fprintln(w)
400			}
401			_, _ = fmt.Fprint(w, clean(l.GetText()))
402			open = true
403		}
404	}
405	if open {
406		_, _ = fmt.Fprintln(w)
407	}
408	if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
409		return err
410	}
411	return nil
412}
413
414func noticef2(w io.Writer, format string, args ...any) {
415	_, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
416}
417
418func newRunWatchCmd(env *cliEnv) *cobra.Command {
419	var interval time.Duration
420	var logFlag bool
421	cmd := &cobra.Command{
422		Use:   "watch <repo> <run id>",
423		Short: "Wait for a run to complete and follow its jobs",
424		Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
425			"of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
426			"`ermite run watch` can gate a script.",
427		Args:              cobra.ExactArgs(2),
428		ValidArgsFunction: completeRepoNames(env),
429	}
430	cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
431	cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
432	cmd.RunE = func(cmd *cobra.Command, args []string) error {
433		id, err := parseRunID(args[1])
434		if err != nil {
435			return err
436		}
437		c, err := env.client(cmd)
438		if err != nil {
439			return err
440		}
441		ctx := env.ctx(cmd)
442		w := cmd.OutOrStdout()
443		last := ""
444		for {
445			res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
446			if err != nil {
447				return err
448			}
449			if line := watchLine(res.Msg); line != last {
450				_, _ = fmt.Fprintln(w, line)
451				last = line
452			}
453			if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
454				if logFlag {
455					if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
456						return err
457					}
458				}
459				if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
460					return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
461				}
462				return nil
463			}
464			select {
465			case <-ctx.Done():
466				return ctx.Err()
467			case <-time.After(interval):
468			}
469		}
470	}
471	return cmd
472}
473
474// watchLine is a one-line state of a run for `run watch`.
475func watchLine(r *ermite.GetRunResponse) string {
476	var parts []string
477	for _, j := range r.GetJobs() {
478		parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
479	}
480	return clean(fmt.Sprintf("run %d  %s  %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
481}
482
483func newRunCancelCmd(env *cliEnv) *cobra.Command {
484	cmd := &cobra.Command{
485		Use:               "cancel <repo> <run id>",
486		Short:             "Cancel a run",
487		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.",
488		Args:              cobra.ExactArgs(2),
489		ValidArgsFunction: completeRepoNames(env),
490	}
491	asJSON := addJSONFlag(cmd)
492	cmd.RunE = func(cmd *cobra.Command, args []string) error {
493		id, err := parseRunID(args[1])
494		if err != nil {
495			return err
496		}
497		c, err := env.client(cmd)
498		if err != nil {
499			return err
500		}
501		res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
502		if err != nil {
503			return err
504		}
505		if *asJSON {
506			return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
507		}
508		noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
509		return nil
510	}
511	return cmd
512}
513
514func newRunRerunCmd(env *cliEnv) *cobra.Command {
515	var failed bool
516	cmd := &cobra.Command{
517		Use:               "rerun <repo> <run id>",
518		Short:             "Run a completed run again",
519		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,
520		Args:              cobra.ExactArgs(2),
521		ValidArgsFunction: completeRepoNames(env),
522	}
523	cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
524	asJSON := addJSONFlag(cmd)
525	cmd.RunE = func(cmd *cobra.Command, args []string) error {
526		id, err := parseRunID(args[1])
527		if err != nil {
528			return err
529		}
530		c, err := env.client(cmd)
531		if err != nil {
532			return err
533		}
534		res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
535		if err != nil {
536			return err
537		}
538		if *asJSON {
539			return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
540		}
541		noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
542		return nil
543	}
544	return cmd
545}

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