neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Dispatch a workflow by its name
f821e9e · Gabriel Morell · 2026-10-22 20:09
Message
{commit_body(@commit)}
Files changed
modified
cli/src/cli.rs
+2
−1
@@ -252,7 +252,8 @@ pub enum CiCommand {
| 252 | 252 | /// Run a workflow by hand (`workflow_dispatch`). |
| 253 | 253 | #[command(visible_alias = "run-workflow")] |
| 254 | 254 | Dispatch { |
| 255 | − /// The workflow, by path or file name (see `gg ci workflows`). | |
| 255 | + /// The workflow: its path, file name (with or without `.yml`), or | |
| 256 | + /// `name:` (see `gg ci workflows`). | |
| 256 | 257 | workflow: String, |
| 257 | 258 | |
| 258 | 259 | /// Branch or tag to run it on. Defaults to the default branch. |
modified
lib/git_gud/workflows.ex
+38
−10
@@ -549,8 +549,14 @@ defmodule GitGud.Workflows do
| 549 | 549 | `inputs` (strings) |
| 550 | 550 | * `:triggered_by_id` — who asked |
| 551 | 551 | |
| 552 | + `workflow` names the file by its path, its file name, its file name | |
| 553 | + without `.yml`/`.yaml`, or the workflow's `name:` (any case) — tried | |
| 554 | + in that order, the first that matches anything winning. Two workflows | |
| 555 | + matching at that step are refused rather than guessed between. | |
| 556 | + | |
| 552 | 557 | Returns `{:ok, run}`, or `{:error, :unknown_ref | :unknown_workflow | |
| 553 | − :not_dispatchable | {:invalid_inputs, [{name, reason}]}}`. | |
| 558 | + {:ambiguous_workflow, [path]} | :not_dispatchable | | |
| 559 | + {:invalid_inputs, [{name, reason}]}}`. | |
| 554 | 560 | """ |
| 555 | 561 | def dispatch_manual(%Repository{} = repo, workflow, ref, opts \\ []) |
| 556 | 562 | when is_binary(workflow) and is_binary(ref) and is_list(opts) do |
@@ -634,7 +640,7 @@ defmodule GitGud.Workflows do
| 634 | 640 | """ |
| 635 | 641 | def dispatch_schedule(%Repository{} = repo, path, cron) when is_binary(path) do |
| 636 | 642 | with {:ok, ref, sha} <- default_branch_head(repo), |
| 637 | − {:ok, path, doc} <- find_workflow(repo, sha, path), | |
| 643 | + {:ok, path, doc} <- find_workflow(repo, sha, path, :path), | |
| 638 | 644 | true <- cron in Schedules.crons(doc) || {:error, :not_scheduled} do |
| 639 | 645 | create_run(repo, %{ |
| 640 | 646 | workflow_path: path, |
@@ -731,14 +737,36 @@ defmodule GitGud.Workflows do
| 731 | 737 | end |
| 732 | 738 | end |
| 733 | 739 | |
| 734 | − defp find_workflow(repo, sha, workflow) do | |
| 735 | − repo | |
| 736 | − |> discover_workflows(sha) | |
| 737 | − |> Enum.find(fn {path, _doc} -> path == workflow or Path.basename(path) == workflow end) | |
| 738 | − |> case do | |
| 739 | − {path, doc} -> {:ok, path, doc} | |
| 740 | − nil -> {:error, :unknown_workflow} | |
| 741 | − end | |
| 740 | + # By how specifically `workflow` names one: the path, the file name, | |
| 741 | + # the file name without its extension, the `name:`. The first of those | |
| 742 | + # that matches anything decides; more than one match there is | |
| 743 | + # ambiguous. A schedule names its file by path and nothing else. | |
| 744 | + defp find_workflow(repo, sha, workflow, how \\ :any) do | |
| 745 | + workflows = discover_workflows(repo, sha) | |
| 746 | + | |
| 747 | + matchers = | |
| 748 | + [ | |
| 749 | + fn path, _doc -> path == workflow end, | |
| 750 | + fn path, _doc -> Path.basename(path) == workflow end, | |
| 751 | + fn path, _doc -> Path.basename(path, Path.extname(path)) == workflow end, | |
| 752 | + fn _path, doc -> | |
| 753 | + is_binary(doc.name) and String.downcase(doc.name) == String.downcase(workflow) | |
| 754 | + end | |
| 755 | + ] | |
| 756 | + |> Enum.take(if how == :path, do: 1, else: 4) | |
| 757 | + | |
| 758 | + Enum.find_value(matchers, {:error, :unknown_workflow}, fn matches? -> | |
| 759 | + case Enum.filter(workflows, fn {path, doc} -> matches?.(path, doc) end) do | |
| 760 | + [] -> | |
| 761 | + nil | |
| 762 | + | |
| 763 | + [{path, doc}] -> | |
| 764 | + {:ok, path, doc} | |
| 765 | + | |
| 766 | + several -> | |
| 767 | + {:error, {:ambiguous_workflow, several |> Enum.map(&elem(&1, 0)) |> Enum.sort()}} | |
| 768 | + end | |
| 769 | + end) | |
| 742 | 770 | end |
| 743 | 771 | |
| 744 | 772 | # An annotated tag's ref points at a *tag object*, not at a commit, and |
modified
lib/git_gud_web/controllers/api/mobile/ci_controller.ex
+7
−0
@@ -190,6 +190,13 @@ defmodule GitGudWeb.Api.Mobile.CiController do
| 190 | 190 | {:error, :unknown_workflow} -> |
| 191 | 191 | send_error(conn, 404, "no such workflow: #{workflow}") |
| 192 | 192 | |
| 193 | + {:error, {:ambiguous_workflow, paths}} -> | |
| 194 | + send_error( | |
| 195 | + conn, | |
| 196 | + 422, | |
| 197 | + "#{workflow} could mean #{Enum.join(paths, " or ")}; give its path" | |
| 198 | + ) | |
| 199 | + | |
| 193 | 200 | {:error, :not_dispatchable} -> |
| 194 | 201 | send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger on #{ref}") |
| 195 | 202 |
modified
lib/git_gud_web/controllers/api/v1/workflow_controller.ex
+3
−0
@@ -69,6 +69,9 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 69 | 69 | {:error, :unknown_workflow} -> |
| 70 | 70 | send_error(conn, 404, "no such workflow: #{workflow}") |
| 71 | 71 | |
| 72 | + {:error, {:ambiguous_workflow, paths}} -> | |
| 73 | + send_error(conn, 422, "#{workflow} could mean #{Enum.join(paths, " or ")}; give its path") | |
| 74 | + | |
| 72 | 75 | {:error, :not_dispatchable} -> |
| 73 | 76 | send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger") |
| 74 | 77 |
added
test/git_gud/workflows/dispatch_lookup_test.exs
+90
−0
@@ -0,0 +1,90 @@
| 1 | +defmodule GitGud.Workflows.DispatchLookupTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Which workflow `dispatch_manual/4` runs for a name: the path, the file | |
| 4 | + name, the file name without its extension, or the `name:` — in that | |
| 5 | + order, the first that matches anything deciding, and two matches there | |
| 6 | + refused rather than guessed between. | |
| 7 | + """ | |
| 8 | + use GitGud.DataCase, async: true | |
| 9 | + | |
| 10 | + alias GitGud.Workflows | |
| 11 | + alias GitGud.Workflows.WorkflowRun | |
| 12 | + | |
| 13 | + import GitGud.ForgeFixtures | |
| 14 | + import GitGud.WorkflowGitHelpers | |
| 15 | + | |
| 16 | + defp workflow(name) do | |
| 17 | + """ | |
| 18 | + name: #{name} | |
| 19 | + on: { workflow_dispatch: } | |
| 20 | + jobs: | |
| 21 | + only: | |
| 22 | + runs-on: self-hosted | |
| 23 | + steps: | |
| 24 | + - run: echo #{name} | |
| 25 | + """ | |
| 26 | + end | |
| 27 | + | |
| 28 | + setup do | |
| 29 | + {_user, repo} = repository_fixture() | |
| 30 | + | |
| 31 | + head = | |
| 32 | + commit!( | |
| 33 | + repo, | |
| 34 | + %{ | |
| 35 | + ".forgejo/workflows/audit.yml" => workflow("Nightly audit"), | |
| 36 | + ".forgejo/workflows/release.yaml" => workflow("ship"), | |
| 37 | + ".forgejo/workflows/build.yml" => workflow("build"), | |
| 38 | + ".forgejo/workflows/build-arm.yml" => workflow("Build"), | |
| 39 | + # Named like another workflow's file stem: the stem match wins. | |
| 40 | + ".forgejo/workflows/lint.yml" => workflow("audit") | |
| 41 | + }, | |
| 42 | + [] | |
| 43 | + ) | |
| 44 | + | |
| 45 | + git!(repo.disk_path, ["update-ref", "refs/heads/main", head]) | |
| 46 | + {:ok, repo: repo} | |
| 47 | + end | |
| 48 | + | |
| 49 | + defp ran(repo, name) do | |
| 50 | + case Workflows.dispatch_manual(repo, name, "main") do | |
| 51 | + {:ok, %WorkflowRun{workflow_path: path}} -> path | |
| 52 | + other -> other | |
| 53 | + end | |
| 54 | + end | |
| 55 | + | |
| 56 | + test "by path and by file name, as before", %{repo: repo} do | |
| 57 | + assert ran(repo, ".forgejo/workflows/audit.yml") == ".forgejo/workflows/audit.yml" | |
| 58 | + assert ran(repo, "audit.yml") == ".forgejo/workflows/audit.yml" | |
| 59 | + end | |
| 60 | + | |
| 61 | + test "by the file name without .yml or .yaml", %{repo: repo} do | |
| 62 | + assert ran(repo, "audit") == ".forgejo/workflows/audit.yml" | |
| 63 | + assert ran(repo, "release") == ".forgejo/workflows/release.yaml" | |
| 64 | + end | |
| 65 | + | |
| 66 | + test "by name:, in any case", %{repo: repo} do | |
| 67 | + assert ran(repo, "Nightly audit") == ".forgejo/workflows/audit.yml" | |
| 68 | + assert ran(repo, "SHIP") == ".forgejo/workflows/release.yaml" | |
| 69 | + end | |
| 70 | + | |
| 71 | + test "a more specific match wins over a name: elsewhere", %{repo: repo} do | |
| 72 | + # lint.yml is named "audit", but audit.yml's stem matches first. | |
| 73 | + assert ran(repo, "audit") == ".forgejo/workflows/audit.yml" | |
| 74 | + end | |
| 75 | + | |
| 76 | + test "two at the same step are refused, naming both", %{repo: repo} do | |
| 77 | + # "build" is build.yml's stem, so that wins; "BUILD" is no stem and | |
| 78 | + # matches both name:s. | |
| 79 | + assert ran(repo, "build") == ".forgejo/workflows/build.yml" | |
| 80 | + | |
| 81 | + assert ran(repo, "BUILD") == | |
| 82 | + {:error, | |
| 83 | + {:ambiguous_workflow, | |
| 84 | + [".forgejo/workflows/build-arm.yml", ".forgejo/workflows/build.yml"]}} | |
| 85 | + end | |
| 86 | + | |
| 87 | + test "nothing matching is still unknown", %{repo: repo} do | |
| 88 | + assert ran(repo, "deploy") == {:error, :unknown_workflow} | |
| 89 | + end | |
| 90 | +end |
Parents: a01fc9a