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}