@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 +21 -0
- package/README.md +81 -0
- package/dist/cli/index.js +172 -0
- package/dist/core/artifact.js +105 -0
- package/dist/core/diff.js +86 -0
- package/dist/core/export.js +48 -0
- package/dist/core/gh.js +140 -0
- package/dist/core/send.js +41 -0
- package/dist/core/state.js +77 -0
- package/dist/runner/claude.js +65 -0
- package/dist/runner/prompt.js +71 -0
- package/dist/runner/review.js +130 -0
- package/dist/server/index.js +178 -0
- package/package.json +46 -0
- package/web/dist/assets/index-B73WZWQg.css +1 -0
- package/web/dist/assets/index-CjzYwp5w.js +163 -0
- package/web/dist/index.html +13 -0
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
|
+
}
|
package/dist/core/gh.js
ADDED
|
@@ -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
|
+
}
|