neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: workflow_dispatch inputs reach the job, and a Run workflow button
9b97d32 · Gabriel Morell · 2026-10-17 17:56
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−0
@@ -90,6 +90,7 @@ reinvent them.
| 90 | 90 | - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style |
| 91 | 91 | - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request` and `workflow_dispatch` |
| 92 | 92 | - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default; other activity types never fire). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`. **Fork and federated pull requests don't run yet**: their head is code the target's maintainers haven't reviewed, and a job is handed the target repository's secrets and a token that can publish its packages; GitHub runs them without secrets and with a read-only token, and until that exists here they don't run at all. `pull_request_target` isn't supported |
| 93 | + - **`on: workflow_dispatch`** from the Actions page ("Run workflow", for anyone who can write to the repository: pick a branch and fill in the inputs) or the Gitea/GitHub API (`POST /api/v1/repos/:owner/:name/actions/workflows/:file/dispatches`, a `ci:write` token or a job token). Declared `inputs:` are checked as GitHub checks them — an undeclared input, a missing `required` one, a `choice` outside its `options`, a `boolean` that isn't true/false or a `number` that isn't numeric answers 422 — and every declared input, defaults applied, reaches the job as `inputs.*` and `github.event.inputs.*` (strings) | |
| 93 | 94 | - **Filter patterns** in `branches:`, `tags:`, `paths:` and their `-ignore` lists follow GitHub's cheat sheet: `*` is any run of characters but `/`, `**` any run at all (`**/` also no directory), `?` zero or one and `+` one or more of the **preceding** character, `[a-z0-9]` one listed character (alphanumerics and `a-z`/`A-Z`/`0-9` ranges only), `\x` a literal `x`. In `branches:`, `tags:` and `paths:` the last matching pattern decides, so `!pattern` excludes and a later positive pattern includes again; a list with only `!` patterns matches nothing. `*` no longer crosses `/` in `branches:` / `tags:` (`branches: ['*']` doesn't run for `feature/x`), and `!` works there now. Branch protection and required-check patterns are separate: their `*` still crosses `/` |
| 94 | 95 | - **`paths:` / `paths-ignore:`**: a push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look |
| 95 | 96 | - **The job's context** follows Forgejo's: `github.event_name`, `ref`, `ref_name`, `ref_type`, `sha`, `repository` (`owner/name`), `repository_id`, `repository_owner(_id)`, `actor(_id)`, `run_id`, `run_number`, `run_attempt` (1, bumped by re-running the job; re-running the whole run makes a new run and a new `run_id`), `workflow`, `workflow_ref` (`owner/name/<path>@<ref>`), `job`, `server_url`, `api_url`, `token`. **`github.event`** is GitHub's webhook payload, trimmed: every event has `repository` and `sender`; `push` has `ref`, `before` (40 zeros for a new ref), `after`, `created`, `deleted`, `forced` (always `false`: not tracked), `compare`, `commits` (at most 20) and `head_commit`, `pusher`; `pull_request` has `action`, `number` and `pull_request` (`title`, `body`, `html_url`, `user`, `head`/`base` with `ref`, `sha`, `label`, `repo`); `workflow_dispatch` has `inputs`, `ref`, `workflow`. **`secrets.GITHUB_TOKEN`** (and `GITEA_TOKEN`, `FORGEJO_TOKEN`) is the job token. The workflow's top-level `env:`, `defaults:`, `permissions:`, `run-name:` and `on:` reach the runner with the job |
modified
lib/git_gud/workflows.ex
+44
−6
@@ -22,6 +22,7 @@ defmodule GitGud.Workflows do
| 22 | 22 | alias GitGud.Repositories.Storage |
| 23 | 23 | alias GitGud.Workflows.Concurrency |
| 24 | 24 | alias GitGud.Workflows.Context |
| 25 | + alias GitGud.Workflows.DispatchInputs | |
| 25 | 26 | alias GitGud.Workflows.EventPayload |
| 26 | 27 | alias GitGud.Workflows.Matrix |
| 27 | 28 | alias GitGud.Workflows.Ref |
@@ -301,19 +302,29 @@ defmodule GitGud.Workflows do
| 301 | 302 | (`.forgejo/workflows/build.yml`); `ref` is a branch or tag, short |
| 302 | 303 | (`main`, `v1.2`) or full (`refs/heads/main`). The workflow has to opt in |
| 303 | 304 | with `on: workflow_dispatch`, as on GitHub, so a file can't be run by |
| 304 | − someone who merely knows its name. `inputs` (string → string) reach the | |
| 305 | − job as `github.event.inputs` / `inputs`. | |
| 305 | + someone who merely knows its name. | |
| 306 | + | |
| 307 | + `opts`: | |
| 308 | + | |
| 309 | + * `:inputs` — name → value, checked against the workflow's declared | |
| 310 | + inputs (`GitGud.Workflows.DispatchInputs`); every declared input, | |
| 311 | + defaults applied, reaches the job as `github.event.inputs` / | |
| 312 | + `inputs` (strings) | |
| 313 | + * `:triggered_by_id` — who asked | |
| 306 | 314 | |
| 307 | 315 | Returns `{:ok, run}`, or `{:error, :unknown_ref | :unknown_workflow | |
| 308 | − :not_dispatchable}`. | |
| 316 | + :not_dispatchable | {:invalid_inputs, [{name, reason}]}}`. | |
| 309 | 317 | """ |
| 310 | − def dispatch_manual(%Repository{} = repo, workflow, ref, triggered_by_id \\ nil, inputs \\ %{}) | |
| 311 | − when is_binary(workflow) and is_binary(ref) and is_map(inputs) do | |
| 318 | + def dispatch_manual(%Repository{} = repo, workflow, ref, opts \\ []) | |
| 319 | + when is_binary(workflow) and is_binary(ref) and is_list(opts) do | |
| 320 | + triggered_by_id = Keyword.get(opts, :triggered_by_id) | |
| 321 | + | |
| 312 | 322 | with {:ok, full_ref, sha} <- resolve_ref(repo, ref), |
| 313 | 323 | {:ok, path, doc} <- find_workflow(repo, sha, workflow), |
| 314 | 324 | true <- |
| 315 | 325 | Yaml.matches?(doc, "workflow_dispatch", Ref.context(full_ref)) || |
| 316 | − {:error, :not_dispatchable} do | |
| 326 | + {:error, :not_dispatchable}, | |
| 327 | + {:ok, inputs} <- resolve_inputs(doc, Keyword.get(opts, :inputs, %{})) do | |
| 317 | 328 | create_run(repo, %{ |
| 318 | 329 | workflow_path: path, |
| 319 | 330 | workflow_name: doc.name, |
@@ -335,6 +346,33 @@ defmodule GitGud.Workflows do
| 335 | 346 | end |
| 336 | 347 | end |
| 337 | 348 | |
| 349 | + defp resolve_inputs(doc, given) do | |
| 350 | + case DispatchInputs.resolve(doc, given || %{}) do | |
| 351 | + {:ok, inputs} -> {:ok, inputs} | |
| 352 | + {:error, errors} -> {:error, {:invalid_inputs, errors}} | |
| 353 | + end | |
| 354 | + end | |
| 355 | + | |
| 356 | + @doc """ | |
| 357 | + The workflows at the default branch's head that can be run by hand | |
| 358 | + (`on: workflow_dispatch`), as `[{path, doc}]` sorted by path. Empty | |
| 359 | + when the default branch doesn't exist yet. | |
| 360 | + """ | |
| 361 | + def dispatchable_workflows(%Repository{} = repo) do | |
| 362 | + case default_branch_ref(repo) && resolve_ref(repo, default_branch_ref(repo)) do | |
| 363 | + {:ok, full_ref, sha} -> | |
| 364 | + repo | |
| 365 | + |> discover_workflows(sha) | |
| 366 | + |> Enum.filter(fn {_path, doc} -> | |
| 367 | + Yaml.matches?(doc, "workflow_dispatch", Ref.context(full_ref)) | |
| 368 | + end) | |
| 369 | + |> Enum.sort_by(&elem(&1, 0)) | |
| 370 | + | |
| 371 | + _ -> | |
| 372 | + [] | |
| 373 | + end | |
| 374 | + end | |
| 375 | + | |
| 338 | 376 | # Beyond this many changed files the filter isn't worth deciding: the push |
| 339 | 377 | # is big enough that running is the safe answer, and GitHub draws a |
| 340 | 378 | # similar line. |
added
lib/git_gud/workflows/dispatch_inputs.ex
+136
−0
@@ -0,0 +1,136 @@
| 1 | +defmodule GitGud.Workflows.DispatchInputs do | |
| 2 | + @moduledoc """ | |
| 3 | + A `workflow_dispatch`'s inputs, checked against what the workflow | |
| 4 | + declares under `on.workflow_dispatch.inputs` — GitHub's (and Gitea's) | |
| 5 | + rules, which answer 422 for: | |
| 6 | + | |
| 7 | + * an input the workflow doesn't declare; | |
| 8 | + * a `required` input that is neither given (nor blank) nor defaulted; | |
| 9 | + * a `choice` whose value isn't one of its `options`; | |
| 10 | + * a `boolean` that isn't `true` / `false`; | |
| 11 | + * a `number` that isn't numeric. | |
| 12 | + | |
| 13 | + `type` defaults to `string`; `environment` is taken as a string (there | |
| 14 | + are no environments here). An input that isn't given takes its | |
| 15 | + `default`, or `""` (`"false"` for a boolean) when it has none. | |
| 16 | + | |
| 17 | + Values come out as strings — booleans `"true"` / `"false"` — which is | |
| 18 | + how GitHub puts them in `github.event.inputs`; the runner converts | |
| 19 | + `inputs.<id>` per the declared type. | |
| 20 | + """ | |
| 21 | + | |
| 22 | + @type errors :: [{String.t(), String.t()}] | |
| 23 | + | |
| 24 | + @doc """ | |
| 25 | + `{:ok, inputs}` — every declared input, defaults applied — or | |
| 26 | + `{:error, [{name, reason}]}`, sorted by name. | |
| 27 | + """ | |
| 28 | + @spec resolve(map(), map()) :: {:ok, %{String.t() => String.t()}} | {:error, errors()} | |
| 29 | + def resolve(doc, given) when is_map(given) do | |
| 30 | + declared = declared(doc) | |
| 31 | + given = Map.new(given, fn {k, v} -> {to_string(k), v} end) | |
| 32 | + | |
| 33 | + unknown = | |
| 34 | + for name <- Map.keys(given), | |
| 35 | + not Map.has_key?(declared, name), | |
| 36 | + do: {name, "is not an input of this workflow"} | |
| 37 | + | |
| 38 | + {values, errors} = | |
| 39 | + Enum.reduce(declared, {%{}, unknown}, fn {name, spec}, {values, errors} -> | |
| 40 | + case value(spec, Map.get(given, name)) do | |
| 41 | + {:ok, value} -> {Map.put(values, name, value), errors} | |
| 42 | + {:error, reason} -> {values, [{name, reason} | errors]} | |
| 43 | + end | |
| 44 | + end) | |
| 45 | + | |
| 46 | + case errors do | |
| 47 | + [] -> {:ok, values} | |
| 48 | + errors -> {:error, Enum.sort(errors)} | |
| 49 | + end | |
| 50 | + end | |
| 51 | + | |
| 52 | + @doc """ | |
| 53 | + The declared inputs, `name => spec` (`spec` always a map), from a | |
| 54 | + normalized workflow or its raw document. | |
| 55 | + """ | |
| 56 | + @spec declared(map()) :: %{String.t() => map()} | |
| 57 | + def declared(%{triggers: triggers}), do: declared_in(triggers) | |
| 58 | + def declared(%{"on" => on}) when is_map(on), do: declared_in(on) | |
| 59 | + def declared(_doc), do: %{} | |
| 60 | + | |
| 61 | + defp declared_in(%{"workflow_dispatch" => %{"inputs" => %{} = inputs}}), | |
| 62 | + do: Map.new(inputs, fn {name, spec} -> {to_string(name), spec_map(spec)} end) | |
| 63 | + | |
| 64 | + defp declared_in(_), do: %{} | |
| 65 | + | |
| 66 | + defp spec_map(%{} = spec), do: spec | |
| 67 | + defp spec_map(_), do: %{} | |
| 68 | + | |
| 69 | + @doc "An input's `type`, `\"string\"` when it doesn't say." | |
| 70 | + @spec type(map()) :: String.t() | |
| 71 | + def type(spec), do: to_string(Map.get(spec, "type") || "string") | |
| 72 | + | |
| 73 | + @doc "A `choice` input's options, as strings." | |
| 74 | + @spec options(map()) :: [String.t()] | |
| 75 | + def options(spec), do: spec |> Map.get("options") |> List.wrap() |> Enum.map(&to_string/1) | |
| 76 | + | |
| 77 | + defp value(spec, nil) do | |
| 78 | + case Map.get(spec, "default") do | |
| 79 | + nil -> | |
| 80 | + if Map.get(spec, "required") in [true, "true"], | |
| 81 | + do: {:error, "is required"}, | |
| 82 | + else: {:ok, if(type(spec) == "boolean", do: "false", else: "")} | |
| 83 | + | |
| 84 | + default -> | |
| 85 | + check(type(spec), spec, default) | |
| 86 | + end | |
| 87 | + end | |
| 88 | + | |
| 89 | + # A required input left blank (an emptied form field) is a missing one. | |
| 90 | + defp value(%{"required" => required} = spec, "") when required in [true, "true"], | |
| 91 | + do: value(spec, nil) | |
| 92 | + | |
| 93 | + defp value(spec, given), do: check(type(spec), spec, given) | |
| 94 | + | |
| 95 | + defp check("boolean", _spec, v) when is_boolean(v), do: {:ok, to_string(v)} | |
| 96 | + | |
| 97 | + defp check("boolean", _spec, v) when is_binary(v) do | |
| 98 | + case String.downcase(v) do | |
| 99 | + b when b in ["true", "false"] -> {:ok, b} | |
| 100 | + _ -> {:error, "must be true or false"} | |
| 101 | + end | |
| 102 | + end | |
| 103 | + | |
| 104 | + defp check("boolean", _spec, _v), do: {:error, "must be true or false"} | |
| 105 | + | |
| 106 | + defp check("number", _spec, v) when is_number(v), do: {:ok, to_string(v)} | |
| 107 | + | |
| 108 | + defp check("number", _spec, v) when is_binary(v) do | |
| 109 | + trimmed = String.trim(v) | |
| 110 | + | |
| 111 | + case Float.parse(trimmed) do | |
| 112 | + {_n, ""} -> {:ok, trimmed} | |
| 113 | + _ -> {:error, "must be a number"} | |
| 114 | + end | |
| 115 | + end | |
| 116 | + | |
| 117 | + defp check("number", _spec, _v), do: {:error, "must be a number"} | |
| 118 | + | |
| 119 | + defp check("choice", spec, v) when is_binary(v) or is_number(v) or is_boolean(v) do | |
| 120 | + v = to_string(v) | |
| 121 | + options = options(spec) | |
| 122 | + | |
| 123 | + if v in options, | |
| 124 | + do: {:ok, v}, | |
| 125 | + else: {:error, "must be one of: " <> Enum.join(options, ", ")} | |
| 126 | + end | |
| 127 | + | |
| 128 | + defp check(_type, _spec, v) when is_binary(v), do: {:ok, v} | |
| 129 | + defp check(_type, _spec, v) when is_number(v) or is_boolean(v), do: {:ok, to_string(v)} | |
| 130 | + defp check(_type, _spec, _v), do: {:error, "must be a string"} | |
| 131 | + | |
| 132 | + @doc "`[{name, reason}]` as one sentence per input, for an API reply or a flash." | |
| 133 | + @spec describe(errors()) :: String.t() | |
| 134 | + def describe(errors), | |
| 135 | + do: Enum.map_join(errors, "; ", fn {name, reason} -> "input #{name} #{reason}" end) | |
| 136 | +end |
modified
lib/git_gud_web/controllers/api/v1/workflow_controller.ex
+30
−3
@@ -4,7 +4,7 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 4 | 4 | |
| 5 | 5 | POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches |
| 6 | 6 | Authorization: token <credential> |
| 7 | − { "ref": "main" } | |
| 7 | + { "ref": "main", "inputs": { "environment": "staging" } } | |
| 8 | 8 | |
| 9 | 9 | `:workflow` is the file's basename (`build.yml`) or its path with the |
| 10 | 10 | slashes percent-encoded (`.forgejo%2Fworkflows%2Fbuild.yml`); the workflow must declare |
@@ -20,7 +20,12 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 20 | 20 | * a personal access token with `ci:write`, whose account may write to |
| 21 | 21 | the repository. |
| 22 | 22 | |
| 23 | − `inputs` are accepted for compatibility and not yet passed to the run. | |
| 23 | + `inputs` is an object of strings, checked against the workflow's | |
| 24 | + `on.workflow_dispatch.inputs` (`GitGud.Workflows.DispatchInputs`): an | |
| 25 | + undeclared input, a missing required one, a `choice` outside its | |
| 26 | + options, a `boolean` that isn't true/false or a `number` that isn't | |
| 27 | + numeric answers 422, as on GitHub. Declared inputs that aren't given | |
| 28 | + take their defaults. | |
| 24 | 29 | """ |
| 25 | 30 | use GitGudWeb, :controller |
| 26 | 31 |
@@ -28,6 +33,7 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 28 | 33 | alias GitGud.Organizations |
| 29 | 34 | alias GitGud.Repositories |
| 30 | 35 | alias GitGud.Workflows |
| 36 | + alias GitGud.Workflows.DispatchInputs | |
| 31 | 37 | alias GitGud.Workflows.JobTokenGrants |
| 32 | 38 | alias GitGud.Workflows.RunnerJwt |
| 33 | 39 |
@@ -36,7 +42,12 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 36 | 42 | {:ok, actor_id} <- authorize(conn, repo), |
| 37 | 43 | ref when is_binary(ref) and ref != "" <- |
| 38 | 44 | params["ref"] || repo.default_branch || {:error, :no_ref}, |
| 39 | − {:ok, _run} <- Workflows.dispatch_manual(repo, workflow, ref, actor_id) do | |
| 45 | + {:ok, inputs} <- inputs_param(params["inputs"]), | |
| 46 | + {:ok, _run} <- | |
| 47 | + Workflows.dispatch_manual(repo, workflow, ref, | |
| 48 | + inputs: inputs, | |
| 49 | + triggered_by_id: actor_id | |
| 50 | + ) do | |
| 40 | 51 | send_resp(conn, 204, "") |
| 41 | 52 | else |
| 42 | 53 | {:error, :not_found} -> |
@@ -60,11 +71,27 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
| 60 | 71 | {:error, :not_dispatchable} -> |
| 61 | 72 | send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger") |
| 62 | 73 | |
| 74 | + {:error, :bad_inputs} -> | |
| 75 | + send_error(conn, 422, "inputs must be an object of strings") | |
| 76 | + | |
| 77 | + {:error, {:invalid_inputs, errors}} -> | |
| 78 | + send_error(conn, 422, DispatchInputs.describe(errors)) | |
| 79 | + | |
| 63 | 80 | {:error, reason} -> |
| 64 | 81 | send_error(conn, 500, inspect(reason)) |
| 65 | 82 | end |
| 66 | 83 | end |
| 67 | 84 | |
| 85 | + defp inputs_param(nil), do: {:ok, %{}} | |
| 86 | + | |
| 87 | + defp inputs_param(%{} = inputs) do | |
| 88 | + if Enum.all?(inputs, fn {_k, v} -> is_binary(v) end), | |
| 89 | + do: {:ok, inputs}, | |
| 90 | + else: {:error, :bad_inputs} | |
| 91 | + end | |
| 92 | + | |
| 93 | + defp inputs_param(_), do: {:error, :bad_inputs} | |
| 94 | + | |
| 68 | 95 | defp fetch_repo(owner, name) do |
| 69 | 96 | {:ok, Repositories.get_repository_by_path!(owner, name)} |
| 70 | 97 | rescue |
modified
lib/git_gud_web/live/workflow_live/index.ex
+224
−0
@@ -2,9 +2,11 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 2 | 2 | use GitGudWeb, :live_view |
| 3 | 3 | |
| 4 | 4 | alias GitGud.Git |
| 5 | + alias GitGud.Organizations | |
| 5 | 6 | alias GitGud.Repositories |
| 6 | 7 | alias GitGud.Repositories.Storage |
| 7 | 8 | alias GitGud.Workflows |
| 9 | + alias GitGud.Workflows.DispatchInputs | |
| 8 | 10 | |
| 9 | 11 | @impl true |
| 10 | 12 | def mount(%{"owner" => owner, "name" => name}, _session, socket) do |
@@ -26,9 +28,138 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 26 | 28 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 27 | 29 | |> stream(:runs, runs) |
| 28 | 30 | |> assign_durations() |
| 31 | + |> assign_dispatch() | |
| 29 | 32 | |> assign(:page_title, "Actions — #{owner}/#{name}")} |
| 30 | 33 | end |
| 31 | 34 | |
| 35 | + # "Run workflow": the default branch's `on: workflow_dispatch` files, | |
| 36 | + # for someone who may write to the repository — the same rule as the | |
| 37 | + # dispatch API's personal tokens. | |
| 38 | + defp assign_dispatch(socket) do | |
| 39 | + repo = socket.assigns.repo | |
| 40 | + user = socket.assigns.current_scope && socket.assigns.current_scope.user | |
| 41 | + can? = user != nil and Organizations.can?(user, repo, :write) == true | |
| 42 | + | |
| 43 | + workflows = | |
| 44 | + if can?, | |
| 45 | + do: | |
| 46 | + for( | |
| 47 | + {path, doc} <- Workflows.dispatchable_workflows(repo), | |
| 48 | + do: %{path: path, name: doc.name || Path.basename(path), inputs: input_specs(doc)} | |
| 49 | + ), | |
| 50 | + else: [] | |
| 51 | + | |
| 52 | + socket | |
| 53 | + |> assign(:dispatchable, workflows) | |
| 54 | + |> assign(:dispatching, nil) | |
| 55 | + |> assign(:branches, []) | |
| 56 | + |> assign(:dispatch_form, nil) | |
| 57 | + |> assign(:inputs_form, nil) | |
| 58 | + end | |
| 59 | + | |
| 60 | + defp input_specs(doc) do | |
| 61 | + doc | |
| 62 | + |> DispatchInputs.declared() | |
| 63 | + |> Enum.sort_by(&elem(&1, 0)) | |
| 64 | + |> Enum.map(fn {name, spec} -> | |
| 65 | + %{ | |
| 66 | + name: name, | |
| 67 | + type: DispatchInputs.type(spec), | |
| 68 | + label: spec["description"] || name, | |
| 69 | + required: spec["required"] in [true, "true"], | |
| 70 | + options: DispatchInputs.options(spec), | |
| 71 | + default: spec["default"] | |
| 72 | + } | |
| 73 | + end) | |
| 74 | + end | |
| 75 | + | |
| 76 | + defp branches(repo) do | |
| 77 | + case Git.list_refs(repo.disk_path) do | |
| 78 | + {:ok, refs} -> | |
| 79 | + refs | |
| 80 | + |> Enum.flat_map(fn | |
| 81 | + %{name: "refs/heads/" <> b} -> [b] | |
| 82 | + _ -> [] | |
| 83 | + end) | |
| 84 | + |> Enum.sort() | |
| 85 | + | |
| 86 | + _ -> | |
| 87 | + [] | |
| 88 | + end | |
| 89 | + end | |
| 90 | + | |
| 91 | + @impl true | |
| 92 | + def handle_event("open_dispatch", %{"path" => path}, socket) do | |
| 93 | + case Enum.find(socket.assigns.dispatchable, &(&1.path == path)) do | |
| 94 | + nil -> | |
| 95 | + {:noreply, socket} | |
| 96 | + | |
| 97 | + workflow -> | |
| 98 | + repo = socket.assigns.repo | |
| 99 | + | |
| 100 | + defaults = | |
| 101 | + Map.new(workflow.inputs, fn input -> | |
| 102 | + {input.name, if(input.default == nil, do: nil, else: to_string(input.default))} | |
| 103 | + end) | |
| 104 | + | |
| 105 | + {:noreply, | |
| 106 | + socket | |
| 107 | + |> assign(:dispatching, workflow) | |
| 108 | + |> assign(:branches, branches(repo)) | |
| 109 | + |> assign(:dispatch_form, to_form(%{"ref" => repo.default_branch}, as: :dispatch)) | |
| 110 | + |> assign(:inputs_form, to_form(defaults, as: :inputs))} | |
| 111 | + end | |
| 112 | + end | |
| 113 | + | |
| 114 | + def handle_event("close_dispatch", _params, socket) do | |
| 115 | + {:noreply, assign(socket, dispatching: nil, dispatch_form: nil, inputs_form: nil)} | |
| 116 | + end | |
| 117 | + | |
| 118 | + def handle_event("dispatch", params, socket) do | |
| 119 | + %{dispatching: workflow, repo: repo, current_scope: scope} = socket.assigns | |
| 120 | + ref = get_in(params, ["dispatch", "ref"]) || repo.default_branch | |
| 121 | + given = Map.get(params, "inputs", %{}) | |
| 122 | + | |
| 123 | + cond do | |
| 124 | + workflow == nil -> | |
| 125 | + {:noreply, socket} | |
| 126 | + | |
| 127 | + # Write access is re-checked: it may have been revoked since mount. | |
| 128 | + Organizations.can?(scope.user, repo, :write) != true -> | |
| 129 | + {:noreply, put_flash(socket, :error, "You can't run this repository's workflows.")} | |
| 130 | + | |
| 131 | + true -> | |
| 132 | + case Workflows.dispatch_manual(repo, workflow.path, ref, | |
| 133 | + inputs: given, | |
| 134 | + triggered_by_id: scope.user.id | |
| 135 | + ) do | |
| 136 | + {:ok, run} -> | |
| 137 | + {:noreply, | |
| 138 | + push_navigate(socket, | |
| 139 | + to: ~p"/r/#{socket.assigns.handle}/#{repo.name}/actions/#{run.number}" | |
| 140 | + )} | |
| 141 | + | |
| 142 | + {:error, reason} -> | |
| 143 | + {:noreply, | |
| 144 | + socket | |
| 145 | + |> assign(:dispatch_form, to_form(%{"ref" => ref}, as: :dispatch)) | |
| 146 | + |> assign(:inputs_form, to_form(given, as: :inputs)) | |
| 147 | + |> put_flash(:error, dispatch_error(reason, ref))} | |
| 148 | + end | |
| 149 | + end | |
| 150 | + end | |
| 151 | + | |
| 152 | + defp dispatch_error({:invalid_inputs, errors}, _ref), | |
| 153 | + do: "Could not run the workflow: " <> DispatchInputs.describe(errors) <> "." | |
| 154 | + | |
| 155 | + defp dispatch_error(:unknown_ref, ref), do: "No such branch: #{ref}." | |
| 156 | + | |
| 157 | + defp dispatch_error(:not_dispatchable, ref), | |
| 158 | + do: "That workflow has no workflow_dispatch trigger on #{ref}." | |
| 159 | + | |
| 160 | + defp dispatch_error(:unknown_workflow, ref), do: "That workflow doesn't exist on #{ref}." | |
| 161 | + defp dispatch_error(_reason, _ref), do: "Could not run the workflow." | |
| 162 | + | |
| 32 | 163 | @impl true |
| 33 | 164 | def handle_info({:ci_run_changed, _run_id}, socket) do |
| 34 | 165 | repo = socket.assigns.repo |
@@ -68,6 +199,99 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 68 | 199 | /> |
| 69 | 200 | <h1 class="text-xl font-semibold">Actions</h1> |
| 70 | 201 | |
| 202 | + <section | |
| 203 | + :if={@dispatchable != []} | |
| 204 | + id="dispatchable-workflows" | |
| 205 | + class="rounded-box border border-base-300 divide-y divide-base-300" | |
| 206 | + > | |
| 207 | + <div | |
| 208 | + :for={{workflow, i} <- Enum.with_index(@dispatchable)} | |
| 209 | + id={"dispatchable-#{i}"} | |
| 210 | + class="p-3" | |
| 211 | + > | |
| 212 | + <div class="flex items-center justify-between gap-3"> | |
| 213 | + <div class="min-w-0"> | |
| 214 | + <p class="font-medium truncate">{workflow.name}</p> | |
| 215 | + <p class="text-xs opacity-60 font-mono truncate">{workflow.path}</p> | |
| 216 | + </div> | |
| 217 | + <button | |
| 218 | + :if={@dispatching == nil or @dispatching.path != workflow.path} | |
| 219 | + type="button" | |
| 220 | + id={"run-workflow-#{i}"} | |
| 221 | + class="btn btn-sm btn-primary transition hover:-translate-y-px" | |
| 222 | + phx-click="open_dispatch" | |
| 223 | + phx-value-path={workflow.path} | |
| 224 | + > | |
| 225 | + <.icon name="hero-play" class="size-4" /> Run workflow | |
| 226 | + </button> | |
| 227 | + </div> | |
| 228 | + | |
| 229 | + <.form | |
| 230 | + :if={@dispatching != nil and @dispatching.path == workflow.path} | |
| 231 | + for={@dispatch_form} | |
| 232 | + id="dispatch-form" | |
| 233 | + phx-submit="dispatch" | |
| 234 | + class="mt-3 space-y-1 max-w-md" | |
| 235 | + > | |
| 236 | + <.input | |
| 237 | + field={@dispatch_form[:ref]} | |
| 238 | + type="select" | |
| 239 | + label="Branch" | |
| 240 | + options={@branches} | |
| 241 | + /> | |
| 242 | + <%= for input <- workflow.inputs do %> | |
| 243 | + <%= case input.type do %> | |
| 244 | + <% "boolean" -> %> | |
| 245 | + <.input | |
| 246 | + field={@inputs_form[input.name]} | |
| 247 | + id={"dispatch-input-#{input.name}"} | |
| 248 | + type="checkbox" | |
| 249 | + label={input.label} | |
| 250 | + /> | |
| 251 | + <% "choice" -> %> | |
| 252 | + <.input | |
| 253 | + field={@inputs_form[input.name]} | |
| 254 | + id={"dispatch-input-#{input.name}"} | |
| 255 | + type="select" | |
| 256 | + label={input.label} | |
| 257 | + options={input.options} | |
| 258 | + /> | |
| 259 | + <% "number" -> %> | |
| 260 | + <.input | |
| 261 | + field={@inputs_form[input.name]} | |
| 262 | + id={"dispatch-input-#{input.name}"} | |
| 263 | + type="number" | |
| 264 | + step="any" | |
| 265 | + label={input.label} | |
| 266 | + required={input.required} | |
| 267 | + /> | |
| 268 | + <% _ -> %> | |
| 269 | + <.input | |
| 270 | + field={@inputs_form[input.name]} | |
| 271 | + id={"dispatch-input-#{input.name}"} | |
| 272 | + type="text" | |
| 273 | + label={input.label} | |
| 274 | + required={input.required} | |
| 275 | + /> | |
| 276 | + <% end %> | |
| 277 | + <% end %> | |
| 278 | + <div class="flex gap-2 pt-2"> | |
| 279 | + <button type="submit" id="dispatch-submit" class="btn btn-sm btn-primary"> | |
| 280 | + Run workflow | |
| 281 | + </button> | |
| 282 | + <button | |
| 283 | + type="button" | |
| 284 | + id="dispatch-cancel" | |
| 285 | + class="btn btn-sm btn-ghost" | |
| 286 | + phx-click="close_dispatch" | |
| 287 | + > | |
| 288 | + Cancel | |
| 289 | + </button> | |
| 290 | + </div> | |
| 291 | + </.form> | |
| 292 | + </div> | |
| 293 | + </section> | |
| 294 | + | |
| 71 | 295 | <section :if={@durations != []} class="rounded-box border border-base-300 p-3"> |
| 72 | 296 | <h2 class="text-sm font-medium mb-2">Run times</h2> |
| 73 | 297 | <GitGudWeb.CiComponents.duration_chart |
added
test/git_gud/workflows/dispatch_inputs_test.exs
+124
−0
@@ -0,0 +1,124 @@
| 1 | +defmodule GitGud.Workflows.DispatchInputsTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias GitGud.Workflows.DispatchInputs | |
| 5 | + alias GitGud.Workflows.Yaml | |
| 6 | + | |
| 7 | + defp doc(inputs_yaml) do | |
| 8 | + {:ok, doc} = | |
| 9 | + Yaml.parse(""" | |
| 10 | + on: | |
| 11 | + workflow_dispatch: | |
| 12 | + inputs: | |
| 13 | + #{inputs_yaml} | |
| 14 | + jobs: | |
| 15 | + a: | |
| 16 | + steps: [{run: "true"}] | |
| 17 | + """) | |
| 18 | + | |
| 19 | + doc | |
| 20 | + end | |
| 21 | + | |
| 22 | + @inputs """ | |
| 23 | + environment: | |
| 24 | + description: Where to | |
| 25 | + type: choice | |
| 26 | + options: [staging, production] | |
| 27 | + default: staging | |
| 28 | + dry_run: | |
| 29 | + type: boolean | |
| 30 | + default: true | |
| 31 | + verbose: | |
| 32 | + type: boolean | |
| 33 | + replicas: | |
| 34 | + type: number | |
| 35 | + reason: | |
| 36 | + required: true | |
| 37 | + note: | |
| 38 | + description: free text | |
| 39 | + target: | |
| 40 | + type: environment | |
| 41 | + """ | |
| 42 | + | |
| 43 | + test "defaults fill what isn't given; values are strings" do | |
| 44 | + assert {:ok, inputs} = DispatchInputs.resolve(doc(@inputs), %{"reason" => "x"}) | |
| 45 | + | |
| 46 | + assert inputs == %{ | |
| 47 | + "environment" => "staging", | |
| 48 | + "dry_run" => "true", | |
| 49 | + "verbose" => "false", | |
| 50 | + "replicas" => "", | |
| 51 | + "reason" => "x", | |
| 52 | + "note" => "", | |
| 53 | + "target" => "" | |
| 54 | + } | |
| 55 | + end | |
| 56 | + | |
| 57 | + test "given values win, booleans and numbers normalised" do | |
| 58 | + given = %{ | |
| 59 | + "reason" => "x", | |
| 60 | + "environment" => "production", | |
| 61 | + "dry_run" => "FALSE", | |
| 62 | + "verbose" => true, | |
| 63 | + "replicas" => " 3 ", | |
| 64 | + "target" => "prod" | |
| 65 | + } | |
| 66 | + | |
| 67 | + assert {:ok, inputs} = DispatchInputs.resolve(doc(@inputs), given) | |
| 68 | + assert inputs["environment"] == "production" | |
| 69 | + assert inputs["dry_run"] == "false" | |
| 70 | + assert inputs["verbose"] == "true" | |
| 71 | + assert inputs["replicas"] == "3" | |
| 72 | + assert inputs["target"] == "prod" | |
| 73 | + end | |
| 74 | + | |
| 75 | + test "an undeclared input is refused" do | |
| 76 | + assert {:error, [{"colour", "is not an input of this workflow"}]} = | |
| 77 | + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "colour" => "red"}) | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "a required input with no default must be given" do | |
| 81 | + assert {:error, [{"reason", "is required"}]} = DispatchInputs.resolve(doc(@inputs), %{}) | |
| 82 | + end | |
| 83 | + | |
| 84 | + test "a required input with a default needn't be" do | |
| 85 | + doc = doc(" level:\n required: true\n default: info\n") | |
| 86 | + assert {:ok, %{"level" => "info"}} = DispatchInputs.resolve(doc, %{}) | |
| 87 | + end | |
| 88 | + | |
| 89 | + test "a choice must be one of its options" do | |
| 90 | + assert {:error, [{"environment", "must be one of: staging, production"}]} = | |
| 91 | + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "environment" => "dev"}) | |
| 92 | + end | |
| 93 | + | |
| 94 | + test "a boolean must be true or false" do | |
| 95 | + assert {:error, [{"dry_run", "must be true or false"}]} = | |
| 96 | + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "dry_run" => "yes"}) | |
| 97 | + end | |
| 98 | + | |
| 99 | + test "a number must be numeric" do | |
| 100 | + assert {:error, [{"replicas", "must be a number"}]} = | |
| 101 | + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "replicas" => "3x"}) | |
| 102 | + | |
| 103 | + assert {:ok, %{"replicas" => "2.5"}} = | |
| 104 | + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "replicas" => "2.5"}) | |
| 105 | + end | |
| 106 | + | |
| 107 | + test "every error at once, by name" do | |
| 108 | + assert {:error, errors} = | |
| 109 | + DispatchInputs.resolve(doc(@inputs), %{"dry_run" => "no", "zz" => "1"}) | |
| 110 | + | |
| 111 | + assert Enum.map(errors, &elem(&1, 0)) == ["dry_run", "reason", "zz"] | |
| 112 | + assert DispatchInputs.describe(errors) =~ "input reason is required" | |
| 113 | + end | |
| 114 | + | |
| 115 | + test "no declared inputs: nothing comes out, and nothing may go in" do | |
| 116 | + {:ok, doc} = Yaml.parse("on: workflow_dispatch\njobs: {a: {steps: []}}\n") | |
| 117 | + assert {:ok, %{}} = DispatchInputs.resolve(doc, %{}) | |
| 118 | + assert {:error, [{"x", _}]} = DispatchInputs.resolve(doc, %{"x" => "1"}) | |
| 119 | + end | |
| 120 | + | |
| 121 | + test "declared/1 reads a stored raw doc too" do | |
| 122 | + assert %{"reason" => %{"required" => true}} = DispatchInputs.declared(doc(@inputs).raw) | |
| 123 | + end | |
| 124 | +end |
modified
test/git_gud_web/controllers/api/v1/workflow_controller_test.exs
+97
−1
@@ -42,12 +42,42 @@ defmodule GitGudWeb.Api.V1.WorkflowControllerTest do
| 42 | 42 | - run: echo hi |
| 43 | 43 | """ |
| 44 | 44 | |
| 45 | + @with_inputs """ | |
| 46 | + name: deploy | |
| 47 | + on: | |
| 48 | + workflow_dispatch: | |
| 49 | + inputs: | |
| 50 | + environment: | |
| 51 | + type: choice | |
| 52 | + options: [staging, production] | |
| 53 | + default: staging | |
| 54 | + dry_run: | |
| 55 | + type: boolean | |
| 56 | + default: true | |
| 57 | + replicas: | |
| 58 | + type: number | |
| 59 | + reason: | |
| 60 | + required: true | |
| 61 | + jobs: | |
| 62 | + deploy: | |
| 63 | + runs-on: ubuntu-latest | |
| 64 | + steps: | |
| 65 | + - run: echo ${{ inputs.environment }} | |
| 66 | + """ | |
| 67 | + | |
| 45 | 68 | setup do |
| 46 | 69 | if System.find_executable("git") == nil do |
| 47 | 70 | {:skip, "git not on PATH"} |
| 48 | 71 | else |
| 49 | 72 | {owner, repo} = repository_fixture() |
| 50 | − commit = commit_workflows(repo, [{"build.yml", @dispatchable}, {"push.yml", @push_only}]) | |
| 73 | + | |
| 74 | + commit = | |
| 75 | + commit_workflows(repo, [ | |
| 76 | + {"build.yml", @dispatchable}, | |
| 77 | + {"deploy.yml", @with_inputs}, | |
| 78 | + {"push.yml", @push_only} | |
| 79 | + ]) | |
| 80 | + | |
| 51 | 81 | git!(repo.disk_path, ["update-ref", "refs/heads/main", commit]) |
| 52 | 82 | {:ok, owner: owner, repo: repo} |
| 53 | 83 | end |
@@ -208,6 +238,72 @@ defmodule GitGudWeb.Api.V1.WorkflowControllerTest do
| 208 | 238 | end |
| 209 | 239 | end |
| 210 | 240 | |
| 241 | + describe "inputs" do | |
| 242 | + defp dispatch_inputs(conn, repo, token, inputs) do | |
| 243 | + conn | |
| 244 | + |> put_req_header("content-type", "application/json") | |
| 245 | + |> put_req_header("authorization", "token " <> token) | |
| 246 | + |> post(dispatch_url(repo, "deploy.yml"), Jason.encode!(%{ref: "main", inputs: inputs})) | |
| 247 | + end | |
| 248 | + | |
| 249 | + test "given values and defaults reach the run's event payload", %{ | |
| 250 | + conn: conn, | |
| 251 | + owner: owner, | |
| 252 | + repo: repo | |
| 253 | + } do | |
| 254 | + conn = | |
| 255 | + dispatch_inputs(conn, repo, pat(owner, ["ci:write"]), %{ | |
| 256 | + "reason" => "hotfix", | |
| 257 | + "replicas" => "3" | |
| 258 | + }) | |
| 259 | + | |
| 260 | + assert response(conn, 204) == "" | |
| 261 | + | |
| 262 | + assert [run] = dispatched_runs(repo) | |
| 263 | + | |
| 264 | + assert run.event_payload["inputs"] == %{ | |
| 265 | + "environment" => "staging", | |
| 266 | + "dry_run" => "true", | |
| 267 | + "replicas" => "3", | |
| 268 | + "reason" => "hotfix" | |
| 269 | + } | |
| 270 | + end | |
| 271 | + | |
| 272 | + test "each rule answers 422 and starts nothing", %{conn: conn, owner: owner, repo: repo} do | |
| 273 | + token = pat(owner, ["ci:write"]) | |
| 274 | + | |
| 275 | + for {inputs, message} <- [ | |
| 276 | + {%{"reason" => "x", "colour" => "red"}, "colour"}, | |
| 277 | + {%{}, "reason is required"}, | |
| 278 | + {%{"reason" => "x", "environment" => "dev"}, "staging, production"}, | |
| 279 | + {%{"reason" => "x", "dry_run" => "maybe"}, "true or false"}, | |
| 280 | + {%{"reason" => "x", "replicas" => "many"}, "number"}, | |
| 281 | + {%{"reason" => 1}, "object of strings"}, | |
| 282 | + {["reason"], "object of strings"} | |
| 283 | + ] do | |
| 284 | + conn = dispatch_inputs(build_conn(), repo, token, inputs) | |
| 285 | + assert json_response(conn, 422)["message"] =~ message | |
| 286 | + end | |
| 287 | + | |
| 288 | + _ = conn | |
| 289 | + assert dispatched_runs(repo) == [] | |
| 290 | + end | |
| 291 | + | |
| 292 | + test "a workflow without declared inputs refuses any", %{ | |
| 293 | + conn: conn, | |
| 294 | + owner: owner, | |
| 295 | + repo: repo | |
| 296 | + } do | |
| 297 | + conn = | |
| 298 | + request_dispatch(conn, repo, "build.yml", pat(owner, ["ci:write"]), %{ | |
| 299 | + "ref" => "main", | |
| 300 | + "inputs" => %{"x" => "1"} | |
| 301 | + }) | |
| 302 | + | |
| 303 | + assert json_response(conn, 422)["message"] =~ "input x" | |
| 304 | + end | |
| 305 | + end | |
| 306 | + | |
| 211 | 307 | # ── git plumbing (the repository is bare) ───────────────────────────── |
| 212 | 308 | |
| 213 | 309 | defp commit_workflows(%Repository{disk_path: path}, files) do |
added
test/git_gud_web/live/workflow_dispatch_live_test.exs
+140
−0
@@ -0,0 +1,140 @@
| 1 | +defmodule GitGudWeb.WorkflowDispatchLiveTest do | |
| 2 | + @moduledoc """ | |
| 3 | + "Run workflow" on the Actions page: offered to writers for the default | |
| 4 | + branch's `on: workflow_dispatch` files, and it starts a run with the | |
| 5 | + inputs filled in. | |
| 6 | + """ | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + import GitGud.AccountsFixtures | |
| 11 | + import GitGud.ForgeFixtures | |
| 12 | + import GitGud.WorkflowGitHelpers | |
| 13 | + import Ecto.Query | |
| 14 | + | |
| 15 | + alias GitGud.Repo | |
| 16 | + alias GitGud.Repositories.Storage | |
| 17 | + alias GitGud.Workflows.WorkflowRun | |
| 18 | + | |
| 19 | + @moduletag :capture_log | |
| 20 | + | |
| 21 | + @deploy """ | |
| 22 | + name: deploy | |
| 23 | + on: | |
| 24 | + workflow_dispatch: | |
| 25 | + inputs: | |
| 26 | + environment: | |
| 27 | + type: choice | |
| 28 | + options: [staging, production] | |
| 29 | + default: staging | |
| 30 | + dry_run: | |
| 31 | + type: boolean | |
| 32 | + reason: | |
| 33 | + required: true | |
| 34 | + jobs: | |
| 35 | + deploy: | |
| 36 | + runs-on: self-hosted | |
| 37 | + steps: | |
| 38 | + - run: echo hi | |
| 39 | + """ | |
| 40 | + | |
| 41 | + @push_only """ | |
| 42 | + name: ci | |
| 43 | + on: push | |
| 44 | + jobs: | |
| 45 | + build: | |
| 46 | + runs-on: self-hosted | |
| 47 | + steps: | |
| 48 | + - run: echo hi | |
| 49 | + """ | |
| 50 | + | |
| 51 | + setup %{conn: conn} do | |
| 52 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 53 | + | |
| 54 | + head = | |
| 55 | + commit!( | |
| 56 | + repo, | |
| 57 | + %{ | |
| 58 | + ".forgejo/workflows/deploy.yml" => @deploy, | |
| 59 | + ".forgejo/workflows/ci.yml" => @push_only | |
| 60 | + }, | |
| 61 | + [] | |
| 62 | + ) | |
| 63 | + | |
| 64 | + git!(repo.disk_path, ["update-ref", "refs/heads/main", head]) | |
| 65 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 66 | + path = "/r/#{Storage.repo_handle(repo)}/#{repo.name}/actions" | |
| 67 | + {:ok, conn: conn, owner: owner, repo: repo, path: path} | |
| 68 | + end | |
| 69 | + | |
| 70 | + test "only the dispatchable workflow gets a button, and only for a writer", %{ | |
| 71 | + conn: conn, | |
| 72 | + owner: owner, | |
| 73 | + path: path | |
| 74 | + } do | |
| 75 | + {:ok, view, _html} = live(log_in_user(conn, owner), path) | |
| 76 | + assert has_element?(view, "#dispatchable-0 #run-workflow-0") | |
| 77 | + refute has_element?(view, "#dispatchable-1") | |
| 78 | + | |
| 79 | + {:ok, anon, _html} = live(build_conn(), path) | |
| 80 | + refute has_element?(anon, "#dispatchable-workflows") | |
| 81 | + | |
| 82 | + {:ok, other, _html} = live(log_in_user(build_conn(), user_fixture()), path) | |
| 83 | + refute has_element?(other, "#dispatchable-workflows") | |
| 84 | + end | |
| 85 | + | |
| 86 | + test "submitting the form starts a run with its inputs", %{ | |
| 87 | + conn: conn, | |
| 88 | + owner: owner, | |
| 89 | + repo: repo, | |
| 90 | + path: path | |
| 91 | + } do | |
| 92 | + {:ok, view, _html} = live(log_in_user(conn, owner), path) | |
| 93 | + view |> element("#run-workflow-0") |> render_click() | |
| 94 | + | |
| 95 | + assert has_element?(view, "#dispatch-form select#dispatch-input-environment") | |
| 96 | + assert has_element?(view, "#dispatch-form input[type=checkbox]#dispatch-input-dry_run") | |
| 97 | + | |
| 98 | + view | |
| 99 | + |> form("#dispatch-form", %{ | |
| 100 | + "dispatch" => %{"ref" => "main"}, | |
| 101 | + "inputs" => %{"environment" => "production", "dry_run" => "true", "reason" => "go"} | |
| 102 | + }) | |
| 103 | + |> render_submit() | |
| 104 | + | |
| 105 | + assert [run] = | |
| 106 | + Repo.all( | |
| 107 | + from r in WorkflowRun, | |
| 108 | + where: r.repository_id == ^repo.id and r.trigger == "workflow_dispatch" | |
| 109 | + ) | |
| 110 | + | |
| 111 | + assert run.triggered_by_id == owner.id | |
| 112 | + | |
| 113 | + assert run.event_payload["inputs"] == %{ | |
| 114 | + "environment" => "production", | |
| 115 | + "dry_run" => "true", | |
| 116 | + "reason" => "go" | |
| 117 | + } | |
| 118 | + | |
| 119 | + assert_redirect(view, "#{path}/#{run.number}") | |
| 120 | + end | |
| 121 | + | |
| 122 | + test "a missing required input is a flash, not a run", %{ | |
| 123 | + conn: conn, | |
| 124 | + owner: owner, | |
| 125 | + repo: repo, | |
| 126 | + path: path | |
| 127 | + } do | |
| 128 | + {:ok, view, _html} = live(log_in_user(conn, owner), path) | |
| 129 | + view |> element("#run-workflow-0") |> render_click() | |
| 130 | + | |
| 131 | + html = | |
| 132 | + view | |
| 133 | + |> form("#dispatch-form", %{"inputs" => %{"reason" => ""}}) | |
| 134 | + |> render_submit() | |
| 135 | + | |
| 136 | + assert html =~ "reason" | |
| 137 | + assert has_element?(view, "#dispatch-form") | |
| 138 | + assert Repo.aggregate(where(WorkflowRun, repository_id: ^repo.id), :count) == 0 | |
| 139 | + end | |
| 140 | +end |
Parents: 2cafbbf