@fullstackhouse/cerber 0.5.0 → 0.7.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/README.md +22 -5
- package/dist/cli/index.js +1 -0
- package/dist/core/anchor.js +73 -0
- package/dist/core/artifact.js +18 -0
- package/dist/core/diff.js +16 -5
- package/dist/core/gh.js +29 -3
- package/dist/core/refresh.js +54 -0
- package/dist/core/send.js +15 -3
- package/dist/core/state.js +25 -0
- package/dist/runner/inflight.js +33 -0
- package/dist/runner/prompt.js +10 -3
- package/dist/runner/review.js +26 -2
- package/dist/server/daemon.js +7 -1
- package/dist/server/index.js +82 -3
- package/package.json +8 -1
- package/web/dist/assets/index-Bq-5Sr6-.js +236 -0
- package/web/dist/assets/{index-D00teMaV.css → index-CwjpyotK.css} +10 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-pyON6-n7.js +0 -229
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ a summary, a chaptered walkthrough of the changes, draft inline comments, and a
|
|
|
5
5
|
verdict with a confidence score. You go through it in a local web cockpit.
|
|
6
6
|
|
|
7
7
|
**Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
|
|
8
|
-
|
|
9
|
-
local and read-only.
|
|
8
|
+
write is the Send button (plus opt-in daemon auto-send you turn on yourself) —
|
|
9
|
+
reviewing is 100% local and read-only.
|
|
10
10
|
|
|
11
11
|
Named after Cerberus, the gatekeeper — and a sibling of
|
|
12
12
|
[cezar](https://github.com/open-mercato/cezar): cezar runs coding agents,
|
|
@@ -48,14 +48,28 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
|
48
48
|
The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
|
|
49
49
|
diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
|
|
50
50
|
|
|
51
|
+
### When the PR moves under you
|
|
52
|
+
|
|
53
|
+
A review is written against one commit, but authors keep pushing. Opening a
|
|
54
|
+
review checks the PR's head and pulls the review forward: comments follow their
|
|
55
|
+
code to its new line numbers, and any whose code is gone are flagged and post
|
|
56
|
+
in the review body instead of inline — GitHub rejects an entire review over one
|
|
57
|
+
comment on a line that is no longer in the diff. Sends carry the reviewed
|
|
58
|
+
commit's SHA, so inline comments land where they were written.
|
|
59
|
+
|
|
60
|
+
The AI's summary and verdict still describe the commit that was reviewed. To
|
|
61
|
+
get its opinion of the new code, hit **Re-review at the new head** in the
|
|
62
|
+
cockpit (or `cerber review <pr> --force`) — comments you wrote or rewrote are
|
|
63
|
+
carried into the fresh review.
|
|
64
|
+
|
|
51
65
|
## Status / roadmap
|
|
52
66
|
|
|
53
|
-
Early
|
|
67
|
+
Early, but all six planned phases have shipped.
|
|
54
68
|
|
|
55
69
|
1. ✅ `cerber review <pr>` + artifact schema + cockpit (read-only)
|
|
56
70
|
2. ✅ Comment editing in the cockpit (edit/approve/drop/add), verdict override,
|
|
57
|
-
gated **Send** (
|
|
58
|
-
GitHub write in the codebase) + `cerber export` to markdown
|
|
71
|
+
gated **Send** (one deliberate click; posts the review via your `gh` — the
|
|
72
|
+
only GitHub write in the codebase) + `cerber export` to markdown
|
|
59
73
|
3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
|
|
60
74
|
freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
|
|
61
75
|
4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
|
|
@@ -64,6 +78,9 @@ Early. Current phase: review + read-only cockpit.
|
|
|
64
78
|
5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
|
|
65
79
|
in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
|
|
66
80
|
`--auto-send-threshold`, default 90%)
|
|
81
|
+
6. ✅ Reviews keep up with the PR: opening one re-anchors its comments onto new
|
|
82
|
+
commits, flags the ones whose code is gone, and offers a re-review that
|
|
83
|
+
keeps everything you wrote
|
|
67
84
|
|
|
68
85
|
## Running on a VPS
|
|
69
86
|
|
package/dist/cli/index.js
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { newSideLineText } from "./diff.js";
|
|
2
|
+
export function reanchorComments(comments, oldDiff, newDiff) {
|
|
3
|
+
const before = newSideLineText(oldDiff);
|
|
4
|
+
const after = newSideLineText(newDiff);
|
|
5
|
+
const results = [];
|
|
6
|
+
const next = comments.map((c) => {
|
|
7
|
+
const result = reanchorOne(c, before, after);
|
|
8
|
+
results.push(result);
|
|
9
|
+
switch (result.state) {
|
|
10
|
+
case "moved":
|
|
11
|
+
return {
|
|
12
|
+
...c,
|
|
13
|
+
line: result.to,
|
|
14
|
+
// Keep pointing at where the human wrote it, however far it travels.
|
|
15
|
+
originalLine: c.originalLine ?? result.from,
|
|
16
|
+
drifted: false,
|
|
17
|
+
};
|
|
18
|
+
case "drifted":
|
|
19
|
+
// Keep the stale line number: it is the only trace of what the comment
|
|
20
|
+
// was about, and the send path folds it into the body either way.
|
|
21
|
+
return { ...c, drifted: true };
|
|
22
|
+
default:
|
|
23
|
+
return c.drifted ? { ...c, drifted: false } : c;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
comments: next,
|
|
28
|
+
results,
|
|
29
|
+
moved: results.filter((r) => r.state === "moved").length,
|
|
30
|
+
drifted: results.filter((r) => r.state === "drifted").length,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function reanchorOne(comment, before, after) {
|
|
34
|
+
const { id, path, line } = comment;
|
|
35
|
+
if (line == null)
|
|
36
|
+
return { id, state: "unanchored", from: null, to: null };
|
|
37
|
+
const oldLines = before.get(path);
|
|
38
|
+
const newLines = after.get(path);
|
|
39
|
+
const oldText = oldLines?.get(line);
|
|
40
|
+
// No text to match on (the comment was already off-diff), or the file is not
|
|
41
|
+
// in the new diff at all — either way there is nothing to anchor to.
|
|
42
|
+
if (!oldLines || !newLines || oldText === undefined) {
|
|
43
|
+
return { id, state: "drifted", from: line, to: null };
|
|
44
|
+
}
|
|
45
|
+
if (newLines.get(line) === oldText) {
|
|
46
|
+
return { id, state: "unchanged", from: line, to: line };
|
|
47
|
+
}
|
|
48
|
+
const candidates = [...newLines].filter(([, text]) => text === oldText).map(([n]) => n);
|
|
49
|
+
if (candidates.length === 0)
|
|
50
|
+
return { id, state: "drifted", from: line, to: null };
|
|
51
|
+
if (candidates.length === 1)
|
|
52
|
+
return { id, state: "moved", from: line, to: candidates[0] };
|
|
53
|
+
// The line alone is ambiguous ("}", a blank line, a repeated call). Ask the
|
|
54
|
+
// neighbours: keep only candidates whose surrounding lines match too.
|
|
55
|
+
const withContext = candidates.filter((n) => contextMatches(oldLines, line, newLines, n));
|
|
56
|
+
if (withContext.length === 1)
|
|
57
|
+
return { id, state: "moved", from: line, to: withContext[0] };
|
|
58
|
+
return { id, state: "drifted", from: line, to: null };
|
|
59
|
+
}
|
|
60
|
+
const CONTEXT = 3;
|
|
61
|
+
function contextMatches(oldLines, oldLine, newLines, newLine) {
|
|
62
|
+
for (let offset = -CONTEXT; offset <= CONTEXT; offset++) {
|
|
63
|
+
if (offset === 0)
|
|
64
|
+
continue;
|
|
65
|
+
const oldText = oldLines.get(oldLine + offset);
|
|
66
|
+
// Outside the old hunk there is nothing to compare — treat as neutral.
|
|
67
|
+
if (oldText === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (newLines.get(newLine + offset) !== oldText)
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
package/dist/core/artifact.js
CHANGED
|
@@ -12,6 +12,10 @@ export const CommentSchema = z.object({
|
|
|
12
12
|
status: z.enum(["draft", "approved", "dropped"]).default("draft"),
|
|
13
13
|
/** Set when the user edits an AI comment's body — calibration signal. */
|
|
14
14
|
editedByUser: z.boolean().default(false),
|
|
15
|
+
/** Line this comment was written against, when a refresh has since moved it. */
|
|
16
|
+
originalLine: z.number().int().positive().nullable().default(null),
|
|
17
|
+
/** The commented line no longer exists in the diff — it can't post inline. */
|
|
18
|
+
drifted: z.boolean().default(false),
|
|
15
19
|
});
|
|
16
20
|
/** A logical group of changes — the unit of the review walkthrough. */
|
|
17
21
|
export const ChapterSchema = z.object({
|
|
@@ -37,6 +41,8 @@ export const PrInfoSchema = z.object({
|
|
|
37
41
|
baseRefName: z.string(),
|
|
38
42
|
headRefName: z.string(),
|
|
39
43
|
headSha: z.string().default(""),
|
|
44
|
+
/** OPEN | CLOSED | MERGED, as of the last fetch. */
|
|
45
|
+
state: z.enum(["OPEN", "CLOSED", "MERGED"]).default("OPEN"),
|
|
40
46
|
additions: z.number().int().default(0),
|
|
41
47
|
deletions: z.number().int().default(0),
|
|
42
48
|
changedFiles: z.number().int().default(0),
|
|
@@ -64,6 +70,16 @@ export const SentInfoSchema = z.object({
|
|
|
64
70
|
/** True when the daemon auto-sent this review (opt-in --auto-send). */
|
|
65
71
|
auto: z.boolean().default(false),
|
|
66
72
|
});
|
|
73
|
+
/** Result of pulling a review forward onto a newer head commit. */
|
|
74
|
+
export const RefreshInfoSchema = z.object({
|
|
75
|
+
at: z.string(),
|
|
76
|
+
fromSha: z.string(),
|
|
77
|
+
toSha: z.string(),
|
|
78
|
+
/** Comments whose line number changed but whose code was found again. */
|
|
79
|
+
moved: z.number().int().default(0),
|
|
80
|
+
/** Comments whose line is gone from the new diff — they now post in the body. */
|
|
81
|
+
drifted: z.number().int().default(0),
|
|
82
|
+
});
|
|
67
83
|
/** What actually happened vs what the AI proposed — recorded at send time. */
|
|
68
84
|
export const CalibrationSchema = z.object({
|
|
69
85
|
aiRecommendation: z.enum(["approve", "comment", "request_changes"]).nullable(),
|
|
@@ -92,6 +108,8 @@ export const ArtifactSchema = z.object({
|
|
|
92
108
|
run: RunInfoSchema.nullable().default(null),
|
|
93
109
|
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
|
|
94
110
|
sent: SentInfoSchema.nullable().default(null),
|
|
111
|
+
/** Last time this review was pulled forward onto a newer head commit. */
|
|
112
|
+
refresh: RefreshInfoSchema.nullable().default(null),
|
|
95
113
|
calibration: CalibrationSchema.nullable().default(null),
|
|
96
114
|
});
|
|
97
115
|
/** What the AI must return. Subset of the artifact; cerber owns the rest. */
|
package/dist/core/diff.js
CHANGED
|
@@ -43,13 +43,13 @@ export function patchForFiles(diff, files) {
|
|
|
43
43
|
.join("\n");
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* New-side lines of the diff with their text, per file: added and context
|
|
47
|
+
* lines inside hunks — exactly the lines a GitHub comment can attach to.
|
|
48
48
|
*/
|
|
49
|
-
export function
|
|
49
|
+
export function newSideLineText(diff) {
|
|
50
50
|
const result = new Map();
|
|
51
51
|
for (const { path, patch } of splitDiffByFile(diff)) {
|
|
52
|
-
const lines = new
|
|
52
|
+
const lines = new Map();
|
|
53
53
|
let newLine = 0;
|
|
54
54
|
let inHunk = false;
|
|
55
55
|
const patchLines = patch.split("\n");
|
|
@@ -68,7 +68,7 @@ export function newSideLines(diff) {
|
|
|
68
68
|
if (line.startsWith("\\"))
|
|
69
69
|
continue; // ""
|
|
70
70
|
if (line.startsWith("+") || line.startsWith(" ") || line === "") {
|
|
71
|
-
lines.
|
|
71
|
+
lines.set(newLine, line === "" ? "" : line.slice(1));
|
|
72
72
|
newLine++;
|
|
73
73
|
}
|
|
74
74
|
// "-" lines belong to the old side only.
|
|
@@ -77,6 +77,17 @@ export function newSideLines(diff) {
|
|
|
77
77
|
}
|
|
78
78
|
return result;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Line numbers (new side of the diff) that a GitHub review comment can attach
|
|
82
|
+
* to, per file: added and context lines inside hunks.
|
|
83
|
+
*/
|
|
84
|
+
export function newSideLines(diff) {
|
|
85
|
+
const result = new Map();
|
|
86
|
+
for (const [path, lines] of newSideLineText(diff)) {
|
|
87
|
+
result.set(path, new Set(lines.keys()));
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
80
91
|
/** Paths present in the diff but not claimed by any chapter. */
|
|
81
92
|
export function unclaimedFiles(diff, chapters) {
|
|
82
93
|
const claimed = new Set(chapters.flatMap((c) => c.files));
|
package/dist/core/gh.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function fetchPrInfo(ref) {
|
|
|
45
45
|
"--repo",
|
|
46
46
|
`${ref.owner}/${ref.repo}`,
|
|
47
47
|
"--json",
|
|
48
|
-
"title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles",
|
|
48
|
+
"title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state",
|
|
49
49
|
]);
|
|
50
50
|
const raw = JSON.parse(out);
|
|
51
51
|
return PrInfoSchema.parse({
|
|
@@ -59,6 +59,7 @@ export async function fetchPrInfo(ref) {
|
|
|
59
59
|
baseRefName: raw.baseRefName,
|
|
60
60
|
headRefName: raw.headRefName,
|
|
61
61
|
headSha: raw.headRefOid ?? "",
|
|
62
|
+
state: raw.state ?? "OPEN",
|
|
62
63
|
additions: raw.additions ?? 0,
|
|
63
64
|
deletions: raw.deletions ?? 0,
|
|
64
65
|
changedFiles: raw.changedFiles ?? 0,
|
|
@@ -119,15 +120,19 @@ export async function submitReview(ref, payload) {
|
|
|
119
120
|
"--input",
|
|
120
121
|
"-",
|
|
121
122
|
];
|
|
123
|
+
const { commitId, ...rest } = payload;
|
|
124
|
+
const body = commitId ? { ...rest, commit_id: commitId } : rest;
|
|
122
125
|
const { execFile: ef } = await import("node:child_process");
|
|
123
126
|
const out = await new Promise((resolve, reject) => {
|
|
124
127
|
const child = ef("gh", args, { maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
|
|
128
|
+
// gh only prints the top-level "message" ("Unprocessable Entity"); the useful
|
|
129
|
+
// part is the errors[] array it leaves in the response body on stdout.
|
|
125
130
|
if (err)
|
|
126
|
-
reject(new Error(`gh api reviews failed: ${stderr
|
|
131
|
+
reject(new Error(`gh api reviews failed: ${ghErrorDetail(stderr, stdout) || err.message}`));
|
|
127
132
|
else
|
|
128
133
|
resolve(stdout);
|
|
129
134
|
});
|
|
130
|
-
child.stdin.write(JSON.stringify(
|
|
135
|
+
child.stdin.write(JSON.stringify(body));
|
|
131
136
|
child.stdin.end();
|
|
132
137
|
});
|
|
133
138
|
try {
|
|
@@ -138,3 +143,24 @@ export async function submitReview(ref, payload) {
|
|
|
138
143
|
return { url: null };
|
|
139
144
|
}
|
|
140
145
|
}
|
|
146
|
+
/** Combine gh's stderr with the details GitHub returns in the response body. */
|
|
147
|
+
export function ghErrorDetail(stderr, stdout) {
|
|
148
|
+
const head = stderr?.trim() ?? "";
|
|
149
|
+
let details = [];
|
|
150
|
+
try {
|
|
151
|
+
const res = JSON.parse(stdout ?? "");
|
|
152
|
+
details = (Array.isArray(res.errors) ? res.errors : [])
|
|
153
|
+
.map((e) => (typeof e === "string" ? e : describeGhError(e)))
|
|
154
|
+
.filter((s) => s.length > 0 && !head.includes(s));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Non-JSON body (rate-limit HTML, empty response) — stderr is all we have.
|
|
158
|
+
}
|
|
159
|
+
return [head, ...details].filter(Boolean).join(" — ");
|
|
160
|
+
}
|
|
161
|
+
function describeGhError(e) {
|
|
162
|
+
if (typeof e.message === "string")
|
|
163
|
+
return e.message;
|
|
164
|
+
const where = [e.resource, e.field].filter((v) => typeof v === "string").join(".");
|
|
165
|
+
return [where, e.code].filter(Boolean).join(": ");
|
|
166
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { reanchorComments } from "./anchor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pull a review forward onto the PR's current head: swap in the new diff and
|
|
4
|
+
* carry every comment across, re-anchored to the code it was written about.
|
|
5
|
+
*
|
|
6
|
+
* The AI's summary, chapters and verdict still describe the commit that was
|
|
7
|
+
* reviewed — this only keeps the comments postable. Re-reviewing is the
|
|
8
|
+
* (expensive) way to get the AI's opinion of the new code.
|
|
9
|
+
*/
|
|
10
|
+
export function refreshArtifact(artifact, pr, diff) {
|
|
11
|
+
if (artifact.pr.headSha !== "" && artifact.pr.headSha === pr.headSha) {
|
|
12
|
+
return { artifact, changed: false, moved: 0, drifted: 0, results: [] };
|
|
13
|
+
}
|
|
14
|
+
const { comments, results, moved, drifted } = reanchorComments(artifact.comments, artifact.diff, diff);
|
|
15
|
+
return {
|
|
16
|
+
artifact: {
|
|
17
|
+
...artifact,
|
|
18
|
+
pr,
|
|
19
|
+
diff,
|
|
20
|
+
comments,
|
|
21
|
+
updatedAt: new Date().toISOString(),
|
|
22
|
+
refresh: {
|
|
23
|
+
at: new Date().toISOString(),
|
|
24
|
+
fromSha: artifact.pr.headSha,
|
|
25
|
+
toSha: pr.headSha,
|
|
26
|
+
moved,
|
|
27
|
+
drifted,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
changed: true,
|
|
31
|
+
moved,
|
|
32
|
+
drifted,
|
|
33
|
+
results,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Comments a human put work into: their own, and AI ones they rewrote.
|
|
38
|
+
* A re-review regenerates everything the AI said, but must not silently bin
|
|
39
|
+
* these — see carryOverComments.
|
|
40
|
+
*/
|
|
41
|
+
export function humanComments(artifact) {
|
|
42
|
+
return artifact.comments.filter((c) => c.origin === "user" || c.editedByUser);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Carry a previous review's human comments into a fresh one, re-anchored to
|
|
46
|
+
* the new diff. AI comments are dropped: the new run just regenerated them.
|
|
47
|
+
*/
|
|
48
|
+
export function carryOverComments(previous, fresh) {
|
|
49
|
+
const human = humanComments(previous);
|
|
50
|
+
if (human.length === 0)
|
|
51
|
+
return { comments: fresh.comments, carried: 0, drifted: 0 };
|
|
52
|
+
const { comments, drifted } = reanchorComments(human, previous.diff, fresh.diff);
|
|
53
|
+
return { comments: [...fresh.comments, ...comments], carried: comments.length, drifted };
|
|
54
|
+
}
|
package/dist/core/send.js
CHANGED
|
@@ -13,7 +13,10 @@ export function buildReviewPayload(artifact, event) {
|
|
|
13
13
|
const inline = [];
|
|
14
14
|
const folded = [];
|
|
15
15
|
for (const c of active) {
|
|
16
|
-
|
|
16
|
+
// A drifted comment's line number may still exist in the diff, but it now
|
|
17
|
+
// holds different code — posting there would point the author at something
|
|
18
|
+
// the comment was never about.
|
|
19
|
+
if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
|
|
17
20
|
inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c.body });
|
|
18
21
|
}
|
|
19
22
|
else {
|
|
@@ -33,11 +36,20 @@ export function buildReviewPayload(artifact, event) {
|
|
|
33
36
|
if (folded.length > 0) {
|
|
34
37
|
parts.push("", "## Additional notes", "");
|
|
35
38
|
for (const c of folded) {
|
|
36
|
-
|
|
39
|
+
// A drifted comment's line no longer exists, so name it as "was at" —
|
|
40
|
+
// pointing a reader at a line number that moved is worse than no number.
|
|
41
|
+
const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
|
|
42
|
+
parts.push(`- \`${c.path}${at}\` — ${c.body}`);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
|
|
40
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
event,
|
|
48
|
+
body: parts.join("\n").trim(),
|
|
49
|
+
comments: inline,
|
|
50
|
+
folded,
|
|
51
|
+
commitId: artifact.pr.headSha || undefined,
|
|
52
|
+
};
|
|
41
53
|
}
|
|
42
54
|
/** Snapshot of AI-proposal vs human-final, recorded at send time for `cerber stats`. */
|
|
43
55
|
export function computeCalibration(artifact, event) {
|
package/dist/core/state.js
CHANGED
|
@@ -76,6 +76,31 @@ export async function updateArtifactByKey(key, mutate) {
|
|
|
76
76
|
await saveArtifact(updated);
|
|
77
77
|
return updated;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Clear "running" statuses left behind by a process that died mid-review.
|
|
81
|
+
* Nothing in a freshly started process is running, so anything still marked
|
|
82
|
+
* running is a leftover — without this it stays wedged forever, with no way to
|
|
83
|
+
* retry it from the cockpit. A `cerber review` running in another terminal is
|
|
84
|
+
* the one false positive; it overwrites the artifact when it finishes anyway.
|
|
85
|
+
*/
|
|
86
|
+
export async function reconcileRunning() {
|
|
87
|
+
const artifacts = await listArtifacts();
|
|
88
|
+
let cleared = 0;
|
|
89
|
+
for (const artifact of artifacts) {
|
|
90
|
+
if (artifact.status !== "running")
|
|
91
|
+
continue;
|
|
92
|
+
await saveArtifact({
|
|
93
|
+
...artifact,
|
|
94
|
+
status: "failed",
|
|
95
|
+
updatedAt: new Date().toISOString(),
|
|
96
|
+
run: artifact.run
|
|
97
|
+
? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
|
|
98
|
+
: null,
|
|
99
|
+
});
|
|
100
|
+
cleared++;
|
|
101
|
+
}
|
|
102
|
+
return cleared;
|
|
103
|
+
}
|
|
79
104
|
export async function listArtifacts() {
|
|
80
105
|
let entries;
|
|
81
106
|
try {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which reviews this process is running right now.
|
|
3
|
+
*
|
|
4
|
+
* `cerber serve --daemon` starts reviews from a timer while the cockpit can
|
|
5
|
+
* start them from a click. Both write the same artifact file, so two runs for
|
|
6
|
+
* one PR would race and the loser's work would vanish. The daemon's `pool`
|
|
7
|
+
* only bounds its own concurrency; this is what the two paths share.
|
|
8
|
+
*
|
|
9
|
+
* In-process only: a `cerber review` in another terminal is invisible here.
|
|
10
|
+
* That's the pre-existing situation and the artifact write is atomic, so the
|
|
11
|
+
* cost is a wasted run, not a corrupt file.
|
|
12
|
+
*/
|
|
13
|
+
const running = new Set();
|
|
14
|
+
export class ReviewInProgressError extends Error {
|
|
15
|
+
id;
|
|
16
|
+
constructor(id) {
|
|
17
|
+
super(`a review of ${id} is already running`);
|
|
18
|
+
this.id = id;
|
|
19
|
+
this.name = "ReviewInProgressError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Claim the slot for an artifact id. Throws if it is already claimed. */
|
|
23
|
+
export function beginReview(id) {
|
|
24
|
+
if (running.has(id))
|
|
25
|
+
throw new ReviewInProgressError(id);
|
|
26
|
+
running.add(id);
|
|
27
|
+
}
|
|
28
|
+
export function endReview(id) {
|
|
29
|
+
running.delete(id);
|
|
30
|
+
}
|
|
31
|
+
export function isReviewRunning(id) {
|
|
32
|
+
return running.has(id);
|
|
33
|
+
}
|
package/dist/runner/prompt.js
CHANGED
|
@@ -8,12 +8,12 @@ export function buildReviewPrompt(pr, diff) {
|
|
|
8
8
|
Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
|
|
9
9
|
|
|
10
10
|
{
|
|
11
|
-
"summary": "markdown — what this PR actually does and why, written top-down: problem first, then the solution, then notable technical details. 1-3 paragraphs.",
|
|
11
|
+
"summary": "markdown — what this PR actually does and why, written top-down: problem first in plain language, then the solution, then notable technical details. A reader should be able to stop after any paragraph with a correct, just less detailed, picture. 1-3 paragraphs.",
|
|
12
12
|
"chapters": [
|
|
13
13
|
{
|
|
14
14
|
"id": "short-kebab-id",
|
|
15
15
|
"title": "Human title of this logical group of changes",
|
|
16
|
-
"explanation": "markdown — what this group changes
|
|
16
|
+
"explanation": "markdown — 2-3 plain sentences: what this group changes, why, and what to look at closely",
|
|
17
17
|
"files": ["path/to/file.ts", "path/other.ts"]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
@@ -21,7 +21,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
21
21
|
{
|
|
22
22
|
"path": "path/to/file.ts",
|
|
23
23
|
"line": 42,
|
|
24
|
-
"body": "markdown — the review comment. Concrete, actionable, kind.",
|
|
24
|
+
"body": "markdown — the review comment. First sentence states the problem plainly; then the evidence or fix. Concrete, actionable, kind.",
|
|
25
25
|
"chapterId": "short-kebab-id"
|
|
26
26
|
}
|
|
27
27
|
],
|
|
@@ -32,6 +32,13 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
Writing style (applies to every markdown field):
|
|
36
|
+
- Write like one human explaining to another: plain words, short sentences, no jargon. Someone who has NOT read the diff should follow every sentence.
|
|
37
|
+
- Lead with the point. State the claim first, support it after. No hedging chains, no em-dash clause stacks.
|
|
38
|
+
- Keep comments to ~3 sentences unless a code suggestion needs more. Length must buy clarity, not cover uncertainty — if you are unsure, say so in one plain sentence.
|
|
39
|
+
- Do NOT imitate the PR description's writing style. Keep this plain register even when the PR itself is dense or writerly.
|
|
40
|
+
- Describe the effect a person would see before the mechanism behind it. Not: "A stray closing code fence with prose on the same line left the block open, rendering the conditions below as code." But: "Also fixes a markdown typo: a code block was never closed, so everything after it displayed as code."
|
|
41
|
+
|
|
35
42
|
Rules:
|
|
36
43
|
- Group ALL changed files into chapters that tell the story of the PR (schema first, then logic, then tests, etc). Every changed file must appear in exactly one chapter.
|
|
37
44
|
- "line" is the line number in the NEW version of the file (the "+" side of the diff). Use null for file-level comments. Only reference lines that appear in the diff.
|
package/dist/runner/review.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
|
|
3
3
|
import { fetchPrDiff, fetchPrInfo } from "../core/gh.js";
|
|
4
|
+
import { carryOverComments } from "../core/refresh.js";
|
|
4
5
|
import { loadArtifact, saveArtifact } from "../core/state.js";
|
|
5
6
|
import { extractJson, runClaude } from "./claude.js";
|
|
7
|
+
import { beginReview, endReview } from "./inflight.js";
|
|
6
8
|
import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
|
|
7
9
|
/** Statuses that a fresh artifact can keep without a re-run. */
|
|
8
10
|
const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
|
|
@@ -11,12 +13,21 @@ const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
|
|
|
11
13
|
* Everything stays local — nothing is ever written to GitHub.
|
|
12
14
|
*/
|
|
13
15
|
export async function reviewPr(ref, opts = {}) {
|
|
16
|
+
beginReview(artifactId(ref));
|
|
17
|
+
try {
|
|
18
|
+
return await runReview(ref, opts);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
endReview(artifactId(ref));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function runReview(ref, opts) {
|
|
14
25
|
const log = opts.onProgress ?? (() => { });
|
|
15
26
|
const now = () => new Date().toISOString();
|
|
16
27
|
log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
|
|
17
28
|
const pr = await fetchPrInfo(ref);
|
|
29
|
+
const existing = await loadArtifact(artifactId(pr));
|
|
18
30
|
if (!opts.force) {
|
|
19
|
-
const existing = await loadArtifact(artifactId(pr));
|
|
20
31
|
if (existing &&
|
|
21
32
|
FRESH_STATUSES.has(existing.status) &&
|
|
22
33
|
existing.pr.headSha !== "" &&
|
|
@@ -30,7 +41,7 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
30
41
|
schemaVersion: SCHEMA_VERSION,
|
|
31
42
|
id: artifactId(pr),
|
|
32
43
|
status: "running",
|
|
33
|
-
createdAt: now(),
|
|
44
|
+
createdAt: existing?.createdAt ?? now(),
|
|
34
45
|
updatedAt: now(),
|
|
35
46
|
pr,
|
|
36
47
|
diff,
|
|
@@ -40,6 +51,7 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
40
51
|
verdict: null,
|
|
41
52
|
run: { model: opts.model ?? null, startedAt: now(), finishedAt: null, costUsd: null, error: null },
|
|
42
53
|
sent: null,
|
|
54
|
+
refresh: null,
|
|
43
55
|
calibration: null,
|
|
44
56
|
};
|
|
45
57
|
await saveArtifact(artifact);
|
|
@@ -72,6 +84,8 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
72
84
|
origin: "ai",
|
|
73
85
|
status: "draft",
|
|
74
86
|
editedByUser: false,
|
|
87
|
+
originalLine: null,
|
|
88
|
+
drifted: false,
|
|
75
89
|
})),
|
|
76
90
|
verdict: review.ai.verdict,
|
|
77
91
|
run: {
|
|
@@ -82,6 +96,16 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
82
96
|
error: null,
|
|
83
97
|
},
|
|
84
98
|
};
|
|
99
|
+
// A re-review regenerates the AI's comments, but the human's own comments
|
|
100
|
+
// and their rewrites of AI ones are work the run must not throw away.
|
|
101
|
+
if (existing) {
|
|
102
|
+
const { comments, carried, drifted } = carryOverComments(existing, artifact);
|
|
103
|
+
if (carried > 0) {
|
|
104
|
+
artifact = { ...artifact, comments };
|
|
105
|
+
log(`Carried over ${carried} comment(s) you wrote or edited` +
|
|
106
|
+
(drifted > 0 ? `; ${drifted} no longer match the diff and will post in the body.` : "."));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
85
109
|
}
|
|
86
110
|
catch (err) {
|
|
87
111
|
artifact = {
|
package/dist/server/daemon.js
CHANGED
|
@@ -3,6 +3,7 @@ import { evaluateAutoSend } from "../core/autosend.js";
|
|
|
3
3
|
import { searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
4
4
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
5
5
|
import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
|
|
6
|
+
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
6
7
|
import { pool, reviewPr } from "../runner/review.js";
|
|
7
8
|
/**
|
|
8
9
|
* Poll GitHub for PRs awaiting the user's review and keep their artifacts
|
|
@@ -62,6 +63,11 @@ export function startDaemon(opts) {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
catch (err) {
|
|
66
|
+
if (err instanceof ReviewInProgressError) {
|
|
67
|
+
// The cockpit (or an earlier, slower poll) is already on it.
|
|
68
|
+
skipped++;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
65
71
|
failed++;
|
|
66
72
|
status.errors++;
|
|
67
73
|
log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -106,7 +112,7 @@ export function startDaemon(opts) {
|
|
|
106
112
|
}
|
|
107
113
|
try {
|
|
108
114
|
const payload = buildReviewPayload(artifact, "APPROVE");
|
|
109
|
-
const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments });
|
|
115
|
+
const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
|
|
110
116
|
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
111
117
|
...a,
|
|
112
118
|
status: "sent",
|