@diffohq/diffo 0.10.0 → 0.12.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 +635 -45
- package/dist/client/assets/{abnfDiagram-O67JEVCF-DBlSJW53.js → abnfDiagram-O67JEVCF-Dz8VXQP2.js} +1 -1
- package/dist/client/assets/{arc-BZZ_ET2N.js → arc-BzCu5Txo.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-DXR42FQE.js +1 -0
- package/dist/client/assets/{architectureDiagram-NJMV4G6O-CmS7e2K4.js → architectureDiagram-NJMV4G6O-DkNhFqaV.js} +1 -1
- package/dist/client/assets/{blockDiagram-BEXU5L5S-Kj8H2ODF.js → blockDiagram-BEXU5L5S-H5iGDrKH.js} +1 -1
- package/dist/client/assets/{c4Diagram-YGBWAQC7-CixbFJxb.js → c4Diagram-YGBWAQC7-BCWmLGS5.js} +1 -1
- package/dist/client/assets/channel-SN11_URN.js +1 -0
- package/dist/client/assets/{chunk-3FUC2YCW-CRdo0Gw5.js → chunk-3FUC2YCW-ca8N6xua.js} +1 -1
- package/dist/client/assets/{chunk-5DYCD2WN-Bw9n6qPh.js → chunk-5DYCD2WN-PNfMjtmJ.js} +1 -1
- package/dist/client/assets/{chunk-742MDFTN-BsvOSyDz.js → chunk-742MDFTN-BCGjcJss.js} +1 -1
- package/dist/client/assets/{chunk-7INBJB4K-ie7EoCoW.js → chunk-7INBJB4K-CrKz2qgm.js} +1 -1
- package/dist/client/assets/{chunk-7M6MHVWA-DJlbYDLH.js → chunk-7M6MHVWA--GjKV-c3.js} +1 -1
- package/dist/client/assets/{chunk-7PRAP22T-Hs5y39V8.js → chunk-7PRAP22T-DjHxtzzp.js} +1 -1
- package/dist/client/assets/{chunk-GTNCS2PH-DNq6Ojc7.js → chunk-GTNCS2PH-Dz4v2kmQ.js} +1 -1
- package/dist/client/assets/{chunk-GWA4HPMP-vgn0kxbp.js → chunk-GWA4HPMP-BSgkR_3v.js} +1 -1
- package/dist/client/assets/{chunk-MBY4JIJT-C7fMuqSl.js → chunk-MBY4JIJT-Bq7wFcIu.js} +1 -1
- package/dist/client/assets/{chunk-NETBCI7D-ZJtjvGwt.js → chunk-NETBCI7D-CcPc99um.js} +1 -1
- package/dist/client/assets/{chunk-O7XYJQB3-DESjnZZT.js → chunk-O7XYJQB3-DmV54lZx.js} +1 -1
- package/dist/client/assets/{chunk-UA2S7LBM-Cmf087s8.js → chunk-UA2S7LBM-BShAYBVS.js} +1 -1
- package/dist/client/assets/{chunk-WEXAMYUT-U1TW5ZAF.js → chunk-WEXAMYUT-D19EF-TI.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-D1B1IYm1.js → chunk-XXDRQBXY-DYNSGGeq.js} +1 -1
- package/dist/client/assets/{chunk-Z7XXMR3K-D3BUH0qv.js → chunk-Z7XXMR3K-CUy3HgJd.js} +1 -1
- package/dist/client/assets/{chunk-ZIGJFQKS-Bx6yZhwg.js → chunk-ZIGJFQKS-D9UToYrl.js} +1 -1
- package/dist/client/assets/{classDiagram-v2-NBCMYWYE-sjiBGJdD.js → classDiagram-v2-NBCMYWYE-Chemkzmq.js} +1 -1
- package/dist/client/assets/{core-6IJUkucC.js → core-DHnkJax7.js} +1 -1
- package/dist/client/assets/{cose-bilkent-JH36ORCC-xaMCCs4V.js → cose-bilkent-JH36ORCC-Bewr0AYG.js} +1 -1
- package/dist/client/assets/{cynefin-OW5HDTMX-C5k9L5NF.js → cynefin-OW5HDTMX-CirO8zKP.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-VND7K2PF-BqiILl4G.js → cynefinDiagram-VND7K2PF-DGrP6yY_.js} +1 -1
- package/dist/client/assets/{dagre-6A5THRUB-BtgX5KvU.js → dagre-6A5THRUB-Co00MmNX.js} +1 -1
- package/dist/client/assets/{diagram-22UHCM2B-BcDIKY8C.js → diagram-22UHCM2B-CyINGoxd.js} +1 -1
- package/dist/client/assets/{diagram-3UASUU5V-C-DrBJ6K.js → diagram-3UASUU5V-9-GW6GaI.js} +1 -1
- package/dist/client/assets/{diagram-ATOU4E4O-B0W6FX6y.js → diagram-ATOU4E4O-BQ-r4IMq.js} +1 -1
- package/dist/client/assets/{diagram-CDSNMT55-D1O9NYQ3.js → diagram-CDSNMT55-wDvP2y-X.js} +1 -1
- package/dist/client/assets/{diagram-MLGK6HIB-WC8izmk2.js → diagram-MLGK6HIB-BnQVNLhl.js} +1 -1
- package/dist/client/assets/{diagram-MPIPVDR6-D5ko9V-k.js → diagram-MPIPVDR6-CvQmhTpZ.js} +1 -1
- package/dist/client/assets/{dist-BBmACywa.js → dist-Be-c8hXl.js} +1 -1
- package/dist/client/assets/{dist-Bss8uvIz.js → dist-Deo8rbAn.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-ZINNZB2B-BFIYDkrv.js → ebnfDiagram-ZINNZB2B-D_lCUmG0.js} +1 -1
- package/dist/client/assets/{elk-276RUBZZ-WsSc0CyB.js → elk-276RUBZZ-rxjXBYWb.js} +1 -1
- package/dist/client/assets/{engine-oniguruma-DmL6leO7.js → engine-oniguruma-BJ2pZwxc.js} +1 -1
- package/dist/client/assets/{erDiagram-OPXOYQCR-CjaKeGKh.js → erDiagram-OPXOYQCR-Dfd_ZBo9.js} +1 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-C3U-p4vY.js +1 -0
- package/dist/client/assets/flowDiagram-KWPJA3E3-Czdi2rFR.js +1 -0
- package/dist/client/assets/{ganttDiagram-FUAMR5RP-22OFMoat.js → ganttDiagram-FUAMR5RP-C1eOGnpc.js} +1 -1
- package/dist/client/assets/{gitGraph-4MIJSDKK-BP0idayz.js → gitGraph-4MIJSDKK-BJDMPbrH.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-X574FWY7-Cx5rPzjc.js → gitGraphDiagram-X574FWY7-COQk3bFW.js} +1 -1
- package/dist/client/assets/index-B8R5lnLt.css +1 -0
- package/dist/client/assets/index-DOSGiRCe.js +93 -0
- package/dist/client/assets/{info-A6RAGUB7-BvZZu8IY.js → info-A6RAGUB7-YKHokYRL.js} +1 -1
- package/dist/client/assets/{infoDiagram-VRGFBTTK-Bbk1aZco.js → infoDiagram-VRGFBTTK-6uJdW7Uf.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-DbyWrUBY.js → ishikawaDiagram-OU5B5YK6-BF3c1rog.js} +1 -1
- package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Ka7zlagO.js → journeyDiagram-ZHPQQLJL-DuWY0HyM.js} +1 -1
- package/dist/client/assets/{kanban-definition-PNTS6WVX-Q_NAEdRt.js → kanban-definition-PNTS6WVX-DpxFXlZZ.js} +1 -1
- package/dist/client/assets/{line-SDAA9fEr.js → line-u-U51JAx.js} +1 -1
- package/dist/client/assets/{linear-BPf2OGUv.js → linear-Dpw7ISW8.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-BjLzFSoy.js → mermaid-parser.core-NVWntgrf.js} +3 -3
- package/dist/client/assets/{mermaid.core-vvUsQbpX.js → mermaid.core-B93MCzyy.js} +4 -4
- package/dist/client/assets/{mindmap-definition-NLK3R4M7-DuVzu_4M.js → mindmap-definition-NLK3R4M7-CIFJqrKY.js} +1 -1
- package/dist/client/assets/{packet-AYTQ26CC-A66za-wc.js → packet-AYTQ26CC-DlE9OJIa.js} +1 -1
- package/dist/client/assets/{pegDiagram-GJSIUBJH-ClH92MDY.js → pegDiagram-GJSIUBJH-DVAxFXk4.js} +1 -1
- package/dist/client/assets/{pie-WAS4IAKB-sJ2H2jF4.js → pie-WAS4IAKB-CnF58beD.js} +1 -1
- package/dist/client/assets/{pieDiagram-5QR66LMP-BmlcB4fD.js → pieDiagram-5QR66LMP-C9NRYvUb.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-O4NWA36T-BUdNHhtc.js → quadrantDiagram-O4NWA36T-Bk84qmTE.js} +1 -1
- package/dist/client/assets/{radar-RG4KPBEZ-B3MRkz7l.js → radar-RG4KPBEZ--0RoMbvC.js} +1 -1
- package/dist/client/assets/{railroad-74A4TZTK-CPQddZw9.js → railroad-74A4TZTK-QYeNnCgA.js} +1 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-CAw2FOzX.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-lUllLfGX.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-Cnu3kREr.js +1 -0
- package/dist/client/assets/{railroadDiagram-XR7U4H2S-B2wicmNk.js → railroadDiagram-XR7U4H2S-CCTlqXdV.js} +1 -1
- package/dist/client/assets/{requirementDiagram-PLB6GJNP-4fGmNry2.js → requirementDiagram-PLB6GJNP-Bqbgo3QR.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-IPEJSGJF-ByuhL9nO.js → sankeyDiagram-IPEJSGJF-BXC5cjxI.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-PO4LG4MO-CHeFXNrS.js → sequenceDiagram-PO4LG4MO-Bds-OSHb.js} +1 -1
- package/dist/client/assets/{src-DuEx6MBc.js → src-DQOrnl7t.js} +1 -1
- package/dist/client/assets/{stateDiagram-v2-GCMORJYK-Dsfly8rU.js → stateDiagram-v2-GCMORJYK-D1xkwXMJ.js} +1 -1
- package/dist/client/assets/{swimlanes-2SLR337P-DDb1xXAV.js → swimlanes-2SLR337P-Djv5MaNv.js} +1 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-By5FZWiE.js +8 -0
- package/dist/client/assets/{timeline-definition-EJHVYXUP-iC4lxMiK.js → timeline-definition-EJHVYXUP-Xpylk9Go.js} +1 -1
- package/dist/client/assets/{treeView-Q6P3EWNA-zXF3RWVa.js → treeView-Q6P3EWNA-DZhZ3oTA.js} +1 -1
- package/dist/client/assets/{treemap-WGGIJYW6-BvA719M0.js → treemap-WGGIJYW6-DM7_PSXC.js} +1 -1
- package/dist/client/assets/{usecaseDiagram-POWQR4AR-CXzh5cdW.js → usecaseDiagram-POWQR4AR-BFIAR0u1.js} +1 -1
- package/dist/client/assets/{vennDiagram-UO4OBE2U-BuMQeNKp.js → vennDiagram-UO4OBE2U-JHvTvqTF.js} +1 -1
- package/dist/client/assets/{wardley-WFR3VGLG-m1zZI6-6.js → wardley-WFR3VGLG-DwlxIwqO.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-VNRHLVJA-DiB2QtGU.js → wardleyDiagram-VNRHLVJA-BqfNR_Ex.js} +1 -1
- package/dist/client/assets/{xychartDiagram-PMCCYNJV-CKRPoaV8.js → xychartDiagram-PMCCYNJV-CNVbi53o.js} +1 -1
- package/dist/client/favicon.svg +15 -7
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-Cds-sOVr.js +0 -1
- package/dist/client/assets/channel-BI4dhOPo.js +0 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-DE5ql6lo.js +0 -1
- package/dist/client/assets/flowDiagram-KWPJA3E3-CObKVtKJ.js +0 -1
- package/dist/client/assets/index-CL4K6Y07.css +0 -1
- package/dist/client/assets/index-DXMhk4ok.js +0 -93
- package/dist/client/assets/railroad-abnf-HS5TGJTU-CijG3JBn.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-CNgXgJgc.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-BkwGmMtj.js +0 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DXpYpXP-.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
|
|
@@ -3607,8 +3924,9 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3607
3924
|
stalled = false;
|
|
3608
3925
|
stallTimer = null;
|
|
3609
3926
|
betweenPolls = false;
|
|
3610
|
-
/** Why the agent is parked between polls: a reply just landed,
|
|
3611
|
-
* ended on its own and the re-poll is expected
|
|
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. */
|
|
3612
3930
|
parked = "replied";
|
|
3613
3931
|
graceTimer = null;
|
|
3614
3932
|
lastDetach = null;
|
|
@@ -3810,7 +4128,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3810
4128
|
if (this.stallTimer !== null) clearTimeout(this.stallTimer);
|
|
3811
4129
|
this.stallTimer = null;
|
|
3812
4130
|
}
|
|
3813
|
-
armGrace(why = "replied") {
|
|
4131
|
+
armGrace(why = "replied", windowMs = this.replyGraceMs) {
|
|
3814
4132
|
this.clearGrace();
|
|
3815
4133
|
this.betweenPolls = true;
|
|
3816
4134
|
this.parked = why;
|
|
@@ -3822,7 +4140,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3822
4140
|
};
|
|
3823
4141
|
const pid = this.owner;
|
|
3824
4142
|
if (pid === null) {
|
|
3825
|
-
const timer = setTimeout(drop,
|
|
4143
|
+
const timer = setTimeout(drop, windowMs);
|
|
3826
4144
|
timer.unref?.();
|
|
3827
4145
|
this.graceTimer = timer;
|
|
3828
4146
|
return;
|
|
@@ -3866,6 +4184,24 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
3866
4184
|
if (!(pid !== null && !isAlive(pid)) && !this.awaitingReply && this.outlining() === null) this.armGrace("repolling");
|
|
3867
4185
|
this.notify();
|
|
3868
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
|
+
}
|
|
3869
4205
|
enqueueThreads(threadIds) {
|
|
3870
4206
|
const bucket = this.bucket();
|
|
3871
4207
|
for (const id of threadIds) bucket.threads.add(id);
|
|
@@ -4391,7 +4727,7 @@ function threadsFromGithub(pr, imported, files) {
|
|
|
4391
4727
|
* body, or a conversation comment.
|
|
4392
4728
|
*/
|
|
4393
4729
|
function githubPosition(anchor, body, files) {
|
|
4394
|
-
if (anchor.kind === "changeset") return null;
|
|
4730
|
+
if (anchor.kind === "changeset" || anchor.kind === "layer") return null;
|
|
4395
4731
|
if (anchor.kind === "file") return {
|
|
4396
4732
|
draft: {
|
|
4397
4733
|
path: anchor.path,
|
|
@@ -4472,6 +4808,37 @@ function planPublicLeg(threads, files) {
|
|
|
4472
4808
|
resolves
|
|
4473
4809
|
};
|
|
4474
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
|
+
}
|
|
4475
4842
|
/** The dialog's rows: what will post, in words. */
|
|
4476
4843
|
function describeLeg(leg) {
|
|
4477
4844
|
return {
|
|
@@ -4480,7 +4847,8 @@ function describeLeg(leg) {
|
|
|
4480
4847
|
anchor: describeAnchor(d.thread.anchor),
|
|
4481
4848
|
text: d.text,
|
|
4482
4849
|
downgraded: d.downgraded,
|
|
4483
|
-
conversation: d.position === null
|
|
4850
|
+
conversation: d.position === null,
|
|
4851
|
+
...d.thread.origin ? { fromAgent: { edited: d.thread.origin.edited } } : {}
|
|
4484
4852
|
})),
|
|
4485
4853
|
replies: leg.replies.map((r) => ({
|
|
4486
4854
|
id: r.thread.id,
|
|
@@ -5001,21 +5369,67 @@ var ReviewStore = class {
|
|
|
5001
5369
|
state: "open",
|
|
5002
5370
|
...options.audience === "pr" ? { audience: "pr" } : intent ? { intent } : {},
|
|
5003
5371
|
...options.parentId ? { parentId: options.parentId } : {},
|
|
5372
|
+
...options.audience === "pr" && options.origin ? { origin: options.origin } : {},
|
|
5004
5373
|
codeContext: capture?.codeContext ?? null,
|
|
5005
5374
|
...capture?.anchored ? { anchored: capture.anchored } : {},
|
|
5375
|
+
...capture?.anchoredLayer ? { anchoredLayer: capture.anchoredLayer } : {},
|
|
5006
5376
|
codeChanged: false,
|
|
5007
5377
|
messages: [{
|
|
5008
5378
|
id: randomUUID(),
|
|
5009
5379
|
author,
|
|
5010
5380
|
text,
|
|
5011
5381
|
at: now,
|
|
5012
|
-
...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {}
|
|
5382
|
+
...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {},
|
|
5383
|
+
...author === "agent" && options.prComment ? { prComment: { text: options.prComment } } : {}
|
|
5013
5384
|
}],
|
|
5014
5385
|
createdAt: now,
|
|
5015
5386
|
updatedAt: now
|
|
5016
5387
|
});
|
|
5017
5388
|
}
|
|
5018
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
|
+
/**
|
|
5019
5433
|
* The closing note of a Finish round, as a thread. Anchored to the changeset
|
|
5020
5434
|
* and opened in the reviewer's voice, so it travels the ordinary path: the
|
|
5021
5435
|
* agent replies to it with `diffo reply`, the queue counts it as feedback
|
|
@@ -5057,14 +5471,15 @@ var ReviewStore = class {
|
|
|
5057
5471
|
* the reviewer raced in since — appended at the end, it would render as an
|
|
5058
5472
|
* answer to a comment it never saw, and the raced comment would stop reading
|
|
5059
5473
|
* as "waiting on the agent" even though it still is. */
|
|
5060
|
-
addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply) {
|
|
5474
|
+
addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply, prComment) {
|
|
5061
5475
|
return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
|
|
5062
5476
|
const message = {
|
|
5063
5477
|
id: randomUUID(),
|
|
5064
5478
|
author,
|
|
5065
5479
|
text,
|
|
5066
5480
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5067
|
-
...author === "agent" && suggestedReply ? { suggestedReply } : {}
|
|
5481
|
+
...author === "agent" && suggestedReply ? { suggestedReply } : {},
|
|
5482
|
+
...author === "agent" && prComment ? { prComment: { text: prComment } } : {}
|
|
5068
5483
|
};
|
|
5069
5484
|
const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
|
|
5070
5485
|
const { awaitingFollowUp: _promised, ...settled } = thread;
|
|
@@ -5180,13 +5595,14 @@ var ReviewStore = class {
|
|
|
5180
5595
|
}));
|
|
5181
5596
|
}
|
|
5182
5597
|
removeThread(threadId) {
|
|
5183
|
-
const
|
|
5184
|
-
if (
|
|
5598
|
+
const removed = this.state.threads.find((t) => t.id === threadId);
|
|
5599
|
+
if (!removed) return false;
|
|
5185
5600
|
this.state = {
|
|
5186
5601
|
...this.state,
|
|
5187
|
-
threads
|
|
5602
|
+
threads: this.state.threads.filter((t) => t.id !== threadId)
|
|
5188
5603
|
};
|
|
5189
5604
|
this.commit();
|
|
5605
|
+
if (removed.origin) this.restorePrComment(removed.origin.threadId, removed.origin.messageId);
|
|
5190
5606
|
return true;
|
|
5191
5607
|
}
|
|
5192
5608
|
/**
|
|
@@ -5229,7 +5645,8 @@ var ReviewStore = class {
|
|
|
5229
5645
|
const { layersSuggested: _suggested, ...rest } = this.state;
|
|
5230
5646
|
this.state = {
|
|
5231
5647
|
...rest,
|
|
5232
|
-
layers
|
|
5648
|
+
layers,
|
|
5649
|
+
threads: reconcileLayerThreads(rest.threads, layers)
|
|
5233
5650
|
};
|
|
5234
5651
|
this.commit();
|
|
5235
5652
|
return layers;
|
|
@@ -5712,9 +6129,32 @@ function migrateLegacySuggestion(value, now) {
|
|
|
5712
6129
|
updatedAt: at
|
|
5713
6130
|
};
|
|
5714
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
|
+
}
|
|
5715
6146
|
function parseAnchor(value) {
|
|
5716
6147
|
if (typeof value !== "object" || value === null) return null;
|
|
5717
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
|
+
}
|
|
5718
6158
|
if (anchor.kind === "hunk") {
|
|
5719
6159
|
if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
|
|
5720
6160
|
if (anchor.side !== "old" && anchor.side !== "new") return null;
|
|
@@ -5747,6 +6187,53 @@ function normalizeAnchored(value) {
|
|
|
5747
6187
|
text: a.text
|
|
5748
6188
|
};
|
|
5749
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
|
+
}
|
|
5750
6237
|
function normalizeThread(value, now) {
|
|
5751
6238
|
if (typeof value !== "object" || value === null) return null;
|
|
5752
6239
|
const t = value;
|
|
@@ -5754,6 +6241,7 @@ function normalizeThread(value, now) {
|
|
|
5754
6241
|
const anchor = parseAnchor(t.anchor);
|
|
5755
6242
|
if (!anchor) return null;
|
|
5756
6243
|
const anchored = normalizeAnchored(t.anchored);
|
|
6244
|
+
const anchoredLayer = normalizeAnchoredLayer(t.anchoredLayer);
|
|
5757
6245
|
if (!Array.isArray(t.messages)) return null;
|
|
5758
6246
|
const messages = [];
|
|
5759
6247
|
for (const m of t.messages) {
|
|
@@ -5770,6 +6258,7 @@ function normalizeThread(value, now) {
|
|
|
5770
6258
|
at: typeof msg.at === "string" ? msg.at : now,
|
|
5771
6259
|
...typeof msg.durationMs === "number" ? { durationMs: msg.durationMs } : {},
|
|
5772
6260
|
...msg.author === "agent" && typeof msg.suggestedReply === "string" && msg.suggestedReply ? { suggestedReply: msg.suggestedReply } : {},
|
|
6261
|
+
...msg.author === "agent" && normalizePrComment(msg.prComment) ? { prComment: normalizePrComment(msg.prComment) } : {},
|
|
5773
6262
|
...typeof msg.editedAt === "string" ? { editedAt: msg.editedAt } : {},
|
|
5774
6263
|
...github ? { github } : {}
|
|
5775
6264
|
});
|
|
@@ -5783,10 +6272,12 @@ function normalizeThread(value, now) {
|
|
|
5783
6272
|
...t.audience === "pr" ? { audience: "pr" } : {},
|
|
5784
6273
|
...typeof t.parentId === "string" && t.parentId !== "" ? { parentId: t.parentId } : {},
|
|
5785
6274
|
...github ? { github } : {},
|
|
6275
|
+
...t.audience === "pr" && normalizeOrigin(t.origin) ? { origin: normalizeOrigin(t.origin) } : {},
|
|
5786
6276
|
...queued ? { queued } : {},
|
|
5787
6277
|
...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
|
|
5788
6278
|
codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
|
|
5789
6279
|
...anchored ? { anchored } : {},
|
|
6280
|
+
...anchoredLayer ? { anchoredLayer } : {},
|
|
5790
6281
|
codeChanged: t.codeChanged === true,
|
|
5791
6282
|
...t.unanswered === true ? { unanswered: true } : {},
|
|
5792
6283
|
...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
|
|
@@ -6662,15 +7153,27 @@ function createApp(ctx, store, review, queue) {
|
|
|
6662
7153
|
"light",
|
|
6663
7154
|
"dark"
|
|
6664
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
|
+
};
|
|
6665
7161
|
app.get("/api/settings", (c) => {
|
|
6666
|
-
|
|
6667
|
-
|
|
7162
|
+
return c.json({
|
|
7163
|
+
theme: stored("theme", THEME_VALUES),
|
|
7164
|
+
critter: stored("critter", CRITTER_VALUES)
|
|
7165
|
+
});
|
|
6668
7166
|
});
|
|
6669
7167
|
app.put("/api/settings", async (c) => {
|
|
6670
7168
|
if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
|
|
6671
|
-
const
|
|
6672
|
-
|
|
6673
|
-
|
|
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);
|
|
6674
7177
|
return c.json({ ok: true });
|
|
6675
7178
|
});
|
|
6676
7179
|
const reviewKind = () => reviewKindOf(ctx.spec, ctx.pr !== void 0);
|
|
@@ -6707,6 +7210,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
6707
7210
|
const promptCtx = (...excludeThreadIds) => ({
|
|
6708
7211
|
repo: repoInfo(),
|
|
6709
7212
|
changeset: store?.get() ?? null,
|
|
7213
|
+
...ctx.pr && review ? { voice: review.get().threads } : {},
|
|
6710
7214
|
siblings: review?.get().threads.filter((t) => !excludeThreadIds.includes(t.id) && (t.state === "open" || t.state === "sent")) ?? []
|
|
6711
7215
|
});
|
|
6712
7216
|
const deliverThreads = (threadIds) => {
|
|
@@ -6750,20 +7254,67 @@ function createApp(ctx, store, review, queue) {
|
|
|
6750
7254
|
const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
|
|
6751
7255
|
const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
|
|
6752
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);
|
|
6753
7259
|
const capture = store ? captureAnchor(store.get(), anchor) : null;
|
|
6754
|
-
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
|
+
}));
|
|
6755
7264
|
}
|
|
6756
7265
|
const anchor = parseAnchor(body?.anchor);
|
|
6757
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
|
+
}
|
|
6758
7272
|
const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
|
|
6759
7273
|
const audience = body?.audience === "pr" ? "pr" : void 0;
|
|
6760
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);
|
|
6761
7276
|
const parentId = typeof body?.parentId === "string" && review.get().threads.some((t) => t.id === body.parentId) ? body.parentId : void 0;
|
|
6762
|
-
|
|
6763
|
-
|
|
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", {
|
|
6764
7291
|
...audience ? { audience } : {},
|
|
6765
|
-
...parentId ? { parentId } : {}
|
|
6766
|
-
|
|
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);
|
|
6767
7318
|
});
|
|
6768
7319
|
app.post("/api/review/layers", async (c) => {
|
|
6769
7320
|
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
@@ -6800,9 +7351,11 @@ function createApp(ctx, store, review, queue) {
|
|
|
6800
7351
|
if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
|
|
6801
7352
|
queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
|
|
6802
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);
|
|
6803
7356
|
const waitedMs = queue?.agentReplied(id, more) ?? null;
|
|
6804
7357
|
const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
|
|
6805
|
-
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);
|
|
6806
7359
|
if (!thread) return c.json({ error: "no such thread" }, 404);
|
|
6807
7360
|
if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
|
|
6808
7361
|
return c.json({
|
|
@@ -6942,7 +7495,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
6942
7495
|
...note !== void 0 ? { note } : {}
|
|
6943
7496
|
};
|
|
6944
7497
|
};
|
|
6945
|
-
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));
|
|
6946
7499
|
const flushableIds = (threads) => new Set(activeThreads(threads).filter((t) => !untouchedAgentVoice(t)).map((t) => t.id));
|
|
6947
7500
|
const projectFinish = (threads) => {
|
|
6948
7501
|
const flushable = flushableIds(threads);
|
|
@@ -7004,6 +7557,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
7004
7557
|
}, coverage),
|
|
7005
7558
|
...leg ? { public: {
|
|
7006
7559
|
...describeLeg(leg),
|
|
7560
|
+
undecidedSuggestions: before.filter((t) => liveSuggestion(t) !== null).length,
|
|
7007
7561
|
canApprove: pr ? !pr.viewer.isAuthor : true,
|
|
7008
7562
|
pendingReview: (review.get().pr?.pendingReviewId ?? pr?.viewer.pendingReviewId ?? null) !== null
|
|
7009
7563
|
} } : {}
|
|
@@ -7029,7 +7583,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
7029
7583
|
event,
|
|
7030
7584
|
comments: publicOutcome.posted,
|
|
7031
7585
|
body: coverage.note ?? "",
|
|
7032
|
-
...publicOutcome.url ? { url: publicOutcome.url } : {}
|
|
7586
|
+
...publicOutcome.url ? { url: publicOutcome.url } : {},
|
|
7587
|
+
suggestions: suggestionsSummary(review.get().threads)
|
|
7033
7588
|
});
|
|
7034
7589
|
ctx.pr.refresh?.();
|
|
7035
7590
|
}
|
|
@@ -7212,6 +7767,16 @@ function createApp(ctx, store, review, queue) {
|
|
|
7212
7767
|
}
|
|
7213
7768
|
});
|
|
7214
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
|
+
});
|
|
7215
7780
|
app.post("/api/agent/end", (c) => {
|
|
7216
7781
|
if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
|
|
7217
7782
|
const sessionPid = parseSessionPid(c.req.header("x-diffo-session-pid"));
|
|
@@ -8242,6 +8807,23 @@ async function announceOpen(port) {
|
|
|
8242
8807
|
});
|
|
8243
8808
|
} catch {}
|
|
8244
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
|
+
}
|
|
8245
8827
|
async function fetchChangesetInfo(port) {
|
|
8246
8828
|
try {
|
|
8247
8829
|
return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
|
|
@@ -8433,16 +9015,19 @@ if (command.kind === "reply") {
|
|
|
8433
9015
|
author: "agent",
|
|
8434
9016
|
text: message,
|
|
8435
9017
|
...command.more ? { more: true } : {},
|
|
8436
|
-
...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
|
|
9018
|
+
...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
|
|
9019
|
+
...command.prComment ? { prComment: command.prComment } : {}
|
|
8437
9020
|
});
|
|
8438
9021
|
if (status === 404) fail(`no thread with id '${command.threadId}'`);
|
|
9022
|
+
if (status === 400) fail(`reply refused: ${body?.error ?? "bad request"}`);
|
|
8439
9023
|
if (status !== 200) fail(`reply failed (${status})`);
|
|
8440
9024
|
const thread = body.thread;
|
|
8441
9025
|
console.log(JSON.stringify({
|
|
8442
9026
|
ok: true,
|
|
8443
9027
|
threadId: thread.id,
|
|
8444
9028
|
state: thread.state,
|
|
8445
|
-
|
|
9029
|
+
...command.prComment ? { prComment: "suggested" } : {},
|
|
9030
|
+
next_step: command.more ? ACK_NEXT_STEP.replyMore : command.prComment ? ACK_NEXT_STEP.replyPrComment : ACK_NEXT_STEP.reply
|
|
8446
9031
|
}));
|
|
8447
9032
|
process.exit(0);
|
|
8448
9033
|
}
|
|
@@ -8455,13 +9040,16 @@ if (command.kind === "comment") {
|
|
|
8455
9040
|
file: command.file,
|
|
8456
9041
|
line: command.line,
|
|
8457
9042
|
text: message,
|
|
8458
|
-
...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
|
|
9043
|
+
...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
|
|
9044
|
+
...command.prComment ? { prComment: command.prComment } : {}
|
|
8459
9045
|
});
|
|
9046
|
+
if (status === 400) fail(`comment refused: ${body?.error ?? "bad request"}`);
|
|
8460
9047
|
if (status !== 200) fail(`comment failed (${status})`);
|
|
8461
9048
|
console.log(JSON.stringify({
|
|
8462
9049
|
ok: true,
|
|
8463
9050
|
threadId: body.id,
|
|
8464
|
-
|
|
9051
|
+
...command.prComment ? { prComment: "suggested" } : {},
|
|
9052
|
+
next_step: command.prComment ? ACK_NEXT_STEP.commentPrComment : ACK_NEXT_STEP.comment
|
|
8465
9053
|
}));
|
|
8466
9054
|
process.exit(0);
|
|
8467
9055
|
}
|
|
@@ -8624,6 +9212,7 @@ if (existing !== null && takeOverPort === null) {
|
|
|
8624
9212
|
console.log(prOpen ? "diffo is already watching this pull request" : "diffo is already watching this repo");
|
|
8625
9213
|
await warnSpecMismatch(existing.port, spec);
|
|
8626
9214
|
await announceOpen(existing.port);
|
|
9215
|
+
await announceArrival(existing.port);
|
|
8627
9216
|
console.log(`→ ${url}`);
|
|
8628
9217
|
await printAgentNextStep(existing.port, prOpen?.pr);
|
|
8629
9218
|
process.exit(0);
|
|
@@ -8644,6 +9233,7 @@ if (existing !== null && takeOverPort !== null) {
|
|
|
8644
9233
|
if (!command.foreground) {
|
|
8645
9234
|
const daemonPort = await requireServer(command.port, spec.kind === "branch" ? spec.base : void 0, SRC_STAMP, prOpen ? command.target : void 0);
|
|
8646
9235
|
await announceOpen(daemonPort);
|
|
9236
|
+
await announceArrival(daemonPort);
|
|
8647
9237
|
const url = reviewUrl(daemonPort);
|
|
8648
9238
|
await printChangesetSummary(daemonPort);
|
|
8649
9239
|
console.log(`→ ${url}`);
|