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.
8 files+1134−30/8 viewed
| 1254 | 1254 | ||
| 1255 | 1255 | Open a repository's **Actions** page, in its sidebar. Pick a workflow to | |
| 1256 | 1256 | 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. | |
| 1258 | 1259 | ||
| 1259 | 1260 | A run's page opens on its **Summary**. Its sidebar lists **All jobs**; | |
| 1260 | 1261 | pick one to see its steps and their logs as they are written. Groups | |
| ⋯ | |||
| 1973 | 1974 | | `workflow` action | Route | | |
| 1974 | 1975 | | --- | --- | | |
| 1975 | 1976 | | `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 | | |
| 1976 | 1978 | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 1977 | 1979 | | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 1978 | 1980 | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text | | |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | `g1t` is the command line for g1t. It does the work you would otherwise do on | |
| ⋯ | |||
| 248 | 248 | what you need to see why, without the rest of the log. `g1t run watch` | |
| 249 | 249 | exits `1` if the run fails. | |
| 250 | 250 | ||
| 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 | + | ||
| 251 | 287 | ### Deployments, logs and secrets | |
| 252 | 288 | ||
| 253 | 289 | ```sh | |
| ⋯ | |||
| 285 | 321 | ## Scripting with JSON | |
| 286 | 322 | ||
| 287 | 323 | 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): | |
| 289 | 326 | ||
| 290 | 327 | ```sh | |
| 291 | 328 | g1t pr list --json number,title,check_status | |
| ⋯ | |||
| 387 | 424 | - Open a pull request: `g1t pr create --fill --issue <n>` | |
| 388 | 425 | - Wait for checks: `g1t pr checks --watch --fail-fast` (exit 0 passed, 1 failed) | |
| 389 | 426 | - See why a run failed: `g1t run logs --failed` | |
| 427 | + | - Run a workflow by hand: `g1t workflow run <file> -f key=value` | |
| 390 | 428 | - Anything else: `g1t api --list <word>`, then `g1t api <operation>` | |
| 391 | 429 | ``` | |
| 392 | 430 | ||
| 619 | 619 | ||
| 620 | 620 | Calls [`cancel_workflow_run`](/reference/api/actions/cancel-workflow-run/). | |
| 621 | 621 | ||
| 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 | + | ||
| 622 | 713 | ## g1t secret | |
| 623 | 714 | ||
| 624 | 715 | Secrets for workflows and deployments, a repository's or a workspace's. |
| 12 | 12 | g1t pr create --fill | |
| 13 | 13 | g1t pr checks --watch --fail-fast | |
| 14 | 14 | g1t run logs --failed | |
| 15 | + | g1t workflow run release.yml -f channel=stable | |
| 15 | 16 | g1t api list_issues --jq '.[].title' | |
| 16 | 17 | ||
| 17 | 18 | Environment: | |
| ⋯ | |||
| 91 | 92 | /// Workflow runs: list, view, watch, logs, rerun, cancel. | |
| 92 | 93 | #[command(subcommand)] | |
| 93 | 94 | Run(RunCommand), | |
| 95 | + | /// Workflows in .g1t/workflows: list, view, run, enable, disable. | |
| 96 | + | #[command(subcommand)] | |
| 97 | + | Workflow(WorkflowCommand), | |
| 94 | 98 | /// Secrets for workflows and deployments, a repository's or a workspace's. | |
| 95 | 99 | #[command(subcommand)] | |
| 96 | 100 | Secret(SecretCommand), | |
| ⋯ | |||
| 668 | 672 | pub debug: bool, | |
| 669 | 673 | } | |
| 670 | 674 | ||
| 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 | + | ||
| 671 | 744 | // --- secret ----------------------------------------------------------- | |
| 672 | 745 | ||
| 673 | 746 | #[derive(Debug, Subcommand)] | |
| 12 | 12 | pub mod run; | |
| 13 | 13 | pub mod secret; | |
| 14 | 14 | pub mod status; | |
| 15 | + | pub mod workflow; | |
| 15 | 16 | ||
| 16 | 17 | use clap::CommandFactory; | |
| 17 | 18 | ||
| ⋯ | |||
| 36 | 37 | Command::Issue(command) => issue::run(&Ctx::new(cli.global)?, command), | |
| 37 | 38 | Command::Pr(command) => pr::run(&Ctx::new(cli.global)?, command), | |
| 38 | 39 | Command::Run(command) => run::run(&Ctx::new(cli.global)?, command), | |
| 40 | + | Command::Workflow(command) => workflow::run(&Ctx::new(cli.global)?, command), | |
| 39 | 41 | Command::Secret(command) => secret::run(&Ctx::new(cli.global)?, command), | |
| 40 | 42 | Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command), | |
| 41 | 43 | Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args), | |
| 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 | + | } |
| 172 | 172 | ops: &["cancel_workflow_run"], | |
| 173 | 173 | }, | |
| 174 | 174 | 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 { | |
| 175 | 195 | command: "secret list", | |
| 176 | 196 | ops: &["list_actions_secrets", "list_actions_secrets_for_workspace"], | |
| 177 | 197 | }, | |
| ⋯ | |||
| 314 | 334 | ("issue", include_str!("commands/issue.rs")), | |
| 315 | 335 | ("pr", include_str!("commands/pr.rs")), | |
| 316 | 336 | ("run", include_str!("commands/run.rs")), | |
| 337 | + | ("workflow", include_str!("commands/workflow.rs")), | |
| 317 | 338 | ("secret", include_str!("commands/secret.rs")), | |
| 318 | 339 | ("deploy", include_str!("commands/deploy.rs")), | |
| 319 | 340 | ("logs", include_str!("commands/logs.rs")), | |
| 892 | 892 | assert_eq!(out.code, 2); | |
| 893 | 893 | assert!(out.stderr.contains("--repo"), "{}", out.stderr); | |
| 894 | 894 | } | |
| 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.