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 <[email protected]>
This commit is contained in:
Nirav Patel
2026-08-18 23:24:27 -04:00
co-authored by Claude Fable 5
commit 9ffd810943
22 changed files with 1255 additions and 0 deletions
+35
View File
@@ -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;
}
+66
View File
@@ -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);
}
+64
View File
@@ -0,0 +1,64 @@
// Nightly per-agent digests. For every distinct agent identity seen in
// Co-Authored-By trailers, write markdown to <digestDir>/<agent>.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; }
+71
View File
@@ -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`);
}
}
+62
View File
@@ -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 <think> blocks.
export function extractJson(text) {
if (text == null) return null;
let s = String(text);
// strip <think>...</think> reasoning blocks
s = s.replace(/<think>[\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; }
}
+45
View File
@@ -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 }
];
}
+33
View File
@@ -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;
}
}
+95
View File
@@ -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<details><summary>Finding details</summary>\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 += `</details>\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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 => `<td class="num">${axes ? esc(axes[a]?.score ?? '—') : '—'}</td>`).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
? `<details><summary>${findings.length} finding(s)</summary><ul>` +
findings.map(f => `<li><b>${esc(f.severity)}/${esc(f.confidence)}</b> ${esc(f.title)} <code>${esc(f.file || '')}</code></li>`).join('') +
`</ul></details>`
: '—';
return `<tr>
<td>${esc(r.ts)}</td>
<td><code>${esc(String(r.sha).slice(0, 10))}</code></td>
<td>${r.pr ? '#' + esc(r.pr) : esc(r.event)}</td>
<td>${esc(r.pusher ?? '')}</td>
<td>${agents || '—'}</td>
<td class="num">${esc(r.overall ?? '—')}</td>
<td>${esc(r.grade ?? '—')}</td>
<td class="${vClass}">${esc(r.verdict ?? (r.error ? 'error' : '—'))}</td>
${axesCells}
<td>${fsum}</td>
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : ''}</td>
</tr>`;
}).join('\n');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>granthi-review · ${esc(repo)}</title>
<style>
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 2rem; color: #1a1a1a; background: #fafafa; }
h1 { font-size: 1.3rem; } h1 code { background:#eee; padding:2px 6px; border-radius:4px; }
table { border-collapse: collapse; width: 100%; background: #fff; font-size: 13px; }
th, td { border: 1px solid #e2e2e2; padding: 5px 8px; text-align: left; vertical-align: top; }
th { background: #f0f0f0; position: sticky; top: 0; }
td.num { text-align: right; }
.pass { color: #1a7f37; font-weight: 600; } .warn { color: #9a6700; font-weight: 600; } .fail { color: #cf222e; font-weight: 600; }
.wrap { overflow-x: auto; }
footer { margin-top: 1rem; color: #888; font-size: 12px; }
</style></head><body>
<h1>granthi-review · <code>${esc(repo)}</code></h1>
<div class="wrap"><table>
<tr><th>ts (utc)</th><th>sha</th><th>event</th><th>pusher</th><th>agents</th><th>overall</th><th>grade</th><th>verdict</th>
${AXES.map(a => `<th>${a.replace(/_/g, ' ')}</th>`).join('')}<th>findings</th><th>error</th></tr>
${trs || '<tr><td colspan="20">No reviews yet.</td></tr>'}
</table></div>
<footer>granthi-review · posture: block on confirmed critical/high correctness+security, admin merge = override</footer>
</body></html>`;
}
+165
View File
@@ -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; }
}
+22
View File
@@ -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;
}
+29
View File
@@ -0,0 +1,29 @@
// Parse commit-message trailers for agent attribution.
// Recognizes Co-Authored-By: Name <email> (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';
}
+73
View File
@@ -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;
}
+62
View File
@@ -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)]; }
+97
View File
@@ -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(`<h1>granthi-review</h1><p>AI code review service. See /review/{owner}/{repo} for history, /health for status.</p>`);
}
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})`);
});