neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: pull_request closed, edited, labeled and unlabeled fire
8a80762 · Gabriel Morell · 2026-10-17 18:05
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