Files
granthi-review/scripts/wire-repos.mjs
T

96 lines
3.7 KiB
JavaScript
Raw Normal View History

#!/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);