neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Mobile API: job reasons, Run workflow with inputs, and approving fork runs
1d7ef31 · Gabriel Morell · 2026-10-17 19:10
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/workflows.ex
+15
−0
@@ -2667,6 +2667,21 @@ defmodule GitGud.Workflows do
| 2667 | 2667 | end |
| 2668 | 2668 | end |
| 2669 | 2669 | |
| 2670 | + @doc """ | |
| 2671 | + Whether `job` was cancelled because a newer run or job in its | |
| 2672 | + concurrency group replaced it — superseded rather than failed, which | |
| 2673 | + its commit status reports as `cancelled`. | |
| 2674 | + """ | |
| 2675 | + def superseded_job?(%WorkflowJob{status: "cancelled"} = job), do: superseded_by_group?(job) | |
| 2676 | + def superseded_job?(_job), do: false | |
| 2677 | + | |
| 2678 | + @doc """ | |
| 2679 | + Whether `job` is a dynamic matrix's placeholder: the one row standing | |
| 2680 | + in for a matrix that can't be expanded until its needs finish. It is | |
| 2681 | + deleted and replaced by its legs (new rows, new ids) when they do. | |
| 2682 | + """ | |
| 2683 | + def matrix_placeholder?(%WorkflowJob{} = job), do: placeholder?(job) | |
| 2684 | + | |
| 2670 | 2685 | # Cancelled by a newer member of its run's group, or of its own. |
| 2671 | 2686 | defp superseded_by_group?(%WorkflowJob{status_reason: @run_superseded <> _}), do: true |
| 2672 | 2687 | defp superseded_by_group?(job), do: superseded?(job) |
modified
lib/git_gud_web/controllers/api/mobile/ci_controller.ex
+119
−0
@@ -129,8 +129,127 @@ defmodule GitGudWeb.Api.Mobile.CiController do
| 129 | 129 | end |
| 130 | 130 | end |
| 131 | 131 | |
| 132 | + @doc """ | |
| 133 | + The workflows that can be run by hand — the default branch's files | |
| 134 | + with `on: workflow_dispatch` — with their declared inputs, the | |
| 135 | + branches to run them on, and whether the viewer may (write access, as | |
| 136 | + on the web's "Run workflow" form). | |
| 137 | + """ | |
| 138 | + def workflows(conn, %{"owner" => owner, "name" => name}) do | |
| 139 | + with {:ok, user} <- Auth.require_scope(conn, "ci:read"), | |
| 140 | + {:ok, repo} <- fetch_repo(conn, user, owner, name) do | |
| 141 | + json(conn, %{ | |
| 142 | + "workflows" => | |
| 143 | + for( | |
| 144 | + {path, doc} <- Workflows.dispatchable_workflows(repo), | |
| 145 | + do: JSON.dispatch_workflow(path, doc) | |
| 146 | + ), | |
| 147 | + "default_branch" => repo.default_branch, | |
| 148 | + "branches" => branches(repo), | |
| 149 | + "can_dispatch" => GitGud.Organizations.can?(user, repo, :write) == true | |
| 150 | + }) | |
| 151 | + else | |
| 152 | + {:error, conn} -> conn | |
| 153 | + end | |
| 154 | + end | |
| 155 | + | |
| 156 | + @doc """ | |
| 157 | + `workflow_dispatch`: run `workflow` (a path from `workflows/2`, or a | |
| 158 | + basename) on `ref` (default: the default branch) with `inputs`. | |
| 159 | + Answers 201 with the new run; 422 with `message` and per-input | |
| 160 | + `errors` when the inputs don't fit what the workflow declares. | |
| 161 | + """ | |
| 162 | + def dispatch(conn, %{"owner" => owner, "name" => name} = params) do | |
| 163 | + with {:ok, user} <- Auth.require_scope(conn, "ci:write"), | |
| 164 | + {:ok, repo} <- fetch_repo(conn, user, owner, name, :write), | |
| 165 | + {:ok, workflow} <- require_string(conn, params, "workflow"), | |
| 166 | + {:ok, inputs} <- inputs_param(conn, params["inputs"]) do | |
| 167 | + ref = blank_to_nil(params["ref"]) || repo.default_branch || "" | |
| 168 | + | |
| 169 | + case Workflows.dispatch_manual(repo, workflow, ref, | |
| 170 | + inputs: inputs, | |
| 171 | + triggered_by_id: user.id | |
| 172 | + ) do | |
| 173 | + {:ok, run} -> | |
| 174 | + conn | |
| 175 | + |> put_status(201) | |
| 176 | + |> json(%{"run" => JSON.run(GitGud.Repo.preload(run, :triggered_by))}) | |
| 177 | + | |
| 178 | + {:error, {:invalid_inputs, errors}} -> | |
| 179 | + conn | |
| 180 | + |> put_status(422) | |
| 181 | + |> json(%{ | |
| 182 | + "message" => Workflows.DispatchInputs.describe(errors), | |
| 183 | + "errors" => Map.new(errors) | |
| 184 | + }) | |
| 185 | + | |
| 186 | + {:error, :unknown_ref} -> | |
| 187 | + send_error(conn, 422, "no such branch or tag: #{ref}") | |
| 188 | + | |
| 189 | + {:error, :unknown_workflow} -> | |
| 190 | + send_error(conn, 404, "no such workflow: #{workflow}") | |
| 191 | + | |
| 192 | + {:error, :not_dispatchable} -> | |
| 193 | + send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger on #{ref}") | |
| 194 | + | |
| 195 | + {:error, %Ecto.Changeset{} = cs} -> | |
| 196 | + send_error(conn, 422, changeset_message(cs)) | |
| 197 | + | |
| 198 | + {:error, reason} -> | |
| 199 | + send_error(conn, 422, inspect(reason)) | |
| 200 | + end | |
| 201 | + else | |
| 202 | + {:error, conn} -> conn | |
| 203 | + end | |
| 204 | + end | |
| 205 | + | |
| 132 | 206 | # ── internals ──────────────────────────────────────────────────────── |
| 133 | 207 | |
| 208 | + defp branches(repo) do | |
| 209 | + case GitGud.Git.list_refs(repo.disk_path) do | |
| 210 | + {:ok, refs} -> | |
| 211 | + refs | |
| 212 | + |> Enum.flat_map(fn | |
| 213 | + %{name: "refs/heads/" <> b} -> [b] | |
| 214 | + _ -> [] | |
| 215 | + end) | |
| 216 | + |> Enum.sort() | |
| 217 | + | |
| 218 | + _ -> | |
| 219 | + [] | |
| 220 | + end | |
| 221 | + end | |
| 222 | + | |
| 223 | + defp require_string(conn, params, key) do | |
| 224 | + case blank_to_nil(params[key]) do | |
| 225 | + nil -> {:error, send_error(conn, 422, "#{key} is required")} | |
| 226 | + value -> {:ok, value} | |
| 227 | + end | |
| 228 | + end | |
| 229 | + | |
| 230 | + defp blank_to_nil(value) when is_binary(value) do | |
| 231 | + case String.trim(value) do | |
| 232 | + "" -> nil | |
| 233 | + trimmed -> trimmed | |
| 234 | + end | |
| 235 | + end | |
| 236 | + | |
| 237 | + defp blank_to_nil(_value), do: nil | |
| 238 | + | |
| 239 | + # Strings, as the web form and the v1 API send them; booleans and | |
| 240 | + # numbers too, since a JSON client may well send a switch as `true`. | |
| 241 | + # `DispatchInputs` checks each against its declared type. | |
| 242 | + defp inputs_param(_conn, nil), do: {:ok, %{}} | |
| 243 | + | |
| 244 | + defp inputs_param(conn, %{} = inputs) do | |
| 245 | + if Enum.all?(inputs, fn {_k, v} -> is_binary(v) or is_boolean(v) or is_number(v) end), | |
| 246 | + do: {:ok, inputs}, | |
| 247 | + else: {:error, send_error(conn, 422, "inputs must be an object of strings")} | |
| 248 | + end | |
| 249 | + | |
| 250 | + defp inputs_param(conn, _inputs), | |
| 251 | + do: {:error, send_error(conn, 422, "inputs must be an object of strings")} | |
| 252 | + | |
| 134 | 253 | defp get_run(conn, repo, number) do |
| 135 | 254 | {:ok, Workflows.get_run!(repo, number)} |
| 136 | 255 | rescue |
modified
lib/git_gud_web/controllers/api/mobile/json.ex
+46
−0
@@ -126,6 +126,9 @@ defmodule GitGudWeb.Api.Mobile.JSON do
| 126 | 126 | "completed_at" => timestamp(run.completed_at), |
| 127 | 127 | "inserted_at" => timestamp(run.inserted_at), |
| 128 | 128 | "triggered_by" => user(loaded(run.triggered_by)), |
| 129 | + # The pull request a `pull_request` / `pull_request_target` run is | |
| 130 | + # for, so the run screen can link back to where it's approved. | |
| 131 | + "pull_request_number" => run.pull_request_number, | |
| 129 | 132 | # A fork pull request's run (no secrets, read-only token), and |
| 130 | 133 | # whether it is still waiting for someone with write access to |
| 131 | 134 | # approve it — its jobs are `awaiting_approval` meanwhile. |
@@ -143,12 +146,55 @@ defmodule GitGudWeb.Api.Mobile.JSON do
| 143 | 146 | "conclusion" => job.conclusion, |
| 144 | 147 | "matrix" => job.matrix, |
| 145 | 148 | "needs" => job.needs, |
| 149 | + # Why it's waiting, skipped or cancelled, or why its matrix | |
| 150 | + # couldn't expand — worded for display, nil when there's nothing | |
| 151 | + # to explain. | |
| 152 | + "status_reason" => job.status_reason, | |
| 153 | + # `github.run_attempt`: 1, bumped by each re-run of the job. | |
| 154 | + "attempt" => job.attempt, | |
| 155 | + # Cancelled by a newer run or job in its concurrency group: news, | |
| 156 | + # but not bad news. | |
| 157 | + "superseded" => GitGud.Workflows.superseded_job?(job), | |
| 158 | + # A dynamic matrix not expanded yet. Its row is replaced by its | |
| 159 | + # legs (with new ids) when it expands, so a client holding this id | |
| 160 | + # must re-read the run rather than keep polling the job. | |
| 161 | + "placeholder" => GitGud.Workflows.matrix_placeholder?(job), | |
| 146 | 162 | "started_at" => timestamp(job.started_at), |
| 147 | 163 | "completed_at" => timestamp(job.completed_at), |
| 148 | 164 | "steps" => job.steps |> loaded([]) |> Enum.sort_by(& &1.number) |> Enum.map(&step/1) |
| 149 | 165 | } |
| 150 | 166 | end |
| 151 | 167 | |
| 168 | + @doc """ | |
| 169 | + A workflow the viewer can run by hand (`on: workflow_dispatch` on the | |
| 170 | + default branch), with the inputs its form needs. `inputs` come sorted | |
| 171 | + by name, as the web form lists them; `type` is GitHub's `string`, | |
| 172 | + `boolean`, `choice`, `number` or `environment`, and `default` a string | |
| 173 | + (or nil) whatever the YAML wrote. | |
| 174 | + """ | |
| 175 | + def dispatch_workflow(path, doc) do | |
| 176 | + inputs = | |
| 177 | + doc | |
| 178 | + |> GitGud.Workflows.DispatchInputs.declared() | |
| 179 | + |> Enum.sort_by(&elem(&1, 0)) | |
| 180 | + |> Enum.map(fn {name, spec} -> | |
| 181 | + %{ | |
| 182 | + "name" => name, | |
| 183 | + "type" => GitGud.Workflows.DispatchInputs.type(spec), | |
| 184 | + "description" => spec["description"], | |
| 185 | + "required" => spec["required"] in [true, "true"], | |
| 186 | + "default" => if(spec["default"] == nil, do: nil, else: to_string(spec["default"])), | |
| 187 | + "options" => GitGud.Workflows.DispatchInputs.options(spec) | |
| 188 | + } | |
| 189 | + end) | |
| 190 | + | |
| 191 | + %{ | |
| 192 | + "path" => path, | |
| 193 | + "name" => doc.name || Path.basename(path), | |
| 194 | + "inputs" => inputs | |
| 195 | + } | |
| 196 | + end | |
| 197 | + | |
| 152 | 198 | def step(step) do |
| 153 | 199 | %{ |
| 154 | 200 | "id" => step.id, |
modified
lib/git_gud_web/controllers/api/mobile/pull_request_controller.ex
+52
−1
@@ -12,6 +12,11 @@ defmodule GitGudWeb.Api.Mobile.PullRequestController do
| 12 | 12 | useful on a phone — approving a green PR from a bus stop. It enforces |
| 13 | 13 | the repo's review rules; `override` is accepted from repo admins and |
| 14 | 14 | recorded as an override, exactly as the web UI does it. |
| 15 | + | |
| 16 | + So is approving (or rejecting) a fork's workflow runs: they wait, | |
| 17 | + without secrets, until someone with write access says the code may | |
| 18 | + run — and that someone is often away from a desk. Those two need | |
| 19 | + `ci:write`, since what they start or stop is CI. | |
| 15 | 20 | """ |
| 16 | 21 | use GitGudWeb, :controller |
| 17 | 22 |
@@ -61,7 +66,11 @@ defmodule GitGudWeb.Api.Mobile.PullRequestController do
| 61 | 66 | "can_comment" => Organizations.can?(user, repo, :read), |
| 62 | 67 | "can_review" => Organizations.can?(user, repo, :read) and pr.author_id != user.id, |
| 63 | 68 | "can_merge" => pr.state == "open" and Organizations.can?(user, repo, :write), |
| 64 | − "can_override" => Organizations.can?(user, repo, :admin) | |
| 69 | + "can_override" => Organizations.can?(user, repo, :admin), | |
| 70 | + # A fork's workflow runs on the current head that nobody with | |
| 71 | + # write access has approved yet, and whether the viewer may. | |
| 72 | + "awaiting_approval_runs" => pr |> awaiting_runs() |> Enum.map(&JSON.run/1), | |
| 73 | + "can_approve_runs" => Organizations.can?(user, repo, :write) == true | |
| 65 | 74 | }) |
| 66 | 75 | else |
| 67 | 76 | {:error, conn} -> conn |
@@ -154,8 +163,50 @@ defmodule GitGudWeb.Api.Mobile.PullRequestController do
| 154 | 163 | end |
| 155 | 164 | end |
| 156 | 165 | |
| 166 | + @doc """ | |
| 167 | + Approve the fork runs waiting on the pull request's current head | |
| 168 | + (`Workflows.approve_pull_request_runs/2`, which checks write access). | |
| 169 | + Answers with the runs it approved. | |
| 170 | + """ | |
| 171 | + def approve_runs(conn, %{"owner" => owner, "name" => name, "number" => number}) do | |
| 172 | + fork_runs(conn, owner, name, number, &GitGud.Workflows.approve_pull_request_runs/2) | |
| 173 | + end | |
| 174 | + | |
| 175 | + @doc """ | |
| 176 | + Cancel the fork runs waiting for approval on the pull request, on any | |
| 177 | + head (`Workflows.reject_pull_request_runs/2`). Answers with the runs it | |
| 178 | + cancelled. | |
| 179 | + """ | |
| 180 | + def reject_runs(conn, %{"owner" => owner, "name" => name, "number" => number}) do | |
| 181 | + fork_runs(conn, owner, name, number, &GitGud.Workflows.reject_pull_request_runs/2) | |
| 182 | + end | |
| 183 | + | |
| 184 | + defp fork_runs(conn, owner, name, number, action) do | |
| 185 | + with {:ok, user} <- Auth.require_scope(conn, "ci:write"), | |
| 186 | + {:ok, repo} <- fetch_repo(conn, user, owner, name), | |
| 187 | + {:ok, n} <- number_param(conn, number), | |
| 188 | + {:ok, pr} <- get_pull(conn, repo, n) do | |
| 189 | + case action.(pr, user) do | |
| 190 | + {:ok, runs} -> | |
| 191 | + json(conn, %{"runs" => Enum.map(runs, &JSON.run/1)}) | |
| 192 | + | |
| 193 | + {:error, :forbidden} -> | |
| 194 | + send_error(conn, 403, "requires write access to #{owner}/#{name}") | |
| 195 | + end | |
| 196 | + else | |
| 197 | + {:error, conn} -> conn | |
| 198 | + end | |
| 199 | + end | |
| 200 | + | |
| 157 | 201 | # ── internals ──────────────────────────────────────────────────────── |
| 158 | 202 | |
| 203 | + defp awaiting_runs(pr) do | |
| 204 | + pr | |
| 205 | + |> GitGud.Workflows.awaiting_approval_runs() | |
| 206 | + |> Enum.filter(&(&1.head_sha == pr.head_sha)) | |
| 207 | + |> GitGud.Repo.preload(:triggered_by) | |
| 208 | + end | |
| 209 | + | |
| 159 | 210 | defp get_pull(conn, repo, number) do |
| 160 | 211 | {:ok, PullRequests.get_pull_request!(repo, number)} |
| 161 | 212 | rescue |
modified
lib/git_gud_web/router.ex
+4
−0
@@ -350,6 +350,8 @@ defmodule GitGudWeb.Router do
| 350 | 350 | post "/repos/:owner/:name/runs/:number/jobs/:job/rerun", CiController, :rerun_job |
| 351 | 351 | post "/repos/:owner/:name/runs/:number/cancel", CiController, :cancel |
| 352 | 352 | get "/repos/:owner/:name/jobs/:job_id/logs", CiController, :logs |
| 353 | + get "/repos/:owner/:name/workflows", CiController, :workflows | |
| 354 | + post "/repos/:owner/:name/workflows/dispatch", CiController, :dispatch | |
| 353 | 355 | |
| 354 | 356 | get "/repos/:owner/:name/issues", IssueController, :index |
| 355 | 357 | get "/repos/:owner/:name/issues/:number", IssueController, :show |
@@ -361,6 +363,8 @@ defmodule GitGudWeb.Router do
| 361 | 363 | post "/repos/:owner/:name/pulls/:number/comments", PullRequestController, :comment |
| 362 | 364 | post "/repos/:owner/:name/pulls/:number/reviews", PullRequestController, :review |
| 363 | 365 | post "/repos/:owner/:name/pulls/:number/merge", PullRequestController, :merge |
| 366 | + post "/repos/:owner/:name/pulls/:number/runs/approve", PullRequestController, :approve_runs | |
| 367 | + post "/repos/:owner/:name/pulls/:number/runs/reject", PullRequestController, :reject_runs | |
| 364 | 368 | |
| 365 | 369 | get "/repos/:owner/:name/wiki", WikiController, :index |
| 366 | 370 | get "/repos/:owner/:name/wiki/:slug", WikiController, :show |
added
test/git_gud_web/controllers/api/mobile/mobile_ci_actions_test.exs
+319
−0
@@ -0,0 +1,319 @@
| 1 | +defmodule GitGudWeb.Api.Mobile.MobileCiActionsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + What the phone needs from the newer CI: why a job is where it is | |
| 4 | + (`status_reason`, `attempt`, superseded, matrix placeholders), "Run | |
| 5 | + workflow" with inputs, and approving a fork's waiting runs. | |
| 6 | + """ | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Ecto.Query | |
| 10 | + import GitGud.AccountsFixtures | |
| 11 | + import GitGud.ForgeFixtures | |
| 12 | + import GitGud.WorkflowGitHelpers | |
| 13 | + | |
| 14 | + alias GitGud.Accounts | |
| 15 | + alias GitGud.Accounts.PersonalAccessToken | |
| 16 | + alias GitGud.Repo | |
| 17 | + alias GitGud.Workflows.WorkflowJob | |
| 18 | + alias GitGud.Workflows.WorkflowRun | |
| 19 | + | |
| 20 | + @moduletag :capture_log | |
| 21 | + | |
| 22 | + @base "/api/mobile/v1" | |
| 23 | + | |
| 24 | + @deploy """ | |
| 25 | + name: deploy | |
| 26 | + on: | |
| 27 | + workflow_dispatch: | |
| 28 | + inputs: | |
| 29 | + environment: | |
| 30 | + type: choice | |
| 31 | + options: [staging, production] | |
| 32 | + default: staging | |
| 33 | + dry_run: | |
| 34 | + type: boolean | |
| 35 | + default: true | |
| 36 | + reason: | |
| 37 | + description: Why now | |
| 38 | + required: true | |
| 39 | + jobs: | |
| 40 | + deploy: | |
| 41 | + runs-on: self-hosted | |
| 42 | + steps: | |
| 43 | + - run: echo hi | |
| 44 | + """ | |
| 45 | + | |
| 46 | + @push_only """ | |
| 47 | + name: ci | |
| 48 | + on: push | |
| 49 | + jobs: | |
| 50 | + build: | |
| 51 | + runs-on: self-hosted | |
| 52 | + steps: | |
| 53 | + - run: echo hi | |
| 54 | + """ | |
| 55 | + | |
| 56 | + setup %{conn: conn} do | |
| 57 | + {user, repo} = repository_fixture() | |
| 58 | + %{conn: mobile_conn(conn, user), user: user, repo: repo, path: repo_path(user, repo)} | |
| 59 | + end | |
| 60 | + | |
| 61 | + defp mobile_conn(conn, user) do | |
| 62 | + {:ok, _t, token} = | |
| 63 | + Accounts.create_personal_access_token( | |
| 64 | + user, | |
| 65 | + "phone-#{System.unique_integer([:positive])}", | |
| 66 | + PersonalAccessToken.mobile_scopes() | |
| 67 | + ) | |
| 68 | + | |
| 69 | + conn | |
| 70 | + |> put_req_header("authorization", "Bearer #{token}") | |
| 71 | + |> put_req_header("accept", "application/json") | |
| 72 | + end | |
| 73 | + | |
| 74 | + defp repo_path(owner, repo), do: "#{@base}/repos/#{owner.handle}/#{repo.name}" | |
| 75 | + | |
| 76 | + defp insert_run(repo, attrs \\ %{}) do | |
| 77 | + %WorkflowRun{repository_id: repo.id} | |
| 78 | + |> WorkflowRun.create_changeset( | |
| 79 | + Map.merge( | |
| 80 | + %{ | |
| 81 | + "number" => 1, | |
| 82 | + "workflow_path" => ".forgejo/workflows/ci.yml", | |
| 83 | + "workflow_name" => "ci", | |
| 84 | + "trigger" => "push", | |
| 85 | + "head_ref" => "refs/heads/main", | |
| 86 | + "head_sha" => :crypto.strong_rand_bytes(20), | |
| 87 | + "workflow_doc" => %{"name" => "ci", "on" => "push", "jobs" => %{}} | |
| 88 | + }, | |
| 89 | + attrs | |
| 90 | + ) | |
| 91 | + ) | |
| 92 | + |> Repo.insert!() | |
| 93 | + end | |
| 94 | + | |
| 95 | + defp insert_job(run, job_id, attrs \\ %{}) do | |
| 96 | + %WorkflowJob{workflow_run_id: run.id} | |
| 97 | + |> WorkflowJob.create_changeset( | |
| 98 | + Map.merge( | |
| 99 | + %{job_id: job_id, name: job_id, runs_on: %{"value" => "ubuntu-latest"}, definition: %{}}, | |
| 100 | + attrs | |
| 101 | + ) | |
| 102 | + ) | |
| 103 | + |> Repo.insert!() | |
| 104 | + end | |
| 105 | + | |
| 106 | + defp set_job(job, changes), do: job |> Ecto.Changeset.change(changes) |> Repo.update!() | |
| 107 | + | |
| 108 | + describe "job JSON" do | |
| 109 | + test "carries status_reason, attempt, superseded and placeholder", ctx do | |
| 110 | + run = insert_run(ctx.repo) | |
| 111 | + | |
| 112 | + insert_job(run, "plain") | |
| 113 | + |> set_job(status: "waiting", status_reason: "Waiting for deploy-prod", attempt: 3) | |
| 114 | + | |
| 115 | + insert_job(run, "old") | |
| 116 | + |> set_job(status: "cancelled", status_reason: "Cancelled by a newer job in deploy") | |
| 117 | + | |
| 118 | + insert_job(run, "stopped") |> set_job(status: "cancelled", status_reason: "Cancelled") | |
| 119 | + | |
| 120 | + insert_job(run, "legs", %{ | |
| 121 | + definition: %{ | |
| 122 | + "strategy" => %{"matrix" => "${{ fromJSON(needs.setup.outputs.matrix) }}"} | |
| 123 | + } | |
| 124 | + }) | |
| 125 | + |> set_job(status: "blocked") | |
| 126 | + | |
| 127 | + body = ctx.conn |> get("#{ctx.path}/runs/#{run.number}") |> json_response(200) | |
| 128 | + jobs = Map.new(body["jobs"], &{&1["job_id"], &1}) | |
| 129 | + | |
| 130 | + assert %{"status" => "waiting", "attempt" => 3} = jobs["plain"] | |
| 131 | + assert jobs["plain"]["status_reason"] == "Waiting for deploy-prod" | |
| 132 | + refute jobs["plain"]["superseded"] | |
| 133 | + refute jobs["plain"]["placeholder"] | |
| 134 | + | |
| 135 | + assert jobs["old"]["superseded"] | |
| 136 | + refute jobs["stopped"]["superseded"] | |
| 137 | + | |
| 138 | + assert jobs["legs"]["placeholder"] | |
| 139 | + assert jobs["legs"]["attempt"] == 1 | |
| 140 | + end | |
| 141 | + | |
| 142 | + test "a job that was replaced 404s its logs", ctx do | |
| 143 | + run = insert_run(ctx.repo) | |
| 144 | + job = insert_job(run, "legs") | |
| 145 | + Repo.delete!(job) | |
| 146 | + | |
| 147 | + assert %{"message" => _} = | |
| 148 | + ctx.conn |> get("#{ctx.path}/jobs/#{job.id}/logs") |> json_response(404) | |
| 149 | + end | |
| 150 | + end | |
| 151 | + | |
| 152 | + describe "run workflow" do | |
| 153 | + setup ctx do | |
| 154 | + head = | |
| 155 | + commit!( | |
| 156 | + ctx.repo, | |
| 157 | + %{ | |
| 158 | + ".forgejo/workflows/deploy.yml" => @deploy, | |
| 159 | + ".forgejo/workflows/ci.yml" => @push_only | |
| 160 | + }, | |
| 161 | + [] | |
| 162 | + ) | |
| 163 | + | |
| 164 | + git!(ctx.repo.disk_path, ["update-ref", "refs/heads/main", head]) | |
| 165 | + git!(ctx.repo.disk_path, ["update-ref", "refs/heads/release", head]) | |
| 166 | + :ok | |
| 167 | + end | |
| 168 | + | |
| 169 | + test "lists the dispatchable workflows with their inputs and branches", ctx do | |
| 170 | + body = ctx.conn |> get("#{ctx.path}/workflows") |> json_response(200) | |
| 171 | + | |
| 172 | + assert body["can_dispatch"] | |
| 173 | + assert body["default_branch"] == ctx.repo.default_branch | |
| 174 | + assert "main" in body["branches"] and "release" in body["branches"] | |
| 175 | + | |
| 176 | + assert [ | |
| 177 | + %{ | |
| 178 | + "path" => ".forgejo/workflows/deploy.yml", | |
| 179 | + "name" => "deploy", | |
| 180 | + "inputs" => inputs | |
| 181 | + } | |
| 182 | + ] = | |
| 183 | + body["workflows"] | |
| 184 | + | |
| 185 | + assert Enum.map(inputs, & &1["name"]) == ["dry_run", "environment", "reason"] | |
| 186 | + [dry_run, environment, reason] = inputs | |
| 187 | + | |
| 188 | + assert %{"type" => "boolean", "default" => "true", "required" => false} = dry_run | |
| 189 | + | |
| 190 | + assert %{"type" => "choice", "options" => ["staging", "production"], "default" => "staging"} = | |
| 191 | + environment | |
| 192 | + | |
| 193 | + assert %{"type" => "string", "required" => true, "description" => "Why now"} = reason | |
| 194 | + end | |
| 195 | + | |
| 196 | + test "a reader sees the list but can't dispatch", ctx do | |
| 197 | + {:ok, repo} = GitGud.Repositories.update_repository(ctx.repo, %{"visibility" => "public"}) | |
| 198 | + conn = mobile_conn(build_conn(), user_fixture()) | |
| 199 | + | |
| 200 | + body = conn |> get("#{ctx.path}/workflows") |> json_response(200) | |
| 201 | + refute body["can_dispatch"] | |
| 202 | + | |
| 203 | + assert conn | |
| 204 | + |> post("#{repo_path(ctx.user, repo)}/workflows/dispatch", %{ | |
| 205 | + "workflow" => ".forgejo/workflows/deploy.yml", | |
| 206 | + "inputs" => %{"reason" => "x"} | |
| 207 | + }) | |
| 208 | + |> json_response(403) | |
| 209 | + end | |
| 210 | + | |
| 211 | + test "dispatches with inputs and answers with the run", ctx do | |
| 212 | + body = | |
| 213 | + ctx.conn | |
| 214 | + |> post("#{ctx.path}/workflows/dispatch", %{ | |
| 215 | + "workflow" => ".forgejo/workflows/deploy.yml", | |
| 216 | + "ref" => "release", | |
| 217 | + "inputs" => %{"reason" => "hotfix", "environment" => "production", "dry_run" => false} | |
| 218 | + }) | |
| 219 | + |> json_response(201) | |
| 220 | + | |
| 221 | + assert %{"trigger" => "workflow_dispatch", "head_ref" => "refs/heads/release"} = body["run"] | |
| 222 | + assert body["run"]["triggered_by"]["handle"] == ctx.user.handle | |
| 223 | + | |
| 224 | + run = Repo.one!(from r in WorkflowRun, where: r.repository_id == ^ctx.repo.id) | |
| 225 | + | |
| 226 | + assert run.event_payload["inputs"] == %{ | |
| 227 | + "reason" => "hotfix", | |
| 228 | + "environment" => "production", | |
| 229 | + "dry_run" => "false" | |
| 230 | + } | |
| 231 | + end | |
| 232 | + | |
| 233 | + test "422s inputs that don't fit, naming each", ctx do | |
| 234 | + body = | |
| 235 | + ctx.conn | |
| 236 | + |> post("#{ctx.path}/workflows/dispatch", %{ | |
| 237 | + "workflow" => "deploy.yml", | |
| 238 | + "inputs" => %{"environment" => "moon"} | |
| 239 | + }) | |
| 240 | + |> json_response(422) | |
| 241 | + | |
| 242 | + assert body["errors"]["reason"] == "is required" | |
| 243 | + assert body["errors"]["environment"] =~ "staging, production" | |
| 244 | + assert body["message"] =~ "input reason is required" | |
| 245 | + end | |
| 246 | + | |
| 247 | + test "refuses a workflow without workflow_dispatch, and an unknown one", ctx do | |
| 248 | + assert ctx.conn | |
| 249 | + |> post("#{ctx.path}/workflows/dispatch", %{"workflow" => "ci.yml"}) | |
| 250 | + |> json_response(422) | |
| 251 | + | |
| 252 | + assert ctx.conn | |
| 253 | + |> post("#{ctx.path}/workflows/dispatch", %{"workflow" => "nope.yml"}) | |
| 254 | + |> json_response(404) | |
| 255 | + | |
| 256 | + assert ctx.conn | |
| 257 | + |> post("#{ctx.path}/workflows/dispatch", %{}) | |
| 258 | + |> json_response(422) | |
| 259 | + end | |
| 260 | + end | |
| 261 | + | |
| 262 | + describe "fork run approval" do | |
| 263 | + setup ctx do | |
| 264 | + pr = pr_with_branches(ctx.repo, ctx.user) | |
| 265 | + | |
| 266 | + run = | |
| 267 | + insert_run(ctx.repo, %{ | |
| 268 | + "trigger" => "pull_request", | |
| 269 | + "head_sha" => pr.head_sha, | |
| 270 | + "pull_request_number" => pr.number, | |
| 271 | + "fork" => true | |
| 272 | + }) | |
| 273 | + | |
| 274 | + job = insert_job(run, "build") |> set_job(status: "awaiting_approval") | |
| 275 | + %{pr: pr, run: run, job: job, pr_path: "#{ctx.path}/pulls/#{pr.number}"} | |
| 276 | + end | |
| 277 | + | |
| 278 | + test "the PR says which runs wait and that the viewer may approve", ctx do | |
| 279 | + body = ctx.conn |> get(ctx.pr_path) |> json_response(200) | |
| 280 | + | |
| 281 | + assert [%{"number" => number, "fork" => true, "awaiting_approval" => true}] = | |
| 282 | + body["awaiting_approval_runs"] | |
| 283 | + | |
| 284 | + assert number == ctx.run.number | |
| 285 | + assert body["can_approve_runs"] | |
| 286 | + | |
| 287 | + run = ctx.conn |> get("#{ctx.path}/runs/#{number}") |> json_response(200) | |
| 288 | + assert run["run"]["pull_request_number"] == ctx.pr.number | |
| 289 | + end | |
| 290 | + | |
| 291 | + test "approve queues the waiting jobs", ctx do | |
| 292 | + body = ctx.conn |> post("#{ctx.pr_path}/runs/approve") |> json_response(200) | |
| 293 | + | |
| 294 | + assert [%{"awaiting_approval" => false}] = body["runs"] | |
| 295 | + assert Repo.get!(WorkflowJob, ctx.job.id).status == "queued" | |
| 296 | + | |
| 297 | + assert [] = (ctx.conn |> get(ctx.pr_path) |> json_response(200))["awaiting_approval_runs"] | |
| 298 | + end | |
| 299 | + | |
| 300 | + test "reject cancels them", ctx do | |
| 301 | + body = ctx.conn |> post("#{ctx.pr_path}/runs/reject") |> json_response(200) | |
| 302 | + | |
| 303 | + assert [%{"status" => "cancelled"}] = body["runs"] | |
| 304 | + assert Repo.get!(WorkflowJob, ctx.job.id).status == "cancelled" | |
| 305 | + end | |
| 306 | + | |
| 307 | + test "someone without write access can't do either", ctx do | |
| 308 | + {:ok, _repo} = GitGud.Repositories.update_repository(ctx.repo, %{"visibility" => "public"}) | |
| 309 | + conn = mobile_conn(build_conn(), user_fixture()) | |
| 310 | + | |
| 311 | + body = conn |> get(ctx.pr_path) |> json_response(200) | |
| 312 | + refute body["can_approve_runs"] | |
| 313 | + | |
| 314 | + assert conn |> post("#{ctx.pr_path}/runs/approve") |> json_response(403) | |
| 315 | + assert conn |> post("#{ctx.pr_path}/runs/reject") |> json_response(403) | |
| 316 | + assert Repo.get!(WorkflowJob, ctx.job.id).status == "awaiting_approval" | |
| 317 | + end | |
| 318 | + end | |
| 319 | +end |
Parents: a5b815d