detached 1bd4fe7

feat(ci): add the run and runner commands and the runner token preset

ermite run list|view [--log] [--job]|watch|cancel|rerun [--failed] and
ermite runner list|remove drive RunService over the API, with the
stable JSON shapes the other commands have. ermite token create --preset
runner makes a token with runner:register. The client lists ci.runs and
ci.runners among the features it uses.
commit
1bd4fe7411d7afeb694e6763af7050568a077af8
author
brice · · 2026-10-12 18:28 UTC
parent
f2fa559
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +836 -11
M internal/adapters/client/client.go +5 -0
@@ -65,6 +65,11 @@ func (c *Client) Repos() ermiteconnect.RepoServiceClient {
6565 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)
6666 }
6767
68+// Runs returns the RunService client.
69+func (c *Client) Runs() ermiteconnect.RunServiceClient {
70+ return ermiteconnect.NewRunServiceClient(c.http, c.base, c.opts...)
71+}
72+
6873 // Tokens returns the TokenService client.
6974 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
7075 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
@@ -65,6 +65,11 @@ func (c *Client) Repos() ermiteconnect.RepoServiceClient {
65 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)65 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)
66 }66 }
67 67
68+// Runs returns the RunService client.
69+func (c *Client) Runs() ermiteconnect.RunServiceClient {
70+ return ermiteconnect.NewRunServiceClient(c.http, c.base, c.opts...)
71+}
72+
68 // Tokens returns the TokenService client.73 // Tokens returns the TokenService client.
69 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {74 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
70 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)75 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +2 -1
@@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureCIRuns, client.FeatureCIRunners,
146147 }))
147148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148149 }
@@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureCIRuns, client.FeatureCIRunners,
146 }))147 }))
147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148 }149 }
M internal/adapters/client/features.go +5 -1
@@ -22,11 +22,15 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
26+ // calls (`ermite runner`).
27+ FeatureCIRuns = "ci.runs"
28+ FeatureCIRunners = "ci.runners"
2529 )
2630
2731 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2832 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
33+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners}
3034 }
3135
3236 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -22,11 +22,15 @@ const (
22 FeatureSigningKey = "signing_key"22 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).23 // FeatureSession is SessionService (`ermite web login`, `web logout`).
24 FeatureSession = "session"24 FeatureSession = "session"
25+ // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
26+ // calls (`ermite runner`).
27+ FeatureCIRuns = "ci.runs"
28+ FeatureCIRunners = "ci.runners"
25 )29 )
26 30
27 // UsedFeatures lists the features the CLI uses, in the order they are reported.31 // UsedFeatures lists the features the CLI uses, in the order they are reported.
28 func UsedFeatures() []string {32 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}33+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners}
30 }34 }
31 35
32 // MissingFeatures returns the features the CLI uses that have is lacking.36 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/client/scopes.go +5 -1
@@ -18,10 +18,12 @@ const (
1818 presetCLI = "cli"
1919 presetAgent = "agent"
2020 presetCI = "ci"
21+ // presetRunner is for a CI runner host: it only registers runners.
22+ presetRunner = "runner"
2123 )
2224
2325 // PresetNames lists the token presets, see PresetScopes.
24-var PresetNames = []string{presetCLI, presetAgent, presetCI}
26+var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
2527
2628 // ScopeName is the name of a scope as users type it and as the docs write
2729 // it: SCOPE_REPO_READ is "repo:read".
@@ -75,6 +77,8 @@ func PresetScopes(name string) ([]ermite.Scope, error) {
7577 return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil
7678 case presetCI:
7779 return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil
80+ case presetRunner:
81+ return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil
7882 default:
7983 return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))
8084 }
@@ -18,10 +18,12 @@ const (
18 presetCLI = "cli"18 presetCLI = "cli"
19 presetAgent = "agent"19 presetAgent = "agent"
20 presetCI = "ci"20 presetCI = "ci"
21+ // presetRunner is for a CI runner host: it only registers runners.
22+ presetRunner = "runner"
21 )23 )
22 24
23 // PresetNames lists the token presets, see PresetScopes.25 // PresetNames lists the token presets, see PresetScopes.
24-var PresetNames = []string{presetCLI, presetAgent, presetCI}26+var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
25 27
26 // ScopeName is the name of a scope as users type it and as the docs write28 // ScopeName is the name of a scope as users type it and as the docs write
27 // it: SCOPE_REPO_READ is "repo:read".29 // it: SCOPE_REPO_READ is "repo:read".
@@ -75,6 +77,8 @@ func PresetScopes(name string) ([]ermite.Scope, error) {
75 return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil77 return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil
76 case presetCI:78 case presetCI:
77 return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil79 return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil
80+ case presetRunner:
81+ return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil
78 default:82 default:
79 return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))83 return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))
80 }84 }
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
A internal/app/cli_run.go +678 -0
@@ -0,0 +1,678 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/spf13/cobra"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/adapters/client"
19+)
20+
21+// jsonRun is the stable JSON shape of a run.
22+type jsonRun struct {
23+ ID int64 `json:"id"`
24+ Repo string `json:"repo"`
25+ Number int64 `json:"number"`
26+ Attempt int32 `json:"attempt"`
27+ Workflow string `json:"workflow"`
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+
42+type jsonStep struct {
43+ Number int32 `json:"number"`
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Conclusion string `json:"conclusion"`
47+ StartedAt *string `json:"started_at"`
48+ CompletedAt *string `json:"completed_at"`
49+ LogStart int64 `json:"log_start"`
50+ LogEnd int64 `json:"log_end"`
51+}
52+
53+type jsonRunJob struct {
54+ ID string `json:"id"`
55+ Attempt int32 `json:"attempt"`
56+ Key string `json:"key"`
57+ Name string `json:"name"`
58+ Status string `json:"status"`
59+ Conclusion string `json:"conclusion"`
60+ Error string `json:"error"`
61+ Labels []string `json:"labels"`
62+ Needs []string `json:"needs"`
63+ Runner string `json:"runner"`
64+ Isolation string `json:"isolation"`
65+ Steps []jsonStep `json:"steps"`
66+ StartedAt *string `json:"started_at"`
67+ CompletedAt *string `json:"completed_at"`
68+}
69+
70+type jsonRunDetail struct {
71+ jsonRun
72+ LatestAttempt int32 `json:"latest_attempt"`
73+ Jobs []jsonRunJob `json:"jobs"`
74+}
75+
76+const jsonRunHelp = `
77+JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
78+ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
79+skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
80+started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
81+jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
82+with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
83+
84+func statusText(s ermite.CiStatus) string {
85+ return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
86+}
87+
88+func conclusionText(c ermite.CiConclusion) string {
89+ if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
90+ return ""
91+ }
92+ return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
93+}
94+
95+func isolationText(i ermite.RunnerIsolation) string {
96+ if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
97+ return ""
98+ }
99+ return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
100+}
101+
102+func stepStateText(s ermite.StepState) string {
103+ return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
104+}
105+
106+// runResult is "status" while the run is going, the conclusion once over.
107+func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
108+ if status == ermite.CiStatus_CI_STATUS_COMPLETED {
109+ return conclusionText(c)
110+ }
111+ return statusText(status)
112+}
113+
114+func toJSONRun(r *ermite.Run) jsonRun {
115+ return jsonRun{
116+ ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
117+ WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
118+ Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
119+ CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
120+ }
121+}
122+
123+func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
124+ steps := make([]jsonStep, 0, len(j.GetSteps()))
125+ for _, s := range j.GetSteps() {
126+ steps = append(steps, jsonStep{
127+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
128+ StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
129+ })
130+ }
131+ labels, needs := j.GetLabels(), j.GetNeeds()
132+ if labels == nil {
133+ labels = []string{}
134+ }
135+ if needs == nil {
136+ needs = []string{}
137+ }
138+ return jsonRunJob{
139+ ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
140+ Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
141+ Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
142+ StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
143+ }
144+}
145+
146+func newRunCmd(env *cliEnv) *cobra.Command {
147+ run := &cobra.Command{
148+ Use: "run",
149+ Short: "List, read, watch, cancel and re-run CI runs",
150+ Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
151+ "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
152+ }
153+ run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
154+ return run
155+}
156+
157+// parseRunID reads a run id argument.
158+func parseRunID(s string) (int64, error) {
159+ id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
160+ if err != nil || id < 1 {
161+ return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
162+ }
163+ return id, nil
164+}
165+
166+func newRunListCmd(env *cliEnv) *cobra.Command {
167+ var workflow, branch, status string
168+ var limit int
169+ cmd := &cobra.Command{
170+ Use: "list <repo>",
171+ Short: "List the runs of a repository",
172+ Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
173+ "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
174+ Args: cobra.ExactArgs(1),
175+ ValidArgsFunction: completeRepoNames(env),
176+ }
177+ cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
178+ cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
179+ cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
180+ cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
181+ asJSON := addJSONFlag(cmd)
182+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
183+ st, err := parseStatusFlag(status)
184+ if err != nil {
185+ return err
186+ }
187+ if limit < 1 {
188+ return fmt.Errorf("--limit must be at least 1")
189+ }
190+ c, err := env.client(cmd, true)
191+ if err != nil {
192+ return err
193+ }
194+ ctx := env.ctx(cmd)
195+ var runs []*ermite.Run
196+ token := ""
197+ for len(runs) < limit {
198+ res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
199+ Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
200+ Workflow: workflow, Branch: branch, Status: st,
201+ }))
202+ if err != nil {
203+ return err
204+ }
205+ runs = append(runs, res.Msg.GetRuns()...)
206+ if token = res.Msg.GetNextPageToken(); token == "" {
207+ break
208+ }
209+ }
210+ if *asJSON {
211+ out := make([]jsonRun, 0, len(runs))
212+ for _, r := range runs {
213+ out = append(out, toJSONRun(r))
214+ }
215+ return writeJSON(cmd.OutOrStdout(), out)
216+ }
217+ if len(runs) == 0 {
218+ noticef(cmd, "no runs")
219+ return nil
220+ }
221+ rows := make([][]string, 0, len(runs))
222+ for _, r := range runs {
223+ rows = append(rows, []string{
224+ strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
225+ strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
226+ r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
227+ })
228+ }
229+ return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
230+ }
231+ return cmd
232+}
233+
234+func shortSha(s string) string {
235+ if len(s) > 8 {
236+ return s[:8]
237+ }
238+ return s
239+}
240+
241+func parseStatusFlag(s string) (ermite.CiStatus, error) {
242+ switch strings.ToLower(s) {
243+ case "":
244+ return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
245+ case "queued":
246+ return ermite.CiStatus_CI_STATUS_QUEUED, nil
247+ case "in_progress", "in-progress":
248+ return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
249+ case "completed":
250+ return ermite.CiStatus_CI_STATUS_COMPLETED, nil
251+ }
252+ return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
253+}
254+
255+func newRunViewCmd(env *cliEnv) *cobra.Command {
256+ var attempt int32
257+ var logFlag bool
258+ var jobFlag string
259+ cmd := &cobra.Command{
260+ Use: "view <repo> <run id>",
261+ Short: "Show a run with its jobs and steps, or the log of a job",
262+ Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
263+ "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
264+ "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
265+ Args: cobra.ExactArgs(2),
266+ ValidArgsFunction: completeRepoNames(env),
267+ }
268+ cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
269+ cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
270+ cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
271+ asJSON := addJSONFlag(cmd)
272+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
273+ id, err := parseRunID(args[1])
274+ if err != nil {
275+ return err
276+ }
277+ c, err := env.client(cmd, true)
278+ if err != nil {
279+ return err
280+ }
281+ ctx := env.ctx(cmd)
282+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
283+ if err != nil {
284+ return err
285+ }
286+ if logFlag {
287+ return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
288+ }
289+ if *asJSON {
290+ d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
291+ for _, j := range res.Msg.GetJobs() {
292+ d.Jobs = append(d.Jobs, toJSONRunJob(j))
293+ }
294+ return writeJSON(cmd.OutOrStdout(), d)
295+ }
296+ return printRun(cmd.OutOrStdout(), res.Msg)
297+ }
298+ return cmd
299+}
300+
301+func printRun(w io.Writer, r *ermite.GetRunResponse) error {
302+ run := r.GetRun()
303+ pairs := [][2]string{
304+ {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
305+ {"result", runResult(run.GetStatus(), run.GetConclusion())},
306+ {"ref", run.GetRef()},
307+ {"commit", run.GetSha()},
308+ {"title", run.GetTitle()},
309+ {"actor", run.GetActor()},
310+ {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
311+ {"created", shortTime(run.GetCreatedAt(), "-")},
312+ }
313+ if run.GetError() != "" {
314+ pairs = append(pairs, [2]string{"error", run.GetError()})
315+ }
316+ if err := fields(w, pairs); err != nil {
317+ return err
318+ }
319+ if len(r.GetJobs()) == 0 {
320+ return nil
321+ }
322+ _, _ = fmt.Fprintln(w)
323+ for _, j := range r.GetJobs() {
324+ line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
325+ if j.GetRunnerName() != "" {
326+ line += " [" + j.GetRunnerName() + "]"
327+ }
328+ _, _ = fmt.Fprintln(w, clean(line))
329+ if j.GetError() != "" {
330+ _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
331+ }
332+ for _, s := range j.GetSteps() {
333+ res := stepStateText(s.GetState())
334+ if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
335+ res = conclusionText(s.GetConclusion())
336+ }
337+ _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
338+ }
339+ }
340+ return nil
341+}
342+
343+// pickJobs selects the jobs --job names (an id or a name), or all of them.
344+func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
345+ if sel == "" {
346+ return jobs, nil
347+ }
348+ var out []*ermite.RunJob
349+ for _, j := range jobs {
350+ if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
351+ out = append(out, j)
352+ }
353+ }
354+ if len(out) == 0 {
355+ return nil, fmt.Errorf("the run has no job %q", sel)
356+ }
357+ return out, nil
358+}
359+
360+// printRunLog prints the log of the chosen jobs; with follow it waits for the
361+// lines of a job that is running.
362+func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
363+ jobs, err := pickJobs(r.GetJobs(), sel)
364+ if err != nil {
365+ return err
366+ }
367+ for _, j := range jobs {
368+ if len(jobs) > 1 {
369+ _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
370+ }
371+ if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
372+ return err
373+ }
374+ }
375+ return nil
376+}
377+
378+// streamJobLog writes the lines of one job. Continued pieces are joined to the
379+// line before them, as the raw log has them.
380+func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
381+ if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
382+ noticef2(w, "job %q has not started: no log yet", j.GetName())
383+ return nil
384+ }
385+ stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
386+ Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
387+ }))
388+ if err != nil {
389+ return err
390+ }
391+ defer func() { _ = stream.Close() }()
392+ open := false
393+ for stream.Receive() {
394+ for _, l := range stream.Msg().GetLines() {
395+ if l.GetContinued() && open {
396+ _, _ = fmt.Fprint(w, clean(l.GetText()))
397+ continue
398+ }
399+ if open {
400+ _, _ = fmt.Fprintln(w)
401+ }
402+ _, _ = fmt.Fprint(w, clean(l.GetText()))
403+ open = true
404+ }
405+ }
406+ if open {
407+ _, _ = fmt.Fprintln(w)
408+ }
409+ if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
410+ return err
411+ }
412+ return nil
413+}
414+
415+func noticef2(w io.Writer, format string, args ...any) {
416+ _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
417+}
418+
419+func newRunWatchCmd(env *cliEnv) *cobra.Command {
420+ var interval time.Duration
421+ var logFlag bool
422+ cmd := &cobra.Command{
423+ Use: "watch <repo> <run id>",
424+ Short: "Wait for a run to complete and follow its jobs",
425+ Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
426+ "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
427+ "`ermite run watch` can gate a script.",
428+ Args: cobra.ExactArgs(2),
429+ ValidArgsFunction: completeRepoNames(env),
430+ }
431+ cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
432+ cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
433+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
434+ id, err := parseRunID(args[1])
435+ if err != nil {
436+ return err
437+ }
438+ c, err := env.client(cmd, true)
439+ if err != nil {
440+ return err
441+ }
442+ ctx := env.ctx(cmd)
443+ w := cmd.OutOrStdout()
444+ last := ""
445+ for {
446+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
447+ if err != nil {
448+ return err
449+ }
450+ if line := watchLine(res.Msg); line != last {
451+ _, _ = fmt.Fprintln(w, line)
452+ last = line
453+ }
454+ if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
455+ if logFlag {
456+ if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
457+ return err
458+ }
459+ }
460+ if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
461+ return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
462+ }
463+ return nil
464+ }
465+ select {
466+ case <-ctx.Done():
467+ return ctx.Err()
468+ case <-time.After(interval):
469+ }
470+ }
471+ }
472+ return cmd
473+}
474+
475+// watchLine is a one-line state of a run for `run watch`.
476+func watchLine(r *ermite.GetRunResponse) string {
477+ var parts []string
478+ for _, j := range r.GetJobs() {
479+ parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
480+ }
481+ return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
482+}
483+
484+func newRunCancelCmd(env *cliEnv) *cobra.Command {
485+ cmd := &cobra.Command{
486+ Use: "cancel <repo> <run id>",
487+ Short: "Cancel a run",
488+ 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.",
489+ Args: cobra.ExactArgs(2),
490+ ValidArgsFunction: completeRepoNames(env),
491+ }
492+ asJSON := addJSONFlag(cmd)
493+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
494+ id, err := parseRunID(args[1])
495+ if err != nil {
496+ return err
497+ }
498+ c, err := env.client(cmd, true)
499+ if err != nil {
500+ return err
501+ }
502+ res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
503+ if err != nil {
504+ return err
505+ }
506+ if *asJSON {
507+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
508+ }
509+ noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
510+ return nil
511+ }
512+ return cmd
513+}
514+
515+func newRunRerunCmd(env *cliEnv) *cobra.Command {
516+ var failed bool
517+ cmd := &cobra.Command{
518+ Use: "rerun <repo> <run id>",
519+ Short: "Run a completed run again",
520+ 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,
521+ Args: cobra.ExactArgs(2),
522+ ValidArgsFunction: completeRepoNames(env),
523+ }
524+ cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
525+ asJSON := addJSONFlag(cmd)
526+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
527+ id, err := parseRunID(args[1])
528+ if err != nil {
529+ return err
530+ }
531+ c, err := env.client(cmd, true)
532+ if err != nil {
533+ return err
534+ }
535+ res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
536+ if err != nil {
537+ return err
538+ }
539+ if *asJSON {
540+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
541+ }
542+ noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
543+ return nil
544+ }
545+ return cmd
546+}
547+
548+// --- runners ---
549+
550+type jsonRunner struct {
551+ ID string `json:"id"`
552+ Name string `json:"name"`
553+ Labels []string `json:"labels"`
554+ Isolation string `json:"isolation"`
555+ Version string `json:"version"`
556+ Arch string `json:"arch"`
557+ Os string `json:"os"`
558+ RegisteredAt *string `json:"registered_at"`
559+ LastSeen *string `json:"last_seen"`
560+ Busy bool `json:"busy"`
561+}
562+
563+const jsonRunnerHelp = `
564+JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
565+container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
566+seen) and busy. A runner not seen for more than about 2 minutes is offline.`
567+
568+func newRunnerCmd(env *cliEnv) *cobra.Command {
569+ runner := &cobra.Command{
570+ Use: "runner",
571+ Short: "List and remove CI runners",
572+ Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
573+ "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
574+ "not limited to some repositories.",
575+ }
576+ runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
577+ return runner
578+}
579+
580+func newRunnerListCmd(env *cliEnv) *cobra.Command {
581+ cmd := &cobra.Command{
582+ Use: "list",
583+ Short: "List the registered runners",
584+ Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
585+ Args: cobra.NoArgs,
586+ }
587+ asJSON := addJSONFlag(cmd)
588+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
589+ c, err := env.client(cmd, true)
590+ if err != nil {
591+ return err
592+ }
593+ ctx := env.ctx(cmd)
594+ runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
595+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
596+ if err != nil {
597+ return nil, "", err
598+ }
599+ return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
600+ })
601+ if err != nil {
602+ return err
603+ }
604+ if *asJSON {
605+ out := make([]jsonRunner, 0, len(runners))
606+ for _, r := range runners {
607+ labels := r.GetLabels()
608+ if labels == nil {
609+ labels = []string{}
610+ }
611+ out = append(out, jsonRunner{
612+ ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
613+ Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
614+ })
615+ }
616+ return writeJSON(cmd.OutOrStdout(), out)
617+ }
618+ if len(runners) == 0 {
619+ noticef(cmd, "no runners: register one with `ermite-runner register`")
620+ return nil
621+ }
622+ rows := make([][]string, 0, len(runners))
623+ for _, r := range runners {
624+ state := "offline"
625+ if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
626+ state = "online"
627+ if r.GetBusy() {
628+ state = "busy"
629+ }
630+ }
631+ rows = append(rows, []string{
632+ r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
633+ strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
634+ })
635+ }
636+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
637+ }
638+ return cmd
639+}
640+
641+func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
642+ cmd := &cobra.Command{
643+ Use: "remove <runner id>",
644+ Short: "Remove a runner",
645+ Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
646+ "affected: it ends, or fails as a lost runner when its heartbeats stop.",
647+ Args: cobra.ExactArgs(1),
648+ ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
649+ if len(args) > 0 {
650+ return nil, cobra.ShellCompDirectiveNoFileComp
651+ }
652+ out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
653+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
654+ if err != nil {
655+ return nil, err
656+ }
657+ return res.Msg.GetRunners(), nil
658+ }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
659+ return out, cobra.ShellCompDirectiveNoFileComp
660+ },
661+ }
662+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
663+ c, err := env.client(cmd, true)
664+ if err != nil {
665+ return err
666+ }
667+ if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
668+ var ce *connect.Error
669+ if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
670+ return fmt.Errorf("no runner has the id %q", args[0])
671+ }
672+ return err
673+ }
674+ noticef(cmd, "runner %s removed", args[0])
675+ return nil
676+ }
677+ return cmd
678+}
@@ -0,0 +1,678 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/spf13/cobra"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/adapters/client"
19+)
20+
21+// jsonRun is the stable JSON shape of a run.
22+type jsonRun struct {
23+ ID int64 `json:"id"`
24+ Repo string `json:"repo"`
25+ Number int64 `json:"number"`
26+ Attempt int32 `json:"attempt"`
27+ Workflow string `json:"workflow"`
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+
42+type jsonStep struct {
43+ Number int32 `json:"number"`
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Conclusion string `json:"conclusion"`
47+ StartedAt *string `json:"started_at"`
48+ CompletedAt *string `json:"completed_at"`
49+ LogStart int64 `json:"log_start"`
50+ LogEnd int64 `json:"log_end"`
51+}
52+
53+type jsonRunJob struct {
54+ ID string `json:"id"`
55+ Attempt int32 `json:"attempt"`
56+ Key string `json:"key"`
57+ Name string `json:"name"`
58+ Status string `json:"status"`
59+ Conclusion string `json:"conclusion"`
60+ Error string `json:"error"`
61+ Labels []string `json:"labels"`
62+ Needs []string `json:"needs"`
63+ Runner string `json:"runner"`
64+ Isolation string `json:"isolation"`
65+ Steps []jsonStep `json:"steps"`
66+ StartedAt *string `json:"started_at"`
67+ CompletedAt *string `json:"completed_at"`
68+}
69+
70+type jsonRunDetail struct {
71+ jsonRun
72+ LatestAttempt int32 `json:"latest_attempt"`
73+ Jobs []jsonRunJob `json:"jobs"`
74+}
75+
76+const jsonRunHelp = `
77+JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
78+ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
79+skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
80+started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
81+jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
82+with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
83+
84+func statusText(s ermite.CiStatus) string {
85+ return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
86+}
87+
88+func conclusionText(c ermite.CiConclusion) string {
89+ if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
90+ return ""
91+ }
92+ return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
93+}
94+
95+func isolationText(i ermite.RunnerIsolation) string {
96+ if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
97+ return ""
98+ }
99+ return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
100+}
101+
102+func stepStateText(s ermite.StepState) string {
103+ return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
104+}
105+
106+// runResult is "status" while the run is going, the conclusion once over.
107+func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
108+ if status == ermite.CiStatus_CI_STATUS_COMPLETED {
109+ return conclusionText(c)
110+ }
111+ return statusText(status)
112+}
113+
114+func toJSONRun(r *ermite.Run) jsonRun {
115+ return jsonRun{
116+ ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
117+ WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
118+ Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
119+ CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
120+ }
121+}
122+
123+func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
124+ steps := make([]jsonStep, 0, len(j.GetSteps()))
125+ for _, s := range j.GetSteps() {
126+ steps = append(steps, jsonStep{
127+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
128+ StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
129+ })
130+ }
131+ labels, needs := j.GetLabels(), j.GetNeeds()
132+ if labels == nil {
133+ labels = []string{}
134+ }
135+ if needs == nil {
136+ needs = []string{}
137+ }
138+ return jsonRunJob{
139+ ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
140+ Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
141+ Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
142+ StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
143+ }
144+}
145+
146+func newRunCmd(env *cliEnv) *cobra.Command {
147+ run := &cobra.Command{
148+ Use: "run",
149+ Short: "List, read, watch, cancel and re-run CI runs",
150+ Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
151+ "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
152+ }
153+ run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
154+ return run
155+}
156+
157+// parseRunID reads a run id argument.
158+func parseRunID(s string) (int64, error) {
159+ id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
160+ if err != nil || id < 1 {
161+ return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
162+ }
163+ return id, nil
164+}
165+
166+func newRunListCmd(env *cliEnv) *cobra.Command {
167+ var workflow, branch, status string
168+ var limit int
169+ cmd := &cobra.Command{
170+ Use: "list <repo>",
171+ Short: "List the runs of a repository",
172+ Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
173+ "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
174+ Args: cobra.ExactArgs(1),
175+ ValidArgsFunction: completeRepoNames(env),
176+ }
177+ cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
178+ cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
179+ cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
180+ cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
181+ asJSON := addJSONFlag(cmd)
182+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
183+ st, err := parseStatusFlag(status)
184+ if err != nil {
185+ return err
186+ }
187+ if limit < 1 {
188+ return fmt.Errorf("--limit must be at least 1")
189+ }
190+ c, err := env.client(cmd, true)
191+ if err != nil {
192+ return err
193+ }
194+ ctx := env.ctx(cmd)
195+ var runs []*ermite.Run
196+ token := ""
197+ for len(runs) < limit {
198+ res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
199+ Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
200+ Workflow: workflow, Branch: branch, Status: st,
201+ }))
202+ if err != nil {
203+ return err
204+ }
205+ runs = append(runs, res.Msg.GetRuns()...)
206+ if token = res.Msg.GetNextPageToken(); token == "" {
207+ break
208+ }
209+ }
210+ if *asJSON {
211+ out := make([]jsonRun, 0, len(runs))
212+ for _, r := range runs {
213+ out = append(out, toJSONRun(r))
214+ }
215+ return writeJSON(cmd.OutOrStdout(), out)
216+ }
217+ if len(runs) == 0 {
218+ noticef(cmd, "no runs")
219+ return nil
220+ }
221+ rows := make([][]string, 0, len(runs))
222+ for _, r := range runs {
223+ rows = append(rows, []string{
224+ strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
225+ strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
226+ r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
227+ })
228+ }
229+ return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
230+ }
231+ return cmd
232+}
233+
234+func shortSha(s string) string {
235+ if len(s) > 8 {
236+ return s[:8]
237+ }
238+ return s
239+}
240+
241+func parseStatusFlag(s string) (ermite.CiStatus, error) {
242+ switch strings.ToLower(s) {
243+ case "":
244+ return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
245+ case "queued":
246+ return ermite.CiStatus_CI_STATUS_QUEUED, nil
247+ case "in_progress", "in-progress":
248+ return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
249+ case "completed":
250+ return ermite.CiStatus_CI_STATUS_COMPLETED, nil
251+ }
252+ return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
253+}
254+
255+func newRunViewCmd(env *cliEnv) *cobra.Command {
256+ var attempt int32
257+ var logFlag bool
258+ var jobFlag string
259+ cmd := &cobra.Command{
260+ Use: "view <repo> <run id>",
261+ Short: "Show a run with its jobs and steps, or the log of a job",
262+ Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
263+ "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
264+ "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
265+ Args: cobra.ExactArgs(2),
266+ ValidArgsFunction: completeRepoNames(env),
267+ }
268+ cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
269+ cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
270+ cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
271+ asJSON := addJSONFlag(cmd)
272+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
273+ id, err := parseRunID(args[1])
274+ if err != nil {
275+ return err
276+ }
277+ c, err := env.client(cmd, true)
278+ if err != nil {
279+ return err
280+ }
281+ ctx := env.ctx(cmd)
282+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
283+ if err != nil {
284+ return err
285+ }
286+ if logFlag {
287+ return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
288+ }
289+ if *asJSON {
290+ d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
291+ for _, j := range res.Msg.GetJobs() {
292+ d.Jobs = append(d.Jobs, toJSONRunJob(j))
293+ }
294+ return writeJSON(cmd.OutOrStdout(), d)
295+ }
296+ return printRun(cmd.OutOrStdout(), res.Msg)
297+ }
298+ return cmd
299+}
300+
301+func printRun(w io.Writer, r *ermite.GetRunResponse) error {
302+ run := r.GetRun()
303+ pairs := [][2]string{
304+ {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
305+ {"result", runResult(run.GetStatus(), run.GetConclusion())},
306+ {"ref", run.GetRef()},
307+ {"commit", run.GetSha()},
308+ {"title", run.GetTitle()},
309+ {"actor", run.GetActor()},
310+ {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
311+ {"created", shortTime(run.GetCreatedAt(), "-")},
312+ }
313+ if run.GetError() != "" {
314+ pairs = append(pairs, [2]string{"error", run.GetError()})
315+ }
316+ if err := fields(w, pairs); err != nil {
317+ return err
318+ }
319+ if len(r.GetJobs()) == 0 {
320+ return nil
321+ }
322+ _, _ = fmt.Fprintln(w)
323+ for _, j := range r.GetJobs() {
324+ line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
325+ if j.GetRunnerName() != "" {
326+ line += " [" + j.GetRunnerName() + "]"
327+ }
328+ _, _ = fmt.Fprintln(w, clean(line))
329+ if j.GetError() != "" {
330+ _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
331+ }
332+ for _, s := range j.GetSteps() {
333+ res := stepStateText(s.GetState())
334+ if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
335+ res = conclusionText(s.GetConclusion())
336+ }
337+ _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
338+ }
339+ }
340+ return nil
341+}
342+
343+// pickJobs selects the jobs --job names (an id or a name), or all of them.
344+func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
345+ if sel == "" {
346+ return jobs, nil
347+ }
348+ var out []*ermite.RunJob
349+ for _, j := range jobs {
350+ if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
351+ out = append(out, j)
352+ }
353+ }
354+ if len(out) == 0 {
355+ return nil, fmt.Errorf("the run has no job %q", sel)
356+ }
357+ return out, nil
358+}
359+
360+// printRunLog prints the log of the chosen jobs; with follow it waits for the
361+// lines of a job that is running.
362+func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
363+ jobs, err := pickJobs(r.GetJobs(), sel)
364+ if err != nil {
365+ return err
366+ }
367+ for _, j := range jobs {
368+ if len(jobs) > 1 {
369+ _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
370+ }
371+ if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
372+ return err
373+ }
374+ }
375+ return nil
376+}
377+
378+// streamJobLog writes the lines of one job. Continued pieces are joined to the
379+// line before them, as the raw log has them.
380+func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
381+ if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
382+ noticef2(w, "job %q has not started: no log yet", j.GetName())
383+ return nil
384+ }
385+ stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
386+ Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
387+ }))
388+ if err != nil {
389+ return err
390+ }
391+ defer func() { _ = stream.Close() }()
392+ open := false
393+ for stream.Receive() {
394+ for _, l := range stream.Msg().GetLines() {
395+ if l.GetContinued() && open {
396+ _, _ = fmt.Fprint(w, clean(l.GetText()))
397+ continue
398+ }
399+ if open {
400+ _, _ = fmt.Fprintln(w)
401+ }
402+ _, _ = fmt.Fprint(w, clean(l.GetText()))
403+ open = true
404+ }
405+ }
406+ if open {
407+ _, _ = fmt.Fprintln(w)
408+ }
409+ if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
410+ return err
411+ }
412+ return nil
413+}
414+
415+func noticef2(w io.Writer, format string, args ...any) {
416+ _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
417+}
418+
419+func newRunWatchCmd(env *cliEnv) *cobra.Command {
420+ var interval time.Duration
421+ var logFlag bool
422+ cmd := &cobra.Command{
423+ Use: "watch <repo> <run id>",
424+ Short: "Wait for a run to complete and follow its jobs",
425+ Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
426+ "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
427+ "`ermite run watch` can gate a script.",
428+ Args: cobra.ExactArgs(2),
429+ ValidArgsFunction: completeRepoNames(env),
430+ }
431+ cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
432+ cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
433+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
434+ id, err := parseRunID(args[1])
435+ if err != nil {
436+ return err
437+ }
438+ c, err := env.client(cmd, true)
439+ if err != nil {
440+ return err
441+ }
442+ ctx := env.ctx(cmd)
443+ w := cmd.OutOrStdout()
444+ last := ""
445+ for {
446+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
447+ if err != nil {
448+ return err
449+ }
450+ if line := watchLine(res.Msg); line != last {
451+ _, _ = fmt.Fprintln(w, line)
452+ last = line
453+ }
454+ if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
455+ if logFlag {
456+ if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
457+ return err
458+ }
459+ }
460+ if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
461+ return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
462+ }
463+ return nil
464+ }
465+ select {
466+ case <-ctx.Done():
467+ return ctx.Err()
468+ case <-time.After(interval):
469+ }
470+ }
471+ }
472+ return cmd
473+}
474+
475+// watchLine is a one-line state of a run for `run watch`.
476+func watchLine(r *ermite.GetRunResponse) string {
477+ var parts []string
478+ for _, j := range r.GetJobs() {
479+ parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
480+ }
481+ return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
482+}
483+
484+func newRunCancelCmd(env *cliEnv) *cobra.Command {
485+ cmd := &cobra.Command{
486+ Use: "cancel <repo> <run id>",
487+ Short: "Cancel a run",
488+ 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.",
489+ Args: cobra.ExactArgs(2),
490+ ValidArgsFunction: completeRepoNames(env),
491+ }
492+ asJSON := addJSONFlag(cmd)
493+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
494+ id, err := parseRunID(args[1])
495+ if err != nil {
496+ return err
497+ }
498+ c, err := env.client(cmd, true)
499+ if err != nil {
500+ return err
501+ }
502+ res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
503+ if err != nil {
504+ return err
505+ }
506+ if *asJSON {
507+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
508+ }
509+ noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
510+ return nil
511+ }
512+ return cmd
513+}
514+
515+func newRunRerunCmd(env *cliEnv) *cobra.Command {
516+ var failed bool
517+ cmd := &cobra.Command{
518+ Use: "rerun <repo> <run id>",
519+ Short: "Run a completed run again",
520+ 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,
521+ Args: cobra.ExactArgs(2),
522+ ValidArgsFunction: completeRepoNames(env),
523+ }
524+ cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
525+ asJSON := addJSONFlag(cmd)
526+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
527+ id, err := parseRunID(args[1])
528+ if err != nil {
529+ return err
530+ }
531+ c, err := env.client(cmd, true)
532+ if err != nil {
533+ return err
534+ }
535+ res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
536+ if err != nil {
537+ return err
538+ }
539+ if *asJSON {
540+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
541+ }
542+ noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
543+ return nil
544+ }
545+ return cmd
546+}
547+
548+// --- runners ---
549+
550+type jsonRunner struct {
551+ ID string `json:"id"`
552+ Name string `json:"name"`
553+ Labels []string `json:"labels"`
554+ Isolation string `json:"isolation"`
555+ Version string `json:"version"`
556+ Arch string `json:"arch"`
557+ Os string `json:"os"`
558+ RegisteredAt *string `json:"registered_at"`
559+ LastSeen *string `json:"last_seen"`
560+ Busy bool `json:"busy"`
561+}
562+
563+const jsonRunnerHelp = `
564+JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
565+container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
566+seen) and busy. A runner not seen for more than about 2 minutes is offline.`
567+
568+func newRunnerCmd(env *cliEnv) *cobra.Command {
569+ runner := &cobra.Command{
570+ Use: "runner",
571+ Short: "List and remove CI runners",
572+ Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
573+ "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
574+ "not limited to some repositories.",
575+ }
576+ runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
577+ return runner
578+}
579+
580+func newRunnerListCmd(env *cliEnv) *cobra.Command {
581+ cmd := &cobra.Command{
582+ Use: "list",
583+ Short: "List the registered runners",
584+ Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
585+ Args: cobra.NoArgs,
586+ }
587+ asJSON := addJSONFlag(cmd)
588+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
589+ c, err := env.client(cmd, true)
590+ if err != nil {
591+ return err
592+ }
593+ ctx := env.ctx(cmd)
594+ runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
595+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
596+ if err != nil {
597+ return nil, "", err
598+ }
599+ return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
600+ })
601+ if err != nil {
602+ return err
603+ }
604+ if *asJSON {
605+ out := make([]jsonRunner, 0, len(runners))
606+ for _, r := range runners {
607+ labels := r.GetLabels()
608+ if labels == nil {
609+ labels = []string{}
610+ }
611+ out = append(out, jsonRunner{
612+ ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
613+ Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
614+ })
615+ }
616+ return writeJSON(cmd.OutOrStdout(), out)
617+ }
618+ if len(runners) == 0 {
619+ noticef(cmd, "no runners: register one with `ermite-runner register`")
620+ return nil
621+ }
622+ rows := make([][]string, 0, len(runners))
623+ for _, r := range runners {
624+ state := "offline"
625+ if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
626+ state = "online"
627+ if r.GetBusy() {
628+ state = "busy"
629+ }
630+ }
631+ rows = append(rows, []string{
632+ r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
633+ strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
634+ })
635+ }
636+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
637+ }
638+ return cmd
639+}
640+
641+func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
642+ cmd := &cobra.Command{
643+ Use: "remove <runner id>",
644+ Short: "Remove a runner",
645+ Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
646+ "affected: it ends, or fails as a lost runner when its heartbeats stop.",
647+ Args: cobra.ExactArgs(1),
648+ ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
649+ if len(args) > 0 {
650+ return nil, cobra.ShellCompDirectiveNoFileComp
651+ }
652+ out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
653+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
654+ if err != nil {
655+ return nil, err
656+ }
657+ return res.Msg.GetRunners(), nil
658+ }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
659+ return out, cobra.ShellCompDirectiveNoFileComp
660+ },
661+ }
662+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
663+ c, err := env.client(cmd, true)
664+ if err != nil {
665+ return err
666+ }
667+ if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
668+ var ce *connect.Error
669+ if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
670+ return fmt.Errorf("no runner has the id %q", args[0])
671+ }
672+ return err
673+ }
674+ noticef(cmd, "runner %s removed", args[0])
675+ return nil
676+ }
677+ return cmd
678+}
A internal/app/cli_run_test.go +132 -0
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// The CLI over a real server: `run list|view|watch|cancel|rerun`, `runner
18+// list|remove`, and the runner token preset.
19+func TestCLIRunsAndRunners(t *testing.T) {
20+ ci := newCIHarness(t, nil)
21+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
22+ runner := ci.registerRunner("alpha", "ubuntu-latest")
23+ ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
24+ ci.waitFor("a run", func() bool { return len(ci.runs()) == 1 })
25+
26+ job := runner.await()
27+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello from the job")
28+ next := runner.await()
29+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "this one failed")
30+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
31+ id := "1"
32+
33+ // list
34+ out := h.cli(t, adminToken, "run", "list", "proj")
35+ require.Empty(t, out.err, out.stderr)
36+ assert.Contains(t, out.stdout, "RESULT")
37+ assert.Contains(t, out.stdout, "failure")
38+ assert.Contains(t, out.stdout, "ci")
39+
40+ js := h.cli(t, adminToken, "run", "list", "proj", "--json")
41+ require.NoError(t, js.err)
42+ runs := decode[[]jsonRun](t, js.stdout)
43+ require.Len(t, runs, 1)
44+ assert.Equal(t, "failure", runs[0].Conclusion)
45+ assert.Equal(t, "completed", runs[0].Status)
46+ assert.Equal(t, "refs/heads/main", runs[0].Ref)
47+ assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
48+
49+ none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
50+ require.NoError(t, none.err)
51+ assert.Contains(t, none.stderr, "no runs")
52+ bad := h.cli(t, adminToken, "run", "list", "proj", "--status", "weird")
53+ require.Error(t, bad.err)
54+
55+ // view
56+ view := h.cli(t, adminToken, "run", "view", "proj", id)
57+ require.NoError(t, view.err, view.stderr)
58+ assert.Contains(t, view.stdout, "result")
59+ assert.Contains(t, view.stdout, "build")
60+ assert.Contains(t, view.stdout, "[alpha]")
61+ assert.Contains(t, view.stdout, "Set up job")
62+
63+ vjs := h.cli(t, adminToken, "run", "view", "proj", id, "--json")
64+ detail := decode[jsonRunDetail](t, vjs.stdout)
65+ require.Len(t, detail.Jobs, 2)
66+ assert.Equal(t, "build", detail.Jobs[0].Key)
67+ assert.Equal(t, "vm", detail.Jobs[0].Isolation)
68+ assert.Equal(t, int32(1), detail.LatestAttempt)
69+
70+ // --log, all jobs and one job
71+ all := h.cli(t, adminToken, "run", "view", "proj", id, "--log")
72+ require.NoError(t, all.err, all.stderr)
73+ assert.Contains(t, all.stdout, "== build ==")
74+ assert.Contains(t, all.stdout, "hello from the job")
75+ assert.Contains(t, all.stdout, "this one failed")
76+ one := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "test")
77+ require.NoError(t, one.err)
78+ assert.Contains(t, one.stdout, "this one failed")
79+ assert.NotContains(t, one.stdout, "hello from the job")
80+ missing := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "nope")
81+ require.Error(t, missing.err)
82+ assert.Contains(t, missing.err.Error(), `no job "nope"`)
83+
84+ // watch: a completed failing run prints once and exits with an error.
85+ w := h.cli(t, adminToken, "run", "watch", "proj", id)
86+ require.Error(t, w.err)
87+ assert.Contains(t, w.err.Error(), "failure")
88+ assert.Contains(t, w.stdout, "build success")
89+
90+ // rerun --failed, then cancel the new attempt.
91+ re := h.cli(t, adminToken, "run", "rerun", "proj", id, "--failed")
92+ require.NoError(t, re.err, re.stderr)
93+ assert.Contains(t, re.stderr, "attempt 2 started")
94+ ca := h.cli(t, adminToken, "run", "cancel", "proj", id, "--json")
95+ require.NoError(t, ca.err, ca.stderr)
96+ assert.Equal(t, int32(2), decode[jsonRun](t, ca.stdout).Attempt)
97+ // No runner holds a job of attempt 2, so the cancel ended it at once: a
98+ // completed run cannot be cancelled again.
99+ again := h.cli(t, adminToken, "run", "cancel", "proj", id)
100+ require.Error(t, again.err)
101+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(again.err))
102+
103+ // A reader may look but not cancel or rerun.
104+ reader := h.token(scopes(domain.ScopeRepoRead))
105+ assert.NoError(t, h.cli(t, reader, "run", "view", "proj", id).err)
106+ denied := h.cli(t, reader, "run", "cancel", "proj", id)
107+ require.Error(t, denied.err)
108+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(denied.err))
109+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "abc").err)
110+
111+ // runners
112+ rl := h.cli(t, adminToken, "runner", "list")
113+ require.NoError(t, rl.err, rl.stderr)
114+ assert.Contains(t, rl.stdout, "alpha")
115+ assert.Contains(t, rl.stdout, "vm")
116+ rjs := decode[[]jsonRunner](t, h.cli(t, adminToken, "runner", "list", "--json").stdout)
117+ require.Len(t, rjs, 1)
118+ assert.Equal(t, []string{"linux", "self-hosted", "ubuntu-latest", "x64"}, rjs[0].Labels)
119+ rm := h.cli(t, adminToken, "runner", "remove", rjs[0].ID)
120+ require.NoError(t, rm.err, rm.stderr)
121+ assert.Error(t, h.cli(t, adminToken, "runner", "remove", rjs[0].ID).err, "already removed")
122+ assert.Contains(t, h.cli(t, adminToken, "runner", "list").stderr, "no runners")
123+
124+ // The runner token preset makes a token that registers runners and nothing
125+ // else.
126+ tok := h.cli(t, adminToken, "token", "create", "runner host", "--preset", "runner", "--json")
127+ require.NoError(t, tok.err, tok.stderr)
128+ created := decode[jsonCreatedToken](t, tok.stdout)
129+ assert.Equal(t, []string{"runner:register"}, created.Scopes)
130+ assert.NoError(t, h.cli(t, created.Secret, "runner", "list").err)
131+ assert.Error(t, h.cli(t, created.Secret, "run", "cancel", "proj", id).err)
132+}
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// The CLI over a real server: `run list|view|watch|cancel|rerun`, `runner
18+// list|remove`, and the runner token preset.
19+func TestCLIRunsAndRunners(t *testing.T) {
20+ ci := newCIHarness(t, nil)
21+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
22+ runner := ci.registerRunner("alpha", "ubuntu-latest")
23+ ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
24+ ci.waitFor("a run", func() bool { return len(ci.runs()) == 1 })
25+
26+ job := runner.await()
27+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello from the job")
28+ next := runner.await()
29+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "this one failed")
30+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
31+ id := "1"
32+
33+ // list
34+ out := h.cli(t, adminToken, "run", "list", "proj")
35+ require.Empty(t, out.err, out.stderr)
36+ assert.Contains(t, out.stdout, "RESULT")
37+ assert.Contains(t, out.stdout, "failure")
38+ assert.Contains(t, out.stdout, "ci")
39+
40+ js := h.cli(t, adminToken, "run", "list", "proj", "--json")
41+ require.NoError(t, js.err)
42+ runs := decode[[]jsonRun](t, js.stdout)
43+ require.Len(t, runs, 1)
44+ assert.Equal(t, "failure", runs[0].Conclusion)
45+ assert.Equal(t, "completed", runs[0].Status)
46+ assert.Equal(t, "refs/heads/main", runs[0].Ref)
47+ assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
48+
49+ none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
50+ require.NoError(t, none.err)
51+ assert.Contains(t, none.stderr, "no runs")
52+ bad := h.cli(t, adminToken, "run", "list", "proj", "--status", "weird")
53+ require.Error(t, bad.err)
54+
55+ // view
56+ view := h.cli(t, adminToken, "run", "view", "proj", id)
57+ require.NoError(t, view.err, view.stderr)
58+ assert.Contains(t, view.stdout, "result")
59+ assert.Contains(t, view.stdout, "build")
60+ assert.Contains(t, view.stdout, "[alpha]")
61+ assert.Contains(t, view.stdout, "Set up job")
62+
63+ vjs := h.cli(t, adminToken, "run", "view", "proj", id, "--json")
64+ detail := decode[jsonRunDetail](t, vjs.stdout)
65+ require.Len(t, detail.Jobs, 2)
66+ assert.Equal(t, "build", detail.Jobs[0].Key)
67+ assert.Equal(t, "vm", detail.Jobs[0].Isolation)
68+ assert.Equal(t, int32(1), detail.LatestAttempt)
69+
70+ // --log, all jobs and one job
71+ all := h.cli(t, adminToken, "run", "view", "proj", id, "--log")
72+ require.NoError(t, all.err, all.stderr)
73+ assert.Contains(t, all.stdout, "== build ==")
74+ assert.Contains(t, all.stdout, "hello from the job")
75+ assert.Contains(t, all.stdout, "this one failed")
76+ one := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "test")
77+ require.NoError(t, one.err)
78+ assert.Contains(t, one.stdout, "this one failed")
79+ assert.NotContains(t, one.stdout, "hello from the job")
80+ missing := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "nope")
81+ require.Error(t, missing.err)
82+ assert.Contains(t, missing.err.Error(), `no job "nope"`)
83+
84+ // watch: a completed failing run prints once and exits with an error.
85+ w := h.cli(t, adminToken, "run", "watch", "proj", id)
86+ require.Error(t, w.err)
87+ assert.Contains(t, w.err.Error(), "failure")
88+ assert.Contains(t, w.stdout, "build success")
89+
90+ // rerun --failed, then cancel the new attempt.
91+ re := h.cli(t, adminToken, "run", "rerun", "proj", id, "--failed")
92+ require.NoError(t, re.err, re.stderr)
93+ assert.Contains(t, re.stderr, "attempt 2 started")
94+ ca := h.cli(t, adminToken, "run", "cancel", "proj", id, "--json")
95+ require.NoError(t, ca.err, ca.stderr)
96+ assert.Equal(t, int32(2), decode[jsonRun](t, ca.stdout).Attempt)
97+ // No runner holds a job of attempt 2, so the cancel ended it at once: a
98+ // completed run cannot be cancelled again.
99+ again := h.cli(t, adminToken, "run", "cancel", "proj", id)
100+ require.Error(t, again.err)
101+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(again.err))
102+
103+ // A reader may look but not cancel or rerun.
104+ reader := h.token(scopes(domain.ScopeRepoRead))
105+ assert.NoError(t, h.cli(t, reader, "run", "view", "proj", id).err)
106+ denied := h.cli(t, reader, "run", "cancel", "proj", id)
107+ require.Error(t, denied.err)
108+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(denied.err))
109+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "abc").err)
110+
111+ // runners
112+ rl := h.cli(t, adminToken, "runner", "list")
113+ require.NoError(t, rl.err, rl.stderr)
114+ assert.Contains(t, rl.stdout, "alpha")
115+ assert.Contains(t, rl.stdout, "vm")
116+ rjs := decode[[]jsonRunner](t, h.cli(t, adminToken, "runner", "list", "--json").stdout)
117+ require.Len(t, rjs, 1)
118+ assert.Equal(t, []string{"linux", "self-hosted", "ubuntu-latest", "x64"}, rjs[0].Labels)
119+ rm := h.cli(t, adminToken, "runner", "remove", rjs[0].ID)
120+ require.NoError(t, rm.err, rm.stderr)
121+ assert.Error(t, h.cli(t, adminToken, "runner", "remove", rjs[0].ID).err, "already removed")
122+ assert.Contains(t, h.cli(t, adminToken, "runner", "list").stderr, "no runners")
123+
124+ // The runner token preset makes a token that registers runners and nothing
125+ // else.
126+ tok := h.cli(t, adminToken, "token", "create", "runner host", "--preset", "runner", "--json")
127+ require.NoError(t, tok.err, tok.stderr)
128+ created := decode[jsonCreatedToken](t, tok.stdout)
129+ assert.Equal(t, []string{"runner:register"}, created.Scopes)
130+ assert.NoError(t, h.cli(t, created.Secret, "runner", "list").err)
131+ assert.Error(t, h.cli(t, created.Secret, "run", "cancel", "proj", id).err)
132+}
M internal/app/cli_token.go +1 -0
@@ -81,6 +81,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8181 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
8282 " cli every scope, for your own daily use\n" +
8383 " agent repo:write, mr:write\n" +
84+ " runner runner:register (a CI runner host)\n" +
8485 " ci repo:read\n" +
8586 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
8687 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
@@ -81,6 +81,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
81 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +81 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
82 " cli every scope, for your own daily use\n" +82 " cli every scope, for your own daily use\n" +
83 " agent repo:write, mr:write\n" +83 " agent repo:write, mr:write\n" +
84+ " runner runner:register (a CI runner host)\n" +
84 " ci repo:read\n" +85 " ci repo:read\n" +
85 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +86 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
86 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +87 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
M internal/app/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 // allFeatures are the features this client uses.16 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"}
18 18
19 func versionFake(features ...string) *fakeAPI {19 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)35 f := versionFake(allFeatures...)
36 url := f.serve(t)36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)37 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")39 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 40
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
43 }43 }
44 44
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners")
47 url := f.serve(t)47 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)48 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")49 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")51 assert.NotContains(t, res.stderr, "repo.update")
52 52
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 56
57 f = versionFake()57 f = versionFake()
M internal/app/root.go +1 -1
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
2828 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env),
3030 )
3131 return root
3232 }
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
26 root.AddCommand(26 root.AddCommand(
27 newServeCmd(), newHookCmd(),27 newServeCmd(), newHookCmd(),
28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env),
30 )30 )
31 return root31 return root
32 }32 }

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