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
33 changes: 33 additions & 0 deletions src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ...]:
Expand Down Expand Up @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions src/github_agent_bridge/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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 []
Expand Down
73 changes: 73 additions & 0 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
Loading