#!/usr/bin/env node // Wire EVERY repo on the peer forge for granthi-review: // - create the push+pull_request webhook -> (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);