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,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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, `<think>` 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": "<shre-reviewer API token: write:repository,write:issue>",
|
||||||
|
"webhookSecret": "<shared HMAC secret>",
|
||||||
|
"routerUrl": "http://localhost:5497",
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
"tenantId": "nirlab",
|
||||||
|
"publicBaseUrl": "http://localhost:5498"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<a class="item" href="http://<review-host>:5498/review/{{.Repository.Owner.Name}}/{{.Repository.Name}}" target="_blank">
|
||||||
|
{{svg "octicon-checklist"}} Reviews
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
(For prod, front the service behind the granthi domain and use a relative
|
||||||
|
path instead of the host-port URL.)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
npm test # node --test: JSON extractor, verdict derivation, trailer parser
|
||||||
|
```
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>ai.granthi.review.digest</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/opt/homebrew/bin/node</string>
|
||||||
|
<string>/Users/aibot/Services/granthi-review/src/server.js</string>
|
||||||
|
<string>--digest</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>/Users/aibot/Services/granthi-review</string>
|
||||||
|
<key>StartCalendarInterval</key>
|
||||||
|
<dict>
|
||||||
|
<key>Hour</key><integer>3</integer>
|
||||||
|
<key>Minute</key><integer>30</integer>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key><false/>
|
||||||
|
<key>StandardOutPath</key><string>/Users/aibot/.granthi-review/digest.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>/Users/aibot/.granthi-review/digest.err.log</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>ai.granthi.review</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/opt/homebrew/bin/node</string>
|
||||||
|
<string>/Users/aibot/Services/granthi-review/src/server.js</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>/Users/aibot/Services/granthi-review</string>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>StandardOutPath</key><string>/Users/aibot/.granthi-review/service.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>/Users/aibot/.granthi-review/service.err.log</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
|
});
|
||||||
@@ -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('<think>{"draft":true} reasoning</think>{"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);
|
||||||
|
});
|
||||||
@@ -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 <[email protected]>';
|
||||||
|
assert.deepEqual(parseTrailers(msg), [{ name: 'Claude Fable 5', email: '[email protected]' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive and tolerant spacing', () => {
|
||||||
|
const msg = 'x\nco-authored-by: Codex Bot <[email protected]> ';
|
||||||
|
assert.deepEqual(parseTrailers(msg), [{ name: 'Codex Bot', email: '[email protected]' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple distinct trailers', () => {
|
||||||
|
const msg = 'x\nCo-Authored-By: A <[email protected]>\nCo-Authored-By: B <[email protected]>';
|
||||||
|
assert.equal(parseTrailers(msg).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupes identical trailers', () => {
|
||||||
|
const msg = 'x\nCo-Authored-By: A <[email protected]>\nCo-authored-by: A <[email protected]>';
|
||||||
|
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: '[email protected]' }), 'noreply');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agentSlug from name when no email', () => {
|
||||||
|
assert.equal(agentSlug({ name: 'My Agent!', email: '' }), 'my-agent');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user