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 @@

Dashboard

Signed in as {{ user.login }}. {{ repositories|length }} installed repository(ies).

+{% if simulated %} +
+

Simulated demo review

+

+ 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. +

+
+{% endif %} +

Repositories

{% if repositories %} @@ -31,13 +49,14 @@

Repositories

Recent reviews

{% if reviews %} - + {% for review in reviews %} + diff --git a/app/templates/review_detail.html b/app/templates/review_detail.html index 14ac957..6d3b9ea 100644 --- a/app/templates/review_detail.html +++ b/app/templates/review_detail.html @@ -4,6 +4,7 @@

Review {{ result.id }}

{{ result.status.value }} + {% if simulated %}simulated{% endif %} {{ "static-only" if result.static_only else "AI-assisted" }} · {{ result.file_count }} file(s) · {{ result.finding_count }} finding(s) · {{ result.duration_ms }} ms diff --git a/docs/vercel.md b/docs/vercel.md index c7ceca0..24465a5 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -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. | @@ -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: @@ -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. diff --git a/pyproject.toml b/pyproject.toml index aaa9813..59236fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,11 @@ dependencies = [ "itsdangerous>=2.2", "PyJWT>=2.8", "cryptography>=42", + # The review pipeline executes these trusted analyzer binaries at runtime (in + # the worker, and synchronously for the serverless demo), so they are runtime + # dependencies, not dev-only ones. + "ruff>=0.6", + "bandit>=1.7", ] [project.optional-dependencies] @@ -25,8 +30,6 @@ dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21", - "ruff>=0.6", - "bandit>=1.7", ] [project.scripts] diff --git a/tests/test_demo_review.py b/tests/test_demo_review.py new file mode 100644 index 0000000..2ed12f3 --- /dev/null +++ b/tests/test_demo_review.py @@ -0,0 +1,249 @@ +"""Simulated demo: seeding, the synchronous review, quotas and gating.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +from app.config import Settings +from app.db import SessionLocal +from app.github.mock import MockGitHubClient +from app.main import create_app +from app.models import ( + Finding, + FindingSource, + Installation, + JobStatus, + PublishedComment, + Repository, + ReviewJob, + ReviewResult, + User, +) +from app.services import demo as demo_service +from app.services.demo import ( + DEMO_HEAD_SHA, + DEMO_PR_NUMBER, + DEMO_REPOSITORY, + DemoUnavailable, + ensure_demo_seed, + run_demo_review, +) +from app.services.quotas import QuotaExceeded + + +@pytest.fixture() +def demo_settings() -> Settings: + return Settings(github_mode="mock", auto_init_db=True, secret_key="test-secret") + + +@pytest.fixture() +def demo_client(demo_settings): + app = create_app(demo_settings) + with TestClient(app) as client: + yield client + + +def _counts(session) -> tuple[int, int, int]: + return ( + session.scalar(select(func.count()).select_from(User)), + session.scalar(select(func.count()).select_from(Installation)), + session.scalar(select(func.count()).select_from(Repository)), + ) + + +def _csrf(html: str) -> str: + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match, "no CSRF token in page" + return match.group(1) + + +def _login(client) -> None: + response = client.get("/auth/github/login", follow_redirects=True) + assert response.status_code == 200 + + +# --------------------------------------------------------------------- seeding +def test_seed_creates_demo_records(session, demo_settings): + seed = ensure_demo_seed(session, demo_settings) + + assert seed is not None + assert seed.user.login == "dev-user" + assert seed.repository.full_name == DEMO_REPOSITORY + assert _counts(session) == (1, 1, 1) + + +def test_seed_is_idempotent_across_calls_and_sessions(session, demo_settings): + ensure_demo_seed(session, demo_settings) + + fresh = SessionLocal() + try: + again = ensure_demo_seed(fresh, demo_settings) + assert again is not None + assert _counts(fresh) == (1, 1, 1) + finally: + fresh.close() + + +def test_seed_does_nothing_in_real_mode(session): + real = Settings(github_mode="real", auto_init_db=True, secret_key="test-secret") + + assert ensure_demo_seed(session, real) is None + assert _counts(session) == (0, 0, 0) + + +def test_seed_requires_auto_init_db(session): + no_init = Settings(github_mode="mock", auto_init_db=False, secret_key="test-secret") + + assert ensure_demo_seed(session, no_init) is None + assert _counts(session) == (0, 0, 0) + + +# ---------------------------------------------------------------- demo review +def test_demo_review_persists_result_findings_and_job(session, demo_settings): + result = asyncio.run(run_demo_review(session, demo_settings)) + + assert isinstance(result, ReviewResult) + assert result.status.value in {"completed", "partial"} + assert result.static_only is True + assert result.ai_used is False + assert result.summary.startswith("Simulated demo review") + assert result.file_count >= 1 + + job = session.get(ReviewJob, result.job_id) + assert job is not None + assert job.status is JobStatus.SUCCEEDED + assert job.dedup_key.startswith("demo:") + assert job.pr_number == DEMO_PR_NUMBER + assert job.head_sha == DEMO_HEAD_SHA + + findings = session.scalars(select(Finding).where(Finding.review_result_id == result.id)).all() + assert findings, "the synthetic sample should produce static findings" + assert result.finding_count == len(findings) + assert all(f.source is FindingSource.STATIC for f in findings) + assert any(f.rule_id.startswith(("ruff/", "bandit/")) for f in findings) + # At least one finding is mapped onto a changed line of the synthetic diff. + assert any(f.line is not None and f.side == "RIGHT" for f in findings) + # Nothing was published to GitHub. + assert session.scalar(select(func.count()).select_from(PublishedComment)) == 0 + + +def test_demo_review_uses_the_mock_client_and_never_a_real_one(session, demo_settings, monkeypatch): + seen: list[object] = [] + + def _spy(repository): + client = _original(repository) + seen.append(client) + return client + + _original = demo_service.build_demo_client + monkeypatch.setattr(demo_service, "build_demo_client", _spy) + + class _Explode: + def __init__(self, *args, **kwargs): # pragma: no cover - must never run + raise AssertionError("the demo must not build a real GitHub client") + + monkeypatch.setattr("app.github.client.RealGitHubClient", _Explode) + + result = asyncio.run(run_demo_review(session, demo_settings)) + + assert result.status.value in {"completed", "partial"} + assert seen and all(isinstance(client, MockGitHubClient) for client in seen) + + +def test_demo_review_is_rejected_in_real_mode(session): + real = Settings(github_mode="real", auto_init_db=True, secret_key="test-secret") + + with pytest.raises(DemoUnavailable): + asyncio.run(run_demo_review(session, real)) + assert _counts(session) == (0, 0, 0) + + +def test_demo_review_enforces_the_daily_quota(session, demo_settings): + demo_settings.quota_reviews_per_user_per_day = 1 + + asyncio.run(run_demo_review(session, demo_settings)) + + with pytest.raises(QuotaExceeded): + asyncio.run(run_demo_review(session, demo_settings)) + + +def test_repeated_demo_runs_create_distinct_reviews(session, demo_settings): + first = asyncio.run(run_demo_review(session, demo_settings)) + second = asyncio.run(run_demo_review(session, demo_settings)) + + assert first.id != second.id + assert session.scalar(select(func.count()).select_from(ReviewResult)) == 2 + assert session.scalar(select(func.count()).select_from(ReviewJob)) == 2 + + +# -------------------------------------------------------------------- web flow +def test_demo_route_requires_authentication(session, demo_client): + response = demo_client.post("/demo/review", data={"csrf_token": "x"}) + assert response.status_code == 401 + + +def test_demo_route_requires_a_csrf_token(session, demo_client): + _login(demo_client) + response = demo_client.post("/demo/review", data={}) + assert response.status_code == 403 + + +def test_demo_route_runs_a_review_and_redirects_to_details(session, demo_client): + _login(demo_client) + token = _csrf(demo_client.get("/dashboard").text) + + response = demo_client.post("/demo/review", data={"csrf_token": token}, follow_redirects=False) + + assert response.status_code == 303 + assert re.match(r"^/reviews/\d+$", response.headers["location"]) + detail = demo_client.get(response.headers["location"]) + assert detail.status_code == 200 + assert "Simulated demo review" in detail.text + assert "simulated" in detail.text + + session.expire_all() + result = session.scalars(select(ReviewResult)).one() + assert result.finding_count >= 1 + assert session.scalar(select(func.count()).select_from(ReviewJob)) == 1 + + +def test_demo_route_is_rejected_in_real_mode(monkeypatch): + real = Settings(github_mode="real", auto_init_db=False, secret_key="test-secret") + client = TestClient(create_app(real)) + # Bypass session auth/CSRF to prove the mode gate itself rejects the request. + monkeypatch.setattr("app.main._require_user", lambda request, session: object()) + monkeypatch.setattr("app.main._verify_csrf", lambda request, token: None) + + response = client.post("/demo/review", data={"csrf_token": "x"}) + + assert response.status_code == 403 + assert "GITHUB_MODE=mock" in response.text + client.close() + + +def test_demo_route_reports_quota_exhaustion(session, demo_client): + _login(demo_client) + token = _csrf(demo_client.get("/dashboard").text) + + cfg = demo_client.app.state.settings + original = cfg.quota_reviews_per_user_per_day + cfg.quota_reviews_per_user_per_day = 0 + try: + response = demo_client.post( + "/demo/review", data={"csrf_token": token}, follow_redirects=False + ) + finally: + cfg.quota_reviews_per_user_per_day = original + + assert response.status_code == 429 + assert "quota" in response.text.lower() + + +def test_dashboard_shows_the_demo_action_in_mock_mode(session, demo_client): + _login(demo_client) + assert "Run demo review" in demo_client.get("/dashboard").text diff --git a/tests/test_web.py b/tests/test_web.py index 77097d9..a9a493f 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -59,6 +59,28 @@ def test_oauth_callback_requires_a_code(web_client): assert response.status_code == 400 +def test_mock_sign_in_is_rejected_in_real_mode(): + """`?code=mock` must not authenticate anyone on a real deployment.""" + from urllib.parse import parse_qs, urlparse + + from fastapi.testclient import TestClient + + from app.config import Settings + from app.main import create_app + + client = TestClient(create_app(Settings(github_mode="real", secret_key="test-secret"))) + try: + start = client.get("/auth/github/login", follow_redirects=False) + assert start.status_code == 303 + state = parse_qs(urlparse(start.headers["location"]).query)["state"][0] + + response = client.get(f"/auth/github/callback?code=mock&state={state}") + + assert response.status_code == 400 + finally: + client.close() + + def test_state_changing_actions_require_csrf(web_client, session, repository): web_client.get("/auth/github/login", follow_redirects=True) response = web_client.post(

WhenStatusModeFindings
WhenStatusModeFilesFindings
{{ 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