Skip to content

Commit 2dcfe1a

Browse files
committed
Match review-context rules on reviewer groups
Implements the previously-stubbed `review` predicate in the code-review external-content engine so a review-context.toml rule can target a reviewer group (the capability upstream left unimplemented): - review_context.py: implement _review_matches for `review.reviewer`, `review.blocking_reviewer`, and `review.author`. A predicate matches when every facet it specifies matches; reviewer groups are matched by Phabricator project slug (resolved via resolve_project_phid) against the revision's requested reviewers. Thread a ReviewInfo (author + reviewer/blocking group PHIDs) through rule_matches/predicate_matches/collect_actions, built from the patch in load_external_content_for_diff. Fails closed when no review info is available (e.g. a GitHub diff). - phabricator.py: expose blocking_reviewer_project_phids and author_username, refactoring the reviewers fetch into a shared _reviewers cached_property. - docs/code-review-skills.md: document the now-working `review` predicate. This replaces the earlier bespoke per-group skill mechanism: teams attach area guidance by reviewer group through the existing review-context.toml, with no second skill system. Depends on the reviewer-group plumbing from the PhabricatorPatch membership change.
1 parent 259cb76 commit 2dcfe1a

4 files changed

Lines changed: 209 additions & 21 deletions

File tree

bugbug/tools/code_review/review_context.py

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,42 @@ def parse_diff_files(diff: str) -> set[str]:
150150
return files
151151

152152

153+
@dataclass(frozen=True)
154+
class ReviewInfo:
155+
"""Reviewer-related facts about a revision, used by the ``review`` predicate."""
156+
157+
author: str | None = None
158+
# Reviewer-group (Phabricator project) PHIDs requested on the revision.
159+
reviewer_groups: frozenset[str] = frozenset()
160+
# The subset of reviewer groups that are *blocking* reviewers.
161+
blocking_reviewer_groups: frozenset[str] = frozenset()
162+
163+
164+
async def build_review_info(patch) -> ReviewInfo:
165+
"""Build a ReviewInfo from a patch, tolerating patches that lack the fields."""
166+
try:
167+
reviewer_groups = frozenset(getattr(patch, "reviewer_project_phids", []) or [])
168+
blocking = frozenset(
169+
getattr(patch, "blocking_reviewer_project_phids", []) or []
170+
)
171+
author = getattr(patch, "author_username", None)
172+
except Exception:
173+
logger.exception("Could not build review info for the patch")
174+
return ReviewInfo()
175+
return ReviewInfo(
176+
author=author,
177+
reviewer_groups=reviewer_groups,
178+
blocking_reviewer_groups=blocking,
179+
)
180+
181+
153182
def rule_matches(
154-
rule: Rule, changed_files: set[str], bug_component: str | None = None
183+
rule: Rule,
184+
changed_files: set[str],
185+
bug_component: str | None = None,
186+
review: ReviewInfo | None = None,
155187
) -> bool:
156-
return predicate_matches(rule.when, changed_files, bug_component)
188+
return predicate_matches(rule.when, changed_files, bug_component, review)
157189

158190

159191
def _normalise_path(path: str) -> str:
@@ -188,8 +220,51 @@ def _bugzilla_matches(
188220
return False
189221

190222

191-
def _review_matches(predicate: ReviewPredicate) -> bool:
192-
return False
223+
def _resolve_group_slugs(slugs: list[str]) -> set[str]:
224+
"""Resolve reviewer-group slugs to project PHIDs (cached, best-effort)."""
225+
from bugbug.tools.core.platforms import phabricator as phab
226+
227+
resolved = set()
228+
for slug in slugs:
229+
phid = phab.resolve_project_phid(slug)
230+
if phid:
231+
resolved.add(phid)
232+
return resolved
233+
234+
235+
def _review_matches(
236+
predicate: ReviewPredicate, review: ReviewInfo | None = None
237+
) -> bool:
238+
"""Match a revision's reviewers/author against a ReviewPredicate.
239+
240+
Each specified facet (``author``, ``reviewer``, ``blocking_reviewer``) must
241+
match — mirroring FilePredicate's include/exclude/ext conjunction. A
242+
predicate with no facets, or a revision with no review info, never matches.
243+
"""
244+
if review is None:
245+
return False
246+
247+
matched_any_facet = False
248+
249+
if predicate.author:
250+
if review.author not in predicate.author:
251+
return False
252+
matched_any_facet = True
253+
254+
if predicate.reviewer:
255+
if not (review.reviewer_groups & _resolve_group_slugs(predicate.reviewer)):
256+
return False
257+
matched_any_facet = True
258+
259+
if predicate.blocking_reviewer:
260+
if not (
261+
review.blocking_reviewer_groups
262+
& _resolve_group_slugs(predicate.blocking_reviewer)
263+
):
264+
return False
265+
matched_any_facet = True
266+
267+
return matched_any_facet
193268

194269

195270
def _patch_matches(predicate: PatchPredicate) -> bool:
@@ -200,20 +275,21 @@ def predicate_matches(
200275
predicate: Predicate,
201276
changed_files: set[str],
202277
bug_component: str | None = None,
278+
review: ReviewInfo | None = None,
203279
) -> bool:
204280
match predicate:
205281
case AllPredicate(children=children):
206282
return all(
207-
predicate_matches(child, changed_files, bug_component)
283+
predicate_matches(child, changed_files, bug_component, review)
208284
for child in children
209285
)
210286
case AnyPredicate(children=children):
211287
return any(
212-
predicate_matches(child, changed_files, bug_component)
288+
predicate_matches(child, changed_files, bug_component, review)
213289
for child in children
214290
)
215291
case NotPredicate(child=child):
216-
return not predicate_matches(child, changed_files, bug_component)
292+
return not predicate_matches(child, changed_files, bug_component, review)
217293
case AnyFilePredicate(predicate=file_predicate):
218294
return any(_file_matches(file_predicate, f) for f in changed_files)
219295
case AllFilesPredicate(predicate=file_predicate):
@@ -223,7 +299,7 @@ def predicate_matches(
223299
case BugzillaPredicate():
224300
return _bugzilla_matches(predicate, bug_component)
225301
case ReviewPredicate():
226-
return _review_matches(predicate)
302+
return _review_matches(predicate, review)
227303
case PatchPredicate():
228304
return _patch_matches(predicate)
229305
raise TypeError(f"Unknown predicate type: {type(predicate).__name__}")
@@ -356,6 +432,7 @@ def collect_actions(
356432
config: ReviewContextConfig,
357433
bug_component: str | None = None,
358434
extra_context_toml: str | None = None,
435+
review: ReviewInfo | None = None,
359436
) -> list[MatchedAction]:
360437
"""Return deduplicated actions matched from config against the diff.
361438
@@ -386,7 +463,7 @@ def collect_actions(
386463
enumerate(config.rules), key=lambda item: (-item[1].priority, item[0])
387464
)
388465
for _, rule in ordered_rules:
389-
if not rule_matches(rule, changed_files, bug_component):
466+
if not rule_matches(rule, changed_files, bug_component, review):
390467
continue
391468
rule_name = rule.name
392469
new_actions = []
@@ -530,11 +607,15 @@ async def load_external_content_for_diff(
530607
return []
531608

532609
bug_component: str | None = None
610+
review: ReviewInfo | None = None
533611
if patch is not None:
534612
bug_component = await patch.bug_component()
613+
review = await build_review_info(patch)
535614

536615
try:
537-
actions = collect_actions(diff, config, bug_component, extra_context_toml)
616+
actions = collect_actions(
617+
diff, config, bug_component, extra_context_toml, review
618+
)
538619
except (tomllib.TOMLDecodeError, ReviewContextValidationError):
539620
logger.exception("Could not parse extra review context")
540621
return []

bugbug/tools/core/platforms/phabricator.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,12 @@ def author_phid(self) -> str:
561561
return self._revision_metadata["fields"]["authorPHID"]
562562

563563
@cached_property
564-
def reviewer_phids(self) -> list[str]:
565-
"""PHIDs of the revision's reviewers (a mix of users and projects).
564+
def _reviewers(self) -> list[dict]:
565+
"""Raw reviewer entries from the revision's reviewers attachment.
566566
567567
The reviewers attachment is not part of the default revision metadata,
568568
so we fetch it with a dedicated ``differential.revision.search`` call.
569+
Each entry carries ``reviewerPHID`` and ``isBlocking``.
569570
"""
570571
phabricator = get_phabricator_client()
571572
response = phabricator.request(
@@ -576,12 +577,12 @@ def reviewer_phids(self) -> list[str]:
576577
data = response.get("data") or []
577578
if not data:
578579
return []
579-
reviewers = data[0].get("attachments", {}).get("reviewers", {})
580-
return [
581-
r["reviewerPHID"]
582-
for r in reviewers.get("reviewers", [])
583-
if r.get("reviewerPHID")
584-
]
580+
return data[0].get("attachments", {}).get("reviewers", {}).get("reviewers", [])
581+
582+
@property
583+
def reviewer_phids(self) -> list[str]:
584+
"""PHIDs of the revision's reviewers (a mix of users and projects)."""
585+
return [r["reviewerPHID"] for r in self._reviewers if r.get("reviewerPHID")]
585586

586587
@property
587588
def reviewer_project_phids(self) -> list[str]:
@@ -633,6 +634,21 @@ def _add(phid: str) -> None:
633634

634635
return phids
635636

637+
@property
638+
def blocking_reviewer_project_phids(self) -> list[str]:
639+
"""PHIDs of the revision's *blocking* reviewer groups."""
640+
return [
641+
r["reviewerPHID"]
642+
for r in self._reviewers
643+
if r.get("isBlocking")
644+
and r.get("reviewerPHID", "").startswith("PHID-PROJ-")
645+
]
646+
647+
@property
648+
def author_username(self) -> str | None:
649+
"""The revision author's Phabricator username (email), if resolvable."""
650+
return self._users_info.get(self.author_phid, {}).get("email")
651+
636652
@property
637653
def diff_author_phid(self) -> str:
638654
return self._diff_metadata["authorPHID"]

docs/code-review-skills.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,16 @@ The patch's associated bug ID is read from `patch.bug_id` (available on
8383
component string compared against the rule is `"Product::Component"`.
8484

8585
The schema accepts `bugzilla`, `review`, and `patch` predicates. Runtime
86-
matching currently implements `bugzilla.component`; `bugzilla.product`,
87-
`bugzilla.keywords`, `bugzilla.severity`, `review.*`, and `patch.*` are parsed
88-
and validated but fail closed until trusted metadata is wired into the matcher.
86+
matching implements `bugzilla.component` and the `review` predicate
87+
(`review.reviewer`, `review.blocking_reviewer`, `review.author`); a `review`
88+
predicate matches when every facet it specifies matches, so a rule can target a
89+
reviewer group (e.g. `review = { reviewer = ["ip-protection-reviewers"] }`).
90+
Reviewer groups are matched by Phabricator project slug, resolved to the
91+
revision's requested reviewers. `bugzilla.product`, `bugzilla.keywords`,
92+
`bugzilla.severity`, and `patch.*` are parsed and validated but fail closed
93+
until the corresponding metadata is wired into the matcher. When the platform
94+
provides no reviewer information (e.g. a plain GitHub diff), `review` predicates
95+
fail closed.
8996

9097
### `fetch_revision`
9198

tests/test_code_review.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
search_text,
2626
)
2727
from bugbug.tools.code_review.review_context import (
28+
ReviewInfo,
2829
_merge_rules,
30+
build_review_info,
2931
external_content_manifest,
3032
format_external_content,
3133
github_repo_allowed,
@@ -39,6 +41,7 @@
3941
BugzillaPredicate,
4042
FilePredicate,
4143
ReviewContextValidationError,
44+
ReviewPredicate,
4245
Rule,
4346
)
4447
from bugbug.tools.code_review.review_context_schema import (
@@ -788,6 +791,87 @@ def test_rule_bugzilla_component_matches():
788791
)
789792

790793

794+
def _patch_resolver(monkeypatch, slug_to_phid):
795+
monkeypatch.setattr(
796+
"bugbug.tools.core.platforms.phabricator.resolve_project_phid",
797+
lambda slug: slug_to_phid.get(slug),
798+
)
799+
800+
801+
def test_rule_review_reviewer_group_matches(monkeypatch):
802+
_patch_resolver(monkeypatch, {"ip-protection-reviewers": "PHID-PROJ-ip"})
803+
rule = Rule(
804+
name="ip",
805+
when=ReviewPredicate(reviewer=["ip-protection-reviewers"]),
806+
load=[],
807+
)
808+
review = ReviewInfo(reviewer_groups=frozenset({"PHID-PROJ-ip"}))
809+
assert rule_matches(rule, {"any/file.cpp"}, review=review)
810+
811+
812+
def test_rule_review_reviewer_group_no_match(monkeypatch):
813+
_patch_resolver(monkeypatch, {"ip-protection-reviewers": "PHID-PROJ-ip"})
814+
rule = Rule(
815+
name="ip",
816+
when=ReviewPredicate(reviewer=["ip-protection-reviewers"]),
817+
load=[],
818+
)
819+
review = ReviewInfo(reviewer_groups=frozenset({"PHID-PROJ-other"}))
820+
assert not rule_matches(rule, {"any/file.cpp"}, review=review)
821+
822+
823+
def test_rule_review_fails_closed_without_review_info(monkeypatch):
824+
_patch_resolver(monkeypatch, {"ip-protection-reviewers": "PHID-PROJ-ip"})
825+
rule = Rule(
826+
name="ip",
827+
when=ReviewPredicate(reviewer=["ip-protection-reviewers"]),
828+
load=[],
829+
)
830+
# No review info (e.g. a GitHub diff) → never matches.
831+
assert not rule_matches(rule, {"any/file.cpp"})
832+
833+
834+
def test_rule_review_blocking_reviewer(monkeypatch):
835+
_patch_resolver(monkeypatch, {"security-reviewers": "PHID-PROJ-sec"})
836+
rule = Rule(
837+
name="sec",
838+
when=ReviewPredicate(blocking_reviewer=["security-reviewers"]),
839+
load=[],
840+
)
841+
# Present as a reviewer but not blocking → no match.
842+
non_blocking = ReviewInfo(reviewer_groups=frozenset({"PHID-PROJ-sec"}))
843+
assert not rule_matches(rule, {"f.cpp"}, review=non_blocking)
844+
# Blocking → match.
845+
blocking = ReviewInfo(
846+
reviewer_groups=frozenset({"PHID-PROJ-sec"}),
847+
blocking_reviewer_groups=frozenset({"PHID-PROJ-sec"}),
848+
)
849+
assert rule_matches(rule, {"f.cpp"}, review=blocking)
850+
851+
852+
def test_rule_review_author():
853+
rule = Rule(name="a", when=ReviewPredicate(author=["alice@mozilla.com"]), load=[])
854+
assert rule_matches(rule, {"f.cpp"}, review=ReviewInfo(author="alice@mozilla.com"))
855+
assert not rule_matches(
856+
rule, {"f.cpp"}, review=ReviewInfo(author="bob@mozilla.com")
857+
)
858+
859+
860+
@pytest.mark.asyncio
861+
async def test_build_review_info_from_patch():
862+
from types import SimpleNamespace
863+
864+
patch = SimpleNamespace(
865+
reviewer_project_phids=["PHID-PROJ-ip", "PHID-PROJ-newtab"],
866+
blocking_reviewer_project_phids=["PHID-PROJ-ip"],
867+
author_username="alice@mozilla.com",
868+
)
869+
info = await build_review_info(patch)
870+
assert info.author == "alice@mozilla.com"
871+
assert info.reviewer_groups == frozenset({"PHID-PROJ-ip", "PHID-PROJ-newtab"})
872+
assert info.blocking_reviewer_groups == frozenset({"PHID-PROJ-ip"})
873+
874+
791875
@pytest.mark.asyncio
792876
async def test_patch_bug_component():
793877
from bugbug.tools.core.platforms.phabricator import PhabricatorPatch

0 commit comments

Comments
 (0)