Skip to content

Commit 92a38f0

Browse files
committed
Update
[ghstack-poisoned]
1 parent b933ad0 commit 92a38f0

4 files changed

Lines changed: 234 additions & 32 deletions

File tree

greenlight/src/greenlight/github_client.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
from typing import TYPE_CHECKING
1212

1313
from greenlight import constants
14-
from greenlight.pr_hash import HumanEvent, PRFingerprint, compute_pr_hash, is_bot
14+
from greenlight.pr_hash import HumanEvent, PRFingerprint, compute_pr_hash, is_bot, is_bot_command
1515
from greenlight.review_gate import ReviewSkip, human_review_skip_reason
1616
from greenlight.state import naive_utc
1717

1818
if TYPE_CHECKING:
19-
from collections.abc import Iterable
19+
from collections.abc import Iterable, Iterator
2020
from datetime import datetime
2121

2222
from github import Github
@@ -29,6 +29,7 @@
2929
_AuthorClient,
3030
_FingerprintPR,
3131
_PRActor,
32+
_PRComment,
3233
_PRReview,
3334
_RepoClient,
3435
)
@@ -213,6 +214,26 @@ def _actor_login(
213214
return login
214215

215216

217+
def _fingerprint_events(
218+
events: Iterable[_PRComment | _PRReview],
219+
self_login: str | None,
220+
authorized_logins: frozenset[str] | None,
221+
) -> Iterator[HumanEvent]:
222+
"""Yield a ``HumanEvent`` for each event passing BOTH fingerprint filters.
223+
224+
Shared across all three sources so every source filters identically: an event is kept only
225+
when ``_actor_login`` attributes it (non-ghost, non-bot, non-self, authorized) AND its body
226+
is not a bot command (``is_bot_command``), so a trusted author's ``@pytorchbot merge`` never
227+
enters the fingerprint.
228+
"""
229+
for event in events:
230+
if _actor_login(event.user, self_login, authorized_logins) is None:
231+
continue
232+
if is_bot_command(event.body):
233+
continue
234+
yield HumanEvent(id=event.id, body=event.body)
235+
236+
216237
def build_pr_fingerprint(
217238
pr: _FingerprintPR,
218239
*,
@@ -235,25 +256,15 @@ def build_pr_fingerprint(
235256
the verifier MUST pass the identically-resolved set or their digests diverge.
236257
237258
Coverage: the fingerprint covers ``head_sha`` and the ``id`` and ``body`` of
238-
non-bot, non-self human events (issue comments, review comments, and reviews). It
239-
deliberately EXCLUDES the changed files, event kind/author/state/timestamp, and the
240-
PR title/body.
259+
non-bot, non-self human events (issue comments, review comments, and reviews),
260+
EXCLUDING any whose body is a bot command (contains a bot-command @-mention such as
261+
``@pytorchbot merge``; see ``is_bot_command``). It also deliberately EXCLUDES the
262+
changed files, event kind/author/state/timestamp, and the PR title/body.
241263
"""
242264
human_events: list[HumanEvent] = []
243-
for comment in pr.get_issue_comments():
244-
if _actor_login(comment.user, self_login, authorized_logins) is None:
245-
continue
246-
human_events.append(HumanEvent(id=comment.id, body=comment.body))
247-
248-
for review_comment in pr.get_review_comments():
249-
if _actor_login(review_comment.user, self_login, authorized_logins) is None:
250-
continue
251-
human_events.append(HumanEvent(id=review_comment.id, body=review_comment.body))
252-
253-
for review in reviews:
254-
if _actor_login(review.user, self_login, authorized_logins) is None:
255-
continue
256-
human_events.append(HumanEvent(id=review.id, body=review.body))
265+
human_events.extend(_fingerprint_events(pr.get_issue_comments(), self_login, authorized_logins))
266+
human_events.extend(_fingerprint_events(pr.get_review_comments(), self_login, authorized_logins))
267+
human_events.extend(_fingerprint_events(reviews, self_login, authorized_logins))
257268

258269
return PRFingerprint(
259270
head_sha=pr.head.sha,

greenlight/src/greenlight/pr_hash.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
``trymerge.py`` authorization check: that is case-sensitive and scoped to the rules whose
1717
file patterns match a single PR, so its set diverges from this full lowercased union and
1818
yields a different digest -- refusing every land.
19+
20+
Bot-command comments/reviews are excluded too: any comment or review whose body contains a
21+
bot-command @-mention (see ``BOT_COMMAND_MENTIONS`` / ``is_bot_command``) is dropped whole, so a
22+
trusted author's ``@pytorchbot merge`` never perturbs the digest. This is part of the same
23+
byte-identical cross-process contract -- the land-time verifier MUST import ``is_bot_command`` and
24+
MUST NOT reimplement it, or its digest diverges and it refuses every land.
1925
"""
2026

2127
from __future__ import annotations
@@ -25,7 +31,7 @@
2531
from dataclasses import asdict, dataclass
2632
from typing import Any
2733

28-
HASH_SCHEME_VERSION = 5
34+
HASH_SCHEME_VERSION = 6
2935

3036
BOT_LOGINS: frozenset[str] = frozenset(
3137
{
@@ -44,6 +50,16 @@
4450
}
4551
)
4652

53+
# Lowercase; ``@pytorchbot`` is NOT a substring of ``@pytorchmergebot``, so both are listed.
54+
BOT_COMMAND_MENTIONS: frozenset[str] = frozenset(
55+
{
56+
"@pytorchbot",
57+
"@pytorchmergebot",
58+
"@claude",
59+
"@greenlight",
60+
}
61+
)
62+
4763

4864
def is_bot(login: str | None, user_type: str | None = None) -> bool:
4965
if user_type is not None and user_type.lower() == "bot":
@@ -56,6 +72,13 @@ def is_bot(login: str | None, user_type: str | None = None) -> bool:
5672
return normalized_login in BOT_LOGINS
5773

5874

75+
def is_bot_command(body: str | None) -> bool:
76+
if not body:
77+
return False
78+
normalized_body = body.lower()
79+
return any(mention in normalized_body for mention in BOT_COMMAND_MENTIONS)
80+
81+
5982
@dataclass(frozen=True, slots=True)
6083
class HumanEvent:
6184
id: int

greenlight/tests/test_github_client.py

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,89 @@ def test_build_pr_fingerprint_self_login_excluded_even_when_authorized():
690690
assert fingerprint.human_events == (HumanEvent(id=1, body="authorized other"),)
691691

692692

693+
def test_build_pr_fingerprint_excludes_bot_command_bodies_across_all_sources():
694+
pr = _FakePR(
695+
issue_comments=[
696+
_FakeComment(1, _FakeActor("alice", "User"), "please fix"),
697+
_FakeComment(2, _FakeActor("alice", "User"), "@pytorchbot merge"),
698+
],
699+
review_comments=[
700+
_FakeComment(3, _FakeActor("bob", "User"), "nit: rename this"),
701+
_FakeComment(4, _FakeActor("bob", "User"), "@pytorchmergebot rebase"),
702+
],
703+
reviews=[
704+
_FakeReview(5, _FakeActor("carol", "User"), "lgtm"),
705+
_FakeReview(6, _FakeActor("carol", "User"), "@claude take a look"),
706+
],
707+
)
708+
709+
fingerprint = _build_fp(pr)
710+
711+
# One bot-command body per source is dropped whole; the three normal human comments survive, so a
712+
# trusted author's '@pytorchbot merge' never perturbs the digest.
713+
assert fingerprint.human_events == (
714+
HumanEvent(id=1, body="please fix"),
715+
HumanEvent(id=3, body="nit: rename this"),
716+
HumanEvent(id=5, body="lgtm"),
717+
)
718+
719+
720+
def test_build_pr_fingerprint_bot_command_excluded_on_top_of_authorized_filter():
721+
pr = _FakePR(
722+
issue_comments=[
723+
_FakeComment(1, _FakeActor("alice", "User"), "genuine review note"),
724+
_FakeComment(2, _FakeActor("alice", "User"), "@greenlight status"),
725+
],
726+
review_comments=[],
727+
reviews=[],
728+
)
729+
730+
# alice is authorized, so the author filter keeps both comments; the bot-command body is still
731+
# dropped, proving is_bot_command runs in addition to (not instead of) the author filter.
732+
fingerprint = _build_fp(pr, authorized_logins=frozenset({"alice"}))
733+
734+
assert fingerprint.human_events == (HumanEvent(id=1, body="genuine review note"),)
735+
736+
737+
def test_build_pr_fingerprint_bot_command_does_not_change_eval_hash():
738+
authorized = frozenset({"alice"})
739+
base = _FakePR(
740+
issue_comments=[
741+
_FakeComment(1, _FakeActor("alice", "User"), "please fix"),
742+
_FakeComment(2, _FakeActor("alice", "User"), "one more thing"),
743+
],
744+
review_comments=[],
745+
reviews=[],
746+
)
747+
base_hash = compute_pr_hash(_build_fp(base, authorized_logins=authorized))
748+
749+
with_bot_command = _FakePR(
750+
issue_comments=[
751+
_FakeComment(1, _FakeActor("alice", "User"), "please fix"),
752+
_FakeComment(2, _FakeActor("alice", "User"), "one more thing"),
753+
_FakeComment(3, _FakeActor("alice", "User"), "@pytorchbot merge"),
754+
],
755+
review_comments=[],
756+
reviews=[],
757+
)
758+
# A '@pytorchbot merge' from the same authorized author is dropped whole, so the eval_hash is
759+
# unchanged and the scan does not re-dispatch a review over it.
760+
assert compute_pr_hash(_build_fp(with_bot_command, authorized_logins=authorized)) == base_hash
761+
762+
with_normal_comment = _FakePR(
763+
issue_comments=[
764+
_FakeComment(1, _FakeActor("alice", "User"), "please fix"),
765+
_FakeComment(2, _FakeActor("alice", "User"), "one more thing"),
766+
_FakeComment(3, _FakeActor("alice", "User"), "actually, rename this"),
767+
],
768+
review_comments=[],
769+
reviews=[],
770+
)
771+
# Control: swapping only that one body for a non-command comment moves the eval_hash, proving
772+
# the equality above is not vacuous.
773+
assert compute_pr_hash(_build_fp(with_normal_comment, authorized_logins=authorized)) != base_hash
774+
775+
693776
def test_fingerprint_pr_threads_authorized_logins():
694777
pr = _FakePR(
695778
issue_comments=[
@@ -735,12 +818,12 @@ def _golden_pr() -> _FakePR:
735818
)
736819

737820

738-
def test_build_pr_fingerprint_golden_hash_scheme_v5():
739-
"""End-to-end golden: build_pr_fingerprint -> compute_pr_hash pins the scheme-v5 digest.
821+
def test_build_pr_fingerprint_golden_hash_scheme_v6():
822+
"""End-to-end golden: build_pr_fingerprint -> compute_pr_hash pins the current-scheme (v6) digest.
740823
741-
Guards against drift in is_bot / BOT_LOGINS / self_login exclusion and the
742-
PR-field mapping. Uses the default scheme_version (5); a future
743-
HASH_SCHEME_VERSION bump regenerates this literal.
824+
Guards against drift in is_bot / BOT_LOGINS / is_bot_command / self_login exclusion and the
825+
PR-field mapping. Uses the default scheme_version (6); a future HASH_SCHEME_VERSION bump
826+
regenerates this literal.
744827
"""
745828
pr = _golden_pr()
746829
fingerprint = _build_fp(pr, self_login="greenlight")
@@ -749,7 +832,7 @@ def test_build_pr_fingerprint_golden_hash_scheme_v5():
749832
HumanEvent(id=1, body="please fix"),
750833
HumanEvent(id=6, body="lgtm"),
751834
)
752-
assert compute_pr_hash(fingerprint) == "9d0506bd3e887a00d858f49e653cab9f913f185674a475582706cc83fcae70d4"
835+
assert compute_pr_hash(fingerprint) == "51a4e58faef3da88e3fe8506437a21d0d7e8060256a2428c410d37a2e8122926"
753836

754837

755838
@pytest.mark.parametrize("null_login", [None, ""])
@@ -904,18 +987,19 @@ def test_fingerprint_pr_allow_skip_no_decision_builds_fingerprint_fetching_revie
904987
assert "get_review_comments" in pr.calls
905988

906989

907-
def test_fingerprint_pr_hash_is_byte_identical_to_pre_skip_scheme():
908-
"""Characterization: a non-skipped PR's eval_hash equals the pre-refactor digest.
990+
def test_fingerprint_pr_golden_hash_scheme_v6():
991+
"""End-to-end golden for the current scheme (v6) through the fingerprint_pr entry point.
909992
910-
Pins the digest produced before reviews were threaded through build_pr_fingerprint,
911-
proving the fingerprint payload is unchanged for PRs that are still fingerprinted.
993+
fingerprint_pr leaves self_login unset, so (unlike test_build_pr_fingerprint_golden_hash_scheme_v6)
994+
the greenlight self-note survives and this pins a distinct digest. A HASH_SCHEME_VERSION bump
995+
regenerates this literal.
912996
"""
913997
pr = _golden_pr()
914998
client = _FakeScanClient(_FakeScanRepo(pr))
915999

9161000
result = github_client.fingerprint_pr(client, "pytorch/pytorch", 9)
9171001

918-
assert result == ("head-sha", "bcf6a1d21566873cd1da85fa724bd1e9996e213b869ed28544751c7e4e06a0f4")
1002+
assert result == ("head-sha", "fed90fac1ad308b7149a622b6d81da3f309ab933d12c85f427d300534f700408")
9191003

9201004

9211005
class _FakeVerdictReview:

greenlight/tests/test_pr_hash.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import pytest
22

33
from greenlight.pr_hash import (
4+
BOT_COMMAND_MENTIONS,
45
BOT_LOGINS,
56
HASH_SCHEME_VERSION,
67
HumanEvent,
78
PRFingerprint,
89
compute_pr_hash,
910
is_bot,
11+
is_bot_command,
1012
)
1113

1214

@@ -154,8 +156,47 @@ def test_compute_pr_hash_golden_pins_canonical_sort_key_scheme_v5():
154156
assert compute_pr_hash(fp) == "1301c1364eff060bfb8b94f3252e8256637279b8c48be6638f502b06db24922d"
155157

156158

159+
def test_compute_pr_hash_golden_scheme_v6():
160+
"""Golden digest pinning the current scheme (v6, SHA-256).
161+
162+
Any change to the payload, its canonicalization, or the hash algorithm breaks
163+
this on purpose; regenerate the literal only alongside a HASH_SCHEME_VERSION bump.
164+
"""
165+
fp = PRFingerprint(
166+
head_sha="def456",
167+
human_events=(
168+
HumanEvent(id=2, body="lgtm"),
169+
HumanEvent(id=1, body="hi"),
170+
),
171+
scheme_version=6,
172+
)
173+
174+
assert compute_pr_hash(fp) == "cb4e0c1926ec5d63d4ba09290d1ccdd46ba7cf2e0c2e2ece2a9fbfefc5fc3226"
175+
176+
177+
def test_compute_pr_hash_golden_pins_canonical_sort_key_scheme_v6():
178+
"""Golden that pins the payload sort KEY (``key=_canonical``) at the current scheme (v6).
179+
180+
The fixture is crafted so canonical (sorted-dict-key) ordering diverges from a
181+
naive ``key=str`` / field-order / insertion ordering: canonical weighs ``body``
182+
first (``body`` sorts before ``id``), while ``str`` weighs ``id`` first. Swapping
183+
``key=_canonical`` for ``key=str`` (or dropping the sort) reorders the list and
184+
breaks this digest.
185+
"""
186+
fp = PRFingerprint(
187+
head_sha="def456",
188+
human_events=(
189+
HumanEvent(id=1, body="z"),
190+
HumanEvent(id=2, body="a"),
191+
),
192+
scheme_version=6,
193+
)
194+
195+
assert compute_pr_hash(fp) == "a6178791d05cc91fe764b8cdfc5321ea871652aaaff608a02c5a20a71e8f20da"
196+
197+
157198
def test_hash_scheme_version_is_pinned():
158-
assert HASH_SCHEME_VERSION == 5
199+
assert HASH_SCHEME_VERSION == 6
159200

160201

161202
def test_pr_fingerprint_scheme_version_defaults_to_current_scheme():
@@ -197,3 +238,46 @@ def test_bot_logins_are_lowercase_and_bracket_free():
197238
for login in BOT_LOGINS:
198239
assert login == login.lower()
199240
assert not login.endswith("[bot]")
241+
242+
243+
@pytest.mark.parametrize(
244+
("body", "expected"),
245+
[
246+
("@pytorchbot merge", True),
247+
("@pytorchmergebot rebase", True),
248+
("@claude please review", True),
249+
("@greenlight status", True),
250+
("@PyTorchBot merge", True),
251+
("@PYTORCHMERGEBOT REBASE", True),
252+
("@Claude", True),
253+
("@GreenLight recheck", True),
254+
("please run @pytorchbot merge -f now", True),
255+
("lgtm, cc @claude", True),
256+
("plain human comment", False),
257+
("no handle at all", False),
258+
("reach me at foo@bar.com", False),
259+
("pytorchbot without the at-sign", False),
260+
("", False),
261+
(None, False),
262+
],
263+
)
264+
def test_is_bot_command_truth_table(body: str | None, expected: bool) -> None:
265+
assert is_bot_command(body) == expected
266+
267+
268+
def test_is_bot_command_matches_both_pytorch_handles_independently():
269+
# '@pytorchbot' is not a substring of '@pytorchmergebot', so each must be listed to be caught.
270+
assert "@pytorchbot" not in "@pytorchmergebot"
271+
assert is_bot_command("@pytorchbot merge") is True
272+
assert is_bot_command("@pytorchmergebot rebase") is True
273+
274+
275+
def test_is_bot_command_handles_lone_surrogate_without_raising():
276+
assert is_bot_command("\ud800 @pytorchbot merge") is True
277+
assert is_bot_command("\ud800 no handle here") is False
278+
279+
280+
def test_bot_command_mentions_are_lowercase_and_at_prefixed():
281+
for mention in BOT_COMMAND_MENTIONS:
282+
assert mention == mention.lower()
283+
assert mention.startswith("@")

0 commit comments

Comments
 (0)