detached 5f5b000

cli_workflow.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"strings"
8
9	"connectrpc.com/connect"
10	"github.com/spf13/cobra"
11
12	"bnema.dev/ermite-api/gen/ermite"
13)
14
15// `ermite workflow run`: start a workflow that has a workflow_dispatch trigger,
16// like `gh workflow run`. `workflow list|disable|enable` show and set the state
17// of the workflow files of a repository, like `gh workflow list|disable|enable`.
18
19func newWorkflowCmd(env *cliEnv) *cobra.Command {
20	wf := &cobra.Command{
21		Use:   "workflow",
22		Short: "Start, list, disable and enable workflows",
23	}
24	wf.AddCommand(newWorkflowRunCmd(env), newWorkflowListCmd(env), newWorkflowDisableCmd(env), newWorkflowEnableCmd(env))
25	return wf
26}
27
28// parseInputFlags reads the -f key=value flags. The first = separates the name
29// from the value, so a value may hold =; a repeated name takes the last value.
30func parseInputFlags(flags []string) (map[string]string, error) {
31	if len(flags) == 0 {
32		return nil, nil
33	}
34	out := make(map[string]string, len(flags))
35	for _, f := range flags {
36		k, v, ok := strings.Cut(f, "=")
37		if !ok || k == "" {
38			return nil, fmt.Errorf("invalid input %q: use -f name=value", f)
39		}
40		out[k] = v
41	}
42	return out, nil
43}
44
45func newWorkflowRunCmd(env *cliEnv) *cobra.Command {
46	var repo, ref string
47	var fields []string
48	cmd := &cobra.Command{
49		Use:   "run <workflow file> --repo <repo>",
50		Short: "Run a workflow that has a workflow_dispatch trigger",
51		Long: "Start a run of a workflow file on a branch or a tag (the default branch without --ref). The workflow is read at\n" +
52			"that ref and must have a workflow_dispatch trigger; -f name=value gives the inputs it declares (checked by type,\n" +
53			"required inputs and choices). The workflow file is its path or its file name. Needs the ci:write scope." + jsonRunHelp,
54		Example: "  ermite workflow run release.yml --repo app -f version=1.2.3\n" +
55			"  ermite workflow run .github/workflows/ci.yml --repo app --ref feature/x",
56		Args:              cobra.ExactArgs(1),
57		ValidArgsFunction: cobra.NoFileCompletions,
58	}
59	addRepoFlag(cmd, env, &repo)
60	cmd.Flags().StringVar(&ref, "ref", "", "the branch or tag to run on (default: the default branch)")
61	cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "an input of the workflow as name=value (repeatable)")
62	asJSON := addJSONFlag(cmd)
63	cmd.RunE = func(cmd *cobra.Command, args []string) error {
64		inputs, err := parseInputFlags(fields)
65		if err != nil {
66			return err
67		}
68		c, err := env.client(cmd)
69		if err != nil {
70			return err
71		}
72		res, err := c.Runs().DispatchWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DispatchWorkflowRequest{
73			Repo: repo, WorkflowPath: args[0], Ref: ref, Inputs: inputs,
74		}))
75		if err != nil {
76			return err
77		}
78		run := res.Msg.GetRun()
79		if *asJSON {
80			return writeJSON(cmd.OutOrStdout(), toJSONRun(run))
81		}
82		noticef(cmd, "run %d of %s started on %s", run.GetId(), run.GetWorkflowName(), refText(run.GetRef()))
83		return nil
84	}
85	return cmd
86}
87
88type jsonWorkflow struct {
89	Repo  string `json:"repo"`
90	File  string `json:"file"`
91	Name  string `json:"name"`
92	State string `json:"state"`
93}
94
95const jsonWorkflowHelp = `
96JSON fields of a workflow (--json): repo, file (the path, always .github/workflows/<file>), name (the
97"name:" of the workflow on the default branch, the path when it has none, empty when the default branch
98has no such file) and state (enabled or disabled).`
99
100func workflowStateText(s ermite.WorkflowState) string {
101	if s == ermite.WorkflowState_WORKFLOW_STATE_DISABLED {
102		return "disabled"
103	}
104	return "enabled"
105}
106
107func toJSONWorkflow(repo string, w *ermite.Workflow) jsonWorkflow {
108	return jsonWorkflow{Repo: repo, File: w.GetPath(), Name: w.GetName(), State: workflowStateText(w.GetState())}
109}
110
111func newWorkflowListCmd(env *cliEnv) *cobra.Command {
112	cmd := &cobra.Command{
113		Use:   "list <repo>",
114		Short: "List the workflows of a repository and whether they are enabled",
115		Long: "List the workflow files of .github/workflows on the default branch with their state, and the disabled\n" +
116			"workflows whose file is absent there (a disabled workflow is listed even in an empty repository), ordered\n" +
117			"by file. Needs the repo:read scope for a private repository." + jsonWorkflowHelp + "\n--json prints an array of workflows.",
118		Example:           "  ermite workflow list app\n  ermite workflow list app --json",
119		Args:              cobra.ExactArgs(1),
120		ValidArgsFunction: completeRepoNames(env),
121	}
122	asJSON := addJSONFlag(cmd)
123	cmd.RunE = func(cmd *cobra.Command, args []string) error {
124		c, err := env.client(cmd)
125		if err != nil {
126			return err
127		}
128		res, err := c.Runs().ListWorkflows(env.ctx(cmd), connect.NewRequest(&ermite.ListWorkflowsRequest{Repo: args[0]}))
129		if err != nil {
130			return c.Describe(err)
131		}
132		list := res.Msg.GetWorkflows()
133		if *asJSON {
134			out := make([]jsonWorkflow, 0, len(list))
135			for _, w := range list {
136				out = append(out, toJSONWorkflow(args[0], w))
137			}
138			return writeJSON(cmd.OutOrStdout(), out)
139		}
140		if len(list) == 0 {
141			noticef(cmd, "no workflows")
142			return nil
143		}
144		rows := make([][]string, 0, len(list))
145		for _, w := range list {
146			rows = append(rows, []string{w.GetPath(), w.GetName(), workflowStateText(w.GetState())})
147		}
148		return table(cmd.OutOrStdout(), []string{"FILE", "NAME", "STATE"}, rows)
149	}
150	return cmd
151}
152
153func newWorkflowDisableCmd(env *cliEnv) *cobra.Command {
154	cmd := &cobra.Command{
155		Use:   "disable <repo> <file>",
156		Short: "Stop a workflow from starting runs",
157		Long: "Disable a workflow file of a repository, like `gh workflow disable`: no event (push, pull_request,\n" +
158			"pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it any more.\n" +
159			"Runs already started go on. The state belongs to the repository and to the file path, not to a branch. The\n" +
160			"file is its path or its file name (release.yml) and does not have to exist: disable it right after\n" +
161			"`ermite repo create`, before the first push. Disabling a disabled workflow changes nothing. Undo with\n" +
162			"`ermite workflow enable`. Needs the repo:admin scope." + jsonWorkflowHelp,
163		Example:           "  ermite workflow disable app release.yml\n  ermite workflow disable app .github/workflows/codeql.yml",
164		Args:              cobra.ExactArgs(2),
165		ValidArgsFunction: completeRepoNames(env),
166	}
167	asJSON := addJSONFlag(cmd)
168	cmd.RunE = func(cmd *cobra.Command, args []string) error {
169		c, err := env.client(cmd)
170		if err != nil {
171			return err
172		}
173		res, err := c.Runs().DisableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DisableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
174		if err != nil {
175			return c.Describe(err)
176		}
177		return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "disabled")
178	}
179	return cmd
180}
181
182func newWorkflowEnableCmd(env *cliEnv) *cobra.Command {
183	cmd := &cobra.Command{
184		Use:   "enable <repo> <file>",
185		Short: "Let a disabled workflow start runs again",
186		Long: "Enable a workflow file of a repository, like `gh workflow enable`. The events that happened while it was\n" +
187			"disabled are not replayed. Enabling a workflow that is not disabled changes nothing. The file is its path\n" +
188			"or its file name. Needs the repo:admin scope." + jsonWorkflowHelp,
189		Example:           "  ermite workflow enable app release.yml",
190		Args:              cobra.ExactArgs(2),
191		ValidArgsFunction: completeRepoNames(env),
192	}
193	asJSON := addJSONFlag(cmd)
194	cmd.RunE = func(cmd *cobra.Command, args []string) error {
195		c, err := env.client(cmd)
196		if err != nil {
197			return err
198		}
199		res, err := c.Runs().EnableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.EnableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
200		if err != nil {
201			return c.Describe(err)
202		}
203		return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "enabled")
204	}
205	return cmd
206}
207
208func printWorkflowState(cmd *cobra.Command, asJSON bool, repo string, w *ermite.Workflow, state string) error {
209	if asJSON {
210		return writeJSON(cmd.OutOrStdout(), toJSONWorkflow(repo, w))
211	}
212	noticef(cmd, "%s %s in %s", state, clean(w.GetPath()), clean(repo))
213	return nil
214}

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 4

Tags 45

↑↓ moveenter openesc close