From 9ffd8109437581c613ccd0ee49951cb7b608af7c Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:24:27 -0400 Subject: [PATCH] granthi-review v1: estate-wide AI code review service Webhook-driven central review service for Granthi forges: HMAC-verified push/pull_request webhooks, per-repo serialization with global LLM concurrency 1, shre-router powered rubric review (8 axes, strict JSON, tolerant extractor + retry), commit statuses + PR scorecard comments, sqlite attribution ledger with Co-Authored-By trailer parsing, per-repo HTML history dashboard, nightly per-agent digests, fail-open-with- visibility when the router is unavailable. Posture: BLOCK on confirmed critical/high correctness+security findings; admin merge is the human override. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + README.md | 95 ++++++++++++++ launchd/ai.granthi.review.digest.plist | 26 ++++ launchd/ai.granthi.review.plist | 21 ++++ package.json | 14 +++ src/config.js | 35 ++++++ src/lib/db.js | 66 ++++++++++ src/lib/digest.js | 64 ++++++++++ src/lib/gitea.js | 71 +++++++++++ src/lib/jsonExtract.js | 62 ++++++++++ src/lib/prompt.js | 45 +++++++ src/lib/queue.js | 33 +++++ src/lib/render.js | 95 ++++++++++++++ src/lib/review.js | 165 +++++++++++++++++++++++++ src/lib/router.js | 22 ++++ src/lib/trailers.js | 29 +++++ src/lib/verdict.js | 73 +++++++++++ src/lib/webhook.js | 62 ++++++++++ src/server.js | 97 +++++++++++++++ test/jsonExtract.test.js | 55 +++++++++ test/trailers.test.js | 42 +++++++ test/verdict.test.js | 80 ++++++++++++ 22 files changed, 1255 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 launchd/ai.granthi.review.digest.plist create mode 100644 launchd/ai.granthi.review.plist create mode 100644 package.json create mode 100644 src/config.js create mode 100644 src/lib/db.js create mode 100644 src/lib/digest.js create mode 100644 src/lib/gitea.js create mode 100644 src/lib/jsonExtract.js create mode 100644 src/lib/prompt.js create mode 100644 src/lib/queue.js create mode 100644 src/lib/render.js create mode 100644 src/lib/review.js create mode 100644 src/lib/router.js create mode 100644 src/lib/trailers.js create mode 100644 src/lib/verdict.js create mode 100644 src/lib/webhook.js create mode 100644 src/server.js create mode 100644 test/jsonExtract.test.js create mode 100644 test/trailers.test.js create mode 100644 test/verdict.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e647057 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# 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`; `--digest` CLI mode. +- `lib/webhook.js` — HMAC verification (`X-Gitea-Signature`, sha256 over raw body) + event → job mapping. Handles `push` and `pull_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}`, plus `overall`, `grade A–F`, `findings[]`, `verdict`. +- `lib/jsonExtract.js` — tolerant JSON extractor (fences, prose, `` blocks, trailing commas) + one retry on parse failure. +- `lib/verdict.js` — **service-side verdict derivation is authoritative**: `fail` iff a finding has severity ∈ {critical, high} AND confidence = confirmed in axes correctness/security (untagged confirmed criticals also block, conservatively). +- `lib/trailers.js` — `Co-Authored-By` trailer 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 → `success` with warning description; fail → `failure`. +- Branch protection on pilot repos requires the `granthi-review` context 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 `warning` with "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): + +```json +{ + "port": 5498, + "forgeBaseUrl": "http://localhost:3030", + "botToken": "", + "webhookSecret": "", + "routerUrl": "http://localhost:5497", + "model": "anthropic/claude-sonnet-4-6", + "tenantId": "nirlab", + "publicBaseUrl": "http://localhost:5498" +} +``` + +Bot identity: Gitea user `shre-reviewer` (created via `gitea admin user create`). + +## Deploy (Mac dev tier) + +- `launchd/ai.granthi.review.plist` → `~/Library/LaunchAgents/`, KeepAlive service on :5498. +- `launchd/ai.granthi.review.digest.plist` → nightly 03:30 `--digest` run. +- Webhooks: Gitea **system webhook** (admin → hooks) pointed at `http://host.docker.internal:5498/webhook` (the gitea container reaches the host that way), events push + pull_request, secret = `webhookSecret`. Fallback: per-repo hooks on pilot repos. +- Pilot gating: `nirpa/gitea-distro` main branch protection lists `granthi-review` in `status_check_contexts` (alongside the CI build context); `enable_approvals_whitelist`/admin-bypass left so admins can merge over a block. + +## 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`): + +```html + + {{svg "octicon-checklist"}} Reviews + +``` + +(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 +``` diff --git a/launchd/ai.granthi.review.digest.plist b/launchd/ai.granthi.review.digest.plist new file mode 100644 index 0000000..00ebb75 --- /dev/null +++ b/launchd/ai.granthi.review.digest.plist @@ -0,0 +1,26 @@ + + + + + Labelai.granthi.review.digest + ProgramArguments + + /opt/homebrew/bin/node + /Users/aibot/Services/granthi-review/src/server.js + --digest + + WorkingDirectory/Users/aibot/Services/granthi-review + StartCalendarInterval + + Hour3 + Minute30 + + RunAtLoad + StandardOutPath/Users/aibot/.granthi-review/digest.log + StandardErrorPath/Users/aibot/.granthi-review/digest.err.log + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/launchd/ai.granthi.review.plist b/launchd/ai.granthi.review.plist new file mode 100644 index 0000000..f8bc633 --- /dev/null +++ b/launchd/ai.granthi.review.plist @@ -0,0 +1,21 @@ + + + + + Labelai.granthi.review + ProgramArguments + + /opt/homebrew/bin/node + /Users/aibot/Services/granthi-review/src/server.js + + WorkingDirectory/Users/aibot/Services/granthi-review + KeepAlive + RunAtLoad + StandardOutPath/Users/aibot/.granthi-review/service.log + StandardErrorPath/Users/aibot/.granthi-review/service.err.log + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c6444c8 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "granthi-review", + "version": "1.0.0", + "description": "Estate-wide AI code review service for Granthi (Gitea) forges. Webhook-driven, shre-router powered.", + "type": "module", + "main": "src/server.js", + "engines": { "node": ">=22.13" }, + "scripts": { + "start": "node src/server.js", + "digest": "node src/server.js --digest", + "test": "node --test test/*.test.js" + }, + "license": "MIT" +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b8bb879 --- /dev/null +++ b/src/config.js @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const CONFIG_DIR = process.env.GRANTHI_REVIEW_HOME || join(homedir(), '.granthi-review'); + +const DEFAULTS = { + port: 5498, + forgeBaseUrl: 'http://localhost:3030', + botToken: '', + webhookSecret: '', + routerUrl: 'http://localhost:5497', + model: 'anthropic/claude-sonnet-4-6', + tenantId: 'nirlab', + publicBaseUrl: 'http://localhost:5498', + dbPath: join(CONFIG_DIR, 'reviews.db'), + digestDir: join(CONFIG_DIR, 'digests'), + maxDiffBytes: 60 * 1024, + routerTimeoutMs: 240000, + digestLastN: 20 +}; + +export function loadConfig() { + let fileCfg = {}; + try { + fileCfg = JSON.parse(readFileSync(join(CONFIG_DIR, 'config.json'), 'utf8')); + } catch { /* config file optional; env/defaults apply */ } + const cfg = { ...DEFAULTS, ...fileCfg }; + // env overrides + if (process.env.GRANTHI_REVIEW_PORT) cfg.port = Number(process.env.GRANTHI_REVIEW_PORT); + if (process.env.GRANTHI_REVIEW_TOKEN) cfg.botToken = process.env.GRANTHI_REVIEW_TOKEN; + if (process.env.GRANTHI_REVIEW_SECRET) cfg.webhookSecret = process.env.GRANTHI_REVIEW_SECRET; + cfg.configDir = CONFIG_DIR; + return cfg; +} diff --git a/src/lib/db.js b/src/lib/db.js new file mode 100644 index 0000000..e3b9aae --- /dev/null +++ b/src/lib/db.js @@ -0,0 +1,66 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const DDL = [ + `CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT NOT NULL, + sha TEXT NOT NULL, + pr INTEGER, + event TEXT NOT NULL, + pusher TEXT, + authors TEXT, + trailers TEXT, + axes TEXT, + overall INTEGER, + grade TEXT, + verdict TEXT, + findings TEXT, + status_state TEXT, + error TEXT, + diff_bytes INTEGER, + truncated INTEGER DEFAULT 0, + model TEXT, + duration_ms INTEGER, + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_reviews_repo_ts ON reviews(repo, ts DESC)`, + `CREATE INDEX IF NOT EXISTS idx_reviews_sha ON reviews(sha)` +]; + +export function openDb(dbPath) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + for (const stmt of DDL) db.prepare(stmt).run(); + return db; +} + +export function insertReview(db, r) { + const stmt = db.prepare(` + INSERT INTO reviews (repo, sha, pr, event, pusher, authors, trailers, axes, + overall, grade, verdict, findings, status_state, error, diff_bytes, + truncated, model, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const res = stmt.run( + r.repo, r.sha, r.pr ?? null, r.event, r.pusher ?? null, + JSON.stringify(r.authors ?? []), JSON.stringify(r.trailers ?? []), + r.axes ? JSON.stringify(r.axes) : null, + r.overall ?? null, r.grade ?? null, r.verdict ?? null, + r.findings ? JSON.stringify(r.findings) : null, + r.statusState ?? null, r.error ?? null, r.diffBytes ?? null, + r.truncated ? 1 : 0, r.model ?? null, r.durationMs ?? null + ); + return res.lastInsertRowid; +} + +export function listReviews(db, repo, limit = 50) { + return db.prepare( + 'SELECT * FROM reviews WHERE repo = ? ORDER BY id DESC LIMIT ?' + ).all(repo, limit); +} + +export function allReviews(db, limit = 500) { + return db.prepare('SELECT * FROM reviews ORDER BY id DESC LIMIT ?').all(limit); +} diff --git a/src/lib/digest.js b/src/lib/digest.js new file mode 100644 index 0000000..90bd256 --- /dev/null +++ b/src/lib/digest.js @@ -0,0 +1,64 @@ +// Nightly per-agent digests. For every distinct agent identity seen in +// Co-Authored-By trailers, write markdown to /.md with the +// last N reviews, score trend, and recurring finding categories. + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { allReviews } from './db.js'; +import { agentSlug } from './trailers.js'; + +export function writeDigests({ cfg, db, log }) { + const rows = allReviews(db, 2000); + const byAgent = new Map(); + for (const r of rows) { + const trailers = r.trailers ? JSON.parse(r.trailers) : []; + for (const t of trailers) { + const slug = agentSlug(t); + if (!byAgent.has(slug)) byAgent.set(slug, { trailer: t, rows: [] }); + byAgent.get(slug).rows.push(r); + } + } + mkdirSync(cfg.digestDir, { recursive: true }); + const written = []; + for (const [slug, { trailer, rows: agentRows }] of byAgent) { + const recent = agentRows.slice(0, cfg.digestLastN); // rows are newest-first + const scored = recent.filter(r => r.overall != null); + const avg = scored.length + ? Math.round(scored.reduce((s, r) => s + r.overall, 0) / scored.length) : null; + const older = scored.slice(Math.ceil(scored.length / 2)); + const newer = scored.slice(0, Math.ceil(scored.length / 2)); + const trend = older.length && newer.length + ? Math.round(avgOf(newer) - avgOf(older)) : 0; + + const catCounts = new Map(); + for (const r of recent) { + for (const f of r.findings ? JSON.parse(r.findings) : []) { + const k = `${f.axis || 'untagged'} / ${f.severity}`; + catCounts.set(k, (catCounts.get(k) || 0) + 1); + } + } + const cats = [...catCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); + + let md = `# granthi-review digest — ${trailer.name}\n\n`; + md += `Generated: ${new Date().toISOString()}\n\n`; + md += `- Reviews on record: ${agentRows.length} (showing last ${recent.length})\n`; + md += `- Average overall (last ${scored.length} scored): ${avg ?? 'n/a'}\n`; + md += `- Trend (newer half vs older half): ${trend >= 0 ? '+' : ''}${trend}\n\n`; + md += `## Recurring finding categories\n\n`; + md += cats.length + ? cats.map(([k, n]) => `- ${k}: ${n}`).join('\n') + '\n' + : '_none_\n'; + md += `\n## Last ${recent.length} reviews\n\n| ts | repo | sha | verdict | grade | overall | findings |\n|---|---|---|---|---|---:|---:|\n`; + for (const r of recent) { + const nf = r.findings ? JSON.parse(r.findings).length : 0; + md += `| ${r.ts} | ${r.repo} | ${String(r.sha).slice(0, 10)} | ${r.verdict ?? 'error'} | ${r.grade ?? '—'} | ${r.overall ?? '—'} | ${nf} |\n`; + } + const file = join(cfg.digestDir, `${slug}.md`); + writeFileSync(file, md); + written.push(file); + log(`digest written: ${file} (${agentRows.length} reviews)`); + } + return written; +} + +function avgOf(rows) { return rows.reduce((s, r) => s + r.overall, 0) / rows.length; } diff --git a/src/lib/gitea.js b/src/lib/gitea.js new file mode 100644 index 0000000..0e69b5c --- /dev/null +++ b/src/lib/gitea.js @@ -0,0 +1,71 @@ +// Minimal Gitea API client (fetch-based, no deps). + +export class GiteaClient { + constructor({ baseUrl, token, timeoutMs = 30000 }) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.token = token; + this.timeoutMs = timeoutMs; + } + + async req(method, path, { body, raw = false, ok = [200, 201] } = {}) { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `token ${this.token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}) + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(this.timeoutMs) + }); + if (!ok.includes(res.status)) { + const text = await res.text().catch(() => ''); + const err = new Error(`gitea ${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`); + err.status = res.status; + throw err; + } + return raw ? res.text() : res.json().catch(() => ({})); + } + + // PR diff + getPrDiff(owner, repo, index) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/pulls/${index}.diff`, { raw: true }); + } + + // Single-commit diff + getCommitDiff(owner, repo, sha) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}.diff`, { raw: true }); + } + + // Compare (commit list between two shas) + compare(owner, repo, before, after) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/compare/${before}...${after}`); + } + + // File content at HEAD (returns null when absent) + async getFileHead(owner, repo, path, maxBytes = 8192) { + try { + const txt = await this.req('GET', + `/api/v1/repos/${owner}/${repo}/raw/${encodeURIComponent(path)}`, { raw: true }); + return txt.slice(0, maxBytes); + } catch (e) { + if (e.status === 404) return null; + throw e; + } + } + + postStatus(owner, repo, sha, { state, context, description, target_url }) { + return this.req('POST', `/api/v1/repos/${owner}/${repo}/statuses/${sha}`, { + body: { state, context, description: (description || '').slice(0, 255), target_url } + }); + } + + postIssueComment(owner, repo, index, body) { + return this.req('POST', `/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, { + body: { body } + }); + } + + getCommit(owner, repo, sha) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}?stat=false&verification=false&files=false`); + } +} diff --git a/src/lib/jsonExtract.js b/src/lib/jsonExtract.js new file mode 100644 index 0000000..fca8b99 --- /dev/null +++ b/src/lib/jsonExtract.js @@ -0,0 +1,62 @@ +// Tolerant JSON extractor: pulls the first well-formed JSON object out of +// LLM output that may be wrapped in prose, markdown fences, or blocks. + +export function extractJson(text) { + if (text == null) return null; + let s = String(text); + + // strip ... reasoning blocks + s = s.replace(/[\s\S]*?<\/think>/gi, ''); + + // prefer fenced ```json blocks if present + const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) { + const parsed = tryParseObject(fence[1]); + if (parsed) return parsed; + } + + // scan for balanced top-level object, string-aware + const start = s.indexOf('{'); + if (start === -1) return null; + for (let from = start; from !== -1; from = s.indexOf('{', from + 1)) { + const candidate = balancedSlice(s, from); + if (candidate) { + const parsed = tryParseObject(candidate); + if (parsed) return parsed; + } + } + return null; +} + +function balancedSlice(s, from) { + let depth = 0, inStr = false, esc = false; + for (let i = from; i < s.length; i++) { + const c = s[i]; + if (inStr) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) return s.slice(from, i + 1); + } + } + return null; +} + +function tryParseObject(str) { + const t = str.trim(); + try { + const v = JSON.parse(t); + return v && typeof v === 'object' && !Array.isArray(v) ? v : null; + } catch { /* fall through to repairs */ } + // common repairs: trailing commas + try { + const v = JSON.parse(t.replace(/,\s*([}\]])/g, '$1')); + return v && typeof v === 'object' && !Array.isArray(v) ? v : null; + } catch { return null; } +} diff --git a/src/lib/prompt.js b/src/lib/prompt.js new file mode 100644 index 0000000..aae3fe3 --- /dev/null +++ b/src/lib/prompt.js @@ -0,0 +1,45 @@ +export function buildReviewPrompt({ repo, ref, sha, prTitle, conventions, diff, truncated }) { + const system = `You are granthi-review, an exacting but fair staff-level code reviewer for the Nirlab estate. +You review a git diff and return ONLY a strict JSON object — no prose, no markdown fences, no commentary. + +Output schema (every key required): +{ + "axes": { + "correctness": {"score": 0-100, "rationale": "one sentence"}, + "security": {"score": 0-100, "rationale": "one sentence"}, + "tests_coverage": {"score": 0-100, "rationale": "one sentence"}, + "design_simplicity":{"score": 0-100, "rationale": "one sentence"}, + "performance": {"score": 0-100, "rationale": "one sentence"}, + "style": {"score": 0-100, "rationale": "one sentence"}, + "breaking_changes": {"score": 0-100, "rationale": "one sentence"}, + "docs": {"score": 0-100, "rationale": "one sentence"} + }, + "overall": 0-100, + "grade": "A"|"B"|"C"|"D"|"F", + "findings": [ + {"title": "...", "file": "path", "line": 123, "severity": "critical"|"high"|"medium"|"low", + "confidence": "confirmed"|"plausible", "axis": "correctness"|"security"|"tests_coverage"|"design_simplicity"|"performance"|"style"|"breaking_changes"|"docs", + "detail": "what is wrong, concrete failure scenario, and the fix"} + ], + "verdict": "pass"|"warn"|"fail" +} + +Rules: +- "confirmed" means you can trace a concrete failure from the diff alone; anything needing unseen context is "plausible". +- severity critical/high is reserved for real correctness bugs, security holes, data loss, or breaking API changes. +- verdict "fail" ONLY when a confirmed critical/high correctness or security finding exists; "warn" for notable but non-blocking issues; otherwise "pass". +- findings may be empty. Do not invent findings to look thorough. Score honestly; 100 on an axis is legitimate for a clean small diff. +- High axis scores mean good. A diff with no tests for new logic caps tests_coverage at 40.`; + + let user = `Repository: ${repo}\nRef: ${ref || '(pr)'}\nHead SHA: ${sha}\n`; + if (prTitle) user += `PR title: ${prTitle}\n`; + if (conventions) { + user += `\nRepo conventions (excerpt from CLAUDE.md / CONTRIBUTING.md — weigh style/design against these):\n---\n${conventions}\n---\n`; + } + if (truncated) user += `\nNOTE: the diff below was TRUNCATED at the size cap; judge only what you see and do not penalize for the missing tail.\n`; + user += `\nUnified diff to review:\n\`\`\`diff\n${diff}\n\`\`\`\n\nReturn the JSON object now.`; + return [ + { role: 'system', content: system }, + { role: 'user', content: user } + ]; +} diff --git a/src/lib/queue.js b/src/lib/queue.js new file mode 100644 index 0000000..de79637 --- /dev/null +++ b/src/lib/queue.js @@ -0,0 +1,33 @@ +// Per-repo serialization + a global mutex (LLM concurrency 1). +// Each repo gets a promise chain; the global chain serializes LLM work. + +export class ReviewQueue { + constructor() { + this.repoChains = new Map(); + this.globalChain = Promise.resolve(); + this.pending = 0; + } + + // Serialize fn per repo key. fn receives a `withGlobal` helper that + // serializes the wrapped section across ALL repos (use around LLM calls). + enqueue(repoKey, fn) { + const prev = this.repoChains.get(repoKey) || Promise.resolve(); + this.pending++; + const next = prev + .catch(() => {}) + .then(() => fn(this.withGlobal.bind(this))) + .finally(() => { + this.pending--; + if (this.repoChains.get(repoKey) === next) this.repoChains.delete(repoKey); + }); + this.repoChains.set(repoKey, next); + return next; + } + + withGlobal(fn) { + const run = this.globalChain.catch(() => {}).then(fn); + // keep the chain alive regardless of outcome + this.globalChain = run.catch(() => {}); + return run; + } +} diff --git a/src/lib/render.js b/src/lib/render.js new file mode 100644 index 0000000..e16eb12 --- /dev/null +++ b/src/lib/render.js @@ -0,0 +1,95 @@ +// Markdown scorecard for forge comments + HTML dashboard rendering. + +const AXES = ['correctness', 'security', 'tests_coverage', 'design_simplicity', + 'performance', 'style', 'breaking_changes', 'docs']; + +const VERDICT_EMOJI = { pass: '✅', warn: '⚠️', fail: '❌' }; + +export function renderScorecardMarkdown({ repo, sha, review, dashboardUrl, truncated }) { + const v = review.verdict; + let md = `## ${VERDICT_EMOJI[v] || ''} granthi-review — grade **${review.grade}** (${review.overall}/100), verdict **${v.toUpperCase()}**\n\n`; + md += `| Axis | Score | Rationale |\n|---|---:|---|\n`; + for (const a of AXES) { + const ax = review.axes[a]; + md += `| ${a.replace(/_/g, ' ')} | ${ax.score} | ${escapePipes(ax.rationale)} |\n`; + } + if (review.findings.length) { + md += `\n### Findings (${review.findings.length})\n\n`; + md += `| Sev | Conf | File | Title |\n|---|---|---|---|\n`; + for (const f of review.findings) { + const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : '—'; + md += `| ${f.severity} | ${f.confidence} | \`${loc}\` | ${escapePipes(f.title)} |\n`; + } + md += `\n
Finding details\n\n`; + for (const f of review.findings) { + md += `**${f.title}** (${f.severity}/${f.confidence}${f.axis ? `, ${f.axis}` : ''})\n\n${f.detail}\n\n`; + } + md += `
\n`; + } else { + md += `\nNo findings.\n`; + } + if (truncated) md += `\n> Diff was truncated at the size cap; review covers the visible portion only.\n`; + if (v === 'fail') md += `\n> **Blocking**: confirmed critical/high correctness or security finding. Override path: an admin merge (branch protection does not apply to admins).\n`; + md += `\n[History for ${repo}](${dashboardUrl}) · sha \`${sha.slice(0, 10)}\`\n`; + return md; +} + +export function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function escapePipes(s) { + return String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +export function renderHistoryHtml({ repo, rows }) { + const trs = rows.map(r => { + const axes = r.axes ? JSON.parse(r.axes) : null; + const findings = r.findings ? JSON.parse(r.findings) : []; + const trailers = r.trailers ? JSON.parse(r.trailers) : []; + const axesCells = AXES.map(a => `${axes ? esc(axes[a]?.score ?? '—') : '—'}`).join(''); + const vClass = r.verdict === 'fail' ? 'fail' : r.verdict === 'warn' ? 'warn' : 'pass'; + const agents = trailers.map(t => esc(t.name)).join(', '); + const fsum = findings.length + ? `
${findings.length} finding(s)
    ` + + findings.map(f => `
  • ${esc(f.severity)}/${esc(f.confidence)} ${esc(f.title)} ${esc(f.file || '')}
  • `).join('') + + `
` + : '—'; + return ` + ${esc(r.ts)} + ${esc(String(r.sha).slice(0, 10))} + ${r.pr ? '#' + esc(r.pr) : esc(r.event)} + ${esc(r.pusher ?? '')} + ${agents || '—'} + ${esc(r.overall ?? '—')} + ${esc(r.grade ?? '—')} + ${esc(r.verdict ?? (r.error ? 'error' : '—'))} + ${axesCells} + ${fsum} + ${r.error ? `${esc(r.error)}` : ''} + `; + }).join('\n'); + + return ` +granthi-review · ${esc(repo)} + +

granthi-review · ${esc(repo)}

+
+ +${AXES.map(a => ``).join('')} +${trs || ''} +
ts (utc)shaeventpusheragentsoverallgradeverdict${a.replace(/_/g, ' ')}findingserror
No reviews yet.
+
granthi-review · posture: block on confirmed critical/high correctness+security, admin merge = override
+`; +} diff --git a/src/lib/review.js b/src/lib/review.js new file mode 100644 index 0000000..3c5139a --- /dev/null +++ b/src/lib/review.js @@ -0,0 +1,165 @@ +// The review pipeline: diff fetch -> conventions -> LLM -> parse -> status + +// comment -> ledger. Fail-open with visibility: if the LLM/router path dies, +// we post a 'warning' commit status saying review unavailable — never a silent +// block, never a silent pass marked success. + +import { chat } from './router.js'; +import { extractJson } from './jsonExtract.js'; +import { normalizeReview } from './verdict.js'; +import { parseTrailers } from './trailers.js'; +import { buildReviewPrompt } from './prompt.js'; +import { renderScorecardMarkdown } from './render.js'; +import { insertReview } from './db.js'; + +const CONTEXT = 'granthi-review'; + +export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { + const { owner, repo, sha } = job; + const fullRepo = `${owner}/${repo}`; + const dashboardUrl = `${cfg.publicBaseUrl}/review/${owner}/${repo}`; + const t0 = Date.now(); + + // 0. pending status (best-effort) + await safe(log, 'pending-status', () => + gitea.postStatus(owner, repo, sha, { + state: 'pending', context: CONTEXT, + description: 'AI review in progress…', target_url: dashboardUrl + })); + + // 1. diff + let diff = '', truncated = false, diffBytes = 0; + try { + diff = await fetchDiff({ gitea, job }); + diffBytes = Buffer.byteLength(diff); + if (diffBytes > cfg.maxDiffBytes) { + diff = Buffer.from(diff).subarray(0, cfg.maxDiffBytes).toString('utf8'); + truncated = true; + log(`[${fullRepo}@${sha.slice(0, 8)}] diff truncated ${diffBytes} -> ${cfg.maxDiffBytes} bytes`); + } + } catch (e) { + return await failOpen({ cfg, gitea, db, job, log, dashboardUrl, t0, error: `diff fetch failed: ${e.message}` }); + } + if (!diff.trim()) { + return await failOpen({ cfg, gitea, db, job, log, dashboardUrl, t0, error: 'empty diff' , state: 'success', description: 'empty diff — nothing to review'}); + } + + // 2. conventions + let conventions = null; + for (const f of ['CLAUDE.md', 'CONTRIBUTING.md']) { + const txt = await safe(log, `conventions ${f}`, () => gitea.getFileHead(owner, repo, f, 4096)); + if (txt) conventions = (conventions ? conventions + '\n\n' : '') + `# ${f}\n${txt}`; + } + if (conventions) conventions = conventions.slice(0, 6000); + + // 3. LLM call (global concurrency 1) with one retry on parse failure + const messages = buildReviewPrompt({ + repo: fullRepo, ref: job.ref, sha, prTitle: job.prTitle, conventions, diff, truncated + }); + let review = null, llmError = null; + await withGlobal(async () => { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const content = await chat({ + routerUrl: cfg.routerUrl, model: cfg.model, messages, + agentId: 'code-reviewer', + sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}`, + tenantId: cfg.tenantId, timeoutMs: cfg.routerTimeoutMs + }); + const raw = extractJson(content); + if (!raw) throw new Error(`no JSON object in model output (attempt ${attempt})`); + review = normalizeReview(raw); + if (!review) throw new Error('normalization failed'); + llmError = null; + break; + } catch (e) { + llmError = e.message; + log(`[${fullRepo}@${sha.slice(0, 8)}] LLM attempt ${attempt} failed: ${e.message}`); + } + } + }); + if (!review) { + return await failOpen({ cfg, gitea, db, job, log, dashboardUrl, t0, error: `review unavailable: ${llmError}` }); + } + + // 4. status + comment + const stateByVerdict = { pass: 'success', warn: 'success', fail: 'failure' }; + const state = stateByVerdict[review.verdict]; + const descByVerdict = { + pass: `Grade ${review.grade} (${review.overall}/100) — pass`, + warn: `Grade ${review.grade} (${review.overall}/100) — pass with warnings (${review.findings.length} finding(s))`, + fail: `Grade ${review.grade} (${review.overall}/100) — BLOCKED: confirmed critical finding (admin merge = override)` + }; + await safe(log, 'final-status', () => + gitea.postStatus(owner, repo, sha, { + state, context: CONTEXT, description: descByVerdict[review.verdict], target_url: dashboardUrl + })); + + const md = renderScorecardMarkdown({ repo: fullRepo, sha, review, dashboardUrl, truncated }); + if (job.prIndex) { + await safe(log, 'pr-comment', () => gitea.postIssueComment(owner, repo, job.prIndex, md)); + } else { + // Gitea has no commit-comment API endpoint; bare pushes get status + + // ledger + dashboard only (README documents this). + log(`[${fullRepo}@${sha.slice(0, 8)}] bare push: status+ledger only (no commit-comment API in Gitea)`); + } + + // 5. ledger + const trailers = dedupeTrailers(job.commitMessages.flatMap(parseTrailers)); + insertReview(db, { + repo: fullRepo, sha, pr: job.prIndex ?? null, event: job.event, + pusher: job.pusher, authors: job.authors, trailers, + axes: review.axes, overall: review.overall, grade: review.grade, + verdict: review.verdict, findings: review.findings, + statusState: state, error: null, diffBytes, truncated, + model: cfg.model, durationMs: Date.now() - t0 + }); + log(`[${fullRepo}@${sha.slice(0, 8)}] reviewed: ${review.verdict} grade=${review.grade} overall=${review.overall} findings=${review.findings.length} in ${Date.now() - t0}ms`); + return { ok: true, verdict: review.verdict }; +} + +async function fetchDiff({ gitea, job }) { + const { owner, repo } = job; + if (job.prIndex) return gitea.getPrDiff(owner, repo, job.prIndex); + // push: per-commit diffs for the pushed shas (payload-provided), capped later + const parts = []; + for (const sha of job.commitShas.slice(0, 20)) { + try { parts.push(await gitea.getCommitDiff(owner, repo, sha)); } + catch (e) { parts.push(`# diff for ${sha} unavailable: ${e.message}\n`); } + } + return parts.join('\n'); +} + +async function failOpen({ cfg, gitea, db, job, log, dashboardUrl, t0, error, state = 'warning', description }) { + const { owner, repo, sha } = job; + const fullRepo = `${owner}/${repo}`; + log(`[${fullRepo}@${sha.slice(0, 8)}] fail-open: ${error}`); + await safe(log, 'fail-open-status', () => + gitea.postStatus(owner, repo, sha, { + state, context: CONTEXT, + description: description || `review unavailable — not blocking (${error.slice(0, 180)})`, + target_url: dashboardUrl + })); + const trailers = dedupeTrailers(job.commitMessages.flatMap(parseTrailers)); + insertReview(db, { + repo: fullRepo, sha, pr: job.prIndex ?? null, event: job.event, + pusher: job.pusher, authors: job.authors, trailers, + axes: null, overall: null, grade: null, verdict: null, findings: null, + statusState: state, error, diffBytes: null, truncated: false, + model: cfg.model, durationMs: Date.now() - t0 + }); + return { ok: false, error }; +} + +function dedupeTrailers(list) { + const seen = new Set(); const out = []; + for (const t of list) { + const k = `${t.name.toLowerCase()}|${t.email}`; + if (!seen.has(k)) { seen.add(k); out.push(t); } + } + return out; +} + +async function safe(log, label, fn) { + try { return await fn(); } + catch (e) { log(`non-fatal (${label}): ${e.message}`); return null; } +} diff --git a/src/lib/router.js b/src/lib/router.js new file mode 100644 index 0000000..f9d0ee8 --- /dev/null +++ b/src/lib/router.js @@ -0,0 +1,22 @@ +// shre-router client. POST /v1/chat with stream:false. +// Real model answer lives in .message.content; top-level .content can carry an +// upstream-down apology — always prefer .message.content. + +export async function chat({ routerUrl, model, messages, agentId, sessionId, tenantId, timeoutMs }) { + const res = await fetch(`${routerUrl.replace(/\/$/, '')}/v1/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false }), + signal: AbortSignal.timeout(timeoutMs) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`router -> ${res.status}: ${text.slice(0, 300)}`); + } + const data = await res.json(); + const content = data?.message?.content ?? data?.content; + if (typeof content !== 'string' || !content.trim()) { + throw new Error('router returned empty content'); + } + return content; +} diff --git a/src/lib/trailers.js b/src/lib/trailers.js new file mode 100644 index 0000000..8473535 --- /dev/null +++ b/src/lib/trailers.js @@ -0,0 +1,29 @@ +// Parse commit-message trailers for agent attribution. +// Recognizes Co-Authored-By: Name (case-insensitive, tolerant spacing). + +const TRAILER_RE = /^\s*co-authored-by\s*:\s*(.+?)\s*(?:<([^<>]*)>)?\s*$/i; + +export function parseTrailers(message) { + if (!message) return []; + const out = []; + const seen = new Set(); + for (const line of String(message).split(/\r?\n/)) { + const m = line.match(TRAILER_RE); + if (!m) continue; + const name = m[1].trim(); + const email = (m[2] || '').trim().toLowerCase(); + const key = `${name.toLowerCase()}|${email}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ name, email }); + } + return out; +} + +// Stable identity slug for an agent trailer (used for digest filenames). +export function agentSlug(trailer) { + const base = trailer.email && trailer.email.includes('@') + ? trailer.email.split('@')[0] + : trailer.name; + return base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'; +} diff --git a/src/lib/verdict.js b/src/lib/verdict.js new file mode 100644 index 0000000..cedd638 --- /dev/null +++ b/src/lib/verdict.js @@ -0,0 +1,73 @@ +// Verdict derivation. Posture: BLOCK on confirmed critical/high findings in +// correctness/security; everything else is warn-or-pass. Human override = +// admin merge (branch protection does not apply to admins). + +const BLOCKING_SEVERITIES = new Set(['critical', 'high']); +const BLOCKING_AXES = new Set(['correctness', 'security']); + +export function isBlockingFinding(f) { + if (!f || typeof f !== 'object') return false; + const sev = String(f.severity || '').toLowerCase(); + const conf = String(f.confidence || '').toLowerCase(); + if (!BLOCKING_SEVERITIES.has(sev)) return false; + if (conf !== 'confirmed') return false; + // If the model tagged an axis, only correctness/security block. + // Untagged critical+confirmed findings block too (conservative). + const axis = f.axis ? String(f.axis).toLowerCase() : null; + return axis === null || BLOCKING_AXES.has(axis); +} + +export function deriveVerdict(review) { + const findings = Array.isArray(review?.findings) ? review.findings : []; + if (findings.some(isBlockingFinding)) return 'fail'; + const hasWarn = findings.some(f => { + const sev = String(f?.severity || '').toLowerCase(); + return sev === 'critical' || sev === 'high' || sev === 'medium'; + }); + if (hasWarn) return 'warn'; + // trust the model's verdict if it said warn without matching findings + if (review?.verdict === 'warn') return 'warn'; + return 'pass'; +} + +export function normalizeReview(raw) { + if (!raw || typeof raw !== 'object') return null; + const axesNames = ['correctness', 'security', 'tests_coverage', 'design_simplicity', + 'performance', 'style', 'breaking_changes', 'docs']; + const axes = {}; + for (const name of axesNames) { + const a = raw.axes?.[name]; + axes[name] = { + score: clampScore(a?.score), + rationale: typeof a?.rationale === 'string' ? a.rationale.slice(0, 500) : '' + }; + } + const findings = (Array.isArray(raw.findings) ? raw.findings : []).map(f => ({ + title: String(f?.title ?? 'untitled').slice(0, 200), + file: String(f?.file ?? '').slice(0, 300), + line: Number.isFinite(Number(f?.line)) ? Number(f.line) : null, + severity: pick(String(f?.severity || '').toLowerCase(), ['critical', 'high', 'medium', 'low'], 'low'), + confidence: pick(String(f?.confidence || '').toLowerCase(), ['confirmed', 'plausible'], 'plausible'), + axis: f?.axis ? String(f.axis).toLowerCase() : null, + detail: String(f?.detail ?? '').slice(0, 2000) + })); + const review = { + axes, + overall: clampScore(raw.overall), + grade: pick(String(raw.grade || '').toUpperCase(), ['A', 'B', 'C', 'D', 'F'], 'C'), + findings, + verdict: pick(String(raw.verdict || '').toLowerCase(), ['pass', 'warn', 'fail'], 'pass') + }; + review.verdict = deriveVerdict(review); // service-side derivation is authoritative + return review; +} + +function clampScore(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(100, Math.round(n))); +} + +function pick(v, allowed, fallback) { + return allowed.includes(v) ? v : fallback; +} diff --git a/src/lib/webhook.js b/src/lib/webhook.js new file mode 100644 index 0000000..7976a03 --- /dev/null +++ b/src/lib/webhook.js @@ -0,0 +1,62 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export function verifySignature(rawBody, signature, secret) { + if (!secret) return false; + if (!signature) return false; + const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); + const a = Buffer.from(expected, 'utf8'); + const b = Buffer.from(String(signature), 'utf8'); + return a.length === b.length && timingSafeEqual(a, b); +} + +const ZERO_SHA = /^0+$/; + +// Turn a Gitea webhook (event header + payload) into a review job, or null if +// the event is not reviewable. +export function jobFromWebhook(event, payload) { + if (event === 'push') { + const after = payload.after; + if (!after || ZERO_SHA.test(after)) return null; // branch delete + const [owner, repo] = String(payload.repository?.full_name || '').split('/'); + if (!owner || !repo) return null; + const commits = Array.isArray(payload.commits) ? payload.commits : []; + if (commits.length === 0) return null; // e.g. tag push / empty + return { + event: 'push', + owner, repo, + sha: after, + ref: payload.ref, + before: payload.before, + prIndex: null, + prTitle: null, + pusher: payload.pusher?.username || payload.pusher?.login || null, + authors: dedupe(commits.map(c => c.author?.name || c.author?.email).filter(Boolean)), + commitShas: commits.map(c => c.id).filter(Boolean), + commitMessages: commits.map(c => c.message || '') + }; + } + if (event === 'pull_request') { + const action = payload.action; + if (!['opened', 'synchronize', 'synchronized', 'reopened'].includes(action)) return null; + const pr = payload.pull_request; + if (!pr) return null; + const [owner, repo] = String(payload.repository?.full_name || '').split('/'); + if (!owner || !repo) return null; + return { + event: `pull_request/${action}`, + owner, repo, + sha: pr.head?.sha, + ref: pr.head?.ref, + before: null, + prIndex: pr.number, + prTitle: pr.title || null, + pusher: payload.sender?.username || payload.sender?.login || null, + authors: dedupe([pr.user?.username || pr.user?.login].filter(Boolean)), + commitShas: [pr.head?.sha].filter(Boolean), + commitMessages: [pr.title || '', pr.body || ''] + }; + } + return null; +} + +function dedupe(a) { return [...new Set(a)]; } diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..e588859 --- /dev/null +++ b/src/server.js @@ -0,0 +1,97 @@ +import { createServer } from 'node:http'; +import { loadConfig } from './config.js'; +import { openDb, listReviews } from './lib/db.js'; +import { GiteaClient } from './lib/gitea.js'; +import { ReviewQueue } from './lib/queue.js'; +import { verifySignature, jobFromWebhook } from './lib/webhook.js'; +import { runReview } from './lib/review.js'; +import { renderHistoryHtml, esc } from './lib/render.js'; +import { writeDigests } from './lib/digest.js'; + +const cfg = loadConfig(); +const log = (...a) => console.log(new Date().toISOString(), ...a); + +// --digest CLI mode (invoked by launchd timer) +if (process.argv.includes('--digest')) { + const db = openDb(cfg.dbPath); + const files = writeDigests({ cfg, db, log }); + log(`digest run complete: ${files.length} file(s)`); + process.exit(0); +} + +if (!cfg.botToken) { console.error('missing botToken in config'); process.exit(1); } +if (!cfg.webhookSecret) { console.error('missing webhookSecret in config'); process.exit(1); } + +const db = openDb(cfg.dbPath); +const gitea = new GiteaClient({ baseUrl: cfg.forgeBaseUrl, token: cfg.botToken }); +const queue = new ReviewQueue(); + +const server = createServer((req, res) => { + const url = new URL(req.url, 'http://x'); + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { status: 'ok', service: 'granthi-review', pending: queue.pending }); + } + if (req.method === 'POST' && url.pathname === '/webhook') { + return handleWebhook(req, res); + } + const m = url.pathname.match(/^\/review\/([\w.-]+)\/([\w.-]+)\/?$/); + if (req.method === 'GET' && m) { + const repo = `${m[1]}/${m[2]}`; + const rows = listReviews(db, repo, 100); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(renderHistoryHtml({ repo, rows })); + } + if (req.method === 'GET' && url.pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(`

granthi-review

AI code review service. See /review/{owner}/{repo} for history, /health for status.

`); + } + json(res, 404, { error: 'not found' }); + } catch (e) { + log('request error:', e.message); + json(res, 500, { error: 'internal' }); + } +}); + +function handleWebhook(req, res) { + const chunks = []; + let size = 0; + req.on('data', c => { + size += c.length; + if (size > 2 * 1024 * 1024) { req.destroy(); return; } + chunks.push(c); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks); + const sig = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256']?.replace(/^sha256=/, ''); + if (!verifySignature(raw, sig, cfg.webhookSecret)) { + log('webhook: bad signature, rejected'); + return json(res, 401, { error: 'bad signature' }); + } + let payload; + try { payload = JSON.parse(raw.toString('utf8')); } + catch { return json(res, 400, { error: 'bad json' }); } + const event = req.headers['x-gitea-event'] || req.headers['x-github-event']; + const job = jobFromWebhook(event, payload); + if (!job) { + log(`webhook: ignored event=${event} action=${payload?.action ?? '-'}`); + return json(res, 200, { accepted: false, reason: 'not reviewable' }); + } + log(`webhook: accepted ${job.event} ${job.owner}/${job.repo}@${String(job.sha).slice(0, 10)}${job.prIndex ? ' PR#' + job.prIndex : ''}`); + // respond immediately; review runs async on the queue + json(res, 202, { accepted: true }); + queue.enqueue(`${job.owner}/${job.repo}`, (withGlobal) => + runReview({ cfg, gitea, db, job, withGlobal, log }) + ).catch(e => log(`review pipeline error for ${job.owner}/${job.repo}@${job.sha}: ${e.stack || e.message}`)); + }); + req.on('error', e => log('webhook req error:', e.message)); +} + +function json(res, code, obj) { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); +} + +server.listen(cfg.port, '0.0.0.0', () => { + log(`granthi-review listening on :${cfg.port} (forge=${cfg.forgeBaseUrl}, model=${cfg.model})`); +}); diff --git a/test/jsonExtract.test.js b/test/jsonExtract.test.js new file mode 100644 index 0000000..9b98f55 --- /dev/null +++ b/test/jsonExtract.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractJson } from '../src/lib/jsonExtract.js'; + +test('parses bare JSON', () => { + assert.deepEqual(extractJson('{"a":1}'), { a: 1 }); +}); + +test('parses fenced json block', () => { + const out = extractJson('Here is the review:\n```json\n{"verdict":"pass"}\n```\nDone.'); + assert.deepEqual(out, { verdict: 'pass' }); +}); + +test('parses fenced block without language tag', () => { + assert.deepEqual(extractJson('```\n{"x": [1,2]}\n```'), { x: [1, 2] }); +}); + +test('parses object embedded in prose', () => { + const out = extractJson('Sure! The result is {"grade":"B","overall":82} as requested.'); + assert.deepEqual(out, { grade: 'B', overall: 82 }); +}); + +test('handles braces inside strings', () => { + const out = extractJson('prefix {"detail":"if (x) { return; }","n":2} suffix'); + assert.deepEqual(out, { detail: 'if (x) { return; }', n: 2 }); +}); + +test('handles escaped quotes inside strings', () => { + const out = extractJson('{"t":"she said \\"hi\\" {ok}"}'); + assert.deepEqual(out, { t: 'she said "hi" {ok}' }); +}); + +test('strips think blocks', () => { + const out = extractJson('{"draft":true} reasoning{"final":true}'); + assert.deepEqual(out, { final: true }); +}); + +test('repairs trailing commas', () => { + assert.deepEqual(extractJson('{"a":1,"b":[1,2,],}'), { a: 1, b: [1, 2] }); +}); + +test('skips broken object and finds later valid one', () => { + const out = extractJson('{"broken": nope} then {"ok":1}'); + assert.deepEqual(out, { ok: 1 }); +}); + +test('returns null for no JSON', () => { + assert.equal(extractJson('no json here'), null); + assert.equal(extractJson(''), null); + assert.equal(extractJson(null), null); +}); + +test('returns null for top-level array', () => { + assert.equal(extractJson('[1,2,3]'), null); +}); diff --git a/test/trailers.test.js b/test/trailers.test.js new file mode 100644 index 0000000..8d5663c --- /dev/null +++ b/test/trailers.test.js @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTrailers, agentSlug } from '../src/lib/trailers.js'; + +test('parses standard Co-Authored-By trailer', () => { + const msg = 'fix: thing\n\nCo-Authored-By: Claude Fable 5 '; + assert.deepEqual(parseTrailers(msg), [{ name: 'Claude Fable 5', email: 'noreply@anthropic.com' }]); +}); + +test('case-insensitive and tolerant spacing', () => { + const msg = 'x\nco-authored-by: Codex Bot '; + assert.deepEqual(parseTrailers(msg), [{ name: 'Codex Bot', email: 'codex@openai.com' }]); +}); + +test('multiple distinct trailers', () => { + const msg = 'x\nCo-Authored-By: A \nCo-Authored-By: B '; + assert.equal(parseTrailers(msg).length, 2); +}); + +test('dedupes identical trailers', () => { + const msg = 'x\nCo-Authored-By: A \nCo-authored-by: A '; + assert.equal(parseTrailers(msg).length, 1); +}); + +test('trailer without email', () => { + const msg = 'x\nCo-Authored-By: Mystery Agent'; + assert.deepEqual(parseTrailers(msg), [{ name: 'Mystery Agent', email: '' }]); +}); + +test('ignores non-trailer lines', () => { + assert.deepEqual(parseTrailers('Co-Authored-By appears in prose but not as trailer: nope\nplain line'), []); + assert.deepEqual(parseTrailers(''), []); + assert.deepEqual(parseTrailers(null), []); +}); + +test('agentSlug from email localpart', () => { + assert.equal(agentSlug({ name: 'Claude Fable 5', email: 'noreply@anthropic.com' }), 'noreply'); +}); + +test('agentSlug from name when no email', () => { + assert.equal(agentSlug({ name: 'My Agent!', email: '' }), 'my-agent'); +}); diff --git a/test/verdict.test.js b/test/verdict.test.js new file mode 100644 index 0000000..378484a --- /dev/null +++ b/test/verdict.test.js @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveVerdict, isBlockingFinding, normalizeReview } from '../src/lib/verdict.js'; + +const F = (over = {}) => ({ + title: 't', file: 'f.js', line: 1, + severity: 'low', confidence: 'plausible', axis: 'style', detail: 'd', ...over +}); + +test('confirmed critical correctness blocks', () => { + assert.equal(isBlockingFinding(F({ severity: 'critical', confidence: 'confirmed', axis: 'correctness' })), true); +}); + +test('confirmed high security blocks', () => { + assert.equal(isBlockingFinding(F({ severity: 'high', confidence: 'confirmed', axis: 'security' })), true); +}); + +test('plausible critical does NOT block', () => { + assert.equal(isBlockingFinding(F({ severity: 'critical', confidence: 'plausible', axis: 'correctness' })), false); +}); + +test('confirmed critical style does NOT block', () => { + assert.equal(isBlockingFinding(F({ severity: 'critical', confidence: 'confirmed', axis: 'style' })), false); +}); + +test('confirmed medium does NOT block', () => { + assert.equal(isBlockingFinding(F({ severity: 'medium', confidence: 'confirmed', axis: 'security' })), false); +}); + +test('untagged axis + confirmed critical blocks (conservative)', () => { + assert.equal(isBlockingFinding(F({ severity: 'critical', confidence: 'confirmed', axis: null })), true); +}); + +test('verdict fail on blocking finding', () => { + const v = deriveVerdict({ findings: [F({ severity: 'high', confidence: 'confirmed', axis: 'correctness' })] }); + assert.equal(v, 'fail'); +}); + +test('verdict warn on plausible high', () => { + const v = deriveVerdict({ findings: [F({ severity: 'high', confidence: 'plausible', axis: 'correctness' })] }); + assert.equal(v, 'warn'); +}); + +test('verdict warn on confirmed medium', () => { + const v = deriveVerdict({ findings: [F({ severity: 'medium', confidence: 'confirmed', axis: 'security' })] }); + assert.equal(v, 'warn'); +}); + +test('verdict pass with only low findings', () => { + const v = deriveVerdict({ findings: [F({ severity: 'low' })] }); + assert.equal(v, 'pass'); +}); + +test('verdict pass with no findings', () => { + assert.equal(deriveVerdict({ findings: [] }), 'pass'); + assert.equal(deriveVerdict({}), 'pass'); +}); + +test('normalizeReview clamps scores and overrides model verdict', () => { + const r = normalizeReview({ + axes: { correctness: { score: 250, rationale: 'x' } }, + overall: -5, grade: 'z', + findings: [{ title: 'boom', severity: 'CRITICAL', confidence: 'Confirmed', axis: 'Correctness', file: 'a.js', line: '7' }], + verdict: 'pass' // model says pass, but blocking finding exists + }); + assert.equal(r.axes.correctness.score, 100); + assert.equal(r.axes.security.score, 0); // missing axis defaults + assert.equal(r.overall, 0); + assert.equal(r.grade, 'C'); + assert.equal(r.findings[0].severity, 'critical'); + assert.equal(r.findings[0].line, 7); + assert.equal(r.verdict, 'fail'); // service-side derivation is authoritative +}); + +test('normalizeReview tolerates junk', () => { + assert.equal(normalizeReview(null), null); + const r = normalizeReview({}); + assert.equal(r.verdict, 'pass'); + assert.equal(r.findings.length, 0); +});