@diffohq/diffo 0.17.2 → 0.18.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/dist/cli.mjs +566 -363
- package/dist/client/assets/{abnfDiagram-YUKMZFIV-DCCl7rag.js → abnfDiagram-WEYOIA3C-B6Y8XL3T.js} +1 -1
- package/dist/client/assets/{arc-CTNHvfmK.js → arc-BMKLX6de.js} +1 -1
- package/dist/client/assets/{architecture-WOLXFQ4H-DedTjxXh.js → architecture-WOLXFQ4H-GCPjgG_6.js} +1 -1
- package/dist/client/assets/{architectureDiagram-47P4ROYG-lUM1bbDm.js → architectureDiagram-ZHVYKT53-D0haekvL.js} +1 -1
- package/dist/client/assets/{blockDiagram-E7TT5TSB-CcPMgsS5.js → blockDiagram-RJAE7QA3-9ozRFq3h.js} +1 -1
- package/dist/client/assets/{c4Diagram-CRA5TL53-bjY81K90.js → c4Diagram-XDPPPE54-3atKbk2-.js} +1 -1
- package/dist/client/assets/channel-CY0_tHjG.js +1 -0
- package/dist/client/assets/{chunk-AW2ZBBNX-DQVomlA0.js → chunk-2E7WHVMD-B-hVLdxW.js} +1 -1
- package/dist/client/assets/{chunk-HTAEGDNF-COsHxO6d.js → chunk-4EMNKR2T-9R_Ywqe-.js} +1 -1
- package/dist/client/assets/{chunk-7TKQ45FW-COALLfSo.js → chunk-55XCU76K-CRfSz7WS.js} +1 -1
- package/dist/client/assets/{chunk-3YJQHVM4-DyXpsW73.js → chunk-7S6XSTFO-jZvVFXd4.js} +1 -1
- package/dist/client/assets/{chunk-E2ZNV5FY-sBUMLz3a.js → chunk-AQDTSKZB-DM1KPnfu.js} +1 -1
- package/dist/client/assets/{chunk-VPRB5NB3-OEYWGbyv.js → chunk-DSNLSHJZ-D-0UEL_k.js} +2 -2
- package/dist/client/assets/chunk-HAGFOTED-C_UwN40v.js +62 -0
- package/dist/client/assets/chunk-HNE4Q36P-Ng5JYsLi.js +1 -0
- package/dist/client/assets/{chunk-EU5HNXII-DynXz8Hb.js → chunk-NQOKRVIF-BGCe7Zmm.js} +1 -1
- package/dist/client/assets/{chunk-4EA7E6EY-DXcHzA4c.js → chunk-UHW7PFBP-Hz5urMmR.js} +1 -1
- package/dist/client/assets/{chunk-KQW6MTUR-D6ZD4OBU.js → chunk-VQ5W62QI-CvIK79s8.js} +1 -1
- package/dist/client/assets/chunk-WQQL4CDK-DP0cdd_-.js +10 -0
- package/dist/client/assets/{chunk-XXDRQBXY-CwVoOjeq.js → chunk-XXDRQBXY-dz3bjW-Z.js} +1 -1
- package/dist/client/assets/{chunk-HJ2JQQFS-BPuBOIfd.js → chunk-YZO67HFG-BGaQ4Np3.js} +1 -1
- package/dist/client/assets/{chunk-DBDB3WZW-BGe0utmm.js → chunk-ZAUX2LTX-CaHpxHrZ.js} +1 -1
- package/dist/client/assets/{chunk-FVRAUYC3-B6UxZYLr.js → chunk-ZCHMQSOR-BQAh24-J.js} +1 -1
- package/dist/client/assets/{classDiagram-v2-K4WV4PDN-Cwjs5Fo7.js → classDiagram-v2-4MYWR3UA-C1uGZilp.js} +1 -1
- package/dist/client/assets/{core-DON0baQC.js → core-DmmPKspM.js} +1 -1
- package/dist/client/assets/{cose-bilkent-JH36ORCC-CanIWrad.js → cose-bilkent-JH36ORCC-CA0uoIGo.js} +1 -1
- package/dist/client/assets/{cynefin-EF2NZ3EQ-BcMV4FxN.js → cynefin-EF2NZ3EQ-D7-HiRmU.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-3GCD6N5R-COPLrt2e.js → cynefinDiagram-WF24UDRP-Ct_qs0cP.js} +1 -1
- package/dist/client/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/client/assets/{dagre-W4DXFKR2-DcO2bm-4.js → dagre-NK4GIZVX-Cffqg6NJ.js} +1 -1
- package/dist/client/assets/{diagram-OVF4WLC6-0c5jMwqP.js → diagram-CBK3MWTC-ByBNedZy.js} +1 -1
- package/dist/client/assets/{diagram-UMYRVEAY-_k3WfcjG.js → diagram-I5XIIV2D-ByMRPZMW.js} +1 -1
- package/dist/client/assets/{diagram-PFPMY2P6-DZEfp0lQ.js → diagram-ODIL2UCW-C0OayomU.js} +1 -1
- package/dist/client/assets/{diagram-ZIFT7M5P-CpFbAbYg.js → diagram-PFBEJUXE-BezcNJg0.js} +1 -1
- package/dist/client/assets/{diagram-YEKJPTXX-D0oEcVM1.js → diagram-RJA3I4VV-Bl3cl2pD.js} +1 -1
- package/dist/client/assets/{diagram-2UJZ2QOL-D9Ko2dkJ.js → diagram-TYUNFSNR-CyGZb5mO.js} +1 -1
- package/dist/client/assets/{dist-BDtfWmIE.js → dist-Uc27UV0T.js} +1 -1
- package/dist/client/assets/{dist-CnRLr4w2.js → dist-YWSBp2hZ.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-VR2GEFS7-BbG7QvtS.js → ebnfDiagram-V4P3TTPT-11k4sRCB.js} +1 -1
- package/dist/client/assets/elk-QONTBCTQ-DM4AV9FF.js +27 -0
- package/dist/client/assets/{engine-oniguruma-5di9SuAb.js → engine-oniguruma-MxRvz1no.js} +1 -1
- package/dist/client/assets/{erDiagram-O2IAPWRE-BZ1E6x8g.js → erDiagram-KYQIGQAA-D38TGssZ.js} +1 -1
- package/dist/client/assets/{eventmodeling-K75KTNOO-6jgX-7pG.js → eventmodeling-K75KTNOO-DF0H3qdx.js} +1 -1
- package/dist/client/assets/flowDiagram-IUGLLWBU-rSziK2Bd.js +1 -0
- package/dist/client/assets/{ganttDiagram-R7TSEDQI-BRYFh2jn.js → ganttDiagram-R7GPNTJY-DXtKy5K6.js} +1 -1
- package/dist/client/assets/{gitGraph-VSP46ZUC-DxTvmQVO.js → gitGraph-VSP46ZUC-BJmPpwux.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-XJZIOB7I-DkJ3V-lX.js → gitGraphDiagram-PDYL7XHY-FwrK2brd.js} +1 -1
- package/dist/client/assets/gn-CAXYAgV-.js +1 -0
- package/dist/client/assets/index-Caehfsn0.css +1 -0
- package/dist/client/assets/index-_VrQDc2L.js +91 -0
- package/dist/client/assets/{info-OHQRW6UA-BKHS23vw.js → info-OHQRW6UA-W1kRy4TV.js} +1 -1
- package/dist/client/assets/{infoDiagram-5W2HQ5XZ-BVCz6PFV.js → infoDiagram-MVDRKAX5-giO2ZEe8.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-K3B6WC7H-B4tLpzvT.js → ishikawaDiagram-HPEHV2GE-DTOlB5LG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-COXZFDF6-DpkzSOyg.js → journeyDiagram-BOSHW7O7-DqENuBSc.js} +1 -1
- package/dist/client/assets/{kanban-definition-P3RFRI5V-BkfcUIAv.js → kanban-definition-FNSA4HC5-CIYIECg_.js} +1 -1
- package/dist/client/assets/katex-B_JcIwqj.js +257 -0
- package/dist/client/assets/{line-CUU9NBUe.js → line-D_x4AjTF.js} +1 -1
- package/dist/client/assets/{linear-Cp1XQoq1.js → linear-CS4Fq7f5.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-Dk_N9wHc.js → mermaid-parser.core-720dQRcD.js} +3 -3
- package/dist/client/assets/{mermaid.core-DFKfv8Jn.js → mermaid.core-DNKhW2d1.js} +5 -5
- package/dist/client/assets/{mindmap-definition-LZFPQGKD-Dgw3CRjk.js → mindmap-definition-6OY57UG3-Tyz7I7bd.js} +11 -9
- package/dist/client/assets/objective-c-D1A_Heim.js +1 -0
- package/dist/client/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/dist/client/assets/{packet-JDAUHWVQ-2Mzusa3F.js → packet-JDAUHWVQ-CIT4RofU.js} +1 -1
- package/dist/client/assets/{pegDiagram-BGZESJAR-DXCIRLqW.js → pegDiagram-ECIUJNBX-BDv64Oqu.js} +1 -1
- package/dist/client/assets/{pie-XZMESJXO-UECjOJM3.js → pie-XZMESJXO-B7TeMgE-.js} +1 -1
- package/dist/client/assets/{pieDiagram-CAPJLFHJ-DnDCb_c1.js → pieDiagram-GNMPY74T-DZ5ISynf.js} +1 -1
- package/dist/client/assets/proto-DB4EqR-F.js +1 -0
- package/dist/client/assets/{quadrantDiagram-GDMTTRAM-DRYlGsWP.js → quadrantDiagram-D57K4MC3-q3ZFhuD4.js} +1 -1
- package/dist/client/assets/{radar-ABXABTNO-D38H1WiB.js → radar-ABXABTNO-DfOTCpQ-.js} +1 -1
- package/dist/client/assets/{railroad-I3PHUGI6-Cdf9S4Mf.js → railroad-I3PHUGI6-DtLPMTjt.js} +1 -1
- package/dist/client/assets/{railroad-abnf-LNEFI6M7-Y5BNc5fo.js → railroad-abnf-LNEFI6M7-DPObEtfh.js} +1 -1
- package/dist/client/assets/{railroad-ebnf-DWYD2UWJ-C2o0Hwuh.js → railroad-ebnf-DWYD2UWJ-g6Y97jri.js} +1 -1
- package/dist/client/assets/{railroad-peg-JR7BVNR7-ByFwOhZy.js → railroad-peg-JR7BVNR7-CfUBWVcL.js} +1 -1
- package/dist/client/assets/{railroadDiagram-SM67HX2B-md_7ESU-.js → railroadDiagram-Y72NRBL7-CKGjfL5n.js} +1 -1
- package/dist/client/assets/{requirementDiagram-X7JNWC4A-DV9e6p3j.js → requirementDiagram-EQYSW5RA-kfSYKuPQ.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-UM26HJRW-CeZSnliY.js → sankeyDiagram-SY24JHO5-Dn5ou0D7.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-ZO4K6R2Y-2AFkjcMu.js → sequenceDiagram-BLEHNHQU-B6Qcw9U_.js} +1 -1
- package/dist/client/assets/{src-WAdTNUSJ.js → src-Bz4qgR7m.js} +1 -1
- package/dist/client/assets/{stateDiagram-v2-TBUQTH76-CHxwC_Ic.js → stateDiagram-v2-A6IESKWZ-B56x9sAq.js} +1 -1
- package/dist/client/assets/swimlanes-B3ENRKFQ-BCQJqf0x.js +1 -0
- package/dist/client/assets/swimlanesDiagram-AYQRV4AK-C1YHgM4T.js +8 -0
- package/dist/client/assets/{timeline-definition-YOQAKGHF-BUIhWEtW.js → timeline-definition-JXYKGLGB-lEItkXBD.js} +1 -1
- package/dist/client/assets/{treeView-D4JQ5SDB-xtykCGnU.js → treeView-D4JQ5SDB-CYZkbtcm.js} +1 -1
- package/dist/client/assets/{treemap-SAJKECNS-C51gkxvB.js → treemap-SAJKECNS-B96tHxAb.js} +1 -1
- package/dist/client/assets/{usecaseDiagram-VIAY4XPW-DG6OBLoV.js → usecaseDiagram-WPOLBNNX-CvTDEhna.js} +1 -1
- package/dist/client/assets/{vennDiagram-BLWOH2XV-BHp7FuOy.js → vennDiagram-Q3Y3IDCW-C2Ix7bQn.js} +1 -1
- package/dist/client/assets/{wardley-7MLQ67FV-D9GUzXWu.js → wardley-7MLQ67FV-EAIVeoto.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YMQ3BMBF-G-sqVoSl.js → wardleyDiagram-IVAIDKWA--Okbz3eY.js} +1 -1
- package/dist/client/assets/{xychartDiagram-TAQBALBS-BURBvnuK.js → xychartDiagram-4URNHDU4-CpPxhDeV.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/dist/client/assets/channel-pdhhFqps.js +0 -1
- package/dist/client/assets/chunk-2BW5OAIV-o5RcgrVm.js +0 -10
- package/dist/client/assets/chunk-NTY3LDVX-BBhs2zfd.js +0 -1
- package/dist/client/assets/chunk-XC4XBNZT-BHfIsy-X.js +0 -62
- package/dist/client/assets/cytoscape.esm-CHBj4VkZ.js +0 -321
- package/dist/client/assets/elk-IJKZMXRS-D9Wm7A4L.js +0 -27
- package/dist/client/assets/flowDiagram-OXPTDLAJ-DTCqNqx8.js +0 -1
- package/dist/client/assets/index-BSqcB6X4.js +0 -93
- package/dist/client/assets/index-ClMx9yVg.css +0 -1
- package/dist/client/assets/katex-DJhrJSbw.js +0 -257
- package/dist/client/assets/swimlanes-N4OXWK64-Cqxh_G4l.js +0 -1
- package/dist/client/assets/swimlanesDiagram-K3J5GTZL-CBFCal5k.js +0 -8
package/dist/cli.mjs
CHANGED
|
@@ -124,6 +124,59 @@ function detectHarness() {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
//#endregion
|
|
127
|
+
//#region src/shared/anchors.ts
|
|
128
|
+
function findHunk$1(files, id) {
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const hunk = file.hunks.find((h) => h.id === id);
|
|
131
|
+
if (hunk) return hunk;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Index of the line numbered `line` on `side`, or -1: a deleted line is not
|
|
135
|
+
* on the new side, an added one not on the old. */
|
|
136
|
+
function indexOfSideLine(lines, side, line) {
|
|
137
|
+
return lines.findIndex((l) => side === "old" ? l.kind !== "add" && l.oldNo === line : l.kind !== "del" && l.newNo === line);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The same hunk, numbered as another diff numbers it. A hunk id is
|
|
141
|
+
* content-addressed — the path and the changed lines, not where they sit — so
|
|
142
|
+
* the hunk a commit introduced and the one the full diff still shows are one
|
|
143
|
+
* id with two sets of line numbers whenever a later commit grew the file
|
|
144
|
+
* above it. A thread anchored in one numbering is renamed into the other by
|
|
145
|
+
* position in the hunk: line k of the hunk here is line k of the hunk there.
|
|
146
|
+
*
|
|
147
|
+
* Unchanged when either diff lacks the hunk, or when the two copies differ in
|
|
148
|
+
* shape (a context line more or less): then the numbers cannot be trusted and
|
|
149
|
+
* the anchor is left as it was, for placement to re-home by line as usual.
|
|
150
|
+
*/
|
|
151
|
+
function translateHunkAnchor(anchor, from, to) {
|
|
152
|
+
const src = findHunk$1(from, anchor.hunkId);
|
|
153
|
+
const dst = findHunk$1(to, anchor.hunkId);
|
|
154
|
+
if (!src || !dst || src === dst) return anchor;
|
|
155
|
+
if (src.lines.length !== dst.lines.length) return anchor;
|
|
156
|
+
if (src.lines.some((l, i) => l.kind !== dst.lines[i].kind)) return anchor;
|
|
157
|
+
const rename = (n) => {
|
|
158
|
+
const i = indexOfSideLine(src.lines, anchor.side, n);
|
|
159
|
+
if (i === -1) return null;
|
|
160
|
+
const l = dst.lines[i];
|
|
161
|
+
return anchor.side === "old" ? l.oldNo : l.newNo;
|
|
162
|
+
};
|
|
163
|
+
const line = rename(anchor.line);
|
|
164
|
+
if (line === null) return anchor;
|
|
165
|
+
if (anchor.endLine === void 0) return line === anchor.line ? anchor : {
|
|
166
|
+
...anchor,
|
|
167
|
+
line
|
|
168
|
+
};
|
|
169
|
+
const endLine = rename(anchor.endLine);
|
|
170
|
+
return endLine === null ? {
|
|
171
|
+
...anchor,
|
|
172
|
+
line
|
|
173
|
+
} : {
|
|
174
|
+
...anchor,
|
|
175
|
+
line,
|
|
176
|
+
endLine
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
127
180
|
//#region src/shared/review.ts
|
|
128
181
|
const THREAD_INTENTS = ["fix", "question"];
|
|
129
182
|
/** A review comment on GitHub tops out at 65 536 characters; a suggestion that
|
|
@@ -260,11 +313,7 @@ function describeAnchor(anchor) {
|
|
|
260
313
|
/** A comment can anchor to a long range; the frozen text keeps its head. */
|
|
261
314
|
const ANCHORED_TEXT_CAP = 10;
|
|
262
315
|
function findHunk(changeset, hunkId) {
|
|
263
|
-
|
|
264
|
-
const hunk = file.hunks.find((h) => h.id === hunkId);
|
|
265
|
-
if (hunk) return hunk;
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
316
|
+
return findHunk$1(changeset.files, hunkId) ?? null;
|
|
268
317
|
}
|
|
269
318
|
/**
|
|
270
319
|
* Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
|
|
@@ -1377,74 +1426,6 @@ function parseLayersInput(raw) {
|
|
|
1377
1426
|
items
|
|
1378
1427
|
};
|
|
1379
1428
|
}
|
|
1380
|
-
//#endregion
|
|
1381
|
-
//#region src/cliArgs.ts
|
|
1382
|
-
const HELP_TEXT = `diffo: review a changeset the way you'd read a book
|
|
1383
|
-
|
|
1384
|
-
Usage: diffo [options] open (or resume) the review for this repo
|
|
1385
|
-
diffo <target> [options] review against a branch, or review a pull request
|
|
1386
|
-
diffo <command> [...] manage the server, or talk to the review
|
|
1387
|
-
diffo help [command] show help for a command
|
|
1388
|
-
|
|
1389
|
-
The target:
|
|
1390
|
-
<branch> everything since forking from that branch (same as --base)
|
|
1391
|
-
<PR URL> a GitHub pull request (also owner/repo#N, #N, or N)
|
|
1392
|
-
(the short forms resolve against this repo's origin)
|
|
1393
|
-
|
|
1394
|
-
For the reviewer:
|
|
1395
|
-
pr <target> Review a pull request (the same as \`diffo <target>\`,
|
|
1396
|
-
and the explicit answer for a pull request of your own;
|
|
1397
|
-
help pr explains the flow)
|
|
1398
|
-
status Show this repo's review server, if one is running
|
|
1399
|
-
(--json for a machine-readable answer)
|
|
1400
|
-
stop Stop this repo's review server
|
|
1401
|
-
clean Remove the worktrees diffo made for pull requests whose
|
|
1402
|
-
review is over (--force takes dirty ones, and ones a
|
|
1403
|
-
running server still serves; --all takes every one)
|
|
1404
|
-
setup Register diffo with the coding agents on this machine
|
|
1405
|
-
(Claude Code, Cursor, VS Code, Copilot, and the shared
|
|
1406
|
-
~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
|
|
1407
|
-
telemetry Show whether anonymous usage data is on, or turn it
|
|
1408
|
-
off / on (telemetry off · telemetry on)
|
|
1409
|
-
|
|
1410
|
-
For the agent (the AI that wrote the change):
|
|
1411
|
-
poll Wait for the reviewer's feedback (blocking long-poll;
|
|
1412
|
-
prints one JSON payload; safe to re-run any time)
|
|
1413
|
-
(--title "<what the change is>" on the first poll names
|
|
1414
|
-
the reviewer's browser tab)
|
|
1415
|
-
reply <threadId> Post a reply to a review thread
|
|
1416
|
-
(--message "<text>", or pipe the text on stdin)
|
|
1417
|
-
comment [<file>] Start a comment thread as the agent: on a line (--line),
|
|
1418
|
-
a file, or (with no file) the whole changeset; the
|
|
1419
|
-
reviewer replies to take it up, or resolves it
|
|
1420
|
-
layers Outline the changeset as steps to read in order
|
|
1421
|
-
(--suggest ["why"] flags it at open; --json '<Layer[]>'
|
|
1422
|
-
or --stdin posts the list, replacing the last one)
|
|
1423
|
-
end Detach from the review politely
|
|
1424
|
-
help agent The agent's whole protocol on one page
|
|
1425
|
-
help guide The guide comment — the map of the change — with an example
|
|
1426
|
-
|
|
1427
|
-
Options (for opening the review):
|
|
1428
|
-
--base <branch> Review everything since forking from <branch>
|
|
1429
|
-
(default: working tree vs HEAD)
|
|
1430
|
-
-p, --port <port> Port to serve on (default: the port this repo last used,
|
|
1431
|
-
else the first free one)
|
|
1432
|
-
--no-open Don't open the browser automatically
|
|
1433
|
-
--foreground Run the server in this terminal instead of leaving a
|
|
1434
|
-
background one behind (Ctrl-C stops it)
|
|
1435
|
-
-v, --version Show version
|
|
1436
|
-
-h, --help Show this help
|
|
1437
|
-
|
|
1438
|
-
Examples:
|
|
1439
|
-
diffo open the review for this repo in the browser
|
|
1440
|
-
diffo main review everything since forking from main
|
|
1441
|
-
diffo https://github.com/o/r/pull/482
|
|
1442
|
-
review pull request #482 with your agent as copilot
|
|
1443
|
-
diffo status is a server running here, and where?
|
|
1444
|
-
diffo reply t-3 -m "fixed" answer a review thread (agent side)
|
|
1445
|
-
|
|
1446
|
-
Feedback lives in the review, not in any process: diffo, poll, status, stop,
|
|
1447
|
-
and setup are all safe to re-run after an interruption.`;
|
|
1448
1429
|
const VERB_HELP = {
|
|
1449
1430
|
agent: `diffo help agent: the agent's whole protocol on one page
|
|
1450
1431
|
|
|
@@ -1796,7 +1777,7 @@ Example:
|
|
|
1796
1777
|
diffo telemetry off`
|
|
1797
1778
|
};
|
|
1798
1779
|
function helpFor(topic) {
|
|
1799
|
-
return topic && VERB_HELP[topic] ||
|
|
1780
|
+
return topic && VERB_HELP[topic] || "diffo: review a changeset the way you'd read a book\n\nUsage: diffo [options] open (or resume) the review for this repo\n diffo <target> [options] review against a branch, or review a pull request\n diffo <command> [...] manage the server, or talk to the review\n diffo help [command] show help for a command\n\nThe target:\n <branch> everything since forking from that branch (same as --base)\n <PR URL> a GitHub pull request (also owner/repo#N, #N, or N)\n (the short forms resolve against this repo's origin)\n\nFor the reviewer:\n pr <target> Review a pull request (the same as `diffo <target>`,\n and the explicit answer for a pull request of your own;\n help pr explains the flow)\n status Show this repo's review server, if one is running\n (--json for a machine-readable answer)\n stop Stop this repo's review server\n clean Remove the worktrees diffo made for pull requests whose\n review is over (--force takes dirty ones, and ones a\n running server still serves; --all takes every one)\n setup Register diffo with the coding agents on this machine\n (Claude Code, Cursor, VS Code, Copilot, and the shared\n ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)\n telemetry Show whether anonymous usage data is on, or turn it\n off / on (telemetry off · telemetry on)\n\nFor the agent (the AI that wrote the change):\n poll Wait for the reviewer's feedback (blocking long-poll;\n prints one JSON payload; safe to re-run any time)\n (--title \"<what the change is>\" on the first poll names\n the reviewer's browser tab)\n reply <threadId> Post a reply to a review thread\n (--message \"<text>\", or pipe the text on stdin)\n comment [<file>] Start a comment thread as the agent: on a line (--line),\n a file, or (with no file) the whole changeset; the\n reviewer replies to take it up, or resolves it\n layers Outline the changeset as steps to read in order\n (--suggest [\"why\"] flags it at open; --json '<Layer[]>'\n or --stdin posts the list, replacing the last one)\n end Detach from the review politely\n help agent The agent's whole protocol on one page\n help guide The guide comment — the map of the change — with an example\n\nOptions (for opening the review):\n --base <branch> Review everything since forking from <branch>\n (default: working tree vs HEAD)\n -p, --port <port> Port to serve on (default: the port this repo last used,\n else the first free one)\n --no-open Don't open the browser automatically\n --foreground Run the server in this terminal instead of leaving a\n background one behind (Ctrl-C stops it)\n -v, --version Show version\n -h, --help Show this help\n\nExamples:\n diffo open the review for this repo in the browser\n diffo main review everything since forking from main\n diffo https://github.com/o/r/pull/482\n review pull request #482 with your agent as copilot\n diffo status is a server running here, and where?\n diffo reply t-3 -m \"fixed\" answer a review thread (agent side)\n\nFeedback lives in the review, not in any process: diffo, poll, status, stop,\nand setup are all safe to re-run after an interruption.";
|
|
1800
1781
|
}
|
|
1801
1782
|
const VERBS = /* @__PURE__ */ new Set([
|
|
1802
1783
|
"poll",
|
|
@@ -3390,6 +3371,86 @@ async function getFullDiffAsync(root, spec) {
|
|
|
3390
3371
|
const [raw, untracked] = await Promise.all([getRawDiffAsync(root, spec), getUntrackedDiffAsync(root)]);
|
|
3391
3372
|
return [raw, untracked].filter(Boolean).join("\n");
|
|
3392
3373
|
}
|
|
3374
|
+
const COMMIT_FIELDS = [
|
|
3375
|
+
"%H",
|
|
3376
|
+
"%h",
|
|
3377
|
+
"%an",
|
|
3378
|
+
"%aI",
|
|
3379
|
+
"%s",
|
|
3380
|
+
"%b"
|
|
3381
|
+
].join("%x1f");
|
|
3382
|
+
/** `3 files changed, 10 insertions(+), 2 deletions(-)` — any part may be absent. */
|
|
3383
|
+
function parseShortstat(line) {
|
|
3384
|
+
const n = (re) => Number.parseInt(re.exec(line)?.[1] ?? "0", 10);
|
|
3385
|
+
return {
|
|
3386
|
+
files: n(/(\d+) files? changed/),
|
|
3387
|
+
additions: n(/(\d+) insertions?\(\+\)/),
|
|
3388
|
+
deletions: n(/(\d+) deletions?\(-\)/)
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
/**
|
|
3392
|
+
* The commits a branch review spans, oldest first. A working-tree review has
|
|
3393
|
+
* none: its changeset is not made of commits. Merges are skipped — what they
|
|
3394
|
+
* bring in is the base branch, which this review is not about.
|
|
3395
|
+
*/
|
|
3396
|
+
async function branchStart(root, base) {
|
|
3397
|
+
try {
|
|
3398
|
+
return (await gitAsync(root, [
|
|
3399
|
+
"merge-base",
|
|
3400
|
+
base,
|
|
3401
|
+
"HEAD"
|
|
3402
|
+
])).trim();
|
|
3403
|
+
} catch {
|
|
3404
|
+
throw new MissingBaseError(base);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
/** Just the shas of `listCommitsAsync`, in the same order: a `rev-list`, no
|
|
3408
|
+
* per-commit stats, so a route that only needs to know whether a sha is in the
|
|
3409
|
+
* changeset does not pay for diffing every commit. */
|
|
3410
|
+
async function listCommitShasAsync(root, spec) {
|
|
3411
|
+
if (spec.kind !== "branch") return [];
|
|
3412
|
+
return (await gitAsync(root, [
|
|
3413
|
+
"rev-list",
|
|
3414
|
+
"--reverse",
|
|
3415
|
+
"--no-merges",
|
|
3416
|
+
`${await branchStart(root, spec.base)}..HEAD`
|
|
3417
|
+
])).split("\n").filter((sha) => sha !== "");
|
|
3418
|
+
}
|
|
3419
|
+
async function listCommitsAsync(root, spec) {
|
|
3420
|
+
if (spec.kind !== "branch") return [];
|
|
3421
|
+
const mergeBase = await branchStart(root, spec.base);
|
|
3422
|
+
return (await gitAsync(root, [
|
|
3423
|
+
"log",
|
|
3424
|
+
"--reverse",
|
|
3425
|
+
"--no-merges",
|
|
3426
|
+
"--no-color",
|
|
3427
|
+
`--format=%x1e${COMMIT_FIELDS}%x1f`,
|
|
3428
|
+
"--shortstat",
|
|
3429
|
+
`${mergeBase}..HEAD`
|
|
3430
|
+
])).split("").filter((chunk) => chunk.trim() !== "").map((chunk) => {
|
|
3431
|
+
const [sha = "", short = "", author = "", at = "", subject = "", body = "", stat = ""] = chunk.split("");
|
|
3432
|
+
return {
|
|
3433
|
+
sha,
|
|
3434
|
+
short,
|
|
3435
|
+
author,
|
|
3436
|
+
at,
|
|
3437
|
+
subject,
|
|
3438
|
+
body: body.trim(),
|
|
3439
|
+
stats: parseShortstat(stat)
|
|
3440
|
+
};
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
/** One commit's own patch, against its first parent — or against nothing, for
|
|
3444
|
+
* a root commit, which `git show` handles where `sha^` would not resolve. */
|
|
3445
|
+
async function getCommitDiffAsync(root, sha) {
|
|
3446
|
+
return gitAsync(root, [
|
|
3447
|
+
"show",
|
|
3448
|
+
"--format=",
|
|
3449
|
+
"--patch",
|
|
3450
|
+
...DIFF_FLAGS,
|
|
3451
|
+
sha
|
|
3452
|
+
]);
|
|
3453
|
+
}
|
|
3393
3454
|
function isSafeRepoPath(root, relPath) {
|
|
3394
3455
|
return resolve(root, relPath).startsWith(resolve(root) + sep);
|
|
3395
3456
|
}
|
|
@@ -3980,86 +4041,396 @@ const VERSION = (() => {
|
|
|
3980
4041
|
return "0.0.0";
|
|
3981
4042
|
})();
|
|
3982
4043
|
//#endregion
|
|
3983
|
-
//#region src/
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
4044
|
+
//#region src/shared/types.ts
|
|
4045
|
+
/** The working-tree edits a branch review also carries, listed after the
|
|
4046
|
+
* commits as one more step to read. Not a sha, so it can never collide with one. */
|
|
4047
|
+
const UNCOMMITTED = "uncommitted";
|
|
4048
|
+
//#endregion
|
|
4049
|
+
//#region src/server/parse.ts
|
|
4050
|
+
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4051
|
+
".png",
|
|
4052
|
+
".jpg",
|
|
4053
|
+
".jpeg",
|
|
4054
|
+
".gif",
|
|
4055
|
+
".webp",
|
|
4056
|
+
".bmp",
|
|
4057
|
+
".svg",
|
|
4058
|
+
".ico",
|
|
4059
|
+
".avif"
|
|
4060
|
+
]);
|
|
4061
|
+
function isImagePath(path) {
|
|
4062
|
+
const dot = path.lastIndexOf(".");
|
|
4063
|
+
return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
3996
4064
|
}
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
stallTimer = null;
|
|
4011
|
-
betweenPolls = false;
|
|
4012
|
-
/** Why the agent is parked between polls: a reply just landed, its poll
|
|
4013
|
-
* ended on its own and the re-poll is expected, or it opened the review and
|
|
4014
|
-
* is still orienting itself before its first poll. */
|
|
4015
|
-
parked = "replied";
|
|
4016
|
-
graceTimer = null;
|
|
4017
|
-
lastDetach = null;
|
|
4018
|
-
since = Date.now();
|
|
4019
|
-
lastDetail = {
|
|
4020
|
-
state: "waiting",
|
|
4021
|
-
reason: "no-agent"
|
|
4065
|
+
/** Content-addressed hunk identity: the hash covers the file path and the changed
|
|
4066
|
+
* lines only — not line numbers, not context. Editing the hunk itself mints a new
|
|
4067
|
+
* id; the occurrence index separates identical hunks within one file. */
|
|
4068
|
+
function hunkId(path, lines, occurrence) {
|
|
4069
|
+
const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
|
|
4070
|
+
return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
|
|
4071
|
+
}
|
|
4072
|
+
function parseSectionMeta(section) {
|
|
4073
|
+
return {
|
|
4074
|
+
renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
|
|
4075
|
+
renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
|
|
4076
|
+
binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
|
|
4077
|
+
symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
|
|
4022
4078
|
};
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4079
|
+
}
|
|
4080
|
+
function splitSections(patch) {
|
|
4081
|
+
if (!patch.trim()) return [];
|
|
4082
|
+
const sections = [];
|
|
4083
|
+
const lines = patch.split("\n");
|
|
4084
|
+
let current = null;
|
|
4085
|
+
for (const line of lines) if (line.startsWith("diff --git ")) {
|
|
4086
|
+
if (current) sections.push(current.join("\n"));
|
|
4087
|
+
current = [line];
|
|
4088
|
+
} else if (current) current.push(line);
|
|
4089
|
+
if (current) sections.push(current.join("\n"));
|
|
4090
|
+
return sections;
|
|
4091
|
+
}
|
|
4092
|
+
/**
|
|
4093
|
+
* `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
|
|
4094
|
+
* the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
|
|
4095
|
+
* guess at what you are inside.
|
|
4096
|
+
*/
|
|
4097
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
4098
|
+
/**
|
|
4099
|
+
* Read one hunk body, starting at the line after its header. The declared counts
|
|
4100
|
+
* are authoritative: consume exactly that many old and new lines and stop, rather
|
|
4101
|
+
* than reading up to the next `@@` — git's own rule, and what keeps the blank line
|
|
4102
|
+
* every patch ends with out of the last hunk.
|
|
4103
|
+
*/
|
|
4104
|
+
function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
|
|
4105
|
+
const out = [];
|
|
4106
|
+
let oldNo = oldStart;
|
|
4107
|
+
let newNo = newStart;
|
|
4108
|
+
let remainingOld = oldCount;
|
|
4109
|
+
let remainingNew = newCount;
|
|
4110
|
+
let i = start;
|
|
4111
|
+
while (remainingOld > 0 || remainingNew > 0) {
|
|
4112
|
+
const line = lines[i];
|
|
4113
|
+
if (line === void 0) break;
|
|
4114
|
+
if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
|
|
4115
|
+
i++;
|
|
4116
|
+
if (line.startsWith("\\")) continue;
|
|
4117
|
+
if (line.startsWith("+")) {
|
|
4118
|
+
out.push({
|
|
4119
|
+
kind: "add",
|
|
4120
|
+
oldNo: null,
|
|
4121
|
+
newNo: newNo++,
|
|
4122
|
+
text: line.slice(1)
|
|
4123
|
+
});
|
|
4124
|
+
remainingNew--;
|
|
4125
|
+
} else if (line.startsWith("-")) {
|
|
4126
|
+
out.push({
|
|
4127
|
+
kind: "del",
|
|
4128
|
+
oldNo: oldNo++,
|
|
4129
|
+
newNo: null,
|
|
4130
|
+
text: line.slice(1)
|
|
4131
|
+
});
|
|
4132
|
+
remainingOld--;
|
|
4133
|
+
} else {
|
|
4134
|
+
out.push({
|
|
4135
|
+
kind: "context",
|
|
4136
|
+
oldNo: oldNo++,
|
|
4137
|
+
newNo: newNo++,
|
|
4138
|
+
text: line.slice(1)
|
|
4139
|
+
});
|
|
4140
|
+
remainingOld--;
|
|
4141
|
+
remainingNew--;
|
|
4142
|
+
}
|
|
4038
4143
|
}
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4144
|
+
return {
|
|
4145
|
+
lines: out,
|
|
4146
|
+
next: i
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
function parseHunks(lines) {
|
|
4150
|
+
const hunks = [];
|
|
4151
|
+
let i = 0;
|
|
4152
|
+
while (i < lines.length) {
|
|
4153
|
+
const header = lines[i]?.match(HUNK_HEADER);
|
|
4154
|
+
if (!header) {
|
|
4155
|
+
i++;
|
|
4156
|
+
continue;
|
|
4157
|
+
}
|
|
4158
|
+
const oldStart = Number(header[1]);
|
|
4159
|
+
const newStart = Number(header[3]);
|
|
4160
|
+
const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
|
|
4161
|
+
const context = (header[5] ?? "").trim();
|
|
4162
|
+
hunks.push({
|
|
4163
|
+
oldStart,
|
|
4164
|
+
newStart,
|
|
4165
|
+
context: context === "" ? null : context,
|
|
4166
|
+
lines: body.lines
|
|
4167
|
+
});
|
|
4168
|
+
i = body.next;
|
|
4042
4169
|
}
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
this.clearBatchWatch();
|
|
4059
|
-
for (const listener of this.batchListeners) listener(closed);
|
|
4170
|
+
return hunks;
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* `--- a/x` / `+++ b/x`, read only from the part of the section before the first
|
|
4174
|
+
* `@@`. That bound is load-bearing: inside a hunk body an added line starting with
|
|
4175
|
+
* `++` is spelled `+++ …`, so scanning the whole section would read file content as
|
|
4176
|
+
* a path header.
|
|
4177
|
+
*/
|
|
4178
|
+
function parseSectionPaths(section, lines) {
|
|
4179
|
+
let from = null;
|
|
4180
|
+
let to = null;
|
|
4181
|
+
for (const line of lines) {
|
|
4182
|
+
if (line.startsWith("@@")) break;
|
|
4183
|
+
if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
|
|
4184
|
+
else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
|
|
4060
4185
|
}
|
|
4061
|
-
|
|
4062
|
-
|
|
4186
|
+
return {
|
|
4187
|
+
from,
|
|
4188
|
+
to,
|
|
4189
|
+
isNew: from === "/dev/null" || /^new file mode /m.test(section),
|
|
4190
|
+
isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
function diffPath(raw) {
|
|
4194
|
+
const path = raw.split(" ", 1)[0] ?? raw;
|
|
4195
|
+
return path === "/dev/null" ? path : stripAB(path);
|
|
4196
|
+
}
|
|
4197
|
+
function parsePatch(patch) {
|
|
4198
|
+
const files = [];
|
|
4199
|
+
for (const section of splitSections(patch)) {
|
|
4200
|
+
const sectionLines = section.split("\n");
|
|
4201
|
+
const meta = parseSectionMeta(section);
|
|
4202
|
+
const paths = parseSectionPaths(section, sectionLines);
|
|
4203
|
+
let path;
|
|
4204
|
+
let oldPath = null;
|
|
4205
|
+
let status;
|
|
4206
|
+
if (meta.renamedTo) {
|
|
4207
|
+
path = meta.renamedTo;
|
|
4208
|
+
oldPath = meta.renamedFrom;
|
|
4209
|
+
status = "renamed";
|
|
4210
|
+
} else if (paths.isNew) {
|
|
4211
|
+
path = paths.to ?? headerPath(section);
|
|
4212
|
+
status = "added";
|
|
4213
|
+
} else if (paths.isDeleted) {
|
|
4214
|
+
path = paths.from ?? headerPath(section);
|
|
4215
|
+
status = "deleted";
|
|
4216
|
+
} else if (paths.to !== null && paths.to !== "/dev/null") {
|
|
4217
|
+
path = paths.to;
|
|
4218
|
+
status = "modified";
|
|
4219
|
+
} else {
|
|
4220
|
+
path = headerPath(section);
|
|
4221
|
+
status = "modified";
|
|
4222
|
+
}
|
|
4223
|
+
const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
|
|
4224
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4225
|
+
const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
|
|
4226
|
+
const lines = hunk.lines;
|
|
4227
|
+
const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
|
|
4228
|
+
const occurrence = seen.get(contentKey) ?? 0;
|
|
4229
|
+
seen.set(contentKey, occurrence + 1);
|
|
4230
|
+
return {
|
|
4231
|
+
id: hunkId(path, lines, occurrence),
|
|
4232
|
+
path,
|
|
4233
|
+
oldStart: hunk.oldStart,
|
|
4234
|
+
newStart: hunk.newStart,
|
|
4235
|
+
lines,
|
|
4236
|
+
...hunk.context === null ? {} : { context: hunk.context }
|
|
4237
|
+
};
|
|
4238
|
+
});
|
|
4239
|
+
files.push({
|
|
4240
|
+
path,
|
|
4241
|
+
oldPath,
|
|
4242
|
+
status,
|
|
4243
|
+
kind,
|
|
4244
|
+
staged: false,
|
|
4245
|
+
hunks
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
return files;
|
|
4249
|
+
}
|
|
4250
|
+
function stripAB(p) {
|
|
4251
|
+
return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
|
|
4252
|
+
}
|
|
4253
|
+
function headerPath(section) {
|
|
4254
|
+
const header = section.split("\n", 1)[0] ?? "";
|
|
4255
|
+
return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
|
|
4256
|
+
}
|
|
4257
|
+
//#endregion
|
|
4258
|
+
//#region src/server/commits.ts
|
|
4259
|
+
function stats$2(files) {
|
|
4260
|
+
let additions = 0;
|
|
4261
|
+
let deletions = 0;
|
|
4262
|
+
for (const file of files) for (const hunk of file.hunks) for (const line of hunk.lines) if (line.kind === "add") additions++;
|
|
4263
|
+
else if (line.kind === "del") deletions++;
|
|
4264
|
+
return {
|
|
4265
|
+
files: files.length,
|
|
4266
|
+
additions,
|
|
4267
|
+
deletions
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
/** A commit's diff has no working-tree file to size a trailing gap from, so
|
|
4271
|
+
* `newLineCount` stays unknown and that gap is simply not offered. The other
|
|
4272
|
+
* gaps read the file at `rev`, the commit's own version. */
|
|
4273
|
+
const commitFiles = (raw, rev) => parsePatch(raw).map((file) => ({
|
|
4274
|
+
...file,
|
|
4275
|
+
staged: false,
|
|
4276
|
+
newLineCount: null,
|
|
4277
|
+
...rev ? { rev } : {}
|
|
4278
|
+
}));
|
|
4279
|
+
/**
|
|
4280
|
+
* The tab fetches every commit's diff at once, and each is a git process: a
|
|
4281
|
+
* few at a time keeps a long branch from spawning one per commit in a burst.
|
|
4282
|
+
*/
|
|
4283
|
+
const GIT_SLOTS = 4;
|
|
4284
|
+
let gitBusy = 0;
|
|
4285
|
+
const gitWaiting = [];
|
|
4286
|
+
async function withGitSlot(work) {
|
|
4287
|
+
if (gitBusy >= GIT_SLOTS) await new Promise((resolve) => gitWaiting.push(resolve));
|
|
4288
|
+
gitBusy++;
|
|
4289
|
+
try {
|
|
4290
|
+
return await work();
|
|
4291
|
+
} finally {
|
|
4292
|
+
gitBusy--;
|
|
4293
|
+
gitWaiting.shift()?.();
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
/** Whether `sha` is a full sha of one of the changeset's commits: the cheap
|
|
4297
|
+
* listing answers, so a reviewer-typed ref never reaches git as one. */
|
|
4298
|
+
async function isListedCommit(root, spec, sha) {
|
|
4299
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) return false;
|
|
4300
|
+
return withGitSlot(async () => (await listCommitShasAsync(root, spec)).includes(sha));
|
|
4301
|
+
}
|
|
4302
|
+
const uncommittedFiles = async (root) => commitFiles(await getFullDiffAsync(root, { kind: "working-tree" }));
|
|
4303
|
+
/**
|
|
4304
|
+
* The changeset's commits, oldest first. A branch review's diff runs from the
|
|
4305
|
+
* merge-base to the *working tree*, so edits not yet committed are part of what
|
|
4306
|
+
* is under review: they follow the commits as one more entry, when there are any.
|
|
4307
|
+
*/
|
|
4308
|
+
async function listCommits(root, spec) {
|
|
4309
|
+
const raw = await listCommitsAsync(root, spec);
|
|
4310
|
+
if (raw.length === 0) return { commits: [] };
|
|
4311
|
+
const commits = raw.map((c) => ({
|
|
4312
|
+
sha: c.sha,
|
|
4313
|
+
short: c.short,
|
|
4314
|
+
subject: c.subject,
|
|
4315
|
+
body: c.body,
|
|
4316
|
+
author: c.author,
|
|
4317
|
+
at: c.at,
|
|
4318
|
+
stats: c.stats
|
|
4319
|
+
}));
|
|
4320
|
+
const pending = await uncommittedFiles(root);
|
|
4321
|
+
if (pending.length > 0) commits.push({
|
|
4322
|
+
sha: UNCOMMITTED,
|
|
4323
|
+
short: "",
|
|
4324
|
+
subject: "Uncommitted changes",
|
|
4325
|
+
body: "",
|
|
4326
|
+
author: null,
|
|
4327
|
+
at: null,
|
|
4328
|
+
stats: stats$2(pending)
|
|
4329
|
+
});
|
|
4330
|
+
return { commits };
|
|
4331
|
+
}
|
|
4332
|
+
/**
|
|
4333
|
+
* One listed commit's own diff. The sha must be one `listCommits` returns: the
|
|
4334
|
+
* route never hands git a ref the reviewer typed, only a full sha it already
|
|
4335
|
+
* listed — checked against the cheap sha listing, not the one that diffs every
|
|
4336
|
+
* commit for its stats. Null when it is not in the changeset.
|
|
4337
|
+
*/
|
|
4338
|
+
async function getCommitChangeset(root, spec, sha) {
|
|
4339
|
+
if (sha === "uncommitted") {
|
|
4340
|
+
if (spec.kind !== "branch") return null;
|
|
4341
|
+
return {
|
|
4342
|
+
sha,
|
|
4343
|
+
files: await uncommittedFiles(root)
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
if (!await isListedCommit(root, spec, sha)) return null;
|
|
4347
|
+
const raw = await withGitSlot(() => getCommitDiffAsync(root, sha));
|
|
4348
|
+
return {
|
|
4349
|
+
sha,
|
|
4350
|
+
files: commitFiles(raw, sha)
|
|
4351
|
+
};
|
|
4352
|
+
}
|
|
4353
|
+
//#endregion
|
|
4354
|
+
//#region src/server/delivery.ts
|
|
4355
|
+
const STALL_AFTER_MS = 3e5;
|
|
4356
|
+
const REPLY_GRACE_MS = 9e4;
|
|
4357
|
+
/** A re-poll within this window of a delivery reads as "hasn't started yet". */
|
|
4358
|
+
const BATCH_SETTLE_MS = 2e3;
|
|
4359
|
+
/** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
|
|
4360
|
+
function pidAlive(pid) {
|
|
4361
|
+
try {
|
|
4362
|
+
process.kill(pid, 0);
|
|
4363
|
+
return true;
|
|
4364
|
+
} catch (err) {
|
|
4365
|
+
return err.code === "EPERM";
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
var DeliveryQueue = class DeliveryQueue {
|
|
4369
|
+
stallAfterMs;
|
|
4370
|
+
replyGraceMs;
|
|
4371
|
+
liveness;
|
|
4372
|
+
batchSettleMs;
|
|
4373
|
+
buckets = /* @__PURE__ */ new Map();
|
|
4374
|
+
scope = "";
|
|
4375
|
+
waiter = null;
|
|
4376
|
+
awaitingReply = false;
|
|
4377
|
+
/** Thread id -> epoch ms it was delivered. */
|
|
4378
|
+
deliveredAt = /* @__PURE__ */ new Map();
|
|
4379
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4380
|
+
stalled = false;
|
|
4381
|
+
stallTimer = null;
|
|
4382
|
+
betweenPolls = false;
|
|
4383
|
+
/** Why the agent is parked between polls: a reply just landed, its poll
|
|
4384
|
+
* ended on its own and the re-poll is expected, or it opened the review and
|
|
4385
|
+
* is still orienting itself before its first poll. */
|
|
4386
|
+
parked = "replied";
|
|
4387
|
+
graceTimer = null;
|
|
4388
|
+
lastDetach = null;
|
|
4389
|
+
since = Date.now();
|
|
4390
|
+
lastDetail = {
|
|
4391
|
+
state: "waiting",
|
|
4392
|
+
reason: "no-agent"
|
|
4393
|
+
};
|
|
4394
|
+
owner = null;
|
|
4395
|
+
/** The session pid that last received the full reply protocol. Later
|
|
4396
|
+
* deliveries to the same session carry the compact form — the full text is
|
|
4397
|
+
* the loop's largest recurring token cost. A takeover (new pid) or an
|
|
4398
|
+
* anonymous session (null pid) always gets the full form again. */
|
|
4399
|
+
protocolPid = null;
|
|
4400
|
+
batch = null;
|
|
4401
|
+
batchWatch = null;
|
|
4402
|
+
waiterWatch = null;
|
|
4403
|
+
batchListeners = /* @__PURE__ */ new Set();
|
|
4404
|
+
constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
|
|
4405
|
+
this.stallAfterMs = stallAfterMs;
|
|
4406
|
+
this.replyGraceMs = replyGraceMs;
|
|
4407
|
+
this.liveness = liveness;
|
|
4408
|
+
this.batchSettleMs = batchSettleMs;
|
|
4409
|
+
}
|
|
4410
|
+
onBatchClosed(listener) {
|
|
4411
|
+
this.batchListeners.add(listener);
|
|
4412
|
+
return () => this.batchListeners.delete(listener);
|
|
4413
|
+
}
|
|
4414
|
+
closeBatch(reason) {
|
|
4415
|
+
const batch = this.batch;
|
|
4416
|
+
if (!batch) return;
|
|
4417
|
+
const closed = {
|
|
4418
|
+
threadIds: batch.threadIds,
|
|
4419
|
+
unanswered: [...this.deliveredAt.keys()],
|
|
4420
|
+
deliveredAt: batch.deliveredAt,
|
|
4421
|
+
closedAt: Date.now(),
|
|
4422
|
+
reason
|
|
4423
|
+
};
|
|
4424
|
+
this.batch = null;
|
|
4425
|
+
this.deliveredAt.clear();
|
|
4426
|
+
this.awaitingReply = false;
|
|
4427
|
+
this.stalled = false;
|
|
4428
|
+
this.clearStall();
|
|
4429
|
+
this.clearBatchWatch();
|
|
4430
|
+
for (const listener of this.batchListeners) listener(closed);
|
|
4431
|
+
}
|
|
4432
|
+
armBatchWatch() {
|
|
4433
|
+
this.clearBatchWatch();
|
|
4063
4434
|
const isAlive = this.liveness.isAlive ?? pidAlive;
|
|
4064
4435
|
const timer = setInterval(() => {
|
|
4065
4436
|
if (!this.batch) return this.clearBatchWatch();
|
|
@@ -4822,6 +5193,14 @@ function githubPosition(anchor, body, files) {
|
|
|
4822
5193
|
downgraded: false
|
|
4823
5194
|
};
|
|
4824
5195
|
const file = files.find((f) => f.path === anchor.path);
|
|
5196
|
+
if (anchor.commit !== void 0 && !file?.hunks.some((h) => h.id === anchor.hunkId)) return {
|
|
5197
|
+
draft: {
|
|
5198
|
+
path: anchor.path,
|
|
5199
|
+
body,
|
|
5200
|
+
subjectType: "FILE"
|
|
5201
|
+
},
|
|
5202
|
+
downgraded: true
|
|
5203
|
+
};
|
|
4825
5204
|
const has = (n) => file?.hunks.some((h) => h.lines.some((l) => anchor.side === "old" ? l.kind !== "add" && l.oldNo === n : l.kind !== "del" && l.newNo === n)) ?? false;
|
|
4826
5205
|
const end = anchor.endLine ?? anchor.line;
|
|
4827
5206
|
if (!has(end) || !has(anchor.line)) return {
|
|
@@ -6253,13 +6632,15 @@ function parseAnchor(value) {
|
|
|
6253
6632
|
if (anchor.side !== "old" && anchor.side !== "new") return null;
|
|
6254
6633
|
if (typeof anchor.line !== "number") return null;
|
|
6255
6634
|
const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
|
|
6635
|
+
const commit = typeof anchor.commit === "string" && /^[0-9a-f]{40}$/.test(anchor.commit) ? anchor.commit : void 0;
|
|
6256
6636
|
return {
|
|
6257
6637
|
kind: "hunk",
|
|
6258
6638
|
hunkId: anchor.hunkId,
|
|
6259
6639
|
path: anchor.path,
|
|
6260
6640
|
side: anchor.side,
|
|
6261
6641
|
line: anchor.line,
|
|
6262
|
-
...endLine !== void 0 ? { endLine } : {}
|
|
6642
|
+
...endLine !== void 0 ? { endLine } : {},
|
|
6643
|
+
...commit !== void 0 ? { commit } : {}
|
|
6263
6644
|
};
|
|
6264
6645
|
}
|
|
6265
6646
|
if (anchor.kind === "file") return typeof anchor.path === "string" ? {
|
|
@@ -6460,215 +6841,6 @@ function normalizePr(value) {
|
|
|
6460
6841
|
};
|
|
6461
6842
|
}
|
|
6462
6843
|
//#endregion
|
|
6463
|
-
//#region src/server/parse.ts
|
|
6464
|
-
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
6465
|
-
".png",
|
|
6466
|
-
".jpg",
|
|
6467
|
-
".jpeg",
|
|
6468
|
-
".gif",
|
|
6469
|
-
".webp",
|
|
6470
|
-
".bmp",
|
|
6471
|
-
".svg",
|
|
6472
|
-
".ico",
|
|
6473
|
-
".avif"
|
|
6474
|
-
]);
|
|
6475
|
-
function isImagePath(path) {
|
|
6476
|
-
const dot = path.lastIndexOf(".");
|
|
6477
|
-
return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
6478
|
-
}
|
|
6479
|
-
/** Content-addressed hunk identity: the hash covers the file path and the changed
|
|
6480
|
-
* lines only — not line numbers, not context. Editing the hunk itself mints a new
|
|
6481
|
-
* id; the occurrence index separates identical hunks within one file. */
|
|
6482
|
-
function hunkId(path, lines, occurrence) {
|
|
6483
|
-
const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
|
|
6484
|
-
return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
|
|
6485
|
-
}
|
|
6486
|
-
function parseSectionMeta(section) {
|
|
6487
|
-
return {
|
|
6488
|
-
renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
|
|
6489
|
-
renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
|
|
6490
|
-
binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
|
|
6491
|
-
symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
|
|
6492
|
-
};
|
|
6493
|
-
}
|
|
6494
|
-
function splitSections(patch) {
|
|
6495
|
-
if (!patch.trim()) return [];
|
|
6496
|
-
const sections = [];
|
|
6497
|
-
const lines = patch.split("\n");
|
|
6498
|
-
let current = null;
|
|
6499
|
-
for (const line of lines) if (line.startsWith("diff --git ")) {
|
|
6500
|
-
if (current) sections.push(current.join("\n"));
|
|
6501
|
-
current = [line];
|
|
6502
|
-
} else if (current) current.push(line);
|
|
6503
|
-
if (current) sections.push(current.join("\n"));
|
|
6504
|
-
return sections;
|
|
6505
|
-
}
|
|
6506
|
-
/**
|
|
6507
|
-
* `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
|
|
6508
|
-
* the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
|
|
6509
|
-
* guess at what you are inside.
|
|
6510
|
-
*/
|
|
6511
|
-
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
6512
|
-
/**
|
|
6513
|
-
* Read one hunk body, starting at the line after its header. The declared counts
|
|
6514
|
-
* are authoritative: consume exactly that many old and new lines and stop, rather
|
|
6515
|
-
* than reading up to the next `@@` — git's own rule, and what keeps the blank line
|
|
6516
|
-
* every patch ends with out of the last hunk.
|
|
6517
|
-
*/
|
|
6518
|
-
function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
|
|
6519
|
-
const out = [];
|
|
6520
|
-
let oldNo = oldStart;
|
|
6521
|
-
let newNo = newStart;
|
|
6522
|
-
let remainingOld = oldCount;
|
|
6523
|
-
let remainingNew = newCount;
|
|
6524
|
-
let i = start;
|
|
6525
|
-
while (remainingOld > 0 || remainingNew > 0) {
|
|
6526
|
-
const line = lines[i];
|
|
6527
|
-
if (line === void 0) break;
|
|
6528
|
-
if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
|
|
6529
|
-
i++;
|
|
6530
|
-
if (line.startsWith("\\")) continue;
|
|
6531
|
-
if (line.startsWith("+")) {
|
|
6532
|
-
out.push({
|
|
6533
|
-
kind: "add",
|
|
6534
|
-
oldNo: null,
|
|
6535
|
-
newNo: newNo++,
|
|
6536
|
-
text: line.slice(1)
|
|
6537
|
-
});
|
|
6538
|
-
remainingNew--;
|
|
6539
|
-
} else if (line.startsWith("-")) {
|
|
6540
|
-
out.push({
|
|
6541
|
-
kind: "del",
|
|
6542
|
-
oldNo: oldNo++,
|
|
6543
|
-
newNo: null,
|
|
6544
|
-
text: line.slice(1)
|
|
6545
|
-
});
|
|
6546
|
-
remainingOld--;
|
|
6547
|
-
} else {
|
|
6548
|
-
out.push({
|
|
6549
|
-
kind: "context",
|
|
6550
|
-
oldNo: oldNo++,
|
|
6551
|
-
newNo: newNo++,
|
|
6552
|
-
text: line.slice(1)
|
|
6553
|
-
});
|
|
6554
|
-
remainingOld--;
|
|
6555
|
-
remainingNew--;
|
|
6556
|
-
}
|
|
6557
|
-
}
|
|
6558
|
-
return {
|
|
6559
|
-
lines: out,
|
|
6560
|
-
next: i
|
|
6561
|
-
};
|
|
6562
|
-
}
|
|
6563
|
-
function parseHunks(lines) {
|
|
6564
|
-
const hunks = [];
|
|
6565
|
-
let i = 0;
|
|
6566
|
-
while (i < lines.length) {
|
|
6567
|
-
const header = lines[i]?.match(HUNK_HEADER);
|
|
6568
|
-
if (!header) {
|
|
6569
|
-
i++;
|
|
6570
|
-
continue;
|
|
6571
|
-
}
|
|
6572
|
-
const oldStart = Number(header[1]);
|
|
6573
|
-
const newStart = Number(header[3]);
|
|
6574
|
-
const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
|
|
6575
|
-
const context = (header[5] ?? "").trim();
|
|
6576
|
-
hunks.push({
|
|
6577
|
-
oldStart,
|
|
6578
|
-
newStart,
|
|
6579
|
-
context: context === "" ? null : context,
|
|
6580
|
-
lines: body.lines
|
|
6581
|
-
});
|
|
6582
|
-
i = body.next;
|
|
6583
|
-
}
|
|
6584
|
-
return hunks;
|
|
6585
|
-
}
|
|
6586
|
-
/**
|
|
6587
|
-
* `--- a/x` / `+++ b/x`, read only from the part of the section before the first
|
|
6588
|
-
* `@@`. That bound is load-bearing: inside a hunk body an added line starting with
|
|
6589
|
-
* `++` is spelled `+++ …`, so scanning the whole section would read file content as
|
|
6590
|
-
* a path header.
|
|
6591
|
-
*/
|
|
6592
|
-
function parseSectionPaths(section, lines) {
|
|
6593
|
-
let from = null;
|
|
6594
|
-
let to = null;
|
|
6595
|
-
for (const line of lines) {
|
|
6596
|
-
if (line.startsWith("@@")) break;
|
|
6597
|
-
if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
|
|
6598
|
-
else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
|
|
6599
|
-
}
|
|
6600
|
-
return {
|
|
6601
|
-
from,
|
|
6602
|
-
to,
|
|
6603
|
-
isNew: from === "/dev/null" || /^new file mode /m.test(section),
|
|
6604
|
-
isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
|
|
6605
|
-
};
|
|
6606
|
-
}
|
|
6607
|
-
function diffPath(raw) {
|
|
6608
|
-
const path = raw.split(" ", 1)[0] ?? raw;
|
|
6609
|
-
return path === "/dev/null" ? path : stripAB(path);
|
|
6610
|
-
}
|
|
6611
|
-
function parsePatch(patch) {
|
|
6612
|
-
const files = [];
|
|
6613
|
-
for (const section of splitSections(patch)) {
|
|
6614
|
-
const sectionLines = section.split("\n");
|
|
6615
|
-
const meta = parseSectionMeta(section);
|
|
6616
|
-
const paths = parseSectionPaths(section, sectionLines);
|
|
6617
|
-
let path;
|
|
6618
|
-
let oldPath = null;
|
|
6619
|
-
let status;
|
|
6620
|
-
if (meta.renamedTo) {
|
|
6621
|
-
path = meta.renamedTo;
|
|
6622
|
-
oldPath = meta.renamedFrom;
|
|
6623
|
-
status = "renamed";
|
|
6624
|
-
} else if (paths.isNew) {
|
|
6625
|
-
path = paths.to ?? headerPath(section);
|
|
6626
|
-
status = "added";
|
|
6627
|
-
} else if (paths.isDeleted) {
|
|
6628
|
-
path = paths.from ?? headerPath(section);
|
|
6629
|
-
status = "deleted";
|
|
6630
|
-
} else if (paths.to !== null && paths.to !== "/dev/null") {
|
|
6631
|
-
path = paths.to;
|
|
6632
|
-
status = "modified";
|
|
6633
|
-
} else {
|
|
6634
|
-
path = headerPath(section);
|
|
6635
|
-
status = "modified";
|
|
6636
|
-
}
|
|
6637
|
-
const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
|
|
6638
|
-
const seen = /* @__PURE__ */ new Map();
|
|
6639
|
-
const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
|
|
6640
|
-
const lines = hunk.lines;
|
|
6641
|
-
const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
|
|
6642
|
-
const occurrence = seen.get(contentKey) ?? 0;
|
|
6643
|
-
seen.set(contentKey, occurrence + 1);
|
|
6644
|
-
return {
|
|
6645
|
-
id: hunkId(path, lines, occurrence),
|
|
6646
|
-
path,
|
|
6647
|
-
oldStart: hunk.oldStart,
|
|
6648
|
-
newStart: hunk.newStart,
|
|
6649
|
-
lines,
|
|
6650
|
-
...hunk.context === null ? {} : { context: hunk.context }
|
|
6651
|
-
};
|
|
6652
|
-
});
|
|
6653
|
-
files.push({
|
|
6654
|
-
path,
|
|
6655
|
-
oldPath,
|
|
6656
|
-
status,
|
|
6657
|
-
kind,
|
|
6658
|
-
staged: false,
|
|
6659
|
-
hunks
|
|
6660
|
-
});
|
|
6661
|
-
}
|
|
6662
|
-
return files;
|
|
6663
|
-
}
|
|
6664
|
-
function stripAB(p) {
|
|
6665
|
-
return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
|
|
6666
|
-
}
|
|
6667
|
-
function headerPath(section) {
|
|
6668
|
-
const header = section.split("\n", 1)[0] ?? "";
|
|
6669
|
-
return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
|
|
6670
|
-
}
|
|
6671
|
-
//#endregion
|
|
6672
6844
|
//#region src/server/changeset.ts
|
|
6673
6845
|
function stats$1(files) {
|
|
6674
6846
|
let additions = 0;
|
|
@@ -7379,8 +7551,17 @@ function createApp(ctx, store, review, queue) {
|
|
|
7379
7551
|
edited: message.prComment.text.trim() !== text
|
|
7380
7552
|
};
|
|
7381
7553
|
}
|
|
7382
|
-
const
|
|
7383
|
-
const
|
|
7554
|
+
const commitFiles = store && anchor.kind === "hunk" && typeof body?.commit === "string" ? (await getCommitChangeset(ctx.root, ctx.spec, body.commit).catch(() => null))?.files ?? null : null;
|
|
7555
|
+
const placed = commitFiles && anchor.kind === "hunk" ? findHunk$1(store.get().files, anchor.hunkId) ? translateHunkAnchor(anchor, commitFiles, store.get().files) : {
|
|
7556
|
+
...anchor,
|
|
7557
|
+
commit: body.commit
|
|
7558
|
+
} : anchor;
|
|
7559
|
+
let capture = store ? captureAnchor(store.get(), placed, review.get().layers) : null;
|
|
7560
|
+
if (store && !capture && commitFiles) capture = captureAnchor({
|
|
7561
|
+
...store.get(),
|
|
7562
|
+
files: commitFiles
|
|
7563
|
+
}, anchor);
|
|
7564
|
+
const created = review.createThread(placed, text, capture, intent, "reviewer", {
|
|
7384
7565
|
...audience ? { audience } : {},
|
|
7385
7566
|
...parentId ? { parentId } : {},
|
|
7386
7567
|
...origin ? { origin } : {}
|
|
@@ -7893,13 +8074,35 @@ function createApp(ctx, store, review, queue) {
|
|
|
7893
8074
|
throw err;
|
|
7894
8075
|
}
|
|
7895
8076
|
});
|
|
7896
|
-
app.get("/api/
|
|
8077
|
+
app.get("/api/commits", async (c) => {
|
|
8078
|
+
try {
|
|
8079
|
+
return c.json(await listCommits(ctx.root, ctx.spec));
|
|
8080
|
+
} catch (err) {
|
|
8081
|
+
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
8082
|
+
throw err;
|
|
8083
|
+
}
|
|
8084
|
+
});
|
|
8085
|
+
app.get("/api/commits/:sha", async (c) => {
|
|
8086
|
+
try {
|
|
8087
|
+
const commit = await getCommitChangeset(ctx.root, ctx.spec, c.req.param("sha"));
|
|
8088
|
+
return commit ? c.json(commit) : c.json({ error: "no such commit in this changeset" }, 404);
|
|
8089
|
+
} catch (err) {
|
|
8090
|
+
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
8091
|
+
throw err;
|
|
8092
|
+
}
|
|
8093
|
+
});
|
|
8094
|
+
app.get("/api/file", async (c) => {
|
|
7897
8095
|
const path = c.req.query("path");
|
|
7898
8096
|
const side = c.req.query("side");
|
|
8097
|
+
const rev = c.req.query("rev");
|
|
7899
8098
|
if (!path || side !== "base" && side !== "head") return c.json({ error: "need ?path= and ?side=base|head" }, 400);
|
|
7900
8099
|
let bytes;
|
|
7901
8100
|
try {
|
|
7902
|
-
|
|
8101
|
+
if (rev !== void 0) {
|
|
8102
|
+
if (side !== "head") return c.json({ error: "?rev= reads the head side only" }, 400);
|
|
8103
|
+
if (!await isListedCommit(ctx.root, ctx.spec, rev)) return c.json({ error: "no such commit in this changeset" }, 404);
|
|
8104
|
+
bytes = getBaseFileBytes(ctx.root, rev, path);
|
|
8105
|
+
} else bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
|
|
7903
8106
|
} catch (err) {
|
|
7904
8107
|
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
7905
8108
|
throw err;
|