Plan: run workflows for pull requests (on: pull_request)
Status: executed (2026-10-17), on top of
workflow-path-filters.md (this reuses its
changed-file listing and paths matching). Written to be
carried out by one agent without further questions.
Why
on: pull_request is accepted by the parser (Yaml.matches?/3 would match
it) but nothing ever dispatches that trigger: the only caller of
Workflows.dispatch/2 is the push worker
(Repositories.Workers.CommitBackfill.dispatch_workflows/4), which always
sends "push". Workflows written for GitHub/Forgejo that test pull
requests never run here, and the PR page’s checks (it already reads commit
statuses, lib/git_gud_web/live/pr_live/show.ex) only show whatever a
push to the same SHA happened to produce.
There is also a context bug to fix first: the runner context carries the
trigger as event but not event_name, so github.event_name is empty in
every job. A bigtrippr job with
if: github.event_name == 'push' && … was skipped on every push because of
it.
Scope
In:
github.event_namein the runner context.- Same-repository pull requests (source and target branches in one repo):
runs on
opened,synchronize(head moved),reopened. types:filter,branches:/branches-ignore:(against the target branch, as on GitHub),paths:/paths-ignore:(against the PR’s diff from its merge-base).- The runner context a PR job expects:
ref,ref_name,head_ref,base_ref,sha,event_name.
Out (state it in the docs, don’t build it):
- Cross-repository (fork) and federated pull requests. Their head is
code the target’s maintainers haven’t reviewed, and a job is handed the
target repository’s secrets (
runner_controller.ex:GitGud.Secrets.resolve_for(repo)) and a token that can publish its packages. GitHub runs fork PRs without secrets and with a read-only token; doing that safely here is its own piece of work. Until then, do not dispatch for them at all, and log at debug level why. pull_request_target, theclosed/edited/labeled/assignedactivity types, merge refs (refs/pull/N/merge; GitHub tests the merge result, this tests the head — say so in the docs).
Where things are
| Runner context | lib/git_gud_web/controllers/runner_controller.ex, the map with "event" => run.trigger (~line 288) |
| Ref → filter ctx | lib/git_gud/workflows/ref.ex (Ref.context/1, Ref.name/1, Ref.type/1) |
| Dispatch | lib/git_gud/workflows.ex — dispatch/2, create_run/2; runs store trigger, head_sha, head_ref |
| Trigger matching | lib/git_gud/workflows/yaml.ex |
| PRs | lib/git_gud/pull_requests.ex: do_create_pull_request/3 (opened), set_state/2 (reopen: "open"), refresh_cross_repo_heads/3 (called on every push via CommitBackfill.refresh_cross_repo_prs/3; despite the name it also covers same-repo PRs, whose source_repository_id is the repo itself — that is the synchronize moment); PullRequest.cross_repo?/1, PullRequest.federated?/1 |
| Commit statuses | lib/git_gud/workflows/commit_status.ex, Workflows.update_run_commit_status/1 (keyed on the run’s head SHA, which the PR page reads) |
| Tests to copy from | test/git_gud/workflows/tag_dispatch_test.exs (on-disk repo + dispatch); find the existing PR tests under test/git_gud/pull_requests* for creating PRs in tests |
Steps
1. github.event_name
In the runner context map add "event_name" => run.trigger || "push"
next to "event". Test: the existing runner controller test that asserts
on the context (find it: grep ref_name under test/) gets an assertion
for event_name. This step is independent: commit it on its own first.
2. Trigger matching for pull requests
In Yaml:
pull_requestconfig’stypes:— default["opened", "synchronize", "reopened"]when absent; the event’s action must be in the list.branches:/branches-ignore:match the target branch (ctx:branchis the target’s short name).branches-ignoremay not be supported for push either — add it for both if missing, with GitHub’s rule thatbranchesandbranches-ignorecan’t both be set (if both: usebranches, log a warning; same choice as paths).paths/paths-ignore: unchanged code from the path-filters plan; ctx:changed_pathsis the PR’s diff (step 3).tags:never applies to pull requests.
matches?/3 gains the action through ctx (:action), not a new arity.
Pure tests in trigger_filters_test.exs: types default and explicit;
target-branch filter; branches-ignore; paths on a PR.
3. Workflows.dispatch_pull_request(pr, action, opts)
New public function in GitGud.Workflows:
- Return
{:ok, []}without doing anything whenpr.state != "open",PullRequest.cross_repo?(pr)orPullRequest.federated?(pr). - Workflows are read from the PR head commit (
pr.head_sha) — that is GitHub’s behaviour forpull_requestand what lets a PR change its own CI. (Fork PRs are excluded above, so this is code from the repo’s own writers.) - Pin
refs/pull/<N>/headtopr.head_shain the repo (Git.update_ref/4, aspin_pull_head/refresh_*already do for cross-repo PRs), so the ref the job is told about exists andactions/checkoutcan fetch it. - Changed paths:
Git.changed_paths(repo_path, merge_base(target head, pr.head_sha), pr.head_sha)— only if some workflow is path-filtered; fail open as in the path plan. - ctx:
%{branch: target short name, action: action, changed_paths: …}. - Create runs with
trigger: "pull_request",head_sha: pr.head_sha,head_ref: "refs/pull/<N>/head", and store the PR’s number, source and target refs on the run. Add a migration (mix ecto.gen.migration add_pull_request_to_workflow_runs) with nullablepull_request_number :integer,pr_head_ref :string,pr_base_ref :stringonworkflow_runs, and cast them inWorkflowRun’s changeset. Refactordispatch/2’s inner loop into a shared private function rather than duplicating it. - Return
{:ok, runs}.
4. The runner context for a PR job
In runner_controller.ex, when run.trigger == "pull_request":
"ref" => "refs/pull/<N>/head"(already the run’shead_ref),"ref_name" => "<N>/head","ref_type" => "branch"? — GitHub setsref_typeempty for PRs; use""."head_ref" => run.pr_head_ref,"base_ref" => run.pr_base_ref(short branch names, as GitHub gives them)."event_name" => "pull_request"(from step 1)."event"stays the trigger string — check what forgejo-runner/act does withevent(it may expect the event payload object there; if the existing push jobs work with a string, keep it, and do not change push behaviour in this plan).
Make sure Ref.name/1 / Ref.type/1 are not fed refs/pull/... paths
that make them crash or misreport; add clauses or bypass them for PR runs.
5. Call sites
- opened: at the end of
do_create_pull_request/3, after the transaction succeeds, callWorkflows.dispatch_pull_request(pr, "opened", triggered_by_id: author.id). Do it outside the transaction; a dispatch failure must never fail PR creation (rescue + log). - synchronize: in
refresh_cross_repo_heads/3, for each PR whosehead_shaactually changed (it hasold_sha), call it with"synchronize"and the pusher if available (threadpusher_idfromCommitBackfill.refresh_cross_repo_prs/3if it’s cheap; else nil). This function already handles same-repo PRs; cross-repo ones are dropped insidedispatch_pull_request/3. - reopened: in
set_state/2when moving"closed"→"open"successfully. Check whether the head moved while closed (re-resolve the source ref) — ifrefresh_*already keepshead_shacurrent for closed PRs, no extra work; otherwise refreshhead_shabefore dispatching. - Dispatch is DB + git work; if PR creation runs inside a LiveView
process, enqueue it (Oban, like
CommitBackfill) instead of running it inline. Look at howCommitBackfillis enqueued and copy that shape (Workers.PullRequestDispatch, args: pr id, action, actor id).
6. Tests
On disk, modelled on tag_dispatch_test.exs:
- same-repo PR opened → a
pull_requestrun on the PR head SHA, withhead_refrefs/pull/N/head, and that ref exists in the repo. - push to the source branch → a
synchronizerun (and the usual push run). types: [opened]→ no run on synchronize.branches: [master]→ runs for a PR targeting master, not one targetingrelease.paths: ["lib/**"]→ PR that only changesdocs/→ no run.- workflow present only on the PR branch (added by the PR) → it runs (workflows come from the head).
- cross-repo PR (fork fixture, if the test helpers have one; else build
the struct with a different
source_repository_id) → no run, and the target’s secrets are never resolved for it. - closed then reopened → a
reopenedrun. - runner context for a PR job (controller test):
event_name,head_ref,base_ref,ref,ref_name.
7. Docs
Where workflow support is documented (see the path plan’s step 7): on: pull_request with the supported types, that it tests the PR head (not a
merge result), that branches means the target branch, and that fork and
federated PRs don’t run yet and why (secrets).
Done when
mix precommitpasses.- Commits, in the repository’s message style: (1)
event_name, (2) the pull request trigger (migration included). Separate from the path-filter commit. - Not pushed. Pushing gitgud deploys the forge.