Skip to content

Commit c2cd5b6

Browse files
committed
Gate --pr recheck on trusted author + requester
- Add --requester and --allow-untrusted-author flags to the review CLI, plumbed through greenlight-review.yml as the `requester` workflow input - Add github_client.get_pr_author to resolve a single PR's author for the --pr scan path - Enforce two authz gates in review.run(): reject untrusted requesters before any network work, and refuse --pr unless the target PR's author is trusted (both case-insensitive, clean exit 0 on refusal) - Add --allow-untrusted-author as a LOCAL-ONLY bypass of the target-author gate, never exposed as a workflow input and never affecting the requester - Document the @greenlight recheck flow in README/CHEATSHEET; cover all gate combinations with new tests Backs the @greenlight recheck feature: a thin pytorch/pytorch trigger (deployed separately) dispatches greenlight-review.yml with the PR number and commenter login. Since --pr names an arbitrary PR (unlike the listing scan, which is already trusted-author-only), the scan must independently verify the target PR's author is trusted, or greenlight could be coaxed into reviewing/approving any PR on request. The core guard: a trusted requester still cannot bypass the target-author gate. resolve_authorized (merge-rules work) runs only after both gates pass, so a spammed recheck from an untrusted login costs nothing. The recheck hint is not yet advertised in the PR status comment; that lands with the trigger. Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent fb9979d commit c2cd5b6

9 files changed

Lines changed: 382 additions & 8 deletions

File tree

.github/workflows/greenlight-review.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ on:
1212
required: false
1313
default: ""
1414
type: string
15+
requester:
16+
description: "Login that requested this review (@greenlight recheck); must be a trusted author"
17+
required: false
18+
default: ""
19+
type: string
1520
max:
1621
description: "Max dispatches this run (empty = no cap)"
1722
required: false
@@ -86,6 +91,7 @@ jobs:
8691
CLICKHOUSE_USERNAME: ${{ secrets.CLICKHOUSE_HUD_USER_USERNAME }}
8792
CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_HUD_USER_PASSWORD }}
8893
IN_PR: ${{ inputs.pr }}
94+
IN_REQUESTER: ${{ inputs.requester }}
8995
IN_MAX: ${{ inputs.max }}
9096
IN_REF: ${{ inputs.ref }}
9197
IN_TIMEOUT: ${{ inputs.timeout_minutes }}
@@ -94,6 +100,7 @@ jobs:
94100
set -euo pipefail
95101
args=()
96102
if [ -n "$IN_PR" ]; then args+=(--pr "$IN_PR"); fi
103+
if [ -n "$IN_REQUESTER" ]; then args+=(--requester "$IN_REQUESTER"); fi
97104
if [ -n "$IN_MAX" ]; then args+=(--max "$IN_MAX"); fi
98105
args+=(--ref "$IN_REF" --timeout-minutes "$IN_TIMEOUT" --log-level "$IN_LOG_LEVEL")
99106
just review "${args[@]}"

greenlight/CHEATSHEET.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ pytest, yamllint) into `.venv`.
3939
```bash
4040
just review # one scan + dispatch iteration, then exit
4141
just run <args> # pass arbitrary args to the greenlight CLI (review is a shortcut)
42-
just review --pr 123 # restrict the scan to PR #123
42+
just review --pr 123 # scan only PR #123 (its author must be trusted)
43+
just review --pr 123 --requester alice # recheck PR #123 for alice (author and alice must be trusted)
4344
just review --max 5 # cap this iteration at 5 dispatches
4445
just review --ref my-branch # dispatch the reviewer workflow at this test-infra ref (default main)
4546
just review --timeout-minutes 60 # re-dispatch an in-flight review after 60 min (default 45)
47+
just review --pr 123 --allow-untrusted-author # LOCAL ONLY: skip the --pr author check
4648
```
4749

4850
`just review` scans the trusted authors' open PRs and, for each PR that is new or changed
@@ -63,6 +65,13 @@ scanner lets a running review finish (or time out and record a verdict) before i
6365
re-dispatches, rather than cancelling and restarting one that is still running; lower
6466
`--timeout-minutes` in the deployment if you need a stuck review reclaimed sooner.
6567

68+
`@greenlight recheck` on a `pytorch/pytorch` PR (via the separately deployed trigger) dispatches
69+
`greenlight-review.yml` with the PR number and commenter as `--pr N --requester <login>`. The scan
70+
is the sole authorizer: `--pr` refuses unless PR N's author is trusted, and `--requester` refuses
71+
unless the commenter is trusted too (case-insensitive; a refusal is a clean exit 0). The local-only
72+
`--allow-untrusted-author` skips the target-author check for iteration and is never a workflow
73+
input. The command is not yet advertised in the PR status comment.
74+
6675
Daemon mode loops the phase on an interval:
6776

6877
```bash

greenlight/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ just run review # scan + dispatch once, then exit
3737
just review # convenience alias for `just run review`
3838
just run review --loop # scan + dispatch forever as a daemon
3939
just run review --loop --interval 30 # daemon, 30s between iterations
40-
just run review --pr 123 # restrict the scan to PR #123
40+
just run review --pr 123 # scan only PR #123 (its author must be trusted)
41+
just run review --pr 123 --requester alice # recheck PR #123 for alice (author and alice must be trusted)
4142
just run review --max 5 # cap this iteration at 5 dispatches
4243
just run review --ref my-branch # dispatch the reviewer workflow at this test-infra ref (default main)
4344
just run review --timeout-minutes 60 # re-dispatch an in-flight review after 60 min (default 45)
45+
just run review --pr 123 --allow-untrusted-author # LOCAL ONLY: skip the --pr author check
4446
```
4547

4648
`review` requires `PYTORCH_GREENLIGHT_GITHUB_TOKEN`, and any scan that finds at least one
@@ -53,6 +55,31 @@ record a verdict) before it re-dispatches, rather than cancelling and restarting
5355
is still running. Lower `--timeout-minutes` in the deployment if you need a stuck review
5456
reclaimed sooner.
5557

58+
### Rechecking a PR (`@greenlight recheck`)
59+
60+
A trusted author can re-trigger a review by commenting `@greenlight recheck` on a
61+
`pytorch/pytorch` PR. A thin `pytorch/pytorch` workflow (deployed separately) dispatches
62+
`greenlight-review.yml` with the PR number and the commenter's login, and the scan re-checks
63+
that one PR through the `--pr` path.
64+
65+
The scan is the single source of authorization and enforces two gates against the trusted-author
66+
set (`review.TRUSTED_AUTHORS`), matched case-insensitively:
67+
68+
- **Target-author gate**`--pr N` looks up PR `N`'s author and refuses (no fingerprint, no
69+
dispatch, no review) unless that author is trusted. Unlike the listing scan, `--pr` names an
70+
arbitrary PR, so this gate is what stops an arbitrary PR from being reviewed or approved on request.
71+
- **Requester gate**`--requester <login>`, when given, additionally requires `<login>` to be
72+
trusted; an untrusted requester is refused before any network work, and the requester is logged
73+
for audit.
74+
75+
A refusal is a clean no-op (exit 0), not a failure. `--allow-untrusted-author` is a **local-only**
76+
flag that skips the target-author gate for iteration; it is deliberately not exposed as a
77+
`greenlight-review.yml` input, is unreachable from the comment/dispatch path, and never affects the
78+
requester gate.
79+
80+
The user-facing `@greenlight recheck` hint is not yet advertised in the PR status comment; that
81+
is added once the `pytorch/pytorch` trigger is deployed.
82+
5683
### Recording a verdict
5784

5885
A privileged CI job records a review verdict with `verdict`. It runs once (never a

greenlight/src/greenlight/cli.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ def build_parser() -> argparse.ArgumentParser:
4646
),
4747
)
4848
review_parser.add_argument(
49-
"--pr", type=int, default=None, help="scan only this PR number, bypassing the trusted-author listing"
49+
"--pr",
50+
type=int,
51+
default=None,
52+
help="scan only this PR number (skips the listing; the PR's author must still be trusted)",
5053
)
5154
review_parser.add_argument(
5255
"--max",
@@ -66,6 +69,16 @@ def build_parser() -> argparse.ArgumentParser:
6669
action="store_true",
6770
help="re-dispatch even if already reviewed; requires --pr, not allowed with --loop",
6871
)
72+
review_parser.add_argument(
73+
"--requester",
74+
default=None,
75+
help="login that requested this review (@greenlight recheck); must be a trusted author or the run refuses",
76+
)
77+
review_parser.add_argument(
78+
"--allow-untrusted-author",
79+
action="store_true",
80+
help="LOCAL USE ONLY: skip the --pr target-author trusted check (never exposed as a workflow input)",
81+
)
6982

7083
verdict_parser = subparsers.add_parser(
7184
"verdict",
@@ -203,6 +216,8 @@ def main(argv: Sequence[str] | None = None) -> int:
203216
ref=args.ref,
204217
timeout_minutes=args.timeout_minutes,
205218
force=args.force,
219+
requester=args.requester,
220+
allow_untrusted_author=args.allow_untrusted_author,
206221
resolve_authorized=authorized_cache.get,
207222
)
208223
return _dispatch(config, run, loop=args.loop, lock_path=lock_path)

greenlight/src/greenlight/github_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ def get_reviews(self) -> Iterable[_PRReview]: ...
8080
class _ScanRepo(Protocol):
8181
def get_pull(self, number: int) -> _FingerprintPR: ...
8282

83+
class _AuthorPR(Protocol):
84+
@property
85+
def user(self) -> _PRUser | None: ...
86+
87+
class _AuthorRepo(Protocol):
88+
def get_pull(self, number: int) -> _AuthorPR: ...
89+
90+
class _AuthorClient(Protocol):
91+
def get_repo(self, full_name_or_id: str) -> _AuthorRepo: ...
92+
8393
class _VerdictReview(Protocol):
8494
@property
8595
def id(self) -> int: ...
@@ -167,6 +177,18 @@ def list_open_prs_by_authors(client: _RepoClient, repo: str, authors: Iterable[s
167177
return sorted(prs, key=lambda p: p.number)
168178

169179

180+
def get_pr_author(client: _AuthorClient, repo: str, number: int) -> str | None:
181+
"""Return the login of a single PR's author, or None if it has no resolvable user.
182+
183+
Used by the ``--pr`` scan path to gate on the target PR's author: unlike the listing path
184+
(already filtered to trusted authors), ``--pr`` names an arbitrary PR, so the caller must
185+
verify its author before fingerprinting or dispatching a review.
186+
"""
187+
pr = client.get_repo(repo).get_pull(number)
188+
user = pr.user
189+
return user.login if user is not None else None
190+
191+
170192
def _actor_login(
171193
user: _PRActor | None, self_login: str | None, authorized_logins: frozenset[str] | None = None
172194
) -> str | None:

greenlight/src/greenlight/review.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@
4646
"jeanschmidt", # Jean Schmidt
4747
}
4848

49+
# Case-insensitive membership for the two authz gates (target-PR author and recheck requester);
50+
# GitHub logins are case-insensitive, so gate on the lowercased login against this derived set.
51+
_TRUSTED_LOWER: frozenset[str] = frozenset(author.lower() for author in TRUSTED_AUTHORS)
52+
53+
54+
def _is_trusted(login: str | None) -> bool:
55+
return login is not None and login.lower() in _TRUSTED_LOWER
56+
57+
4958
_FINGERPRINT_WORKERS = 8
5059

5160
# Never-reviewed candidates sort ahead of every recorded one; this stands in for their
@@ -64,6 +73,10 @@ def _default_fetch(client: Github) -> list[OpenPR]:
6473
return github_client.list_open_prs_by_authors(client, TARGET_REPO, TRUSTED_AUTHORS)
6574

6675

76+
def _default_fetch_author(client: Github, pr_number: int) -> str | None:
77+
return github_client.get_pr_author(client, TARGET_REPO, pr_number)
78+
79+
6780
def _default_fingerprint(client: Github, pr_number: int, authorized_logins: frozenset[str]) -> tuple[str, str]:
6881
return github_client.fingerprint_pr(client, TARGET_REPO, pr_number, authorized_logins=authorized_logins)
6982

@@ -289,8 +302,11 @@ def run(
289302
ref: str = DEFAULT_DISPATCH_REF,
290303
timeout_minutes: int = DEFAULT_TIMEOUT_MINUTES,
291304
force: bool = False,
305+
requester: str | None = None,
306+
allow_untrusted_author: bool = False,
292307
build_github: Callable[[str], Github] = github_client.build_client,
293308
fetch: Callable[[Github], list[OpenPR]] = _default_fetch,
309+
fetch_author: Callable[[Github, int], str | None] = _default_fetch_author,
294310
fingerprint: Callable[[Github, int, frozenset[str]], tuple[str, str]] = _default_fingerprint,
295311
read_state: Callable[[str, Sequence[int]], dict[int, PRState]] = state.read_latest_states,
296312
dispatch: Callable[[Github, int, str, str, str], None] = dispatch_module.dispatch_review,
@@ -302,13 +318,29 @@ def run(
302318
token = config.github_token
303319
if not token:
304320
raise ValueError("PYTORCH_GREENLIGHT_GITHUB_TOKEN is required to query GitHub")
305-
# Resolved once per scan and never caught here: a cold failure must fail the scan (one-shot
306-
# exits non-zero, daemon backs off) rather than silently revert to hashing all human comments.
307-
authorized_logins = resolve_authorized()
308-
logger.info("filtering fingerprint comments to %d merge-authorized login(s)", len(authorized_logins))
321+
# Requester gate (recheck path): an untrusted requester is rejected before any network work,
322+
# so a spammed @greenlight recheck from an untrusted login costs nothing. A policy refusal is
323+
# not a failure -- return cleanly rather than raising (no non-zero exit, no daemon backoff).
324+
if requester is not None:
325+
if not _is_trusted(requester):
326+
logger.warning("refusing review: requester %r is not a trusted author", requester)
327+
return
328+
logger.info("review requested by trusted author %s", requester)
309329
with contextlib.ExitStack() as clients:
310330
client = build_github(token)
311331
clients.callback(_close_client, client)
332+
# Target-author gate: the listing path is already trusted-only, but --pr names an arbitrary
333+
# PR, so its author MUST be trusted too or greenlight would review/approve any PR on request.
334+
# allow_untrusted_author bypasses ONLY this check (local iteration; never a workflow input).
335+
if pr is not None and not allow_untrusted_author:
336+
author = fetch_author(client, pr)
337+
if not _is_trusted(author):
338+
logger.warning("refusing --pr %d: author %r is not a trusted author", pr, author)
339+
return
340+
# Resolved once per scan and never caught here: a cold failure must fail the scan (one-shot
341+
# exits non-zero, daemon backs off) rather than silently revert to hashing all human comments.
342+
authorized_logins = resolve_authorized()
343+
logger.info("filtering fingerprint comments to %d merge-authorized login(s)", len(authorized_logins))
312344
pr_numbers, updated_at_by_number = _candidate_numbers(client, pr=pr, fetch=fetch)
313345
states = read_state(TARGET_REPO, pr_numbers)
314346
evaluated_at = now()

greenlight/tests/test_cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def fake_run_forever(config, *, run):
107107
"ref": "main",
108108
"timeout_minutes": 45,
109109
"force": False,
110+
"requester": None,
111+
"allow_untrusted_author": False,
110112
}
111113

112114

@@ -504,6 +506,15 @@ def test_review_parser_scan_flag_defaults():
504506
assert args.ref == DEFAULT_DISPATCH_REF
505507
assert args.timeout_minutes == DEFAULT_TIMEOUT_MINUTES
506508
assert args.force is False
509+
assert args.requester is None
510+
assert args.allow_untrusted_author is False
511+
512+
513+
def test_review_parser_parses_requester_and_allow_untrusted_author():
514+
parser = cli.build_parser()
515+
args = parser.parse_args(["review", "--pr", "5", "--requester", "albanD", "--allow-untrusted-author"])
516+
assert args.requester == "albanD"
517+
assert args.allow_untrusted_author is True
507518

508519

509520
def test_main_review_binds_scan_flags_into_run(monkeypatch):
@@ -523,6 +534,8 @@ def test_main_review_binds_scan_flags_into_run(monkeypatch):
523534
"ref": "release/2.9",
524535
"timeout_minutes": 60,
525536
"force": False,
537+
"requester": None,
538+
"allow_untrusted_author": False,
526539
}
527540

528541

@@ -541,6 +554,8 @@ def test_main_review_defaults_bind_into_run(monkeypatch):
541554
"ref": DEFAULT_DISPATCH_REF,
542555
"timeout_minutes": DEFAULT_TIMEOUT_MINUTES,
543556
"force": False,
557+
"requester": None,
558+
"allow_untrusted_author": False,
544559
}
545560

546561

@@ -579,6 +594,28 @@ def test_main_review_force_binds_into_run(monkeypatch):
579594
"ref": DEFAULT_DISPATCH_REF,
580595
"timeout_minutes": DEFAULT_TIMEOUT_MINUTES,
581596
"force": True,
597+
"requester": None,
598+
"allow_untrusted_author": False,
599+
}
600+
601+
602+
def test_main_review_binds_requester_and_override_into_run(monkeypatch):
603+
review_mock = Mock()
604+
monkeypatch.setattr(review, "run", review_mock)
605+
monkeypatch.setattr(cli, "single_instance_lock", _noop_lock)
606+
monkeypatch.setattr(cli, "configure_logging", Mock())
607+
608+
rc = cli.main(["review", "--pr", "5", "--requester", "albanD", "--allow-untrusted-author"])
609+
610+
assert rc == EXIT_OK
611+
assert _pop_resolve_authorized(review_mock.call_args.kwargs) == {
612+
"pr": 5,
613+
"max_dispatches": None,
614+
"ref": DEFAULT_DISPATCH_REF,
615+
"timeout_minutes": DEFAULT_TIMEOUT_MINUTES,
616+
"force": False,
617+
"requester": "albanD",
618+
"allow_untrusted_author": True,
582619
}
583620

584621

greenlight/tests/test_github_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,48 @@ def test_list_open_prs_by_authors_propagates_errors():
263263
github_client.list_open_prs_by_authors(client, "pytorch/pytorch", ["alice"])
264264

265265

266+
class _FakeAuthorPull:
267+
def __init__(self, user: _FakeUser | None) -> None:
268+
self.user = user
269+
270+
271+
class _FakeAuthorRepo:
272+
def __init__(self, pull: _FakeAuthorPull) -> None:
273+
self._pull = pull
274+
self.get_pull_numbers: list[int] = []
275+
276+
def get_pull(self, number: int) -> _FakeAuthorPull:
277+
self.get_pull_numbers.append(number)
278+
return self._pull
279+
280+
281+
class _FakeAuthorClient:
282+
def __init__(self, repo: _FakeAuthorRepo) -> None:
283+
self._repo = repo
284+
self.get_repo_names: list[str] = []
285+
286+
def get_repo(self, full_name_or_id: str) -> _FakeAuthorRepo:
287+
self.get_repo_names.append(full_name_or_id)
288+
return self._repo
289+
290+
291+
def test_get_pr_author_returns_login():
292+
repo = _FakeAuthorRepo(_FakeAuthorPull(_FakeUser("albanD")))
293+
client = _FakeAuthorClient(repo)
294+
295+
author = github_client.get_pr_author(client, "pytorch/pytorch", 42)
296+
297+
assert author == "albanD"
298+
assert client.get_repo_names == ["pytorch/pytorch"]
299+
assert repo.get_pull_numbers == [42]
300+
301+
302+
def test_get_pr_author_returns_none_when_user_missing():
303+
client = _FakeAuthorClient(_FakeAuthorRepo(_FakeAuthorPull(None)))
304+
305+
assert github_client.get_pr_author(client, "pytorch/pytorch", 7) is None
306+
307+
266308
def test_build_client_returns_github_instance_with_per_page_100():
267309
client = github_client.build_client("x")
268310

0 commit comments

Comments
 (0)