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.

616 linesCodeBlameRaw
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
4use serde_json::{Map, Value, json};
5use std::io::{IsTerminal, Read};
6use std::time::Duration;
7
8use crate::cli::{
9 WorkflowCommand, WorkflowListArgs, WorkflowRunArgs, WorkflowSelect, WorkflowViewArgs,
10};
11use crate::client::Client;
12use crate::context::Ctx;
13use crate::error::{Error, Result};
14use crate::output::{Cell, Output, ago, now, parse_time, s};
15use crate::prompt;
16use 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.
20const LOOKS: u32 = 10;
21
22pub 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.
33pub 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`.
38fn file_of(workflow: &Value) -> &str {
39 let path = s(workflow, "path");
40 path.rsplit('/').next().unwrap_or(path)
41}
42
43/// `CI (ci.yml)`.
44fn label(workflow: &Value) -> String {
45 format!("{} ({})", s(workflow, "name"), file_of(workflow))
46}
47
48fn 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.
57pub 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.
77fn 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
123fn active(workflow: &Value) -> bool {
124 s(workflow, "state") == "active"
125}
126
127fn 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
169fn 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
179fn 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`.
305fn 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.
326fn 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.
334fn 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.
354fn 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
383fn 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`.
502fn 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
528fn 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)]
572mod 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}