granthi-review is a REQUIRED status check on main, and Gitea counts a required context as satisfied only on 'success'. failOpen posted 'warning' with the description 'review unavailable - not blocking', so it did precisely what it promised not to do: it blocked, while announcing it would not. Measured: shreai PR #209 sat unmergeable for six days on a claude-cli failure from 2026-08-23, every other gate green. The failure is also non-deterministic. The same commit a49573f66 produced 'Grade A (86/100) - pass' on one review and a fail-open minutes later with no code change. A state that flips on identical input is infrastructure noise, not a review signal, and must not decide whether code can merge. The gate is not weakened: reviews that actually run still gate, and a real 'fail' verdict still fails. Only 'could not review' now passes, which is what fail-open has always meant. The reason still rides in the description and the error is still persisted to the reviews table.
granthi-review
Estate-wide AI code review for Granthi (Gitea) forges. One central webhook-driven service — every repo gets reviews with zero per-repo CI setup. Powered by shre-router.
Architecture
Gitea (peer :3030) ──webhook (push, pull_request, HMAC-signed)──▶ granthi-review (:5498)
│
per-repo serialization queue, global LLM concurrency 1
│
diff fetch (PR .diff / per-commit .diff) + CLAUDE.md/CONTRIBUTING.md head
│
shre-router POST /v1/chat (agentId: code-reviewer)
│
strict-JSON rubric → 8 axes + findings + verdict
▼
commit status 'granthi-review' + PR comment (scorecard) + sqlite ledger
▼
GET /review/{owner}/{repo} (history dashboard)
nightly per-agent digests → ~/.granthi-review/digests/
Components (all in src/):
server.js— HTTP server:POST /webhook,GET /health,GET /review/:owner/:repo;--digestCLI mode.lib/webhook.js— HMAC verification (X-Gitea-Signature, sha256 over raw body) + event → job mapping. Handlespushandpull_request(opened / synchronize / reopened).lib/queue.js— per-repo serialization; global mutex around LLM calls (concurrency 1).lib/review.js— the pipeline. Diff capped at 60KB (truncation logged and disclosed to the model and in the comment).lib/prompt.js— rubric prompt requiring strict JSON: 8 axes (correctness, security, tests_coverage, design_simplicity, performance, style, breaking_changes, docs), each{score 0-100, rationale}, plusoverall,grade A–F,findings[],verdict.lib/jsonExtract.js— tolerant JSON extractor (fences, prose,<think>blocks, trailing commas) + one retry on parse failure.lib/verdict.js— service-side verdict derivation is authoritative:failiff a finding has severity ∈ {critical, high} AND confidence = confirmed in axes correctness/security (untagged confirmed criticals also block, conservatively).lib/trailers.js—Co-Authored-Bytrailer parser for agent attribution.lib/db.js— sqlite ledger (node:sqlite, zero deps): repo, sha, pr, pusher, authors, trailers, axes, grade, findings, timings.lib/digest.js— nightly per-agent digests (score trend, recurring finding categories, last N reviews).
Dependencies: none. Node ≥ 22.13 (uses node:sqlite, global fetch).
Review posture (approved)
BLOCK on confirmed-critical, with human override.
- Commit status context:
granthi-review. verdict pass →success; warn →successwith warning description; fail →failure. - Branch protection on pilot repos requires the
granthi-reviewcontext but does not apply to admins — an admin merge is the override. The blocking comment says so explicitly. - Fail-open with visibility: if the router/LLM is down or output is unparseable after retry, the service posts status
warningwith "review unavailable" — it never blocks silently and never fakes a pass. - Bare pushes (no PR): commit status + ledger + dashboard only. Gitea 1.27 has no commit-comment API endpoint, so no comment is posted for pushes.
Config
~/.granthi-review/config.json (chmod 600):
{
"port": 5498,
"forgeBaseUrl": "http://localhost:3030",
"botToken": "<shre-reviewer API token: write:repository,write:issue>",
"webhookSecret": "<shared HMAC secret>",
"routerUrl": "http://localhost:5497",
"model": "anthropic/claude-sonnet-4-6",
"tenantId": "nirlab",
"publicBaseUrl": "http://localhost:5498",
"admin_token": "<admin-scoped token (user nirpa) — used ONLY to self-heal bot access>",
"botUsername": "shre-reviewer"
}
Bot identity: Gitea user shre-reviewer (created via gitea admin user create).
admin_token powers the 403 self-heal: when a status/comment post returns 403
(a repo auto-wired by the admin default hook where the bot has no access yet),
the service adds shre-reviewer as collaborator (write) via the admin token
and retries the post once. Without admin_token the 403 surfaces as before.
Deploy (Mac dev tier)
launchd/ai.granthi.review.plist→~/Library/LaunchAgents/, KeepAlive service on :5498, running from the~/Services/granthi-reviewclone.launchd/ai.granthi.review.digest.plist→ nightly 03:30--digestrun.- Webhooks (all target
http://host.docker.internal:5498/webhook, events push + pull_request, secret =webhookSecret):- Per-repo hooks on the pilot repos:
nirpa/gitea-distro,nirpa/granthi-staging-infra,nirpa/granthi-review. - Admin default hook (
POST /api/v1/admin/hooks) — Gitea 1.27's API can only create default hooks (copied into repos created after the hook exists), not system hooks, so every new repo is auto-wired but pre-existing repos need a per-repo hook. To cover the whole estate in one shot, create the same hook as a system webhook via the admin web UI (Site Administration → Integrations → Webhooks → System Webhooks) — API tokens cannot do it in 1.27.
- Per-repo hooks on the pilot repos:
- Pilot gating:
nirpa/gitea-distromain branch protection listsgranthi-reviewinstatus_check_contexts(alongsidebuild-branded-image / build);block_admin_merge_overrideis false, so admins can merge over a block (the override path).
Router integration notes (learned live)
- The shre-router request MUST carry
tools:falseandraw:true, otherwise keyword fast-paths (current-info briefing, store-resolver, retail dispatch) hijack review prompts whose diff content mentions github/sales/store-ish words and return non-review text. - The domain preflight can 400 (
agent_mismatch) when the intent classifier misreads a diff; attempt 2 retries as the c-suitefallbackAgentId(defaultarchitect), which bypasses the tier-gated preflight. - The router may serve a different model than requested (
_shre.modelis recorded in the ledger'smodelcolumn).
Known v1 limitations
- Bare pushes get status + ledger only (no Gitea commit-comment API).
Hardening (fix/review-hardening)
- Full push-range review: pushes are reviewed as ONE compare diff
before...after(web compare.diffroute — the 1.27 API has no compare diff variant). New-branch pushes (before= zero sha) fall back to the head commit diff. No more first-20-commits slicing under a head-sha status. - Truncated diff ≠ pass: when the 60KB cap truncates the diff, the prompt and the status description both say
partial review (diff truncated)and the verdict is capped atwarn(a confirmed-criticalfailstill fails). - Diff fetch failure ≠ pass: any diff-fetch failure (throw, non-200, empty body for a non-empty push/PR) short-circuits to status
warning—review unavailable — diff fetch failed (not blocking)— with a ledger row. A diffless prompt is never sent. - Crash-safe queue: every accepted job is persisted to sqlite (
queue_jobs) before the pending status posts; on startup, rows that never reached a final state are re-enqueued, so a restart no longer strands a sha atpending. - 403 self-heal: see
admin_tokenabove.scripts/wire-repos.mjswires the webhook + bot collaborator across ALL repos on the peer (idempotent; re-runnable).
Adding the Reviews tab later (Granthi overlay)
The dashboard page GET /review/{owner}/{repo} is designed to be iframed or
linked as a repo tab. In the gitea-distro overlay, add an extra-tabs template
(custom/templates/custom/extra_tabs.tmpl):
<a class="item" href="http://<review-host>:5498/review/{{.Repository.Owner.Name}}/{{.Repository.Name}}" target="_blank">
{{svg "octicon-checklist"}} Reviews
</a>
(For prod, front the service behind the granthi domain and use a relative path instead of the host-port URL.)
Tests
npm test # node --test: JSON extractor, verdict derivation, trailer parser