@fullstackhouse/cerber 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Full Stack House
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # cerber 🐕
2
+
3
+ **AI code-review cockpit.** Claude reviews your pull requests into local artifacts —
4
+ a summary, a chaptered walkthrough of the changes, draft inline comments, and a
5
+ verdict with a confidence score. You go through it in a local web cockpit.
6
+
7
+ **Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
8
+ writes happen when you hit Send (coming in a later phase) — reviewing is 100%
9
+ local and read-only.
10
+
11
+ Named after Cerberus, the gatekeeper — and a sibling of
12
+ [cezar](https://github.com/open-mercato/cezar): cezar runs coding agents,
13
+ cerber guards what gets merged.
14
+
15
+ ## Quick start
16
+
17
+ Requires Node 20+, an authenticated [`gh`](https://cli.github.com/) and a
18
+ logged-in [`claude`](https://claude.com/claude-code) CLI. No API keys, no config, no database.
19
+
20
+ ```bash
21
+ # Review a PR (writes a local artifact to ~/.cerber, nothing else)
22
+ npx @fullstackhouse/cerber review https://github.com/owner/repo/pull/123
23
+
24
+ # Review several
25
+ npx @fullstackhouse/cerber review owner/repo#123 owner/repo#124
26
+ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
27
+
28
+ # See what's in the queue
29
+ npx @fullstackhouse/cerber list
30
+
31
+ # Open the cockpit
32
+ npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
33
+ ```
34
+
35
+ ## What a review looks like
36
+
37
+ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
38
+ `CERBER_HOME`) containing:
39
+
40
+ - **Summary** — what the PR actually does and why, written top-down
41
+ - **Chapters** — the changed files grouped into a logical walkthrough, each
42
+ with a title, an explanation, and its slice of the diff
43
+ - **Draft comments** — inline, anchored to file/line, only things worth a
44
+ human's time
45
+ - **Verdict** — approve / comment / request changes, with a 0–100 confidence
46
+ score and reasoning
47
+
48
+ The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
49
+ diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
50
+
51
+ ## Status / roadmap
52
+
53
+ Early. Current phase: review + read-only cockpit.
54
+
55
+ 1. ✅ `cerber review <pr>` + artifact schema + cockpit (read-only)
56
+ 2. ✅ Comment editing in the cockpit (edit/approve/drop/add), verdict override,
57
+ gated **Send** (double-confirm; posts the review via your `gh` — the only
58
+ GitHub write in the codebase) + `cerber export` to markdown
59
+ 3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
60
+ freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
61
+ 4. Daemon/VPS mode: poll for PRs awaiting your review, review them
62
+ automatically, cockpit always warm
63
+ 5. Confidence calibration → shadow-mode auto-send → opt-in auto-send above a
64
+ threshold
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ pnpm install
70
+ pnpm dev review <pr> # run the CLI from source
71
+ pnpm dev serve # API on :4820
72
+ pnpm --dir . typecheck && pnpm test
73
+ pnpm build # dist/ (CLI+server) + web/dist (cockpit)
74
+ ```
75
+
76
+ Web cockpit dev with hot reload: `pnpm dev serve` in one terminal,
77
+ `npx vite --config web/vite.config.ts` in another (proxies `/api`).
78
+
79
+ ## License
80
+
81
+ MIT © Full Stack House
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import readline from "node:readline/promises";
4
+ import { artifactId, artifactKey } from "../core/artifact.js";
5
+ import { toMarkdown } from "../core/export.js";
6
+ import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
7
+ import { buildReviewPayload, eventForRecommendation } from "../core/send.js";
8
+ import { cerberHome, listArtifacts, loadArtifact, updateArtifactByKey } from "../core/state.js";
9
+ import { pool, reviewPr } from "../runner/review.js";
10
+ import { startServer } from "../server/index.js";
11
+ const program = new Command();
12
+ program
13
+ .name("cerber")
14
+ .description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
15
+ .version("0.3.0");
16
+ program
17
+ .command("review")
18
+ .description("Run an AI review of one or more PRs (local artifact only — no GitHub writes)")
19
+ .argument("[pr...]", "PR URL, owner/repo#number, or number (with --repo)")
20
+ .option("-R, --repo <owner/repo>", "repository for bare PR numbers / to scope --awaiting-me")
21
+ .option("-m, --model <model>", "Claude model override (e.g. sonnet, opus)")
22
+ .option("-a, --awaiting-me", "discover and review all open PRs awaiting your review")
23
+ .option("-P, --parallel <n>", "how many reviews to run concurrently", "3")
24
+ .option("-f, --force", "re-review even if the artifact is up to date with the PR head")
25
+ .action(async (prs, opts) => {
26
+ let refs;
27
+ if (opts.awaitingMe) {
28
+ const found = await searchAwaitingMe(opts.repo);
29
+ console.log(`Found ${found.length} open PR(s) awaiting your review${opts.repo ? ` in ${opts.repo}` : ""}.`);
30
+ for (const f of found) {
31
+ console.log(` ${f.owner}/${f.repo}#${f.number} — ${f.title}`);
32
+ }
33
+ refs = found;
34
+ }
35
+ else {
36
+ if (prs.length === 0) {
37
+ console.error("Pass PR references, or use --awaiting-me.");
38
+ process.exit(1);
39
+ }
40
+ refs = prs.map((input) => parsePrRef(input, opts.repo));
41
+ }
42
+ if (refs.length === 0)
43
+ return;
44
+ const parallel = Math.max(1, Number(opts.parallel) || 1);
45
+ let failures = 0;
46
+ let skips = 0;
47
+ const rows = [];
48
+ await pool(refs, parallel, async (ref) => {
49
+ const label = `${ref.owner}/${ref.repo}#${ref.number}`;
50
+ try {
51
+ const { artifact, skipped } = await reviewPr(ref, {
52
+ model: opts.model,
53
+ force: opts.force,
54
+ onProgress: (m) => console.log(`[${label}] ${m}`),
55
+ });
56
+ if (skipped) {
57
+ skips++;
58
+ return;
59
+ }
60
+ const v = artifact.verdict;
61
+ rows.push(`✔ ${label.padEnd(40)} ${v ? `${v.recommendation} ${v.confidence}%` : "?"}`.padEnd(65) +
62
+ `${artifact.comments.length} comment(s)` +
63
+ (artifact.run?.costUsd != null ? `, $${artifact.run.costUsd.toFixed(2)}` : ""));
64
+ }
65
+ catch (err) {
66
+ failures++;
67
+ rows.push(`✘ ${label} failed: ${err instanceof Error ? err.message : err}`);
68
+ }
69
+ });
70
+ console.log("");
71
+ for (const row of rows)
72
+ console.log(row);
73
+ if (skips > 0)
74
+ console.log(`(${skips} already up to date — use --force to re-review)`);
75
+ console.log(`\nArtifacts in ${cerberHome()}/reviews — view with: cerber serve`);
76
+ if (failures > 0)
77
+ process.exitCode = 1;
78
+ });
79
+ program
80
+ .command("list")
81
+ .description("List review artifacts and their statuses")
82
+ .action(async () => {
83
+ const artifacts = await listArtifacts();
84
+ if (artifacts.length === 0) {
85
+ console.log(`No reviews yet in ${cerberHome()}. Run: cerber review <pr-url>`);
86
+ return;
87
+ }
88
+ for (const a of artifacts) {
89
+ const v = a.verdict ? `${a.verdict.recommendation} ${a.verdict.confidence}%` : "-";
90
+ console.log(`${a.status.padEnd(8)} ${a.id.padEnd(40)} ${v.padEnd(20)} ${a.pr.title}`);
91
+ }
92
+ });
93
+ program
94
+ .command("export")
95
+ .description("Print a review as markdown (never touches GitHub)")
96
+ .argument("<pr>", "PR URL, owner/repo#number, or number (with --repo)")
97
+ .option("-R, --repo <owner/repo>", "repository for bare PR numbers")
98
+ .action(async (input, opts) => {
99
+ const ref = parsePrRef(input, opts.repo);
100
+ const artifact = await loadArtifact(artifactId(ref));
101
+ if (!artifact) {
102
+ console.error(`No review found for ${artifactId(ref)}. Run: cerber review ${input}`);
103
+ process.exit(1);
104
+ }
105
+ console.log(toMarkdown(artifact));
106
+ });
107
+ program
108
+ .command("send")
109
+ .description("Send a reviewed artifact to GitHub as a PR review — the ONLY command that writes to GitHub")
110
+ .argument("<pr>", "PR URL, owner/repo#number, or number (with --repo)")
111
+ .option("-R, --repo <owner/repo>", "repository for bare PR numbers")
112
+ .option("-e, --event <event>", "APPROVE | COMMENT | REQUEST_CHANGES (default: the artifact's verdict)")
113
+ .option("-y, --yes", "skip the confirmation prompt")
114
+ .action(async (input, opts) => {
115
+ const ref = parsePrRef(input, opts.repo);
116
+ const id = artifactId(ref);
117
+ const artifact = await loadArtifact(id);
118
+ if (!artifact) {
119
+ console.error(`No review found for ${id}. Run: cerber review ${input}`);
120
+ process.exit(1);
121
+ }
122
+ if (artifact.sent) {
123
+ console.error(`Already sent at ${artifact.sent.at}${artifact.sent.url ? ` — ${artifact.sent.url}` : ""}`);
124
+ process.exit(1);
125
+ }
126
+ const event = (opts.event ??
127
+ (artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
128
+ if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
129
+ console.error(`Invalid event: ${event}`);
130
+ process.exit(1);
131
+ }
132
+ const payload = buildReviewPayload(artifact, event);
133
+ console.log(`About to send to ${artifact.pr.url}:`);
134
+ console.log(` event: ${event}`);
135
+ console.log(` inline comments: ${payload.comments.length}`);
136
+ if (payload.folded.length > 0) {
137
+ console.log(` comments folded into body (no diff anchor): ${payload.folded.length}`);
138
+ }
139
+ console.log(` body: ${payload.body.length} chars\n`);
140
+ if (!opts.yes) {
141
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
142
+ const answer = await rl.question("Send this review to GitHub? [y/N] ");
143
+ rl.close();
144
+ if (answer.trim().toLowerCase() !== "y") {
145
+ console.log("Aborted — nothing was sent.");
146
+ return;
147
+ }
148
+ }
149
+ const { url } = await submitReview(ref, {
150
+ event: payload.event,
151
+ body: payload.body,
152
+ comments: payload.comments,
153
+ });
154
+ await updateArtifactByKey(artifactKey(id), (a) => ({
155
+ ...a,
156
+ status: "sent",
157
+ sent: { at: new Date().toISOString(), event, url },
158
+ }));
159
+ console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
160
+ });
161
+ program
162
+ .command("serve")
163
+ .description("Start the local review cockpit")
164
+ .option("-p, --port <port>", "port", "4820")
165
+ .option("-H, --host <host>", "host to bind", "127.0.0.1")
166
+ .action(async (opts) => {
167
+ await startServer({ port: Number(opts.port), host: opts.host });
168
+ });
169
+ program.parseAsync().catch((err) => {
170
+ console.error(err instanceof Error ? err.message : err);
171
+ process.exit(1);
172
+ });
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ export const SCHEMA_VERSION = 1;
3
+ /** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
4
+ export const CommentSchema = z.object({
5
+ id: z.string(),
6
+ path: z.string(),
7
+ /** Line number in the NEW version of the file; null = file-level comment. */
8
+ line: z.number().int().positive().nullable(),
9
+ body: z.string(),
10
+ chapterId: z.string().nullable().default(null),
11
+ origin: z.enum(["ai", "user"]),
12
+ status: z.enum(["draft", "approved", "dropped"]).default("draft"),
13
+ });
14
+ /** A logical group of changes — the unit of the review walkthrough. */
15
+ export const ChapterSchema = z.object({
16
+ id: z.string(),
17
+ title: z.string(),
18
+ explanation: z.string(),
19
+ files: z.array(z.string()),
20
+ });
21
+ export const VerdictSchema = z.object({
22
+ recommendation: z.enum(["approve", "comment", "request_changes"]),
23
+ /** 0-100. How confident the AI is that its recommendation is right. */
24
+ confidence: z.number().min(0).max(100),
25
+ reasoning: z.string(),
26
+ });
27
+ export const PrInfoSchema = z.object({
28
+ owner: z.string(),
29
+ repo: z.string(),
30
+ number: z.number().int().positive(),
31
+ title: z.string(),
32
+ url: z.string(),
33
+ author: z.string(),
34
+ body: z.string().default(""),
35
+ baseRefName: z.string(),
36
+ headRefName: z.string(),
37
+ headSha: z.string().default(""),
38
+ additions: z.number().int().default(0),
39
+ deletions: z.number().int().default(0),
40
+ changedFiles: z.number().int().default(0),
41
+ });
42
+ export const ArtifactStatusSchema = z.enum([
43
+ "awaiting", // discovered, not yet reviewed by AI
44
+ "running", // AI review in progress
45
+ "ready", // AI review done, awaiting the human
46
+ "reviewed", // human went through it (edited/approved comments)
47
+ "sent", // review submitted to GitHub by explicit user action
48
+ "skipped", // human decided not to review
49
+ "failed", // AI run errored
50
+ ]);
51
+ export const RunInfoSchema = z.object({
52
+ model: z.string().nullable().default(null),
53
+ startedAt: z.string(),
54
+ finishedAt: z.string().nullable().default(null),
55
+ costUsd: z.number().nullable().default(null),
56
+ error: z.string().nullable().default(null),
57
+ });
58
+ export const SentInfoSchema = z.object({
59
+ at: z.string(),
60
+ event: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
61
+ url: z.string().nullable().default(null),
62
+ });
63
+ export const ArtifactSchema = z.object({
64
+ schemaVersion: z.literal(SCHEMA_VERSION),
65
+ /** "owner/repo#123" */
66
+ id: z.string(),
67
+ status: ArtifactStatusSchema,
68
+ createdAt: z.string(),
69
+ updatedAt: z.string(),
70
+ pr: PrInfoSchema,
71
+ /** Full unified diff of the PR, as fetched at review time. */
72
+ diff: z.string(),
73
+ /** Markdown summary of what the PR does (pr-polish style). */
74
+ summary: z.string().default(""),
75
+ chapters: z.array(ChapterSchema).default([]),
76
+ comments: z.array(CommentSchema).default([]),
77
+ verdict: VerdictSchema.nullable().default(null),
78
+ run: RunInfoSchema.nullable().default(null),
79
+ /** Set once the user explicitly sent the review to GitHub. */
80
+ sent: SentInfoSchema.nullable().default(null),
81
+ });
82
+ /** What the AI must return. Subset of the artifact; cerber owns the rest. */
83
+ export const AiReviewSchema = z.object({
84
+ summary: z.string(),
85
+ chapters: z.array(z.object({
86
+ id: z.string(),
87
+ title: z.string(),
88
+ explanation: z.string(),
89
+ files: z.array(z.string()),
90
+ })),
91
+ comments: z.array(z.object({
92
+ path: z.string(),
93
+ line: z.number().int().positive().nullable(),
94
+ body: z.string(),
95
+ chapterId: z.string().nullable().optional(),
96
+ })),
97
+ verdict: VerdictSchema,
98
+ });
99
+ export function artifactId(pr) {
100
+ return `${pr.owner}/${pr.repo}#${pr.number}`;
101
+ }
102
+ /** Filesystem-safe key for an artifact id: "owner/repo#123" -> "owner__repo__123" */
103
+ export function artifactKey(id) {
104
+ return id.replace(/\//g, "__").replace(/#/g, "__");
105
+ }
@@ -0,0 +1,86 @@
1
+ export function splitDiffByFile(diff) {
2
+ const patches = [];
3
+ const lines = diff.split("\n");
4
+ let current = null;
5
+ let currentPath = null;
6
+ const flush = () => {
7
+ if (current && currentPath) {
8
+ patches.push({ path: currentPath, patch: current.join("\n") });
9
+ }
10
+ current = null;
11
+ currentPath = null;
12
+ };
13
+ for (const line of lines) {
14
+ if (line.startsWith("diff --git ")) {
15
+ flush();
16
+ current = [line];
17
+ // "diff --git a/foo b/foo" — prefer the b-side path; fall back to a-side.
18
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
19
+ currentPath = m ? m[2] : line.slice("diff --git ".length);
20
+ }
21
+ else if (current) {
22
+ current.push(line);
23
+ // Deleted files: b-side is /dev/null, use the a-side path instead.
24
+ if (line.startsWith("--- a/") && currentPath === "/dev/null") {
25
+ currentPath = line.slice("--- a/".length);
26
+ }
27
+ if (line === "+++ /dev/null") {
28
+ const minus = current.find((l) => l.startsWith("--- a/"));
29
+ if (minus)
30
+ currentPath = minus.slice("--- a/".length);
31
+ }
32
+ }
33
+ }
34
+ flush();
35
+ return patches;
36
+ }
37
+ /** Concatenate the patches of the given files (order = diff order). */
38
+ export function patchForFiles(diff, files) {
39
+ const wanted = new Set(files);
40
+ return splitDiffByFile(diff)
41
+ .filter((p) => wanted.has(p.path))
42
+ .map((p) => p.patch)
43
+ .join("\n");
44
+ }
45
+ /**
46
+ * Line numbers (new side of the diff) that a GitHub review comment can attach
47
+ * to, per file: added and context lines inside hunks.
48
+ */
49
+ export function newSideLines(diff) {
50
+ const result = new Map();
51
+ for (const { path, patch } of splitDiffByFile(diff)) {
52
+ const lines = new Set();
53
+ let newLine = 0;
54
+ let inHunk = false;
55
+ const patchLines = patch.split("\n");
56
+ for (let i = 0; i < patchLines.length; i++) {
57
+ const line = patchLines[i];
58
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
59
+ if (hunk) {
60
+ newLine = Number(hunk[1]);
61
+ inHunk = true;
62
+ continue;
63
+ }
64
+ if (!inHunk)
65
+ continue;
66
+ if (line === "" && i === patchLines.length - 1)
67
+ continue; // trailing split artifact
68
+ if (line.startsWith("\\"))
69
+ continue; // ""
70
+ if (line.startsWith("+") || line.startsWith(" ") || line === "") {
71
+ lines.add(newLine);
72
+ newLine++;
73
+ }
74
+ // "-" lines belong to the old side only.
75
+ }
76
+ result.set(path, lines);
77
+ }
78
+ return result;
79
+ }
80
+ /** Paths present in the diff but not claimed by any chapter. */
81
+ export function unclaimedFiles(diff, chapters) {
82
+ const claimed = new Set(chapters.flatMap((c) => c.files));
83
+ return splitDiffByFile(diff)
84
+ .map((p) => p.path)
85
+ .filter((p) => !claimed.has(p));
86
+ }
@@ -0,0 +1,48 @@
1
+ /** Render an artifact as a standalone markdown review document. */
2
+ export function toMarkdown(artifact) {
3
+ const lines = [];
4
+ const { pr, verdict } = artifact;
5
+ lines.push(`# Review: ${artifact.id} — ${pr.title}`);
6
+ lines.push("");
7
+ lines.push(`> ${pr.url}`);
8
+ lines.push(`> ${pr.author} · \`${pr.headRefName}\` → \`${pr.baseRefName}\` · ${pr.changedFiles} files +${pr.additions} -${pr.deletions}`);
9
+ lines.push("");
10
+ if (verdict) {
11
+ lines.push(`**Verdict: ${verdict.recommendation.replace("_", " ")}** (confidence ${verdict.confidence}%)`);
12
+ lines.push("");
13
+ lines.push(verdict.reasoning);
14
+ lines.push("");
15
+ }
16
+ lines.push("## Summary");
17
+ lines.push("");
18
+ lines.push(artifact.summary || "(no summary)");
19
+ lines.push("");
20
+ if (artifact.chapters.length > 0) {
21
+ lines.push("## Walkthrough");
22
+ lines.push("");
23
+ for (const ch of artifact.chapters) {
24
+ lines.push(`### ${ch.title}`);
25
+ lines.push("");
26
+ lines.push(ch.explanation);
27
+ lines.push("");
28
+ lines.push(ch.files.map((f) => `- \`${f}\``).join("\n"));
29
+ lines.push("");
30
+ const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
31
+ for (const c of comments) {
32
+ lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
33
+ lines.push("");
34
+ }
35
+ }
36
+ }
37
+ const orphans = artifact.comments.filter((c) => c.status !== "dropped" &&
38
+ (!c.chapterId || !artifact.chapters.some((ch) => ch.id === c.chapterId)));
39
+ if (orphans.length > 0) {
40
+ lines.push("## Other comments");
41
+ lines.push("");
42
+ for (const c of orphans) {
43
+ lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
44
+ lines.push("");
45
+ }
46
+ }
47
+ return lines.join("\n");
48
+ }
@@ -0,0 +1,140 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { PrInfoSchema } from "./artifact.js";
4
+ const execFileAsync = promisify(execFile);
5
+ const MAX_BUFFER = 64 * 1024 * 1024;
6
+ /**
7
+ * Accepts: a full PR URL, "owner/repo#123", or a bare number (requires repoFlag "owner/repo").
8
+ */
9
+ export function parsePrRef(input, repoFlag) {
10
+ const urlMatch = input.match(/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
11
+ if (urlMatch) {
12
+ return { owner: urlMatch[1], repo: urlMatch[2], number: Number(urlMatch[3]) };
13
+ }
14
+ const shortMatch = input.match(/^([^/\s#]+)\/([^/\s#]+)#(\d+)$/);
15
+ if (shortMatch) {
16
+ return { owner: shortMatch[1], repo: shortMatch[2], number: Number(shortMatch[3]) };
17
+ }
18
+ const numMatch = input.match(/^#?(\d+)$/);
19
+ if (numMatch) {
20
+ if (!repoFlag) {
21
+ throw new Error(`PR "${input}" is a bare number — pass --repo owner/repo, or use a full URL / owner/repo#number.`);
22
+ }
23
+ const [owner, repo] = repoFlag.split("/");
24
+ if (!owner || !repo)
25
+ throw new Error(`Invalid --repo "${repoFlag}", expected owner/repo.`);
26
+ return { owner, repo, number: Number(numMatch[1]) };
27
+ }
28
+ throw new Error(`Cannot parse PR reference: "${input}"`);
29
+ }
30
+ async function gh(args) {
31
+ try {
32
+ const { stdout } = await execFileAsync("gh", args, { maxBuffer: MAX_BUFFER });
33
+ return stdout;
34
+ }
35
+ catch (err) {
36
+ const e = err;
37
+ throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${e.stderr?.trim() || e.message}`);
38
+ }
39
+ }
40
+ export async function fetchPrInfo(ref) {
41
+ const out = await gh([
42
+ "pr",
43
+ "view",
44
+ String(ref.number),
45
+ "--repo",
46
+ `${ref.owner}/${ref.repo}`,
47
+ "--json",
48
+ "title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles",
49
+ ]);
50
+ const raw = JSON.parse(out);
51
+ return PrInfoSchema.parse({
52
+ owner: ref.owner,
53
+ repo: ref.repo,
54
+ number: ref.number,
55
+ title: raw.title,
56
+ url: raw.url,
57
+ author: raw.author?.login ?? "unknown",
58
+ body: raw.body ?? "",
59
+ baseRefName: raw.baseRefName,
60
+ headRefName: raw.headRefName,
61
+ headSha: raw.headRefOid ?? "",
62
+ additions: raw.additions ?? 0,
63
+ deletions: raw.deletions ?? 0,
64
+ changedFiles: raw.changedFiles ?? 0,
65
+ });
66
+ }
67
+ export async function fetchPrDiff(ref) {
68
+ return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
69
+ }
70
+ /** Map `gh search prs` JSON output to PR refs. Drafts are excluded. */
71
+ export function mapSearchResults(raw) {
72
+ return raw
73
+ .filter((r) => !r.isDraft)
74
+ .flatMap((r) => {
75
+ const [owner, repo] = r.repository.nameWithOwner.split("/");
76
+ if (!owner || !repo)
77
+ return [];
78
+ return [
79
+ {
80
+ owner,
81
+ repo,
82
+ number: r.number,
83
+ title: r.title,
84
+ url: r.url,
85
+ updatedAt: r.updatedAt,
86
+ isDraft: r.isDraft,
87
+ },
88
+ ];
89
+ });
90
+ }
91
+ /** Open, non-draft PRs where the current gh user's review is requested. */
92
+ export async function searchAwaitingMe(repoFilter, limit = 50) {
93
+ const args = [
94
+ "search",
95
+ "prs",
96
+ "--review-requested=@me",
97
+ "--state=open",
98
+ "--limit",
99
+ String(limit),
100
+ "--json",
101
+ "number,title,repository,isDraft,url,updatedAt",
102
+ ];
103
+ if (repoFilter)
104
+ args.push("--repo", repoFilter);
105
+ const out = await gh(args);
106
+ return mapSearchResults(JSON.parse(out));
107
+ }
108
+ /**
109
+ * THE ONLY GITHUB WRITE IN CERBER.
110
+ * Submits a review in one shot. Must only ever be called from an explicit
111
+ * user action (cockpit Send button / `cerber send` after confirmation).
112
+ */
113
+ export async function submitReview(ref, payload) {
114
+ const args = [
115
+ "api",
116
+ `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/reviews`,
117
+ "--method",
118
+ "POST",
119
+ "--input",
120
+ "-",
121
+ ];
122
+ const { execFile: ef } = await import("node:child_process");
123
+ const out = await new Promise((resolve, reject) => {
124
+ const child = ef("gh", args, { maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
125
+ if (err)
126
+ reject(new Error(`gh api reviews failed: ${stderr?.trim() || err.message}`));
127
+ else
128
+ resolve(stdout);
129
+ });
130
+ child.stdin.write(JSON.stringify(payload));
131
+ child.stdin.end();
132
+ });
133
+ try {
134
+ const res = JSON.parse(out);
135
+ return { url: res.html_url ?? null };
136
+ }
137
+ catch {
138
+ return { url: null };
139
+ }
140
+ }