Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

The CLI has g1t workflow: list shows the workflows in .g1t/workflows that are on (--all for those turned off) with their triggers and ids; view shows one with its state, triggers, manual-run inputs, notes and latest five runs, --yaml prints its file on the default branch or on --ref, and --web opens it; run runs a workflow_dispatch workflow on --ref with inputs from -f and -F (@FILE reads a file) or, with a bare --json, a JSON object on stdin, asks for each input at a terminal when none are given, and prints the run's address, looking for the run for a few seconds if the answer does not carry it; enable and disable turn one on and off. A workflow is named by its file name, id or name; left out, g1t asks at a terminal and otherwise exits 2 naming what to pass, and a name that matches none exits 1. The CLI guide has a Workflows section, the Actions guide points to it, and the command reference is regenerated.

syntaqxcommitted Parent464e21fBrowse files
8 files+1134−30/8 viewed
+3−1
12541254
12551255 Open a repository's **Actions** page, in its sidebar. Pick a workflow to
12561256 see its runs, run it by hand if it has `workflow_dispatch`, or turn it off
1257−without touching its file.
1257+without touching its file. From a terminal, [`g1t workflow`](/guides/cli/#workflows)
1258+does the same.
12581259
12591260 A run's page opens on its **Summary**. Its sidebar lists **All jobs**;
12601261 pick one to see its steps and their logs as they are written. Groups
19731974 | `workflow` action | Route |
19741975 | --- | --- |
19751976 | `list` | `GET /repos/{owner}/{repo}/actions/workflows` |
1977+| `get` | `GET /repos/{owner}/{repo}/actions/workflows/{workflow}`, with `ref`: the workflow, with `source`, its file on that branch or tag |
19761978 | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` |
19771979 | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` |
19781980 | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text |
+40−2
11 ---
22 title: The g1t CLI
3−description: Install the g1t command line, sign in, and work with repositories, issues, pull requests, runs, deployments, secrets and logs from a terminal, a script or an agent.
3+description: Install the g1t command line, sign in, and work with repositories, issues, pull requests, runs, workflows, deployments, secrets and logs from a terminal, a script or an agent.
44 ---
55
66 `g1t` is the command line for g1t. It does the work you would otherwise do on
248248 what you need to see why, without the rest of the log. `g1t run watch`
249249 exits `1` if the run fails.
250250
251+### Workflows
252+
253+`g1t workflow` works with the workflows in `.g1t/workflows`, as the
254+repository's **Actions** page does.
255+
256+```sh
257+g1t workflow list # those turned on; --all for every one
258+g1t workflow view ci.yml # its triggers, inputs and latest runs
259+g1t workflow view release.yml --yaml --ref v1.4.0
260+g1t workflow run release.yml -f channel=stable
261+g1t workflow disable Nightly
262+g1t workflow enable nightly.yml
263+```
264+
265+A workflow is named by its file name (`ci.yml`), its id (`wfl_…`) or its
266+name (`CI`, in any case). When more than one has that name, name it by its
267+file. Left out at a terminal, g1t asks which; in a script it is an error
268+that names what to pass.
269+
270+`g1t workflow run` runs a workflow that has `on: workflow_dispatch`, on the
271+default branch or the branch or tag `--ref` names, and prints its run's
272+address once the run is there, so `g1t run watch` can follow it. Give its
273+inputs with `-f key=value` (or `-F key=@file` to read a value from a file),
274+or as one JSON object on stdin with `--json`:
275+
276+```sh
277+echo '{"channel":"stable","dry_run":true}' | g1t workflow run release.yml --json
278+```
279+
280+At a terminal with no inputs given, g1t asks for each, offering its
281+default. Only the inputs the workflow declares are taken, as on the
282+[Actions page](/guides/actions/#runs-and-logs). `g1t workflow view --yaml`
283+prints the workflow's file as it is on the default branch, or on `--ref`.
284+Turning a workflow off keeps its file: nothing starts it until it is
285+turned on again, and a workflow the workspace requires stays on.
286+
251287 ### Deployments, logs and secrets
252288
253289 ```sh
285321 ## Scripting with JSON
286322
287323 Every command takes `--json`. Alone, it prints every field the API returns;
288−with a comma-separated list, only those:
324+with a comma-separated list, only those (`g1t workflow run` is the one
325+exception: there, `--json` alone reads the inputs from stdin):
289326
290327 ```sh
291328 g1t pr list --json number,title,check_status
387424 - Open a pull request: `g1t pr create --fill --issue <n>`
388425 - Wait for checks: `g1t pr checks --watch --fail-fast` (exit 0 passed, 1 failed)
389426 - See why a run failed: `g1t run logs --failed`
427+- Run a workflow by hand: `g1t workflow run <file> -f key=value`
390428 - Anything else: `g1t api --list <word>`, then `g1t api <operation>`
391429 ```
392430
+91−0
619619
620620 Calls [`cancel_workflow_run`](/reference/api/actions/cancel-workflow-run/).
621621
622+## g1t workflow
623+
624+Workflows in .g1t/workflows: list, view, run, enable, disable.
625+
626+### g1t workflow list
627+
628+List the workflows in .g1t/workflows, with whether each is on.
629+
630+```text
631+g1t workflow list [OPTIONS]
632+```
633+
634+| Argument or flag | |
635+| --- | --- |
636+| `-a, --all` | Include the workflows that are turned off. |
637+| `-L, --limit <LIMIT>` | Default: `50`. |
638+
639+Calls [`list_workflows`](/reference/api/actions/list-workflows/).
640+
641+### g1t workflow view
642+
643+Show a workflow and its latest runs; --yaml prints its file.
644+
645+```text
646+g1t workflow view [OPTIONS] [WORKFLOW]
647+```
648+
649+| Argument or flag | |
650+| --- | --- |
651+| `[WORKFLOW]` | Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out. |
652+| `-y, --yaml` | Print the workflow's file. |
653+| `-g, --ref <REF>` | The branch or tag to read the file on, with --yaml; the default branch when left out. |
654+| `-w, --web` | Open it in the browser. |
655+
656+Calls [`list_workflows`](/reference/api/actions/list-workflows/), [`get_workflow`](/reference/api/actions/get-workflow/), [`list_workflow_runs`](/reference/api/actions/list-workflow-runs/).
657+
658+### g1t workflow run
659+
660+Run a workflow that has `on: workflow_dispatch`, and print its run's address.
661+
662+```text
663+g1t workflow run [OPTIONS] [WORKFLOW]
664+```
665+
666+| Argument or flag | |
667+| --- | --- |
668+| `[WORKFLOW]` | Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out. |
669+| `-g, --ref <REF>` | The branch or tag to run it on; the default branch when left out. |
670+| `-f, --raw-field <KEY=VALUE>` | An input, key=value; repeat for more. |
671+| `-F, --field <KEY=VALUE>` | An input, key=value, where @FILE reads the value from a file (@- from stdin). |
672+
673+```text
674+Inputs are given with -f (or -F, where @FILE reads a file), or as a JSON object on
675+stdin with --json. At a terminal, with none given, g1t asks for each input.
676+
677+Examples:
678+ g1t workflow run release.yml
679+ g1t workflow run release.yml --ref v1.4.0 -f channel=stable -f dry_run=true
680+ echo '{"channel":"stable"}' | g1t workflow run Release --json
681+```
682+
683+Calls [`list_workflows`](/reference/api/actions/list-workflows/), [`dispatch_workflow`](/reference/api/actions/dispatch-workflow/), [`list_workflow_runs`](/reference/api/actions/list-workflow-runs/).
684+
685+### g1t workflow enable
686+
687+Turn a workflow on again.
688+
689+```text
690+g1t workflow enable [OPTIONS] [WORKFLOW]
691+```
692+
693+| Argument or flag | |
694+| --- | --- |
695+| `[WORKFLOW]` | Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out. |
696+
697+Calls [`list_workflows`](/reference/api/actions/list-workflows/), [`enable_workflow`](/reference/api/actions/enable-workflow/).
698+
699+### g1t workflow disable
700+
701+Turn a workflow off: its file stays, and nothing starts it until it is turned on.
702+
703+```text
704+g1t workflow disable [OPTIONS] [WORKFLOW]
705+```
706+
707+| Argument or flag | |
708+| --- | --- |
709+| `[WORKFLOW]` | Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out. |
710+
711+Calls [`list_workflows`](/reference/api/actions/list-workflows/), [`disable_workflow`](/reference/api/actions/disable-workflow/).
712+
622713 ## g1t secret
623714
624715 Secrets for workflows and deployments, a repository's or a workspace's.
+73−0
1212 g1t pr create --fill
1313 g1t pr checks --watch --fail-fast
1414 g1t run logs --failed
15+ g1t workflow run release.yml -f channel=stable
1516 g1t api list_issues --jq '.[].title'
1617
1718 Environment:
9192 /// Workflow runs: list, view, watch, logs, rerun, cancel.
9293 #[command(subcommand)]
9394 Run(RunCommand),
95+ /// Workflows in .g1t/workflows: list, view, run, enable, disable.
96+ #[command(subcommand)]
97+ Workflow(WorkflowCommand),
9498 /// Secrets for workflows and deployments, a repository's or a workspace's.
9599 #[command(subcommand)]
96100 Secret(SecretCommand),
668672 pub debug: bool,
669673 }
670674
675+// --- workflow ---------------------------------------------------------
676+
677+#[derive(Debug, Subcommand)]
678+pub enum WorkflowCommand {
679+ /// List the workflows in .g1t/workflows, with whether each is on.
680+ List(WorkflowListArgs),
681+ /// Show a workflow and its latest runs; --yaml prints its file.
682+ View(WorkflowViewArgs),
683+ /// Run a workflow that has `on: workflow_dispatch`, and print its run's address.
684+ #[command(after_help = "\
685+Inputs are given with -f (or -F, where @FILE reads a file), or as a JSON object on
686+stdin with --json. At a terminal, with none given, g1t asks for each input.
687+
688+Examples:
689+ g1t workflow run release.yml
690+ g1t workflow run release.yml --ref v1.4.0 -f channel=stable -f dry_run=true
691+ echo '{\"channel\":\"stable\"}' | g1t workflow run Release --json")]
692+ Run(WorkflowRunArgs),
693+ /// Turn a workflow on again.
694+ Enable(WorkflowSelect),
695+ /// Turn a workflow off: its file stays, and nothing starts it until it is turned on.
696+ Disable(WorkflowSelect),
697+}
698+
699+#[derive(Debug, Args)]
700+pub struct WorkflowSelect {
701+ /// Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out.
702+ pub workflow: Option<String>,
703+}
704+
705+#[derive(Debug, Args)]
706+pub struct WorkflowListArgs {
707+ /// Include the workflows that are turned off.
708+ #[arg(short, long)]
709+ pub all: bool,
710+ #[arg(short = 'L', long, default_value_t = 50)]
711+ pub limit: usize,
712+}
713+
714+#[derive(Debug, Args)]
715+pub struct WorkflowViewArgs {
716+ /// Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out.
717+ pub workflow: Option<String>,
718+ /// Print the workflow's file.
719+ #[arg(short, long)]
720+ pub yaml: bool,
721+ /// The branch or tag to read the file on, with --yaml; the default branch when left out.
722+ #[arg(short, long = "ref", value_name = "REF", requires = "yaml")]
723+ pub git_ref: Option<String>,
724+ /// Open it in the browser.
725+ #[arg(short, long, conflicts_with = "yaml")]
726+ pub web: bool,
727+}
728+
729+#[derive(Debug, Args)]
730+pub struct WorkflowRunArgs {
731+ /// Its file name (ci.yml), id (wfl_…) or name; asked for at a terminal when left out.
732+ pub workflow: Option<String>,
733+ /// The branch or tag to run it on; the default branch when left out.
734+ #[arg(short, long = "ref", value_name = "REF")]
735+ pub git_ref: Option<String>,
736+ /// An input, key=value; repeat for more.
737+ #[arg(short = 'f', long = "raw-field", value_name = "KEY=VALUE")]
738+ pub raw_field: Vec<String>,
739+ /// An input, key=value, where @FILE reads the value from a file (@- from stdin).
740+ #[arg(short = 'F', long = "field", value_name = "KEY=VALUE")]
741+ pub field: Vec<String>,
742+}
743+
671744 // --- secret -----------------------------------------------------------
672745
673746 #[derive(Debug, Subcommand)]
+2−0
1212 pub mod run;
1313 pub mod secret;
1414 pub mod status;
15+pub mod workflow;
1516
1617 use clap::CommandFactory;
1718
3637 Command::Issue(command) => issue::run(&Ctx::new(cli.global)?, command),
3738 Command::Pr(command) => pr::run(&Ctx::new(cli.global)?, command),
3839 Command::Run(command) => run::run(&Ctx::new(cli.global)?, command),
40+ Command::Workflow(command) => workflow::run(&Ctx::new(cli.global)?, command),
3941 Command::Secret(command) => secret::run(&Ctx::new(cli.global)?, command),
4042 Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command),
4143 Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args),
+616−0
1+//! `g1t workflow`: the workflows in `.g1t/workflows`: listing them, reading
2+//! one and its file, running one by hand, and turning one on or off.
3+
4+use serde_json::{Map, Value, json};
5+use std::io::{IsTerminal, Read};
6+use std::time::Duration;
7+
8+use crate::cli::{
9+ WorkflowCommand, WorkflowListArgs, WorkflowRunArgs, WorkflowSelect, WorkflowViewArgs,
10+};
11+use crate::client::Client;
12+use crate::context::Ctx;
13+use crate::error::{Error, Result};
14+use crate::output::{Cell, Output, ago, now, parse_time, s};
15+use crate::prompt;
16+use crate::repo::Repo;
17+
18+/// How many times `workflow run` looks for the run it started, a second
19+/// apart, when the answer does not carry it.
20+const LOOKS: u32 = 10;
21+
22+pub fn run(ctx: &Ctx, command: WorkflowCommand) -> Result<()> {
23+ match command {
24+ WorkflowCommand::List(args) => list(ctx, args),
25+ WorkflowCommand::View(args) => view(ctx, args),
26+ WorkflowCommand::Run(args) => dispatch(ctx, args),
27+ WorkflowCommand::Enable(args) => switch(ctx, args, true),
28+ WorkflowCommand::Disable(args) => switch(ctx, args, false),
29+ }
30+}
31+
32+/// The workflow's page: the Actions tab, showing its runs.
33+pub fn workflow_url(site: &str, repo: &Repo, id: &str) -> String {
34+ format!("{site}/{}/{}/actions?workflow={id}", repo.owner, repo.name)
35+}
36+
37+/// `ci.yml` for `.g1t/workflows/ci.yml`.
38+fn file_of(workflow: &Value) -> &str {
39+ let path = s(workflow, "path");
40+ path.rsplit('/').next().unwrap_or(path)
41+}
42+
43+/// `CI (ci.yml)`.
44+fn label(workflow: &Value) -> String {
45+ format!("{} ({})", s(workflow, "name"), file_of(workflow))
46+}
47+
48+fn all(client: &Client, repo: &Repo) -> Result<Vec<Value>> {
49+ Ok(super::items(
50+ client.op("list_workflows").repo(repo).send()?,
51+ "workflows",
52+ ))
53+}
54+
55+/// The workflows `given` names: by id or path, then file name, then name
56+/// (any case), the first way that finds any.
57+pub fn matching<'a>(workflows: &'a [Value], given: &str) -> Vec<&'a Value> {
58+ let given = given.trim();
59+ let file = given.trim_start_matches(".g1t/workflows/");
60+ let by = |test: &dyn Fn(&Value) -> bool| -> Vec<&'a Value> {
61+ workflows.iter().filter(|w| test(w)).collect()
62+ };
63+ for found in [
64+ by(&|w| s(w, "id") == given || s(w, "path") == given),
65+ by(&|w| file_of(w) == file),
66+ by(&|w| s(w, "name").eq_ignore_ascii_case(given)),
67+ ] {
68+ if !found.is_empty() {
69+ return found;
70+ }
71+ }
72+ Vec::new()
73+}
74+
75+/// The workflow the command is about: named, or picked at a terminal from
76+/// those `fits` keeps.
77+fn pick(
78+ client: &Client,
79+ repo: &Repo,
80+ given: Option<&str>,
81+ fits: impl Fn(&Value) -> bool,
82+ none: &str,
83+) -> Result<Value> {
84+ let workflows = all(client, repo)?;
85+ if let Some(given) = given {
86+ let found = matching(&workflows, given);
87+ return match found.as_slice() {
88+ [] => Err(Error::failed(format!(
89+ "There is no workflow {} in {repo}. `g1t workflow list --all` lists them.",
90+ given.trim()
91+ ))),
92+ [one] => Ok((*one).clone()),
93+ many if prompt::interactive() => {
94+ let options: Vec<String> = many.iter().map(|w| label(w)).collect();
95+ let i = prompt::select(
96+ &format!("More than one workflow is called {}. Which?", given.trim()),
97+ &options,
98+ )?;
99+ Ok(many[i].clone())
100+ }
101+ many => Err(Error::usage(format!(
102+ "More than one workflow is called {}: {}. Name it by its file.",
103+ given.trim(),
104+ many.iter()
105+ .map(|w| file_of(w))
106+ .collect::<Vec<_>>()
107+ .join(", ")
108+ ))),
109+ };
110+ }
111+ if !prompt::interactive() {
112+ return Err(prompt::needed("The workflow", "its file name, id or name"));
113+ }
114+ let options: Vec<&Value> = workflows.iter().filter(|w| fits(w)).collect();
115+ if options.is_empty() {
116+ return Err(Error::failed(format!("{repo} has no {none}.")));
117+ }
118+ let labels: Vec<String> = options.iter().map(|w| label(w)).collect();
119+ let i = prompt::select("Which workflow?", &labels)?;
120+ Ok(options[i].clone())
121+}
122+
123+fn active(workflow: &Value) -> bool {
124+ s(workflow, "state") == "active"
125+}
126+
127+fn list(ctx: &Ctx, args: WorkflowListArgs) -> Result<()> {
128+ let (repo, client) = ctx.repo_client()?;
129+ let mut workflows = all(&client, &repo)?;
130+ let off = workflows.iter().filter(|w| !active(w)).count();
131+ if !args.all {
132+ workflows.retain(active);
133+ }
134+ workflows.truncate(args.limit);
135+ if ctx.out.structured() {
136+ return ctx.out.emit(&Value::Array(workflows));
137+ }
138+ if workflows.is_empty() {
139+ if off > 0 {
140+ eprintln!("No workflows are on in {repo}; --all lists the {off} turned off.");
141+ } else {
142+ eprintln!(
143+ "No workflows in {repo}. They are read from .g1t/workflows on the default branch."
144+ );
145+ }
146+ return Ok(());
147+ }
148+ let out = &ctx.out;
149+ let rows: Vec<Vec<Cell>> = workflows
150+ .iter()
151+ .map(|w| {
152+ let state = if w["error"].is_string() && active(w) {
153+ "error"
154+ } else {
155+ s(w, "state")
156+ };
157+ vec![
158+ Cell::styled(s(w, "name"), out.bold(s(w, "name"))),
159+ Cell::styled(state, out.state(state)),
160+ Cell::new(file_of(w)),
161+ Cell::new(events(w)),
162+ Cell::styled(s(w, "id"), out.dim(s(w, "id"))),
163+ ]
164+ })
165+ .collect();
166+ out.table(&["name", "state", "file", "on", "id"], &rows)
167+}
168+
169+fn events(workflow: &Value) -> String {
170+ workflow["events"]
171+ .as_array()
172+ .into_iter()
173+ .flatten()
174+ .filter_map(Value::as_str)
175+ .collect::<Vec<_>>()
176+ .join(", ")
177+}
178+
179+fn view(ctx: &Ctx, args: WorkflowViewArgs) -> Result<()> {
180+ let (repo, client) = ctx.repo_client()?;
181+ let workflow = pick(
182+ &client,
183+ &repo,
184+ args.workflow.as_deref(),
185+ |_| true,
186+ "workflows",
187+ )?;
188+ let id = s(&workflow, "id");
189+ let url = workflow_url(&client.host.site, &repo, id);
190+ if args.web {
191+ return super::browse::open(ctx, &url);
192+ }
193+ let out = &ctx.out;
194+ if args.yaml {
195+ let detail = client
196+ .op("get_workflow")
197+ .repo(&repo)
198+ .arg("workflow", id)
199+ .query_opt("ref", args.git_ref.clone())
200+ .send()?;
201+ if out.structured() {
202+ return out.emit(&detail);
203+ }
204+ if out.tty {
205+ println!(
206+ "{} {}",
207+ out.bold(s(&detail, "name")),
208+ out.dim(&format!("· {} · {}", s(&detail, "path"), s(&detail, "ref")))
209+ );
210+ println!();
211+ }
212+ let source = s(&detail, "source");
213+ print!("{source}");
214+ if !source.ends_with('\n') {
215+ println!();
216+ }
217+ return Ok(());
218+ }
219+ let runs = super::items(
220+ client
221+ .op("list_workflow_runs")
222+ .repo(&repo)
223+ .query("workflow", id)
224+ .query("per_page", 5)
225+ .send()?,
226+ "workflow_runs",
227+ );
228+ let mut shown = workflow.clone();
229+ if let Some(map) = shown.as_object_mut() {
230+ map.insert("runs".into(), Value::Array(runs.clone()));
231+ map.insert("url".into(), Value::String(url.clone()));
232+ }
233+ if out.structured() {
234+ return out.emit(&shown);
235+ }
236+ println!(
237+ "{} {}",
238+ out.bold(s(&workflow, "name")),
239+ out.dim(s(&workflow, "path"))
240+ );
241+ let mut facts = vec![out.state(s(&workflow, "state"))];
242+ if !events(&workflow).is_empty() {
243+ facts.push(format!("on {}", events(&workflow)));
244+ }
245+ facts.push(out.dim(id));
246+ println!("{}", facts.join(" · "));
247+ if let Some(error) = workflow["error"].as_str() {
248+ println!("{} {error}", out.red("Error:"));
249+ }
250+ if let Some(inputs) = workflow["dispatch"].as_object() {
251+ println!();
252+ if inputs.is_empty() {
253+ println!("Runs by hand, with no inputs.");
254+ } else {
255+ println!("Runs by hand, with inputs:");
256+ for (name, spec) in inputs {
257+ println!(" {} {}", out.bold(name), out.dim(&describe(spec)));
258+ }
259+ }
260+ }
261+ for note in workflow["notes"].as_array().into_iter().flatten() {
262+ println!("{} {}", out.yellow("Note:"), s(note, "message"));
263+ }
264+ println!();
265+ if runs.is_empty() {
266+ println!("No runs yet.");
267+ } else {
268+ println!("{}", out.bold("Latest runs"));
269+ let rows: Vec<Vec<Cell>> = runs
270+ .iter()
271+ .map(|r| {
272+ let st = match s(r, "status") {
273+ "completed" => r["conclusion"].as_str().unwrap_or("completed"),
274+ other => other,
275+ };
276+ let branch = s(r, "ref")
277+ .trim_start_matches("refs/heads/")
278+ .trim_start_matches("refs/tags/")
279+ .to_owned();
280+ vec![
281+ Cell::styled(st, out.state(st)),
282+ Cell::new(format!("#{} {}", r["number"], s(r, "title"))),
283+ Cell::styled(branch.clone(), out.cyan(&branch)),
284+ Cell::new(s(r, "event")),
285+ Cell::styled(s(r, "id"), out.dim(s(r, "id"))),
286+ Cell::styled(ago(s(r, "created_at")), out.dim(&ago(s(r, "created_at")))),
287+ ]
288+ })
289+ .collect();
290+ out.table(&["state", "run", "branch", "event", "id", "age"], &rows)?;
291+ }
292+ println!();
293+ println!(
294+ "{}",
295+ out.dim(&format!(
296+ "More runs: g1t run list --workflow {}",
297+ file_of(&workflow)
298+ ))
299+ );
300+ println!("{}", out.dim(&url));
301+ Ok(())
302+}
303+
304+/// `(choice: beta, stable; default beta; required) What it is for`.
305+fn describe(spec: &Value) -> String {
306+ let mut parts = vec![spec["type"].as_str().unwrap_or("string").to_owned()];
307+ if let Some(options) = spec["options"].as_array() {
308+ let options: Vec<String> = options.iter().map(text).collect();
309+ parts[0] = format!("{}: {}", parts[0], options.join(", "));
310+ }
311+ if let Some(default) = spec.get("default").filter(|d| !d.is_null()) {
312+ parts.push(format!("default {}", text(default)));
313+ }
314+ if spec["required"] == true {
315+ parts.push("required".to_owned());
316+ }
317+ let mut line = format!("({})", parts.join("; "));
318+ if let Some(about) = spec["description"].as_str().filter(|d| !d.is_empty()) {
319+ line.push(' ');
320+ line.push_str(about);
321+ }
322+ line
323+}
324+
325+/// A value as text: strings without their quotes.
326+fn text(value: &Value) -> String {
327+ match value {
328+ Value::String(t) => t.clone(),
329+ other => other.to_string(),
330+ }
331+}
332+
333+/// `key=value` into `inputs`; `files` reads `@FILE` values.
334+fn put(inputs: &mut Map<String, Value>, pair: &str, files: bool) -> Result<()> {
335+ let (key, value) = pair
336+ .split_once('=')
337+ .filter(|(k, _)| !k.trim().is_empty())
338+ .ok_or_else(|| Error::usage(format!("`{pair}` is not key=value.")))?;
339+ let value = match value.strip_prefix('@').filter(|_| files) {
340+ Some("-") => {
341+ let mut text = String::new();
342+ std::io::stdin().read_to_string(&mut text)?;
343+ text
344+ }
345+ Some(path) => std::fs::read_to_string(path)
346+ .map_err(|e| Error::usage(format!("Could not read {path}: {e}")))?,
347+ None => value.to_owned(),
348+ };
349+ inputs.insert(key.trim().to_owned(), Value::String(value));
350+ Ok(())
351+}
352+
353+/// Asks for each of the workflow's inputs, offering its default.
354+fn ask(spec: &Map<String, Value>) -> Result<Map<String, Value>> {
355+ let mut inputs = Map::new();
356+ for (name, input) in spec {
357+ let default = input.get("default").filter(|d| !d.is_null()).map(text);
358+ let options: Vec<String> = input["options"]
359+ .as_array()
360+ .map(|o| o.iter().map(text).collect())
361+ .unwrap_or_default();
362+ let value = if !options.is_empty() {
363+ // The default first, so taking the first is taking it.
364+ let mut ordered = options.clone();
365+ if let Some(d) = &default
366+ && let Some(at) = ordered.iter().position(|o| o == d)
367+ {
368+ let first = ordered.remove(at);
369+ ordered.insert(0, first);
370+ }
371+ let i = prompt::select(&format!("{name} {}", describe(input)), &ordered)?;
372+ ordered[i].clone()
373+ } else {
374+ prompt::input(&format!("{name} {}", describe(input)), default.as_deref())?
375+ };
376+ if !value.is_empty() {
377+ inputs.insert(name.clone(), Value::String(value));
378+ }
379+ }
380+ Ok(inputs)
381+}
382+
383+fn dispatch(ctx: &Ctx, args: WorkflowRunArgs) -> Result<()> {
384+ // A bare --json is the inputs on stdin, as a JSON object; --json FIELDS
385+ // and --jq still shape what is printed.
386+ let from_stdin = ctx.out.json.as_ref().is_some_and(Vec::is_empty) && ctx.out.jq.is_none();
387+ let out = if from_stdin {
388+ Output {
389+ json: None,
390+ ..ctx.out.clone()
391+ }
392+ } else {
393+ ctx.out.clone()
394+ };
395+ let mut inputs = Map::new();
396+ if from_stdin {
397+ if !args.raw_field.is_empty() || !args.field.is_empty() {
398+ return Err(Error::usage(
399+ "--json reads every input from stdin: leave out -f and -F.",
400+ ));
401+ }
402+ if std::io::stdin().is_terminal() {
403+ return Err(Error::usage(
404+ "--json reads the inputs from stdin, as a JSON object such as {\"channel\":\"stable\"}.",
405+ ));
406+ }
407+ let mut text = String::new();
408+ std::io::stdin().read_to_string(&mut text)?;
409+ inputs = match serde_json::from_str::<Value>(&text) {
410+ Ok(Value::Object(map)) => map,
411+ _ => {
412+ return Err(Error::usage(
413+ "The inputs on stdin must be a JSON object, such as {\"channel\":\"stable\"}.",
414+ ));
415+ }
416+ };
417+ }
418+ for pair in &args.raw_field {
419+ put(&mut inputs, pair, false)?;
420+ }
421+ for pair in &args.field {
422+ put(&mut inputs, pair, true)?;
423+ }
424+ let (repo, client) = ctx.repo_client()?;
425+ let workflow = pick(
426+ &client,
427+ &repo,
428+ args.workflow.as_deref(),
429+ |w| active(w) && w["dispatch"].is_object(),
430+ "workflows that can be run by hand (`on: workflow_dispatch`)",
431+ )?;
432+ let asked = !from_stdin && args.raw_field.is_empty() && args.field.is_empty();
433+ if asked
434+ && prompt::interactive()
435+ && let Some(spec) = workflow["dispatch"].as_object()
436+ && !spec.is_empty()
437+ {
438+ inputs = ask(spec)?;
439+ }
440+ let id = s(&workflow, "id").to_owned();
441+ let since = now();
442+ let started = client
443+ .op("dispatch_workflow")
444+ .repo(&repo)
445+ .arg("workflow", &id)
446+ .field_opt("ref", args.git_ref.clone())
447+ .field("inputs", Value::Object(inputs))
448+ .send()?;
449+ let on = args.git_ref.clone().unwrap_or_else(|| {
450+ let r = s(&started, "ref");
451+ if r.is_empty() {
452+ "the default branch".to_owned()
453+ } else {
454+ r.trim_start_matches("refs/heads/")
455+ .trim_start_matches("refs/tags/")
456+ .to_owned()
457+ }
458+ });
459+ let run = if started["id"].is_string() {
460+ Some(started)
461+ } else {
462+ look_for_run(&client, &repo, &id, since)?
463+ };
464+ let Some(mut run) = run else {
465+ if out.structured() {
466+ return out.emit(&json!({ "workflow": workflow, "run": null }));
467+ }
468+ eprintln!(
469+ "{} Asked for a run of {} on {on}. It has not appeared yet: `g1t run list --workflow {}` lists its runs.",
470+ out.green("✓"),
471+ label(&workflow),
472+ file_of(&workflow)
473+ );
474+ return Ok(());
475+ };
476+ let run_id = s(&run, "id").to_owned();
477+ let url = super::run::run_url(&client.host.site, &repo, &run_id);
478+ if let Some(map) = run.as_object_mut() {
479+ map.insert("url".into(), Value::String(url.clone()));
480+ }
481+ if out.structured() {
482+ return out.emit(&run);
483+ }
484+ let number = run["number"]
485+ .as_u64()
486+ .map(|n| format!(" #{n}"))
487+ .unwrap_or_default();
488+ eprintln!(
489+ "{} Started {}{number} on {on}",
490+ out.green("✓"),
491+ label(&workflow)
492+ );
493+ println!("{url}");
494+ if out.tty {
495+ eprintln!("{}", out.dim(&format!("Follow it: g1t run watch {run_id}")));
496+ }
497+ Ok(())
498+}
499+
500+/// The run a dispatch made, once it shows: the workflow's newest
501+/// `workflow_dispatch` run made since `since`.
502+fn look_for_run(client: &Client, repo: &Repo, workflow: &str, since: i64) -> Result<Option<Value>> {
503+ for look in 0..LOOKS {
504+ if look > 0 {
505+ std::thread::sleep(Duration::from_secs(1));
506+ }
507+ let runs = super::items(
508+ client
509+ .op("list_workflow_runs")
510+ .repo(repo)
511+ .query("workflow", workflow)
512+ .query("event", "workflow_dispatch")
513+ .query("per_page", 1)
514+ .send()?,
515+ "workflow_runs",
516+ );
517+ // A few seconds' grace for the server's clock and this one's.
518+ if let Some(run) = runs
519+ .into_iter()
520+ .find(|r| parse_time(s(r, "created_at")).is_some_and(|t| t >= since - 5))
521+ {
522+ return Ok(Some(run));
523+ }
524+ }
525+ Ok(None)
526+}
527+
528+fn switch(ctx: &Ctx, args: WorkflowSelect, on: bool) -> Result<()> {
529+ let (repo, client) = ctx.repo_client()?;
530+ let workflow = if on {
531+ pick(
532+ &client,
533+ &repo,
534+ args.workflow.as_deref(),
535+ |w| !active(w),
536+ "workflows turned off",
537+ )?
538+ } else {
539+ pick(
540+ &client,
541+ &repo,
542+ args.workflow.as_deref(),
543+ active,
544+ "workflows turned on",
545+ )?
546+ };
547+ let id = s(&workflow, "id");
548+ let call = if on {
549+ client.op("enable_workflow")
550+ } else {
551+ client.op("disable_workflow")
552+ };
553+ let changed = call.repo(&repo).arg("workflow", id).send()?;
554+ let shown = if changed.is_object() {
555+ changed
556+ } else {
557+ workflow.clone()
558+ };
559+ super::issue::done(
560+ ctx,
561+ &shown,
562+ &format!(
563+ "Turned {} {}",
564+ label(&workflow),
565+ if on { "on" } else { "off" }
566+ ),
567+ &workflow_url(&client.host.site, &repo, id),
568+ )
569+}
570+
571+#[cfg(test)]
572+mod tests {
573+ use super::*;
574+
575+ fn workflows() -> Vec<Value> {
576+ vec![
577+ json!({ "id": "wfl_1", "path": ".g1t/workflows/ci.yml", "name": "CI", "state": "active" }),
578+ json!({ "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "state": "active" }),
579+ json!({ "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "ci", "state": "disabled" }),
580+ ]
581+ }
582+
583+ #[test]
584+ fn workflows_are_named_by_id_file_path_or_name() {
585+ let all = workflows();
586+ let ids = |given: &str| -> Vec<&str> {
587+ matching(&all, given).iter().map(|w| s(w, "id")).collect()
588+ };
589+ assert_eq!(ids("wfl_2"), ["wfl_2"]);
590+ assert_eq!(ids("release.yml"), ["wfl_2"]);
591+ assert_eq!(ids(".g1t/workflows/release.yml"), ["wfl_2"]);
592+ assert_eq!(ids("release"), ["wfl_2"]);
593+ // A file name wins over a name, and names match in any case.
594+ assert_eq!(ids("ci.yml"), ["wfl_1"]);
595+ assert_eq!(ids("CI"), ["wfl_1", "wfl_3"]);
596+ assert!(ids("deploy.yml").is_empty());
597+ }
598+
599+ #[test]
600+ fn inputs_are_described_with_their_options_and_default() {
601+ let spec = json!({ "type": "choice", "options": ["beta", "stable"], "default": "beta", "required": true, "description": "Where it goes" });
602+ assert_eq!(
603+ describe(&spec),
604+ "(choice: beta, stable; default beta; required) Where it goes"
605+ );
606+ }
607+
608+ #[test]
609+ fn fields_must_be_key_value() {
610+ let mut inputs = Map::new();
611+ put(&mut inputs, "channel=stable=1", false).unwrap();
612+ assert_eq!(inputs["channel"], "stable=1");
613+ assert!(put(&mut inputs, "channel", false).is_err());
614+ assert!(put(&mut inputs, "=x", false).is_err());
615+ }
616+}
+21−0
172172 ops: &["cancel_workflow_run"],
173173 },
174174 Mapping {
175+ command: "workflow list",
176+ ops: &["list_workflows"],
177+ },
178+ Mapping {
179+ command: "workflow view",
180+ ops: &["list_workflows", "get_workflow", "list_workflow_runs"],
181+ },
182+ Mapping {
183+ command: "workflow run",
184+ ops: &["list_workflows", "dispatch_workflow", "list_workflow_runs"],
185+ },
186+ Mapping {
187+ command: "workflow enable",
188+ ops: &["list_workflows", "enable_workflow"],
189+ },
190+ Mapping {
191+ command: "workflow disable",
192+ ops: &["list_workflows", "disable_workflow"],
193+ },
194+ Mapping {
175195 command: "secret list",
176196 ops: &["list_actions_secrets", "list_actions_secrets_for_workspace"],
177197 },
314334 ("issue", include_str!("commands/issue.rs")),
315335 ("pr", include_str!("commands/pr.rs")),
316336 ("run", include_str!("commands/run.rs")),
337+ ("workflow", include_str!("commands/workflow.rs")),
317338 ("secret", include_str!("commands/secret.rs")),
318339 ("deploy", include_str!("commands/deploy.rs")),
319340 ("logs", include_str!("commands/logs.rs")),
+288−0
892892 assert_eq!(out.code, 2);
893893 assert!(out.stderr.contains("--repo"), "{}", out.stderr);
894894 }
895+
896+fn workflows_mock(dispatched: Value) -> Mock {
897+ Mock::start(move |r| match (r.method.as_str(), r.path()) {
898+ ("GET", "/repos/acme/web/actions/workflows") => (
899+ 200,
900+ json!([
901+ { "id": "wfl_1", "path": ".g1t/workflows/ci.yml", "name": "CI", "events": ["push", "pull_request"],
902+ "state": "active", "error": null, "notes": [], "dispatch": null, "last_run": null },
903+ { "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "events": ["workflow_dispatch"],
904+ "state": "active", "error": null, "notes": [],
905+ "dispatch": { "channel": { "type": "choice", "options": ["beta", "stable"], "default": "beta" } }, "last_run": null },
906+ { "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly", "events": ["schedule"],
907+ "state": "disabled", "error": null, "notes": [], "dispatch": null, "last_run": null }
908+ ]),
909+ ),
910+ ("GET", "/repos/acme/web/actions/workflows/wfl_2") => (
911+ 200,
912+ json!({ "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "state": "active",
913+ "ref": "v1.4.0", "source": "name: Release\non: workflow_dispatch\n" }),
914+ ),
915+ ("GET", "/repos/acme/web/actions/runs") => (
916+ 200,
917+ json!([{ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "title": "Release run by ana", "number": 4,
918+ "event": "workflow_dispatch", "ref": "refs/heads/main", "status": "queued", "conclusion": null,
919+ "created_at": "2999-01-01T00:00:00Z" }]),
920+ ),
921+ ("POST", "/repos/acme/web/actions/workflows/wfl_2/dispatches") => (200, dispatched.clone()),
922+ ("PUT", p) if p.ends_with("/enable") || p.ends_with("/disable") => (
923+ 200,
924+ json!({ "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly",
925+ "state": if p.ends_with("/enable") { "active" } else { "disabled" } }),
926+ ),
927+ _ => (404, json!({ "error": { "message": "No such endpoint." } })),
928+ })
929+}
930+
931+fn release_run() -> Value {
932+ json!({ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "number": 4, "event": "workflow_dispatch",
933+ "ref": "refs/tags/v1.4.0", "status": "queued" })
934+}
935+
936+#[test]
937+fn workflow_list_shows_those_turned_on_unless_all() {
938+ let mock = workflows_mock(json!({}));
939+ let out = run(&mock, &["workflow", "list", "-R", "acme/web"]);
940+ assert_eq!(out.code, 0, "{}", out.stderr);
941+ let names: Vec<&str> = out
942+ .stdout
943+ .lines()
944+ .map(|l| l.split('\t').next().unwrap())
945+ .collect();
946+ assert_eq!(names, ["CI", "Release"]);
947+ assert_eq!(
948+ out.stdout.lines().next().unwrap(),
949+ "CI\tactive\tci.yml\tpush, pull_request\twfl_1"
950+ );
951+ let out = run(
952+ &mock,
953+ &[
954+ "workflow", "list", "--all", "-R", "acme/web", "--jq", ".[].path",
955+ ],
956+ );
957+ assert_eq!(out.code, 0, "{}", out.stderr);
958+ assert_eq!(out.stdout.lines().count(), 3);
959+}
960+
961+#[test]
962+fn workflow_view_takes_a_name_and_shows_its_latest_runs() {
963+ let mock = workflows_mock(json!({}));
964+ let out = run(
965+ &mock,
966+ &[
967+ "workflow",
968+ "view",
969+ "release",
970+ "-R",
971+ "acme/web",
972+ "--json",
973+ "id,runs,url",
974+ ],
975+ );
976+ assert_eq!(out.code, 0, "{}", out.stderr);
977+ let shown: Value = serde_json::from_str(&out.stdout).unwrap();
978+ assert_eq!(shown["id"], "wfl_2");
979+ assert_eq!(shown["runs"][0]["id"], "run_9");
980+ assert_eq!(
981+ shown["url"],
982+ "https://g1t.sh/acme/web/actions?workflow=wfl_2"
983+ );
984+ let runs = mock
985+ .requests()
986+ .into_iter()
987+ .find(|r| r.path() == "/repos/acme/web/actions/runs")
988+ .unwrap();
989+ assert_eq!(
990+ runs.target,
991+ "/repos/acme/web/actions/runs?workflow=wfl_2&per_page=5"
992+ );
993+ // Its file, on a tag: only the file when piped.
994+ let out = run(
995+ &mock,
996+ &[
997+ "workflow",
998+ "view",
999+ "release.yml",
1000+ "--yaml",
1001+ "--ref",
1002+ "v1.4.0",
1003+ "-R",
1004+ "acme/web",
1005+ ],
1006+ );
1007+ assert_eq!(out.code, 0, "{}", out.stderr);
1008+ assert_eq!(out.stdout, "name: Release\non: workflow_dispatch\n");
1009+ assert!(
1010+ mock.requests()
1011+ .iter()
1012+ .any(|r| r.target == "/repos/acme/web/actions/workflows/wfl_2?ref=v1.4.0")
1013+ );
1014+ // --ref is for --yaml.
1015+ let out = run(
1016+ &mock,
1017+ &[
1018+ "workflow", "view", "ci.yml", "--ref", "main", "-R", "acme/web",
1019+ ],
1020+ );
1021+ assert_eq!(out.code, 2);
1022+}
1023+
1024+#[test]
1025+fn workflow_run_sends_the_ref_and_inputs_and_prints_the_run() {
1026+ let mock = workflows_mock(release_run());
1027+ let out = run(
1028+ &mock,
1029+ &[
1030+ "workflow",
1031+ "run",
1032+ "Release",
1033+ "--ref",
1034+ "v1.4.0",
1035+ "-f",
1036+ "channel=stable",
1037+ "-R",
1038+ "acme/web",
1039+ ],
1040+ );
1041+ assert_eq!(out.code, 0, "{}", out.stderr);
1042+ assert_eq!(
1043+ out.stdout.trim(),
1044+ "https://g1t.sh/acme/web/actions/runs/run_9"
1045+ );
1046+ assert!(
1047+ out.stderr
1048+ .contains("Started Release (release.yml) #4 on v1.4.0"),
1049+ "{}",
1050+ out.stderr
1051+ );
1052+ let sent = mock
1053+ .requests()
1054+ .into_iter()
1055+ .find(|r| r.method == "POST")
1056+ .unwrap();
1057+ assert_eq!(
1058+ sent.json(),
1059+ json!({ "ref": "v1.4.0", "inputs": { "channel": "stable" } })
1060+ );
1061+}
1062+
1063+#[test]
1064+fn workflow_run_json_reads_the_inputs_from_stdin() {
1065+ let mock = workflows_mock(release_run());
1066+ let dir = temp_dir("dispatch");
1067+ let out = g1t(
1068+ &mock,
1069+ &dir,
1070+ &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1071+ Some("{\"channel\":\"beta\",\"dry_run\":true}"),
1072+ Some("g1t_test"),
1073+ );
1074+ assert_eq!(out.code, 0, "{}", out.stderr);
1075+ assert_eq!(
1076+ out.stdout.trim(),
1077+ "https://g1t.sh/acme/web/actions/runs/run_9"
1078+ );
1079+ let sent = mock
1080+ .requests()
1081+ .into_iter()
1082+ .find(|r| r.method == "POST")
1083+ .unwrap();
1084+ assert_eq!(
1085+ sent.json(),
1086+ json!({ "inputs": { "channel": "beta", "dry_run": true } })
1087+ );
1088+ // Not an object, or with -f as well, is using it wrongly.
1089+ let out = g1t(
1090+ &mock,
1091+ &dir,
1092+ &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1093+ Some("[1]"),
1094+ Some("g1t_test"),
1095+ );
1096+ assert_eq!(out.code, 2);
1097+ let out = g1t(
1098+ &mock,
1099+ &dir,
1100+ &[
1101+ "workflow",
1102+ "run",
1103+ "release.yml",
1104+ "--json",
1105+ "-f",
1106+ "a=b",
1107+ "-R",
1108+ "acme/web",
1109+ ],
1110+ Some("{}"),
1111+ Some("g1t_test"),
1112+ );
1113+ assert_eq!(out.code, 2);
1114+}
1115+
1116+#[test]
1117+fn workflow_run_looks_for_the_run_when_the_answer_lacks_it() {
1118+ let mock = workflows_mock(json!({}));
1119+ let out = run(
1120+ &mock,
1121+ &["workflow", "run", "wfl_2", "-R", "acme/web", "--jq", ".id"],
1122+ );
1123+ assert_eq!(out.code, 0, "{}", out.stderr);
1124+ assert_eq!(out.stdout, "run_9\n");
1125+ assert!(mock.requests().iter().any(|r| r.target
1126+ == "/repos/acme/web/actions/runs?workflow=wfl_2&event=workflow_dispatch&per_page=1"));
1127+}
1128+
1129+#[test]
1130+fn workflow_enable_and_disable_put_its_state() {
1131+ let mock = workflows_mock(json!({}));
1132+ let out = run(
1133+ &mock,
1134+ &["workflow", "enable", "nightly.yml", "-R", "acme/web"],
1135+ );
1136+ assert_eq!(out.code, 0, "{}", out.stderr);
1137+ assert!(
1138+ out.stderr.contains("Turned Nightly (nightly.yml) on"),
1139+ "{}",
1140+ out.stderr
1141+ );
1142+ let out = run(
1143+ &mock,
1144+ &[
1145+ "workflow", "disable", "Nightly", "-R", "acme/web", "--json", "state",
1146+ ],
1147+ );
1148+ assert_eq!(out.stdout, "{\"state\":\"disabled\"}\n");
1149+ let puts: Vec<String> = mock
1150+ .requests()
1151+ .into_iter()
1152+ .filter(|r| r.method == "PUT")
1153+ .map(|r| r.path().to_owned())
1154+ .collect();
1155+ assert_eq!(
1156+ puts,
1157+ [
1158+ "/repos/acme/web/actions/workflows/wfl_3/enable",
1159+ "/repos/acme/web/actions/workflows/wfl_3/disable"
1160+ ]
1161+ );
1162+}
1163+
1164+#[test]
1165+fn a_workflow_that_is_not_there_or_not_named_fails_without_asking() {
1166+ let mock = workflows_mock(json!({}));
1167+ let out = run(&mock, &["workflow", "view", "deploy.yml", "-R", "acme/web"]);
1168+ assert_eq!(out.code, 1);
1169+ assert!(
1170+ out.stderr
1171+ .contains("There is no workflow deploy.yml in acme/web"),
1172+ "{}",
1173+ out.stderr
1174+ );
1175+ let out = run(&mock, &["workflow", "run", "-R", "acme/web"]);
1176+ assert_eq!(out.code, 2);
1177+ assert!(
1178+ out.stderr.contains("The workflow is needed"),
1179+ "{}",
1180+ out.stderr
1181+ );
1182+}

Comments

Sign in to comment.