fix: review hardening — full push-range diff, fail-closed diff fetch, crash-safe queue, 403 self-heal
- push reviews now fetch ONE compare diff (before...after) instead of
slicing to the first 20 commits under a head-sha status; new-branch
pushes (zero before-sha) fall back to the head commit diff
- truncated diffs can no longer yield a clean pass: verdict capped at
warn and status description prefixed 'partial review (diff truncated)'
- any diff-fetch failure (throw / empty body on a non-empty push or PR)
short-circuits to status 'warning' ('review unavailable — diff fetch
failed (not blocking)') with a ledger row; a diffless prompt is never
sent to the model
- accepted jobs persist to sqlite (queue_jobs) before the pending status
posts; startup re-enqueues rows that never reached a final state, so
restarts no longer strand shas at 'pending'
- 403 on status/comment posts self-heals: admin-scoped token (config
admin_token) adds shre-reviewer as collaborator (write), retries once
- scripts/wire-repos.mjs: idempotent estate-wide webhook + collaborator
wiring over GET /repos/search
- tests: 32 -> 51 (truncation cap, persisted-queue reconciliation,
collab-retry stubs, fetchDiff routing)
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f35a75f787
commit
e32c600215
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
// Wire EVERY repo on the peer forge for granthi-review:
|
||||
// - create the push+pull_request webhook -> <hookUrl> (HMAC secret from the
|
||||
// service config) on every repo that lacks it (idempotent: skipped when a
|
||||
// hook with that exact URL already exists)
|
||||
// - add the bot (shre-reviewer) as collaborator (write) so status/comment
|
||||
// posts don't 403
|
||||
// Re-runnable at any time. Uses the admin-scoped token (config admin_token).
|
||||
//
|
||||
// Usage: node scripts/wire-repos.mjs [--dry-run]
|
||||
|
||||
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 cfg = JSON.parse(readFileSync(join(CONFIG_DIR, 'config.json'), 'utf8'));
|
||||
|
||||
const base = (cfg.forgeBaseUrl || 'http://localhost:3030').replace(/\/$/, '');
|
||||
const adminToken = cfg.admin_token || process.env.GITEA_CLAUDE_SIA_TOKEN;
|
||||
const secret = cfg.webhookSecret;
|
||||
const botUsername = cfg.botUsername || 'shre-reviewer';
|
||||
const hookUrl = process.env.GRANTHI_REVIEW_HOOK_URL || 'http://host.docker.internal:5498/webhook';
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
if (!adminToken) { console.error('no admin token (config admin_token or $GITEA_CLAUDE_SIA_TOKEN)'); process.exit(1); }
|
||||
if (!secret) { console.error('no webhookSecret in config'); process.exit(1); }
|
||||
|
||||
async function api(method, path, body, ok = [200, 201, 204]) {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${adminToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!ok.includes(res.status)) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function allRepos() {
|
||||
const repos = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const out = await api('GET', `/api/v1/repos/search?limit=50&page=${page}`);
|
||||
const batch = out?.data || [];
|
||||
repos.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
const repos = await allRepos();
|
||||
let hooksCreated = 0, hooksPresent = 0, collabAdded = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (const r of repos) {
|
||||
const full = r.full_name;
|
||||
const [owner, name] = full.split('/');
|
||||
try {
|
||||
if (r.archived || r.empty || r.mirror) { skipped++; console.log(`skip ${full} (${r.archived ? 'archived' : r.mirror ? 'mirror' : 'empty'})`); continue; }
|
||||
const hooks = await api('GET', `/api/v1/repos/${full}/hooks?limit=50`) || [];
|
||||
const has = hooks.some(h => h?.config?.url === hookUrl);
|
||||
if (has) {
|
||||
hooksPresent++;
|
||||
} else if (dryRun) {
|
||||
console.log(`would wire ${full}`);
|
||||
} else {
|
||||
await api('POST', `/api/v1/repos/${full}/hooks`, {
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['push', 'pull_request'],
|
||||
config: { url: hookUrl, content_type: 'json', secret }
|
||||
});
|
||||
hooksCreated++;
|
||||
console.log(`wired ${full}`);
|
||||
}
|
||||
// collaborator write for the bot (idempotent PUT); skip bot-owned repos
|
||||
if (owner !== botUsername && !dryRun) {
|
||||
await api('PUT', `/api/v1/repos/${full}/collaborators/${botUsername}`, { permission: 'write' });
|
||||
collabAdded++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`ERROR ${full}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ntotal repos: ${repos.length}`);
|
||||
console.log(`hooks created: ${hooksCreated}, already wired: ${hooksPresent}, skipped (archived/empty/mirror): ${skipped}`);
|
||||
console.log(`collaborator (write) ensured: ${collabAdded}, errors: ${errors}`);
|
||||
process.exit(errors ? 2 : 0);
|
||||
Reference in New Issue
Block a user