local-diff-reviewer 0.1.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 +179 -0
- package/SKILL.md +62 -0
- package/dist/cli/start.js +949 -0
- package/dist/web/assets/arc-CR7lDgfr.js +1 -0
- package/dist/web/assets/architecture-7EHR7CIX-nXR9vjYB.js +1 -0
- package/dist/web/assets/architectureDiagram-3BPJPVTR-B-Kf98tI.js +36 -0
- package/dist/web/assets/array-yLZkcI6Y.js +1 -0
- package/dist/web/assets/blockDiagram-GPEHLZMM-C6G2D8zB.js +132 -0
- package/dist/web/assets/c4Diagram-AAUBKEIU-B8Z0vFGi.js +10 -0
- package/dist/web/assets/channel-BLQGEX2L.js +1 -0
- package/dist/web/assets/chunk-2J33WTMH-BJYcz9p6.js +1 -0
- package/dist/web/assets/chunk-3OPIFGDE-CadWbiuZ.js +62 -0
- package/dist/web/assets/chunk-4BX2VUAB-p5TBPKfa.js +1 -0
- package/dist/web/assets/chunk-4EGX6M5U-Dm0Urt6c.js +1 -0
- package/dist/web/assets/chunk-55IACEB6-Cd7Tpfqt.js +1 -0
- package/dist/web/assets/chunk-5DO6E6H7-eFUPFW6j.js +1 -0
- package/dist/web/assets/chunk-5ZQYHXKU-rVi82pOk.js +2 -0
- package/dist/web/assets/chunk-727SXJPM-DCK60o1J.js +206 -0
- package/dist/web/assets/chunk-AQP2D5EJ-DsaTBCmF.js +231 -0
- package/dist/web/assets/chunk-BR22UD5L-CAPtHiCF.js +1 -0
- package/dist/web/assets/chunk-BSJP7CBP-Bhh_dCA1.js +1 -0
- package/dist/web/assets/chunk-CSCIHK7Q-BTxAxDrY.js +124 -0
- package/dist/web/assets/chunk-FHYWG6QK-DYV7MBXV.js +1 -0
- package/dist/web/assets/chunk-FMBD7UC4-DlFkGqeu.js +15 -0
- package/dist/web/assets/chunk-KSCS5N6A-BYPjHZNn.js +10 -0
- package/dist/web/assets/chunk-L5ZTLDWV-BMFnPCzP.js +1 -0
- package/dist/web/assets/chunk-LZXEDZCA-BKhHYjKh.js +2 -0
- package/dist/web/assets/chunk-MPE355IW-Bn5Gex5z.js +1 -0
- package/dist/web/assets/chunk-MZUSXYTE-DkK02s8r.js +1 -0
- package/dist/web/assets/chunk-N66VUXT2-BxW6-FUi.js +1 -0
- package/dist/web/assets/chunk-ND2GUHAM-7KRXvqPH.js +1 -0
- package/dist/web/assets/chunk-NNHCCRGN-D80gzfPm.js +159 -0
- package/dist/web/assets/chunk-NZK2D7GU-C2gmfVLG.js +1 -0
- package/dist/web/assets/chunk-O5CBEL6O-BDgBHpRo.js +70 -0
- package/dist/web/assets/chunk-PUPMXCY4-C8RfbV9f.js +1 -0
- package/dist/web/assets/chunk-QZHKN3VN-BGdvirFT.js +1 -0
- package/dist/web/assets/chunk-UIBZB4QT-JbcSSk3W.js +1 -0
- package/dist/web/assets/chunk-WCWK7LTN-DnI1dFXp.js +1 -0
- package/dist/web/assets/chunk-WU5MYG2G-VXAyXnrB.js +1 -0
- package/dist/web/assets/chunk-XPW4576I-CNcwF46w.js +32 -0
- package/dist/web/assets/classDiagram-4FO5ZUOK-DGbdorsF.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-CdJT8iZ_.js +1 -0
- package/dist/web/assets/cose-bilkent-S5V4N54A-DGxrNo18.js +1 -0
- package/dist/web/assets/cytoscape.esm-DBZzCT3P.js +321 -0
- package/dist/web/assets/dagre-BLlLfumR.js +1 -0
- package/dist/web/assets/dagre-BM42HDAG-DgS2nVEP.js +4 -0
- package/dist/web/assets/defaultLocale-Bld4b7vH.js +1 -0
- package/dist/web/assets/diagram-2AECGRRQ-CZ3HTlHq.js +43 -0
- package/dist/web/assets/diagram-5GNKFQAL-Q7ELSmIM.js +10 -0
- package/dist/web/assets/diagram-KO2AKTUF-E5adO5kX.js +3 -0
- package/dist/web/assets/diagram-LMA3HP47-DckjoLmV.js +24 -0
- package/dist/web/assets/diagram-OG6HWLK6-C6IV8tgf.js +24 -0
- package/dist/web/assets/dist-BBL8i-qs.js +1 -0
- package/dist/web/assets/erDiagram-TEJ5UH35-CtdFt0tH.js +85 -0
- package/dist/web/assets/eventmodeling-FCH6USID-C_CDGdeY.js +1 -0
- package/dist/web/assets/flowDiagram-I6XJVG4X-Cj3R7yTy.js +162 -0
- package/dist/web/assets/ganttDiagram-6RSMTGT7-Bb6ylSyS.js +292 -0
- package/dist/web/assets/gitGraph-WXDBUCRP-GdVUV9HW.js +1 -0
- package/dist/web/assets/gitGraphDiagram-PVQCEYII-DJHSJ07v.js +106 -0
- package/dist/web/assets/graphlib-DkOQ9ah2.js +1 -0
- package/dist/web/assets/index-Byh7XaFy.js +288 -0
- package/dist/web/assets/index-CND5NAGY.css +1 -0
- package/dist/web/assets/info-J43DQDTF-BWgpjD75.js +1 -0
- package/dist/web/assets/infoDiagram-5YYISTIA-1xrP_FxB.js +2 -0
- package/dist/web/assets/init-rddNFwID.js +1 -0
- package/dist/web/assets/ishikawaDiagram-YF4QCWOH-DxyBX-lT.js +70 -0
- package/dist/web/assets/journeyDiagram-JHISSGLW-DMcyhQOZ.js +139 -0
- package/dist/web/assets/kanban-definition-UN3LZRKU-qZQpCEuA.js +89 -0
- package/dist/web/assets/katex-D9DkQQYz.js +257 -0
- package/dist/web/assets/line-BJ3a5kXp.js +1 -0
- package/dist/web/assets/linear-WojM0Myz.js +1 -0
- package/dist/web/assets/mermaid-parser.core-CXQdsx5L.js +4 -0
- package/dist/web/assets/mermaid.core-D-XPCWky.js +9 -0
- package/dist/web/assets/mindmap-definition-RKZ34NQL-B3OxgBPA.js +96 -0
- package/dist/web/assets/ordinal-7rXhq2H9.js +1 -0
- package/dist/web/assets/packet-YPE3B663-Dtmistlh.js +1 -0
- package/dist/web/assets/path-BJQEcSo7.js +1 -0
- package/dist/web/assets/pie-LRSECV5Y-CRgiS3oB.js +1 -0
- package/dist/web/assets/pieDiagram-4H26LBE5-Div1l6Q1.js +30 -0
- package/dist/web/assets/quadrantDiagram-W4KKPZXB-CeXbRhNe.js +7 -0
- package/dist/web/assets/radar-GUYGQ44K-CF2B6KbU.js +1 -0
- package/dist/web/assets/requirementDiagram-4Y6WPE33-BlGgeVCv.js +84 -0
- package/dist/web/assets/rough.esm-KjoEK0it.js +1 -0
- package/dist/web/assets/sankeyDiagram-5OEKKPKP-BhwjPB4q.js +40 -0
- package/dist/web/assets/sequenceDiagram-3UESZ5HK-DNHlWYtx.js +162 -0
- package/dist/web/assets/src-Bn2XKj5Y.js +1 -0
- package/dist/web/assets/stateDiagram-AJRCARHV-CdZgtb-A.js +1 -0
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-C5vqR8l5.js +1 -0
- package/dist/web/assets/timeline-definition-PNZ67QCA-Ch-1WuO4.js +120 -0
- package/dist/web/assets/treeView-BLDUP644-Bdt69Bnk.js +1 -0
- package/dist/web/assets/treemap-LRROVOQU-BBya6zH4.js +1 -0
- package/dist/web/assets/vennDiagram-CIIHVFJN-BU0olyYI.js +34 -0
- package/dist/web/assets/wardley-L42UT6IY-B77cR16B.js +1 -0
- package/dist/web/assets/wardleyDiagram-YWT4CUSO-DXFmQa94.js +78 -0
- package/dist/web/assets/xychartDiagram-2RQKCTM6-BY6dYn-A.js +7 -0
- package/dist/web/index.html +13 -0
- package/package.json +48 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/start.ts
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
6
|
+
import { dirname as dirname2, join as join4, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
// src/server/storage.ts
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { homedir, platform } from "node:os";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// src/shared/thread-utils.ts
|
|
16
|
+
function sameAnchor(left, right) {
|
|
17
|
+
if (left.type !== right.type || left.filePath !== right.filePath) return false;
|
|
18
|
+
if (left.type === "file" && right.type === "file") return true;
|
|
19
|
+
if (left.type === "diff-line" && right.type === "diff-line") {
|
|
20
|
+
return left.side === right.side && left.lineNumber === right.lineNumber;
|
|
21
|
+
}
|
|
22
|
+
if (left.type === "markdown-line" && right.type === "markdown-line") {
|
|
23
|
+
return left.lineNumber === right.lineNumber;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function anchorKey(anchor) {
|
|
28
|
+
if (anchor.type === "file") return `file:${anchor.filePath}`;
|
|
29
|
+
if (anchor.type === "diff-line") return `diff:${anchor.filePath}:${anchor.side}:${anchor.lineNumber}`;
|
|
30
|
+
return `markdown:${anchor.filePath}:${anchor.lineNumber}`;
|
|
31
|
+
}
|
|
32
|
+
function getThreadStatus(thread) {
|
|
33
|
+
if (thread.status === "resolved") return "resolved";
|
|
34
|
+
return getOpenThreadStatus(thread);
|
|
35
|
+
}
|
|
36
|
+
function getOpenThreadStatus(thread) {
|
|
37
|
+
return thread.comments.some((comment) => comment.author === "agent") ? "replied" : "submit";
|
|
38
|
+
}
|
|
39
|
+
function getMergedThreadStatus(threads) {
|
|
40
|
+
if (threads.length > 0 && threads.every((thread) => getThreadStatus(thread) === "resolved")) return "resolved";
|
|
41
|
+
return threads.some((thread) => getOpenThreadStatus(thread) === "replied") ? "replied" : "submit";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/server/storage.ts
|
|
45
|
+
async function readComments(repoRoot) {
|
|
46
|
+
const path = commentsPath(repoRoot);
|
|
47
|
+
try {
|
|
48
|
+
return normalizeStore(JSON.parse(await readFile(path, "utf8")));
|
|
49
|
+
} catch {
|
|
50
|
+
return readLegacyComments(repoRoot);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function writeComments(repoRoot, store) {
|
|
54
|
+
const path = commentsPath(repoRoot);
|
|
55
|
+
await mkdir(dirname(path), { recursive: true });
|
|
56
|
+
await writeFile(path, `${JSON.stringify(store, null, 2)}
|
|
57
|
+
`, "utf8");
|
|
58
|
+
}
|
|
59
|
+
function commentsPath(repoRoot) {
|
|
60
|
+
const repoName = basename(repoRoot) || "repo";
|
|
61
|
+
const repoHash = createHash("sha256").update(repoRoot).digest("hex").slice(0, 12);
|
|
62
|
+
return join(commentLogsDir(), `${repoName}-${repoHash}.comments.json`);
|
|
63
|
+
}
|
|
64
|
+
function commentLogsDir() {
|
|
65
|
+
if (platform() === "win32") {
|
|
66
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "diff-review", "logs");
|
|
67
|
+
}
|
|
68
|
+
return join(homedir(), ".local", "diff-review", "logs");
|
|
69
|
+
}
|
|
70
|
+
async function readLegacyComments(repoRoot) {
|
|
71
|
+
try {
|
|
72
|
+
return normalizeStore(JSON.parse(await readFile(join(repoRoot, ".diff-review", "comments.json"), "utf8")));
|
|
73
|
+
} catch {
|
|
74
|
+
return { threads: [] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function normalizeStore(store) {
|
|
78
|
+
const groups = /* @__PURE__ */ new Map();
|
|
79
|
+
for (const thread of store.threads) {
|
|
80
|
+
const key = anchorKey(thread.anchor);
|
|
81
|
+
groups.set(key, [...groups.get(key) ?? [], thread]);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
threads: Array.from(groups.values()).map((threads) => {
|
|
85
|
+
const [firstThread, ...restThreads] = threads;
|
|
86
|
+
const merged = {
|
|
87
|
+
...firstThread,
|
|
88
|
+
comments: threads.flatMap((thread) => thread.comments),
|
|
89
|
+
status: getMergedThreadStatus(threads),
|
|
90
|
+
updatedAt: latestTimestamp(threads.map((thread) => thread.updatedAt))
|
|
91
|
+
};
|
|
92
|
+
if (restThreads.length === 0) return merged;
|
|
93
|
+
return {
|
|
94
|
+
...merged,
|
|
95
|
+
createdAt: earliestTimestamp(threads.map((thread) => thread.createdAt))
|
|
96
|
+
};
|
|
97
|
+
})
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function latestTimestamp(values) {
|
|
101
|
+
return values.reduce((latest, value) => value > latest ? value : latest, values[0] ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
102
|
+
}
|
|
103
|
+
function earliestTimestamp(values) {
|
|
104
|
+
return values.reduce((earliest, value) => value < earliest ? value : earliest, values[0] ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/core/comment-import.ts
|
|
108
|
+
async function importAgentComments(repoRoot, diffFiles, rawComments) {
|
|
109
|
+
const result = { imported: 0, skipped: [] };
|
|
110
|
+
if (rawComments.length === 0) return result;
|
|
111
|
+
const store = await readComments(repoRoot);
|
|
112
|
+
for (let index = 0; index < rawComments.length; index += 1) {
|
|
113
|
+
const label = `--comment #${index + 1}`;
|
|
114
|
+
const parsed = parseImportComment(rawComments[index], label, result);
|
|
115
|
+
if (!parsed) continue;
|
|
116
|
+
if (parsed.type === "reply") {
|
|
117
|
+
if (appendAgentReply(store.threads, parsed, result, label)) {
|
|
118
|
+
result.imported += 1;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const thread = buildAgentThread(parsed, diffFiles, result, label);
|
|
123
|
+
if (!thread) continue;
|
|
124
|
+
const existingThread = store.threads.find((item) => sameAnchor(item.anchor, thread.anchor));
|
|
125
|
+
if (hasDuplicateAgentComment(store.threads, thread)) {
|
|
126
|
+
result.skipped.push(`${label}: duplicate agent comment skipped`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (existingThread) {
|
|
130
|
+
existingThread.comments.push(thread.comments[0]);
|
|
131
|
+
existingThread.status = getOpenThreadStatus(existingThread);
|
|
132
|
+
existingThread.updatedAt = thread.updatedAt;
|
|
133
|
+
} else {
|
|
134
|
+
store.threads.push(thread);
|
|
135
|
+
}
|
|
136
|
+
result.imported += 1;
|
|
137
|
+
}
|
|
138
|
+
if (result.imported > 0) {
|
|
139
|
+
await writeComments(repoRoot, store);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
function parseImportComment(raw, label, result) {
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(raw);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
result.skipped.push(`${label}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
if (!isRecord(value)) {
|
|
152
|
+
result.skipped.push(`${label}: comment must be a JSON object`);
|
|
153
|
+
return void 0;
|
|
154
|
+
}
|
|
155
|
+
if (value.type === "reply") {
|
|
156
|
+
const threadId = stringField(value, "threadId");
|
|
157
|
+
const body = stringField(value, "body")?.trim();
|
|
158
|
+
if (!threadId || !body) {
|
|
159
|
+
result.skipped.push(`${label}: reply comments require non-empty threadId and body`);
|
|
160
|
+
return void 0;
|
|
161
|
+
}
|
|
162
|
+
return { type: "reply", threadId, body };
|
|
163
|
+
}
|
|
164
|
+
if (value.type === "thread") {
|
|
165
|
+
const filePath = stringField(value, "filePath");
|
|
166
|
+
const body = stringField(value, "body")?.trim();
|
|
167
|
+
if (!filePath || !body) {
|
|
168
|
+
result.skipped.push(`${label}: thread comments require non-empty filePath and body`);
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
return { type: "thread", filePath, body, position: parsePosition(value.position) };
|
|
172
|
+
}
|
|
173
|
+
result.skipped.push(`${label}: unsupported comment type`);
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
function appendAgentReply(threads, comment, result, label) {
|
|
177
|
+
const thread = threads.find((item) => item.id === comment.threadId);
|
|
178
|
+
if (!thread) {
|
|
179
|
+
result.skipped.push(`${label}: thread ${comment.threadId} was not found`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (thread.comments.some((item) => item.author === "agent" && item.body.trim() === comment.body)) {
|
|
183
|
+
result.skipped.push(`${label}: duplicate agent reply skipped`);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
187
|
+
thread.comments.push({
|
|
188
|
+
id: crypto.randomUUID(),
|
|
189
|
+
body: comment.body,
|
|
190
|
+
author: "agent",
|
|
191
|
+
createdAt: now,
|
|
192
|
+
updatedAt: now
|
|
193
|
+
});
|
|
194
|
+
if (thread.status !== "resolved") {
|
|
195
|
+
thread.status = "replied";
|
|
196
|
+
}
|
|
197
|
+
thread.updatedAt = now;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
function buildAgentThread(comment, diffFiles, result, label) {
|
|
201
|
+
const file = diffFiles.find((item) => item.path === comment.filePath || item.oldPath === comment.filePath || item.newPath === comment.filePath);
|
|
202
|
+
if (!file) {
|
|
203
|
+
result.skipped.push(`${label}: ${comment.filePath} is not present in the current diff`);
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
const anchor = buildAnchor(file, comment, result, label);
|
|
207
|
+
if (!anchor) return void 0;
|
|
208
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
209
|
+
return {
|
|
210
|
+
id: crypto.randomUUID(),
|
|
211
|
+
filePath: anchor.filePath,
|
|
212
|
+
anchor,
|
|
213
|
+
status: "replied",
|
|
214
|
+
comments: [
|
|
215
|
+
{
|
|
216
|
+
id: crypto.randomUUID(),
|
|
217
|
+
body: comment.body,
|
|
218
|
+
author: "agent",
|
|
219
|
+
createdAt: now,
|
|
220
|
+
updatedAt: now
|
|
221
|
+
}
|
|
222
|
+
],
|
|
223
|
+
createdAt: now,
|
|
224
|
+
updatedAt: now
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function buildAnchor(file, comment, result, label) {
|
|
228
|
+
if (!comment.position) {
|
|
229
|
+
return { type: "file", filePath: file.path };
|
|
230
|
+
}
|
|
231
|
+
const position = comment.position;
|
|
232
|
+
if (isMarkdownPosition(position)) {
|
|
233
|
+
const line2 = getStartLine(position.line);
|
|
234
|
+
if (!Number.isInteger(line2) || line2 < 1) {
|
|
235
|
+
result.skipped.push(`${label}: markdown position requires a positive line number`);
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
if (!file.isMarkdown) {
|
|
239
|
+
result.skipped.push(`${label}: markdown position can only be used for Markdown files`);
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
return { type: "markdown-line", filePath: file.path, lineNumber: line2, blockId: `line-${line2}` };
|
|
243
|
+
}
|
|
244
|
+
const side = position.side ?? "new";
|
|
245
|
+
const line = getStartLine(position.line);
|
|
246
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
247
|
+
result.skipped.push(`${label}: diff position requires a positive line number`);
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
if (!diffLineExists(file, side, line)) {
|
|
251
|
+
result.skipped.push(`${label}: ${file.path}:${line} (${side}) is not present in the current diff`);
|
|
252
|
+
return void 0;
|
|
253
|
+
}
|
|
254
|
+
return { type: "diff-line", filePath: file.path, side, lineNumber: line };
|
|
255
|
+
}
|
|
256
|
+
function hasDuplicateAgentComment(threads, nextThread) {
|
|
257
|
+
const nextComment = nextThread.comments[0]?.body.trim();
|
|
258
|
+
return threads.some((thread) => {
|
|
259
|
+
return thread.comments.some((comment) => comment.author === "agent" && comment.body.trim() === nextComment) && thread.filePath === nextThread.filePath && sameAnchor(thread.anchor, nextThread.anchor);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function diffLineExists(file, side, lineNumber) {
|
|
263
|
+
return file.hunks.some(
|
|
264
|
+
(hunk) => hunk.lines.some((line) => side === "old" ? line.oldLineNumber === lineNumber : line.newLineNumber === lineNumber)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
function isMarkdownPosition(position) {
|
|
268
|
+
return "type" in position && position.type === "markdown";
|
|
269
|
+
}
|
|
270
|
+
function parsePosition(value) {
|
|
271
|
+
if (!isRecord(value)) return void 0;
|
|
272
|
+
if (value.type === "markdown") {
|
|
273
|
+
return { type: "markdown", line: parseLine(value.line) };
|
|
274
|
+
}
|
|
275
|
+
const side = value.side === "old" || value.side === "new" ? value.side : void 0;
|
|
276
|
+
return { side, line: parseLine(value.line) };
|
|
277
|
+
}
|
|
278
|
+
function parseLine(value) {
|
|
279
|
+
if (typeof value === "number") return value;
|
|
280
|
+
if (isRecord(value) && typeof value.start === "number") {
|
|
281
|
+
return typeof value.end === "number" ? { start: value.start, end: value.end } : { start: value.start };
|
|
282
|
+
}
|
|
283
|
+
return Number.NaN;
|
|
284
|
+
}
|
|
285
|
+
function getStartLine(line) {
|
|
286
|
+
if (typeof line === "number") return line;
|
|
287
|
+
return line?.start ?? Number.NaN;
|
|
288
|
+
}
|
|
289
|
+
function stringField(value, key) {
|
|
290
|
+
const field = value[key];
|
|
291
|
+
return typeof field === "string" ? field : void 0;
|
|
292
|
+
}
|
|
293
|
+
function isRecord(value) {
|
|
294
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/core/diff-parser.ts
|
|
298
|
+
var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
299
|
+
function parseUnifiedDiff(input) {
|
|
300
|
+
const lines = input.replace(/\r\n/g, "\n").split("\n");
|
|
301
|
+
const files = [];
|
|
302
|
+
let currentFile;
|
|
303
|
+
let currentHunk;
|
|
304
|
+
let oldLine = 0;
|
|
305
|
+
let newLine = 0;
|
|
306
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
307
|
+
const line = lines[index];
|
|
308
|
+
if (line.startsWith("diff --git ")) {
|
|
309
|
+
const paths = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
310
|
+
currentFile = {
|
|
311
|
+
oldPath: paths?.[1] ?? "",
|
|
312
|
+
newPath: paths?.[2] ?? "",
|
|
313
|
+
path: paths?.[2] ?? "",
|
|
314
|
+
status: "modified",
|
|
315
|
+
additions: 0,
|
|
316
|
+
deletions: 0,
|
|
317
|
+
isMarkdown: isMarkdownPath(paths?.[2] ?? ""),
|
|
318
|
+
hunks: []
|
|
319
|
+
};
|
|
320
|
+
files.push(currentFile);
|
|
321
|
+
currentHunk = void 0;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!currentFile) continue;
|
|
325
|
+
if (line.startsWith("rename from ")) {
|
|
326
|
+
currentFile.oldPath = line.slice("rename from ".length);
|
|
327
|
+
currentFile.status = "renamed";
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (line.startsWith("rename to ")) {
|
|
331
|
+
currentFile.newPath = line.slice("rename to ".length);
|
|
332
|
+
currentFile.path = currentFile.newPath;
|
|
333
|
+
currentFile.isMarkdown = isMarkdownPath(currentFile.path);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (line.startsWith("new file mode")) {
|
|
337
|
+
currentFile.status = "added";
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (line.startsWith("deleted file mode")) {
|
|
341
|
+
currentFile.status = "deleted";
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (line.startsWith("--- ")) {
|
|
345
|
+
currentFile.oldPath = normalizeDiffPath(line.slice(4));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (line.startsWith("+++ ")) {
|
|
349
|
+
currentFile.newPath = normalizeDiffPath(line.slice(4));
|
|
350
|
+
currentFile.path = currentFile.newPath !== "/dev/null" ? currentFile.newPath : currentFile.oldPath;
|
|
351
|
+
currentFile.isMarkdown = isMarkdownPath(currentFile.path);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const hunkMatch = line.match(hunkHeaderPattern);
|
|
355
|
+
if (hunkMatch) {
|
|
356
|
+
oldLine = Number(hunkMatch[1]);
|
|
357
|
+
newLine = Number(hunkMatch[3]);
|
|
358
|
+
currentHunk = {
|
|
359
|
+
header: line,
|
|
360
|
+
oldStart: oldLine,
|
|
361
|
+
oldLines: Number(hunkMatch[2] ?? 1),
|
|
362
|
+
newStart: newLine,
|
|
363
|
+
newLines: Number(hunkMatch[4] ?? 1),
|
|
364
|
+
lines: []
|
|
365
|
+
};
|
|
366
|
+
currentFile.hunks.push(currentHunk);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!currentHunk) continue;
|
|
370
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
371
|
+
currentHunk.lines.push({ type: "add", content: line.slice(1), newLineNumber: newLine });
|
|
372
|
+
currentFile.additions += 1;
|
|
373
|
+
newLine += 1;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
377
|
+
currentHunk.lines.push({ type: "remove", content: line.slice(1), oldLineNumber: oldLine });
|
|
378
|
+
currentFile.deletions += 1;
|
|
379
|
+
oldLine += 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (line.startsWith(" ")) {
|
|
383
|
+
currentHunk.lines.push({
|
|
384
|
+
type: "context",
|
|
385
|
+
content: line.slice(1),
|
|
386
|
+
oldLineNumber: oldLine,
|
|
387
|
+
newLineNumber: newLine
|
|
388
|
+
});
|
|
389
|
+
oldLine += 1;
|
|
390
|
+
newLine += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (line === "\") {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const synthetic = { type: "context", content: line, oldLineNumber: oldLine, newLineNumber: newLine };
|
|
397
|
+
currentHunk.lines.push(synthetic);
|
|
398
|
+
}
|
|
399
|
+
return files.filter((file) => file.path);
|
|
400
|
+
}
|
|
401
|
+
function normalizeDiffPath(path) {
|
|
402
|
+
if (path === "/dev/null") return path;
|
|
403
|
+
return path.replace(/^[ab]\//, "");
|
|
404
|
+
}
|
|
405
|
+
function isMarkdownPath(path) {
|
|
406
|
+
return /\.(md|mdx)$/i.test(path);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/core/git.ts
|
|
410
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
411
|
+
import { execFile } from "node:child_process";
|
|
412
|
+
import { promisify } from "node:util";
|
|
413
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
414
|
+
import { join as join2, normalize, relative } from "node:path";
|
|
415
|
+
var execFileAsync = promisify(execFile);
|
|
416
|
+
async function getRepoRoot(cwd) {
|
|
417
|
+
const { stdout } = await execGit(["rev-parse", "--show-toplevel"], cwd);
|
|
418
|
+
return stdout.trim();
|
|
419
|
+
}
|
|
420
|
+
async function getDiff(mode, repoRoot) {
|
|
421
|
+
if (mode.kind === "staged") {
|
|
422
|
+
return execGitStdout(["diff", "--cached", "--no-ext-diff", "--no-color"], repoRoot);
|
|
423
|
+
}
|
|
424
|
+
if (mode.kind === "revision") {
|
|
425
|
+
return execGitStdout(["diff", "--no-ext-diff", "--no-color", mode.base, mode.target], repoRoot);
|
|
426
|
+
}
|
|
427
|
+
const trackedDiff = await execGitStdout(["diff", "--no-ext-diff", "--no-color"], repoRoot);
|
|
428
|
+
const untrackedDiff = await getUntrackedDiff(repoRoot);
|
|
429
|
+
return [trackedDiff, untrackedDiff].filter(Boolean).join("\n");
|
|
430
|
+
}
|
|
431
|
+
async function readFileForPreview(file, mode, repoRoot) {
|
|
432
|
+
const targetPath = file.status === "deleted" ? file.oldPath : file.path;
|
|
433
|
+
if (!isSafeRepoPath(repoRoot, targetPath)) {
|
|
434
|
+
throw new Error(`Unsafe file path: ${targetPath}`);
|
|
435
|
+
}
|
|
436
|
+
if (mode.kind === "staged") {
|
|
437
|
+
if (file.status === "deleted") {
|
|
438
|
+
return { content: await gitShow(`HEAD:${targetPath}`, repoRoot), deleted: true };
|
|
439
|
+
}
|
|
440
|
+
return { content: await gitShow(`:${targetPath}`, repoRoot), deleted: false };
|
|
441
|
+
}
|
|
442
|
+
if (mode.kind === "revision") {
|
|
443
|
+
const ref = file.status === "deleted" ? mode.base : mode.target;
|
|
444
|
+
return { content: await gitShow(`${ref}:${targetPath}`, repoRoot), deleted: file.status === "deleted" };
|
|
445
|
+
}
|
|
446
|
+
if (file.status === "deleted") {
|
|
447
|
+
return { content: await gitShow(`HEAD:${targetPath}`, repoRoot), deleted: true };
|
|
448
|
+
}
|
|
449
|
+
return { content: await readFile2(join2(repoRoot, targetPath), "utf8"), deleted: false };
|
|
450
|
+
}
|
|
451
|
+
function parseReviewMode(args) {
|
|
452
|
+
const filtered = args.filter(Boolean);
|
|
453
|
+
if (filtered.length === 0 || filtered[0] === "working") return { kind: "working" };
|
|
454
|
+
if (filtered[0] === "staged") return { kind: "staged" };
|
|
455
|
+
if (filtered.length === 2) return { kind: "revision", base: filtered[0], target: filtered[1] };
|
|
456
|
+
throw new Error("Usage: local-diff-reviewer [working|staged|<base> <target>]");
|
|
457
|
+
}
|
|
458
|
+
function diffHash(diff) {
|
|
459
|
+
return createHash2("sha256").update(diff).digest("hex").slice(0, 16);
|
|
460
|
+
}
|
|
461
|
+
function isSafeRepoPath(repoRoot, path) {
|
|
462
|
+
const normalized = normalize(join2(repoRoot, path));
|
|
463
|
+
const rel = relative(repoRoot, normalized);
|
|
464
|
+
return rel !== "" && !rel.startsWith("..") && !rel.startsWith("/");
|
|
465
|
+
}
|
|
466
|
+
async function gitShow(revPath, repoRoot) {
|
|
467
|
+
return execGitStdout(["show", revPath], repoRoot);
|
|
468
|
+
}
|
|
469
|
+
async function execGitStdout(args, cwd) {
|
|
470
|
+
const { stdout } = await execGit(args, cwd);
|
|
471
|
+
return stdout;
|
|
472
|
+
}
|
|
473
|
+
async function execGit(args, cwd) {
|
|
474
|
+
const { stdout, stderr } = await execFileAsync("git", args, {
|
|
475
|
+
cwd,
|
|
476
|
+
maxBuffer: 1024 * 1024 * 80
|
|
477
|
+
});
|
|
478
|
+
return { stdout, stderr };
|
|
479
|
+
}
|
|
480
|
+
async function getUntrackedDiff(repoRoot) {
|
|
481
|
+
const stdout = await execGitStdout(["ls-files", "--others", "--exclude-standard"], repoRoot);
|
|
482
|
+
const paths = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
483
|
+
const diffs = await Promise.all(
|
|
484
|
+
paths.map(async (path) => {
|
|
485
|
+
if (!isSafeRepoPath(repoRoot, path)) return "";
|
|
486
|
+
const content = await readFile2(join2(repoRoot, path));
|
|
487
|
+
if (content.includes(0)) return binaryAddedDiff(path);
|
|
488
|
+
return textAddedDiff(path, content.toString("utf8"));
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
return diffs.filter(Boolean).join("\n");
|
|
492
|
+
}
|
|
493
|
+
function textAddedDiff(path, content) {
|
|
494
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
495
|
+
const hasTrailingNewline = lines.at(-1) === "";
|
|
496
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
497
|
+
const additions = bodyLines.map((line) => `+${line}`).join("\n");
|
|
498
|
+
const noNewline = hasTrailingNewline ? "" : "\n\";
|
|
499
|
+
return [
|
|
500
|
+
`diff --git a/${path} b/${path}`,
|
|
501
|
+
"new file mode 100644",
|
|
502
|
+
"index 0000000..0000000",
|
|
503
|
+
"--- /dev/null",
|
|
504
|
+
`+++ b/${path}`,
|
|
505
|
+
`@@ -0,0 +1,${bodyLines.length} @@`,
|
|
506
|
+
additions + noNewline
|
|
507
|
+
].join("\n");
|
|
508
|
+
}
|
|
509
|
+
function binaryAddedDiff(path) {
|
|
510
|
+
return [`diff --git a/${path} b/${path}`, "new file mode 100644", "index 0000000..0000000", `Binary files /dev/null and b/${path} differ`].join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/server/index.ts
|
|
514
|
+
import express from "express";
|
|
515
|
+
import { existsSync } from "node:fs";
|
|
516
|
+
import { join as join3 } from "node:path";
|
|
517
|
+
|
|
518
|
+
// src/core/markdown-source-map.ts
|
|
519
|
+
import GithubSlugger from "github-slugger";
|
|
520
|
+
function buildMarkdownBlocks(content) {
|
|
521
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
522
|
+
const blocks = [];
|
|
523
|
+
const slugger = new GithubSlugger();
|
|
524
|
+
let index = 0;
|
|
525
|
+
while (index < lines.length) {
|
|
526
|
+
const line = lines[index];
|
|
527
|
+
const lineNumber = index + 1;
|
|
528
|
+
if (!line.trim()) {
|
|
529
|
+
index += 1;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (/^```/.test(line.trim())) {
|
|
533
|
+
const start2 = lineNumber;
|
|
534
|
+
const collected2 = [line];
|
|
535
|
+
index += 1;
|
|
536
|
+
while (index < lines.length && !/^```/.test(lines[index].trim())) {
|
|
537
|
+
collected2.push(lines[index]);
|
|
538
|
+
index += 1;
|
|
539
|
+
}
|
|
540
|
+
if (index < lines.length) {
|
|
541
|
+
collected2.push(lines[index]);
|
|
542
|
+
index += 1;
|
|
543
|
+
}
|
|
544
|
+
blocks.push(makeBlock("code", start2, index, collected2.join("\n"), slugger));
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
548
|
+
blocks.push(makeBlock("heading", lineNumber, lineNumber, line, slugger));
|
|
549
|
+
index += 1;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (/^\s*>/.test(line)) {
|
|
553
|
+
const start2 = lineNumber;
|
|
554
|
+
const collected2 = [];
|
|
555
|
+
while (index < lines.length && /^\s*>/.test(lines[index])) {
|
|
556
|
+
collected2.push(lines[index]);
|
|
557
|
+
index += 1;
|
|
558
|
+
}
|
|
559
|
+
blocks.push(makeBlock("blockquote", start2, index, collected2.join("\n"), slugger));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
|
|
563
|
+
const start2 = lineNumber;
|
|
564
|
+
const collected2 = [];
|
|
565
|
+
while (index < lines.length && (lines[index].trim() === "" || /^\s*([-*+]|\d+\.)\s+/.test(lines[index]))) {
|
|
566
|
+
collected2.push(lines[index]);
|
|
567
|
+
index += 1;
|
|
568
|
+
}
|
|
569
|
+
blocks.push(makeBlock("list", start2, index, collected2.join("\n"), slugger));
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (line.includes("|") && index + 1 < lines.length && /^\s*\|?[\s:-]+\|/.test(lines[index + 1])) {
|
|
573
|
+
const start2 = lineNumber;
|
|
574
|
+
const collected2 = [];
|
|
575
|
+
while (index < lines.length && lines[index].includes("|")) {
|
|
576
|
+
collected2.push(lines[index]);
|
|
577
|
+
index += 1;
|
|
578
|
+
}
|
|
579
|
+
blocks.push(makeBlock("table", start2, index, collected2.join("\n"), slugger));
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const start = lineNumber;
|
|
583
|
+
const collected = [];
|
|
584
|
+
while (index < lines.length && lines[index].trim() !== "") {
|
|
585
|
+
collected.push(lines[index]);
|
|
586
|
+
index += 1;
|
|
587
|
+
}
|
|
588
|
+
blocks.push(makeBlock("paragraph", start, index, collected.join("\n"), slugger));
|
|
589
|
+
}
|
|
590
|
+
return blocks;
|
|
591
|
+
}
|
|
592
|
+
function makeBlock(type, startLine, endLine, text, slugger) {
|
|
593
|
+
const seed = `${type}-${startLine}-${text.slice(0, 80)}`;
|
|
594
|
+
return {
|
|
595
|
+
id: slugger.slug(seed || `${type}-${startLine}`),
|
|
596
|
+
type,
|
|
597
|
+
startLine,
|
|
598
|
+
endLine,
|
|
599
|
+
text
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/core/prompt.ts
|
|
604
|
+
function formatPrompt(threads) {
|
|
605
|
+
return threads.map((thread) => {
|
|
606
|
+
const line = getAnchorLine(thread);
|
|
607
|
+
const location = line ? `${thread.filePath}:${line}` : thread.filePath;
|
|
608
|
+
const [firstComment, ...replies] = thread.comments;
|
|
609
|
+
const replyText = replies.map((comment, index) => {
|
|
610
|
+
const author = comment.author === "agent" ? "Agent" : "User";
|
|
611
|
+
return `Reply ${index + 1} (${author})
|
|
612
|
+
${comment.body.trim()}`;
|
|
613
|
+
}).join("\n");
|
|
614
|
+
return [`[thread:${thread.id}]`, location, firstComment?.body.trim(), replyText].filter(Boolean).join("\n");
|
|
615
|
+
}).join("\n\n");
|
|
616
|
+
}
|
|
617
|
+
function getAnchorLine(thread) {
|
|
618
|
+
if (thread.anchor.type === "file") return void 0;
|
|
619
|
+
return thread.anchor.lineNumber;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/server/index.ts
|
|
623
|
+
async function startServer(state, port = 4966) {
|
|
624
|
+
const app = express();
|
|
625
|
+
app.use(express.json({ limit: "2mb" }));
|
|
626
|
+
app.get("/api/session", (_req, res) => {
|
|
627
|
+
res.json(state.session);
|
|
628
|
+
});
|
|
629
|
+
app.get("/api/diff", (_req, res) => {
|
|
630
|
+
res.json({ files: state.diffFiles });
|
|
631
|
+
});
|
|
632
|
+
app.get("/api/markdown-preview", async (req, res, next) => {
|
|
633
|
+
try {
|
|
634
|
+
const filePath = String(req.query.path ?? "");
|
|
635
|
+
const file = state.diffFiles.find((item) => item.path === filePath || item.oldPath === filePath);
|
|
636
|
+
if (!file || !file.isMarkdown) {
|
|
637
|
+
res.status(404).json({ error: "Markdown file not found in diff" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const { content, deleted } = await readFileForPreview(file, state.session.mode, state.session.repoRoot);
|
|
641
|
+
const preview = {
|
|
642
|
+
filePath: file.path,
|
|
643
|
+
content,
|
|
644
|
+
deleted,
|
|
645
|
+
blocks: buildMarkdownBlocks(content)
|
|
646
|
+
};
|
|
647
|
+
res.json(preview);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
next(error);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
app.get("/api/threads", async (_req, res, next) => {
|
|
653
|
+
try {
|
|
654
|
+
res.json(await readComments(state.session.repoRoot));
|
|
655
|
+
} catch (error) {
|
|
656
|
+
next(error);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
app.post("/api/threads", async (req, res, next) => {
|
|
660
|
+
try {
|
|
661
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
662
|
+
const body = req.body;
|
|
663
|
+
const commentBody = body.body?.trim();
|
|
664
|
+
if (!commentBody) {
|
|
665
|
+
res.status(400).json({ error: "Comment body is required" });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const store = await readComments(state.session.repoRoot);
|
|
669
|
+
const existingThread = store.threads.find((thread2) => sameAnchor(thread2.anchor, body.anchor));
|
|
670
|
+
const comment = {
|
|
671
|
+
id: crypto.randomUUID(),
|
|
672
|
+
body: commentBody,
|
|
673
|
+
author: "user",
|
|
674
|
+
createdAt: now,
|
|
675
|
+
updatedAt: now
|
|
676
|
+
};
|
|
677
|
+
if (existingThread) {
|
|
678
|
+
existingThread.comments.push(comment);
|
|
679
|
+
existingThread.status = getOpenThreadStatus(existingThread);
|
|
680
|
+
existingThread.updatedAt = now;
|
|
681
|
+
await writeComments(state.session.repoRoot, store);
|
|
682
|
+
res.status(201).json(existingThread);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const thread = {
|
|
686
|
+
id: crypto.randomUUID(),
|
|
687
|
+
filePath: body.filePath,
|
|
688
|
+
anchor: body.anchor,
|
|
689
|
+
status: "submit",
|
|
690
|
+
comments: [comment],
|
|
691
|
+
createdAt: now,
|
|
692
|
+
updatedAt: now
|
|
693
|
+
};
|
|
694
|
+
store.threads.push(thread);
|
|
695
|
+
await writeComments(state.session.repoRoot, store);
|
|
696
|
+
res.status(201).json(thread);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
next(error);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
app.post("/api/threads/:id/comments", async (req, res, next) => {
|
|
702
|
+
try {
|
|
703
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
704
|
+
const store = await readComments(state.session.repoRoot);
|
|
705
|
+
const thread = store.threads.find((item) => item.id === req.params.id);
|
|
706
|
+
if (!thread) {
|
|
707
|
+
res.status(404).json({ error: "Thread not found" });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const body = req.body;
|
|
711
|
+
const commentBody = body.body?.trim();
|
|
712
|
+
if (!commentBody) {
|
|
713
|
+
res.status(400).json({ error: "Comment body is required" });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const comment = {
|
|
717
|
+
id: crypto.randomUUID(),
|
|
718
|
+
body: commentBody,
|
|
719
|
+
author: body.author === "agent" ? "agent" : "user",
|
|
720
|
+
createdAt: now,
|
|
721
|
+
updatedAt: now
|
|
722
|
+
};
|
|
723
|
+
thread.comments.push(comment);
|
|
724
|
+
if (thread.status !== "resolved") {
|
|
725
|
+
thread.status = getOpenThreadStatus(thread);
|
|
726
|
+
}
|
|
727
|
+
thread.updatedAt = now;
|
|
728
|
+
await writeComments(state.session.repoRoot, store);
|
|
729
|
+
res.status(201).json(comment);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
next(error);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
app.patch("/api/threads/:id", async (req, res, next) => {
|
|
735
|
+
try {
|
|
736
|
+
const store = await readComments(state.session.repoRoot);
|
|
737
|
+
const thread = store.threads.find((item) => item.id === req.params.id);
|
|
738
|
+
if (!thread) {
|
|
739
|
+
res.status(404).json({ error: "Thread not found" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (req.body.status === "resolved" || req.body.status === "replied" || req.body.status === "submit") {
|
|
743
|
+
thread.status = req.body.status === "resolved" ? "resolved" : getOpenThreadStatus(thread);
|
|
744
|
+
thread.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
745
|
+
}
|
|
746
|
+
await writeComments(state.session.repoRoot, store);
|
|
747
|
+
res.json(thread);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
next(error);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
app.delete("/api/threads/:id", async (req, res, next) => {
|
|
753
|
+
try {
|
|
754
|
+
const store = await readComments(state.session.repoRoot);
|
|
755
|
+
store.threads = store.threads.filter((item) => item.id !== req.params.id);
|
|
756
|
+
await writeComments(state.session.repoRoot, store);
|
|
757
|
+
res.status(204).end();
|
|
758
|
+
} catch (error) {
|
|
759
|
+
next(error);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
app.patch("/api/threads/:id/comments/:commentId", async (req, res, next) => {
|
|
763
|
+
try {
|
|
764
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
765
|
+
const store = await readComments(state.session.repoRoot);
|
|
766
|
+
const thread = store.threads.find((item) => item.id === req.params.id);
|
|
767
|
+
if (!thread) {
|
|
768
|
+
res.status(404).json({ error: "Thread not found" });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (getThreadStatus(thread) !== "submit") {
|
|
772
|
+
res.status(400).json({ error: "Only submitted comments can be edited" });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const comment = thread.comments.find((item) => item.id === req.params.commentId);
|
|
776
|
+
if (!comment) {
|
|
777
|
+
res.status(404).json({ error: "Comment not found" });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (comment.author === "agent") {
|
|
781
|
+
res.status(400).json({ error: "Agent comments are read-only" });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const nextBody = String(req.body?.body ?? "").trim();
|
|
785
|
+
if (!nextBody) {
|
|
786
|
+
res.status(400).json({ error: "Comment body is required" });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
comment.body = nextBody;
|
|
790
|
+
comment.updatedAt = now;
|
|
791
|
+
thread.updatedAt = now;
|
|
792
|
+
await writeComments(state.session.repoRoot, store);
|
|
793
|
+
res.json(comment);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
next(error);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
app.delete("/api/threads/:id/comments/:commentId", async (req, res, next) => {
|
|
799
|
+
try {
|
|
800
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
801
|
+
const store = await readComments(state.session.repoRoot);
|
|
802
|
+
const thread = store.threads.find((item) => item.id === req.params.id);
|
|
803
|
+
if (!thread) {
|
|
804
|
+
res.status(404).json({ error: "Thread not found" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (getThreadStatus(thread) !== "submit") {
|
|
808
|
+
res.status(400).json({ error: "Only submitted comments can be deleted" });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const comment = thread.comments.find((item) => item.id === req.params.commentId);
|
|
812
|
+
if (!comment) {
|
|
813
|
+
res.status(404).json({ error: "Comment not found" });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (comment.author === "agent") {
|
|
817
|
+
res.status(400).json({ error: "Agent comments are read-only" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
thread.comments = thread.comments.filter((item) => item.id !== req.params.commentId);
|
|
821
|
+
thread.updatedAt = now;
|
|
822
|
+
store.threads = store.threads.filter((item) => item.id !== thread.id || thread.comments.length > 0);
|
|
823
|
+
await writeComments(state.session.repoRoot, store);
|
|
824
|
+
res.status(204).end();
|
|
825
|
+
} catch (error) {
|
|
826
|
+
next(error);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
app.post("/api/prompt", async (req, res, next) => {
|
|
830
|
+
try {
|
|
831
|
+
const scope = req.body;
|
|
832
|
+
const store = await readComments(state.session.repoRoot);
|
|
833
|
+
const threads = selectPromptThreads(store.threads, scope);
|
|
834
|
+
res.json({ prompt: formatPrompt(threads) });
|
|
835
|
+
} catch (error) {
|
|
836
|
+
next(error);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
const webDist = state.webDist ?? join3(process.cwd(), "dist", "web");
|
|
840
|
+
if (existsSync(webDist)) {
|
|
841
|
+
app.use(express.static(webDist));
|
|
842
|
+
app.get(/.*/, (_req, res) => res.sendFile(join3(webDist, "index.html")));
|
|
843
|
+
}
|
|
844
|
+
app.use((error, _req, res, _next) => {
|
|
845
|
+
res.status(500).json({ error: error.message });
|
|
846
|
+
});
|
|
847
|
+
return new Promise((resolve2) => {
|
|
848
|
+
const server = app.listen(port, "127.0.0.1", () => {
|
|
849
|
+
const address = server.address();
|
|
850
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
851
|
+
resolve2(`http://127.0.0.1:${actualPort}`);
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
function selectPromptThreads(threads, scope) {
|
|
856
|
+
if (scope.type === "thread") return threads.filter((thread) => thread.id === scope.threadId);
|
|
857
|
+
if (scope.type === "file-unresolved") {
|
|
858
|
+
return threads.filter((thread) => thread.filePath === scope.filePath && thread.status !== "resolved");
|
|
859
|
+
}
|
|
860
|
+
return threads.filter((thread) => thread.status !== "resolved");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/cli/start.ts
|
|
864
|
+
var packageRoot = resolve(dirname2(fileURLToPath(import.meta.url)), "../..");
|
|
865
|
+
var builtWebDist = join4(packageRoot, "dist", "web");
|
|
866
|
+
async function main() {
|
|
867
|
+
const { dev, reviewArgs, comments } = parseCliOptions(process.argv.slice(2));
|
|
868
|
+
const mode = parseReviewMode(reviewArgs);
|
|
869
|
+
const repoRoot = await getRepoRoot(process.cwd());
|
|
870
|
+
const diff = await getDiff(mode, repoRoot);
|
|
871
|
+
const diffFiles = parseUnifiedDiff(diff);
|
|
872
|
+
const session = {
|
|
873
|
+
id: crypto.randomUUID(),
|
|
874
|
+
repoRoot,
|
|
875
|
+
mode,
|
|
876
|
+
diffHash: diffHash(diff),
|
|
877
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
878
|
+
};
|
|
879
|
+
const importResult = await importAgentComments(repoRoot, diffFiles, comments);
|
|
880
|
+
const hasBuiltWeb = existsSync2(join4(builtWebDist, "index.html"));
|
|
881
|
+
const apiUrl = await startServer({ session, diffFiles, webDist: hasBuiltWeb ? builtWebDist : void 0 });
|
|
882
|
+
const useVite = dev || !hasBuiltWeb;
|
|
883
|
+
const uiUrl = useVite ? "http://127.0.0.1:5173" : apiUrl;
|
|
884
|
+
if (useVite) {
|
|
885
|
+
startVite();
|
|
886
|
+
}
|
|
887
|
+
openBrowser(uiUrl);
|
|
888
|
+
console.log(`Diff Review is running: ${uiUrl}`);
|
|
889
|
+
console.log(`Mode: ${modeLabel(mode)}`);
|
|
890
|
+
console.log(`Files: ${diffFiles.length}`);
|
|
891
|
+
if (comments.length > 0) {
|
|
892
|
+
console.log(`Agent comments imported: ${importResult.imported}`);
|
|
893
|
+
for (const skipped of importResult.skipped) {
|
|
894
|
+
console.warn(`Skipped ${skipped}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function parseCliOptions(args) {
|
|
899
|
+
const reviewArgs = [];
|
|
900
|
+
const comments = [];
|
|
901
|
+
let dev = false;
|
|
902
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
903
|
+
const arg = args[index];
|
|
904
|
+
if (arg === "--dev") {
|
|
905
|
+
dev = true;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (arg === "--comment") {
|
|
909
|
+
const comment = args[index + 1];
|
|
910
|
+
if (!comment) throw new Error("--comment requires a JSON value");
|
|
911
|
+
comments.push(comment);
|
|
912
|
+
index += 1;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (arg.startsWith("--comment=")) {
|
|
916
|
+
const comment = arg.slice("--comment=".length);
|
|
917
|
+
if (!comment) throw new Error("--comment requires a JSON value");
|
|
918
|
+
comments.push(comment);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
reviewArgs.push(arg);
|
|
922
|
+
}
|
|
923
|
+
return { dev, reviewArgs, comments };
|
|
924
|
+
}
|
|
925
|
+
function modeLabel(mode) {
|
|
926
|
+
if (mode.kind === "revision") return `${mode.base}..${mode.target}`;
|
|
927
|
+
return mode.kind;
|
|
928
|
+
}
|
|
929
|
+
function startVite() {
|
|
930
|
+
const child = spawn("npm", ["run", "web:dev"], {
|
|
931
|
+
cwd: process.cwd(),
|
|
932
|
+
stdio: "inherit",
|
|
933
|
+
shell: process.platform === "win32",
|
|
934
|
+
env: { ...process.env, BROWSER: "none" }
|
|
935
|
+
});
|
|
936
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
937
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
938
|
+
}
|
|
939
|
+
function openBrowser(url) {
|
|
940
|
+
const child = process.platform === "darwin" ? spawn("open", [url], { stdio: "ignore", detached: true }) : process.platform === "win32" ? spawn("cmd", ["/c", "start", "", url], {
|
|
941
|
+
stdio: "ignore",
|
|
942
|
+
detached: true
|
|
943
|
+
}) : spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
944
|
+
child.unref();
|
|
945
|
+
}
|
|
946
|
+
main().catch((error) => {
|
|
947
|
+
console.error(error instanceof Error ? error.message : error);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
});
|