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
22 changes: 20 additions & 2 deletions docs/webhook-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 37 additions & 17 deletions src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/github_agent_bridge/ingestion_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import sqlite3
from dataclasses import replace
from pathlib import Path

from .models import GitHubContext, Job, Notification, ResolvedGitHubEvent
Expand Down Expand Up @@ -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"
Expand Down
132 changes: 131 additions & 1 deletion src/github_agent_bridge/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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"),
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading