diff --git a/app/main.py b/app/main.py index 7c19816..79b81c9 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,7 @@ from app.config import Settings from app.config import settings as default_settings -from app.db import ensure_schema, get_session, init_db +from app.db import SessionLocal, ensure_schema, get_session, init_db from app.github.factory import build_github_client, client_is_simulated from app.github.webhook import ( SUPPORTED_EVENTS, @@ -38,7 +38,8 @@ User, WebhookDelivery, ) -from app.services.quotas import QuotaService +from app.services.demo import DemoUnavailable, ensure_demo_seed, run_demo_review +from app.services.quotas import QuotaExceeded, QuotaService logger = logging.getLogger("codeyinkx.web") @@ -69,6 +70,13 @@ async def lifespan(_: FastAPI): # may be served by a process that never ran this startup hook. ensure_schema(enabled=cfg.auto_init_db) logger.warning("AUTO_INIT_DB enabled: created the schema on startup (demo mode)") + if cfg.github_mode == "mock": + # Idempotent: makes the simulated repository visible immediately. + with SessionLocal() as seed_session: + try: + ensure_demo_seed(seed_session, cfg) + except Exception: + logger.exception("demo seed failed; it will retry on first use") yield app = FastAPI( @@ -191,6 +199,10 @@ def landing(request: Request) -> HTMLResponse: @app.get("/dashboard", response_class=HTMLResponse) def dashboard(request: Request, session: Session = Depends(get_session)) -> HTMLResponse: user = _require_user(request, session) + if cfg.github_mode == "mock": + # Seeding is idempotent and gated on AUTO_INIT_DB, so this is three + # reads after the first request. It does not run in real mode. + ensure_demo_seed(session, cfg) repositories = session.scalars(select(Repository).order_by(Repository.full_name)).all() reviews = session.scalars( select(ReviewResult).order_by(ReviewResult.created_at.desc()).limit(20) @@ -210,6 +222,33 @@ def dashboard(request: Request, session: Session = Depends(get_session)) -> HTML ), ) + @app.post("/demo/review") + async def demo_review( + request: Request, csrf_token: str = Form(""), session: Session = Depends(get_session) + ) -> RedirectResponse: + """Run a simulated review. + + Mock mode only. It analyses a synthetic pull request with the real static + pipeline, never contacts GitHub, never posts a comment and never calls an + AI provider. Quotas are enforced by the review service. + """ + _require_user(request, session) + _verify_csrf(request, csrf_token) + if cfg.github_mode != "mock": + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "the demo review is only available when GITHUB_MODE=mock", + ) + try: + result = await run_demo_review(session, cfg) + except DemoUnavailable as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc + except QuotaExceeded as exc: + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, f"{exc}. Try again tomorrow." + ) from exc + return RedirectResponse(url=f"/reviews/{result.id}", status_code=303) + @app.get("/reviews", response_class=HTMLResponse) def reviews( request: Request, page: int = 1, session: Session = Depends(get_session) @@ -379,7 +418,15 @@ def callback( if not code: raise HTTPException(status.HTTP_400_BAD_REQUEST, "missing authorization code") - login_name = "dev-user" if code == "mock" else _exchange_code(cfg, code) + if code == "mock": + # The local sign-in shortcut must never work on a real deployment. + if cfg.github_mode == "real": + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "mock sign-in is disabled in real mode" + ) + login_name = "dev-user" + else: + login_name = _exchange_code(cfg, code) user = session.scalar(select(User).where(User.login == login_name)) if user is None: user = User(login=login_name) diff --git a/app/services/demo.py b/app/services/demo.py new file mode 100644 index 0000000..9781c16 --- /dev/null +++ b/app/services/demo.py @@ -0,0 +1,191 @@ +"""Simulated demo: idempotent seeding and a bounded synchronous review. + +The demo lets a deployment exercise the real analysis pipeline with **no GitHub +App, no credentials and no AI provider**. It is available only when +``GITHUB_MODE=mock`` and ``AUTO_INIT_DB=true``. + +Safety properties: +* the GitHub client is the in-memory mock — no network request is made; +* the AI provider is a null provider — no provider is ever contacted; +* nothing is published: ``publish_review`` is never called, so no comment, + approval, merge or write of any kind reaches GitHub; +* the review is executed synchronously and the job records the real outcome + (running -> succeeded/failed). A queued job is never reported as completed. +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.ai.provider import NullAIProvider +from app.config import Settings +from app.github.mock import MockGitHubClient, synthetic_project +from app.models import Installation, JobStatus, Repository, ReviewJob, ReviewResult, User +from app.services.reviews import analyze, persist_outcome + +logger = logging.getLogger("codeyinkx.demo") + +DEMO_USER_LOGIN = "dev-user" +DEMO_USER_EMAIL = "dev@example.com" +DEMO_INSTALLATION_ID = 1 +DEMO_ACCOUNT_LOGIN = "octo" +DEMO_GITHUB_REPO_ID = 1 +DEMO_REPOSITORY = "octo/demo" +DEMO_PR_NUMBER = 7 +DEMO_HEAD_SHA = "demo-head-0001" +DEMO_BASE_SHA = "demo-base-0001" + +DEMO_SUMMARY_PREFIX = ( + "Simulated demo review — no GitHub calls, no comments posted, no AI provider. " + "It analyses a synthetic pull request that is never sent anywhere.\n\n" +) + + +class DemoUnavailable(Exception): + """The demo is not enabled in this deployment.""" + + +@dataclass +class DemoSeed: + user: User + installation: Installation + repository: Repository + + +def demo_enabled(settings: Settings) -> bool: + """The demo requires simulated GitHub mode *and* schema auto-initialisation.""" + return settings.github_mode == "mock" and settings.auto_init_db + + +def ensure_demo_seed(session: Session, settings: Settings) -> DemoSeed | None: + """Idempotently create the demo user, installation and repository. + + Returns ``None`` when the demo is not enabled — including every deployment + with ``GITHUB_MODE=real``, where nothing is ever seeded. After the first + call this is three reads, so it is safe to call on each request. + """ + if not demo_enabled(settings): + return None + + try: + user = session.scalar(select(User).where(User.login == DEMO_USER_LOGIN)) + if user is None: + user = User(login=DEMO_USER_LOGIN, email=DEMO_USER_EMAIL) + session.add(user) + session.flush() + + installation = session.scalar( + select(Installation).where(Installation.installation_id == DEMO_INSTALLATION_ID) + ) + if installation is None: + installation = Installation( + installation_id=DEMO_INSTALLATION_ID, + account_login=DEMO_ACCOUNT_LOGIN, + account_type="Organization", + ) + session.add(installation) + session.flush() + + repository = session.scalar( + select(Repository).where(Repository.full_name == DEMO_REPOSITORY) + ) + if repository is None: + repository = Repository( + installation_id=installation.id, + github_repo_id=DEMO_GITHUB_REPO_ID, + full_name=DEMO_REPOSITORY, + private=False, + default_branch="main", + ) + session.add(repository) + session.flush() + + session.commit() + except IntegrityError: + # A concurrent request seeded first; re-read instead of duplicating. + session.rollback() + return _read_seed(session) + return DemoSeed(user=user, installation=installation, repository=repository) + + +def _read_seed(session: Session) -> DemoSeed | None: + user = session.scalar(select(User).where(User.login == DEMO_USER_LOGIN)) + installation = session.scalar( + select(Installation).where(Installation.installation_id == DEMO_INSTALLATION_ID) + ) + repository = session.scalar(select(Repository).where(Repository.full_name == DEMO_REPOSITORY)) + if user is None or installation is None or repository is None: + return None + return DemoSeed(user=user, installation=installation, repository=repository) + + +def build_demo_client(repository: Repository) -> MockGitHubClient: + """The in-memory GitHub stand-in for the synthetic demo pull request.""" + return synthetic_project( + full_name=repository.full_name, + number=DEMO_PR_NUMBER, + head_sha=DEMO_HEAD_SHA, + private=False, + ) + + +async def run_demo_review(session: Session, settings: Settings) -> ReviewResult: + """Analyse the synthetic pull request synchronously and persist the result. + + Reuses the real pipeline (``analyze`` + ``persist_outcome``) but deliberately + skips publishing and AI. Raises :class:`DemoUnavailable` when the demo is not + enabled and :class:`~app.services.quotas.QuotaExceeded` when the caller is + over quota; in both cases nothing is presented as a completed review. + """ + if settings.github_mode != "mock": + raise DemoUnavailable("the demo review is only available when GITHUB_MODE=mock") + + seed = ensure_demo_seed(session, settings) + if seed is None or seed.repository is None: + raise DemoUnavailable("the demo review requires AUTO_INIT_DB=true in mock mode") + + repository = seed.repository + # A demo run is a new review of the same synthetic commit, so it gets its own + # job. This is not a queued webhook job: it runs now, and records the real + # outcome. + job = ReviewJob( + dedup_key=f"demo:{uuid.uuid4().hex}", + repository_id=repository.id, + pr_number=DEMO_PR_NUMBER, + head_sha=DEMO_HEAD_SHA, + base_sha=DEMO_BASE_SHA, + status=JobStatus.RUNNING, + attempts=1, + max_attempts=1, + ) + session.add(job) + session.flush() + + client = build_demo_client(repository) + try: + outcome = await analyze( + session, + settings, + client, + repository, + DEMO_PR_NUMBER, + ai_provider=NullAIProvider("the simulated demo never calls an AI provider"), + ) + except Exception as exc: + job.status = JobStatus.FAILED + job.last_error = f"{type(exc).__name__}: {exc}"[:2000] + session.commit() + raise + + outcome.result.summary = DEMO_SUMMARY_PREFIX + outcome.result.summary + result = persist_outcome(session, outcome, job) + job.status = JobStatus.SUCCEEDED + job.lease_expires_at = None + session.commit() + return result diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 86e2229..3f8c2ba 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -4,6 +4,24 @@
Signed in as {{ user.login }}. {{ repositories|length }} installed repository(ies).
+{% if simulated %} ++ Runs the real static-analysis pipeline against a synthetic pull request. It never + contacts GitHub, never posts a comment and never calls an AI provider. +
+ ++ Results are labelled as simulated. The review runs synchronously because the + background worker cannot run on a serverless host. +
+| When | Status | Mode | Findings | ||
|---|---|---|---|---|---|
| When | Status | Mode | Files | Findings | |
| {{ review.created_at.strftime("%Y-%m-%d %H:%M") }} UTC | {{ review.status.value }} | {{ "static-only" if review.static_only else "AI-assisted" }} | +{{ review.file_count }} | {{ review.finding_count }} | Details |