From 747887182f28ba64dacd86523a178c4844d4a060 Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Tue, 26 May 2026 21:52:05 +0000 Subject: [PATCH 1/3] fix: accept github app bot trigger actors --- src/github_agent_bridge/actors.py | 2 +- tests/test_queue.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/github_agent_bridge/actors.py b/src/github_agent_bridge/actors.py index 4e6a457..fb668ad 100644 --- a/src/github_agent_bridge/actors.py +++ b/src/github_agent_bridge/actors.py @@ -11,7 +11,7 @@ from .models import GitHubContext, Notification -LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") +LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\[bot\])?$") RESERVED_SENDERS = {"github", "notifications"} diff --git a/tests/test_queue.py b/tests/test_queue.py index 1a653fa..b0dbe67 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -76,6 +76,35 @@ def fake_actor(ctx, *, gh_bin="gh"): assert job.trigger_actor_avatar_url == "https://avatars.githubusercontent.com/u/294235?v=4" +def test_enqueue_accepts_github_app_bot_actor_from_context(tmp_path, monkeypatch): + def fake_actor(ctx, *, gh_bin="gh"): + from github_agent_bridge.actors import TriggerActor + + return TriggerActor( + login="copilot-pull-request-reviewer[bot]", + avatar_url="https://avatars.githubusercontent.com/in/946600?v=4", + ) + + monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", fake_actor) + q = JobQueue(tmp_path / "q.sqlite3") + + job, state = q.enqueue( + Notification( + uid=1, + message_id="<1@github.com>", + subject="Re: [gisce/erp] PR", + from_addr="GitHub ", + body="https://github.com/gisce/erp/pull/1#pullrequestreview-99", + auth={"spf": True, "dkim": True, "dmarc": True}, + ), + policy(), + ) + + assert state == "enqueued" + assert job.trigger_actor == "copilot-pull-request-reviewer[bot]" + assert job.trigger_actor_avatar_url == "https://avatars.githubusercontent.com/in/946600?v=4" + + def test_enqueue_falls_back_to_context_actor_for_generic_github_sender(tmp_path, monkeypatch): calls = [] From 9a45ea2834e4598c15d55213b0fca1f9ecde3e75 Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Wed, 27 May 2026 05:27:43 +0000 Subject: [PATCH 2/3] test: cover github app bot actor normalization --- tests/test_actors.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_actors.py b/tests/test_actors.py index 6e207a4..6bd079e 100644 --- a/tests/test_actors.py +++ b/tests/test_actors.py @@ -4,7 +4,13 @@ import sqlite3 import subprocess -from github_agent_bridge.actors import actor_endpoint, backfill_trigger_actors, trigger_actor_from_notification +from github_agent_bridge.actors import ( + actor_details_from_github_payload, + actor_endpoint, + backfill_trigger_actors, + normalize_github_login, + trigger_actor_from_notification, +) from github_agent_bridge.models import GitHubContext, Notification from github_agent_bridge.policy import Policy from github_agent_bridge.queue import JobQueue @@ -23,6 +29,25 @@ def test_trigger_actor_from_notification_uses_github_sender_login(): assert trigger_actor_from_notification(n) == "ecarreras" +def test_normalize_github_login_accepts_github_app_bot_suffix(): + assert normalize_github_login("copilot-pull-request-reviewer[bot]") == "copilot-pull-request-reviewer[bot]" + + +def test_actor_details_from_github_payload_accepts_github_app_bot_login(): + actor = actor_details_from_github_payload( + { + "user": { + "login": "copilot-pull-request-reviewer[bot]", + "avatar_url": "https://avatars.githubusercontent.com/in/946600?v=4", + } + } + ) + + assert actor is not None + assert actor.login == "copilot-pull-request-reviewer[bot]" + assert actor.avatar_url == "https://avatars.githubusercontent.com/in/946600?v=4" + + def test_actor_endpoint_prefers_exact_trigger_resource(): assert actor_endpoint(GitHubContext(urls=[], repo="gisce/erp", issue_number=1, comment_id=99)) == "repos/gisce/erp/issues/comments/99" assert actor_endpoint(GitHubContext(urls=[], repo="gisce/erp", issue_number=1)) == "repos/gisce/erp/issues/1" From ba393caad9d90ace3fee580ba97606cdbda138c2 Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Wed, 27 May 2026 09:46:49 +0000 Subject: [PATCH 3/3] fix: route dashboard login and use configured gh --- src/github_agent_bridge/actors.py | 16 ++++++++++++---- src/github_agent_bridge/backend.py | 19 +++++++++++++++++-- tests/test_actors.py | 7 +++++++ tests/test_backend.py | 18 ++++++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/github_agent_bridge/actors.py b/src/github_agent_bridge/actors.py index fb668ad..94f916e 100644 --- a/src/github_agent_bridge/actors.py +++ b/src/github_agent_bridge/actors.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import sqlite3 import subprocess @@ -15,6 +16,10 @@ RESERVED_SENDERS = {"github", "notifications"} +def default_gh_bin() -> str: + return os.getenv("GITHUB_AGENT_BRIDGE_GH_BIN", "gh") + + @dataclass(frozen=True) class TriggerActor: login: str @@ -43,7 +48,8 @@ def trigger_actor_details_from_notification(notification: Notification) -> Trigg return TriggerActor(login=login, avatar_url=github_avatar_url(login)) if login else None -def trigger_actor_details_for_enqueue(notification: Notification, ctx: GitHubContext, *, gh_bin: str = "gh") -> TriggerActor | None: +def trigger_actor_details_for_enqueue(notification: Notification, ctx: GitHubContext, *, gh_bin: str | None = None) -> TriggerActor | None: + gh_bin = gh_bin or default_gh_bin() return github_actor_details_for_context(ctx, gh_bin=gh_bin) or trigger_actor_details_from_notification(notification) @@ -86,7 +92,8 @@ def actor_endpoint(ctx: GitHubContext) -> str | None: return None -def github_actor_details_for_context(ctx: GitHubContext, *, gh_bin: str = "gh") -> TriggerActor | None: +def github_actor_details_for_context(ctx: GitHubContext, *, gh_bin: str | None = None) -> TriggerActor | None: + gh_bin = gh_bin or default_gh_bin() endpoint = actor_endpoint(ctx) if endpoint is None: return None @@ -103,12 +110,13 @@ def github_actor_details_for_context(ctx: GitHubContext, *, gh_bin: str = "gh") return actor_details_from_github_payload(payload if isinstance(payload, dict) else {}) -def github_actor_for_context(ctx: GitHubContext, *, gh_bin: str = "gh") -> str | None: +def github_actor_for_context(ctx: GitHubContext, *, gh_bin: str | None = None) -> str | None: actor = github_actor_details_for_context(ctx, gh_bin=gh_bin) return actor.login if actor else None -def backfill_trigger_actors(db: str | Path, *, gh_bin: str = "gh", limit: int | None = None, dry_run: bool = False) -> dict[str, Any]: +def backfill_trigger_actors(db: str | Path, *, gh_bin: str | None = None, limit: int | None = None, dry_run: bool = False) -> dict[str, Any]: + gh_bin = gh_bin or default_gh_bin() path = Path(db).expanduser() if not path.exists(): return {"db_exists": False, "checked": 0, "updated": 0, "missing": 0, "dry_run": dry_run} diff --git a/src/github_agent_bridge/backend.py b/src/github_agent_bridge/backend.py index 7c00ecf..be140af 100644 --- a/src/github_agent_bridge/backend.py +++ b/src/github_agent_bridge/backend.py @@ -252,6 +252,15 @@ async def current_profile(request: Request) -> dict[str, Any]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_authorized") return profile + async def require_dashboard_profile_or_login(request: Request) -> RedirectResponse | None: + try: + await current_profile(request) + except HTTPException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED and config.oauth_ready: + return RedirectResponse("/auth/login", status_code=status.HTTP_302_FOUND) + raise + return None + @app.exception_handler(sqlite3.OperationalError) async def database_unavailable(_: Request, exc: sqlite3.OperationalError) -> JSONResponse: return JSONResponse({"error": "database_unavailable", "detail": str(exc)}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE, headers=_redacted_headers()) @@ -275,11 +284,17 @@ def dashboard_index() -> FileResponse: return FileResponse(index, headers=_redacted_headers()) @app.get("/") - def dashboard(_: str = Depends(current_user)) -> FileResponse: + async def dashboard(request: Request) -> Response: + redirect = await require_dashboard_profile_or_login(request) + if redirect is not None: + return redirect return dashboard_index() @app.get("/jobs/{job_path:path}") - def dashboard_job(job_path: str, _: str = Depends(current_user)) -> FileResponse: + async def dashboard_job(job_path: str, request: Request) -> Response: + redirect = await require_dashboard_profile_or_login(request) + if redirect is not None: + return redirect return dashboard_index() @app.get("/api/status") diff --git a/tests/test_actors.py b/tests/test_actors.py index 6bd079e..367f7b8 100644 --- a/tests/test_actors.py +++ b/tests/test_actors.py @@ -8,6 +8,7 @@ actor_details_from_github_payload, actor_endpoint, backfill_trigger_actors, + default_gh_bin, normalize_github_login, trigger_actor_from_notification, ) @@ -33,6 +34,12 @@ def test_normalize_github_login_accepts_github_app_bot_suffix(): assert normalize_github_login("copilot-pull-request-reviewer[bot]") == "copilot-pull-request-reviewer[bot]" +def test_default_gh_bin_uses_service_environment(monkeypatch): + monkeypatch.setenv("GITHUB_AGENT_BRIDGE_GH_BIN", "/opt/bin/gh") + + assert default_gh_bin() == "/opt/bin/gh" + + def test_actor_details_from_github_payload_accepts_github_app_bot_login(): actor = actor_details_from_github_payload( { diff --git a/tests/test_backend.py b/tests/test_backend.py index 423d068..a1c05da 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -111,17 +111,27 @@ def test_dashboard_job_frontend_route_falls_back_for_deep_links(tmp_path): assert "root" in response.text -def test_dashboard_ui_requires_auth_by_default(tmp_path): +def test_dashboard_ui_redirects_to_oauth_login_by_default(tmp_path): db = tmp_path / "bridge.sqlite3" static_dir = tmp_path / "static" static_dir.mkdir() (static_dir / "index.html").write_text("
", encoding="utf-8") JobQueue(db) - app = create_app(DashboardConfig(db=db, static_dir=static_dir, secret_key="secret", allowed_users={"alice"})) + app = create_app( + DashboardConfig( + db=db, + static_dir=static_dir, + secret_key="secret", + oauth_client_id="client", + oauth_client_secret="client-secret", + allowed_users={"alice"}, + ) + ) - response = TestClient(app).get("/") + response = TestClient(app, follow_redirects=False).get("/") - assert response.status_code == 401 + assert response.status_code == 302 + assert response.headers["location"] == "/auth/login" def test_dashboard_ui_reports_missing_build_after_auth(tmp_path):