Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions core/workflows/review_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
get_label_name,
get_login,
is_automation_user,
is_oz_bot_user,
is_spec_only_pr,
ORG_MEMBER_ASSOCIATIONS,
POWERED_BY_SUFFIX,
Expand Down Expand Up @@ -780,11 +781,12 @@ def _dismiss_stale_oz_changes_requested_reviews(
def _format_non_member_review_section(
*,
pr_author_login: str,
is_non_member: bool,
ownership_areas_block: str = "",
ownership_areas_loaded: bool = False,
stakeholders_block: str = "",
) -> str:
"""Render the non-member reviewer-selection section of the agent prompt.
"""Render the reviewer-selection section of the agent prompt.

When ``ownership_areas_loaded`` is true, instruct the agent to return
a single ``recommended_area`` from the parsed ``warpdotdev/warp-ownership``
Expand All @@ -793,13 +795,23 @@ def _format_non_member_review_section(
source. When ownership areas are unavailable (fetch failure, empty
repo), fall back to the legacy STAKEHOLDERS prompt block where the
agent returns ``recommended_reviewers`` directly.

``is_non_member`` controls the REJECT outcome wording so it matches
:func:`apply_review_result`: non-member PRs get a blocking
``REQUEST_CHANGES`` review on reject, while the Oz bot's own PRs only
get a ``COMMENT`` so they never appear merge-blocked.
"""
reject_outcome_line = (
'- If your `verdict` is `"REJECT"`, the workflow will post a GitHub `REQUEST_CHANGES` review and will not request a human reviewer.'
if is_non_member
else '- If your `verdict` is `"REJECT"`, the workflow will post your review as a GitHub `COMMENT` and will not request a human reviewer.'
)
if ownership_areas_loaded:
return dedent(
f"""
Non-Member Reviewer Selection:
- The PR author (@{pr_author_login or 'unknown'}) is not a repository member or collaborator, so the workflow should request exactly one human reviewer when your `verdict` is `"APPROVE"`.
- If your `verdict` is `"REJECT"`, the workflow will post a GitHub `REQUEST_CHANGES` review and will not request a human reviewer.
Reviewer Selection:
- This pull request needs a human reviewer (its author @{pr_author_login or 'unknown'} is an automation account or an external contributor), so the workflow should request exactly one human reviewer when your `verdict` is `"APPROVE"`.
{reject_outcome_line}
- Return a `recommended_area` field alongside `verdict`, `body`, and `comments`. Example: {{"recommended_area": "MCP (Model Context Protocol)"}}.
- `recommended_area` must be EXACTLY ONE area name copied verbatim from the "Ownership Areas" list below. Match the PR title, body, and changed files to the area whose `matches:` description best describes the change.
- Do NOT invent area names. Do NOT return multiple names, a list, or owner handles. The workflow itself looks up the owners for the chosen area and randomly selects one.
Expand All @@ -817,9 +829,9 @@ def _format_non_member_review_section(
).strip()
return dedent(
f"""
Non-Member Reviewer Selection:
- The PR author (@{pr_author_login or 'unknown'}) is not a repository member or collaborator, so the workflow should request exactly one human reviewer when your `verdict` is `"APPROVE"`.
- If your `verdict` is `"REJECT"`, the workflow will post a GitHub `REQUEST_CHANGES` review and will not request a human reviewer.
Reviewer Selection:
- This pull request needs a human reviewer (its author @{pr_author_login or 'unknown'} is an automation account or an external contributor), so the workflow should request exactly one human reviewer when your `verdict` is `"APPROVE"`.
{reject_outcome_line}
- Return a `recommended_reviewers` field alongside `verdict`, `body`, and `comments`.
- `recommended_reviewers` must be a JSON list with exactly one bare GitHub login string, for example: {{"recommended_reviewers": ["octocat"]}}.
- Choose that single reviewer from `.github/STAKEHOLDERS` by matching the changed file paths against the STAKEHOLDERS rules. Later rules override earlier rules, and more specific matching rules should be preferred over catch-all rules.
Expand Down Expand Up @@ -951,6 +963,7 @@ class ReviewContext(TypedDict):
diff_line_map: dict[str, dict[str, list[int]]]
diff_content_map: dict[str, dict[str, dict[str, str]]]
is_non_member: bool
requires_human_reviewer: bool
spec_only: bool
pr_author_login: str
stakeholder_logins: list[str]
Expand Down Expand Up @@ -1069,6 +1082,12 @@ def gather_review_context(
else ""
)
is_non_member = _is_non_member_pr(pr) and not spec_only
# The Oz bot's own PRs and external-contributor PRs require a human
# reviewer. Other automation accounts are excluded
# so Oz does not override their own reviewer-assignment conventions.
requires_human_reviewer = (
is_oz_bot_user(getattr(pr, "user", None)) or _is_non_member_pr(pr)
)
pr_author_login = str(
getattr(getattr(pr, "user", None), "login", "") or ""
)
Expand All @@ -1077,7 +1096,7 @@ def gather_review_context(
pr_assignee_reviewers: list[str] = []
ownership_areas_serialized: list[dict[str, Any]] = []
ownership_areas_loaded = False
if is_non_member:
if requires_human_reviewer:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Bot-authored and spec-only external PRs now enter this prompt section, but the section still says REJECT will post REQUEST_CHANGES; apply_review_result only does that for is_non_member, so these runs get misleading review instructions. Parameterize the reject-event wording or use a separate prompt for reviewer-only escalation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to make this consistent and the desired behavior is that Oz-authored PRs never get the request changes action so they don't appear blocked.

pr_assignee_reviewers = _reviewer_from_pr_assignee(
pr,
pr_author_login=pr_author_login,
Expand Down Expand Up @@ -1116,13 +1135,15 @@ def gather_review_context(
)
non_member_review_section = _format_non_member_review_section(
pr_author_login=pr_author_login,
is_non_member=is_non_member,
ownership_areas_block=ownership_areas_block,
ownership_areas_loaded=True,
stakeholders_block=stakeholders_block,
)
else:
non_member_review_section = _format_non_member_review_section(
pr_author_login=pr_author_login,
is_non_member=is_non_member,
stakeholders_block=stakeholders_block,
ownership_areas_loaded=False,
)
Expand Down Expand Up @@ -1170,6 +1191,7 @@ def gather_review_context(
diff_line_map=_serialize_diff_line_map(diff_line_map),
diff_content_map=_serialize_diff_content_map(diff_content_map),
is_non_member=bool(is_non_member),
requires_human_reviewer=bool(requires_human_reviewer),
spec_only=bool(spec_only),
pr_author_login=pr_author_login,
stakeholder_logins=sorted(_stakeholder_logins(stakeholders_entries)),
Expand Down Expand Up @@ -1323,6 +1345,12 @@ def apply_review_result(
pr_number = int(context["pr_number"])
requester = str(context.get("requester") or "")
is_non_member = bool(context.get("is_non_member"))
# Human reviewer escalation for the Oz bot's own PRs and external-contributor PRs is
# controlled by the requires_human_reviewer flag. For backward compatibility with older
# serialized contexts, is_non_member is used as a fallback if the flag is missing.
requires_human_reviewer = bool(
context.get("requires_human_reviewer", is_non_member)
)
pr_author_login = str(context.get("pr_author_login") or "")
raw_stakeholder_entries = context.get("stakeholder_entries") or []
stakeholder_entries = [
Expand Down Expand Up @@ -1365,7 +1393,7 @@ def apply_review_result(
if is_non_member and verdict == _VERDICT_REJECT
else "COMMENT"
)
if is_non_member and verdict == _VERDICT_APPROVE:
if requires_human_reviewer and verdict == _VERDICT_APPROVE:
recommended_reviewers = _reviewer_from_pr_assignee(
pr,
pr_author_login=pr_author_login,
Expand Down
25 changes: 23 additions & 2 deletions oz/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ def is_automation_user(user: Any) -> bool:
)


def oz_bot_login() -> str:
"""Return the configured Oz GitHub App bot login, or empty when unset.

Derived from ``GH_APP_SLUG`` as ``<slug>[bot]`` to match the login
GitHub stamps on App-authored actions.
"""
app_slug = optional_env("GH_APP_SLUG")
return f"{app_slug}[bot]" if app_slug else ""


def is_oz_bot_user(user: Any) -> bool:
"""Return whether *user* is this repo's Oz GitHub App bot.

Narrower than :func:`is_automation_user`: only the configured Oz bot
matches, so other automation accounts (e.g. dependabot) are excluded.
"""
bot_login = oz_bot_login()
if not bot_login:
return False
return get_login(user).strip().lower() == bot_login.lower()



def get_timestamp_text(value: Any) -> str:
if isinstance(value, datetime):
Expand Down Expand Up @@ -610,8 +632,7 @@ def __init__(
run_id=self.run_id,
)
self._workflow_prefix = _workflow_metadata_prefix(workflow, issue_number)
app_slug = optional_env("GH_APP_SLUG")
self._bot_login = f"{app_slug}[bot]" if app_slug else ""
self._bot_login = oz_bot_login()
self.comment_id: int | None = (
int(comment_id) if comment_id is not None and int(comment_id) > 0 else None
)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ def test_prompt_includes_ownership_area_selection_instructions(self) -> None:
context = self._base_context()
context["non_member_review_section"] = _format_non_member_review_section(
pr_author_login="contributor",
is_non_member=True,
ownership_areas_block=(
"- MCP (Model Context Protocol)\n"
" owners: @peicodes, @vkodithala\n"
Expand All @@ -333,6 +334,7 @@ def test_prompt_includes_stakeholders_fallback_instructions(self) -> None:
context = self._base_context()
context["non_member_review_section"] = _format_non_member_review_section(
pr_author_login="contributor",
is_non_member=True,
stakeholders_block="- /docs/ → @docs-owner",
ownership_areas_loaded=False,
)
Expand Down
Loading
Loading