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
18 changes: 13 additions & 5 deletions src/github_agent_bridge/actors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os
import re
import sqlite3
import subprocess
Expand All @@ -11,10 +12,14 @@

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\])?$")
Comment thread
giscebot marked this conversation as resolved.
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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
19 changes: 17 additions & 2 deletions src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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")
Expand Down
34 changes: 33 additions & 1 deletion tests/test_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
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,
default_gh_bin,
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
Expand All @@ -23,6 +30,31 @@ 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_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(
{
"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"
Expand Down
18 changes: 14 additions & 4 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!doctype html><div id=\"root\"></div>", 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):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <notifications@github.com>",
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 = []

Expand Down
Loading