@diffohq/diffo 0.9.0 → 0.11.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 +19 -29
- package/dist/cli.mjs +795 -81
- package/dist/client/assets/{abnfDiagram-O67JEVCF-Dh4is2Nj.js → abnfDiagram-O67JEVCF-BP5iCFkM.js} +1 -1
- package/dist/client/assets/{arc-CMN0Gb1g.js → arc-C87wOYz0.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-CsHR0ILn.js +1 -0
- package/dist/client/assets/{architectureDiagram-NJMV4G6O-DxVGDSE1.js → architectureDiagram-NJMV4G6O-Ch5MdO5l.js} +1 -1
- package/dist/client/assets/{blockDiagram-BEXU5L5S-T1F9hSwZ.js → blockDiagram-BEXU5L5S-CFc74J_K.js} +1 -1
- package/dist/client/assets/{c4Diagram-YGBWAQC7-DC6zwYuX.js → c4Diagram-YGBWAQC7-ClsjpVUP.js} +1 -1
- package/dist/client/assets/channel-DQRWLOSk.js +1 -0
- package/dist/client/assets/{chunk-3FUC2YCW-D0PAoCRl.js → chunk-3FUC2YCW-N2ZkdF_f.js} +1 -1
- package/dist/client/assets/{chunk-5DYCD2WN-NqHzOtt0.js → chunk-5DYCD2WN-5muGfxCV.js} +1 -1
- package/dist/client/assets/{chunk-742MDFTN-B9Y5HHB7.js → chunk-742MDFTN-C5vBH3An.js} +1 -1
- package/dist/client/assets/{chunk-7INBJB4K-CBOWlj91.js → chunk-7INBJB4K-DPMhysil.js} +1 -1
- package/dist/client/assets/{chunk-7M6MHVWA-D2dZ_rQI.js → chunk-7M6MHVWA-D5sd2f7K.js} +1 -1
- package/dist/client/assets/{chunk-7PRAP22T-7dilu8x-.js → chunk-7PRAP22T-CI_sV34J.js} +1 -1
- package/dist/client/assets/{chunk-GTNCS2PH-at0ENjhB.js → chunk-GTNCS2PH-DfNZcIoa.js} +1 -1
- package/dist/client/assets/{chunk-GWA4HPMP-C06n_37U.js → chunk-GWA4HPMP-DQAQRo6A.js} +1 -1
- package/dist/client/assets/{chunk-MBY4JIJT-DuowBunG.js → chunk-MBY4JIJT-BaSwxH2J.js} +1 -1
- package/dist/client/assets/{chunk-NETBCI7D-ClNA7QFH.js → chunk-NETBCI7D-7ynGMJbY.js} +1 -1
- package/dist/client/assets/{chunk-O7XYJQB3-BjcjInBL.js → chunk-O7XYJQB3-B6zthtTM.js} +1 -1
- package/dist/client/assets/{chunk-UA2S7LBM-B-PAn1hd.js → chunk-UA2S7LBM-C5XT36wS.js} +1 -1
- package/dist/client/assets/{chunk-WEXAMYUT-Br84uK4B.js → chunk-WEXAMYUT-DKSfnXN1.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BbBZykgX.js → chunk-XXDRQBXY-C_bd9P3S.js} +1 -1
- package/dist/client/assets/{chunk-Z7XXMR3K-CQ-grwfe.js → chunk-Z7XXMR3K-DX8Rrfc1.js} +1 -1
- package/dist/client/assets/{chunk-ZIGJFQKS-CLwfE-hs.js → chunk-ZIGJFQKS-DYXH6dfg.js} +1 -1
- package/dist/client/assets/{classDiagram-v2-NBCMYWYE-DD-bcOHO.js → classDiagram-v2-NBCMYWYE-ppwXe7vx.js} +1 -1
- package/dist/client/assets/{core-BADyutHL.js → core-Cg_mWgd2.js} +1 -1
- package/dist/client/assets/{cose-bilkent-JH36ORCC-fuMg3J_l.js → cose-bilkent-JH36ORCC-DBThhQUN.js} +1 -1
- package/dist/client/assets/{cynefin-OW5HDTMX-DwFXJ_x8.js → cynefin-OW5HDTMX-BzSaDndh.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-VND7K2PF-cGL_k8gj.js → cynefinDiagram-VND7K2PF-D7pfZG6I.js} +1 -1
- package/dist/client/assets/{dagre-6A5THRUB-BetgCBn9.js → dagre-6A5THRUB-C09fFRAG.js} +1 -1
- package/dist/client/assets/{diagram-22UHCM2B-C7qT2Ix2.js → diagram-22UHCM2B-Ch1ZxAj-.js} +1 -1
- package/dist/client/assets/{diagram-3UASUU5V-DGUO9DWV.js → diagram-3UASUU5V-BgHiK8FV.js} +1 -1
- package/dist/client/assets/{diagram-ATOU4E4O-f520gjx5.js → diagram-ATOU4E4O-Bo77_UMN.js} +1 -1
- package/dist/client/assets/{diagram-CDSNMT55-Dhrm4GzB.js → diagram-CDSNMT55--hmHMcni.js} +1 -1
- package/dist/client/assets/{diagram-MLGK6HIB-Di0aRiuU.js → diagram-MLGK6HIB-D9F7Il7J.js} +1 -1
- package/dist/client/assets/{diagram-MPIPVDR6-BHhVZUxI.js → diagram-MPIPVDR6-D3d07JsR.js} +1 -1
- package/dist/client/assets/{dist-Cezvykkn.js → dist-CAR9vYXv.js} +1 -1
- package/dist/client/assets/{dist-CbdYocyN.js → dist-Dgznis65.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-ZINNZB2B-CQA0eib_.js → ebnfDiagram-ZINNZB2B-Bso144Pp.js} +1 -1
- package/dist/client/assets/{elk-276RUBZZ-CZKKVSu0.js → elk-276RUBZZ-DsWB0IFq.js} +1 -1
- package/dist/client/assets/{engine-oniguruma-B68c_YAs.js → engine-oniguruma-BDu3jbE4.js} +1 -1
- package/dist/client/assets/{erDiagram-OPXOYQCR-DcNnfq_S.js → erDiagram-OPXOYQCR-DFcxAkd4.js} +1 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-Dy8DiEN2.js +1 -0
- package/dist/client/assets/flowDiagram-KWPJA3E3-CNCW86am.js +1 -0
- package/dist/client/assets/{ganttDiagram-FUAMR5RP-Dteh33ci.js → ganttDiagram-FUAMR5RP-DZ8yDc0-.js} +1 -1
- package/dist/client/assets/{gitGraph-4MIJSDKK-DMTE1jTV.js → gitGraph-4MIJSDKK-jziaCjGb.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-X574FWY7-EV_0IPok.js → gitGraphDiagram-X574FWY7-DCRBeaGE.js} +1 -1
- package/dist/client/assets/index-C4VoHKyI.css +1 -0
- package/dist/client/assets/index-CWGB-mch.js +93 -0
- package/dist/client/assets/{info-A6RAGUB7-WkHzxEDS.js → info-A6RAGUB7-CVherEOw.js} +1 -1
- package/dist/client/assets/{infoDiagram-VRGFBTTK-BcH5U5G2.js → infoDiagram-VRGFBTTK-0xq1jhrR.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-Z8vZnj1l.js → ishikawaDiagram-OU5B5YK6-BOs1zS14.js} +1 -1
- package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Cn-m-UYg.js → journeyDiagram-ZHPQQLJL-D0Ozyf-O.js} +1 -1
- package/dist/client/assets/{kanban-definition-PNTS6WVX-DLOrUOML.js → kanban-definition-PNTS6WVX-DRE_uQjW.js} +1 -1
- package/dist/client/assets/{line-DhKwT3E3.js → line-DaN3iy15.js} +1 -1
- package/dist/client/assets/{linear-DvwuMCFu.js → linear-CRCMQGaX.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-CwcOI9nG.js → mermaid-parser.core-oN6M_WEa.js} +3 -3
- package/dist/client/assets/{mermaid.core-BabKb2iQ.js → mermaid.core-YJctE5LN.js} +4 -4
- package/dist/client/assets/{mindmap-definition-NLK3R4M7-D7wpNtz6.js → mindmap-definition-NLK3R4M7-C6eqCgee.js} +1 -1
- package/dist/client/assets/{packet-AYTQ26CC-BJablqte.js → packet-AYTQ26CC-D09KC4vR.js} +1 -1
- package/dist/client/assets/{pegDiagram-GJSIUBJH-EAlXAkUz.js → pegDiagram-GJSIUBJH-CbKH-rBq.js} +1 -1
- package/dist/client/assets/{pie-WAS4IAKB-BAAbtIiO.js → pie-WAS4IAKB-whtC2UUK.js} +1 -1
- package/dist/client/assets/{pieDiagram-5QR66LMP-b1pYP4ES.js → pieDiagram-5QR66LMP-Dvc9m9wg.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-O4NWA36T-B_LJNNdE.js → quadrantDiagram-O4NWA36T-D0G-xgdx.js} +1 -1
- package/dist/client/assets/{radar-RG4KPBEZ-BKldNRRC.js → radar-RG4KPBEZ-UErkZb84.js} +1 -1
- package/dist/client/assets/{railroad-74A4TZTK-LvULpHpQ.js → railroad-74A4TZTK-CQBJHfzO.js} +1 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-BedGOWtg.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-DudE8AEK.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-DlrQn7uw.js +1 -0
- package/dist/client/assets/{railroadDiagram-XR7U4H2S-kp6LeaeM.js → railroadDiagram-XR7U4H2S-DVT-lJ4r.js} +1 -1
- package/dist/client/assets/{requirementDiagram-PLB6GJNP-Br-OgP6b.js → requirementDiagram-PLB6GJNP-BWJZJJlR.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-IPEJSGJF-DSIbAxcK.js → sankeyDiagram-IPEJSGJF-CEh_PdFx.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-PO4LG4MO-uVv8u8VI.js → sequenceDiagram-PO4LG4MO-DodtYIMU.js} +1 -1
- package/dist/client/assets/{src-Bmfs-TFn.js → src-1v74j3tV.js} +1 -1
- package/dist/client/assets/{stateDiagram-v2-GCMORJYK-Crql1z4d.js → stateDiagram-v2-GCMORJYK-kMbRFMI8.js} +1 -1
- package/dist/client/assets/{swimlanes-2SLR337P-CnpU7AM-.js → swimlanes-2SLR337P-D6NL4heU.js} +1 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-CC02q6Vu.js +8 -0
- package/dist/client/assets/{timeline-definition-EJHVYXUP-CqzGi-Sd.js → timeline-definition-EJHVYXUP-DBiPEcaq.js} +1 -1
- package/dist/client/assets/{treeView-Q6P3EWNA-N_EzEUvg.js → treeView-Q6P3EWNA-B4A_X6Ee.js} +1 -1
- package/dist/client/assets/{treemap-WGGIJYW6-BYeVgP-v.js → treemap-WGGIJYW6-BKfnTV1e.js} +1 -1
- package/dist/client/assets/{usecaseDiagram-POWQR4AR-Dyeq2Zcy.js → usecaseDiagram-POWQR4AR-C-wOJ0sN.js} +1 -1
- package/dist/client/assets/{vennDiagram-UO4OBE2U-CuCjJaOo.js → vennDiagram-UO4OBE2U-CgBX6mVG.js} +1 -1
- package/dist/client/assets/{wardley-WFR3VGLG-AV0-dO_Z.js → wardley-WFR3VGLG-Ca0IxSXR.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-VNRHLVJA-f0dVm2LA.js → wardleyDiagram-VNRHLVJA-BtiwsIrq.js} +1 -1
- package/dist/client/assets/{xychartDiagram-PMCCYNJV-BNjSW7P1.js → xychartDiagram-PMCCYNJV-BYZTngs6.js} +1 -1
- package/dist/client/favicon.svg +15 -7
- package/dist/client/index.html +2 -2
- package/package.json +9 -8
- package/plugin.json +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-DaIFNiR2.js +0 -1
- package/dist/client/assets/channel-Ci5ZF5xD.js +0 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-C61pKQjq.js +0 -1
- package/dist/client/assets/flowDiagram-KWPJA3E3-BolWtw3C.js +0 -1
- package/dist/client/assets/index-B__poZ2b.js +0 -93
- package/dist/client/assets/index-CL4K6Y07.css +0 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-CORGw4Le.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-BXCX4hkl.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-C6BQUpjn.js +0 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-Crca2YKi.js +0 -8
package/dist/cli.mjs
CHANGED
|
@@ -126,6 +126,29 @@ function detectHarness() {
|
|
|
126
126
|
//#endregion
|
|
127
127
|
//#region src/shared/review.ts
|
|
128
128
|
const THREAD_INTENTS = ["fix", "question"];
|
|
129
|
+
/** A review comment on GitHub tops out at 65 536 characters; a suggestion that
|
|
130
|
+
* long is a mistake, and the reviewer edits it in a thread card. */
|
|
131
|
+
const PR_COMMENT_MAX = 1e4;
|
|
132
|
+
function parsePrComment(value) {
|
|
133
|
+
if (typeof value !== "string") return void 0;
|
|
134
|
+
const text = value.trim();
|
|
135
|
+
return text ? text.slice(0, PR_COMMENT_MAX) : void 0;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The suggestion the reviewer can still act on: the latest agent message
|
|
139
|
+
* carrying one, while nothing has been decided about it and the thread is
|
|
140
|
+
* open. Earlier suggestions in the same thread are superseded, decided or
|
|
141
|
+
* not — a redraft replaces, it never stacks.
|
|
142
|
+
*/
|
|
143
|
+
function liveSuggestion(thread) {
|
|
144
|
+
if (isPublic(thread) || thread.state === "resolved") return null;
|
|
145
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
146
|
+
const m = thread.messages[i];
|
|
147
|
+
if (m.author !== "agent" || !m.prComment) continue;
|
|
148
|
+
return m.prComment.outcome === void 0 ? m : null;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
129
152
|
function parseSuggestedReply(value) {
|
|
130
153
|
if (typeof value !== "string") return void 0;
|
|
131
154
|
const line = value.replace(/\s+/g, " ").trim();
|
|
@@ -166,6 +189,10 @@ function seenByAgent(thread, index) {
|
|
|
166
189
|
function untouchedAgentVoice(thread) {
|
|
167
190
|
return startedByAgent(thread) && !thread.messages.some((m) => m.author === "reviewer");
|
|
168
191
|
}
|
|
192
|
+
/** The path a layer file names, without the reserved `:from-to` range. */
|
|
193
|
+
function layerFilePath(file) {
|
|
194
|
+
return (typeof file === "string" ? file : file.path).replace(/:\d+-\d+$/, "");
|
|
195
|
+
}
|
|
169
196
|
/**
|
|
170
197
|
* Feedback the reviewer is still owed an answer on — the delivery queue's contents,
|
|
171
198
|
* derived rather than remembered, so a restart cannot drop an Ask nobody collected.
|
|
@@ -199,13 +226,15 @@ const EMPTY_REVIEW = {
|
|
|
199
226
|
* Past threads are hidden, never deleted: a stash or a branch switch empties the diff
|
|
200
227
|
* for a minute and must not destroy a thread.
|
|
201
228
|
*/
|
|
202
|
-
function threadsInChangeset(files, threads) {
|
|
229
|
+
function threadsInChangeset(files, threads, layers) {
|
|
203
230
|
const hunkIds = new Set(files.flatMap((f) => f.hunks.map((h) => h.id)));
|
|
204
231
|
const paths = new Set(files.flatMap((f) => f.oldPath ? [f.path, f.oldPath] : [f.path]));
|
|
232
|
+
const layerIds = new Set((layers?.items ?? []).map((l) => l.id));
|
|
205
233
|
const lives = (thread) => {
|
|
206
234
|
const anchor = thread.anchor;
|
|
207
235
|
if (anchor.kind === "changeset") return files.length > 0;
|
|
208
236
|
if (anchor.kind === "file") return paths.has(anchor.path);
|
|
237
|
+
if (anchor.kind === "layer") return layerIds.has(anchor.layerId);
|
|
209
238
|
return hunkIds.has(anchor.hunkId) || paths.has(anchor.path);
|
|
210
239
|
};
|
|
211
240
|
const active = [];
|
|
@@ -223,6 +252,7 @@ function anchorSpan(anchor) {
|
|
|
223
252
|
function describeAnchor(anchor) {
|
|
224
253
|
if (anchor.kind === "changeset") return "the whole changeset";
|
|
225
254
|
if (anchor.kind === "file") return anchor.path;
|
|
255
|
+
if (anchor.kind === "layer") return `layer "${anchor.title}"`;
|
|
226
256
|
return `${anchor.path}:${anchorSpan(anchor)} (${anchor.side} side)`;
|
|
227
257
|
}
|
|
228
258
|
//#endregion
|
|
@@ -241,7 +271,19 @@ function findHunk(changeset, hunkId) {
|
|
|
241
271
|
* — for the anchored line range — where those rows sit in it and their text.
|
|
242
272
|
* Null for non-hunk anchors and for a hunk the changeset no longer has.
|
|
243
273
|
*/
|
|
244
|
-
function captureAnchor(changeset, anchor) {
|
|
274
|
+
function captureAnchor(changeset, anchor, layers) {
|
|
275
|
+
if (anchor.kind === "layer") {
|
|
276
|
+
const layer = layers?.items.find((l) => l.id === anchor.layerId);
|
|
277
|
+
if (!layer) return null;
|
|
278
|
+
return {
|
|
279
|
+
codeContext: null,
|
|
280
|
+
anchoredLayer: {
|
|
281
|
+
title: layer.title,
|
|
282
|
+
...layer.summary ? { summary: layer.summary } : {},
|
|
283
|
+
files: layer.files.map(layerFilePath)
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
245
287
|
if (anchor.kind !== "hunk") return null;
|
|
246
288
|
const hunk = findHunk(changeset, anchor.hunkId);
|
|
247
289
|
if (!hunk) return null;
|
|
@@ -355,6 +397,8 @@ const ACK_NEXT_STEP = {
|
|
|
355
397
|
reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
356
398
|
replyMore: "Interim reply posted — the reviewer still sees you working on this thread. Post the follow-up as a plain reply (no --more) BEFORE your next poll: re-polling closes the batch and counts the promise as never kept.",
|
|
357
399
|
comment: `It's in the review as your comment, labeled as yours — the reviewer replies to take it up, or resolves it. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
|
|
400
|
+
replyPrComment: `Reply posted, with the suggested review comment under it. The reviewer adds it to their review, edits it first, or dismisses it — nothing posts on its own, and you will hear what happened when they submit. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
401
|
+
commentPrComment: `It's in the review as your comment, with the suggested review comment under it. The reviewer adds it to their review, edits it first, or dismisses it — nothing posts on its own. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
|
|
358
402
|
layers: `The outline is live in the reviewer's Layers tab, resolved against the changeset as it moves. Files you touch later land in a trailing "Since your review" layer until you re-post the whole list. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
|
|
359
403
|
layersSuggested: `The review now offers the outline to the reviewer. Mention it in your handoff too — "say layers and I'll outline it" — and post it with \`${CLI_COMMANDS.layers}\` when they ask. Then run \`${CLI_COMMANDS.poll}\`.`,
|
|
360
404
|
layersAlready: `This review already carries layers, so there is nothing to suggest — re-post the whole list with \`${CLI_COMMANDS.layers}\` if the outline is stale. Then run \`${CLI_COMMANDS.poll}\`.`,
|
|
@@ -372,6 +416,18 @@ const HELP_AGENT_PR = `Reviewing a pull request (\`diffo <PR URL | owner/repo#N
|
|
|
372
416
|
label: read what the reviewer wants from the words. When the answer is a
|
|
373
417
|
fix, put it in a \`\`\`suggestion block in your reply, not an edit to the
|
|
374
418
|
worktree — the reviewer can post your reply to GitHub from there.
|
|
419
|
+
- Read who each private message is for. A question ("what calls this?")
|
|
420
|
+
wants an answer. A finding stated about the code ("this resets the streak
|
|
421
|
+
on late completions") is a thought on its way to the author: write the
|
|
422
|
+
review comment they would leave, in their voice, and attach it with
|
|
423
|
+
\`--pr-comment "<text>"\` on your reply (or your comment). It shows under
|
|
424
|
+
your reply with Add to review / Edit / Dismiss; your evidence stays in the
|
|
425
|
+
reply, never in the comment. Attach one when they state a finding, when
|
|
426
|
+
their hunch proves right, when they ask you to draft it, or when a plain
|
|
427
|
+
question turns up a bug you can show. Not when they were wrong (say so
|
|
428
|
+
privately), not on pushback against your own reply — redraft the one
|
|
429
|
+
already in the thread instead — and not when asked but you have nothing
|
|
430
|
+
worth saying to the author: say so privately, never invent one.
|
|
375
431
|
- The guide and layers work as above, built from the description, the commits
|
|
376
432
|
and the diff; skip the guide when the description already orients.
|
|
377
433
|
- When the reviewer submits, a poll returns a \`"kind": "submitted"\` notice:
|
|
@@ -442,14 +498,56 @@ const LAYERS = {
|
|
|
442
498
|
stance: GUIDE.stance,
|
|
443
499
|
/** A post is the whole list. */
|
|
444
500
|
replace: "a post replaces the whole list, never merges; ids are kept for titles that match, so a re-post never moves the reviewer's place",
|
|
501
|
+
/** What earns a line in a layer's `decisions`, and the bar for it. The
|
|
502
|
+
* reviewer reads these before the diff — or instead of it — so every entry
|
|
503
|
+
* must be something they could act on, never a retelling of the change. */
|
|
504
|
+
decisions: "a layer may carry up to five \"decisions\", each one short line (under eight words) with a one-sentence \"detail\" and an \"at\" — where it lives: \"path\", \"path:line\" or \"path:from-to\", or a list of up to three such places when the decision spans hunks (the rule and the test that pins it). A decision is where you chose between real alternatives a reviewer might weigh differently (a cache, a data shape, a fallback), where the code now does something the diff does not make obvious (a 400 where there was an empty list, a sort every caller sees), or where something in the repo or the environment shaped the change (no clock on the model, tests you could not run). Only what a reviewer could want done differently or would be surprised by later; nothing about naming or style; no verdicts. Write each line the way you would answer \"what did you decide here?\" to a teammate who has not seen the code: a concrete statement in plain words (\"Unknown ?due= values return 400, not an empty list\", \"Todos store a date, never a time\"), understood in a second by someone who has not read the diff — no project nicknames, no clever phrasing, no mechanism named instead of its effect. Most layers have none — a plumbing step has nothing to decide, so leave the list out",
|
|
445
505
|
/** The payload, in one line. */
|
|
446
|
-
shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }] }]"
|
|
506
|
+
shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }], \"decisions\": [{ \"text\": \"…\", \"detail\": \"…\", \"at\": \"src/a.ts:12-18\" | [\"src/a.ts:12-18\", \"test/a.test.ts:40\"] }] (optional, at most five) }]"
|
|
447
507
|
};
|
|
448
508
|
const AUTHOR_DOCTRINE = {
|
|
449
509
|
guide: GUIDE,
|
|
450
510
|
layers: LAYERS,
|
|
451
511
|
fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
|
|
452
512
|
};
|
|
513
|
+
/**
|
|
514
|
+
* The one piece of PR doctrine with no analogue for an author: the reviewer's
|
|
515
|
+
* private words are often a review comment in the making, and the agent is the
|
|
516
|
+
* one who can write it while the reviewer is still reading. The comment is
|
|
517
|
+
* addressed to the author in the reviewer's voice; nothing of it posts until the
|
|
518
|
+
* reviewer adds it to their review and submits.
|
|
519
|
+
*/
|
|
520
|
+
const PR_COMMENT_DOCTRINE = `## Drafting a review comment for the author
|
|
521
|
+
|
|
522
|
+
The reviewer's private messages are of two kinds. A question to you ("what
|
|
523
|
+
calls this?") wants an answer. A finding stated about the code ("this resets
|
|
524
|
+
the streak on late completions") is a thought on its way to the author: the
|
|
525
|
+
reviewer is thinking out loud at the line, and the next thing they would type
|
|
526
|
+
is a review comment. Write that comment for them, attached to your reply:
|
|
527
|
+
|
|
528
|
+
${CLI} reply <threadId> --message "<your private answer>" --pr-comment "<the comment>"
|
|
529
|
+
|
|
530
|
+
Attach one when: they state a finding (always, a nit included); their hunch
|
|
531
|
+
phrased as a question turns out right; they ask you to draft it ("write this
|
|
532
|
+
up for the author"); or your answer to a plain question is a bug with a case
|
|
533
|
+
to show. Do not attach one when they were wrong — say so privately — or when
|
|
534
|
+
they are pushing back on your own reply; then redraft the comment already in
|
|
535
|
+
the thread, which replaces it. Asked to suggest a change where you find
|
|
536
|
+
nothing worth saying to the author, say so privately and attach nothing: a
|
|
537
|
+
comment invented to fill the request is the one thing the reviewer cannot
|
|
538
|
+
tell apart from a finding. On a comment of your own (\`${CLI} comment\`), the
|
|
539
|
+
same flag carries a finding you can show, never a hunch.
|
|
540
|
+
|
|
541
|
+
The comment is in the reviewer's voice, to the author: state the finding, why
|
|
542
|
+
it matters, and what would fix it. A \`\`\`suggestion block when the fix is local
|
|
543
|
+
to the anchored lines. Nothing about you, the worktree, tests you ran, or
|
|
544
|
+
Diffo — that evidence goes in --message, where the reviewer reads it. Match
|
|
545
|
+
how this reviewer writes: their own review comments on this pull request are
|
|
546
|
+
quoted under "The reviewer's voice" when there are any.
|
|
547
|
+
|
|
548
|
+
Nothing you attach reaches GitHub. It appears under your reply with Add to
|
|
549
|
+
review / Edit / Dismiss; the reviewer decides, and the submit notice tells you
|
|
550
|
+
what happened to each.`;
|
|
453
551
|
const PR_DOCTRINE = {
|
|
454
552
|
guide: {
|
|
455
553
|
when: "the pull request description leaves a cold reader without the shape of the change — skip when the description already orients",
|
|
@@ -461,7 +559,8 @@ const PR_DOCTRINE = {
|
|
|
461
559
|
...LAYERS,
|
|
462
560
|
order: "the order you would explain it in after reading the description and the commits — the file that explains the rest first, mechanical consequences last; for a feature, follow the request from entry point to effect; for a refactor, contract first, then consumers"
|
|
463
561
|
},
|
|
464
|
-
fix: "- `issue` threads want a fix you cannot push: work it out in the worktree, verify it the cheapest honest way, restore the worktree (`git checkout -- .`), and reply with a ```suggestion block plus what you checked. Never commit or push to the pull request."
|
|
562
|
+
fix: "- `issue` threads want a fix you cannot push: work it out in the worktree, verify it the cheapest honest way, restore the worktree (`git checkout -- .`), and reply with a ```suggestion block plus what you checked. Never commit or push to the pull request.",
|
|
563
|
+
prComment: PR_COMMENT_DOCTRINE
|
|
465
564
|
};
|
|
466
565
|
function doctrineFor(changeset) {
|
|
467
566
|
return changeset?.pr ? PR_DOCTRINE : AUTHOR_DOCTRINE;
|
|
@@ -565,7 +664,7 @@ ${intentContract(threads, D).map((line) => ` ${line}`).join("\n")}
|
|
|
565
664
|
|
|
566
665
|
Change only what these threads ask about — the reviewer is mid-read. Re-read
|
|
567
666
|
the current file before editing; the code may have moved. Resolving a thread
|
|
568
|
-
is the reviewer's call, never yours
|
|
667
|
+
is the reviewer's call, never yours.${D.prComment ? `\n\nA private message that states a finding about the code is a review comment in the making: attach the comment the reviewer would leave the author, in their voice, with \`--pr-comment "<text>"\` on your reply (evidence stays in --message). Not when they were wrong, not on pushback against your reply. \`${CLI} help agent\` has the whole rule.` : ""}`;
|
|
569
668
|
}
|
|
570
669
|
function replyProtocol(threads, mode = "full", D = AUTHOR_DOCTRINE) {
|
|
571
670
|
if (mode === "compact") return compactProtocol(threads, D);
|
|
@@ -618,7 +717,64 @@ Rules:
|
|
|
618
717
|
- Each "commented change" above was frozen when the comment was written —
|
|
619
718
|
re-read the current file before editing; the code may have moved since.
|
|
620
719
|
- Your code edits are detected automatically and the reviewer's diff updates
|
|
621
|
-
live. Resolving a thread is the reviewer's call, never yours
|
|
720
|
+
live. Resolving a thread is the reviewer's call, never yours.${D.prComment ? `\n\n${D.prComment}` : ""}`;
|
|
721
|
+
}
|
|
722
|
+
const VOICE_COMMENT_CAP = 5;
|
|
723
|
+
const VOICE_EDIT_CAP = 3;
|
|
724
|
+
const VOICE_TEXT_CAP = 400;
|
|
725
|
+
function voiceQuote(text) {
|
|
726
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
727
|
+
return flat.length > VOICE_TEXT_CAP ? `${flat.slice(0, 399)}…` : flat;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The reviewer's own review comments on this pull request, newest first, and
|
|
731
|
+
* for each suggestion of the agent's they edited before adding, both versions.
|
|
732
|
+
* The agent sees "I wrote X, they posted Y" and the next suggestion sounds like
|
|
733
|
+
* the reviewer. Null when there is nothing of theirs yet.
|
|
734
|
+
*/
|
|
735
|
+
function voiceLines(threads) {
|
|
736
|
+
const byId = new Map(threads.map((t) => [t.id, t]));
|
|
737
|
+
const comments = threads.filter((t) => isPublic(t) && t.messages[0]?.author === "reviewer").map((t) => ({
|
|
738
|
+
at: t.createdAt,
|
|
739
|
+
line: `- ${describeAnchor(t.anchor)} — "${voiceQuote(t.messages[0].text)}"`
|
|
740
|
+
})).sort((a, b) => b.at.localeCompare(a.at)).slice(0, VOICE_COMMENT_CAP);
|
|
741
|
+
const edits = [];
|
|
742
|
+
for (const t of threads) {
|
|
743
|
+
if (isPublic(t)) continue;
|
|
744
|
+
for (const m of t.messages) {
|
|
745
|
+
const o = m.prComment?.outcome;
|
|
746
|
+
if (m.author !== "agent" || !o || o.kind !== "added" || !o.edited) continue;
|
|
747
|
+
const theirs = byId.get(o.draftThreadId)?.messages[0]?.text;
|
|
748
|
+
if (!theirs) continue;
|
|
749
|
+
edits.push({
|
|
750
|
+
at: o.at,
|
|
751
|
+
lines: [
|
|
752
|
+
`- ${describeAnchor(t.anchor)}`,
|
|
753
|
+
` - yours: "${voiceQuote(m.prComment.text)}"`,
|
|
754
|
+
` - theirs: "${voiceQuote(theirs)}"`
|
|
755
|
+
]
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
edits.sort((a, b) => b.at.localeCompare(a.at));
|
|
760
|
+
const pairs = edits.slice(0, VOICE_EDIT_CAP).flatMap((e) => e.lines);
|
|
761
|
+
if (comments.length === 0 && pairs.length === 0) return null;
|
|
762
|
+
return [
|
|
763
|
+
"## The reviewer's voice",
|
|
764
|
+
"",
|
|
765
|
+
...comments.length > 0 ? ["Their review comments on this pull request so far (posted or drafted):", ...comments.map((c) => c.line)] : [],
|
|
766
|
+
...pairs.length > 0 ? [
|
|
767
|
+
...comments.length > 0 ? [""] : [],
|
|
768
|
+
"What you suggested, and what they posted after editing it:",
|
|
769
|
+
...pairs
|
|
770
|
+
] : []
|
|
771
|
+
].join("\n");
|
|
772
|
+
}
|
|
773
|
+
/** The voice block, when the review is a pull request and the reviewer has written anything. */
|
|
774
|
+
function voiceBlock(ctx) {
|
|
775
|
+
if (!ctx.changeset?.pr || !ctx.voice) return [];
|
|
776
|
+
const block = voiceLines(ctx.voice);
|
|
777
|
+
return block ? [block, ""] : [];
|
|
622
778
|
}
|
|
623
779
|
const FRAME_FILE_CAP = 40;
|
|
624
780
|
function fileLine(file) {
|
|
@@ -693,8 +849,19 @@ function snapshotWindow(total, anchored) {
|
|
|
693
849
|
/** The snapshot as the agent sees it: windowed on the anchored rows, each of
|
|
694
850
|
* them marked with `>`. Threads from before `anchored` existed keep the old
|
|
695
851
|
* head-of-hunk window. */
|
|
852
|
+
/** A layer thread's stand-in for the diff snapshot: the step as the reviewer
|
|
853
|
+
* read it, so the agent answers about the outline, not about a line. */
|
|
854
|
+
function layerBlock(layer) {
|
|
855
|
+
return [
|
|
856
|
+
"The layer this comments on, as it was outlined when the comment was written:",
|
|
857
|
+
`- title: ${layer.title}`,
|
|
858
|
+
...layer.summary ? ["- summary:", ...layer.summary.split("\n").map((l) => ` ${l}`)] : [],
|
|
859
|
+
`- files: ${layer.files.map((f) => `\`${f}\``).join(", ")}`
|
|
860
|
+
];
|
|
861
|
+
}
|
|
696
862
|
function snapshotBlock(thread) {
|
|
697
863
|
const { codeContext, anchored } = thread;
|
|
864
|
+
if (thread.anchor.kind === "layer") return thread.anchoredLayer ? layerBlock(thread.anchoredLayer) : [];
|
|
698
865
|
if (!codeContext) {
|
|
699
866
|
if (!anchored) return [];
|
|
700
867
|
const shown = anchored.text.split("\n");
|
|
@@ -708,7 +875,7 @@ function snapshotBlock(thread) {
|
|
|
708
875
|
];
|
|
709
876
|
}
|
|
710
877
|
const lines = codeContext.split("\n");
|
|
711
|
-
const where = thread.anchor.kind === "
|
|
878
|
+
const where = thread.anchor.kind === "hunk" || thread.anchor.kind === "file" ? `\`${thread.anchor.path}\`` : "the file";
|
|
712
879
|
if (thread.deliveredThrough) return [`(diff snapshot delivered to you earlier — read the current ${where} instead)`];
|
|
713
880
|
if (!anchored) return [
|
|
714
881
|
"The commented change:",
|
|
@@ -758,6 +925,7 @@ function buildThreadPrompt(thread, ctx) {
|
|
|
758
925
|
threadBlock(thread, 0),
|
|
759
926
|
"",
|
|
760
927
|
...siblings ? [siblings, ""] : [],
|
|
928
|
+
...voiceBlock(ctx),
|
|
761
929
|
replyProtocol([thread], ctx.protocol, D),
|
|
762
930
|
""
|
|
763
931
|
].join("\n");
|
|
@@ -773,6 +941,7 @@ function buildCoalescedPrompt(threads, ctx) {
|
|
|
773
941
|
...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
|
|
774
942
|
...threads.map((t, i) => `${threadBlock(t, i)}\n`),
|
|
775
943
|
...siblings ? [siblings, ""] : [],
|
|
944
|
+
...voiceBlock(ctx),
|
|
776
945
|
replyProtocol(threads, ctx.protocol, D),
|
|
777
946
|
""
|
|
778
947
|
].join("\n");
|
|
@@ -781,6 +950,17 @@ function buildCoalescedPrompt(threads, ctx) {
|
|
|
781
950
|
* The reviewer submitted the pull-request review on GitHub. Context, not work:
|
|
782
951
|
* the agent learns the review ended and what it said, and owes nothing.
|
|
783
952
|
*/
|
|
953
|
+
/** What became of the agent's suggested review comments, in one line: the
|
|
954
|
+
* feedback that lets it calibrate, aggregate so it never argues for one. */
|
|
955
|
+
function suggestionsLine(s) {
|
|
956
|
+
const n = (count, word) => `${count} ${word}`;
|
|
957
|
+
const parts = [
|
|
958
|
+
`${n(s.posted, "posted")}${s.edited > 0 ? ` (${s.edited} edited first)` : ""}`,
|
|
959
|
+
...s.dismissed > 0 ? [n(s.dismissed, "dismissed")] : [],
|
|
960
|
+
...s.undecided > 0 ? [`${s.undecided} left undecided (they stay private and never post)`] : []
|
|
961
|
+
];
|
|
962
|
+
return `Of the ${s.total} review comment${s.total === 1 ? "" : "s"} you suggested: ${parts.join(", ")}.`;
|
|
963
|
+
}
|
|
784
964
|
function buildSubmittedPrompt(ctx, submitted) {
|
|
785
965
|
const pr = ctx.changeset?.pr;
|
|
786
966
|
return [
|
|
@@ -791,6 +971,7 @@ function buildSubmittedPrompt(ctx, submitted) {
|
|
|
791
971
|
...submitted.body.split("\n").map((l) => `> ${l}`),
|
|
792
972
|
""
|
|
793
973
|
] : [],
|
|
974
|
+
...submitted.suggestions && submitted.suggestions.total > 0 ? [suggestionsLine(submitted.suggestions), ""] : [],
|
|
794
975
|
"Nothing to act on — this is context so you know where the review stands. Mention it to the user in one line.",
|
|
795
976
|
`Run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up. If the user is done, \`${CLI_COMMANDS.end}\`.`,
|
|
796
977
|
""
|
|
@@ -823,6 +1004,8 @@ function buildLayersRequestPrompt(ctx, existing) {
|
|
|
823
1004
|
"",
|
|
824
1005
|
`Order: ${D.layers.order}. ${D.layers.mechanical}. ${D.layers.stance}. Files are whole files, by path relative to the repo root; list every file of the changeset somewhere, or the leftovers land in "Since your review".`,
|
|
825
1006
|
"",
|
|
1007
|
+
`Decisions: ${D.layers.decisions}.`,
|
|
1008
|
+
"",
|
|
826
1009
|
`Shape: ${LAYERS.shape}`,
|
|
827
1010
|
"",
|
|
828
1011
|
`Post it: \`${CLI_COMMANDS.layers}\`, or pipe the JSON to \`${CLI_COMMANDS.layersStdin}\` when it is long. Nothing else is owed for this item — no reply, no comment.`,
|
|
@@ -898,6 +1081,8 @@ const SUMMARY_CAP = 4e3;
|
|
|
898
1081
|
/** A note is one line by contract — anything past the first newline is dropped,
|
|
899
1082
|
* and the line itself is capped so the file header stays a header. */
|
|
900
1083
|
const NOTE_CAP = 200;
|
|
1084
|
+
const DECISION_TEXT_CAP = 120;
|
|
1085
|
+
const DECISION_DETAIL_CAP = 400;
|
|
901
1086
|
const REPO_PATH = /^[^\0]+$/;
|
|
902
1087
|
/**
|
|
903
1088
|
* Paths are relative to the repo root: no leading slash, no `..` segment, no
|
|
@@ -956,6 +1141,93 @@ function oneLine(text, cap) {
|
|
|
956
1141
|
const line = text.split("\n", 1)[0].trim();
|
|
957
1142
|
return line.length > cap ? `${line.slice(0, cap - 1).trimEnd()}…` : line;
|
|
958
1143
|
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Where a decision points: `path`, `path:line` or `path:from-to`, the same
|
|
1146
|
+
* spelling a file reference in a summary uses. Lines are head-side and
|
|
1147
|
+
* 1-based; a range is normalised so `from` never exceeds `to`.
|
|
1148
|
+
*/
|
|
1149
|
+
function parseDecisionAt(raw) {
|
|
1150
|
+
const m = /^(.*?)(?::(\d+)(?:-(\d+))?)?$/.exec(raw.trim());
|
|
1151
|
+
const path = m?.[1] ?? "";
|
|
1152
|
+
const why = badPath(path);
|
|
1153
|
+
if (why) return `"at" path "${raw}" ${why}`;
|
|
1154
|
+
if (m?.[2] === void 0) return { path };
|
|
1155
|
+
const a = Number.parseInt(m[2], 10);
|
|
1156
|
+
const b = m[3] === void 0 ? a : Number.parseInt(m[3], 10);
|
|
1157
|
+
if (a < 1 || b < 1) return `"at" line in "${raw}" must be 1 or more`;
|
|
1158
|
+
const [line, endLine] = a <= b ? [a, b] : [b, a];
|
|
1159
|
+
return endLine === line ? {
|
|
1160
|
+
path,
|
|
1161
|
+
line
|
|
1162
|
+
} : {
|
|
1163
|
+
path,
|
|
1164
|
+
line,
|
|
1165
|
+
endLine
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/** The object form of `at` back as the string the agent would have written;
|
|
1169
|
+
* null for anything that is not that object. */
|
|
1170
|
+
function atToString(raw) {
|
|
1171
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
1172
|
+
const a = raw;
|
|
1173
|
+
if (typeof a.path !== "string") return null;
|
|
1174
|
+
if (a.line === void 0) return a.endLine === void 0 ? a.path : null;
|
|
1175
|
+
if (typeof a.line !== "number" || !Number.isInteger(a.line)) return null;
|
|
1176
|
+
if (a.endLine === void 0) return `${a.path}:${a.line}`;
|
|
1177
|
+
if (typeof a.endLine !== "number" || !Number.isInteger(a.endLine)) return null;
|
|
1178
|
+
return `${a.path}:${a.line}-${a.endLine}`;
|
|
1179
|
+
}
|
|
1180
|
+
function parseDecision(raw, where) {
|
|
1181
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
error: `${where}: each decision is an object { text, detail?, at? }`
|
|
1184
|
+
};
|
|
1185
|
+
const d = raw;
|
|
1186
|
+
const text = typeof d.text === "string" ? oneLine(d.text, DECISION_TEXT_CAP) : "";
|
|
1187
|
+
if (text === "") return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
error: `${where}: needs a non-empty "text"`
|
|
1190
|
+
};
|
|
1191
|
+
if (d.detail !== void 0 && typeof d.detail !== "string") return {
|
|
1192
|
+
ok: false,
|
|
1193
|
+
error: `${where} ("${text}"): "detail" must be a string`
|
|
1194
|
+
};
|
|
1195
|
+
const detail = typeof d.detail === "string" ? d.detail.trim().slice(0, DECISION_DETAIL_CAP) : void 0;
|
|
1196
|
+
let at;
|
|
1197
|
+
if (d.at !== void 0) {
|
|
1198
|
+
const raws = Array.isArray(d.at) ? d.at : [d.at];
|
|
1199
|
+
if (raws.length === 0) return {
|
|
1200
|
+
ok: false,
|
|
1201
|
+
error: `${where} ("${text}"): "at" must name at least one place`
|
|
1202
|
+
};
|
|
1203
|
+
if (raws.length > 3) return {
|
|
1204
|
+
ok: false,
|
|
1205
|
+
error: `${where} ("${text}"): "at" names ${raws.length} places; keep it to 3 — the layer's files are the file list`
|
|
1206
|
+
};
|
|
1207
|
+
at = [];
|
|
1208
|
+
for (const raw of raws) {
|
|
1209
|
+
const str = typeof raw === "string" ? raw : atToString(raw);
|
|
1210
|
+
if (str === null) return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
error: `${where} ("${text}"): "at" is a string — "path", "path:line" or "path:from-to" — or a list of up to 3`
|
|
1213
|
+
};
|
|
1214
|
+
const parsed = parseDecisionAt(str);
|
|
1215
|
+
if (typeof parsed === "string") return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
error: `${where} ("${text}"): ${parsed}`
|
|
1218
|
+
};
|
|
1219
|
+
at.push(parsed);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
ok: true,
|
|
1224
|
+
decision: {
|
|
1225
|
+
text,
|
|
1226
|
+
...detail ? { detail } : {},
|
|
1227
|
+
...at ? { at } : {}
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
959
1231
|
/** The reason behind `--suggest`, fit for the one line the empty state quotes
|
|
960
1232
|
* it in. Undefined when there is nothing usable — the suggestion stands alone. */
|
|
961
1233
|
function parseSuggestReason(raw) {
|
|
@@ -967,8 +1239,9 @@ function parseSuggestReason(raw) {
|
|
|
967
1239
|
* The whole post, or the first thing wrong with it. Titles and file lists are
|
|
968
1240
|
* required and non-empty; everything else is optional and normalised — a
|
|
969
1241
|
* summary trimmed and capped, a note cut to one line, an unknown `kind`
|
|
970
|
-
* refused rather than silently dropped (the agent meant something by it)
|
|
971
|
-
*
|
|
1242
|
+
* refused rather than silently dropped (the agent meant something by it), a
|
|
1243
|
+
* decisions list checked entry by entry and refused past five. Any `id` the
|
|
1244
|
+
* agent sends is ignored: the server mints them.
|
|
972
1245
|
*/
|
|
973
1246
|
function parseLayersInput(raw) {
|
|
974
1247
|
if (!Array.isArray(raw)) return {
|
|
@@ -1017,11 +1290,29 @@ function parseLayersInput(raw) {
|
|
|
1017
1290
|
ok: false,
|
|
1018
1291
|
error: `${where} ("${title}"): "kind" can only be "mechanical"; leave it out otherwise`
|
|
1019
1292
|
};
|
|
1293
|
+
let decisions;
|
|
1294
|
+
if (l.decisions !== void 0) {
|
|
1295
|
+
if (!Array.isArray(l.decisions)) return {
|
|
1296
|
+
ok: false,
|
|
1297
|
+
error: `${where} ("${title}"): "decisions" must be a list`
|
|
1298
|
+
};
|
|
1299
|
+
if (l.decisions.length > 5) return {
|
|
1300
|
+
ok: false,
|
|
1301
|
+
error: `${where} ("${title}"): keep "decisions" to 5 — the ones a reviewer could want done differently, not a retelling of the diff`
|
|
1302
|
+
};
|
|
1303
|
+
decisions = [];
|
|
1304
|
+
for (const [j, raw] of l.decisions.entries()) {
|
|
1305
|
+
const parsed = parseDecision(raw, `${where} ("${title}") decision ${j + 1}`);
|
|
1306
|
+
if (!parsed.ok) return parsed;
|
|
1307
|
+
decisions.push(parsed.decision);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1020
1310
|
items.push({
|
|
1021
1311
|
title,
|
|
1022
1312
|
files,
|
|
1023
1313
|
...summary !== "" ? { summary } : {},
|
|
1024
|
-
...l.kind === "mechanical" ? { kind: "mechanical" } : {}
|
|
1314
|
+
...l.kind === "mechanical" ? { kind: "mechanical" } : {},
|
|
1315
|
+
...decisions && decisions.length > 0 ? { decisions } : {}
|
|
1025
1316
|
});
|
|
1026
1317
|
}
|
|
1027
1318
|
return {
|
|
@@ -1278,7 +1569,7 @@ Examples:
|
|
|
1278
1569
|
diffo poll`,
|
|
1279
1570
|
reply: `diffo reply: post a reply to a review thread
|
|
1280
1571
|
|
|
1281
|
-
Usage: diffo reply <threadId> --message "<text>" [--suggest-reply "<one line>"]
|
|
1572
|
+
Usage: diffo reply <threadId> --message "<text>" [--suggest-reply "<one line>"] [--pr-comment "<text>"]
|
|
1282
1573
|
… | diffo reply <threadId> (long replies: pipe on stdin)
|
|
1283
1574
|
|
|
1284
1575
|
Thread ids arrive in poll payloads. Each run posts one message, so don't
|
|
@@ -1292,6 +1583,12 @@ ghost text in their reply box, taken with Tab, edited or ignored at will.
|
|
|
1292
1583
|
Use it when your message ends in a decision that is theirs to make
|
|
1293
1584
|
("want me to extract this?" → --suggest-reply "yes, extract it"); never
|
|
1294
1585
|
on a message that only reports.
|
|
1586
|
+
--pr-comment, on a pull request only, attaches the review comment the
|
|
1587
|
+
reviewer would leave the author: in their voice, to the author, as GitHub
|
|
1588
|
+
will show it (Markdown; a \`\`\`suggestion block when the fix is local to the
|
|
1589
|
+
anchored lines). It appears under your reply with Add to review / Edit /
|
|
1590
|
+
Dismiss; nothing posts until the reviewer submits. Your evidence goes in
|
|
1591
|
+
--message, never in the comment. \`diffo help agent\` says when to attach one.
|
|
1295
1592
|
Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
|
|
1296
1593
|
diagram in the review.
|
|
1297
1594
|
|
|
@@ -1301,7 +1598,7 @@ Example:
|
|
|
1301
1598
|
diffo reply t-3 --message "fixed: the guard now covers the empty case"`,
|
|
1302
1599
|
comment: `diffo comment: start a comment thread as the agent
|
|
1303
1600
|
|
|
1304
|
-
Usage: diffo comment [<file>] [--line <n>] --message "<text>" [--suggest-reply "<one line>"]
|
|
1601
|
+
Usage: diffo comment [<file>] [--line <n>] --message "<text>" [--suggest-reply "<one line>"] [--pr-comment "<text>"]
|
|
1305
1602
|
… | diffo comment [<file>] [--line <n>] (long comments: pipe on stdin)
|
|
1306
1603
|
|
|
1307
1604
|
Anchors to a line (--line), a file, or, with no file, the whole changeset.
|
|
@@ -1313,6 +1610,10 @@ Spend these sparingly; an agent that annotates everything gets skimmed.
|
|
|
1313
1610
|
ghost text in their reply box, taken with Tab, edited or ignored at will.
|
|
1314
1611
|
Use it when the comment proposes something and the call is theirs; the
|
|
1315
1612
|
reply they take hands the thread to you like any other.
|
|
1613
|
+
--pr-comment, on a pull request only, attaches the review comment the
|
|
1614
|
+
reviewer would leave the author about this: same rules as on \`reply\`. For a
|
|
1615
|
+
finding you can show (a case you ran, a call site the change misses), not
|
|
1616
|
+
for a hunch.
|
|
1316
1617
|
Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
|
|
1317
1618
|
diagram in the review.
|
|
1318
1619
|
|
|
@@ -1342,6 +1643,8 @@ accepted (the file may land later) and simply shows nothing until it does.
|
|
|
1342
1643
|
Files you touch after posting land in a trailing "Since your review" layer
|
|
1343
1644
|
until you re-post.
|
|
1344
1645
|
|
|
1646
|
+
Decisions: ${LAYERS.decisions}.
|
|
1647
|
+
|
|
1345
1648
|
Shape: ${LAYERS.shape}
|
|
1346
1649
|
|
|
1347
1650
|
Output: {"ok":true,"layers":4,"next_step":"…"}
|
|
@@ -1349,7 +1652,7 @@ Output: {"ok":true,"layers":4,"next_step":"…"}
|
|
|
1349
1652
|
|
|
1350
1653
|
Examples:
|
|
1351
1654
|
diffo layers --suggest "the parser change explains the rest"
|
|
1352
|
-
diffo layers --json '[{"title":"Parser contract","summary":"The contract the rest of the change leans on: bad input now comes back as null, not an exception.","files":["src/parse.ts"]},{"title":"Callers adapted","kind":"mechanical","summary":"Call sites following the new return type.","files":["src/cli.ts","src/api.ts"]}]'`,
|
|
1655
|
+
diffo layers --json '[{"title":"Parser contract","summary":"The contract the rest of the change leans on: bad input now comes back as null, not an exception.","files":["src/parse.ts"],"decisions":[{"text":"Bad input returns null, not a throw","detail":"Callers already branch on null for a missing value; a throw would need a try at every site.","at":"src/parse.ts:41-46"}]},{"title":"Callers adapted","kind":"mechanical","summary":"Call sites following the new return type.","files":["src/cli.ts","src/api.ts"]}]'`,
|
|
1353
1656
|
end: `diffo end: detach from the review politely
|
|
1354
1657
|
|
|
1355
1658
|
Usage: diffo end
|
|
@@ -1762,6 +2065,7 @@ function parseVerb(verb, rest) {
|
|
|
1762
2065
|
title: { type: "string" },
|
|
1763
2066
|
more: { type: "boolean" },
|
|
1764
2067
|
"suggest-reply": { type: "string" },
|
|
2068
|
+
"pr-comment": { type: "string" },
|
|
1765
2069
|
json: { type: "boolean" },
|
|
1766
2070
|
help: {
|
|
1767
2071
|
type: "boolean",
|
|
@@ -1775,6 +2079,7 @@ function parseVerb(verb, rest) {
|
|
|
1775
2079
|
};
|
|
1776
2080
|
const { values, positionals } = parsed.value;
|
|
1777
2081
|
const suggestReply = values["suggest-reply"];
|
|
2082
|
+
const prComment = values["pr-comment"];
|
|
1778
2083
|
if (values.help) return {
|
|
1779
2084
|
kind: "help",
|
|
1780
2085
|
topic: verb
|
|
@@ -1801,6 +2106,16 @@ function parseVerb(verb, rest) {
|
|
|
1801
2106
|
message: "--suggest-reply is one line — the reviewer completes it"
|
|
1802
2107
|
};
|
|
1803
2108
|
}
|
|
2109
|
+
if (prComment !== void 0) {
|
|
2110
|
+
if (verb !== "reply" && verb !== "comment") return {
|
|
2111
|
+
kind: "error",
|
|
2112
|
+
message: `'${verb}' takes no --pr-comment`
|
|
2113
|
+
};
|
|
2114
|
+
if (!prComment.trim()) return {
|
|
2115
|
+
kind: "error",
|
|
2116
|
+
message: "--pr-comment needs the review comment you are suggesting"
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
1804
2119
|
if (values.title !== void 0 && verb !== "poll") return {
|
|
1805
2120
|
kind: "error",
|
|
1806
2121
|
message: `'${verb}' takes no --title`
|
|
@@ -1850,7 +2165,8 @@ function parseVerb(verb, rest) {
|
|
|
1850
2165
|
threadId,
|
|
1851
2166
|
message: values.message ?? null,
|
|
1852
2167
|
more: values.more === true,
|
|
1853
|
-
suggestReply: suggestReply?.trim() ?? null
|
|
2168
|
+
suggestReply: suggestReply?.trim() ?? null,
|
|
2169
|
+
prComment: prComment?.trim() ?? null
|
|
1854
2170
|
};
|
|
1855
2171
|
}
|
|
1856
2172
|
const file = positionals[0] ?? null;
|
|
@@ -1875,7 +2191,8 @@ function parseVerb(verb, rest) {
|
|
|
1875
2191
|
file,
|
|
1876
2192
|
line,
|
|
1877
2193
|
message: values.message ?? null,
|
|
1878
|
-
suggestReply: suggestReply?.trim() ?? null
|
|
2194
|
+
suggestReply: suggestReply?.trim() ?? null,
|
|
2195
|
+
prComment: prComment?.trim() ?? null
|
|
1879
2196
|
};
|
|
1880
2197
|
}
|
|
1881
2198
|
//#endregion
|
|
@@ -1959,7 +2276,6 @@ query($owner: String!, $name: String!, $number: Int!) {
|
|
|
1959
2276
|
messageHeadline
|
|
1960
2277
|
committedDate
|
|
1961
2278
|
author { name user { login avatarUrl } }
|
|
1962
|
-
statusCheckRollup { state }
|
|
1963
2279
|
}
|
|
1964
2280
|
}
|
|
1965
2281
|
}
|
|
@@ -1974,6 +2290,24 @@ query($owner: String!, $name: String!, $number: Int!) {
|
|
|
1974
2290
|
}
|
|
1975
2291
|
}
|
|
1976
2292
|
}`;
|
|
2293
|
+
/**
|
|
2294
|
+
* CI status, asked for on its own. Reading it needs the checks and statuses
|
|
2295
|
+
* scopes, which a fine-grained or SAML-limited token may lack while reading
|
|
2296
|
+
* everything else about the pull request; GraphQL answers that with a
|
|
2297
|
+
* FORBIDDEN error on this one field, and `gh` turns any error into a failed
|
|
2298
|
+
* call. Kept out of the PR query so a token that cannot see CI still opens
|
|
2299
|
+
* the review.
|
|
2300
|
+
*/
|
|
2301
|
+
const CHECKS_QUERY = `
|
|
2302
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
2303
|
+
repository(owner: $owner, name: $name) {
|
|
2304
|
+
pullRequest(number: $number) {
|
|
2305
|
+
commits(last: 1) {
|
|
2306
|
+
nodes { commit { statusCheckRollup { state } } }
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
}`;
|
|
1977
2311
|
/** The pages after the first of each connection the PR query opens. */
|
|
1978
2312
|
const REVIEWS_QUERY = `
|
|
1979
2313
|
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
|
|
@@ -2220,18 +2554,32 @@ var GhClient = class {
|
|
|
2220
2554
|
reviewNodes
|
|
2221
2555
|
};
|
|
2222
2556
|
}
|
|
2557
|
+
/** CI on the head commit, best effort: a token without the checks scope
|
|
2558
|
+
* gets `unknown` and the review opens anyway. Never throws. */
|
|
2559
|
+
async checks(ref) {
|
|
2560
|
+
try {
|
|
2561
|
+
const rollup = ((await this.graphql(ref.host, CHECKS_QUERY, {
|
|
2562
|
+
owner: ref.owner,
|
|
2563
|
+
name: ref.repo,
|
|
2564
|
+
number: ref.number
|
|
2565
|
+
})).repository?.pullRequest?.commits.nodes.at(-1)?.commit)?.statusCheckRollup;
|
|
2566
|
+
return checkState(rollup?.state);
|
|
2567
|
+
} catch {
|
|
2568
|
+
return "unknown";
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2223
2571
|
async getPr(ref) {
|
|
2224
|
-
const { viewer, pr, reviewNodes } = await this.head(ref);
|
|
2225
|
-
return prInfo(ref, viewer, pr, reviewNodes);
|
|
2572
|
+
const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
|
|
2573
|
+
return prInfo(ref, viewer, pr, reviewNodes, checks);
|
|
2226
2574
|
}
|
|
2227
2575
|
async listThreads(ref) {
|
|
2228
2576
|
const { pr, reviewNodes } = await this.head(ref);
|
|
2229
2577
|
return this.conversation(ref, pr, reviewNodes);
|
|
2230
2578
|
}
|
|
2231
2579
|
async fetchPr(ref) {
|
|
2232
|
-
const { viewer, pr, reviewNodes } = await this.head(ref);
|
|
2580
|
+
const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
|
|
2233
2581
|
return {
|
|
2234
|
-
pr: prInfo(ref, viewer, pr, reviewNodes),
|
|
2582
|
+
pr: prInfo(ref, viewer, pr, reviewNodes, checks),
|
|
2235
2583
|
threads: await this.conversation(ref, pr, reviewNodes)
|
|
2236
2584
|
};
|
|
2237
2585
|
}
|
|
@@ -2345,9 +2693,8 @@ var GhClient = class {
|
|
|
2345
2693
|
}
|
|
2346
2694
|
};
|
|
2347
2695
|
/** The PR query's answer as a PrInfo, with every review page in hand. */
|
|
2348
|
-
function prInfo(ref, viewer, pr, reviewNodes) {
|
|
2349
|
-
const
|
|
2350
|
-
const commits = commitNodes.map((n) => {
|
|
2696
|
+
function prInfo(ref, viewer, pr, reviewNodes, checks) {
|
|
2697
|
+
const commits = (pr.commits.nodes ?? []).map((n) => {
|
|
2351
2698
|
const c = n.commit;
|
|
2352
2699
|
const author = c.author;
|
|
2353
2700
|
return {
|
|
@@ -2357,7 +2704,6 @@ function prInfo(ref, viewer, pr, reviewNodes) {
|
|
|
2357
2704
|
at: String(c.committedDate ?? "")
|
|
2358
2705
|
};
|
|
2359
2706
|
});
|
|
2360
|
-
const rollup = (commitNodes.at(-1)?.commit)?.statusCheckRollup;
|
|
2361
2707
|
const reviews = reviewNodes.map((r) => ({
|
|
2362
2708
|
id: String(r.id),
|
|
2363
2709
|
author: user(r.author),
|
|
@@ -2390,7 +2736,7 @@ function prInfo(ref, viewer, pr, reviewNodes) {
|
|
|
2390
2736
|
sha: String(pr.headRefOid)
|
|
2391
2737
|
},
|
|
2392
2738
|
commits,
|
|
2393
|
-
checks: { state:
|
|
2739
|
+
checks: { state: checks },
|
|
2394
2740
|
reviews: reviews.filter((r) => r.state !== "PENDING"),
|
|
2395
2741
|
approvals: [...latest.values()].filter((s) => s === "APPROVED").length,
|
|
2396
2742
|
changesRequested: [...latest.values()].filter((s) => s === "CHANGES_REQUESTED").length,
|
|
@@ -2608,6 +2954,25 @@ function branchExists(root, branch) {
|
|
|
2608
2954
|
return err.status !== 1;
|
|
2609
2955
|
}
|
|
2610
2956
|
}
|
|
2957
|
+
/** {@link branchExists}, off the event loop, for the daemon's background sweep
|
|
2958
|
+
* over every repo it has ever reviewed. Same lean: true unless git said no. */
|
|
2959
|
+
async function branchExistsAsync(root, branch) {
|
|
2960
|
+
try {
|
|
2961
|
+
await execFileAsync("git", [
|
|
2962
|
+
"show-ref",
|
|
2963
|
+
"--verify",
|
|
2964
|
+
"--quiet",
|
|
2965
|
+
`refs/heads/${branch}`
|
|
2966
|
+
], {
|
|
2967
|
+
cwd: root,
|
|
2968
|
+
encoding: "utf-8",
|
|
2969
|
+
maxBuffer: MAX_BUFFER
|
|
2970
|
+
});
|
|
2971
|
+
return true;
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
return err.code !== 1;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2611
2976
|
/**
|
|
2612
2977
|
* The name of the linked worktree we're in, or null in the main one. A linked
|
|
2613
2978
|
* worktree's git dir is `<main>/.git/worktrees/<name>`, while the main worktree's
|
|
@@ -3242,7 +3607,17 @@ const SCHEMA_VERSION = 3;
|
|
|
3242
3607
|
const REVIEWS_RESHAPED_AT = 2;
|
|
3243
3608
|
var DiffoDb = class {
|
|
3244
3609
|
db;
|
|
3610
|
+
closed = false;
|
|
3245
3611
|
path;
|
|
3612
|
+
/**
|
|
3613
|
+
* Opening is cheap on purpose: the schema, and one SQL statement for the TTL.
|
|
3614
|
+
* Nothing here touches the filesystem beyond the database file, and nothing
|
|
3615
|
+
* asks git. It used to: every open checked every review's branch with a
|
|
3616
|
+
* `git show-ref`, across every repo this machine ever reviewed — 73 git
|
|
3617
|
+
* processes, 1.5s, on a database a few weeks old, and an open constructs
|
|
3618
|
+
* this five times. The checks live in {@link pruneRepo} (one repo, before its
|
|
3619
|
+
* review loads) and {@link maintain} (everything, in the daemon's background).
|
|
3620
|
+
*/
|
|
3246
3621
|
constructor(path = defaultDbPath()) {
|
|
3247
3622
|
this.path = path;
|
|
3248
3623
|
mkdirSync(dirname(path), {
|
|
@@ -3254,7 +3629,7 @@ var DiffoDb = class {
|
|
|
3254
3629
|
chmodSync(path, 384);
|
|
3255
3630
|
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA journal_size_limit = 4194304;");
|
|
3256
3631
|
this.ensureSchema();
|
|
3257
|
-
this.
|
|
3632
|
+
this.pruneExpiredReviews();
|
|
3258
3633
|
}
|
|
3259
3634
|
/**
|
|
3260
3635
|
* Creates what is missing and retires what is unknown. Runs at open, and
|
|
@@ -3323,38 +3698,69 @@ var DiffoDb = class {
|
|
|
3323
3698
|
}
|
|
3324
3699
|
}
|
|
3325
3700
|
/**
|
|
3326
|
-
*
|
|
3327
|
-
*
|
|
3328
|
-
*
|
|
3329
|
-
|
|
3701
|
+
* Reviews untouched for `REVIEW_TTL_DAYS` go on every open: one statement,
|
|
3702
|
+
* no git, and the backstop for everything the two targeted sweeps below leave
|
|
3703
|
+
* for later.
|
|
3704
|
+
*/
|
|
3705
|
+
pruneExpiredReviews() {
|
|
3706
|
+
const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
|
|
3707
|
+
const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
|
|
3708
|
+
if (Number(stale.changes) > 0) this.checkpoint();
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Retire this one repo's reviews for branches it no longer has. The server
|
|
3712
|
+
* runs it before loading the review it is about to serve, so a branch
|
|
3713
|
+
* deleted since the last server start is forgotten before a namesake could
|
|
3714
|
+
* inherit its threads. Only this repo's branches are asked about, so the
|
|
3715
|
+
* cost is a handful of `git show-ref`s, once per server start, never per CLI
|
|
3716
|
+
* call. Branch existence is asked of git rather than assumed — when git can't
|
|
3717
|
+
* answer the row is kept, because guessing here deletes threads.
|
|
3718
|
+
*/
|
|
3719
|
+
pruneRepo(repoPath) {
|
|
3720
|
+
const rows = this.db.prepare("SELECT DISTINCT branch FROM reviews WHERE repo_path = ?").all(repoPath);
|
|
3721
|
+
let dropped = 0;
|
|
3722
|
+
for (const { branch } of rows) {
|
|
3723
|
+
if (branch === "" || branchExists(repoPath, branch)) continue;
|
|
3724
|
+
dropped += this.dropReviews(repoPath, branch);
|
|
3725
|
+
}
|
|
3726
|
+
return dropped;
|
|
3727
|
+
}
|
|
3728
|
+
/**
|
|
3729
|
+
* The full sweep, for a long-lived process with time on its hands: rows about
|
|
3730
|
+
* repos that are gone, and reviews for branches that are. The daemon runs it
|
|
3731
|
+
* once, a few seconds after it is serving; `diffo clean` runs it on demand.
|
|
3732
|
+
* Git is asked asynchronously and one repo at a time, so a sweep over every
|
|
3733
|
+
* repo this machine ever reviewed never blocks a request. A `close()` midway
|
|
3734
|
+
* ends it quietly between steps.
|
|
3735
|
+
*
|
|
3736
|
+
* Rows about a worktree that is gone can never be reached again — the path
|
|
3737
|
+
* *is* the key. An unmounted volume looks the same as a deleted worktree, so
|
|
3738
|
+
* its rows go too; the `servers` row needs its process dead as well, or a live
|
|
3739
|
+
* server's claim could be dropped over a moment of unreachability.
|
|
3330
3740
|
*/
|
|
3331
|
-
|
|
3332
|
-
|
|
3741
|
+
async maintain() {
|
|
3742
|
+
const report = { reviews: 0 };
|
|
3743
|
+
if (this.closed) return report;
|
|
3744
|
+
for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) report.reviews += this.dropReviews(repo_path);
|
|
3333
3745
|
for (const { repo_path } of this.distinctRepoPaths("repo_ports")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM repo_ports WHERE repo_path = ?").run(repo_path);
|
|
3334
3746
|
const worktrees = this.db.prepare("SELECT worktree_path, repo_path FROM worktrees").all();
|
|
3335
3747
|
for (const { worktree_path, repo_path } of worktrees) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM worktrees WHERE worktree_path = ?").run(worktree_path);
|
|
3336
3748
|
const servers = this.db.prepare("SELECT repo_path, pid FROM servers").all();
|
|
3337
3749
|
for (const { repo_path, pid } of servers) if (!existsSync(repo_path) && !pidAlive$1(pid)) this.db.prepare("DELETE FROM servers WHERE repo_path = ?").run(repo_path);
|
|
3338
|
-
this.pruneStaleReviews();
|
|
3339
|
-
}
|
|
3340
|
-
/**
|
|
3341
|
-
* Reviews the reviewer can never return to: a branch that no longer exists, or
|
|
3342
|
-
* one untouched for `REVIEW_TTL_DAYS`. Branch existence is asked of git rather
|
|
3343
|
-
* than assumed — if git can't answer the row is kept, because guessing here
|
|
3344
|
-
* deletes threads.
|
|
3345
|
-
*/
|
|
3346
|
-
pruneStaleReviews() {
|
|
3347
|
-
const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
|
|
3348
|
-
const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
|
|
3349
|
-
if (Number(stale.changes) > 0) {
|
|
3350
|
-
this.checkpoint();
|
|
3351
|
-
return;
|
|
3352
|
-
}
|
|
3353
3750
|
const rows = this.db.prepare("SELECT DISTINCT repo_path, branch FROM reviews").all();
|
|
3354
3751
|
for (const { repo_path, branch } of rows) {
|
|
3355
|
-
if (branch === ""
|
|
3356
|
-
|
|
3752
|
+
if (branch === "") continue;
|
|
3753
|
+
const exists = await branchExistsAsync(repo_path, branch);
|
|
3754
|
+
if (this.closed) return report;
|
|
3755
|
+
if (!exists) report.reviews += this.dropReviews(repo_path, branch);
|
|
3357
3756
|
}
|
|
3757
|
+
if (report.reviews > 0) this.checkpoint();
|
|
3758
|
+
return report;
|
|
3759
|
+
}
|
|
3760
|
+
/** Every review of a repo, or only one branch's. Returns the rows dropped. */
|
|
3761
|
+
dropReviews(repoPath, branch) {
|
|
3762
|
+
const result = branch === void 0 ? this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repoPath) : this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repoPath, branch);
|
|
3763
|
+
return Number(result.changes);
|
|
3358
3764
|
}
|
|
3359
3765
|
/** Fold the write-ahead log back into the database file and truncate it. WAL
|
|
3360
3766
|
* only shrinks at a checkpoint, which a long-lived server never reaches. */
|
|
@@ -3452,6 +3858,7 @@ var DiffoDb = class {
|
|
|
3452
3858
|
else this.db.prepare("DELETE FROM servers WHERE repo_path = ? AND port = ?").run(repoPath, port);
|
|
3453
3859
|
}
|
|
3454
3860
|
close() {
|
|
3861
|
+
this.closed = true;
|
|
3455
3862
|
this.db.close();
|
|
3456
3863
|
}
|
|
3457
3864
|
};
|
|
@@ -3517,6 +3924,10 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3517
3924
|
stalled = false;
|
|
3518
3925
|
stallTimer = null;
|
|
3519
3926
|
betweenPolls = false;
|
|
3927
|
+
/** Why the agent is parked between polls: a reply just landed, its poll
|
|
3928
|
+
* ended on its own and the re-poll is expected, or it opened the review and
|
|
3929
|
+
* is still orienting itself before its first poll. */
|
|
3930
|
+
parked = "replied";
|
|
3520
3931
|
graceTimer = null;
|
|
3521
3932
|
lastDetach = null;
|
|
3522
3933
|
since = Date.now();
|
|
@@ -3664,7 +4075,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3664
4075
|
if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
|
|
3665
4076
|
if (this.outlining() !== null) return "delivered";
|
|
3666
4077
|
if (this.waiter) return "polling";
|
|
3667
|
-
if (this.betweenPolls) return
|
|
4078
|
+
if (this.betweenPolls) return this.parked;
|
|
3668
4079
|
return this.lastDetach ?? "no-agent";
|
|
3669
4080
|
}
|
|
3670
4081
|
hasListener() {
|
|
@@ -3717,9 +4128,10 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3717
4128
|
if (this.stallTimer !== null) clearTimeout(this.stallTimer);
|
|
3718
4129
|
this.stallTimer = null;
|
|
3719
4130
|
}
|
|
3720
|
-
armGrace() {
|
|
4131
|
+
armGrace(why = "replied", windowMs = this.replyGraceMs) {
|
|
3721
4132
|
this.clearGrace();
|
|
3722
4133
|
this.betweenPolls = true;
|
|
4134
|
+
this.parked = why;
|
|
3723
4135
|
const drop = () => {
|
|
3724
4136
|
this.graceTimer = null;
|
|
3725
4137
|
if (!this.betweenPolls) return;
|
|
@@ -3728,7 +4140,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3728
4140
|
};
|
|
3729
4141
|
const pid = this.owner;
|
|
3730
4142
|
if (pid === null) {
|
|
3731
|
-
const timer = setTimeout(drop,
|
|
4143
|
+
const timer = setTimeout(drop, windowMs);
|
|
3732
4144
|
timer.unref?.();
|
|
3733
4145
|
this.graceTimer = timer;
|
|
3734
4146
|
return;
|
|
@@ -3754,6 +4166,42 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3754
4166
|
this.graceTimer = null;
|
|
3755
4167
|
this.betweenPolls = false;
|
|
3756
4168
|
}
|
|
4169
|
+
/**
|
|
4170
|
+
* A poll ended without `diffo end`: the poll window closed, or the process
|
|
4171
|
+
* died under its harness. Neither means the agent left — the CLI tells the
|
|
4172
|
+
* session to re-run the poll, and it usually does within moments — so the
|
|
4173
|
+
* reviewer keeps an agent instead of being asked to invite one again. The
|
|
4174
|
+
* agent parks in the same grace a reply uses: a named session is watched
|
|
4175
|
+
* until its process is gone (or the cap), an unnamed one gets the fixed
|
|
4176
|
+
* window. Only a session that is already dead reads as gone at once. When
|
|
4177
|
+
* the grace runs out without a re-poll, the reason it fell to waiting is
|
|
4178
|
+
* still the dead poll.
|
|
4179
|
+
*/
|
|
4180
|
+
pollDropped() {
|
|
4181
|
+
this.lastDetach = "disconnected";
|
|
4182
|
+
const pid = this.owner;
|
|
4183
|
+
const isAlive = this.liveness.isAlive ?? pidAlive;
|
|
4184
|
+
if (!(pid !== null && !isAlive(pid)) && !this.awaitingReply && this.outlining() === null) this.armGrace("repolling");
|
|
4185
|
+
this.notify();
|
|
4186
|
+
}
|
|
4187
|
+
/**
|
|
4188
|
+
* An agent's CLI opened this review. The agent reads the change and writes
|
|
4189
|
+
* its guide before it ever polls, which can take minutes — and a reviewer who
|
|
4190
|
+
* opened the URL meanwhile must not be told there is no agent and offered an
|
|
4191
|
+
* Invite. So from the open until the first poll the agent is 'working', for
|
|
4192
|
+
* the reason 'arriving': a named session is watched until its process is gone
|
|
4193
|
+
* (or the cap), an anonymous one gets a fixed window. The first poll ends it
|
|
4194
|
+
* the way it ends every grace. An agent already here in any state is left
|
|
4195
|
+
* alone — a second open is not a second agent. Returns whether it took.
|
|
4196
|
+
*/
|
|
4197
|
+
agentArrived(pid) {
|
|
4198
|
+
if (this.presence() !== "waiting") return false;
|
|
4199
|
+
this.owner = DeliveryQueue.clean(pid);
|
|
4200
|
+
this.lastDetach = null;
|
|
4201
|
+
this.armGrace("arriving", this.liveness.arriveGraceMs ?? 3e5);
|
|
4202
|
+
this.notify();
|
|
4203
|
+
return true;
|
|
4204
|
+
}
|
|
3757
4205
|
enqueueThreads(threadIds) {
|
|
3758
4206
|
const bucket = this.bucket();
|
|
3759
4207
|
for (const id of threadIds) bucket.threads.add(id);
|
|
@@ -3841,8 +4289,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3841
4289
|
onAbort?.(() => {
|
|
3842
4290
|
if (this.waiter === waiter) {
|
|
3843
4291
|
this.releaseWaiter(null);
|
|
3844
|
-
this.
|
|
3845
|
-
this.notify();
|
|
4292
|
+
this.pollDropped();
|
|
3846
4293
|
}
|
|
3847
4294
|
});
|
|
3848
4295
|
});
|
|
@@ -4280,7 +4727,7 @@ function threadsFromGithub(pr, imported, files) {
|
|
|
4280
4727
|
* body, or a conversation comment.
|
|
4281
4728
|
*/
|
|
4282
4729
|
function githubPosition(anchor, body, files) {
|
|
4283
|
-
if (anchor.kind === "changeset") return null;
|
|
4730
|
+
if (anchor.kind === "changeset" || anchor.kind === "layer") return null;
|
|
4284
4731
|
if (anchor.kind === "file") return {
|
|
4285
4732
|
draft: {
|
|
4286
4733
|
path: anchor.path,
|
|
@@ -4361,6 +4808,37 @@ function planPublicLeg(threads, files) {
|
|
|
4361
4808
|
resolves
|
|
4362
4809
|
};
|
|
4363
4810
|
}
|
|
4811
|
+
/**
|
|
4812
|
+
* What became of the agent's suggested review comments, counted at submit:
|
|
4813
|
+
* a suggestion is `posted` when the draft it became is still in the review
|
|
4814
|
+
* (a discarded draft takes the thread with it), `edited` when the reviewer
|
|
4815
|
+
* changed the words first. The agent hears this once, in the submit notice.
|
|
4816
|
+
*/
|
|
4817
|
+
function suggestionsSummary(threads) {
|
|
4818
|
+
const ids = new Set(threads.map((t) => t.id));
|
|
4819
|
+
const s = {
|
|
4820
|
+
total: 0,
|
|
4821
|
+
posted: 0,
|
|
4822
|
+
edited: 0,
|
|
4823
|
+
dismissed: 0,
|
|
4824
|
+
undecided: 0
|
|
4825
|
+
};
|
|
4826
|
+
for (const t of threads) {
|
|
4827
|
+
if (isPublic(t)) continue;
|
|
4828
|
+
for (const m of t.messages) {
|
|
4829
|
+
if (m.author !== "agent" || !m.prComment) continue;
|
|
4830
|
+
s.total++;
|
|
4831
|
+
const o = m.prComment.outcome;
|
|
4832
|
+
if (!o) s.undecided++;
|
|
4833
|
+
else if (o.kind === "dismissed") s.dismissed++;
|
|
4834
|
+
else if (ids.has(o.draftThreadId)) {
|
|
4835
|
+
s.posted++;
|
|
4836
|
+
if (o.edited) s.edited++;
|
|
4837
|
+
} else s.undecided++;
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
return s;
|
|
4841
|
+
}
|
|
4364
4842
|
/** The dialog's rows: what will post, in words. */
|
|
4365
4843
|
function describeLeg(leg) {
|
|
4366
4844
|
return {
|
|
@@ -4369,7 +4847,8 @@ function describeLeg(leg) {
|
|
|
4369
4847
|
anchor: describeAnchor(d.thread.anchor),
|
|
4370
4848
|
text: d.text,
|
|
4371
4849
|
downgraded: d.downgraded,
|
|
4372
|
-
conversation: d.position === null
|
|
4850
|
+
conversation: d.position === null,
|
|
4851
|
+
...d.thread.origin ? { fromAgent: { edited: d.thread.origin.edited } } : {}
|
|
4373
4852
|
})),
|
|
4374
4853
|
replies: leg.replies.map((r) => ({
|
|
4375
4854
|
id: r.thread.id,
|
|
@@ -4890,21 +5369,67 @@ var ReviewStore = class {
|
|
|
4890
5369
|
state: "open",
|
|
4891
5370
|
...options.audience === "pr" ? { audience: "pr" } : intent ? { intent } : {},
|
|
4892
5371
|
...options.parentId ? { parentId: options.parentId } : {},
|
|
5372
|
+
...options.audience === "pr" && options.origin ? { origin: options.origin } : {},
|
|
4893
5373
|
codeContext: capture?.codeContext ?? null,
|
|
4894
5374
|
...capture?.anchored ? { anchored: capture.anchored } : {},
|
|
5375
|
+
...capture?.anchoredLayer ? { anchoredLayer: capture.anchoredLayer } : {},
|
|
4895
5376
|
codeChanged: false,
|
|
4896
5377
|
messages: [{
|
|
4897
5378
|
id: randomUUID(),
|
|
4898
5379
|
author,
|
|
4899
5380
|
text,
|
|
4900
5381
|
at: now,
|
|
4901
|
-
...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {}
|
|
5382
|
+
...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {},
|
|
5383
|
+
...author === "agent" && options.prComment ? { prComment: { text: options.prComment } } : {}
|
|
4902
5384
|
}],
|
|
4903
5385
|
createdAt: now,
|
|
4904
5386
|
updatedAt: now
|
|
4905
5387
|
});
|
|
4906
5388
|
}
|
|
4907
5389
|
/**
|
|
5390
|
+
* The reviewer's decision on a suggested PR comment: added to their review
|
|
5391
|
+
* (the public draft it became is named) or dismissed. Stamped once — a
|
|
5392
|
+
* decided suggestion is history, and the card shows it as such. Null when
|
|
5393
|
+
* the thread or message is not a live suggestion.
|
|
5394
|
+
*/
|
|
5395
|
+
decidePrComment(threadId, messageId, outcome) {
|
|
5396
|
+
const current = this.state.threads.find((t) => t.id === threadId);
|
|
5397
|
+
const message = current?.messages.find((m) => m.id === messageId);
|
|
5398
|
+
if (!current || !message || message.author !== "agent" || !message.prComment) return null;
|
|
5399
|
+
if (message.prComment.outcome !== void 0) return null;
|
|
5400
|
+
return this.setPrCommentOutcome(threadId, messageId, outcome);
|
|
5401
|
+
}
|
|
5402
|
+
/**
|
|
5403
|
+
* The decision taken back: a dismissed suggestion returns live, with its
|
|
5404
|
+
* buttons. An added one does too, but only once the draft it became is gone
|
|
5405
|
+
* (discarded) — while the draft stands, reviving the suggestion would offer
|
|
5406
|
+
* the same comment twice. Null when there is nothing to restore.
|
|
5407
|
+
*/
|
|
5408
|
+
restorePrComment(threadId, messageId) {
|
|
5409
|
+
const current = this.state.threads.find((t) => t.id === threadId);
|
|
5410
|
+
const message = current?.messages.find((m) => m.id === messageId);
|
|
5411
|
+
const outcome = message?.prComment?.outcome;
|
|
5412
|
+
if (!current || !message || !outcome) return null;
|
|
5413
|
+
if (outcome.kind === "added" && this.state.threads.some((t) => t.id === outcome.draftThreadId)) return null;
|
|
5414
|
+
return this.setPrCommentOutcome(threadId, messageId, void 0);
|
|
5415
|
+
}
|
|
5416
|
+
setPrCommentOutcome(threadId, messageId, outcome) {
|
|
5417
|
+
return this.update(threadId, (thread) => ({
|
|
5418
|
+
...thread,
|
|
5419
|
+
messages: thread.messages.map((m) => {
|
|
5420
|
+
if (m.id !== messageId) return m;
|
|
5421
|
+
const { outcome: _was, ...rest } = m.prComment;
|
|
5422
|
+
return {
|
|
5423
|
+
...m,
|
|
5424
|
+
prComment: outcome ? {
|
|
5425
|
+
...rest,
|
|
5426
|
+
outcome
|
|
5427
|
+
} : rest
|
|
5428
|
+
};
|
|
5429
|
+
})
|
|
5430
|
+
}));
|
|
5431
|
+
}
|
|
5432
|
+
/**
|
|
4908
5433
|
* The closing note of a Finish round, as a thread. Anchored to the changeset
|
|
4909
5434
|
* and opened in the reviewer's voice, so it travels the ordinary path: the
|
|
4910
5435
|
* agent replies to it with `diffo reply`, the queue counts it as feedback
|
|
@@ -4946,14 +5471,15 @@ var ReviewStore = class {
|
|
|
4946
5471
|
* the reviewer raced in since — appended at the end, it would render as an
|
|
4947
5472
|
* answer to a comment it never saw, and the raced comment would stop reading
|
|
4948
5473
|
* as "waiting on the agent" even though it still is. */
|
|
4949
|
-
addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply) {
|
|
5474
|
+
addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply, prComment) {
|
|
4950
5475
|
return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
|
|
4951
5476
|
const message = {
|
|
4952
5477
|
id: randomUUID(),
|
|
4953
5478
|
author,
|
|
4954
5479
|
text,
|
|
4955
5480
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4956
|
-
...author === "agent" && suggestedReply ? { suggestedReply } : {}
|
|
5481
|
+
...author === "agent" && suggestedReply ? { suggestedReply } : {},
|
|
5482
|
+
...author === "agent" && prComment ? { prComment: { text: prComment } } : {}
|
|
4957
5483
|
};
|
|
4958
5484
|
const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
|
|
4959
5485
|
const { awaitingFollowUp: _promised, ...settled } = thread;
|
|
@@ -5069,13 +5595,14 @@ var ReviewStore = class {
|
|
|
5069
5595
|
}));
|
|
5070
5596
|
}
|
|
5071
5597
|
removeThread(threadId) {
|
|
5072
|
-
const
|
|
5073
|
-
if (
|
|
5598
|
+
const removed = this.state.threads.find((t) => t.id === threadId);
|
|
5599
|
+
if (!removed) return false;
|
|
5074
5600
|
this.state = {
|
|
5075
5601
|
...this.state,
|
|
5076
|
-
threads
|
|
5602
|
+
threads: this.state.threads.filter((t) => t.id !== threadId)
|
|
5077
5603
|
};
|
|
5078
5604
|
this.commit();
|
|
5605
|
+
if (removed.origin) this.restorePrComment(removed.origin.threadId, removed.origin.messageId);
|
|
5079
5606
|
return true;
|
|
5080
5607
|
}
|
|
5081
5608
|
/**
|
|
@@ -5118,7 +5645,8 @@ var ReviewStore = class {
|
|
|
5118
5645
|
const { layersSuggested: _suggested, ...rest } = this.state;
|
|
5119
5646
|
this.state = {
|
|
5120
5647
|
...rest,
|
|
5121
|
-
layers
|
|
5648
|
+
layers,
|
|
5649
|
+
threads: reconcileLayerThreads(rest.threads, layers)
|
|
5122
5650
|
};
|
|
5123
5651
|
this.commit();
|
|
5124
5652
|
return layers;
|
|
@@ -5601,9 +6129,32 @@ function migrateLegacySuggestion(value, now) {
|
|
|
5601
6129
|
updatedAt: at
|
|
5602
6130
|
};
|
|
5603
6131
|
}
|
|
6132
|
+
/**
|
|
6133
|
+
* A re-post dropped a layer (its title is gone), so the threads on it have
|
|
6134
|
+
* nothing left to sit under — the same fate as a hunk whose id rotated: a
|
|
6135
|
+
* `sent` thread was overtaken and reads as addressed; an open one stays as it
|
|
6136
|
+
* is and the UI files it with the threads the changeset left behind.
|
|
6137
|
+
*/
|
|
6138
|
+
function reconcileLayerThreads(threads, layers) {
|
|
6139
|
+
const ids = new Set(layers.items.map((l) => l.id));
|
|
6140
|
+
return threads.map((thread) => thread.anchor.kind === "layer" && thread.state === "sent" && !ids.has(thread.anchor.layerId) ? {
|
|
6141
|
+
...thread,
|
|
6142
|
+
state: "addressed",
|
|
6143
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6144
|
+
} : thread);
|
|
6145
|
+
}
|
|
5604
6146
|
function parseAnchor(value) {
|
|
5605
6147
|
if (typeof value !== "object" || value === null) return null;
|
|
5606
6148
|
const anchor = value;
|
|
6149
|
+
if (anchor.kind === "layer") {
|
|
6150
|
+
if (typeof anchor.layerId !== "string" || anchor.layerId === "") return null;
|
|
6151
|
+
if (typeof anchor.title !== "string" || anchor.title.trim() === "") return null;
|
|
6152
|
+
return {
|
|
6153
|
+
kind: "layer",
|
|
6154
|
+
layerId: anchor.layerId,
|
|
6155
|
+
title: anchor.title.trim()
|
|
6156
|
+
};
|
|
6157
|
+
}
|
|
5607
6158
|
if (anchor.kind === "hunk") {
|
|
5608
6159
|
if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
|
|
5609
6160
|
if (anchor.side !== "old" && anchor.side !== "new") return null;
|
|
@@ -5636,6 +6187,53 @@ function normalizeAnchored(value) {
|
|
|
5636
6187
|
text: a.text
|
|
5637
6188
|
};
|
|
5638
6189
|
}
|
|
6190
|
+
/** Dropped when malformed — the thread keeps its anchor's title regardless. */
|
|
6191
|
+
function normalizeAnchoredLayer(value) {
|
|
6192
|
+
if (typeof value !== "object" || value === null) return null;
|
|
6193
|
+
const a = value;
|
|
6194
|
+
if (typeof a.title !== "string" || !Array.isArray(a.files)) return null;
|
|
6195
|
+
if (!a.files.every((f) => typeof f === "string")) return null;
|
|
6196
|
+
return {
|
|
6197
|
+
title: a.title,
|
|
6198
|
+
...typeof a.summary === "string" && a.summary !== "" ? { summary: a.summary } : {},
|
|
6199
|
+
files: a.files
|
|
6200
|
+
};
|
|
6201
|
+
}
|
|
6202
|
+
function normalizePrComment(value) {
|
|
6203
|
+
if (typeof value !== "object" || value === null) return null;
|
|
6204
|
+
const v = value;
|
|
6205
|
+
if (typeof v.text !== "string" || v.text.trim() === "") return null;
|
|
6206
|
+
const o = v.outcome;
|
|
6207
|
+
let outcome;
|
|
6208
|
+
if (typeof o === "object" && o !== null) {
|
|
6209
|
+
const r = o;
|
|
6210
|
+
const at = typeof r.at === "string" ? r.at : (/* @__PURE__ */ new Date()).toISOString();
|
|
6211
|
+
if (r.kind === "dismissed") outcome = {
|
|
6212
|
+
kind: "dismissed",
|
|
6213
|
+
at
|
|
6214
|
+
};
|
|
6215
|
+
else if (r.kind === "added" && typeof r.draftThreadId === "string") outcome = {
|
|
6216
|
+
kind: "added",
|
|
6217
|
+
draftThreadId: r.draftThreadId,
|
|
6218
|
+
edited: r.edited === true,
|
|
6219
|
+
at
|
|
6220
|
+
};
|
|
6221
|
+
}
|
|
6222
|
+
return {
|
|
6223
|
+
text: v.text,
|
|
6224
|
+
...outcome ? { outcome } : {}
|
|
6225
|
+
};
|
|
6226
|
+
}
|
|
6227
|
+
function normalizeOrigin(value) {
|
|
6228
|
+
if (typeof value !== "object" || value === null) return null;
|
|
6229
|
+
const v = value;
|
|
6230
|
+
if (typeof v.threadId !== "string" || typeof v.messageId !== "string") return null;
|
|
6231
|
+
return {
|
|
6232
|
+
threadId: v.threadId,
|
|
6233
|
+
messageId: v.messageId,
|
|
6234
|
+
edited: v.edited === true
|
|
6235
|
+
};
|
|
6236
|
+
}
|
|
5639
6237
|
function normalizeThread(value, now) {
|
|
5640
6238
|
if (typeof value !== "object" || value === null) return null;
|
|
5641
6239
|
const t = value;
|
|
@@ -5643,6 +6241,7 @@ function normalizeThread(value, now) {
|
|
|
5643
6241
|
const anchor = parseAnchor(t.anchor);
|
|
5644
6242
|
if (!anchor) return null;
|
|
5645
6243
|
const anchored = normalizeAnchored(t.anchored);
|
|
6244
|
+
const anchoredLayer = normalizeAnchoredLayer(t.anchoredLayer);
|
|
5646
6245
|
if (!Array.isArray(t.messages)) return null;
|
|
5647
6246
|
const messages = [];
|
|
5648
6247
|
for (const m of t.messages) {
|
|
@@ -5659,6 +6258,7 @@ function normalizeThread(value, now) {
|
|
|
5659
6258
|
at: typeof msg.at === "string" ? msg.at : now,
|
|
5660
6259
|
...typeof msg.durationMs === "number" ? { durationMs: msg.durationMs } : {},
|
|
5661
6260
|
...msg.author === "agent" && typeof msg.suggestedReply === "string" && msg.suggestedReply ? { suggestedReply: msg.suggestedReply } : {},
|
|
6261
|
+
...msg.author === "agent" && normalizePrComment(msg.prComment) ? { prComment: normalizePrComment(msg.prComment) } : {},
|
|
5662
6262
|
...typeof msg.editedAt === "string" ? { editedAt: msg.editedAt } : {},
|
|
5663
6263
|
...github ? { github } : {}
|
|
5664
6264
|
});
|
|
@@ -5672,10 +6272,12 @@ function normalizeThread(value, now) {
|
|
|
5672
6272
|
...t.audience === "pr" ? { audience: "pr" } : {},
|
|
5673
6273
|
...typeof t.parentId === "string" && t.parentId !== "" ? { parentId: t.parentId } : {},
|
|
5674
6274
|
...github ? { github } : {},
|
|
6275
|
+
...t.audience === "pr" && normalizeOrigin(t.origin) ? { origin: normalizeOrigin(t.origin) } : {},
|
|
5675
6276
|
...queued ? { queued } : {},
|
|
5676
6277
|
...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
|
|
5677
6278
|
codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
|
|
5678
6279
|
...anchored ? { anchored } : {},
|
|
6280
|
+
...anchoredLayer ? { anchoredLayer } : {},
|
|
5679
6281
|
codeChanged: t.codeChanged === true,
|
|
5680
6282
|
...t.unanswered === true ? { unanswered: true } : {},
|
|
5681
6283
|
...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
|
|
@@ -6489,6 +7091,9 @@ const POLL_HEARTBEAT_MS = 15e3;
|
|
|
6489
7091
|
* aborts, and a listening poll blocks the daemon's idle reap.
|
|
6490
7092
|
*/
|
|
6491
7093
|
const POLL_MAX_MS = 18e5;
|
|
7094
|
+
/** How long after listening the daemon starts the machine-wide database sweep:
|
|
7095
|
+
* past the first page load, which is what the sweep must never delay. */
|
|
7096
|
+
const MAINTENANCE_DELAY_MS = 5e3;
|
|
6492
7097
|
function createApp(ctx, store, review, queue) {
|
|
6493
7098
|
const app = new Hono();
|
|
6494
7099
|
if (review && queue) queue.onBatchClosed((closed) => {
|
|
@@ -6548,15 +7153,27 @@ function createApp(ctx, store, review, queue) {
|
|
|
6548
7153
|
"light",
|
|
6549
7154
|
"dark"
|
|
6550
7155
|
]);
|
|
7156
|
+
const CRITTER_VALUES = /* @__PURE__ */ new Set(["on", "off"]);
|
|
7157
|
+
const stored = (key, values) => {
|
|
7158
|
+
const value = ctx.uiSettings?.get(key) ?? null;
|
|
7159
|
+
return value !== null && values.has(value) ? value : null;
|
|
7160
|
+
};
|
|
6551
7161
|
app.get("/api/settings", (c) => {
|
|
6552
|
-
|
|
6553
|
-
|
|
7162
|
+
return c.json({
|
|
7163
|
+
theme: stored("theme", THEME_VALUES),
|
|
7164
|
+
critter: stored("critter", CRITTER_VALUES)
|
|
7165
|
+
});
|
|
6554
7166
|
});
|
|
6555
7167
|
app.put("/api/settings", async (c) => {
|
|
6556
7168
|
if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
|
|
6557
|
-
const
|
|
6558
|
-
|
|
6559
|
-
|
|
7169
|
+
const body = await c.req.json().catch(() => null);
|
|
7170
|
+
const theme = body?.theme;
|
|
7171
|
+
const critter = body?.critter;
|
|
7172
|
+
const themeOk = typeof theme === "string" && THEME_VALUES.has(theme);
|
|
7173
|
+
const critterOk = typeof critter === "string" && CRITTER_VALUES.has(critter);
|
|
7174
|
+
if (theme !== void 0 && !themeOk || critter !== void 0 && !critterOk || !(themeOk || critterOk)) return c.json({ error: "need {theme?: \"system\" | \"light\" | \"dark\", critter?: \"on\" | \"off\"}, at least one" }, 400);
|
|
7175
|
+
if (themeOk) ctx.uiSettings.set("theme", theme);
|
|
7176
|
+
if (critterOk) ctx.uiSettings.set("critter", critter);
|
|
6560
7177
|
return c.json({ ok: true });
|
|
6561
7178
|
});
|
|
6562
7179
|
const reviewKind = () => reviewKindOf(ctx.spec, ctx.pr !== void 0);
|
|
@@ -6593,6 +7210,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
6593
7210
|
const promptCtx = (...excludeThreadIds) => ({
|
|
6594
7211
|
repo: repoInfo(),
|
|
6595
7212
|
changeset: store?.get() ?? null,
|
|
7213
|
+
...ctx.pr && review ? { voice: review.get().threads } : {},
|
|
6596
7214
|
siblings: review?.get().threads.filter((t) => !excludeThreadIds.includes(t.id) && (t.state === "open" || t.state === "sent")) ?? []
|
|
6597
7215
|
});
|
|
6598
7216
|
const deliverThreads = (threadIds) => {
|
|
@@ -6636,20 +7254,67 @@ function createApp(ctx, store, review, queue) {
|
|
|
6636
7254
|
const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
|
|
6637
7255
|
const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
|
|
6638
7256
|
const anchor = agentAnchor(file, line);
|
|
7257
|
+
const prComment = parsePrComment(body?.prComment);
|
|
7258
|
+
if (body?.prComment !== void 0 && !ctx.pr) return c.json({ error: "a suggested PR comment needs a pull request under review" }, 400);
|
|
6639
7259
|
const capture = store ? captureAnchor(store.get(), anchor) : null;
|
|
6640
|
-
return c.json(review.createThread(anchor, text, capture, void 0, "agent", {
|
|
7260
|
+
return c.json(review.createThread(anchor, text, capture, void 0, "agent", {
|
|
7261
|
+
suggestedReply: parseSuggestedReply(body?.suggestedReply),
|
|
7262
|
+
...prComment ? { prComment } : {}
|
|
7263
|
+
}));
|
|
6641
7264
|
}
|
|
6642
7265
|
const anchor = parseAnchor(body?.anchor);
|
|
6643
7266
|
if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
|
|
7267
|
+
const layer = anchor.kind === "layer" ? review.get().layers?.items.find((l) => l.id === anchor.layerId) : void 0;
|
|
7268
|
+
if (anchor.kind === "layer") {
|
|
7269
|
+
if (!layer) return c.json({ error: "that layer is not in the outline" }, 400);
|
|
7270
|
+
anchor.title = layer.title;
|
|
7271
|
+
}
|
|
6644
7272
|
const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
|
|
6645
7273
|
const audience = body?.audience === "pr" ? "pr" : void 0;
|
|
6646
7274
|
if (audience && !ctx.pr) return c.json({ error: "public comments need a pull request under review" }, 400);
|
|
7275
|
+
if (audience && anchor.kind === "layer") return c.json({ error: "a comment on a layer is for the agent, not the pull request" }, 400);
|
|
6647
7276
|
const parentId = typeof body?.parentId === "string" && review.get().threads.some((t) => t.id === body.parentId) ? body.parentId : void 0;
|
|
6648
|
-
|
|
6649
|
-
|
|
7277
|
+
let origin;
|
|
7278
|
+
if (body?.origin !== void 0) {
|
|
7279
|
+
const o = body.origin;
|
|
7280
|
+
const source = audience && typeof o?.threadId === "string" ? review.get().threads.find((t) => t.id === o.threadId) : void 0;
|
|
7281
|
+
const message = source && typeof o?.messageId === "string" ? source.messages.find((m) => m.id === o.messageId) : void 0;
|
|
7282
|
+
if (!source || !message || liveSuggestion(source)?.id !== message.id) return c.json({ error: "that suggested comment is no longer live" }, 409);
|
|
7283
|
+
origin = {
|
|
7284
|
+
threadId: source.id,
|
|
7285
|
+
messageId: message.id,
|
|
7286
|
+
edited: message.prComment.text.trim() !== text
|
|
7287
|
+
};
|
|
7288
|
+
}
|
|
7289
|
+
const capture = store ? captureAnchor(store.get(), anchor, review.get().layers) : null;
|
|
7290
|
+
const created = review.createThread(anchor, text, capture, intent, "reviewer", {
|
|
6650
7291
|
...audience ? { audience } : {},
|
|
6651
|
-
...parentId ? { parentId } : {}
|
|
6652
|
-
|
|
7292
|
+
...parentId ? { parentId } : {},
|
|
7293
|
+
...origin ? { origin } : {}
|
|
7294
|
+
});
|
|
7295
|
+
if (origin) review.decidePrComment(origin.threadId, origin.messageId, {
|
|
7296
|
+
kind: "added",
|
|
7297
|
+
draftThreadId: created.id,
|
|
7298
|
+
edited: origin.edited,
|
|
7299
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
7300
|
+
});
|
|
7301
|
+
return c.json(created);
|
|
7302
|
+
});
|
|
7303
|
+
app.post("/api/review/threads/:id/messages/:messageId/pr-comment", async (c) => {
|
|
7304
|
+
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
7305
|
+
const body = await c.req.json().catch(() => null);
|
|
7306
|
+
const id = c.req.param("id");
|
|
7307
|
+
const messageId = c.req.param("messageId");
|
|
7308
|
+
if (body?.outcome === "restored") {
|
|
7309
|
+
const thread = review.restorePrComment(id, messageId);
|
|
7310
|
+
return thread ? c.json(thread) : c.json({ error: "nothing to restore there" }, 404);
|
|
7311
|
+
}
|
|
7312
|
+
if (body?.outcome !== "dismissed") return c.json({ error: "outcome must be \"dismissed\" or \"restored\"" }, 400);
|
|
7313
|
+
const thread = review.decidePrComment(id, messageId, {
|
|
7314
|
+
kind: "dismissed",
|
|
7315
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
7316
|
+
});
|
|
7317
|
+
return thread ? c.json(thread) : c.json({ error: "no live suggested comment there" }, 404);
|
|
6653
7318
|
});
|
|
6654
7319
|
app.post("/api/review/layers", async (c) => {
|
|
6655
7320
|
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
@@ -6686,9 +7351,11 @@ function createApp(ctx, store, review, queue) {
|
|
|
6686
7351
|
if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
|
|
6687
7352
|
queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
|
|
6688
7353
|
const more = body?.more === true;
|
|
7354
|
+
const prComment = parsePrComment(body?.prComment);
|
|
7355
|
+
if (body?.prComment !== void 0 && !ctx.pr) return c.json({ error: "a suggested PR comment needs a pull request under review" }, 400);
|
|
6689
7356
|
const waitedMs = queue?.agentReplied(id, more) ?? null;
|
|
6690
7357
|
const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
|
|
6691
|
-
const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more, parseSuggestedReply(body?.suggestedReply));
|
|
7358
|
+
const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more, parseSuggestedReply(body?.suggestedReply), prComment);
|
|
6692
7359
|
if (!thread) return c.json({ error: "no such thread" }, 404);
|
|
6693
7360
|
if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
|
|
6694
7361
|
return c.json({
|
|
@@ -6828,7 +7495,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
6828
7495
|
...note !== void 0 ? { note } : {}
|
|
6829
7496
|
};
|
|
6830
7497
|
};
|
|
6831
|
-
const activeThreads = (threads) => threadsInChangeset(store?.get().files ?? [], threads).active.filter((t) => !isPublic(t));
|
|
7498
|
+
const activeThreads = (threads) => threadsInChangeset(store?.get().files ?? [], threads, review?.get().layers).active.filter((t) => !isPublic(t));
|
|
6832
7499
|
const flushableIds = (threads) => new Set(activeThreads(threads).filter((t) => !untouchedAgentVoice(t)).map((t) => t.id));
|
|
6833
7500
|
const projectFinish = (threads) => {
|
|
6834
7501
|
const flushable = flushableIds(threads);
|
|
@@ -6890,6 +7557,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
6890
7557
|
}, coverage),
|
|
6891
7558
|
...leg ? { public: {
|
|
6892
7559
|
...describeLeg(leg),
|
|
7560
|
+
undecidedSuggestions: before.filter((t) => liveSuggestion(t) !== null).length,
|
|
6893
7561
|
canApprove: pr ? !pr.viewer.isAuthor : true,
|
|
6894
7562
|
pendingReview: (review.get().pr?.pendingReviewId ?? pr?.viewer.pendingReviewId ?? null) !== null
|
|
6895
7563
|
} } : {}
|
|
@@ -6915,7 +7583,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
6915
7583
|
event,
|
|
6916
7584
|
comments: publicOutcome.posted,
|
|
6917
7585
|
body: coverage.note ?? "",
|
|
6918
|
-
...publicOutcome.url ? { url: publicOutcome.url } : {}
|
|
7586
|
+
...publicOutcome.url ? { url: publicOutcome.url } : {},
|
|
7587
|
+
suggestions: suggestionsSummary(review.get().threads)
|
|
6919
7588
|
});
|
|
6920
7589
|
ctx.pr.refresh?.();
|
|
6921
7590
|
}
|
|
@@ -7098,6 +7767,16 @@ function createApp(ctx, store, review, queue) {
|
|
|
7098
7767
|
}
|
|
7099
7768
|
});
|
|
7100
7769
|
});
|
|
7770
|
+
app.post("/api/agent/arrive", (c) => {
|
|
7771
|
+
if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
|
|
7772
|
+
if (!c.req.header("x-diffo-agent") && !c.req.header("x-diffo-session-pid")) return c.json({ error: "agent arrivals must send the x-diffo-agent header" }, 403);
|
|
7773
|
+
const arrived = queue.agentArrived(parseSessionPid(c.req.header("x-diffo-session-pid")));
|
|
7774
|
+
return c.json({
|
|
7775
|
+
ok: true,
|
|
7776
|
+
arrived,
|
|
7777
|
+
presence: queue.presence()
|
|
7778
|
+
});
|
|
7779
|
+
});
|
|
7101
7780
|
app.post("/api/agent/end", (c) => {
|
|
7102
7781
|
if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
|
|
7103
7782
|
const sessionPid = parseSessionPid(c.req.header("x-diffo-session-pid"));
|
|
@@ -7271,11 +7950,13 @@ function startServer(options) {
|
|
|
7271
7950
|
throw new RepoAlreadyServedError(holder);
|
|
7272
7951
|
}
|
|
7273
7952
|
db.setPreferredPort(resolve(options.root), options.port);
|
|
7953
|
+
const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
|
|
7954
|
+
const retired = db.pruneRepo(resolve(options.root));
|
|
7955
|
+
if (retired > 0) log(`retired ${retired} ${retired === 1 ? "review" : "reviews"} for branches this repo no longer has`);
|
|
7274
7956
|
const review = new ReviewStore(options.root, db, options.spec);
|
|
7275
7957
|
const queue = new DeliveryQueue();
|
|
7276
7958
|
queue.rescope(store.get().repo.branch);
|
|
7277
7959
|
rehydrateQueue(review, queue);
|
|
7278
|
-
const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
|
|
7279
7960
|
const telemetry = options.telemetry ?? new Telemetry({
|
|
7280
7961
|
store: {
|
|
7281
7962
|
get: (key) => db.getUiSetting(key),
|
|
@@ -7358,6 +8039,12 @@ function startServer(options) {
|
|
|
7358
8039
|
port: options.port,
|
|
7359
8040
|
hostname: "127.0.0.1"
|
|
7360
8041
|
});
|
|
8042
|
+
const maintenance = setTimeout(() => {
|
|
8043
|
+
db.maintain().then((report) => {
|
|
8044
|
+
if (report.reviews > 0) log(`retired ${report.reviews} ${report.reviews === 1 ? "review" : "reviews"} whose repo or branch is gone`);
|
|
8045
|
+
}).catch((err) => log(`database sweep failed: ${err.message}`));
|
|
8046
|
+
}, MAINTENANCE_DELAY_MS);
|
|
8047
|
+
maintenance.unref();
|
|
7361
8048
|
const deregister = () => {
|
|
7362
8049
|
try {
|
|
7363
8050
|
db.removeServer(review.repoPath, options.port);
|
|
@@ -7381,6 +8068,7 @@ function startServer(options) {
|
|
|
7381
8068
|
queue,
|
|
7382
8069
|
telemetry,
|
|
7383
8070
|
stopWatching: async () => {
|
|
8071
|
+
clearTimeout(maintenance);
|
|
7384
8072
|
idle?.stop();
|
|
7385
8073
|
puller?.stop();
|
|
7386
8074
|
unsubscribeReconcile();
|
|
@@ -7986,6 +8674,7 @@ if (command.kind === "setup") {
|
|
|
7986
8674
|
if (command.kind === "clean") {
|
|
7987
8675
|
const db = new DiffoDb();
|
|
7988
8676
|
try {
|
|
8677
|
+
await db.maintain();
|
|
7989
8678
|
const entries = sweepWorktrees(db, void 0, {
|
|
7990
8679
|
force: command.force,
|
|
7991
8680
|
all: command.all
|
|
@@ -8118,6 +8807,23 @@ async function announceOpen(port) {
|
|
|
8118
8807
|
});
|
|
8119
8808
|
} catch {}
|
|
8120
8809
|
}
|
|
8810
|
+
/**
|
|
8811
|
+
* An agent opened the review (piped stdout, like `printAgentNextStep`): tell
|
|
8812
|
+
* the server it is here before the URL prints, so the reviewer who opens the
|
|
8813
|
+
* page while the guide is being written sees an agent orienting itself, not
|
|
8814
|
+
* "no agent" and an Invite. A human's terminal open says nothing — the
|
|
8815
|
+
* reviewer IS the opener, and the chip must keep offering the invite.
|
|
8816
|
+
*/
|
|
8817
|
+
async function announceArrival(port) {
|
|
8818
|
+
if (process.stdout.isTTY) return;
|
|
8819
|
+
try {
|
|
8820
|
+
await fetch(apiUrl(port, "/api/agent/arrive"), {
|
|
8821
|
+
method: "POST",
|
|
8822
|
+
headers: sessionHeaders(),
|
|
8823
|
+
signal: AbortSignal.timeout(2e3)
|
|
8824
|
+
});
|
|
8825
|
+
} catch {}
|
|
8826
|
+
}
|
|
8121
8827
|
async function fetchChangesetInfo(port) {
|
|
8122
8828
|
try {
|
|
8123
8829
|
return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
|
|
@@ -8309,16 +9015,19 @@ if (command.kind === "reply") {
|
|
|
8309
9015
|
author: "agent",
|
|
8310
9016
|
text: message,
|
|
8311
9017
|
...command.more ? { more: true } : {},
|
|
8312
|
-
...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
|
|
9018
|
+
...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
|
|
9019
|
+
...command.prComment ? { prComment: command.prComment } : {}
|
|
8313
9020
|
});
|
|
8314
9021
|
if (status === 404) fail(`no thread with id '${command.threadId}'`);
|
|
9022
|
+
if (status === 400) fail(`reply refused: ${body?.error ?? "bad request"}`);
|
|
8315
9023
|
if (status !== 200) fail(`reply failed (${status})`);
|
|
8316
9024
|
const thread = body.thread;
|
|
8317
9025
|
console.log(JSON.stringify({
|
|
8318
9026
|
ok: true,
|
|
8319
9027
|
threadId: thread.id,
|
|
8320
9028
|
state: thread.state,
|
|
8321
|
-
|
|
9029
|
+
...command.prComment ? { prComment: "suggested" } : {},
|
|
9030
|
+
next_step: command.more ? ACK_NEXT_STEP.replyMore : command.prComment ? ACK_NEXT_STEP.replyPrComment : ACK_NEXT_STEP.reply
|
|
8322
9031
|
}));
|
|
8323
9032
|
process.exit(0);
|
|
8324
9033
|
}
|
|
@@ -8331,13 +9040,16 @@ if (command.kind === "comment") {
|
|
|
8331
9040
|
file: command.file,
|
|
8332
9041
|
line: command.line,
|
|
8333
9042
|
text: message,
|
|
8334
|
-
...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
|
|
9043
|
+
...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
|
|
9044
|
+
...command.prComment ? { prComment: command.prComment } : {}
|
|
8335
9045
|
});
|
|
9046
|
+
if (status === 400) fail(`comment refused: ${body?.error ?? "bad request"}`);
|
|
8336
9047
|
if (status !== 200) fail(`comment failed (${status})`);
|
|
8337
9048
|
console.log(JSON.stringify({
|
|
8338
9049
|
ok: true,
|
|
8339
9050
|
threadId: body.id,
|
|
8340
|
-
|
|
9051
|
+
...command.prComment ? { prComment: "suggested" } : {},
|
|
9052
|
+
next_step: command.prComment ? ACK_NEXT_STEP.commentPrComment : ACK_NEXT_STEP.comment
|
|
8341
9053
|
}));
|
|
8342
9054
|
process.exit(0);
|
|
8343
9055
|
}
|
|
@@ -8500,6 +9212,7 @@ if (existing !== null && takeOverPort === null) {
|
|
|
8500
9212
|
console.log(prOpen ? "diffo is already watching this pull request" : "diffo is already watching this repo");
|
|
8501
9213
|
await warnSpecMismatch(existing.port, spec);
|
|
8502
9214
|
await announceOpen(existing.port);
|
|
9215
|
+
await announceArrival(existing.port);
|
|
8503
9216
|
console.log(`→ ${url}`);
|
|
8504
9217
|
await printAgentNextStep(existing.port, prOpen?.pr);
|
|
8505
9218
|
process.exit(0);
|
|
@@ -8520,6 +9233,7 @@ if (existing !== null && takeOverPort !== null) {
|
|
|
8520
9233
|
if (!command.foreground) {
|
|
8521
9234
|
const daemonPort = await requireServer(command.port, spec.kind === "branch" ? spec.base : void 0, SRC_STAMP, prOpen ? command.target : void 0);
|
|
8522
9235
|
await announceOpen(daemonPort);
|
|
9236
|
+
await announceArrival(daemonPort);
|
|
8523
9237
|
const url = reviewUrl(daemonPort);
|
|
8524
9238
|
await printChangesetSummary(daemonPort);
|
|
8525
9239
|
console.log(`→ ${url}`);
|