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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '&').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 => `<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>`;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)]; }
|
||||
@@ -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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user