@fullstackhouse/cerber 0.5.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.
@@ -8,9 +8,12 @@ import { fileURLToPath } from "node:url";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
10
10
  import { toMarkdown } from "../core/export.js";
11
- import { submitReview } from "../core/gh.js";
11
+ import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
+ import { refreshArtifact } from "../core/refresh.js";
12
13
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
13
- import { listArtifacts, loadArtifactByKey, updateArtifactByKey } from "../core/state.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";
14
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
18
  export async function buildApp(opts) {
16
19
  const app = new Hono();
@@ -117,6 +120,8 @@ export async function buildApp(opts) {
117
120
  origin: "user",
118
121
  status: "draft",
119
122
  editedByUser: false,
123
+ originalLine: null,
124
+ drifted: false,
120
125
  },
121
126
  ],
122
127
  }));
@@ -134,6 +139,76 @@ export async function buildApp(opts) {
134
139
  return c.json({ error: "not found" }, 404);
135
140
  return c.json(updated);
136
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
+ });
137
212
  // ---- Export (local file download) ----
138
213
  app.get("/api/reviews/:key/export", async (c) => {
139
214
  const artifact = await loadArtifactByKey(c.req.param("key"));
@@ -160,7 +235,7 @@ export async function buildApp(opts) {
160
235
  }
161
236
  const payload = buildReviewPayload(artifact, event);
162
237
  try {
163
- 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 });
164
239
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
165
240
  ...a,
166
241
  status: "sent",
@@ -198,6 +273,10 @@ export async function buildApp(opts) {
198
273
  return app;
199
274
  }
200
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
+ }
201
280
  const app = await buildApp(opts);
202
281
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
203
282
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.5.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",