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
53 changes: 50 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
191 changes: 191 additions & 0 deletions app/services/demo.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 20 additions & 1 deletion app/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@
<h1>Dashboard</h1>
<p class="muted">Signed in as {{ user.login }}. {{ repositories|length }} installed repository(ies).</p>

{% if simulated %}
<section class="card">
<h2>Simulated demo review</h2>
<p>
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.
</p>
<form method="post" action="/demo/review">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Run demo review</button>
</form>
<p class="muted">
Results are labelled as simulated. The review runs synchronously because the
background worker cannot run on a serverless host.
</p>
</section>
{% endif %}

<section class="card">
<h2>Repositories</h2>
{% if repositories %}
Expand Down Expand Up @@ -31,13 +49,14 @@ <h2>Repositories</h2>
<h2>Recent reviews</h2>
{% if reviews %}
<table>
<thead><tr><th>When</th><th>Status</th><th>Mode</th><th>Findings</th><th></th></tr></thead>
<thead><tr><th>When</th><th>Status</th><th>Mode</th><th>Files</th><th>Findings</th><th></th></tr></thead>
<tbody>
{% for review in reviews %}
<tr>
<td>{{ review.created_at.strftime("%Y-%m-%d %H:%M") }} UTC</td>
<td><span class="badge">{{ review.status.value }}</span></td>
<td>{{ "static-only" if review.static_only else "AI-assisted" }}</td>
<td>{{ review.file_count }}</td>
<td>{{ review.finding_count }}</td>
<td><a href="/reviews/{{ review.id }}">Details</a></td>
</tr>
Expand Down
1 change: 1 addition & 0 deletions app/templates/review_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<h1>Review {{ result.id }}</h1>
<p>
<span class="badge">{{ result.status.value }}</span>
{% if simulated %}<span class="badge">simulated</span>{% endif %}
{{ "static-only" if result.static_only else "AI-assisted" }} ·
{{ result.file_count }} file(s) · {{ result.finding_count }} finding(s) ·
{{ result.duration_ms }} ms
Expand Down
34 changes: 34 additions & 0 deletions docs/vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ a live Vercel account.
| Web app, dashboard, health checks | ✅ Works | It is a single FastAPI ASGI app. |
| SQLite schema bootstrap | ✅ Works (ephemeral) | Only inside `/tmp`, per function instance. |
| Reviews being processed | ❌ **Does not work** | The worker is a long-running process; Vercel Functions are request-scoped. |
| **Simulated demo review** (the dashboard button) | ✅ Works | Runs synchronously in the request against synthetic code — see below. |
| Webhook → published review | ❌ **Does not work** | Webhooks enqueue jobs that nothing drains, so no review is ever posted. |
| Persistent review history | ❌ **Does not work** | The filesystem is ephemeral and each instance has its own `/tmp`. |
| GitHub App credentials | ⚠️ Configure only if you mean it | Unconfigured is the safe default and is labelled as such in the UI. |
Expand Down Expand Up @@ -85,6 +86,33 @@ before the first query on a cold instance. It is:

This does **not** provide durable storage. See the `/tmp` section below.

## The simulated demo review

The dashboard shows a **Run demo review** action whenever `GITHUB_MODE=mock`. It:

- seeds, idempotently, a demo user, installation and simulated repository
(`octo/demo`) — only in mock mode, and never in real mode;
- analyses a **deterministic synthetic pull request** with the real static
pipeline (Ruff + Bandit) through the in-memory mock GitHub client;
- **never contacts GitHub, never posts a comment and never calls an AI provider** —
publishing is skipped entirely and the provider is a null provider;
- persists a `review_results` row, its `findings` and a `review_jobs` row, and
records the real outcome (`running → succeeded` / `failed`) — a queued job is
never reported as complete;
- redirects to the review detail page, which shows the status, files reviewed,
finding count and links, labelled **simulated**.

It runs **synchronously** because the worker cannot run here. Each click is a new
review of the same synthetic commit, bounded by the existing server-side quotas
(a `429` is returned when the daily limit is reached). The action is
authenticated, CSRF-protected, and gated on `GITHUB_MODE=mock`: in real mode the
endpoint returns `403` and nothing is seeded.

Findings come from the analyzers, which are runtime dependencies
(`ruff`, `bandit`) so they are installed in the function bundle. If an analyzer
is unavailable, the review still completes and its diagnostics say which one was
skipped — the UI never pretends analysis ran.

## Vercel project settings

When you import the repository:
Expand Down Expand Up @@ -161,6 +189,12 @@ None of these are provided by this change set.
`APP_ENV=production`.
- [ ] Do **not** add any `GITHUB_*` or `AI_*` credentials.
- [ ] Deploy, then check `/healthz` returns `{"status":"ok"}`.
- [ ] Sign in (mock mode) and confirm `/dashboard` shows **Run demo review** and
the seeded `octo/demo` repository.
- [ ] Click **Run demo review**: it should redirect to a review page labelled
**simulated**, with a non-zero finding count and inline-mapped findings.
- [ ] Accept that the button is the *only* way reviews happen here, and that
re-running is bounded by quota.
- [ ] Check `/` renders and shows the "Simulated GitHub mode" banner.
- [ ] Accept that `/dashboard` shows empty states and that **no review will ever
be processed** on this deployment.
Expand Down
Loading
Loading