diff --git a/src/github_agent_bridge/queue.py b/src/github_agent_bridge/queue.py index 6c767f6..b9d539a 100644 --- a/src/github_agent_bridge/queue.py +++ b/src/github_agent_bridge/queue.py @@ -5,6 +5,7 @@ import sqlite3 from importlib import resources from pathlib import Path +from urllib.parse import urlparse from .models import GitHubContext, Job, Notification, utc_now from .parser import classify_github_action, classify_work_intent, extract_github_context @@ -28,6 +29,15 @@ def load_schema() -> str: ACK_RETRY_LIMIT = 2 +def _webhook_hook_target_from_api_url(api_url: str) -> tuple[str, str] | None: + parts = [part for part in urlparse(api_url).path.split("/") if part] + if len(parts) >= 4 and parts[0] == "repos" and parts[3] == "hooks": + return f"{parts[1]}/{parts[2]}", "repository" + if len(parts) >= 3 and parts[0] == "orgs" and parts[2] == "hooks": + return parts[1], "organization" + return None + + def semantic_event_identity( action: str, ctx: GitHubContext, trigger_actor: str | None ) -> tuple[object, ...]: @@ -116,6 +126,7 @@ def connect(self) -> sqlite3.Connection: self._ensure_columns(con) self._ensure_indexes(con) self._backfill_job_runs(con) + self._backfill_webhook_hook_targets(con) return con def init(self) -> None: @@ -124,6 +135,7 @@ def init(self) -> None: self._ensure_columns(con) self._ensure_indexes(con) self._backfill_job_runs(con) + self._backfill_webhook_hook_targets(con) def enqueue(self, n: Notification, policy: Policy) -> tuple[Job | None, str]: """Backward-compatible email enqueue entrypoint.""" @@ -1020,6 +1032,27 @@ def _ensure_indexes(self, con: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_quarantined_notifications_unresolved ON quarantined_notifications(resolved_at, created_at)" ) + def _backfill_webhook_hook_targets(self, con: sqlite3.Connection) -> None: + if con.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='webhook_hooks'").fetchone() is None: + return + columns = {row["name"] for row in con.execute("PRAGMA table_info(webhook_hooks)")} + if not {"hook_id", "target", "target_type", "github_api_url"} <= columns: + return + rows = con.execute( + "SELECT hook_id,target,target_type,github_api_url FROM webhook_hooks WHERE github_api_url IS NOT NULL" + ).fetchall() + for row in rows: + target = _webhook_hook_target_from_api_url(str(row["github_api_url"] or "")) + if target is None: + continue + target_name, target_type = target + if row["target"] == target_name and row["target_type"] == target_type: + continue + con.execute( + "UPDATE webhook_hooks SET target=?,target_type=? WHERE hook_id=?", + (target_name, target_type, row["hook_id"]), + ) + def _row_to_job(self, row: sqlite3.Row | None) -> Job | None: if row is None: return None diff --git a/src/github_agent_bridge/webhook.py b/src/github_agent_bridge/webhook.py index aaf377d..3151187 100644 --- a/src/github_agent_bridge/webhook.py +++ b/src/github_agent_bridge/webhook.py @@ -153,6 +153,25 @@ def webhook_notification( return notification +def webhook_hook_target(payload: dict[str, Any]) -> tuple[str, str]: + hook = payload.get("hook") if isinstance(payload, dict) else None + repository = payload.get("repository") if isinstance(payload, dict) else None + organization = payload.get("organization") if isinstance(payload, dict) else None + repository_name = str(repository.get("full_name") or "") if isinstance(repository, dict) else "" + organization_name = str(organization.get("login") or "") if isinstance(organization, dict) else "" + hook_url = str(hook.get("url") or "") if isinstance(hook, dict) else "" + + if "/repos/" in hook_url and repository_name: + return repository_name, "repository" + if "/orgs/" in hook_url and organization_name: + return organization_name, "organization" + if repository_name: + return repository_name, "repository" + if organization_name: + return organization_name, "organization" + return "unknown", "repository" + + def persist_shadow_delivery( db: str | Path, *, @@ -197,13 +216,7 @@ def persist_shadow_delivery( status = "duplicate" if hook_id: hook = payload.get("hook") if isinstance(payload, dict) else None - organization = payload.get("organization") if isinstance(payload, dict) else None - target_type = "organization" if isinstance(organization, dict) else "repository" - target = ( - organization.get("login") if isinstance(organization, dict) - else repository.get("full_name") if isinstance(repository, dict) - else None - ) or "unknown" + target, target_type = webhook_hook_target(payload) if event_name == "ping" and isinstance(hook, dict): config = hook.get("config") if isinstance(hook.get("config"), dict) else {} events = hook.get("events") if isinstance(hook.get("events"), list) else [] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index a63f9a5..2d022ba 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -931,6 +931,45 @@ def test_webhook_monitoring_endpoints_keep_summary_light_and_return_real_data(tm } +def test_webhook_repository_hook_ping_keeps_repository_target_when_organization_is_present(tmp_path): + db = tmp_path / "bridge.sqlite3" + client = TestClient(create_app(DashboardConfig( + db=db, + require_auth=False, + webhook_secrets=(SECRET,), + ))) + ping = json.dumps({ + "zen": "Keep it logically awesome.", + "hook": { + "id": 42, "name": "web", "active": True, + "events": ["*"], + "config": { + "content_type": "json", "insecure_ssl": "0", + "secret": "********", "url": "https://gab.gisce.net/api/webhooks/github", + }, + "created_at": "2026-10-05T15:29:44Z", + "updated_at": "2026-10-05T15:29:44Z", + "url": "https://api.github.com/repos/gisce/github-agent-bridge/hooks/42", + "ping_url": "https://api.github.com/repos/gisce/github-agent-bridge/hooks/42/pings", + "deliveries_url": "https://api.github.com/repos/gisce/github-agent-bridge/hooks/42/deliveries", + }, + "repository": {"full_name": "gisce/github-agent-bridge"}, + "organization": {"login": "gisce"}, + }).encode() + + response = client.post( + "/api/webhooks/github", + content=ping, + headers=hook_headers(ping, delivery="repo-ping-1", event="ping", hook_id="42"), + ) + + assert response.status_code == 200 + hook = client.get("/api/webhooks/github/hooks").json()["hooks"][0] + assert hook["target"] == "gisce/github-agent-bridge" + assert hook["target_type"] == "repository" + assert hook["admin_url"] == "https://github.com/gisce/github-agent-bridge/settings/hooks/42" + + def test_webhook_coverage_uses_comparable_window_keys_and_grace(tmp_path): db = tmp_path / "bridge.sqlite3" JobQueue(db) @@ -1201,6 +1240,40 @@ def test_existing_webhook_receipt_schema_is_migrated_for_hook_inventory(tmp_path assert {"delivery_url", "last_delivery_id", "last_result"} <= hook_columns +def test_existing_webhook_hook_targets_are_backfilled_from_github_api_url(tmp_path): + db = tmp_path / "bridge.sqlite3" + JobQueue(db) + with sqlite3.connect(db) as con: + con.execute( + "INSERT INTO webhook_hooks(hook_id,target,target_type,events_json,github_api_url,updated_at) " + "VALUES(?,?,?,?,?,?)", + ( + "42", "gisce", "organization", "[]", + "https://api.github.com/repos/gisce/github-agent-bridge/hooks/42", + "2026-10-05T15:29:45+00:00", + ), + ) + con.execute( + "INSERT INTO webhook_hooks(hook_id,target,target_type,events_json,github_api_url,updated_at) " + "VALUES(?,?,?,?,?,?)", + ( + "43", "gisce", "organization", "[]", + "https://api.github.com/orgs/gisce/hooks/43", + "2026-10-05T15:29:45+00:00", + ), + ) + + client = TestClient(create_app(DashboardConfig(db=db, require_auth=False, webhook_secrets=(SECRET,)))) + hooks = {hook["id"]: hook for hook in client.get("/api/webhooks/github/hooks").json()["hooks"]} + + assert hooks["42"]["target"] == "gisce/github-agent-bridge" + assert hooks["42"]["target_type"] == "repository" + assert hooks["42"]["admin_url"] == "https://github.com/gisce/github-agent-bridge/settings/hooks/42" + assert hooks["43"]["target"] == "gisce" + assert hooks["43"]["target_type"] == "organization" + assert hooks["43"]["admin_url"] == "https://github.com/organizations/gisce/settings/hooks/43" + + def test_submitted_review_uses_same_canonical_key_as_email_ingestion(tmp_path): payload = json.dumps({ "action": "submitted",