feat(python): implemented pull request vouching and gated review system

- Introduced a vouching mechanism to manage PR authorization via a tracked user list and discussion-based management workflows.
- Added automated PR gatekeeping workflows to close contributions from unvouched users and require specific labels for review.
- Refactored PR event handling to support label-based review deferral and enforce authorization checks for labelers.
- Added comprehensive test coverage for vouch-gate logic, including label activation and unauthorized access scenarios.
This commit is contained in:
can1357
2026-06-19 03:14:43 +02:00
parent 71144825ec
commit aca5d5f48a
9 changed files with 491 additions and 18 deletions
+107
View File
@@ -0,0 +1,107 @@
# The list of vouched (or actively denounced) users for this repository.
#
# Only vouched users can open PRs here; unvouched/denounced PRs are
# auto-closed by .github/workflows/vouch-pr.yml (mitchellh/vouch check-pr).
# Issues are intentionally NOT gated here — robomp triages those.
#
# Write-access collaborators and bots are auto-allowed and need no entry.
# A denounced user ("-handle") is always blocked, even if also listed.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: `platform:username` (default platform: github).
# - Denounce by prefixing with minus: `-username` / `-platform:username`.
# - Optional free-text reason after a space following the handle.
#
# Maintainers manage this list by commenting `!vouch` / `!denounce [user]`
# on a discussion (see .github/workflows/vouch-manage.yml).
#
# Seed (2026-06-19): authors with >=2 merged PRs in the prior 6 months.
# Audit found 0 denounce-worthy actors; all reverts were maintainer
# technical rollbacks, not abuse. See the PR/commit history for provenance.
a-glapinski
ak4153
apoc
AsafMah
azais-corentin
basedcorp99
BayLee4
bjin
cagedbird043
cexll
chan1103
chuaaron
ckumar1
daandden
daaximus
danzaio
DarkPhilosophy
DeprecatedLuke
djdembeck
dmarsh-gusto
elikoga
enieuwy
ForeverYoungPp
GratefulDave
H4vC
HabibPro1999
handlecusion
haosenwang1018
hezhiyang2000
infernix
inprealpha
itertea
jchristman
jiwangyihao
kamafozilov
KamijoToma
Kukkerem
ldx
lederniermagicien
loftiskg
lyc-aon
makoMakoGo
masonc15
maximhar
maxvisionai
metaphorics
MikeeI
Mokto
mouyase
muness
nnk97
ogrodev
oldschoola
Parsifa1
phanthh
pidevxplay
qfrtt
ravshansbox
rburketaylor
RensTillmann
riverpilot
romanalexander
RzNmKX
scarthread
segmentationf4u1t
shoucandanghehe
shyndman
sit
slact
smileynet
sundbp
superhedge22
tc97222
tdiant
tsagi2045
turbomolli
usr-bin-roygbiv
vmcall
VoidChecksum
voiys
watzon
WodenJay
wolfiesch
zakhar-kogan
zamorakpds
+53
View File
@@ -0,0 +1,53 @@
name: Vouch (manage)
# Let maintainers vouch/denounce/unvouch by commenting on a Discussion:
# !vouch vouch the discussion author
# !vouch @user [reason] vouch a specific user
# !denounce [@user] [reason]
# !unvouch [@user]
# Only collaborators with admin/maintain/write are honored (triage EXCLUDED;
# upstream's default `roles` includes triage, which we override below).
# The action commits the VOUCHED.td change back to the default branch.
on:
discussion_comment:
types: [created]
# Serialize writes to VOUCHED.td so concurrent vouches don't clobber.
concurrency:
group: vouch-manage
cancel-in-progress: false
# The job carries its own identity via the App token below, so the
# workflow itself needs no permissions.
permissions: {}
jobs:
manage:
runs-on: ubuntu-latest
steps:
# A GitHub App identity is required only if the default branch is
# protected (the stock GITHUB_TOKEN cannot bypass branch protection).
# No branch protection? Delete this step, drop the checkout `token:`,
# set `permissions: { contents: write, discussions: write }`, and use
# `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` below.
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.VOUCH_ID }}
private-key: ${{ secrets.VOUCH_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/manage-by-discussion@v1
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
vouch-keyword: "!vouch"
denounce-keyword: "!denounce"
unvouch-keyword: "!unvouch"
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
+51
View File
@@ -0,0 +1,51 @@
name: Vouch (PR gate)
# Auto-close PRs from unvouched or denounced users. Issues are left alone
# (robomp triages those). Runs under `pull_request_target` so the token can
# act on fork PRs; this job does NO checkout and runs NO PR code — it only
# reads .github/VOUCHED.td from the base repo and calls the GitHub API.
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
permissions:
contents: read # read VOUCHED.td from the base branch
pull-requests: write # close + comment
issues: write # add the `vouched` label (labels use the Issues API)
concurrency:
group: vouch-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: vouch
uses: mitchellh/vouch/action/check-pr@v1
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
require-vouch: true # block unvouched, not only denounced
# vouched-file: .github/VOUCHED.td (default)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Survivors of the gate (vouched, or auto-allowed collaborators/bots) get
# a FRESH `vouched` label on every (re)open / ready-for-review. robomp
# reviews ONLY on that label event (ROBOMP_PR_REVIEW_TRIGGER=vouched_label),
# so review is always triggered by a just-validated PR, never a stale label.
- name: Label vouched PRs for robomp review
if: ${{ steps.vouch.outputs.status == 'vouched' || steps.vouch.outputs.status == 'allowed' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
gh label create vouched --repo "$REPO" --color 2da44e --description "Passed the vouch gate" --force
# remove+add so a fresh `labeled` event fires even when the label
# persisted across close/reopen (re-adding an existing label emits no
# event). The check above just re-validated, so trust is never stale.
gh pr edit "$PR" --repo "$REPO" --remove-label vouched || true
gh pr edit "$PR" --repo "$REPO" --add-label vouched
+20
View File
@@ -110,6 +110,26 @@ ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS=60
ROBOMP_REQUEST_TIMEOUT_SECONDS=120
# =============================================================================
# --- PR review / vouch gate ---
# =============================================================================
# robomp reviews incoming contributor PRs. When PRs are gated by the vouch
# GitHub Action (.github/workflows/vouch-pr.yml), set the trigger to
# `vouched_label` so robomp reviews ONLY the PRs the gate let through (the
# workflow labels survivors with ROBOMP_VOUCH_REVIEW_LABEL) instead of racing
# the gate on PR open. Set ROBOMP_PR_REVIEW_ENABLED=false to disable robomp PR
# review entirely.
ROBOMP_PR_REVIEW_ENABLED=true
# open | vouched_label
ROBOMP_PR_REVIEW_TRIGGER=open
ROBOMP_VOUCH_REVIEW_LABEL=vouched
# In vouched_label mode only `labeled` events from this actor trigger review,
# so a manual triage/maintainer label cannot bypass the gate. Default matches
# the stock GITHUB_TOKEN actor; set to your App's bot login if the vouch
# workflow labels via a GitHub App token.
ROBOMP_VOUCH_REVIEW_LABELER=github-actions[bot]
# =============================================================================
# --- Per-submitter rate limiting ---
# =============================================================================
+10
View File
@@ -44,6 +44,16 @@ services:
ROBOMP_MAINTAINER_LOGINS: ${ROBOMP_MAINTAINER_LOGINS:-}
ROBOMP_REVIEWER_BOTS: ${ROBOMP_REVIEWER_BOTS:-}
# --- PR review / vouch gate ---
# When PRs are gated by the vouch GitHub Action, set the trigger to
# `vouched_label` so robomp reviews ONLY PRs that survive the gate
# (the workflow labels survivors `ROBOMP_VOUCH_REVIEW_LABEL`), instead
# of racing the gate on PR open. `vouched_label` keeps review ENABLED.
ROBOMP_PR_REVIEW_ENABLED: ${ROBOMP_PR_REVIEW_ENABLED:-true}
ROBOMP_PR_REVIEW_TRIGGER: ${ROBOMP_PR_REVIEW_TRIGGER:-open}
ROBOMP_VOUCH_REVIEW_LABEL: ${ROBOMP_VOUCH_REVIEW_LABEL:-vouched}
ROBOMP_VOUCH_REVIEW_LABELER: ${ROBOMP_VOUCH_REVIEW_LABELER:-github-actions[bot]}
# --- model selection ---
ROBOMP_MODEL: ${ROBOMP_MODEL:-anthropic/claude-sonnet-4-6}
ROBOMP_PROVIDER: ${ROBOMP_PROVIDER:-}
+12
View File
@@ -38,6 +38,18 @@ class Settings(BaseSettings):
git_author_email: str = Field(..., alias="ROBOMP_GIT_AUTHOR_EMAIL")
repo_allowlist_raw: str = Field("", alias="ROBOMP_REPO_ALLOWLIST")
pr_review_enabled: bool = Field(True, alias="ROBOMP_PR_REVIEW_ENABLED")
# PR review trigger. "open" (default) reviews incoming PRs on
# opened/reopened/ready_for_review. "vouched_label" DEFERS review until the
# vouch GitHub Action labels the PR `vouch_review_label`, so robomp reviews
# only PRs that survive the vouch gate. `pr_review_enabled` remains the
# master switch (False disables review under either trigger).
pr_review_trigger: Literal["open", "vouched_label"] = Field("open", alias="ROBOMP_PR_REVIEW_TRIGGER")
vouch_review_label: str = Field("vouched", alias="ROBOMP_VOUCH_REVIEW_LABEL")
# In vouched_label mode, only `labeled` events from this actor trigger a
# review, so a manual label by a triage/maintainer cannot bypass the gate.
# Default is the actor for the stock GITHUB_TOKEN; set to your App's bot
# login (e.g. "vouch-bot[bot]") if the vouch workflow labels via an App.
vouch_review_labeler: str = Field("github-actions[bot]", alias="ROBOMP_VOUCH_REVIEW_LABELER")
# gh-proxy. Set BOTH to route GitHub through the proxy; leave both empty
# to keep PAT-on-orchestrator behavior. Mixing the two (PAT + proxy) is
+41 -18
View File
@@ -140,6 +140,23 @@ def is_implementation_authorizer(
return False
def _pr_review_pr(pr: Mapping[str, Any], repo: str, action: str, bot_login: str) -> RouteDecision:
"""Build a `review_pr` decision for an incoming PR, or the matching skip."""
if str(pr.get("state") or "open") != "open":
return RouteDecision("skip", None, repo, None, "PR not open")
if bool(pr.get("draft")):
return RouteDecision("skip", None, repo, None, "draft PR")
if _is_bot_account(pr.get("user") or {}, bot_login):
return RouteDecision("skip", None, repo, None, "bot-authored PR")
number = pr.get("number")
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "PR missing number")
login, assoc = _submitter_info(pr)
return RouteDecision(
"queue", "review_pr", repo, issue_key(repo, number), f"pull_request.{action}", submitter=login, association=assoc
)
def route(
event_type: str,
payload: Mapping[str, Any],
@@ -150,6 +167,9 @@ def route(
reviewer_bots: frozenset[str] = frozenset(),
resolve_issue_from_pr: PrIssueResolver = None,
pr_review_enabled: bool = True,
pr_review_trigger: str = "open",
vouch_review_label: str = "vouched",
vouch_review_labeler: str = "github-actions[bot]",
) -> RouteDecision:
"""Decide whether and how to handle a webhook event.
@@ -281,24 +301,27 @@ def route(
if not pr_review_enabled:
return RouteDecision("skip", None, repo, None, "PR review disabled")
pr = payload.get("pull_request") or {}
if bool(pr.get("draft")):
return RouteDecision("skip", None, repo, None, "draft PR")
pr_user = pr.get("user") or {}
if _is_bot_account(pr_user, bot_login):
return RouteDecision("skip", None, repo, None, "bot-authored PR")
number = pr.get("number")
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "PR missing number")
login, assoc = _submitter_info(pr)
return RouteDecision(
"queue",
"review_pr",
repo,
issue_key(repo, number),
f"pull_request.{action}",
submitter=login,
association=assoc,
)
if pr_review_trigger == "vouched_label":
# Defer to the vouch gate. robomp reviews ONLY on the `labeled`
# event the workflow emits AFTER a fresh check (it re-applies the
# vouch label on every opened/reopened/ready_for_review). Never
# trust a persisted label here: a since-denounced author must not
# slip through on reopen.
return RouteDecision("skip", None, repo, None, "deferred to vouch label")
return _pr_review_pr(pr, repo, action, bot_login)
if event_type == "pull_request" and action == "labeled" and pr_review_trigger == "vouched_label":
if not pr_review_enabled:
return RouteDecision("skip", None, repo, None, "PR review disabled")
label = payload.get("label")
label_name = str(label.get("name") or "") if isinstance(label, Mapping) else ""
if label_name.lower() != vouch_review_label.lower():
return RouteDecision("skip", None, repo, None, f"label {label_name!r} not vouch label")
sender = payload.get("sender")
labeler = str(sender.get("login") or "") if isinstance(sender, Mapping) else ""
if labeler.lower() != vouch_review_labeler.lower():
return RouteDecision("skip", None, repo, None, f"vouch label not from trusted labeler ({labeler!r})")
return _pr_review_pr(payload.get("pull_request") or {}, repo, action, bot_login)
if event_type == "pull_request_review_comment" and action == "created":
comment = payload.get("comment") or {}
+3
View File
@@ -341,6 +341,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
maintainers=cfg.maintainer_logins,
reviewer_bots=cfg.reviewer_bots,
pr_review_enabled=cfg.pr_review_enabled,
pr_review_trigger=cfg.pr_review_trigger,
vouch_review_label=cfg.vouch_review_label,
vouch_review_labeler=cfg.vouch_review_labeler,
resolve_issue_from_pr=_resolve,
)
+194
View File
@@ -857,3 +857,197 @@ def test_route_non_directive_comment_carries_no_pragmas() -> None:
)
assert decision.directive is False
assert decision.directive_pragmas == ()
# ---------- vouched-label deferred PR review ----------
def test_route_vouched_label_defers_pr_open() -> None:
decision = route(
"pull_request",
{
"action": "opened",
"pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "deferred to vouch label"
def test_route_vouched_label_reviews_on_label() -> None:
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "vouched"},
"pull_request": {
"number": 9,
"draft": False,
"user": {"login": "alice", "type": "User"},
"author_association": "CONTRIBUTOR",
},
"repository": {"full_name": "octo/widget"},
"sender": {"login": "github-actions[bot]"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert decision.should_queue
assert decision.task == "review_pr"
assert decision.issue_key == "octo/widget#9"
assert decision.submitter == "alice"
def test_route_vouched_label_ignores_other_labels() -> None:
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "bug"},
"pull_request": {"number": 9, "user": {"login": "alice"}},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
def test_route_vouched_label_ready_for_review_defers_even_with_label() -> None:
# Persisted labels are NOT trusted; the workflow re-applies the label (a
# fresh `labeled` event) after re-validating, so ready_for_review defers.
decision = route(
"pull_request",
{
"action": "ready_for_review",
"pull_request": {
"number": 9,
"draft": False,
"user": {"login": "alice", "type": "User"},
"labels": [{"name": "vouched"}],
},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "deferred to vouch label"
def test_route_vouched_label_labeled_skips_draft() -> None:
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "vouched"},
"pull_request": {"number": 9, "draft": True, "user": {"login": "alice", "type": "User"}},
"repository": {"full_name": "octo/widget"},
"sender": {"login": "github-actions[bot]"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "draft PR"
def test_route_default_trigger_ignores_labeled() -> None:
# Backward-compat: the default "open" trigger does not route `labeled`.
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "vouched"},
"pull_request": {"number": 9, "user": {"login": "alice"}},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
)
assert not decision.should_queue
def test_route_vouched_label_reopened_defers_even_with_label() -> None:
# A since-denounced author must not slip through on reopen via a stale
# label; reopened always defers to a fresh gate check + re-label.
decision = route(
"pull_request",
{
"action": "reopened",
"pull_request": {
"number": 9,
"draft": False,
"user": {"login": "alice", "type": "User"},
"labels": [{"name": "vouched"}],
},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "deferred to vouch label"
def test_route_vouched_label_reopened_without_label_defers() -> None:
decision = route(
"pull_request",
{
"action": "reopened",
"pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}},
"repository": {"full_name": "octo/widget"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "deferred to vouch label"
def test_route_vouched_label_rejects_manual_labeler() -> None:
# A triage/maintainer hand-adding the label must NOT trigger review.
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "vouched"},
"pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}},
"repository": {"full_name": "octo/widget"},
"sender": {"login": "evilmaintainer"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert "trusted labeler" in decision.reason
def test_route_vouched_label_skips_closed_pr() -> None:
# `labeled` can fire on a closed PR; never review one.
decision = route(
"pull_request",
{
"action": "labeled",
"label": {"name": "vouched"},
"pull_request": {"number": 9, "state": "closed", "user": {"login": "alice", "type": "User"}},
"repository": {"full_name": "octo/widget"},
"sender": {"login": "github-actions[bot]"},
},
allowlist=ALLOWLIST,
bot_login=BOT,
pr_review_trigger="vouched_label",
)
assert not decision.should_queue
assert decision.reason == "PR not open"