@fullstackhouse/cerber 0.3.0 → 0.6.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 +49 -9
- package/dist/cli/index.js +88 -7
- package/dist/core/anchor.js +73 -0
- package/dist/core/artifact.js +34 -1
- package/dist/core/autosend.js +26 -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 +28 -3
- package/dist/core/state.js +51 -0
- package/dist/runner/inflight.js +33 -0
- package/dist/runner/prompt.js +10 -3
- package/dist/runner/review.js +28 -2
- package/dist/server/daemon.js +142 -0
- package/dist/server/index.js +116 -7
- package/package.json +4 -1
- package/web/dist/assets/index-BzQacQob.js +230 -0
- package/web/dist/assets/{index-B73WZWQg.css → index-DlvELqhQ.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjzYwp5w.js +0 -163
|
@@ -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,8 @@ 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,
|
|
55
|
+
calibration: null,
|
|
43
56
|
};
|
|
44
57
|
await saveArtifact(artifact);
|
|
45
58
|
const { prompt, truncated } = buildReviewPrompt(pr, diff);
|
|
@@ -70,6 +83,9 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
70
83
|
chapterId: c.chapterId ?? null,
|
|
71
84
|
origin: "ai",
|
|
72
85
|
status: "draft",
|
|
86
|
+
editedByUser: false,
|
|
87
|
+
originalLine: null,
|
|
88
|
+
drifted: false,
|
|
73
89
|
})),
|
|
74
90
|
verdict: review.ai.verdict,
|
|
75
91
|
run: {
|
|
@@ -80,6 +96,16 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
80
96
|
error: null,
|
|
81
97
|
},
|
|
82
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
|
+
}
|
|
83
109
|
}
|
|
84
110
|
catch (err) {
|
|
85
111
|
artifact = {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { artifactKey } from "../core/artifact.js";
|
|
2
|
+
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
|
+
import { searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
4
|
+
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
5
|
+
import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
|
|
6
|
+
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
7
|
+
import { pool, reviewPr } from "../runner/review.js";
|
|
8
|
+
/**
|
|
9
|
+
* Poll GitHub for PRs awaiting the user's review and keep their artifacts
|
|
10
|
+
* warm. Reviews are read-only; the daemon NEVER sends anything.
|
|
11
|
+
*/
|
|
12
|
+
export function startDaemon(opts) {
|
|
13
|
+
let timer = null;
|
|
14
|
+
let stopped = false;
|
|
15
|
+
const status = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
polling: false,
|
|
18
|
+
repos: opts.repos,
|
|
19
|
+
intervalMs: opts.intervalMs,
|
|
20
|
+
polls: 0,
|
|
21
|
+
errors: 0,
|
|
22
|
+
lastPollAt: null,
|
|
23
|
+
nextPollAt: new Date(Date.now()).toISOString(),
|
|
24
|
+
lastSummary: null,
|
|
25
|
+
autoSend: opts.autoSend,
|
|
26
|
+
autoSendThreshold: opts.autoSendThreshold,
|
|
27
|
+
autoSent: 0,
|
|
28
|
+
autoSendCandidates: 0,
|
|
29
|
+
};
|
|
30
|
+
const log = (m) => console.log(`[daemon] ${m}`);
|
|
31
|
+
async function poll() {
|
|
32
|
+
if (status.polling)
|
|
33
|
+
return;
|
|
34
|
+
status.polling = true;
|
|
35
|
+
status.lastPollAt = new Date().toISOString();
|
|
36
|
+
try {
|
|
37
|
+
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
|
|
40
|
+
.flat()
|
|
41
|
+
.filter((r) => {
|
|
42
|
+
const id = `${r.owner}/${r.repo}#${r.number}`;
|
|
43
|
+
if (seen.has(id))
|
|
44
|
+
return false;
|
|
45
|
+
seen.add(id);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
let reviewed = 0;
|
|
49
|
+
let skipped = 0;
|
|
50
|
+
let failed = 0;
|
|
51
|
+
await pool(refs, opts.parallel, async (ref) => {
|
|
52
|
+
const label = `${ref.owner}/${ref.repo}#${ref.number}`;
|
|
53
|
+
try {
|
|
54
|
+
const result = await reviewPr(ref, {
|
|
55
|
+
model: opts.model,
|
|
56
|
+
onProgress: (m) => log(`[${label}] ${m}`),
|
|
57
|
+
});
|
|
58
|
+
if (result.skipped)
|
|
59
|
+
skipped++;
|
|
60
|
+
else {
|
|
61
|
+
reviewed++;
|
|
62
|
+
await handleAutoSend(result.artifact);
|
|
63
|
+
}
|
|
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
|
+
}
|
|
71
|
+
failed++;
|
|
72
|
+
status.errors++;
|
|
73
|
+
log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
77
|
+
log(status.lastSummary);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
status.errors++;
|
|
81
|
+
status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
|
|
82
|
+
log(status.lastSummary);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
status.polls++;
|
|
86
|
+
status.polling = false;
|
|
87
|
+
if (!stopped) {
|
|
88
|
+
status.nextPollAt = new Date(Date.now() + opts.intervalMs).toISOString();
|
|
89
|
+
timer = setTimeout(poll, opts.intervalMs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function handleAutoSend(artifact) {
|
|
94
|
+
const decision = evaluateAutoSend(artifact, opts.autoSendThreshold);
|
|
95
|
+
if (decision.eligible)
|
|
96
|
+
status.autoSendCandidates++;
|
|
97
|
+
const entryBase = {
|
|
98
|
+
at: new Date().toISOString(),
|
|
99
|
+
id: artifact.id,
|
|
100
|
+
recommendation: artifact.verdict?.recommendation ?? null,
|
|
101
|
+
confidence: artifact.verdict?.confidence ?? null,
|
|
102
|
+
mode: opts.autoSend,
|
|
103
|
+
};
|
|
104
|
+
if (!decision.eligible) {
|
|
105
|
+
await appendAutoSendLog({ ...entryBase, decision: decision.reason, sent: false });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (opts.autoSend === "shadow") {
|
|
109
|
+
log(`[${artifact.id}] WOULD auto-send: ${decision.reason} (shadow mode — nothing sent)`);
|
|
110
|
+
await appendAutoSendLog({ ...entryBase, decision: `would send: ${decision.reason}`, sent: false });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const payload = buildReviewPayload(artifact, "APPROVE");
|
|
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 });
|
|
116
|
+
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
117
|
+
...a,
|
|
118
|
+
status: "sent",
|
|
119
|
+
sent: { at: new Date().toISOString(), event: "APPROVE", url, auto: true },
|
|
120
|
+
calibration: computeCalibration(a, "APPROVE"),
|
|
121
|
+
}));
|
|
122
|
+
status.autoSent++;
|
|
123
|
+
log(`[${artifact.id}] AUTO-SENT approve (${decision.reason})${url ? ` — ${url}` : ""}`);
|
|
124
|
+
await appendAutoSendLog({ ...entryBase, decision: `auto-sent: ${decision.reason}`, sent: true });
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
status.errors++;
|
|
128
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
129
|
+
log(`[${artifact.id}] auto-send failed: ${message}`);
|
|
130
|
+
await appendAutoSendLog({ ...entryBase, decision: `auto-send failed: ${message}`, sent: false });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
void poll();
|
|
134
|
+
return {
|
|
135
|
+
status: () => ({ ...status }),
|
|
136
|
+
stop: () => {
|
|
137
|
+
stopped = true;
|
|
138
|
+
if (timer)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
import { serve } from "@hono/node-server";
|
|
2
2
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
4
5
|
import { promises as fs } from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
|
|
9
10
|
import { toMarkdown } from "../core/export.js";
|
|
10
|
-
import { submitReview } from "../core/gh.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
|
|
12
|
+
import { refreshArtifact } from "../core/refresh.js";
|
|
13
|
+
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
14
|
+
import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
15
|
+
import { isReviewRunning } from "../runner/inflight.js";
|
|
16
|
+
import { reviewPr } from "../runner/review.js";
|
|
13
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
export async function
|
|
18
|
+
export async function buildApp(opts) {
|
|
15
19
|
const app = new Hono();
|
|
20
|
+
if (opts.token) {
|
|
21
|
+
const token = opts.token;
|
|
22
|
+
app.use("*", async (c, next) => {
|
|
23
|
+
const query = c.req.query("token");
|
|
24
|
+
const cookie = getCookie(c, "cerber_token");
|
|
25
|
+
const header = c.req.header("authorization");
|
|
26
|
+
if (query === token || cookie === token || header === `Bearer ${token}`) {
|
|
27
|
+
if (query === token && cookie !== token) {
|
|
28
|
+
// First visit via ?token=… — set the cookie so the SPA's asset and API requests pass.
|
|
29
|
+
setCookie(c, "cerber_token", token, { httpOnly: true, sameSite: "Strict", path: "/" });
|
|
30
|
+
}
|
|
31
|
+
return next();
|
|
32
|
+
}
|
|
33
|
+
return c.text("unauthorized — pass ?token=… or Authorization: Bearer …\n", 401);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
app.get("/api/daemon", (c) => c.json(opts.daemon ? opts.daemon.status() : { enabled: false }));
|
|
16
37
|
app.get("/api/reviews", async (c) => {
|
|
17
38
|
const artifacts = await listArtifacts();
|
|
18
39
|
return c.json(artifacts.map((a) => ({
|
|
@@ -74,6 +95,9 @@ export async function startServer(opts) {
|
|
|
74
95
|
...(body.status !== undefined
|
|
75
96
|
? { status: body.status }
|
|
76
97
|
: {}),
|
|
98
|
+
...(body.body !== undefined && body.body !== cm.body && cm.origin === "ai"
|
|
99
|
+
? { editedByUser: true }
|
|
100
|
+
: {}),
|
|
77
101
|
}
|
|
78
102
|
: cm),
|
|
79
103
|
}));
|
|
@@ -95,6 +119,9 @@ export async function startServer(opts) {
|
|
|
95
119
|
chapterId: body.chapterId ?? null,
|
|
96
120
|
origin: "user",
|
|
97
121
|
status: "draft",
|
|
122
|
+
editedByUser: false,
|
|
123
|
+
originalLine: null,
|
|
124
|
+
drifted: false,
|
|
98
125
|
},
|
|
99
126
|
],
|
|
100
127
|
}));
|
|
@@ -112,6 +139,76 @@ export async function startServer(opts) {
|
|
|
112
139
|
return c.json({ error: "not found" }, 404);
|
|
113
140
|
return c.json(updated);
|
|
114
141
|
});
|
|
142
|
+
// ---- Freshness: pull a review forward onto the PR's current head ----
|
|
143
|
+
// Read-only against GitHub (pr view + pr diff); writes only the local artifact.
|
|
144
|
+
app.post("/api/reviews/:key/refresh", async (c) => {
|
|
145
|
+
const artifact = await loadArtifactByKey(c.req.param("key"));
|
|
146
|
+
if (!artifact)
|
|
147
|
+
return c.json({ error: "not found" }, 404);
|
|
148
|
+
const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
|
|
149
|
+
let pr;
|
|
150
|
+
try {
|
|
151
|
+
pr = await fetchPrInfo(ref);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
|
|
155
|
+
}
|
|
156
|
+
const stale = artifact.pr.headSha !== "" && artifact.pr.headSha !== pr.headSha;
|
|
157
|
+
// A sent review is a record of what was posted — never rewrite it. A running
|
|
158
|
+
// one is mid-run and would be overwritten from under the runner.
|
|
159
|
+
if (!stale || artifact.sent || artifact.status === "running") {
|
|
160
|
+
return c.json({ stale, changed: false, prState: pr.state, artifact });
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const diff = await fetchPrDiff(ref);
|
|
164
|
+
const result = refreshArtifact(artifact, pr, diff);
|
|
165
|
+
const saved = await updateArtifactByKey(c.req.param("key"), () => result.artifact);
|
|
166
|
+
return c.json({
|
|
167
|
+
stale: true,
|
|
168
|
+
changed: result.changed,
|
|
169
|
+
prState: pr.state,
|
|
170
|
+
moved: result.moved,
|
|
171
|
+
drifted: result.drifted,
|
|
172
|
+
artifact: saved,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
// ---- Re-review: start an AI run for a PR that has moved on ----
|
|
180
|
+
app.post("/api/reviews/:key/rerun", async (c) => {
|
|
181
|
+
const key = c.req.param("key");
|
|
182
|
+
const artifact = await loadArtifactByKey(key);
|
|
183
|
+
if (!artifact)
|
|
184
|
+
return c.json({ error: "not found" }, 404);
|
|
185
|
+
if (artifact.sent)
|
|
186
|
+
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
187
|
+
if (isReviewRunning(artifact.id)) {
|
|
188
|
+
return c.json({ error: "a review of this PR is already running" }, 409);
|
|
189
|
+
}
|
|
190
|
+
const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
|
|
191
|
+
// Mark it running before responding, so the cockpit's next poll can't catch
|
|
192
|
+
// the old "ready" status and conclude the run already finished.
|
|
193
|
+
const running = await updateArtifactByKey(key, (a) => ({
|
|
194
|
+
...a,
|
|
195
|
+
status: "running",
|
|
196
|
+
run: { model: null, startedAt: new Date().toISOString(), finishedAt: null, costUsd: null, error: null },
|
|
197
|
+
}));
|
|
198
|
+
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
|
199
|
+
void reviewPr(ref, { force: true, onProgress: (m) => console.log(`[rerun ${artifact.id}] ${m}`) })
|
|
200
|
+
.catch(async (err) => {
|
|
201
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
202
|
+
console.error(`[rerun ${artifact.id}] failed: ${message}`);
|
|
203
|
+
// The runner marks the artifact failed once it owns it; a failure before
|
|
204
|
+
// that (fetching the PR, or a run already in flight) would otherwise
|
|
205
|
+
// leave it stuck at "running" with nothing to clear it.
|
|
206
|
+
await updateArtifactByKey(key, (a) => a.status === "running"
|
|
207
|
+
? { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
|
|
208
|
+
: a).catch(() => { });
|
|
209
|
+
});
|
|
210
|
+
return c.json(running, 202);
|
|
211
|
+
});
|
|
115
212
|
// ---- Export (local file download) ----
|
|
116
213
|
app.get("/api/reviews/:key/export", async (c) => {
|
|
117
214
|
const artifact = await loadArtifactByKey(c.req.param("key"));
|
|
@@ -138,11 +235,12 @@ export async function startServer(opts) {
|
|
|
138
235
|
}
|
|
139
236
|
const payload = buildReviewPayload(artifact, event);
|
|
140
237
|
try {
|
|
141
|
-
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 });
|
|
238
|
+
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 });
|
|
142
239
|
const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
|
|
143
240
|
...a,
|
|
144
241
|
status: "sent",
|
|
145
|
-
sent: { at: new Date().toISOString(), event: payload.event, url },
|
|
242
|
+
sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
|
|
243
|
+
calibration: computeCalibration(a, payload.event),
|
|
146
244
|
}));
|
|
147
245
|
return c.json(updated);
|
|
148
246
|
}
|
|
@@ -172,7 +270,18 @@ export async function startServer(opts) {
|
|
|
172
270
|
else {
|
|
173
271
|
app.get("/", (c) => c.text("cerber API is running, but the web cockpit is not built.\nRun `pnpm build` in the cerber repo, or use the API: GET /api/reviews\n"));
|
|
174
272
|
}
|
|
273
|
+
return app;
|
|
274
|
+
}
|
|
275
|
+
export async function startServer(opts) {
|
|
276
|
+
const cleared = await reconcileRunning();
|
|
277
|
+
if (cleared > 0) {
|
|
278
|
+
console.log(`Cleared ${cleared} review(s) left mid-run by a previous process — re-review to retry.`);
|
|
279
|
+
}
|
|
280
|
+
const app = await buildApp(opts);
|
|
175
281
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
176
|
-
|
|
282
|
+
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
|
283
|
+
console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
|
|
284
|
+
if (opts.daemon)
|
|
285
|
+
console.log("daemon: polling for PRs awaiting your review");
|
|
177
286
|
});
|
|
178
287
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:fullstackhouse/cerber",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"packageManager": "pnpm@10.33.2",
|
|
8
9
|
"bin": {
|
|
9
10
|
"cerber": "dist/cli/index.js"
|
|
10
11
|
},
|
|
@@ -36,6 +37,8 @@
|
|
|
36
37
|
"@types/react": "^19.0.4",
|
|
37
38
|
"@types/react-dom": "^19.0.2",
|
|
38
39
|
"@vitejs/plugin-react": "^4.3.4",
|
|
40
|
+
"dompurify": "^3.4.13",
|
|
41
|
+
"marked": "^18.0.10",
|
|
39
42
|
"react": "^19.0.0",
|
|
40
43
|
"react-dom": "^19.0.0",
|
|
41
44
|
"tsx": "^4.19.2",
|