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