From 7f1dc6a44f0ac76351def29d9dd3426456db7b7b Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Mon, 2 Nov 2026 17:15:56 +0100 Subject: [PATCH] feat: re-review pull requests after new pushes Co-authored-by: ecarreras --- docs/webhook-events.md | 22 +- src/github_agent_bridge/backend.py | 54 ++- src/github_agent_bridge/ingestion_contract.py | 9 + src/github_agent_bridge/queue.py | 4 + src/github_agent_bridge/webhook.py | 132 ++++++- tests/test_webhook.py | 370 ++++++++++++++++++ 6 files changed, 571 insertions(+), 20 deletions(-) diff --git a/docs/webhook-events.md b/docs/webhook-events.md index 2d71032..9b8f393 100644 --- a/docs/webhook-events.md +++ b/docs/webhook-events.md @@ -27,12 +27,30 @@ and other actions remain distinct observational identities and must not retrigger work without an explicit Phase 2 policy decision. For `issues`, only `assigned` is actionable when the payload assignee matches a configured bot login. For `pull_request`, `assigned` and `review_requested` retain their -configured-bot checks, while `closed` is actionable only when `merged=true`; -that delivery becomes a read-only `sync_after_merge` job. Comment and review +configured-bot checks. `synchronize` creates a new read-only `submit_review` +job only when a configured bot is still a reviewer, while `closed` is +actionable only when `merged=true`; that delivery becomes a read-only +`sync_after_merge` job. Comment and review deliveries must additionally be addressed to the bot, belong to a PR authored by the bot, or target a PR/issue assigned to the bot. Being a reviewer, subscriber, or email recipient is not authorization to work. +Reviewer membership is not copied into a bridge-owned table. A synchronize +payload can directly prove an outstanding request through +`pull_request.requested_reviewers`; after a completed review disappears from +that list, the bridge reads the fully paginated issue timeline and uses the +latest `review_requested`/`review_request_removed` transition for each +configured bot. This keeps GitHub as the source of truth and avoids stale state +or merge cleanup. A failed, incomplete or malformed lookup fails closed with +`reviewer_lookup_failed`. Canary mode performs the lookup only for +`webhookCanaryRepos`; both canary and primary modes also honor `enabledRepos` +before calling GitHub. + +Each accepted synchronize delivery is keyed by repository, PR number and the +new head SHA. The SHA is also retained in the job context, so a push received +while an older review is running queues a distinct follow-up behind the +per-work-key concurrency guard instead of coalescing into the stale review. + Assignment, review-request, issue-close and issue-reopen deliveries use an immutable timeline event ID only after API resolution proves one unique match. IMAP reads the ID embedded in the notification `Message-ID`; webhook ingestion diff --git a/src/github_agent_bridge/backend.py b/src/github_agent_bridge/backend.py index a716b94..a7e2445 100644 --- a/src/github_agent_bridge/backend.py +++ b/src/github_agent_bridge/backend.py @@ -51,6 +51,7 @@ from .systemd_status import allowed_unit_names, stream_journal_lines, systemd_status from .web_push import delete_subscription, save_subscription, subscription_status from .webhook import ( + bot_is_pull_request_reviewer, persist_shadow_delivery, resolve_github_event_for_webhook, verify_signature, @@ -849,24 +850,43 @@ async def _receive_github_webhook( elif sender_login in policy.bot_logins: enqueue_status = "ignored_bot" else: - notification = webhook_notification( - event_name, - delivery_id, - payload, - bot_logins=policy.bot_logins, - received_at=source_received_at, - ) - if notification is None: - enqueue_status = "ignored" - else: - job, enqueue_status = JobQueue(config.db).ingest( - notification, - policy, - source="webhook", - source_key=delivery_id, - resolved_event=resolved_event, + action = str(payload.get("action") or "") + rereview_authorized = False + if event_name == "pull_request" and action == "synchronize": + if policy.enabled_repos and repo not in policy.enabled_repos: + enqueue_status = "outside_enabled_repos" + else: + reviewer_status = await asyncio.to_thread( + bot_is_pull_request_reviewer, + payload, + policy.bot_logins, + ) + if reviewer_status is None: + enqueue_status = "reviewer_lookup_failed" + elif not reviewer_status: + enqueue_status = "ignored" + else: + rereview_authorized = True + if enqueue_status is None: + notification = webhook_notification( + event_name, + delivery_id, + payload, + bot_logins=policy.bot_logins, + received_at=source_received_at, + rereview_authorized=rereview_authorized, ) - job_id = job.id if job else None + if notification is None: + enqueue_status = "ignored" + else: + job, enqueue_status = JobQueue(config.db).ingest( + notification, + policy, + source="webhook", + source_key=delivery_id, + resolved_event=resolved_event, + ) + job_id = job.id if job else None receipt = persist_shadow_delivery( config.db, delivery_id=delivery_id, diff --git a/src/github_agent_bridge/ingestion_contract.py b/src/github_agent_bridge/ingestion_contract.py index 6d4496f..c5af5d0 100644 --- a/src/github_agent_bridge/ingestion_contract.py +++ b/src/github_agent_bridge/ingestion_contract.py @@ -54,6 +54,13 @@ def canonical_event_identity( return None if event_type == "pull_request" and action == "merged" and thread: return f"pull_request:merged:{repo}:{thread}" + if ( + event_type == "pull_request" + and action == "synchronize" + and thread + and object_id + ): + return f"pull_request:synchronize:{repo}:{thread}:{str(object_id).lower()}" if event_type == "workflow_run" and object_id: return f"workflow_run:{action}:{repo}:{object_id}" if (event_type, action) in OCCURRENCE_ACTIONS and thread: @@ -169,6 +176,8 @@ def _descriptor( bridge_action, ctx.workflow_run_id, ) + elif event_type == "pull_request" and event_action == "synchronize": + object_id = str(notification.metadata.get("github_head_sha") or "") or None elif bridge_action == "sync_after_merge": event_type, event_action = "pull_request", "merged" elif flags["assigned"] and bridge_action == "open_issue": diff --git a/src/github_agent_bridge/queue.py b/src/github_agent_bridge/queue.py index ecb7e2b..5f49efd 100644 --- a/src/github_agent_bridge/queue.py +++ b/src/github_agent_bridge/queue.py @@ -2,6 +2,7 @@ import hashlib import sqlite3 +from dataclasses import replace from pathlib import Path from .models import GitHubContext, Job, Notification, ResolvedGitHubEvent @@ -150,6 +151,9 @@ def ingest( self._ensure_initialized() source_key = source_key or n.message_id ctx = extract_github_context(n.body) + head_sha = str(n.metadata.get("github_head_sha") or "").lower() + if source == "webhook" and ctx.is_pull_request and head_sha: + ctx = replace(ctx, commit_sha=head_sha) resolved_event = resolved_event or ( resolve_github_event_for_notification(n, ctx) if source == "email" diff --git a/src/github_agent_bridge/webhook.py b/src/github_agent_bridge/webhook.py index 56b61f9..de0e9f9 100644 --- a/src/github_agent_bridge/webhook.py +++ b/src/github_agent_bridge/webhook.py @@ -3,11 +3,13 @@ import hashlib import hmac import json +import re +import subprocess from dataclasses import dataclass from pathlib import Path from typing import Any -from .actors import resolve_github_event_occurrence +from .actors import default_gh_bin, resolve_github_event_occurrence from .actionability import feedback_authorization_from_webhook from .models import Notification, ResolvedGitHubEvent, utc_now from .ingestion_contract import canonical_event_identity @@ -40,6 +42,111 @@ def verify_signature(payload: bytes, signature: str, secrets: tuple[str, ...]) - ) +def bot_is_pull_request_reviewer( + payload: dict[str, Any], + bot_logins: set[str], + *, + gh_bin: str | None = None, + timeout: int = 5, +) -> bool | None: + """Return whether a configured bot has an active PR review request. + + The signed synchronize payload proves current outstanding review requests. + Completed reviews disappear from that list, so the issue timeline remains + the source of truth for the most recent request/removal transition. ``None`` + means the paginated lookup could not be completed and callers must fail + closed instead of dispatching an unauthorised review. + """ + configured = { + str(login).lower().lstrip("@") + for login in bot_logins + if str(login).strip() + } + if not configured: + return False + pull_request = payload.get("pull_request") + if not isinstance(pull_request, dict): + return False + requested_reviewers = pull_request.get("requested_reviewers") + if isinstance(requested_reviewers, list) and any( + str(reviewer.get("login") or "").lower() in configured + for reviewer in requested_reviewers + if isinstance(reviewer, dict) + ): + return True + repository = payload.get("repository") + repo = ( + str(repository.get("full_name") or "").lower() + if isinstance(repository, dict) + else "" + ) + number = pull_request.get("number") + if not repo or not isinstance(number, int) or number <= 0: + return False + endpoint = f"repos/{repo}/issues/{number}/events?per_page=100" + try: + proc = subprocess.run( + [ + gh_bin or default_gh_bin(), + "api", + "--paginate", + "--slurp", + endpoint, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if proc.returncode != 0: + return None + try: + pages = json.loads(proc.stdout or "[]") + except json.JSONDecodeError: + return None + if not isinstance(pages, list) or any( + not isinstance(page, list) for page in pages + ): + return None + latest: dict[str, tuple[int, bool]] = {} + for page in pages: + for event in page: + if not isinstance(event, dict): + continue + action = str(event.get("event") or "").lower() + if action not in {"review_requested", "review_request_removed"}: + continue + reviewer = event.get("requested_reviewer") + login = ( + str(reviewer.get("login") or "").lower() + if isinstance(reviewer, dict) + else "" + ) + if login not in configured: + continue + event_id = event.get("id") + if not isinstance(event_id, int) or event_id <= 0: + return None + current = latest.get(login) + if current is None or event_id > current[0]: + latest[login] = (event_id, action == "review_requested") + return any(active for _, active in latest.values()) + + +def _pull_request_head_sha(payload: dict[str, Any]) -> str | None: + pull_request = payload.get("pull_request") + head = pull_request.get("head") if isinstance(pull_request, dict) else None + value = str( + payload.get("after") + or (head.get("sha") if isinstance(head, dict) else "") + or "" + ).lower() + return value if re.fullmatch(r"[0-9a-f]{40,64}", value) else None + + def canonical_webhook_event_key( event_name: str, payload: dict[str, Any], @@ -127,6 +234,17 @@ def canonical_webhook_event_key( else requested_login ), ) or (fallback if number and requested_login else None) + if event_name == "pull_request" and action == "synchronize": + pull_request = payload.get("pull_request") or {} + number = pull_request.get("number") if isinstance(pull_request, dict) else None + head_sha = _pull_request_head_sha(payload) + return canonical_event_identity( + repository=repo, + thread=number, + event_type=event_name, + event_action=action, + object_id=head_sha, + ) if event_name == "pull_request" and action == "closed": pull_request = payload.get("pull_request") or {} merged = pull_request.get("merged") if isinstance(pull_request, dict) else False @@ -264,6 +382,7 @@ def webhook_notification( *, bot_logins: set[str] | None = None, received_at: str | None = None, + rereview_authorized: bool = False, ) -> Notification | None: """Translate actionable webhook payloads into the transport-neutral queue input.""" received_at = received_at or utc_now() @@ -276,6 +395,7 @@ def webhook_notification( ("pull_request_review_comment", "created"), ("pull_request_review", "submitted"), ("pull_request", "review_requested"), + ("pull_request", "synchronize"), ("pull_request", "assigned"), ("pull_request", "closed"), ("commit_comment", "created"), @@ -312,6 +432,9 @@ def webhook_notification( requested_login = str(reviewer.get("login") or "").lower() if requested_login not in configured_logins: return None + if event_name == "pull_request" and action == "synchronize": + if not rereview_authorized or _pull_request_head_sha(payload) is None: + return None if event_name == "workflow_run": conclusion = str(source.get("conclusion") or "").lower() if conclusion != "failure": @@ -351,6 +474,8 @@ def webhook_notification( assignee = payload.get("assignee") if isinstance(payload.get("assignee"), dict) else {} target_label = "pull request" if event_name == "pull_request" else "issue" body = f"Assigned @{str(assignee.get('login') or '')} to this {target_label}." + elif event_name == "pull_request" and action == "synchronize": + body = "The pull request head changed. Review requested." elif event_name == "pull_request": body = "Review requested." else: @@ -404,6 +529,11 @@ def webhook_notification( if event_name == "pull_request_review" else {} ), + **( + {"github_head_sha": _pull_request_head_sha(payload)} + if event_name == "pull_request" and action == "synchronize" + else {} + ), }, ) if event_name != "workflow_run" and classify_github_action( diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 6a16e49..4bf9a1f 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -4,6 +4,7 @@ import hmac import json import sqlite3 +import subprocess from datetime import UTC, datetime, timedelta from fastapi.testclient import TestClient @@ -16,6 +17,7 @@ from github_agent_bridge.policy import Policy from github_agent_bridge.queue import JobQueue from github_agent_bridge.webhook import ( + bot_is_pull_request_reviewer, resolve_github_event_for_webhook, webhook_notification, ) @@ -214,6 +216,129 @@ def pull_request_closed_payload(*, merged: bool, number: int = 272) -> bytes: }).encode() +def pull_request_synchronize_payload( + *, + number: int = 233, + requested_reviewers: tuple[str, ...] = (), + before: str = "1" * 40, + after: str = "2" * 40, +) -> bytes: + return json.dumps({ + "action": "synchronize", + "before": before, + "after": after, + "repository": {"full_name": "gisce/github-agent-bridge"}, + "pull_request": { + "id": 1829195123, + "number": number, + "title": "feat: enable guarded webhook canary ingestion", + "html_url": ( + f"https://github.com/gisce/github-agent-bridge/pull/{number}" + ), + "requested_reviewers": [ + {"login": login} for login in requested_reviewers + ], + "head": {"sha": after}, + }, + "sender": {"login": "ecarreras"}, + }).encode() + + +def test_synchronize_reviewer_lookup_uses_current_payload_without_api(monkeypatch): + def fail_run(*args, **kwargs): + raise AssertionError("current requested reviewers should not need an API call") + + monkeypatch.setattr("github_agent_bridge.webhook.subprocess.run", fail_run) + + assert bot_is_pull_request_reviewer( + json.loads( + pull_request_synchronize_payload(requested_reviewers=("GISCEBOT",)) + ), + {"giscebot"}, + ) is True + + +def test_synchronize_reviewer_lookup_uses_complete_timeline(monkeypatch): + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps([ + [ + { + "id": 101, + "event": "review_requested", + "requested_reviewer": {"login": "giscebot"}, + } + ], + [ + { + "id": 202, + "event": "review_request_removed", + "requested_reviewer": {"login": "giscebot"}, + } + ], + ]), + stderr="", + ) + + monkeypatch.setattr("github_agent_bridge.webhook.subprocess.run", fake_run) + + assert bot_is_pull_request_reviewer( + json.loads(pull_request_synchronize_payload()), + {"giscebot"}, + gh_bin="/opt/bin/gh", + ) is False + assert calls[0][0] == [ + "/opt/bin/gh", + "api", + "--paginate", + "--slurp", + "repos/gisce/github-agent-bridge/issues/233/events?per_page=100", + ] + + +def test_synchronize_reviewer_lookup_accepts_latest_review_request(monkeypatch): + events = [[ + { + "id": 202, + "event": "review_request_removed", + "requested_reviewer": {"login": "giscebot"}, + }, + { + "id": 303, + "event": "review_requested", + "requested_reviewer": {"login": "giscebot"}, + }, + ]] + monkeypatch.setattr( + "github_agent_bridge.webhook.subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args[0], 0, stdout=json.dumps(events), stderr="" + ), + ) + + assert bot_is_pull_request_reviewer( + json.loads(pull_request_synchronize_payload()), {"giscebot"} + ) is True + + +def test_synchronize_reviewer_lookup_fails_closed_on_api_error(monkeypatch): + monkeypatch.setattr( + "github_agent_bridge.webhook.subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args[0], 1, stdout="", stderr="rate limited" + ), + ) + + assert bot_is_pull_request_reviewer( + json.loads(pull_request_synchronize_payload()), {"giscebot"} + ) is None + + def assignment_payload( *, event_name: str = "issues", @@ -781,6 +906,41 @@ def test_webhook_canary_ignores_repo_outside_webhook_canary_repos(tmp_path): ).fetchone()[0] == "outside_canary" +def test_webhook_canary_does_not_lookup_rereviewer_outside_scope( + tmp_path, monkeypatch +): + def fail_lookup(payload, bot_logins): + raise AssertionError("outside-canary repositories must not use the API") + + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", fail_lookup + ) + policy = canary_policy(tmp_path) + policy.write_text(json.dumps({ + "trustedOrgs": ["gisce"], + "webhookCanaryRepos": ["gisce/other"], + "botLogins": ["giscebot"], + })) + payload = pull_request_synchronize_payload() + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="canary", + webhook_policy=policy, + ) + + response = TestClient(create_app(config)).post( + "/api/webhooks/github", + content=payload, + headers=signed_headers( + payload, delivery="outside-canary-sync", event="pull_request" + ), + ) + + assert response.json()["enqueue_status"] == "outside_canary" + + def test_webhook_canary_ignores_events_created_by_configured_bot(tmp_path): payload = pull_request_review_payload(state="commented", sender="giscebot") config = DashboardConfig( @@ -929,6 +1089,180 @@ def fail_context_lookup(notification, ctx): assert con.execute("SELECT trigger_actor FROM jobs").fetchone()[0] == "review-requester" +def test_webhook_canary_enqueues_rereview_after_pull_request_synchronize( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", + lambda payload, bot_logins: True, + ) + policy = canary_policy(tmp_path) + policy.write_text(json.dumps({ + "trustedOrgs": ["gisce"], + "webhookCanaryRepos": ["gisce/github-agent-bridge"], + "botLogins": ["giscebot"], + "actions": {"trustedAuto": ["submit_review"]}, + })) + payload = pull_request_synchronize_payload() + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="canary", + webhook_policy=policy, + ) + + response = TestClient(create_app(config)).post( + "/api/webhooks/github", + content=payload, + headers=signed_headers( + payload, + delivery="review-synchronize", + event="pull_request", + ), + ) + + assert response.json()["enqueue_status"] == "enqueued" + assert response.json()["event_key"] == ( + "pull_request:synchronize:gisce/github-agent-bridge:233:" + "2" * 40 + ) + with sqlite3.connect(config.db) as con: + row = con.execute( + "SELECT action,work_intent,work_key,context_json,metadata_json FROM jobs" + ).fetchone() + assert row[:3] == ( + "submit_review", + "review_only", + "gisce/github-agent-bridge#233", + ) + assert json.loads(row[3])["commit_sha"] == "2" * 40 + assert json.loads(row[4])["event_envelope"]["event_action"] == ( + "synchronize" + ) + + +def test_webhook_canary_ignores_synchronize_when_bot_is_not_reviewer( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", + lambda payload, bot_logins: False, + ) + payload = pull_request_synchronize_payload() + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="canary", + webhook_policy=canary_policy(tmp_path), + ) + + response = TestClient(create_app(config)).post( + "/api/webhooks/github", + content=payload, + headers=signed_headers( + payload, + delivery="non-reviewer-synchronize", + event="pull_request", + ), + ) + + assert response.json()["enqueue_status"] == "ignored" + with sqlite3.connect(config.db) as con: + assert con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 0 + + +def test_webhook_canary_reports_synchronize_reviewer_lookup_failure( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", + lambda payload, bot_logins: None, + ) + payload = pull_request_synchronize_payload() + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="canary", + webhook_policy=canary_policy(tmp_path), + ) + + response = TestClient(create_app(config)).post( + "/api/webhooks/github", + content=payload, + headers=signed_headers( + payload, + delivery="reviewer-lookup-failure", + event="pull_request", + ), + ) + + assert response.json()["enqueue_status"] == "reviewer_lookup_failed" + with sqlite3.connect(config.db) as con: + assert con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 0 + assert con.execute( + "SELECT enqueue_status FROM webhook_shadow_receipts" + ).fetchone()[0] == "reviewer_lookup_failed" + + +def test_webhook_synchronize_new_sha_does_not_coalesce_into_running_review( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", + lambda payload, bot_logins: True, + ) + policy = canary_policy(tmp_path) + policy.write_text(json.dumps({ + "trustedOrgs": ["gisce"], + "webhookCanaryRepos": ["gisce/github-agent-bridge"], + "botLogins": ["giscebot"], + "actions": {"trustedAuto": ["submit_review"]}, + })) + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="canary", + webhook_policy=policy, + ) + client = TestClient(create_app(config)) + first_payload = pull_request_synchronize_payload(after="2" * 40) + first = client.post( + "/api/webhooks/github", + content=first_payload, + headers=signed_headers( + first_payload, delivery="first-synchronize", event="pull_request" + ), + ) + running = JobQueue(config.db).claim_next("review-worker") + second_payload = pull_request_synchronize_payload( + before="2" * 40, after="3" * 40 + ) + + second = client.post( + "/api/webhooks/github", + content=second_payload, + headers=signed_headers( + second_payload, delivery="second-synchronize", event="pull_request" + ), + ) + + assert first.json()["enqueue_status"] == "enqueued" + assert running is not None + assert second.json()["enqueue_status"] == "enqueued" + with sqlite3.connect(config.db) as con: + rows = con.execute( + "SELECT status,context_json FROM jobs ORDER BY id" + ).fetchall() + assert [row[0] for row in rows] == ["running", "pending"] + assert [json.loads(row[1])["commit_sha"] for row in rows] == [ + "2" * 40, + "3" * 40, + ] + + def test_webhook_canary_enqueues_failed_workflow_run(tmp_path): payload = workflow_run_payload(conclusion="failure") config = DashboardConfig( @@ -1108,6 +1442,42 @@ def test_webhook_commit_comment_keeps_cross_source_identity(tmp_path, monkeypatc ).fetchone()[0] == expected +def test_webhook_primary_does_not_lookup_rereviewer_outside_enabled_repos( + tmp_path, monkeypatch +): + def fail_lookup(payload, bot_logins): + raise AssertionError("disabled repositories must not use the API") + + monkeypatch.setattr( + "github_agent_bridge.backend.bot_is_pull_request_reviewer", fail_lookup + ) + policy = canary_policy(tmp_path) + policy.write_text(json.dumps({ + "trustedOrgs": ["gisce"], + "enabledRepos": ["gisce/other"], + "botLogins": ["giscebot"], + })) + payload = pull_request_synchronize_payload() + config = DashboardConfig( + db=tmp_path / "bridge.sqlite3", + require_auth=False, + webhook_secrets=(SECRET,), + webhook_mode="primary", + webhook_policy=policy, + webhook_primary_ack=True, + ) + + response = TestClient(create_app(config)).post( + "/api/webhooks/github", + content=payload, + headers=signed_headers( + payload, delivery="outside-enabled-sync", event="pull_request" + ), + ) + + assert response.json()["enqueue_status"] == "outside_enabled_repos" + + def test_webhook_canary_ignores_approved_pull_request_reviews(tmp_path): payload = pull_request_review_payload(state="approved") config = DashboardConfig(