Dispatch a workflow by its name

f821e9e · Gabriel Morell · 2026-10-22 20:09

17 files +140 -11
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