| 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 | } |