@fullstackhouse/cerber 0.39.0 → 0.40.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 +27 -3
- package/dist/cli/index.js +12 -0
- package/dist/core/artifact.js +16 -0
- package/dist/core/autosend.js +8 -0
- package/dist/core/history.js +5 -0
- package/dist/core/raised.js +39 -1
- package/dist/core/refresh.js +16 -0
- package/dist/core/revise.js +13 -3
- package/dist/core/state.js +12 -2
- package/dist/runner/review.js +21 -2
- package/dist/server/daemon.js +1 -0
- package/dist/server/index.js +87 -23
- package/package.json +1 -1
- package/web/dist/assets/index-DRrlQJxs.js +248 -0
- package/web/dist/assets/{index-C1O70i8E.css → index-U9lc9BeR.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C09rdnDK.js +0 -248
package/README.md
CHANGED
|
@@ -429,8 +429,31 @@ cockpit (or `cerber review <pr> --force`).
|
|
|
429
429
|
> starts and they do not come back, on success or on failure. This is a
|
|
430
430
|
> decision, not a bug ([`docs/lifecycle.md`](docs/lifecycle.md)): the run owns
|
|
431
431
|
> the draft. If you have written comments you want to keep, send the review or
|
|
432
|
-
> copy them out first. Your *decisions* do survive — a
|
|
433
|
-
>
|
|
432
|
+
> copy them out first. Your *decisions* do survive — a `reviewed` or `skipped`
|
|
433
|
+
> you set and the chat transcript are kept, and a send is kept as a record of
|
|
434
|
+
> what went to GitHub (see the next section).
|
|
435
|
+
|
|
436
|
+
### Re-reviewing one you already sent
|
|
437
|
+
|
|
438
|
+
Sending freezes the draft — it is the record of what GitHub has, so nothing
|
|
439
|
+
can edit it afterwards. What you can do is replace it. The same **Re-review at
|
|
440
|
+
head** button works on a sent review, and on a PR whose author has pushed
|
|
441
|
+
since it is the only thing that does: the review you sent answers a commit
|
|
442
|
+
that is gone, and cerber will not rewrite a review that is already public.
|
|
443
|
+
|
|
444
|
+
The run drafts against the current head and hands you an ordinary unsent
|
|
445
|
+
review again — editable, arguable, sendable. What you sent is kept beside it:
|
|
446
|
+
a note above the draft says what you posted and when, with a link, and the
|
|
447
|
+
line over the Send button says the next click would be review 2. **Nothing on
|
|
448
|
+
GitHub changes** — a second review sits beside the first; cerber never edits
|
|
449
|
+
or withdraws what it posted.
|
|
450
|
+
|
|
451
|
+
The poll does this on its own, too, for a sent review whose PR moved on and
|
|
452
|
+
whose author has asked you for another look. The two things that can reach
|
|
453
|
+
GitHub without you both stand down on a row like that: auto-send will not
|
|
454
|
+
approve a PR you have already reviewed yourself, and a handoff stops offering
|
|
455
|
+
to carry the draft — it is a new draft, but sending it would still be your
|
|
456
|
+
second review on that PR.
|
|
434
457
|
|
|
435
458
|
## Arguing with the review before you send it
|
|
436
459
|
|
|
@@ -488,7 +511,8 @@ the exchange went anywhere near GitHub.
|
|
|
488
511
|
Nothing in the conversation reaches GitHub. Send still builds its payload from
|
|
489
512
|
the summary, comments and verdict alone, and is still one deliberate click. A
|
|
490
513
|
review that has already been sent can't be argued with: that artifact is the
|
|
491
|
-
record of what GitHub has
|
|
514
|
+
record of what GitHub has — re-review it first, and the new draft is open to
|
|
515
|
+
the conversation like any other.
|
|
492
516
|
|
|
493
517
|
## Every row remembers what happened to it
|
|
494
518
|
|
package/dist/cli/index.js
CHANGED
|
@@ -231,6 +231,18 @@ program
|
|
|
231
231
|
}
|
|
232
232
|
const payload = buildReviewPayload(artifact, event);
|
|
233
233
|
console.log(`About to send to ${artifact.pr.url}:`);
|
|
234
|
+
// Said before anything else about the payload: on a PR you have already
|
|
235
|
+
// reviewed, "what is in this review" is the second question. The cockpit's
|
|
236
|
+
// send panel says the same thing in the same place (SPEC §8.3).
|
|
237
|
+
const posted = artifact.priorSends;
|
|
238
|
+
if (posted.length > 0) {
|
|
239
|
+
const last = posted[posted.length - 1];
|
|
240
|
+
const when = new Date(last.at).toLocaleDateString();
|
|
241
|
+
console.log(` ⚠ you already sent ${posted.length === 1 ? "a review" : `${posted.length} reviews`}` +
|
|
242
|
+
` on this PR${posted.length === 1 ? "" : ", the last"} on ${when}` +
|
|
243
|
+
` as ${last.event.toLowerCase().replace("_", " ")}${last.url ? ` (${last.url})` : ""}.` +
|
|
244
|
+
` This would be review ${posted.length + 1}; it sits beside them, replacing nothing.`);
|
|
245
|
+
}
|
|
234
246
|
console.log(` event: ${event}`);
|
|
235
247
|
console.log(` inline comments: ${payload.comments.length}`);
|
|
236
248
|
if (payload.folded.length > 0) {
|
package/dist/core/artifact.js
CHANGED
|
@@ -403,6 +403,22 @@ export const ArtifactSchema = z.object({
|
|
|
403
403
|
run: RunInfoSchema.nullable().default(null),
|
|
404
404
|
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
|
|
405
405
|
sent: SentInfoSchema.nullable().default(null),
|
|
406
|
+
/**
|
|
407
|
+
* Reviews posted from this row that a later re-review has since replaced,
|
|
408
|
+
* oldest first.
|
|
409
|
+
*
|
|
410
|
+
* `sent` means "the draft on this page is the one that went to GitHub", and
|
|
411
|
+
* every guard in cerber reads it that way — the send path's "already sent"
|
|
412
|
+
* refusal most of all. So when a re-review replaces that draft the send
|
|
413
|
+
* cannot stay in the field (the new draft was never sent) and must not simply
|
|
414
|
+
* go (it is on the PR, under the user's name, for good). It moves here.
|
|
415
|
+
*
|
|
416
|
+
* What it buys is a cockpit that can say "you reviewed this on the 14th" over
|
|
417
|
+
* a draft of the 16th, and a send panel that says the next click posts a
|
|
418
|
+
* second review rather than a first. The GitHub URL rides along, because the
|
|
419
|
+
* record is only useful if the thing it names can be opened.
|
|
420
|
+
*/
|
|
421
|
+
priorSends: z.array(SentInfoSchema).default([]),
|
|
406
422
|
/** Set when cerber filed this draft away itself, and why. Never on a sent one. */
|
|
407
423
|
filed: FiledInfoSchema.nullable().default(null),
|
|
408
424
|
/** Set when you gave this review to somebody else. Mutually exclusive with `filed`. */
|
package/dist/core/autosend.js
CHANGED
|
@@ -13,6 +13,14 @@ import { sendTreatment } from "./raised.js";
|
|
|
13
13
|
export function evaluateAutoSend(artifact, threshold) {
|
|
14
14
|
if (artifact.sent)
|
|
15
15
|
return { eligible: false, reason: "already sent" };
|
|
16
|
+
// The same rule, for a send a re-review has since superseded. `sent` only
|
|
17
|
+
// ever describes the draft on the row, so once one moves to `priorSends`
|
|
18
|
+
// that guard stops seeing it — and "never a re-send" would quietly come to
|
|
19
|
+
// mean "never twice in a row". A PR this user has already reviewed is the
|
|
20
|
+
// last place to post unattended: the second review is theirs to send.
|
|
21
|
+
if (artifact.priorSends.length > 0) {
|
|
22
|
+
return { eligible: false, reason: "a review was already sent on this PR — the next one is yours to send" };
|
|
23
|
+
}
|
|
16
24
|
if (artifact.status !== "ready")
|
|
17
25
|
return { eligible: false, reason: `status is ${artifact.status}` };
|
|
18
26
|
if (!artifact.verdict)
|
package/dist/core/history.js
CHANGED
|
@@ -174,6 +174,11 @@ export function describeChange(before, after) {
|
|
|
174
174
|
lines.push(`sent to GitHub as ${after.sent.event.toLowerCase().replace("_", " ")}` +
|
|
175
175
|
`${after.sent.auto ? ", by auto-send" : ""}`);
|
|
176
176
|
}
|
|
177
|
+
// The one state change nothing else on this list would record: the row went
|
|
178
|
+
// from "this draft is on GitHub" back to "this draft has never been sent".
|
|
179
|
+
if (after.priorSends.length > (before?.priorSends.length ?? 0)) {
|
|
180
|
+
lines.push("a new draft took over from the review that was sent — what was sent stays on GitHub");
|
|
181
|
+
}
|
|
177
182
|
if (after.filed && after.filed.at !== before?.filed?.at) {
|
|
178
183
|
lines.push(`filed under settled — ${FILED_PHRASE[after.filed.reason] ?? after.filed.reason}`);
|
|
179
184
|
}
|
package/dist/core/raised.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Undecided is not "post": a duplicate the user never looked at is exactly what
|
|
6
6
|
* this exists to keep off the PR. The one place undecided still posts is a
|
|
7
7
|
* resolved thread — somebody marked that problem settled, and a review that
|
|
8
|
-
* found it again in the current code is saying it isn't.
|
|
8
|
+
* found it again in the current code is saying it isn't. A finding the user
|
|
9
|
+
* has rewritten is not undecided at all: `rewriteFinding` decides it.
|
|
9
10
|
*/
|
|
10
11
|
export function sendTreatment(comment) {
|
|
11
12
|
const raised = comment.alreadyRaised;
|
|
@@ -19,6 +20,43 @@ export function sendTreatment(comment) {
|
|
|
19
20
|
return raised.where.kind === "thread" ? "reply" : "hold";
|
|
20
21
|
return raised.where.kind === "thread" && raised.where.state === "resolved" ? "post" : "hold";
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Rewrite a finding's body — and, where somebody else had raised it, let the
|
|
25
|
+
* rewrite be the decision.
|
|
26
|
+
*
|
|
27
|
+
* An undecided match is Send leaving the duplicate out, and that default is
|
|
28
|
+
* about one thing: nobody has looked at it yet. Rewriting the finding is
|
|
29
|
+
* looking at it. The words that would post are no longer the ones the check
|
|
30
|
+
* compared — they are what the user asked for, by hand or through the chat —
|
|
31
|
+
* and cerber does not hold back what the user wrote. A finding is compared
|
|
32
|
+
* once for the same reason (`whatToCompare`): the user who rewrote it is the
|
|
33
|
+
* one deciding about it.
|
|
34
|
+
*
|
|
35
|
+
* The match itself stays, with who raised it, where, and why the two are the
|
|
36
|
+
* same defect: the cockpit still shows all of it, and "send anyway" toggles
|
|
37
|
+
* straight back off. A decision already made is left alone — including
|
|
38
|
+
* `reply`, which keeps answering in their thread, in the new words.
|
|
39
|
+
*
|
|
40
|
+
* Only a match that would otherwise be held is decided. On a resolved thread
|
|
41
|
+
* the default already posts, and writing the decision down there would trade a
|
|
42
|
+
* state that follows the thread for one that doesn't: the cockpit would stop
|
|
43
|
+
* saying *why* it posts, and the finding would keep posting if somebody
|
|
44
|
+
* reopened that thread. Nothing is held, so there is nothing to decide — which
|
|
45
|
+
* goes for a dropped comment too, where drop decides it and the cockpit shows
|
|
46
|
+
* no toggle to take it back with.
|
|
47
|
+
*/
|
|
48
|
+
export function rewriteFinding(comment, body) {
|
|
49
|
+
if (body === comment.body)
|
|
50
|
+
return comment;
|
|
51
|
+
const raised = comment.alreadyRaised;
|
|
52
|
+
const held = comment.status !== "dropped" &&
|
|
53
|
+
raised != null &&
|
|
54
|
+
raised.decision === null &&
|
|
55
|
+
sendTreatment({ alreadyRaised: raised }) === "hold";
|
|
56
|
+
if (!held)
|
|
57
|
+
return { ...comment, body };
|
|
58
|
+
return { ...comment, body, alreadyRaised: { ...raised, decision: "send" } };
|
|
59
|
+
}
|
|
22
60
|
/**
|
|
23
61
|
* Which match a comment keeps when several remarks raised the same thing:
|
|
24
62
|
* the liveliest place first, because that is what decides whether posting it
|
package/dist/core/refresh.js
CHANGED
|
@@ -44,6 +44,19 @@ export function refreshArtifact(artifact, pr, diff) {
|
|
|
44
44
|
export function userOwnsStatus(a) {
|
|
45
45
|
return a.sent !== null || a.status === "reviewed" || a.status === "skipped";
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* This row's sends, with the one a starting run is about to supersede folded in.
|
|
49
|
+
*
|
|
50
|
+
* The only way back into a review that was sent is a new draft, and `sent`
|
|
51
|
+
* describes a draft that is about to stop existing — so it cannot stay where
|
|
52
|
+
* it is. It also cannot simply go: that review is on the PR under the user's
|
|
53
|
+
* name, and a cockpit that forgets it would offer a second one as if it were
|
|
54
|
+
* the first. Called by the claim at the top of a run, which is the one moment
|
|
55
|
+
* a send stops covering what is on the page.
|
|
56
|
+
*/
|
|
57
|
+
export function supersede(a) {
|
|
58
|
+
return a.sent ? [...a.priorSends, a.sent] : a.priorSends;
|
|
59
|
+
}
|
|
47
60
|
/**
|
|
48
61
|
* Fold a finished review run onto whatever the artifact says now.
|
|
49
62
|
*
|
|
@@ -76,6 +89,9 @@ export function mergeRunResult(fresh, current) {
|
|
|
76
89
|
// the stamp away with the status.
|
|
77
90
|
settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
|
|
78
91
|
sent: current.sent,
|
|
92
|
+
// Written by this run's own claim (`supersede`), or by an earlier one —
|
|
93
|
+
// either way the disk is the only copy; `fresh` was built before the claim.
|
|
94
|
+
priorSends: current.priorSends,
|
|
79
95
|
// Disk wins: the ledger is the poll's, and the run knows nothing about it.
|
|
80
96
|
notified: current.notified,
|
|
81
97
|
calibration: current.calibration,
|
package/dist/core/revise.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rewriteFinding } from "./raised.js";
|
|
2
3
|
/** The review as it stands, for restoring later. */
|
|
3
4
|
export function snapshotReview(artifact, at) {
|
|
4
5
|
return {
|
|
@@ -43,10 +44,17 @@ export function mergeConcurrentEdits(before, after, current) {
|
|
|
43
44
|
};
|
|
44
45
|
const merged = current.comments.map((now) => {
|
|
45
46
|
const chosen = userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now;
|
|
47
|
+
if (chosen === now)
|
|
48
|
+
return now;
|
|
46
49
|
// Who else raised a comment is the check's to say, not the turn's: a check
|
|
47
50
|
// that landed while the turn ran — and a decision made about it — is only
|
|
48
51
|
// on disk, and the turn's copy predates both.
|
|
49
|
-
|
|
52
|
+
const onDisk = { ...chosen, body: now.body, alreadyRaised: now.alreadyRaised };
|
|
53
|
+
// The rewrite the turn made still decides an undecided match, as any
|
|
54
|
+
// rewrite does — measured against the body on disk, so a turn that changed
|
|
55
|
+
// nothing decides nothing. Never on a sent review: that is a record of what
|
|
56
|
+
// was posted, and nothing on it is re-decided, here or anywhere else.
|
|
57
|
+
return current.sent ? { ...onDisk, body: chosen.body } : rewriteFinding(onDisk, chosen.body);
|
|
50
58
|
});
|
|
51
59
|
// Comments the turn itself added are not in `current` yet. Only those: a
|
|
52
60
|
// comment present in both `before` and `after` but missing from `current` is
|
|
@@ -138,8 +146,10 @@ export function applyRevisions(artifact, revisions, opts = {}) {
|
|
|
138
146
|
...next,
|
|
139
147
|
comments: next.comments.map((c) => c.id === revision.commentId
|
|
140
148
|
? {
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
// A rewrite the user asked for decides a duplicate nobody
|
|
150
|
+
// had decided about: what posts is their words now, not the
|
|
151
|
+
// ones the check compared (`rewriteFinding`).
|
|
152
|
+
...rewriteFinding(c, revision.body),
|
|
143
153
|
// Omitted = the turn is not re-grading; null = "not a finding".
|
|
144
154
|
severity: revision.severity === undefined ? c.severity : revision.severity,
|
|
145
155
|
}
|
package/dist/core/state.js
CHANGED
|
@@ -197,11 +197,18 @@ export async function reconcileRunning(opts = {}) {
|
|
|
197
197
|
const stuckCheck = artifact.raisedCheck?.checkingSince != null;
|
|
198
198
|
if (!stuckRun && !stuckChat && !stuckCheck)
|
|
199
199
|
continue;
|
|
200
|
+
// A row still carrying its send was interrupted before the run claimed it,
|
|
201
|
+
// so the draft on it is untouched — and it is the one that went to GitHub.
|
|
202
|
+
// "failed" would hide a sent review behind a broken re-review, and the
|
|
203
|
+
// interruption belongs to the re-review rather than to the run block,
|
|
204
|
+
// which still describes the review that was posted. It goes to the history
|
|
205
|
+
// instead, which is where a thing that happened *to* a row belongs.
|
|
206
|
+
const interruptedSend = stuckRun && artifact.sent != null;
|
|
200
207
|
await saveArtifact({
|
|
201
208
|
...artifact,
|
|
202
|
-
status: stuckRun ? "failed" : artifact.status,
|
|
209
|
+
status: stuckRun ? (artifact.sent ? "sent" : "failed") : artifact.status,
|
|
203
210
|
updatedAt: new Date().toISOString(),
|
|
204
|
-
run: stuckRun && artifact.run
|
|
211
|
+
run: stuckRun && artifact.run && !interruptedSend
|
|
205
212
|
? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
|
|
206
213
|
: artifact.run,
|
|
207
214
|
pendingChat: stuckChat
|
|
@@ -211,6 +218,9 @@ export async function reconcileRunning(opts = {}) {
|
|
|
211
218
|
// report, and the next open starts another.
|
|
212
219
|
raisedCheck: artifact.raisedCheck && { ...artifact.raisedCheck, checkingSince: null },
|
|
213
220
|
});
|
|
221
|
+
if (interruptedSend) {
|
|
222
|
+
await noteHistory(artifact.id, "re-review interrupted — cerber restarted; the sent review is untouched");
|
|
223
|
+
}
|
|
214
224
|
cleared++;
|
|
215
225
|
}
|
|
216
226
|
return cleared;
|
package/dist/runner/review.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../core/artifact.js";
|
|
3
3
|
import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
|
|
4
4
|
import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
|
|
5
|
-
import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
|
|
5
|
+
import { mergeRunResult, supersede, userOwnsStatus } from "../core/refresh.js";
|
|
6
6
|
import { loadArtifact, noteHistory, saveArtifact, updateArtifactByKey } from "../core/state.js";
|
|
7
7
|
import { withWriter } from "../core/history.js";
|
|
8
8
|
import { loadConfig } from "../core/config.js";
|
|
@@ -191,13 +191,19 @@ async function performReview(ref, pr, existing, opts, log) {
|
|
|
191
191
|
trigger: opts.trigger ?? "user",
|
|
192
192
|
reviewedSha: null,
|
|
193
193
|
},
|
|
194
|
+
// Both filled in at the claim below, from the row as it stands then: a
|
|
195
|
+
// send this run supersedes moves to `priorSends` rather than vanishing.
|
|
194
196
|
sent: null,
|
|
197
|
+
priorSends: [],
|
|
195
198
|
refresh: null,
|
|
196
199
|
// A new draft has new findings, compared afresh once the run has written them.
|
|
197
200
|
raisedCheck: null,
|
|
198
201
|
filed: null,
|
|
199
202
|
handoff: null,
|
|
200
203
|
settledAt: null,
|
|
204
|
+
// Carried at the claim, not reset: it describes a review that really was
|
|
205
|
+
// sent, and `cerber stats` is the only reader. Dropping it here
|
|
206
|
+
// would quietly delete one data point every time a sent PR is re-reviewed.
|
|
201
207
|
calibration: null,
|
|
202
208
|
// The conversation is the user's writing, so a re-review keeps it — the
|
|
203
209
|
// chat prompt replays the transcript, which is what makes it survive the
|
|
@@ -222,10 +228,23 @@ async function performReview(ref, pr, existing, opts, log) {
|
|
|
222
228
|
// (the guard above only lets a forced re-review that far), and claiming it is
|
|
223
229
|
// how the reopen happens at all. A settle that appeared since is the one this
|
|
224
230
|
// must not touch.
|
|
231
|
+
//
|
|
232
|
+
// A send is the one decision the claim takes *over* rather than leaves
|
|
233
|
+
// alone, because the only way to reopen a sent row is to replace the draft
|
|
234
|
+
// that was sent. It is not discarded: `supersede` files it under
|
|
235
|
+
// `priorSends`, so the cockpit can still say what went to GitHub and when,
|
|
236
|
+
// and the send panel knows the next click would post a second review.
|
|
237
|
+
// Read from `current` rather than `existing`, which is minutes old.
|
|
225
238
|
const decided = existing ? userOwnsStatus(existing) : false;
|
|
226
239
|
const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => !decided && userOwnsStatus(current)
|
|
227
240
|
? current
|
|
228
|
-
: {
|
|
241
|
+
: {
|
|
242
|
+
...artifact,
|
|
243
|
+
notified: current.notified,
|
|
244
|
+
viewed: current.viewed,
|
|
245
|
+
priorSends: supersede(current),
|
|
246
|
+
calibration: current.calibration,
|
|
247
|
+
});
|
|
229
248
|
const held = claimed !== null && !decided && userOwnsStatus(claimed);
|
|
230
249
|
if (!held) {
|
|
231
250
|
if (claimed)
|
package/dist/server/daemon.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -16,9 +16,9 @@ import { refreshArtifact, userOwnsStatus } from "../core/refresh.js";
|
|
|
16
16
|
import { withWriter } from "../core/history.js";
|
|
17
17
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
18
18
|
import { buildReviewPayload, computeCalibration, describeReplyFailures, recordReplies, } from "../core/send.js";
|
|
19
|
-
import { raisedChanged, startRaisedCheck } from "../core/raised.js";
|
|
19
|
+
import { raisedChanged, rewriteFinding, startRaisedCheck } from "../core/raised.js";
|
|
20
20
|
import { checkAlreadyRaised, claimRaisedCheck, finishRaisedCheck, isRaisedCheckRunning, prepareRaisedCheck, releaseRaisedCheck, } from "../runner/raised.js";
|
|
21
|
-
import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
21
|
+
import { listArtifacts, loadArtifact, loadArtifactByKey, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
22
22
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
23
23
|
import { reviewPr } from "../runner/review.js";
|
|
24
24
|
import { runChatTurn } from "../runner/chat.js";
|
|
@@ -342,6 +342,7 @@ export async function buildApp(opts) {
|
|
|
342
342
|
},
|
|
343
343
|
sent: null,
|
|
344
344
|
refresh: null,
|
|
345
|
+
priorSends: [],
|
|
345
346
|
filed: null,
|
|
346
347
|
handoff: null,
|
|
347
348
|
raisedCheck: null,
|
|
@@ -465,11 +466,19 @@ export async function buildApp(opts) {
|
|
|
465
466
|
...a,
|
|
466
467
|
comments: a.comments.map((cm) => cm.id === id
|
|
467
468
|
? {
|
|
468
|
-
|
|
469
|
+
// Rewriting a finding somebody else raised decides it: the words
|
|
470
|
+
// that post are the user's, not the ones the check compared. An
|
|
471
|
+
// explicit decision in the same request still wins, below. On a
|
|
472
|
+
// sent review nothing is re-decided — it is a record of what was
|
|
473
|
+
// posted, and `foldRaised` holds the same line.
|
|
474
|
+
...(body.body === undefined
|
|
475
|
+
? cm
|
|
476
|
+
: a.sent
|
|
477
|
+
? { ...cm, body: String(body.body) }
|
|
478
|
+
: rewriteFinding(cm, String(body.body))),
|
|
469
479
|
...(decision.success && body.raisedDecision !== undefined && cm.alreadyRaised
|
|
470
480
|
? { alreadyRaised: { ...cm.alreadyRaised, decision: decision.data } }
|
|
471
481
|
: {}),
|
|
472
|
-
...(body.body !== undefined ? { body: String(body.body) } : {}),
|
|
473
482
|
...(body.severity !== undefined
|
|
474
483
|
? { severity: SeveritySchema.nullable().parse(body.severity) }
|
|
475
484
|
: {}),
|
|
@@ -560,8 +569,14 @@ export async function buildApp(opts) {
|
|
|
560
569
|
const stale = artifact.pr.headSha !== "" && artifact.pr.headSha !== pr.headSha;
|
|
561
570
|
// A sent review is a record of what was posted — never rewrite it. A running
|
|
562
571
|
// one is mid-run and would be overwritten from under the runner.
|
|
572
|
+
//
|
|
573
|
+
// `headSha` rides on the answer either way, and it is the only thing a
|
|
574
|
+
// declined refresh can tell the cockpit: on a sent row nothing is written,
|
|
575
|
+
// so the artifact handed back still names the commit that was reviewed,
|
|
576
|
+
// and the page would have nothing to say about a PR that has moved past a
|
|
577
|
+
// review the user cannot edit but can replace.
|
|
563
578
|
if (!stale || artifact.sent || artifact.status === "running") {
|
|
564
|
-
return c.json({ stale, changed: false, prState: pr.state, artifact });
|
|
579
|
+
return c.json({ stale, changed: false, prState: pr.state, headSha: pr.headSha, artifact });
|
|
565
580
|
}
|
|
566
581
|
try {
|
|
567
582
|
const diff = await fetchPrDiff(ref);
|
|
@@ -590,6 +605,7 @@ export async function buildApp(opts) {
|
|
|
590
605
|
stale: true,
|
|
591
606
|
changed: applied && result.changed,
|
|
592
607
|
prState: pr.state,
|
|
608
|
+
headSha: pr.headSha,
|
|
593
609
|
moved: applied ? result.moved : 0,
|
|
594
610
|
drifted: applied ? result.drifted : 0,
|
|
595
611
|
artifact: saved,
|
|
@@ -605,8 +621,13 @@ export async function buildApp(opts) {
|
|
|
605
621
|
const artifact = await loadArtifactByKey(key);
|
|
606
622
|
if (!artifact)
|
|
607
623
|
return c.json({ error: "not found" }, 404);
|
|
608
|
-
|
|
609
|
-
|
|
624
|
+
// A sent row may be re-reviewed, and this is the only way back into one.
|
|
625
|
+
// The author pushed again, somebody asked for another look, the review
|
|
626
|
+
// went out at a commit that no longer exists — none of that is answered by
|
|
627
|
+
// the review already on the PR, and refusing here left the cockpit with no
|
|
628
|
+
// move at all while `cerber review --force` did it from a terminal. What
|
|
629
|
+
// was sent is kept (`supersede`), the new draft is sendable in its own
|
|
630
|
+
// right, and the send panel says it would be a second review.
|
|
610
631
|
if (isReviewRunning(artifact.id)) {
|
|
611
632
|
return c.json({ error: busy(artifact, "a review of this PR is already running") }, 409);
|
|
612
633
|
}
|
|
@@ -617,21 +638,29 @@ export async function buildApp(opts) {
|
|
|
617
638
|
const withSource = sourceParam == null ? opts.withSource : sourceParam !== "0";
|
|
618
639
|
// Mark it running before responding, so the cockpit's next poll can't catch
|
|
619
640
|
// the old "ready" status and conclude the run already finished.
|
|
641
|
+
//
|
|
642
|
+
// The run block is left alone while the row still carries its send: that
|
|
643
|
+
// block describes the review on GitHub — its model, its cost, the commit
|
|
644
|
+
// it read — and a stub written here would erase all of it minutes before
|
|
645
|
+
// the runner's claim writes a real one, with nothing able to bring it back
|
|
646
|
+
// if the run never gets that far. The claim replaces it either way.
|
|
620
647
|
const running = await updateArtifactByKey(key, (a) => ({
|
|
621
648
|
...a,
|
|
622
649
|
status: "running",
|
|
623
|
-
run:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
650
|
+
run: a.sent
|
|
651
|
+
? a.run
|
|
652
|
+
: {
|
|
653
|
+
model: null,
|
|
654
|
+
startedAt: new Date().toISOString(),
|
|
655
|
+
finishedAt: null,
|
|
656
|
+
costUsd: null,
|
|
657
|
+
error: null,
|
|
658
|
+
withSource: withSource !== false,
|
|
659
|
+
trusted: false,
|
|
660
|
+
sessionId: null,
|
|
661
|
+
trigger: "user",
|
|
662
|
+
reviewedSha: null,
|
|
663
|
+
},
|
|
635
664
|
}));
|
|
636
665
|
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
|
637
666
|
void reviewPr(ref, {
|
|
@@ -646,9 +675,20 @@ export async function buildApp(opts) {
|
|
|
646
675
|
// The runner marks the artifact failed once it owns it; a failure before
|
|
647
676
|
// that (fetching the PR, or a run already in flight) would otherwise
|
|
648
677
|
// leave it stuck at "running" with nothing to clear it.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
678
|
+
//
|
|
679
|
+
// A row that still carries its send never got as far as the claim, so
|
|
680
|
+
// the draft on it is the one that went to GitHub and nothing about it
|
|
681
|
+
// has changed: it goes back to `sent`, untouched. The failure is the
|
|
682
|
+
// re-review's, not that review's, so it goes in the history rather
|
|
683
|
+
// than onto a run block that describes something else entirely.
|
|
684
|
+
const back = await updateArtifactByKey(key, (a) => a.status === "running"
|
|
685
|
+
? a.sent
|
|
686
|
+
? { ...a, status: "sent" }
|
|
687
|
+
: { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
|
|
688
|
+
: a).catch(() => null);
|
|
689
|
+
if (back?.sent) {
|
|
690
|
+
await noteHistory(back.id, `re-review did not start: ${message}`).catch(() => { });
|
|
691
|
+
}
|
|
652
692
|
});
|
|
653
693
|
return c.json(running, 202);
|
|
654
694
|
});
|
|
@@ -1009,12 +1049,36 @@ export async function buildApp(opts) {
|
|
|
1009
1049
|
let sendError = null;
|
|
1010
1050
|
let sent = null;
|
|
1011
1051
|
let payload = null;
|
|
1012
|
-
|
|
1052
|
+
//
|
|
1053
|
+
// A send a re-review has since superseded counts here too. It is not on
|
|
1054
|
+
// this draft, but it is on the PR under the user's name, and this guard
|
|
1055
|
+
// exists to stop a handoff being the second review — which it would be.
|
|
1056
|
+
const onDisk = postReview ? await loadArtifactByKey(key) : null;
|
|
1057
|
+
const alreadySent = postReview
|
|
1058
|
+
? (onDisk?.sent ??
|
|
1059
|
+
artifact.sent ??
|
|
1060
|
+
(onDisk ?? artifact).priorSends.at(-1) ??
|
|
1061
|
+
null)
|
|
1062
|
+
: null;
|
|
1013
1063
|
// And there has to be a review to post. The dialog only offers the box on a
|
|
1014
1064
|
// row that has one, but the dialog is not the guard: a stale tab or a direct
|
|
1015
1065
|
// call would otherwise submit a body that is nothing but cerber's footer,
|
|
1016
1066
|
// under the user's name, on a PR nobody has drafted a word about.
|
|
1017
1067
|
const somethingToSay = artifact.summary !== "" || artifact.comments.some((cm) => cm.status !== "dropped");
|
|
1068
|
+
// A decline here has to say so. The dialog's own line promises "this row
|
|
1069
|
+
// goes under sent, with the review on the PR"; letting the guard refuse in
|
|
1070
|
+
// silence walks the user to the next review believing it posted.
|
|
1071
|
+
if (postReview && alreadySent) {
|
|
1072
|
+
// Phrased to read after the cockpit's own "the review did not post:",
|
|
1073
|
+
// which is what wraps every `sendError` on the way to the user.
|
|
1074
|
+
sendError =
|
|
1075
|
+
`a review was already sent on this PR on ${alreadySent.at.slice(0, 10)}` +
|
|
1076
|
+
`${alreadySent.url ? ` (${alreadySent.url})` : ""}, and a second one is yours to send.` +
|
|
1077
|
+
` The draft is untouched.`;
|
|
1078
|
+
}
|
|
1079
|
+
else if (postReview && !somethingToSay) {
|
|
1080
|
+
sendError = "there is no draft on this row — only cerber's footer would have gone up.";
|
|
1081
|
+
}
|
|
1018
1082
|
if (postReview && !alreadySent && somethingToSay) {
|
|
1019
1083
|
payload = buildReviewPayload(artifact, "COMMENT");
|
|
1020
1084
|
try {
|
package/package.json
CHANGED