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
5 changes: 2 additions & 3 deletions core/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,10 @@
OZ_AGENT_MENTION = "@oz-agent"
OZ_REVIEW_COMMAND = "/oz-review"
OZ_VERIFY_COMMAND = "/oz-verify"
MAX_EXPLICIT_REVIEW_INVOCATIONS_PER_PR = 3
OZ_REVIEW_COMMAND_PATTERN = re.compile(
r"(?:^|\s)(?:/oz-review|@oz-agent\s+/review)(?![-\w])", re.IGNORECASE
)

MAX_DAILY_REVIEW_INVOCATIONS = 5

def has_oz_review_command(body: str) -> bool:
"""Return whether *body* carries an explicit Oz review invocation."""
Expand Down Expand Up @@ -481,14 +480,14 @@ def route_event(event: str, payload: dict[str, Any]) -> RouteDecision:

__all__ = [
"AUTO_IMPLEMENT_LABEL",
"MAX_DAILY_REVIEW_INVOCATIONS",
"NEEDS_INFO_LABEL",
"OZ_AGENT_LOGIN",
"OZ_AGENT_MENTION",
"OZ_REVIEW_COMMAND",
"OZ_REVIEW_COMMAND_PATTERN",
"OZ_VERIFY_COMMAND",
"OZ_REVIEW_LABEL",
"MAX_EXPLICIT_REVIEW_INVOCATIONS_PER_PR",
"PLAN_APPROVED_LABEL",
"READY_TO_IMPLEMENT_LABEL",
"READY_TO_SPEC_LABEL",
Expand Down
107 changes: 93 additions & 14 deletions core/workflows/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping

Expand All @@ -10,8 +11,10 @@
make_run_adapter,
)

from oz.helpers import ENFORCEMENT_COMMENT_RUN_ID

from core.routing import (
MAX_EXPLICIT_REVIEW_INVOCATIONS_PER_PR,
MAX_DAILY_REVIEW_INVOCATIONS,
WORKFLOW_CREATE_IMPLEMENTATION_FROM_ISSUE,
WORKFLOW_CREATE_SPEC_FROM_ISSUE,
WORKFLOW_PLAN_APPROVED,
Expand Down Expand Up @@ -155,17 +158,67 @@ def _is_automation_user(user: Any) -> bool:
return bool(login) and login.endswith("[bot]")


def _explicit_review_invocation_count(pr: Any) -> int:
"""Count non-bot explicit review invocations already persisted on *pr*."""
def _explicit_review_invocations_in_window(
pr: Any,
) -> tuple[int, datetime | None]:
"""Count Oz review progress comments on *pr* within the rolling 24-hour window.

Each dispatched /oz-review run produces one bot-authored issue comment
carrying the ``review-pull-request`` workflow metadata. Progress comments
are the unit of counting; enforcement-only comments (identified by the
``pr-issue-state-enforcement`` run_id) are excluded because no review run
was dispatched for them.

Returns ``(count, oldest)`` where *oldest* is the ``created_at`` of the
earliest in-window progress comment, used to compute the retry message.
The current invocation has not yet been dispatched, so *count* reflects
only prior completed runs. Callers must therefore use ``>= MAX`` to block
and ``== MAX - 1`` to emit the advisory.

Comments without a ``created_at`` are counted conservatively but do not
contribute to *oldest*, so the retry duration may be omitted when
timestamps are unavailable.
"""
window_start = datetime.now(timezone.utc) - timedelta(hours=24)
review_workflow_marker = f'"workflow":"{WORKFLOW_REVIEW_PR}"'
enforcement_marker = f'"run_id":"{ENFORCEMENT_COMMENT_RUN_ID}"'
count = 0
for comment in list(pr.get_issue_comments()) + list(pr.get_review_comments()):
oldest: datetime | None = None
for comment in list(pr.get_issue_comments()):
if not _is_automation_user(_get_field(comment, "user")):
continue
body = str(_get_field(comment, "body", "") or "")
if not has_oz_review_command(body):
if review_workflow_marker not in body:
continue
if _is_automation_user(_get_field(comment, "user")):
if enforcement_marker in body:
continue
count += 1
return count
created_at = _get_field(comment, "created_at")
if created_at is None:
# No timestamp: count conservatively without updating oldest.
count += 1
continue
# Normalise to UTC-aware for comparison.
if getattr(created_at, "tzinfo", None) is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if created_at >= window_start:
count += 1
if oldest is None or created_at < oldest:
oldest = created_at
return count, oldest


def _format_retry_duration(oldest_invocation: datetime) -> str:
"""Return a human-readable duration until the oldest invocation ages out."""
reset_at = oldest_invocation + timedelta(hours=24)
remaining = reset_at - datetime.now(timezone.utc)
total_seconds = max(0, int(remaining.total_seconds()))
hours, remainder = divmod(total_seconds, 3600)
minutes = remainder // 60
if hours > 0:
return f"~{hours}h {minutes}m"
if minutes > 0:
return f"~{minutes}m"
return "shortly"


def _is_explicit_review_invocation(payload: Mapping[str, Any]) -> bool:
Expand Down Expand Up @@ -209,9 +262,7 @@ def build_dispatch(self, payload: Mapping[str, Any], *, github_client: Any, work
pr = repo_handle.get_pull(pr_number)
if _is_explicit_review_invocation(payload):
try:
invocation_count = _explicit_review_invocation_count(
pr
)
invocation_count, oldest_invocation = _explicit_review_invocations_in_window(pr)
except Exception:
# Fail open: if the throttle lookup itself fails for any
# reason (transient API error, permissions issue, etc.) we
Expand All @@ -223,15 +274,43 @@ def build_dispatch(self, payload: Mapping[str, Any], *, github_client: Any, work
pr_number,
)
invocation_count = 0
if invocation_count > MAX_EXPLICIT_REVIEW_INVOCATIONS_PER_PR:
oldest_invocation = None
retry_suffix = (
f" Your next slot opens in {_format_retry_duration(oldest_invocation)}."
if oldest_invocation is not None
else ""
)
if invocation_count >= MAX_DAILY_REVIEW_INVOCATIONS:
logger.info(
"Skipping /oz-review for %s PR #%s: invocation count %s exceeds limit %s",
"Skipping /oz-review for %s PR #%s: %s prior runs in window meets/exceeds daily limit %s",
full_name,
pr_number,
invocation_count,
MAX_EXPLICIT_REVIEW_INVOCATIONS_PER_PR,
MAX_DAILY_REVIEW_INVOCATIONS,
)
try:
repo_handle.get_issue(pr_number).create_comment(
f"You've used all {MAX_DAILY_REVIEW_INVOCATIONS} `/oz-review` slots "
f"for the current 24-hour window.{retry_suffix}"
)
except Exception:
logger.exception(
"Failed to post review-limit comment for %s PR #%s",
full_name,
pr_number,
)
return None
if invocation_count == MAX_DAILY_REVIEW_INVOCATIONS - 1:
try:
repo_handle.get_issue(pr_number).create_comment(
f"This is your last `/oz-review` for the current 24-hour window.{retry_suffix}"
)
except Exception:
logger.exception(
"Failed to post review advisory comment for %s PR #%s",
full_name,
pr_number,
)
if not enforce_pr_issue_state_for_review(
repo_handle,
owner=owner,
Expand Down
4 changes: 2 additions & 2 deletions core/workflows/review_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from oz.helpers import (
build_comment_body,
comment_metadata,
ENFORCEMENT_COMMENT_RUN_ID,
get_field,
get_label_name,
is_automation_user,
Expand Down Expand Up @@ -49,7 +50,6 @@
_REVIEW_OUTPUT_FILENAME = "review.json"
_READY_TO_SPEC_LABEL = "ready-to-spec"
_READY_TO_IMPLEMENT_LABEL = "ready-to-implement"
_ENFORCEMENT_COMMENT_RUN_ID = "pr-issue-state-enforcement"
_READY_LABELS = frozenset({_READY_TO_SPEC_LABEL, _READY_TO_IMPLEMENT_LABEL})

# Allowed values for the agent-supplied ``verdict`` field on ``review.json``.
Expand Down Expand Up @@ -287,7 +287,7 @@ def _upsert_pr_issue_state_enforcement_comment(
metadata = comment_metadata(
WORKFLOW_NAME,
pr_number,
run_id=_ENFORCEMENT_COMMENT_RUN_ID,
run_id=ENFORCEMENT_COMMENT_RUN_ID,
)
comment_body = build_comment_body(body, metadata)
issue = github.get_issue(pr_number)
Expand Down
3 changes: 3 additions & 0 deletions oz/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ def _github_actions_error(message: str) -> None:
# Author associations treated as trusted without org-membership probing.
ORG_MEMBER_ASSOCIATIONS: set[str] = {"COLLABORATOR", "MEMBER", "OWNER"}

ENFORCEMENT_COMMENT_RUN_ID = "pr-issue-state-enforcement"


_CLOSING_ISSUES_QUERY = (
"query($owner: String!, $name: String!, $number: Int!, $after: String) {"
" repository(owner: $owner, name: $name) {"
Expand Down
Loading
Loading