diff --git a/backend/app/api/github_app.py b/backend/app/api/github_app.py new file mode 100644 index 0000000..119ede5 --- /dev/null +++ b/backend/app/api/github_app.py @@ -0,0 +1,356 @@ +"""GitHub App one-click install flow. + +Endpoints +--------- +GET /github/app/install — redirect to GitHub App authorization page +GET /github/app/callback — OAuth callback; stores installation record +GET /github/app/installations — list active installations (JSON) +POST /github/app/webhook — receive GitHub App webhook events +GET / — landing page with install button + +JWT authentication for GitHub App API calls uses RS256 signed tokens. +PyJWT is preferred when available; falls back to raw cryptography primitives. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from datetime import UTC, datetime +from typing import Any + +import structlog +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.config import settings + +logger = structlog.get_logger() + +github_app_router = APIRouter() + +# ── In-memory installation store (keyed by installation_id) ───────────────── +# Production deployments should swap this for a Redis-backed store. + +_installations: dict[int, dict[str, Any]] = {} + + +# ── JWT helpers ────────────────────────────────────────────────────────────── + + +def _make_jwt(app_id: str, private_key_pem: str) -> str: + """Return a signed RS256 JWT suitable for GitHub App API calls. + + The token is valid for 60 seconds — well within GitHub's 10-minute limit. + PyJWT is used when available; otherwise falls back to the ``cryptography`` + package's hazmat primitives. + """ + now = int(time.time()) + payload = { + "iat": now - 60, # issued 60 s in the past to allow clock skew + "exp": now + 60, + "iss": app_id, + } + + # Normalise escaped newlines that may appear when the key is stored as an + # environment variable (e.g. "-----BEGIN RSA PRIVATE KEY-----\nMII...") + pem = private_key_pem.replace("\\n", "\n") + + try: + import jwt # PyJWT + + return jwt.encode(payload, pem, algorithm="RS256") # type: ignore[no-any-return] + except ImportError: + pass + + # Fallback: build the JWT manually using cryptography.hazmat + import base64 + + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + body = _b64url(json.dumps(payload).encode()) + message = f"{header}.{body}".encode() + + private_key = serialization.load_pem_private_key(pem.encode(), password=None) + signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256()) # type: ignore[call-arg] + return f"{header}.{body}.{_b64url(signature)}" + + +# ── Graceful guard ─────────────────────────────────────────────────────────── + + +def _require_app_configured() -> None: + """Raise HTTP 503 with setup instructions when app credentials are absent.""" + if not settings.github_app_id: + raise HTTPException( + status_code=503, + detail=( + "GitHub App not configured. Set the following environment variables: " + "ODIN_GITHUB_APP_ID, ODIN_GITHUB_APP_CLIENT_ID, " + "ODIN_GITHUB_APP_CLIENT_SECRET, ODIN_GITHUB_APP_PRIVATE_KEY, " + "ODIN_GITHUB_APP_WEBHOOK_SECRET" + ), + ) + + +# ── Landing page ───────────────────────────────────────────────────────────── + + +@github_app_router.get("/", response_class=HTMLResponse, include_in_schema=False) +async def landing_page() -> HTMLResponse: + """Self-hoster landing page with a one-click GitHub App install button.""" + app_configured = bool(settings.github_app_id and settings.github_app_client_id) + if app_configured: + install_url = f"https://github.com/apps/{settings.github_app_client_id}/installations/new" + button_html = f'Install Odin on GitHub' + else: + button_html = ( + '

GitHub App is not yet configured on this instance. ' + "Set ODIN_GITHUB_APP_ID and related env vars.

" + ) + + html = f""" + + + + + Odin — AI Code Review + + + +

Odin

+

AI-powered code review that lives in your GitHub workflow. + Install the GitHub App to get automatic PR reviews — no manual webhook + setup required.

+ {button_html} + +""" + return HTMLResponse(content=html) + + +# ── Install redirect ───────────────────────────────────────────────────────── + + +@github_app_router.get("/app/install") +async def github_app_install() -> RedirectResponse: + """Redirect the user to the GitHub App installation page.""" + _require_app_configured() + install_url = f"https://github.com/apps/{settings.github_app_client_id}/installations/new" + logger.info("redirecting to github app install", url=install_url) + return RedirectResponse(url=install_url, status_code=302) + + +# ── OAuth callback ─────────────────────────────────────────────────────────── + + +@github_app_router.get("/app/callback", response_class=HTMLResponse) +async def github_app_callback( + installation_id: int | None = None, + setup_action: str | None = None, + code: str | None = None, +) -> HTMLResponse: + """Handle the post-install callback from GitHub. + + GitHub redirects here with ``?installation_id=&setup_action=install`` + after the user grants access. We store the installation and show a + confirmation page. + """ + _require_app_configured() + + if installation_id is None: + raise HTTPException(status_code=400, detail="Missing installation_id parameter") + + # Fetch installation details from GitHub to get the account info + account_login = "unknown" + account_type = "unknown" + repos: list[str] = [] + + try: + import httpx + + token = _make_jwt(settings.github_app_id, settings.github_app_private_key) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://api.github.com/app/installations/{installation_id}", + headers=headers, + ) + if resp.status_code == 200: + data = resp.json() + account = data.get("account", {}) + account_login = account.get("login", "unknown") + account_type = account.get("type", "unknown") + except Exception as exc: + logger.warning("could not fetch installation details", error=str(exc)) + + record = { + "installation_id": installation_id, + "account_login": account_login, + "account_type": account_type, + "repos": repos, + "setup_action": setup_action or "install", + "installed_at": datetime.now(UTC).isoformat(), + } + _installations[installation_id] = record + + logger.info( + "github app installed", + installation_id=installation_id, + account=account_login, + action=setup_action, + ) + + html = f""" + + + + Odin — Installation successful + + + +

Odin installed successfully

+

The Odin GitHub App has been installed on {account_login}.

+

Odin will now automatically review pull requests. No further configuration needed.

+

Installation ID: {installation_id}

+ +""" + return HTMLResponse(content=html, status_code=200) + + +# ── Installations list ─────────────────────────────────────────────────────── + + +@github_app_router.get("/app/installations") +async def list_installations() -> JSONResponse: + """Return all active GitHub App installations as JSON.""" + _require_app_configured() + return JSONResponse(content={"installations": list(_installations.values())}) + + +# ── App webhook ────────────────────────────────────────────────────────────── + + +def _verify_app_webhook_signature(payload: bytes, sig_header: str | None) -> bool: + """Verify the X-Hub-Signature-256 header using the app webhook secret.""" + secret = settings.github_app_webhook_secret + if not secret or not sig_header or not sig_header.startswith("sha256="): + return False + expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, sig_header) + + +async def _handle_installation_event(payload: dict[str, Any]) -> None: + """Process installation created/deleted events.""" + action = payload.get("action", "") + installation = payload.get("installation", {}) + installation_id: int = installation.get("id", 0) + account = installation.get("account", {}) + account_login: str = account.get("login", "unknown") + account_type: str = account.get("type", "unknown") + + if action == "created": + repos = [r.get("full_name", "") for r in payload.get("repositories", [])] + _installations[installation_id] = { + "installation_id": installation_id, + "account_login": account_login, + "account_type": account_type, + "repos": repos, + "setup_action": "install", + "installed_at": datetime.now(UTC).isoformat(), + } + logger.info( + "app installation created", installation_id=installation_id, account=account_login + ) + + elif action in {"deleted", "suspend"}: + _installations.pop(installation_id, None) + logger.info("app installation removed", installation_id=installation_id, action=action) + + +async def _handle_app_pull_request(payload: dict[str, Any]) -> None: + """Forward App pull_request events into the existing review pipeline.""" + from app.services.webhook_processor import process_pr_webhook + + action = payload.get("action", "") + if action not in {"opened", "synchronize", "reopened"}: + return + + pr = payload.get("pull_request", {}) + repo_full = payload.get("repository", {}).get("full_name", "") + if not repo_full: + return + + owner, _, repo = repo_full.partition("/") + pull_number: int = pr.get("number", 0) + head_sha: str = pr.get("head", {}).get("sha", "") + + if not (owner and repo and pull_number and head_sha): + return + + logger.info( + "app webhook: dispatching pr review", + repo=repo_full, + pr=pull_number, + sha=head_sha[:8], + ) + await process_pr_webhook(owner=owner, repo=repo, pull_number=pull_number, head_sha=head_sha) + + +@github_app_router.post("/app/webhook") +async def github_app_webhook( + request: Request, + background_tasks: BackgroundTasks, +) -> dict[str, str]: + """Receive and dispatch GitHub App webhook events. + + Handles: installation, pull_request, push. + Signature is verified with ODIN_GITHUB_APP_WEBHOOK_SECRET. + """ + payload_bytes = await request.body() + sig_header = request.headers.get("X-Hub-Signature-256") + + if not _verify_app_webhook_signature(payload_bytes, sig_header): + logger.warning("app webhook signature verification failed") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + event_type = request.headers.get("X-GitHub-Event", "") + payload: dict[str, Any] = json.loads(payload_bytes) + + if event_type == "installation": + background_tasks.add_task(_handle_installation_event, payload) + return {"status": "accepted", "event": "installation"} + + if event_type == "pull_request": + background_tasks.add_task(_handle_app_pull_request, payload) + return {"status": "accepted", "event": "pull_request"} + + logger.debug("app webhook: ignored event", event_name=event_type) + return {"status": "ignored", "reason": f"event '{event_type}' not handled"} diff --git a/backend/app/config.py b/backend/app/config.py index a685dfe..b0c1fbc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,6 +28,13 @@ class Settings(BaseSettings): github_webhook_secret: str = "" webhook_max_file_bytes: int = 100_000 + # GitHub App integration (one-click install flow) + github_app_id: str = "" + github_app_client_id: str = "" + github_app_client_secret: str = "" + github_app_private_key: str = "" # PEM content, newlines as \n + github_app_webhook_secret: str = "" + # MCP server mcp_enabled: bool = True diff --git a/backend/app/main.py b/backend/app/main.py index 770888e..1222573 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ import app.graph_rag._store_ref as _store_ref import app.services._feedback_ref as _feedback_ref +from app.api.github_app import github_app_router from app.api.routes import router from app.api.webhook import webhook_router from app.config import settings @@ -116,6 +117,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp app.include_router(router, prefix="/api") app.include_router(webhook_router, prefix="/api") +app.include_router(github_app_router, prefix="/api/github") # Expose Prometheus metrics app.add_route("/metrics", metrics_endpoint) # type: ignore[arg-type] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6d9234c..38cb999 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -65,6 +65,7 @@ exclude = ["tests/fixtures/*"] "app/graph_rag/store.py" = ["E501"] "tests/*.py" = ["E501"] "app/rules/builtin/*.py" = ["E501"] +"app/api/github_app.py" = ["E501"] "app/dataflow/registry.py" = ["E501"] "app/dataflow/tracker.py" = ["E501"] "app/agents/graph.py" = ["E501"] diff --git a/backend/tests/test_github_app.py b/backend/tests/test_github_app.py new file mode 100644 index 0000000..13ca54c --- /dev/null +++ b/backend/tests/test_github_app.py @@ -0,0 +1,259 @@ +"""Tests for the GitHub App one-click install flow.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.api.github_app import _installations + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _app_sig(payload: bytes, secret: str = "test-app-secret") -> str: + return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +def _patch_app_configured( + app_id: str = "12345", + client_id: str = "Iv1.abc123", + client_secret: str = "secret", + private_key: str = "", + webhook_secret: str = "test-app-secret", +): + """Context manager that patches all GitHub App settings fields.""" + return patch.multiple( + "app.api.github_app.settings", + github_app_id=app_id, + github_app_client_id=client_id, + github_app_client_secret=client_secret, + github_app_private_key=private_key, + github_app_webhook_secret=webhook_secret, + ) + + +# ── Install redirect ───────────────────────────────────────────────────────── + + +def test_install_redirect_returns_302_when_configured(client: TestClient) -> None: + """GET /api/github/app/install should redirect when GitHub App is configured.""" + with _patch_app_configured(): + response = client.get("/api/github/app/install", follow_redirects=False) + assert response.status_code == 302 + assert "github.com/apps/" in response.headers["location"] + + +def test_install_redirect_returns_503_when_not_configured(client: TestClient) -> None: + """GET /api/github/app/install returns 503 when ODIN_GITHUB_APP_ID is unset.""" + with patch("app.api.github_app.settings.github_app_id", ""): + response = client.get("/api/github/app/install") + assert response.status_code == 503 + body = response.json() + assert "ODIN_GITHUB_APP_ID" in body["detail"] + + +def test_install_redirect_url_contains_client_id(client: TestClient) -> None: + """The redirect URL should embed the app's client_id slug.""" + with _patch_app_configured(client_id="Iv1.myspecialapp"): + response = client.get("/api/github/app/install", follow_redirects=False) + assert "Iv1.myspecialapp" in response.headers["location"] + + +# ── OAuth callback ─────────────────────────────────────────────────────────── + + +def test_callback_stores_installation_and_returns_200(client: TestClient) -> None: + """GET /api/github/app/callback should store the installation and return 200.""" + _installations.clear() + + import httpx + import respx + + with ( + _patch_app_configured(), + patch("app.api.github_app._make_jwt", return_value="fake-jwt"), + respx.mock(base_url="https://api.github.com") as mock_api, + ): + mock_api.get("/app/installations/99").mock( + return_value=httpx.Response( + 200, + json={"account": {"login": "octocat", "type": "User"}}, + ) + ) + response = client.get( + "/api/github/app/callback", + params={"installation_id": 99, "setup_action": "install"}, + ) + + assert response.status_code == 200 + assert "octocat" in response.text or "99" in response.text + + +def test_callback_missing_installation_id_returns_400(client: TestClient) -> None: + """Callback without installation_id should return 400.""" + with _patch_app_configured(): + response = client.get("/api/github/app/callback") + assert response.status_code == 400 + + +def test_callback_stores_record_in_memory(client: TestClient) -> None: + """After a successful callback the record should appear in _installations.""" + _installations.clear() + + # Patch at module level so the async context manager resolves correctly + import httpx + import respx + + with ( + _patch_app_configured(), + patch("app.api.github_app._make_jwt", return_value="fake-jwt"), + respx.mock(base_url="https://api.github.com") as mock_api, + ): + mock_api.get("/app/installations/42").mock( + return_value=httpx.Response( + 200, + json={"account": {"login": "myorg", "type": "Organization"}}, + ) + ) + client.get( + "/api/github/app/callback", + params={"installation_id": 42, "setup_action": "install"}, + ) + + assert 42 in _installations + record = _installations[42] + assert record["installation_id"] == 42 + assert record["account_login"] == "myorg" + + +# ── Installations list ─────────────────────────────────────────────────────── + + +def test_installations_list_returns_json(client: TestClient) -> None: + """GET /api/github/app/installations should return JSON with installations key.""" + _installations.clear() + _installations[1] = { + "installation_id": 1, + "account_login": "alice", + "account_type": "User", + "repos": ["alice/repo"], + "setup_action": "install", + "installed_at": "2026-04-05T12:00:00+00:00", + } + + with _patch_app_configured(): + response = client.get("/api/github/app/installations") + + assert response.status_code == 200 + data = response.json() + assert "installations" in data + assert len(data["installations"]) == 1 + assert data["installations"][0]["account_login"] == "alice" + + +def test_installations_list_returns_503_when_not_configured(client: TestClient) -> None: + """Installations list returns 503 when the app is not configured.""" + with patch("app.api.github_app.settings.github_app_id", ""): + response = client.get("/api/github/app/installations") + assert response.status_code == 503 + + +# ── App webhook ────────────────────────────────────────────────────────────── + + +def test_app_webhook_rejects_bad_signature(client: TestClient) -> None: + """App webhook endpoint rejects requests with an invalid signature.""" + payload = json.dumps({"action": "created"}).encode() + with _patch_app_configured(): + response = client.post( + "/api/github/app/webhook", + content=payload, + headers={ + "X-GitHub-Event": "installation", + "X-Hub-Signature-256": "sha256=badsig", + "Content-Type": "application/json", + }, + ) + assert response.status_code == 401 + + +def test_app_webhook_accepts_valid_installation_event(client: TestClient) -> None: + """App webhook accepts a correctly signed installation event.""" + _installations.clear() + payload = json.dumps( + { + "action": "created", + "installation": { + "id": 77, + "account": {"login": "testorg", "type": "Organization"}, + }, + "repositories": [], + } + ).encode() + sig = _app_sig(payload) + + with _patch_app_configured(): + response = client.post( + "/api/github/app/webhook", + content=payload, + headers={ + "X-GitHub-Event": "installation", + "X-Hub-Signature-256": sig, + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "accepted" + + +def test_app_webhook_ignores_unknown_events(client: TestClient) -> None: + """Unknown events are gracefully ignored (not rejected).""" + payload = json.dumps({"action": "labeled"}).encode() + sig = _app_sig(payload) + + with _patch_app_configured(): + response = client.post( + "/api/github/app/webhook", + content=payload, + headers={ + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": sig, + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + +# ── Landing page ───────────────────────────────────────────────────────────── + + +def test_landing_page_returns_html(client: TestClient) -> None: + """GET /api/github/ returns the HTML landing page.""" + response = client.get("/api/github/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Odin" in response.text + + +def test_landing_page_shows_install_button_when_configured(client: TestClient) -> None: + """Landing page contains the install link when the app is configured.""" + with _patch_app_configured(client_id="Iv1.myapp"): + response = client.get("/api/github/") + assert "Install Odin on GitHub" in response.text + assert "Iv1.myapp" in response.text + + +def test_landing_page_shows_warning_when_not_configured(client: TestClient) -> None: + """Landing page shows a configuration warning when app_id is absent.""" + with patch.multiple( + "app.api.github_app.settings", + github_app_id="", + github_app_client_id="", + ): + response = client.get("/api/github/") + assert "not yet configured" in response.text