Actions: pull_request closed, edited, labeled and unlabeled fire

8a80762 · Gabriel Morell · 2026-10-17 18:05

18 files +307 -42
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −1
@@ -89,7 +89,7 @@ reinvent them.
89 89 - **Matrix builds**, with `runs-on` as a real label set — `strategy.matrix` expands into one job per combination, each claimed independently, so `runs-on: [self-hosted, "${{ matrix.arch }}"]` sends the arm64 leg to an arm64 machine and the amd64 leg to an amd64 one. `include`/`exclude` follow GitHub's semantics, `fail-fast` defaults on, `max-parallel` caps how many legs of the job run at once, and `needs:` names the group rather than a leg. The matrix, an axis, or `include`/`exclude` can be computed — `matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}` — with the expression subset below plus `fromJSON()`/`toJSON()`; one that reads `needs` shows as a single job until those needs finish, then becomes its legs. A matrix with no combinations left (everything excluded, an empty axis) or an expression that doesn't evaluate to the right shape fails the job, saying why in its log
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 − - **`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
92 + - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default), and — when `types:` names them — `closed` (on close and on merge; `github.event.pull_request.merged` tells them apart, and it is dispatched once, as the PR closes), `edited` (title or body changed) and `labeled` / `unlabeled` (`github.event.label.name`). `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 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)
94 94 - **`on: schedule`** (`- cron:`, five-field cron in UTC): read from the **default branch's** latest commit and run there, whichever branch the workflow file was pushed to; `github.event.schedule` is the cron that fired. Crons that can fire more often than **every five minutes** (GitHub's minimum), `@reboot` and ones that don't parse are ignored with a warning in the log. Archived repositories don't run their schedules
95 95 - **`on: create` / `on: delete`** when a branch or tag is created or deleted (`branches:` / `tags:` filters don't apply, as on GitHub). `create` runs the new ref's workflows at its commit, alongside its `push` run; `delete` runs the **default branch's** workflows at its head, since the ref is gone (nothing runs when the default branch itself is deleted). `github.event.ref` is the short name, `github.event.ref_type` `branch` or `tag`
modified lib/git_gud/pull_requests.ex
+60 −10
@@ -573,22 +573,38 @@ defmodule GitGud.PullRequests do
573 573 :ok
574 574 end
575 575
576 − def update_pull_request(%PullRequest{} = pr, attrs) do
577 − pr
578 − |> PullRequest.update_changeset(attrs)
579 − |> Repo.update()
576 + @doc """
577 + Update a pull request's editable fields. A change to its title or body
578 + starts its `pull_request` `edited` workflows. `opts`: `:actor_id`.
579 + """
580 + def update_pull_request(%PullRequest{} = pr, attrs, opts \\ []) do
581 + changeset = PullRequest.update_changeset(pr, attrs)
582 +
583 + case Repo.update(changeset) do
584 + {:ok, updated} ->
585 + if Map.has_key?(changeset.changes, :title) or Map.has_key?(changeset.changes, :body),
586 + do: :ok = PullRequestDispatch.enqueue(updated, "edited", opts[:actor_id])
587 +
588 + {:ok, updated}
589 +
590 + error ->
591 + error
592 + end
580 593 end
581 594
582 595 @doc """
583 − Open or close a pull request.
596 + Open or close a pull request. Reopening starts its `pull_request`
597 + `reopened` workflows, closing its `closed` ones. `opts`: `:actor_id`.
584 598
585 599 A merged PR is terminal — reopening one would claim the merge commit
586 600 never happened, so it's refused here rather than left to each caller
587 601 to remember.
588 602 """
589 − def set_state(%PullRequest{state: "merged"}, "open"), do: {:error, :merged}
603 + def set_state(pr, state, opts \\ [])
604 +
605 + def set_state(%PullRequest{state: "merged"}, "open", _opts), do: {:error, :merged}
590 606
591 − def set_state(%PullRequest{} = pr, state) when state in ["open", "closed"] do
607 + def set_state(%PullRequest{} = pr, state, opts) when state in ["open", "closed"] do
592 608 result =
593 609 pr
594 610 |> PullRequest.state_changeset(state)
@@ -599,9 +615,13 @@ defmodule GitGud.PullRequests do
599 615 # Pushes to a closed PR's branch don't move its head (only open PRs
600 616 # are refreshed on push), so catch up before its CI runs.
601 617 reopened = refresh_head_on_reopen(reopened)
602 − :ok = PullRequestDispatch.enqueue(reopened, "reopened", nil)
618 + :ok = PullRequestDispatch.enqueue(reopened, "reopened", opts[:actor_id])
603 619 {:ok, reopened}
604 620
621 + {"open", {:ok, %PullRequest{state: "closed"} = closed}} ->
622 + :ok = PullRequestDispatch.enqueue(closed, "closed", opts[:actor_id])
623 + {:ok, closed}
624 +
605 625 _ ->
606 626 result
607 627 end
@@ -710,6 +730,9 @@ defmodule GitGud.PullRequests do
710 730 if pr.target_ref == repo.default_branch,
711 731 do: {:ok, _count} = GitGud.Workflows.Schedules.sync(repo)
712 732
733 + # `closed`, with `merged: true` in the payload, as on GitHub.
734 + :ok = PullRequestDispatch.enqueue(updated, "closed", actor.id)
735 +
713 736 _ = GitGud.Federation.Publishing.publish_pr_merged(repo, updated)
714 737 {:ok, updated}
715 738 end
@@ -1306,8 +1329,35 @@ defmodule GitGud.PullRequests do
1306 1329
1307 1330 # ── labels ───────────────────────────────────────────────────────────
1308 1331
1309 − def attach_label(%PullRequest{id: id}, label), do: Labels.attach(label, {@target_type, id})
1310 − def detach_label(%PullRequest{id: id}, label), do: Labels.detach(label, {@target_type, id})
1332 + @doc """
1333 + Label a pull request. A label it didn't have starts its `pull_request`
1334 + `labeled` workflows (`github.event.label`). `opts`: `:actor_id`.
1335 + """
1336 + def attach_label(%PullRequest{id: id} = pr, label, opts \\ []) do
1337 + had? = labeled?(pr, label)
1338 +
1339 + with :ok <- Labels.attach(label, {@target_type, id}) do
1340 + if not had?, do: :ok = enqueue_label_event(pr, "labeled", label, opts)
1341 + :ok
1342 + end
1343 + end
1344 +
1345 + @doc "Unlabel a pull request; `unlabeled` workflows as for `attach_label/3`."
1346 + def detach_label(%PullRequest{id: id} = pr, label, opts \\ []) do
1347 + had? = labeled?(pr, label)
1348 + :ok = Labels.detach(label, {@target_type, id})
1349 + if had?, do: :ok = enqueue_label_event(pr, "unlabeled", label, opts)
1350 + :ok
1351 + end
1352 +
1353 + defp labeled?(%PullRequest{id: id}, label),
1354 + do: Enum.any?(Labels.labels_for({@target_type, id}), &(&1.id == label.id))
1355 +
1356 + defp enqueue_label_event(pr, action, label, opts) do
1357 + PullRequestDispatch.enqueue(pr, action, opts[:actor_id], %{
1358 + "label" => %{"name" => label.name, "color" => label.color}
1359 + })
1360 + end
1311 1361
1312 1362 @doc "Edit a comment's body, stamping `edited_at`."
1313 1363 def edit_comment(%PrComment{} = comment, attrs) do
modified lib/git_gud/workflows.ex
+16 −10
@@ -229,8 +229,11 @@ defmodule GitGud.Workflows do
229 229 Start the `on: pull_request` workflows for a pull request.
230 230
231 231 `action` is the activity type — `"opened"`, `"synchronize"` (the head
232 − moved) or `"reopened"` — matched against the workflow's `types:`.
233 − `opts`: `:triggered_by_id`.
232 + moved), `"reopened"`, `"closed"` (closed or merged: the payload's
233 + `pull_request.merged` tells them apart), `"edited"` (title or body),
234 + `"labeled"` / `"unlabeled"` — matched against the workflow's `types:`,
235 + whose default is the first three. `opts`: `:triggered_by_id`,
236 + `:payload` (merged into the event payload: `labeled`'s `"label"`).
234 237
235 238 Workflows are read from the PR's head commit, as on GitHub, which is what
236 239 lets a pull request change its own CI. That is also why fork and
@@ -238,14 +241,16 @@ defmodule GitGud.Workflows do
238 241 target's maintainers haven't reviewed, and a job is handed the target
239 242 repository's secrets and a token that can publish its packages. GitHub
240 243 runs those without secrets and with a read-only token; until that exists
241 − here, they don't run. Closed and merged PRs don't run either.
244 + here, they don't run. Closed and merged PRs run nothing but `closed`.
242 245
243 246 The job checks out `refs/pull/<N>/head`, which is pinned to the PR's head
244 247 here. Returns `{:ok, [run]}`.
245 248 """
246 249 def dispatch_pull_request(%PullRequest{} = pr, action, opts \\ []) when is_binary(action) do
247 250 cond do
248 − pr.state != "open" ->
251 + # `closed` is the one activity a closed (or merged) PR has; it is
252 + # dispatched once, as it closes.
253 + pr.state != "open" and not (action == "closed" and pr.state in ~w(closed merged)) ->
249 254 {:ok, []}
250 255
251 256 PullRequest.cross_repo?(pr) or PullRequest.federated?(pr) ->
@@ -304,12 +309,13 @@ defmodule GitGud.Workflows do
304 309 pr_base_ref: Ref.name(target_ref)
305 310 },
306 311 fn ->
307 − EventPayload.pull_request(
308 − Repo.preload(repo, [:owner, :organization]),
309 − Repo.preload(pr, :author),
310 − action,
311 − %{target_sha: target_sha, sender: triggered_by_id && Repo.get(User, triggered_by_id)}
312 − )
312 + repo
313 + |> Repo.preload([:owner, :organization])
314 + |> EventPayload.pull_request(Repo.preload(pr, :author), action, %{
315 + target_sha: target_sha,
316 + sender: triggered_by_id && Repo.get(User, triggered_by_id)
317 + })
318 + |> Map.merge(Keyword.get(opts, :payload, %{}))
313 319 end
314 320 )
315 321
modified lib/git_gud/workflows/event_payload.ex
+11 −2
@@ -79,10 +79,16 @@ defmodule GitGud.Workflows.EventPayload do
79 79 "number" => pr.number,
80 80 "title" => pr.title || "",
81 81 "body" => pr.body || "",
82 − "state" => "open",
82 + # GitHub's states are open and closed; merged is closed + merged.
83 + "state" => if(pr.state in ["closed", "merged"], do: "closed", else: "open"),
83 84 "html_url" => repo_url(repo) <> "/pulls/#{pr.number}",
84 85 "draft" => false,
85 − "merged" => false,
86 + # A real boolean: forgejo-runner reads it to scope the cache of a
87 + # `closed` run.
88 + "merged" => pr.state == "merged",
89 + "merged_at" => iso8601(Map.get(pr, :merged_at)),
90 + "merge_commit_sha" =>
91 + if(pr.state == "merged", do: sha_hex(Map.get(pr, :merge_commit_sha)), else: nil),
86 92 "user" => %{"login" => author_login(repo, Map.get(pr, :author))},
87 93 "head" => %{
88 94 "ref" => source,
@@ -190,6 +196,9 @@ defmodule GitGud.Workflows.EventPayload do
190 196 defp short_branch(b) when is_binary(b), do: b
191 197 defp short_branch(_), do: ""
192 198
199 + defp iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
200 + defp iso8601(_), do: nil
201 +
193 202 defp sha_hex(<<_::binary-size(20)>> = sha), do: Git.to_hex(sha)
194 203 defp sha_hex(_), do: @zero_sha
195 204 end
modified lib/git_gud/workflows/workers/pull_request_dispatch.ex
+29 −12
@@ -7,10 +7,12 @@ defmodule GitGud.Workflows.Workers.PullRequestDispatch do
7 7 post-receive worker; dispatch reads the tree and writes runs, which none
8 8 of them should wait for or be failed by. Enqueue with `enqueue/3`.
9 9
10 − The job carries the head it was enqueued for. If the PR has moved on
11 − since (two pushes in quick succession), it does nothing: the later push
12 − enqueued its own job, and two runs on the newest head would be one too
13 − many.
10 + The job carries the head it was enqueued for. For the activity types
11 + that are about the head (`opened`, `synchronize`, `reopened`), a PR
12 + that has moved on since (two pushes in quick succession) does nothing:
13 + the later push enqueued its own job, and two runs on the newest head
14 + would be one too many. `closed`, `edited`, `labeled` and `unlabeled`
15 + still run on the head as it is — they happened whatever the head did.
14 16
15 17 One attempt: a retry after a partial dispatch would start the runs that
16 18 did get created a second time.
@@ -25,17 +27,28 @@ defmodule GitGud.Workflows.Workers.PullRequestDispatch do
25 27 alias GitGud.Repo
26 28 alias GitGud.Workflows
27 29
30 + @head_actions ~w(opened synchronize reopened)
31 +
28 32 @doc """
29 − Enqueue a dispatch for `pr` with activity type `action`. Never raises and
30 − never fails the caller: a PR is opened whether or not its CI could be
31 − queued.
33 + Enqueue a dispatch for `pr` with activity type `action`. `extra` (string
34 + keys, JSON-safe) is merged into the event payload — `labeled`'s
35 + `"label"`. Never raises and never fails the caller: a PR is opened
36 + whether or not its CI could be queued.
32 37 """
33 − def enqueue(%PullRequest{id: id, head_sha: <<_::binary-size(20)>> = head}, action, actor_id) do
38 + def enqueue(pr, action, actor_id, extra \\ %{})
39 +
40 + def enqueue(
41 + %PullRequest{id: id, head_sha: <<_::binary-size(20)>> = head},
42 + action,
43 + actor_id,
44 + extra
45 + ) do
34 46 %{
35 47 "pull_request_id" => id,
36 48 "action" => action,
37 49 "head_sha" => Git.to_hex(head),
38 − "actor_id" => actor_id
50 + "actor_id" => actor_id,
51 + "extra" => extra
39 52 }
40 53 |> new()
41 54 |> Oban.insert()
@@ -53,16 +66,20 @@ defmodule GitGud.Workflows.Workers.PullRequestDispatch do
53 66 :ok
54 67 end
55 68
56 − def enqueue(_pr, _action, _actor_id), do: :ok
69 + def enqueue(_pr, _action, _actor_id, _extra), do: :ok
57 70
58 71 @impl Oban.Worker
59 72 def perform(%Oban.Job{
60 73 args: %{"pull_request_id" => id, "action" => action, "head_sha" => head} = args
61 74 }) do
62 75 with %PullRequest{} = pr <- Repo.get(PullRequest, id),
63 − true <- pr.head_sha != nil and Git.to_hex(pr.head_sha) == head do
76 + true <- pr.head_sha != nil,
77 + true <- action not in @head_actions or Git.to_hex(pr.head_sha) == head do
64 78 {:ok, _runs} =
65 − Workflows.dispatch_pull_request(pr, action, triggered_by_id: args["actor_id"])
79 + Workflows.dispatch_pull_request(pr, action,
80 + triggered_by_id: args["actor_id"],
81 + payload: args["extra"] || %{}
82 + )
66 83
67 84 :ok
68 85 else
modified lib/git_gud/workflows/yaml.ex
+3 −2
@@ -139,8 +139,9 @@ defmodule GitGud.Workflows.Yaml do
139 139
140 140 @pull_request_types ~w(opened synchronize reopened)
141 141
142 − # GitHub's default `types` for `pull_request` — and the only activity types
143 − # dispatched here, so a workflow that asks for `closed` alone never runs.
142 + # GitHub's default `types` for `pull_request`. `closed`, `edited`,
143 + # `labeled` and `unlabeled` are dispatched too, but only run a workflow
144 + # whose `types:` names them.
144 145 defp type_match?("pull_request", config, %{action: action}) when is_binary(action) do
145 146 types = config |> Map.get("types", @pull_request_types) |> List.wrap()
146 147 action in Enum.map(types, &to_string/1)
modified lib/git_gud_web/live/pr_live/show.ex
+11 −5
@@ -85,6 +85,8 @@ defmodule GitGudWeb.PrLive.Show do
85 85
86 86 defp viewer(socket), do: socket.assigns.current_scope && socket.assigns.current_scope.user
87 87
88 + defp viewer_id(socket), do: (u = viewer(socket)) && u.id
89 +
88 90 # Takes assigns rather than the socket so `render/1` and the event
89 91 # handlers can share it. Author or repo admin, same rule as issues.
90 92 defp can_edit?(%{current_scope: %{user: %{id: uid}}} = assigns),
@@ -217,7 +219,7 @@ defmodule GitGudWeb.PrLive.Show do
217 219 if can_change_state?(socket.assigns) do
218 220 new_state = if pr.state == "open", do: "closed", else: "open"
219 221
220 − case PullRequests.set_state(pr, new_state) do
222 + case PullRequests.set_state(pr, new_state, actor_id: viewer_id(socket)) do
221 223 {:ok, _} ->
222 224 kind = if new_state == "closed", do: "closed", else: "reopened"
223 225 :ok = Events.record(pr, kind, viewer(socket))
@@ -258,7 +260,9 @@ defmodule GitGudWeb.PrLive.Show do
258 260 {:noreply, assign(socket, :editing_title?, false)}
259 261
260 262 true ->
261 − case PullRequests.update_pull_request(pr, %{"title" => String.trim(title)}) do
263 + case PullRequests.update_pull_request(pr, %{"title" => String.trim(title)},
264 + actor_id: viewer_id(socket)
265 + ) do
262 266 {:ok, updated} ->
263 267 :ok =
264 268 Events.record(pr, "title_changed", viewer(socket), %{
@@ -287,8 +291,8 @@ defmodule GitGudWeb.PrLive.Show do
287 291 attached? = Enum.any?(pr.labels, &(&1.id == label.id))
288 292
289 293 if attached?,
290 − do: PullRequests.detach_label(pr, label),
291 − else: PullRequests.attach_label(pr, label)
294 + do: PullRequests.detach_label(pr, label, actor_id: viewer_id(socket)),
295 + else: PullRequests.attach_label(pr, label, actor_id: viewer_id(socket))
292 296
293 297 kind = if attached?, do: "unlabeled", else: "labeled"
294 298 :ok = Events.record(pr, kind, viewer(socket), %{name: label.name, color: label.color})
@@ -466,7 +470,9 @@ defmodule GitGudWeb.PrLive.Show do
466 470 {:noreply, assign(socket, :editing_body?, false)}
467 471
468 472 true ->
469 − case PullRequests.update_pull_request(target, %{"body" => trimmed}) do
473 + case PullRequests.update_pull_request(target, %{"body" => trimmed},
474 + actor_id: viewer_id(socket)
475 + ) do
470 476 {:ok, updated} ->
471 477 :ok =
472 478 Events.record(target, "description_changed", viewer(socket), %{
added test/git_gud/workflows/pull_request_activity_test.exs
+176 −0
@@ -0,0 +1,176 @@
1 +defmodule GitGud.Workflows.PullRequestActivityTest do
2 + @moduledoc """
3 + `pull_request` `closed`, `edited`, `labeled` and `unlabeled`: dispatched
4 + from where they happen, and run only by a workflow whose `types:` asks.
5 + """
6 + use GitGud.DataCase, async: false
7 +
8 + alias GitGud.PullRequests
9 + alias GitGud.Repositories.Repository
10 + alias GitGud.Workflows.WorkflowRun
11 + alias GitGud.Workflows.WorkflowSchedule
12 +
13 + import GitGud.ForgeFixtures
14 + import GitGud.WorkflowGitHelpers
15 +
16 + @moduletag :capture_log
17 +
18 + @wf ".forgejo/workflows/ci.yml"
19 +
20 + defp workflow(types) do
21 + """
22 + name: ci
23 + on:
24 + pull_request:
25 + types: [#{Enum.join(types, ", ")}]
26 + schedule:
27 + - cron: "0 3 * * *"
28 + jobs:
29 + build:
30 + runs-on: self-hosted
31 + steps:
32 + - run: echo hello
33 + """
34 + end
35 +
36 + @default_types """
37 + name: ci
38 + on: pull_request
39 + jobs:
40 + build:
41 + runs-on: self-hosted
42 + steps:
43 + - run: echo hello
44 + """
45 +
46 + setup do
47 + {user, repo} = repository_fixture()
48 + {:ok, repo: repo, user: user}
49 + end
50 +
51 + defp open_pr!(repo, user, contents) do
52 + main = commit!(repo, %{"README" => "hi\n"}, [])
53 + git!(repo.disk_path, ["update-ref", "refs/heads/main", main])
54 + feature = commit!(repo, %{"README" => "hi\n", @wf => contents}, [main])
55 + git!(repo.disk_path, ["update-ref", "refs/heads/feature", feature])
56 +
57 + {:ok, pr} = PullRequests.create_pull_request(repo, user, pr_attrs("feature", "main"))
58 + drain!()
59 + pr
60 + end
61 +
62 + defp drain!, do: Oban.drain_queue(queue: :ci)
63 +
64 + defp actions(%Repository{id: rid}) do
65 + from(r in WorkflowRun,
66 + where: r.repository_id == ^rid and r.trigger == "pull_request",
67 + order_by: r.id
68 + )
69 + |> Repo.all()
70 + |> Enum.map(& &1.event_payload["action"])
71 + end
72 +
73 + defp last_payload(%Repository{id: rid}) do
74 + from(r in WorkflowRun,
75 + where: r.repository_id == ^rid and r.trigger == "pull_request",
76 + order_by: [desc: r.id],
77 + limit: 1
78 + )
79 + |> Repo.one!()
80 + |> Map.fetch!(:event_payload)
81 + end
82 +
83 + test "types: [closed] fires on close, with merged false", %{repo: repo, user: user} do
84 + pr = open_pr!(repo, user, workflow(["closed"]))
85 + assert actions(repo) == []
86 +
87 + {:ok, _closed} = PullRequests.set_state(pr, "closed", actor_id: user.id)
88 + drain!()
89 +
90 + assert actions(repo) == ["closed"]
91 + payload = last_payload(repo)
92 + assert payload["pull_request"]["merged"] == false
93 + assert payload["pull_request"]["state"] == "closed"
94 + assert payload["sender"]["login"] == user.handle
95 + end
96 +
97 + test "types: [closed] fires on merge, with merged true", %{repo: repo, user: user} do
98 + pr = open_pr!(repo, user, workflow(["closed"]))
99 +
100 + {:ok, merged} = PullRequests.merge!(pr, user)
101 + drain!()
102 +
103 + assert actions(repo) == ["closed"]
104 + payload = last_payload(repo)
105 + assert payload["pull_request"]["merged"] == true
106 + assert payload["pull_request"]["state"] == "closed"
107 +
108 + assert payload["pull_request"]["merge_commit_sha"] ==
109 + GitGud.Git.to_hex(merged.merge_commit_sha)
110 +
111 + # The merge brought the workflow onto the default branch, and with
112 + # it the schedule.
113 + assert [%WorkflowSchedule{cron: "0 3 * * *"}] =
114 + Repo.all(from s in WorkflowSchedule, where: s.repository_id == ^repo.id)
115 + end
116 +
117 + test "the default types don't fire on close", %{repo: repo, user: user} do
118 + pr = open_pr!(repo, user, @default_types)
119 + assert actions(repo) == ["opened"]
120 +
121 + {:ok, _closed} = PullRequests.set_state(pr, "closed")
122 + drain!()
123 + assert actions(repo) == ["opened"]
124 + end
125 +
126 + test "labeled carries the label; unlabeled too; re-adding a label isn't an event", %{
127 + repo: repo,
128 + user: user
129 + } do
130 + pr = open_pr!(repo, user, workflow(["labeled", "unlabeled"]))
131 + label = label_fixture(repo, %{"name" => "deploy-preview"})
132 +
133 + :ok = PullRequests.attach_label(pr, label)
134 + :ok = PullRequests.attach_label(pr, label)
135 + drain!()
136 + assert actions(repo) == ["labeled"]
137 + assert last_payload(repo)["label"]["name"] == "deploy-preview"
138 +
139 + :ok = PullRequests.detach_label(pr, label)
140 + :ok = PullRequests.detach_label(pr, label)
141 + drain!()
142 + assert actions(repo) == ["labeled", "unlabeled"]
143 + assert last_payload(repo)["label"]["name"] == "deploy-preview"
144 + end
145 +
146 + test "editing the title or body fires edited; other fields don't", %{
147 + repo: repo,
148 + user: user
149 + } do
150 + pr = open_pr!(repo, user, workflow(["edited"]))
151 +
152 + {:ok, pr} = PullRequests.update_pull_request(pr, %{"title" => "A new title"})
153 + drain!()
154 + assert actions(repo) == ["edited"]
155 + assert last_payload(repo)["pull_request"]["title"] == "A new title"
156 +
157 + {:ok, pr} = PullRequests.update_pull_request(pr, %{"title" => "A new title"})
158 + drain!()
159 + assert actions(repo) == ["edited"]
160 +
161 + {:ok, _pr} = PullRequests.update_pull_request(pr, %{"body" => "More words"})
162 + drain!()
163 + assert actions(repo) == ["edited", "edited"]
164 + end
165 +
166 + test "a closed pull request still runs nothing but closed", %{repo: repo, user: user} do
167 + pr = open_pr!(repo, user, workflow(["labeled", "closed"]))
168 + {:ok, closed} = PullRequests.set_state(pr, "closed")
169 + drain!()
170 + assert actions(repo) == ["closed"]
171 +
172 + :ok = PullRequests.attach_label(closed, label_fixture(repo))
173 + drain!()
174 + assert actions(repo) == ["closed"]
175 + end
176 +end

Parents: 587faf4