diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index 69da60d4..08b87751 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -393,6 +393,37 @@ def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tm assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" +def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): + state = make_state() + reviewer_bot.ensure_review_entry(state, 42, create=True) + payload_path = tmp_path / "observer-noop.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 1, + "kind": "observer_noop", + "reason": "ignored_non_human_automation", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 777, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:111", + "pr_number": 42, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "777") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + assert reviewer_bot.handle_workflow_run_event(state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} + + def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/.github/workflows/reviewer-bot-pr-comment-observer.yml b/.github/workflows/reviewer-bot-pr-comment-observer.yml index 1b6bfc86..864db1b4 100644 --- a/.github/workflows/reviewer-bot-pr-comment-observer.yml +++ b/.github/workflows/reviewer-bot-pr-comment-observer.yml @@ -38,43 +38,59 @@ jobs: sender_type = os.environ.get('COMMENT_SENDER_TYPE', '').strip() installation_id = os.environ.get('COMMENT_INSTALLATION_ID', '').strip() via_github_app = os.environ.get('COMMENT_PERFORMED_VIA_GITHUB_APP', '').strip().lower() + noop_reason = None if comment_user_type == 'Bot' or comment_author.endswith('[bot]') or comment_author == 'guidelines-bot': - raise SystemExit(0) - if installation_id or via_github_app == 'true' or (sender_type and sender_type not in {'User', 'Bot'}): - raise SystemExit(0) - command_pattern = re.compile(r'^@guidelines\-bot\s+/[A-Za-z0-9?_\-]+(?:\s+.*)?$') - lines = [line for line in normalized.splitlines() if line.strip()] - command_lines = [line for line in lines if command_pattern.match(line.strip())] - non_command_lines = [line for line in lines if not command_pattern.match(line.strip())] - if not normalized: - comment_class = 'empty_or_whitespace' - elif command_lines and not non_command_lines: - comment_class = 'command_only' - elif command_lines and non_command_lines: - comment_class = 'command_plus_text' + noop_reason = 'ignored_non_human_automation' + elif installation_id or via_github_app == 'true' or (sender_type and sender_type not in {'User', 'Bot'}): + noop_reason = 'ignored_non_human_automation' + if noop_reason is not None: + payload = { + 'schema_version': 1, + 'kind': 'observer_noop', + 'reason': noop_reason, + 'source_workflow_name': 'Reviewer Bot PR Comment Observer', + 'source_workflow_file': '.github/workflows/reviewer-bot-pr-comment-observer.yml', + 'source_run_id': int(os.environ['GITHUB_RUN_ID']), + 'source_run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']), + 'source_event_name': 'issue_comment', + 'source_event_action': 'created', + 'source_event_key': f"issue_comment:{os.environ['COMMENT_ID']}", + 'pr_number': int(os.environ['PR_NUMBER']), + } else: - comment_class = 'plain_text' - digest = hashlib.sha256(normalized.encode('utf-8')).hexdigest() - payload = { - 'schema_version': 2, - 'source_workflow_name': 'Reviewer Bot PR Comment Observer', - 'source_workflow_file': '.github/workflows/reviewer-bot-pr-comment-observer.yml', - 'source_run_id': int(os.environ['GITHUB_RUN_ID']), - 'source_run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']), - 'source_event_name': 'issue_comment', - 'source_event_action': 'created', - 'source_event_key': f"issue_comment:{os.environ['COMMENT_ID']}", - 'pr_number': int(os.environ['PR_NUMBER']), - 'comment_id': int(os.environ['COMMENT_ID']), - 'comment_class': comment_class, - 'has_non_command_text': bool(non_command_lines), - 'source_body_digest': digest, - 'source_created_at': os.environ['COMMENT_CREATED_AT'], - 'actor_login': os.environ['COMMENT_AUTHOR'], - 'actor_id': int(os.environ['COMMENT_AUTHOR_ID']), - 'actor_class': 'repo_user_principal' if comment_user_type == 'User' else 'unknown_actor', - 'source_artifact_name': f"reviewer-bot-comment-context-{os.environ['GITHUB_RUN_ID']}-attempt-{os.environ['GITHUB_RUN_ATTEMPT']}", - } + command_pattern = re.compile(r'^@guidelines\-bot\s+/[A-Za-z0-9?_\-]+(?:\s+.*)?$') + lines = [line for line in normalized.splitlines() if line.strip()] + command_lines = [line for line in lines if command_pattern.match(line.strip())] + non_command_lines = [line for line in lines if not command_pattern.match(line.strip())] + if not normalized: + comment_class = 'empty_or_whitespace' + elif command_lines and not non_command_lines: + comment_class = 'command_only' + elif command_lines and non_command_lines: + comment_class = 'command_plus_text' + else: + comment_class = 'plain_text' + digest = hashlib.sha256(normalized.encode('utf-8')).hexdigest() + payload = { + 'schema_version': 2, + 'source_workflow_name': 'Reviewer Bot PR Comment Observer', + 'source_workflow_file': '.github/workflows/reviewer-bot-pr-comment-observer.yml', + 'source_run_id': int(os.environ['GITHUB_RUN_ID']), + 'source_run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']), + 'source_event_name': 'issue_comment', + 'source_event_action': 'created', + 'source_event_key': f"issue_comment:{os.environ['COMMENT_ID']}", + 'pr_number': int(os.environ['PR_NUMBER']), + 'comment_id': int(os.environ['COMMENT_ID']), + 'comment_class': comment_class, + 'has_non_command_text': bool(non_command_lines), + 'source_body_digest': digest, + 'source_created_at': os.environ['COMMENT_CREATED_AT'], + 'actor_login': os.environ['COMMENT_AUTHOR'], + 'actor_id': int(os.environ['COMMENT_AUTHOR_ID']), + 'actor_class': 'repo_user_principal' if comment_user_type == 'User' else 'unknown_actor', + 'source_artifact_name': f"reviewer-bot-comment-context-{os.environ['GITHUB_RUN_ID']}-attempt-{os.environ['GITHUB_RUN_ATTEMPT']}", + } with open(os.environ['PAYLOAD_PATH'], 'w', encoding='utf-8') as handle: json.dump(payload, handle) PY diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 292639ad..56ee6d78 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -180,6 +180,33 @@ def _load_deferred_context() -> dict: return payload +def _validate_observer_noop_payload(payload: dict) -> None: + required = { + "schema_version", + "kind", + "reason", + "source_workflow_name", + "source_workflow_file", + "source_run_id", + "source_run_attempt", + "source_event_name", + "source_event_action", + "source_event_key", + "pr_number", + } + missing = sorted(required - set(payload)) + if missing: + raise RuntimeError("Observer no-op payload missing required fields: " + ", ".join(missing)) + if payload.get("schema_version") != 1: + raise RuntimeError("Observer no-op payload schema_version is not accepted") + if payload.get("kind") != "observer_noop": + raise RuntimeError("Observer no-op payload kind mismatch") + if not isinstance(payload.get("reason"), str) or not payload.get("reason"): + raise RuntimeError("Observer no-op payload reason must be a non-empty string") + if not isinstance(payload.get("pr_number"), int): + raise RuntimeError("Observer no-op payload pr_number must be an integer") + + def _expected_observer_identity(payload: dict) -> tuple[str, str]: event_name = payload.get("source_event_name") event_action = payload.get("source_event_action") @@ -291,6 +318,15 @@ def handle_workflow_run_event(bot, state: dict) -> bool: event_action = payload.get("source_event_action") source_event_key = str(payload.get("source_event_key", "")) try: + if payload.get("kind") == "observer_noop": + _validate_observer_noop_payload(payload) + _validate_workflow_run_artifact_identity(payload) + print( + "Observer workflow produced explicit no-op payload for " + f"{source_event_key}: {payload.get('reason')}" + ) + return False + if event_name == "issue_comment": _validate_deferred_comment_artifact(payload) _validate_workflow_run_artifact_identity(payload)