Mobile API: job reasons, Run workflow with inputs, and approving fork runs

1d7ef31 · Gabriel Morell · 2026-10-17 19:10

17 files +555 -1
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