Feat: Gate fork PR transfer on maintainer approval - #390
Merged
tykeal merged 2 commits intoAug 25, 2026
Merged
Conversation
Transferring a fork pull request pushes someone else's code into Gerrit under the tool's SSH identity, where Gerrit CI then executes it. Nothing required a maintainer to have looked at it first. Require an approving review, checked before the tool fetches the pull request and before it unlocks the Gerrit key, so a pull request awaiting review reaches neither. Same-repository pull requests skip the gate: pushing a branch to the base repository already implies write access, and Dependabot, pre-commit.ci and Copilot all work that way, so automation consumers never meet it. Reviews rather than comment directives, because on a Gerrit mirror the only usable trust signal is organisation membership, which the pull request author frequently holds. GitHub structurally forbids approving your own pull request; a comment scheme has no equivalent. The author is excluded locally as well, since that structural guarantee is doing most of the work. Approval binds to the head SHA. GitHub keeps approvals across pushes unless branch protection dismisses them, so without this a contributor could gain approval for one revision and push another. Maintainers re-approve after each push, which is the correct trade. Reviews arrive as history rather than current state, so reduce to each author's latest position. COMMENTED and PENDING express no position and must not displace an approval; DISMISSED must, or a revoked approval keeps authorising the transfer. Map pull_request_review to UPDATE, and let a review-triggered run authorise the create-missing fallback. A review changes no code, so an existing change should gain a patchset rather than a sibling; but the review may equally be the event that first unblocks the pull request, in which case no change exists yet and UPDATE alone would hard-fail every fork PR on its first approval. Blocking finishes the run successfully. A pull request waiting for a human is not broken, and a red check would say otherwise. One comment explains what is missing and later runs edit it rather than adding another; a stale approval is reported as stale, so a maintainer who did approve is not told that nobody has. Every failure path refuses: unreadable reviews, an unresolvable pull request, and a missing token all block rather than pass. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
ModeSevenIndustrialSolutions
requested review from
a team
and
a balanced review from Copilot
August 25, 2026 18:27
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 18:27
View session
There was a problem hiding this comment.
Pull request overview
Adds maintainer-approval gating before fork PRs transfer to Gerrit.
Changes:
- Evaluates trusted reviews against the current head SHA.
- Supports review-triggered updates and create-missing fallback.
- Adds gate documentation and tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/github2gerrit/pr_approval.py |
Implements approval evaluation and notices. |
src/github2gerrit/cli.py |
Integrates gating into transfer paths. |
src/github2gerrit/core.py |
Handles review-triggered creation fallback. |
src/github2gerrit/models.py |
Maps review events to updates. |
src/github2gerrit/github_api.py |
Adds review API abstractions. |
tests/test_fork_approval_gate.py |
Tests gate behavior. |
docs/features.md |
Documents approval gating. |
Suppressed comments (1)
src/github2gerrit/cli.py:301
- The approval is checked against the PR API snapshot, but that SHA is not carried into the checkout.
_prepare_workspace_checkoutlater fetches the movingrefs/pull/<N>/head, so the author can push a new commit between this check and the fetch and this run will transfer code that was never approved. Carry the approved head SHA into the checkout and fail closed unless the fetched/archive commit exactly matches it (or fetch the exact approved SHA).
status = evaluate_fork_approval(
pr_obj, head_sha=head_sha, author_login=author
)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 18:48
View session
ModeSevenIndustrialSolutions
force-pushed
the
feat/fork-approval-gate
branch
from
August 25, 2026 19:04
3321bd7 to
c9d9277
Compare
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 19:04
View session
ModeSevenIndustrialSolutions
force-pushed
the
feat/fork-approval-gate
branch
from
August 25, 2026 19:21
c9d9277 to
6b559fb
Compare
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 19:22
View session
ModeSevenIndustrialSolutions
force-pushed
the
feat/fork-approval-gate
branch
from
August 25, 2026 19:41
6b559fb to
7423cac
Compare
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 19:42
View session
3 tasks
1. Approval could pass with no SHA to check against. An APPROVED
review carrying an empty commit_id, or a PR whose head SHA could
not be read, fell through to approved. The guarantee is that the
approval covers exactly the commit being transferred, so absent
metadata now withholds it.
2. Unknown provenance bypassed the gate. is_fork_pr states a fact and
reports False when it cannot tell; head_is_trusted answers the
authorisation question and reports False in the same case. The gate
uses the latter, so a pull request whose head could not be resolved
is gated rather than waved through. This is the distinction drawn
in 384 and then not used here.
3. Review-triggered create-missing applied to every pull request. A
same-repository PR was never gated, so a review on one could
quietly override CREATE_MISSING=false. Scoped to untrusted heads.
4. The comment marker was treated as proof of authorship. Anyone may
paste it, which could suppress the notice or make the tool try to
edit a stranger's comment. Ownership is now established by
attempting the edit, which the API refuses on another user's
comment; a failure means it was not ours and the search continues.
5. The documentation told maintainers to re-run a failed job, while
the gate finishes successfully. Corrected.
6. The approval bound only to an API snapshot, while the workspace
fetch reads the mutable refs/pull/<N>/head. A push timed against a
running workflow could therefore transfer a commit nobody had
reviewed. The gate now records the approved commit and the fetch
refuses anything else.
The approved commit travels with the pull request, as an explicit
Orchestrator argument, rather than through the environment. Bulk
runs process up to four pull requests concurrently, so a
process-global value would let one worker clear or overwrite
another's constraint.
The archive fallback re-reads the pull request's current head, so
it is checked there too, before the download rather than after:
unlike the git path there is no commit object left to compare once
the files have landed.
7. The create-missing notice named only two authorisation sources
while there are now three, so the first approved fork PR was told
a comment or flag triggered it. _should_create_missing returns the
reason and the notice renders it, rather than restating conditions
it cannot see.
8. An approved pull request kept displaying the block notice. The
gate now retracts its own notice when approval arrives, using the
same edit-to-prove-ownership rule, and creates nothing where no
notice exists.
9. Both README caller examples registered only pull_request_target,
so anyone following the documented setup would never get the
approval-triggered re-run. Both now include pull_request_review.
10. A review on a same-repository pull request ran the whole pipeline
and resubmitted an unchanged head. The trigger exists to unblock
gated pull requests, so review-triggered runs stop early when the
head is trusted.
11. The blocked notice asserted the pull request came from a fork,
while the gate also covers heads whose origin could not be
established. Reworded to say what the tool actually knows.
Also exempt direct CLI invocation. Requiring a GitHub approval there
asks the wrong question: the operator chose the pull request and is
using their own credentials, so they are already the authority. The
gate exists for the unattended path, where the tool acts on a shared
identity with nobody watching.
The action's own self-test caught the gate working: pull requests to
this repository come from a fork, so the deliberate failure case now
exits successfully having transferred nothing. That assertion is
skipped for fork pull requests, which is honest about what can be
tested from one.
The test fixture for the single-PR path omitted head.repo from its
event payload, which real pull request payloads always carry; it now
includes it rather than relying on the gate ignoring absent
provenance.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
ModeSevenIndustrialSolutions
force-pushed
the
feat/fork-approval-gate
branch
from
August 25, 2026 19:57
7423cac to
bfdb90e
Compare
Copilot started reviewing on behalf of
ModeSevenIndustrialSolutions
August 25, 2026 19:58
View session
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/github2gerrit/models.py:163
- Mapping every submitted review to UPDATE still resubmits approved fork PRs on non-approval reviews. After a fork head has one valid approval,
COMMENTEDis deliberately ignored by the history reducer, so any outsider can submit another comment-only review; the gate remains approved and the unchanged head runs through the Gerrit update pipeline again. Preserve the triggering review state and only continue the transfer for anAPPROVEDsubmission; dismissal/change-request events can still evaluate and update the gate notice, but should stop when another approval leaves the gate open.
if self.event_name == "pull_request_review":
return PROperationMode.UPDATE
tykeal
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #383
Completes the fork-handling work started in #384 and #382. Transferring a fork
pull request pushes someone else's code into Gerrit under the tool's SSH
identity, where Gerrit CI then executes it. Nothing required a maintainer to
have looked at it first.
Scope
The gate applies to unattended runs where the tool cannot positively establish
that the head lives in the base repository.
Same-repository pull requests skip it: pushing a branch to the base repository
already implies write access, and Dependabot, pre-commit.ci and Copilot all work
that way, so automation-only consumers never meet it. That is why it defaults to
on with no new input.
Direct CLI invocation also skips it. The operator chose the pull request and is
using their own credentials, so they are already the authority the gate looks
for.
Everything else — forks, and pull requests whose provenance the tool cannot
establish — needs approval. This uses
head_is_trustedrather thanis_fork_pr: the former answers the authorisation question and reportsFalsewhen provenance is unknown, which is the distinction #384 introduced.
Why reviews rather than comment directives
On a Gerrit mirror the only usable trust signal is organisation membership. The
opendaylight/mdsalcollaborator list is entirely LF infrastructure staff andbots, while project committers appear only as
MEMBER— so requiringwriteoradminwould trust the wrong people and exclude the right ones.But the pull request author frequently holds that same membership. Under a
comment scheme they could authorise their own change. GitHub structurally
forbids approving your own pull request, so a review carries a guarantee a
comment cannot. That is the whole argument, and it is why the author is also
excluded locally — the structural check is doing most of the work.
What counts as approval
An approving review where every one of the following holds:
author_associationis in the trusted set (shared with PR comment commands accept directives from any user #382via
trust.py);Binding to the head SHA is essential. GitHub keeps approvals across pushes
unless branch protection dismisses them, so an unbound approval would let a
contributor gain approval for one revision and push another. Maintainers
re-approve after each push, which is the correct trade. Absent SHA metadata on
either side withholds approval rather than skipping the comparison.
The binding survives the fetch. The gate reads the head SHA from the API,
while the workspace fetch reads the mutable
refs/pull/<N>/head. The toolrecords the approved commit and refuses anything else after fetching, so a push
timed against a running workflow cannot slip an unreviewed commit into Gerrit.
That refusal fails loudly, unlike an ordinary block, because it falls outside the
normal course of events.
Reviews arrive as history, not current state, so the evaluation reduces to
each author's latest position.
COMMENTEDandPENDINGexpress no position andmust not displace an approval.
DISMISSEDmust — my own test caught that itoriginally did not, leaving a revoked approval still authorising the transfer.
Placement and failure behaviour
The gate runs before the tool fetches anything from the pull request and before
the Gerrit key is materialised, on both the single-PR and bulk paths.
Every failure path refuses: unreadable reviews, an unresolvable pull request, a
missing
GITHUB_TOKEN, and absent SHA metadata all block. The token casemattered —
_extract_and_display_pr_inforeturns early without one, so placingthe gate inside it would have failed open exactly when the environment is least
well configured.
pull_request_reviewsupportpull_request_targetcarries no event for a submitted review, so approvingwould otherwise do nothing. Callers need:
The event maps to
UPDATE, and a review-triggered run on an untrusted headauthorises the create-missing fallback. Both halves are needed:
than a sibling — mapping to
CREATEwould raise a duplicate on everyre-approval after a push;
request, when no Gerrit change exists —
UPDATEalone hard-fails there, atcore.py:1768, and only after the fork has been fetched and the key unlocked.Scoped to untrusted heads so a review on a same-repository pull request cannot
quietly override
CREATE_MISSING=false.action.yamlneeds no change: PR number andPR_HEAD_REPOboth resolve fromthe
pull_request_reviewpayload, and_augment_pr_refs_if_needed(#384) fillsthe refs GitHub leaves empty on that event.
When the gate blocks
The run finishes successfully. A pull request waiting for a human is not
broken, and a red check would suggest otherwise — this follows the existing
G2G_DISABLEDprecedent of exiting cleanly with a message.One comment explains what is missing, and later runs edit it rather than adding
another. Ownership of that comment is established by attempting the edit,
which the API refuses on another user's comment — the marker alone is not proof
of authorship, since anyone may paste it. A stale approval is reported as stale,
so a maintainer who did approve is not told that nobody has.
A note on the self-test
Test GitHub Actionfailed on the first push of this branch, and the reason isworth recording: pull requests to this repository come from a fork, so the gate
blocked the action's own deliberate-failure test, which then exited successfully
having transferred nothing. The gate working as designed. That assertion is now
skipped for fork pull requests, which is honest about what can be tested from
one.
Validation
uv run pytest tests/— full suite passes, coverage 72.30%tests/test_fork_approval_gate.pycovers trusted and untrusted associations,head-SHA binding including the approve-then-push bypass and absent metadata,
self-approval, the history reduction (supersession, trailing comments,
dismissal, pending), the fail-closed paths, comment ownership against a
planted marker, the post-fetch head comparison, per-PR recording hygiene in
bulk runs, gate scoping, and the review-event operation mode
prek run --all-files,zizmor --persona auditor(no findings),aislop(score 100, zero findings)
Follow-up
This changes no repository's behaviour on its own:
AUTOMATION_ONLYstilldefaults to
trueand closes human pull requests before the gate is reached. Aproject accepting user submissions sets that to
false, at which point thisbecomes the control.