Skip to content
Merged
3 changes: 3 additions & 0 deletions .github/workflows/reviewer-bot-pr-review-comment-observer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_AUTHOR_ID: ${{ github.event.comment.user.id }}
COMMENT_USER_TYPE: ${{ github.event.comment.user.type }}
COMMENT_COMMIT_ID: ${{ github.event.comment.commit_id }}
COMMENT_SENDER_TYPE: ${{ github.event.sender.type }}
COMMENT_INSTALLATION_ID: ${{ github.event.installation.id }}
COMMENT_PERFORMED_VIA_GITHUB_APP: ${{ github.event.comment.performed_via_github_app.id > 0 && 'true' || 'false' }}
Expand All @@ -42,6 +43,7 @@ jobs:
'source_workflow_file': '.github/workflows/reviewer-bot-pr-review-comment-observer.yml',
'source_run_id': int(os.environ['GITHUB_RUN_ID']),
'source_run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']),
'source_artifact_name': f"reviewer-bot-review-comment-context-{os.environ['GITHUB_RUN_ID']}-attempt-{os.environ['GITHUB_RUN_ATTEMPT']}",
'source_event_name': 'pull_request_review_comment',
'source_event_action': 'created',
'source_event_key': f"pull_request_review_comment:{os.environ['COMMENT_ID']}",
Expand All @@ -52,6 +54,7 @@ jobs:
'comment_author': os.environ['COMMENT_AUTHOR'],
'comment_author_id': int(os.environ['COMMENT_AUTHOR_ID']),
'comment_user_type': os.environ['COMMENT_USER_TYPE'],
'source_commit_id': os.environ.get('COMMENT_COMMIT_ID', '').strip() or None,
'comment_sender_type': os.environ['COMMENT_SENDER_TYPE'],
'comment_installation_id': os.environ.get('COMMENT_INSTALLATION_ID', '').strip() or None,
'comment_performed_via_github_app': os.environ['COMMENT_PERFORMED_VIA_GITHUB_APP'] == 'true',
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/reviewer-bot-sweeper-repair.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ jobs:
WORKFLOW_RUN_ID: ${{ github.run_id }}
WORKFLOW_NAME: ${{ github.workflow }}
WORKFLOW_JOB_NAME: ${{ github.job }}
REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }}
REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }}
run: uv run --project "$BOT_SRC_ROOT" reviewer-bot
- name: Workflow summary
run: |
Expand Down
116 changes: 116 additions & 0 deletions scripts/reviewer_bot_core/reviewer_response_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@

_UNSET = object()
_ASSIGNMENT_GUIDANCE_AUTHORS = {"github-actions", "github-actions[bot]", "guidelines-bot"}
_FAIL_CLOSED_REVIEWER_ACTIVITY_GAP_REASONS = frozenset(
{
"observer_failed",
"observer_cancelled",
"observer_run_missing",
"observer_state_unknown",
"artifact_missing",
"artifact_invalid",
"artifact_expired",
"reconcile_failed_closed",
}
)
_REVIEWER_ACTIVITY_GAP_KINDS = frozenset(
{
"issue_comment:created",
"pull_request_review:submitted",
"pull_request_review_comment:created",
}
)


def _record_timestamp(record: dict | None, *, parse_timestamp) -> datetime | None:
Expand Down Expand Up @@ -139,6 +158,102 @@ def _build_current_scope_key(
)


def _gap_source_kind(gap: dict) -> str | None:
source_event_kind = gap.get("source_event_kind")
if isinstance(source_event_kind, str) and source_event_kind.strip():
return source_event_kind
source_event_key = gap.get("source_event_key")
if not isinstance(source_event_key, str):
return None
if source_event_key.startswith("issue_comment:"):
return "issue_comment:created"
if source_event_key.startswith("pull_request_review_comment:"):
return "pull_request_review_comment:created"
if source_event_key.startswith("pull_request_review:"):
return "pull_request_review:submitted"
return None


def _gap_event_timestamp(gap: dict):
return live_review_support.parse_github_timestamp(gap.get("source_event_created_at"))


def _visible_review_commit_id(gap: dict) -> str | None:
diagnostic = gap.get("visible_review_diagnostic")
payload = diagnostic.get("payload") if isinstance(diagnostic, dict) else None
commit_id = payload.get("commit_id") if isinstance(payload, dict) else None
if isinstance(commit_id, str) and commit_id.strip():
return commit_id
source_commit_id = gap.get("source_commit_id")
if isinstance(source_commit_id, str) and source_commit_id.strip():
return source_commit_id
comment = gap.get("comment")
comment_commit_id = None
if isinstance(comment, dict):
comment_commit_id = comment.get("commit_id") or comment.get("original_commit_id")
return comment_commit_id if isinstance(comment_commit_id, str) and comment_commit_id.strip() else None


def _visible_activity_author(gap: dict) -> str | None:
diagnostic = gap.get("visible_review_diagnostic")
payload = diagnostic.get("payload") if isinstance(diagnostic, dict) else None
author = payload.get("author") if isinstance(payload, dict) else None
if isinstance(author, str) and author.strip():
return author
source_actor = gap.get("source_actor_login")
if isinstance(source_actor, str) and source_actor.strip():
return source_actor
actor = gap.get("actor")
if isinstance(actor, str) and actor.strip():
return actor
comment = gap.get("comment")
user = comment.get("user") if isinstance(comment, dict) else None
login = user.get("login") if isinstance(user, dict) else None
return login if isinstance(login, str) and login.strip() else None


def has_fail_closed_reviewer_activity_for_current_scope(
review_data: dict,
fail_closed_gaps,
*,
anchor_timestamp: str | None = None,
current_head_sha: str | None = None,
) -> bool:
current_reviewer = review_data.get("current_reviewer")
if not isinstance(current_reviewer, str) or not current_reviewer.strip():
return False
floor = live_review_support.parse_github_timestamp(anchor_timestamp)
if floor is None:
_, cycle_boundary = _initial_cycle_boundary(review_data)
floor = live_review_support.parse_github_timestamp(cycle_boundary)
for gap in fail_closed_gaps:
if not isinstance(gap, dict):
continue
if gap.get("operator_action_required") is not True:
continue
if gap.get("reason") not in _FAIL_CLOSED_REVIEWER_ACTIVITY_GAP_REASONS:
continue
source_kind = _gap_source_kind(gap)
if source_kind not in _REVIEWER_ACTIVITY_GAP_KINDS:
continue
event_timestamp = _gap_event_timestamp(gap)
if event_timestamp is None:
continue
if floor is not None and event_timestamp < floor:
continue
author = _visible_activity_author(gap)
if not isinstance(author, str) or author.lower() != current_reviewer.lower():
continue
if source_kind in {"pull_request_review:submitted", "pull_request_review_comment:created"}:
if not isinstance(current_head_sha, str) or not current_head_sha.strip():
continue
commit_id = _visible_review_commit_id(gap)
if commit_id != current_head_sha:
continue
return True
return False


def _current_scope_fields(
review_data: dict,
current_reviewer: str | None,
Expand Down Expand Up @@ -478,6 +593,7 @@ def derive_reviewer_response_state(
reason="no_reviewer_activity",
scope_fields=_current_scope_fields(review_data, current_reviewer, current_head, None),
anchor_timestamp=_initial_reviewer_anchor(review_data),
current_head_sha=current_head,
reviewer_comment=reviewer_comment,
reviewer_review=reviewer_review,
current_cycle_reviewer_handoff=reviewer_handoff,
Expand Down
39 changes: 39 additions & 0 deletions scripts/reviewer_bot_lib/deferred_gap_bookkeeping.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,49 @@ def _payload_or_existing(payload: dict, existing: dict, key: str):
return existing.get(key) if value is None else value


def _first_present_payload_value(payload: dict, keys: tuple[str, ...]):
for key in keys:
if key not in payload:
continue
value = payload.get(key)
if isinstance(value, str):
value = value.strip()
if value is not None and value != "":
return value
return None


def _copy_source_evidence(fields: dict, payload: dict, existing: dict) -> None:
evidence_fields = {
"source_actor_login": ("source_actor_login", "comment_author", "actor_login"),
"source_actor_id": ("source_actor_id", "comment_author_id", "actor_id"),
"source_actor_user_type": ("source_actor_user_type", "comment_user_type"),
"source_actor_sender_type": ("source_actor_sender_type", "comment_sender_type"),
"source_actor_installation_id": ("source_actor_installation_id", "comment_installation_id"),
"source_actor_performed_via_github_app": (
"source_actor_performed_via_github_app",
"comment_performed_via_github_app",
),
"source_comment_id": ("source_comment_id", "comment_id"),
"source_review_id": ("source_review_id", "review_id", "pull_request_review_id"),
"source_commit_id": ("source_commit_id",),
"source_review_state": ("source_review_state",),
}
for target_key, source_keys in evidence_fields.items():
value = _first_present_payload_value(payload, source_keys)
if value is None:
value = existing.get(target_key)
if value is not None:
fields[target_key] = value


def _source_event_created_at(payload: dict, existing: dict):
return (
payload.get("source_created_at")
or payload.get("comment_created_at")
or payload.get("source_submitted_at")
or payload.get("source_dismissed_at")
or payload.get("source_event_created_at")
or existing.get("source_event_created_at")
)

Expand Down Expand Up @@ -280,6 +318,7 @@ def record_deferred_gap_diagnostic(
source_dismissed_at = _payload_or_existing(payload, existing, "source_dismissed_at")
if source_dismissed_at is not None:
fields["source_dismissed_at"] = source_dismissed_at
_copy_source_evidence(fields, payload, existing)
existing.update(fields)
changed = previous != existing
deferred_gaps[source_event_key] = existing
Expand Down
22 changes: 17 additions & 5 deletions scripts/reviewer_bot_lib/event_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,15 @@ def build_pull_request_sync_request(bot: EventInputsContext) -> PullRequestSyncR
)


def build_replay_comment_event_request(payload, *, live_comment=None, comment_body: str | None = None) -> CommentEventRequest:
if getattr(getattr(payload, "identity", None), "schema_version", None) != 3:
raise InvalidEventInput("build_replay_comment_event_request", ("schema-v3 comment-like payload required",))
def build_replay_comment_event_request(
payload,
*,
live_comment=None,
live_pr=None,
comment_body: str | None = None,
) -> CommentEventRequest:
if not hasattr(payload, "identity") or not hasattr(payload, "comment_id"):
raise InvalidEventInput("build_replay_comment_event_request", ("typed deferred comment payload required",))
if not hasattr(payload, "issue_state") or not hasattr(payload, "issue_author") or not hasattr(payload, "issue_labels"):
raise InvalidEventInput("build_replay_comment_event_request", ("typed deferred comment payload required",))
resolved_body = payload.comment_body if comment_body is None else comment_body
Expand All @@ -467,12 +473,18 @@ def build_replay_comment_event_request(payload, *, live_comment=None, comment_bo
if live_comment is not None and live_comment.comment_performed_via_github_app_available
else payload.comment_performed_via_github_app
)
issue_author = payload.issue_author
if live_pr is not None and not issue_author.strip():
issue_author = live_pr.issue_author
issue_labels = payload.issue_labels
if live_pr is not None and not issue_labels:
issue_labels = live_pr.issue_labels
return CommentEventRequest(
issue_number=payload.identity.pr_number,
is_pull_request=True,
issue_state=payload.issue_state,
issue_author=payload.issue_author,
issue_labels=payload.issue_labels,
issue_author=issue_author,
issue_labels=issue_labels,
comment_id=payload.comment_id,
comment_author=(live_comment.comment_author if live_comment is not None else payload.comment_author),
comment_author_id=payload.comment_author_id,
Expand Down
38 changes: 35 additions & 3 deletions scripts/reviewer_bot_lib/project_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from dataclasses import asdict, dataclass
from typing import Any

from scripts.reviewer_bot_core import live_review_support, reviewer_response_policy

from . import deferred_gap_bookkeeping as gap_bookkeeping
from .config import (
REVIEWER_BOARD_ENABLED_ENV,
REVIEWER_BOARD_FIELD_ASSIGNED_AT,
Expand Down Expand Up @@ -92,6 +95,9 @@ class ReviewStateDerivation:
state: str
anchor_timestamp: str | None
reason: str | None
current_scope_key: str | None
current_scope_basis: str | None
current_head_sha: str | None


@dataclass(frozen=True)
Expand Down Expand Up @@ -262,6 +268,21 @@ def _format_date(value: Any) -> str | None:
return value[:10]


def _deferred_gap_records(review_data: dict[str, Any]) -> tuple[dict, ...]:
return tuple(
gap_bookkeeping.get_deferred_gap(review_data, source_event_key)
for source_event_key in gap_bookkeeping.list_deferred_gap_keys(review_data)
)


def _timestamp_at_or_after_anchor(value: Any, anchor: str | None) -> bool:
timestamp = live_review_support.parse_github_timestamp(value)
anchor_timestamp = live_review_support.parse_github_timestamp(anchor)
if timestamp is None or anchor_timestamp is None:
return False
return timestamp >= anchor_timestamp


def _derive_review_state(
bot: ProjectBoardProjectionContext,
issue_number: int,
Expand All @@ -279,6 +300,9 @@ def _derive_review_state(
state=state,
anchor_timestamp=derived.get("anchor_timestamp") if isinstance(derived.get("anchor_timestamp"), str) else None,
reason=derived.get("reason") if isinstance(derived.get("reason"), str) else None,
current_scope_key=derived.get("current_scope_key") if isinstance(derived.get("current_scope_key"), str) else None,
current_scope_basis=derived.get("current_scope_basis") if isinstance(derived.get("current_scope_basis"), str) else None,
current_head_sha=derived.get("current_head_sha") if isinstance(derived.get("current_head_sha"), str) else None,
)


Expand Down Expand Up @@ -355,13 +379,21 @@ def derive_board_projection(input: BoardProjectionInput) -> BoardProjectionValue

needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_NO
repair_needed = load_repair_marker(review_data, "status_label_projection")
if isinstance(repair_needed, dict) and repair_needed.get("kind") == "projection_failure":
fail_closed_reviewer_activity = reviewer_response_policy.has_fail_closed_reviewer_activity_for_current_scope(
review_data,
_deferred_gap_records(review_data),
anchor_timestamp=derivation.anchor_timestamp,
current_head_sha=derivation.current_head_sha,
)
if (isinstance(repair_needed, dict) and repair_needed.get("kind") == "projection_failure") or fail_closed_reviewer_activity:
needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED
elif review_data.get("mandatory_approver_required"):
needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED
elif review_data.get("transition_notice_sent_at"):
elif derivation.state != "awaiting_reviewer_response":
needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_NO
elif _timestamp_at_or_after_anchor(review_data.get("transition_notice_sent_at"), derivation.anchor_timestamp):
needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT
elif review_data.get("transition_warning_sent"):
elif _timestamp_at_or_after_anchor(review_data.get("transition_warning_sent"), derivation.anchor_timestamp):
needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT

return BoardProjectionValues(
Expand Down
Loading
Loading