revdeck 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTRIBUTING.md +98 -0
  3. package/LICENSE +21 -0
  4. package/README.md +223 -2
  5. package/README.ru.md +210 -0
  6. package/SECURITY.md +45 -0
  7. package/dist/cli.js +2669 -0
  8. package/dist/cli.js.map +7 -0
  9. package/dist/daemon.js +3715 -0
  10. package/dist/daemon.js.map +7 -0
  11. package/dist/mcp.js +885 -0
  12. package/dist/mcp.js.map +7 -0
  13. package/dist/ui/assets/index-ByfGHwCb.css +1 -0
  14. package/dist/ui/assets/index-mvPA8Avw.js +10 -0
  15. package/dist/ui/favicon.svg +4 -0
  16. package/dist/ui/index.html +16 -0
  17. package/docs/README.md +17 -0
  18. package/docs/adr/0001-daemon-first.md +63 -0
  19. package/docs/architecture.md +91 -0
  20. package/docs/configuration.md +121 -0
  21. package/docs/integrations.md +84 -0
  22. package/docs/protocol.md +96 -0
  23. package/docs/ru/README.md +17 -0
  24. package/docs/ru/adr/0001-daemon-first.md +61 -0
  25. package/docs/ru/architecture.md +91 -0
  26. package/docs/ru/configuration.md +119 -0
  27. package/docs/ru/integrations.md +82 -0
  28. package/docs/ru/protocol.md +96 -0
  29. package/docs/ru/threat-model.md +50 -0
  30. package/docs/screenshot.png +0 -0
  31. package/docs/screenshot.ru.png +0 -0
  32. package/docs/threat-model.md +51 -0
  33. package/examples/monorepo.yml +38 -0
  34. package/examples/simple.yml +36 -0
  35. package/examples/web-chat-core.yml +45 -0
  36. package/package.json +79 -4
  37. package/skills/revdeck/en/Criteria.md +265 -0
  38. package/skills/revdeck/en/Feedback.md +64 -0
  39. package/skills/revdeck/en/Review.md +157 -0
  40. package/skills/revdeck/en/Rules.md +75 -0
  41. package/skills/revdeck/en/SKILL.md +78 -0
  42. package/skills/revdeck/en/Sources.md +47 -0
  43. package/skills/revdeck/ru/Criteria.md +249 -0
  44. package/skills/revdeck/ru/Feedback.md +64 -0
  45. package/skills/revdeck/ru/Review.md +148 -0
  46. package/skills/revdeck/ru/Rules.md +69 -0
  47. package/skills/revdeck/ru/SKILL.md +73 -0
  48. package/skills/revdeck/ru/Sources.md +46 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,3715 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+
17
+ // src/core/redact.ts
18
+ function redact(value) {
19
+ let text2 = String(value ?? "").replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ");
20
+ for (const [pattern, replacement] of TOKEN_PATTERNS) {
21
+ text2 = text2.replace(pattern, replacement);
22
+ }
23
+ return text2.slice(0, 4e3);
24
+ }
25
+ function redactLine(value, max = 300) {
26
+ return redact(value).replace(/\s+/g, " ").trim().slice(0, max);
27
+ }
28
+ var TOKEN_PATTERNS;
29
+ var init_redact = __esm({
30
+ "src/core/redact.ts"() {
31
+ "use strict";
32
+ TOKEN_PATTERNS = [
33
+ [/\bgl[a-z]{2,6}-[A-Za-z0-9_-]{10,}/g, "gl-***"],
34
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh-***"],
35
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "gh-***"],
36
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox-***"],
37
+ [/\bAKIA[0-9A-Z]{16}\b/g, "AKIA***"],
38
+ [/\bsk-[A-Za-z0-9]{20,}/g, "sk-***"],
39
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "jwt-***"],
40
+ [/\b[0-9a-f]{40,}\b/gi, "hex-***"],
41
+ [/((?:private[-_]token|access[-_]token|authorization|bearer|password|passwd|secret)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|\S+)/gi, "$1***"],
42
+ [/([?&](?:private_token|access_token|token|key)=)[^&\s]+/gi, "$1***"]
43
+ ];
44
+ }
45
+ });
46
+
47
+ // src/core/locale.ts
48
+ function detectLocale(explicit) {
49
+ const value = (explicit ?? process.env.REVDECK_LANG ?? process.env.LC_ALL ?? process.env.LANG ?? "").toLowerCase();
50
+ return value.startsWith("ru") ? "ru" : "en";
51
+ }
52
+ function setLocale(value) {
53
+ current = value;
54
+ process.env.REVDECK_LANG = value;
55
+ }
56
+ function tr(ru, en) {
57
+ return current === "ru" ? ru : en;
58
+ }
59
+ function detectTextLocale(text2, fallback = current) {
60
+ const cyrillic = (text2.match(/[А-Яа-яЁё]/g) ?? []).length;
61
+ const latin = (text2.match(/[A-Za-z]/g) ?? []).length;
62
+ if (cyrillic === 0 && latin === 0) return fallback;
63
+ return cyrillic >= latin ? "ru" : "en";
64
+ }
65
+ function resolveOutputLanguage(setting, texts) {
66
+ if (setting === "ru" || setting === "en") return setting;
67
+ return detectTextLocale(texts.join("\n"), "en");
68
+ }
69
+ var current;
70
+ var init_locale = __esm({
71
+ "src/core/locale.ts"() {
72
+ "use strict";
73
+ current = detectLocale();
74
+ }
75
+ });
76
+
77
+ // src/core/errors.ts
78
+ function messageOf(error) {
79
+ if (error instanceof Error) return error.message;
80
+ return String(error);
81
+ }
82
+ var MrReviewError, ConfigError, ProviderError, AuthError;
83
+ var init_errors = __esm({
84
+ "src/core/errors.ts"() {
85
+ "use strict";
86
+ MrReviewError = class extends Error {
87
+ code;
88
+ hint;
89
+ constructor(message, code = "REVDECK_ERROR", hint) {
90
+ super(message);
91
+ this.name = "MrReviewError";
92
+ this.code = code;
93
+ this.hint = hint;
94
+ }
95
+ };
96
+ ConfigError = class extends MrReviewError {
97
+ constructor(message, hint) {
98
+ super(message, "CONFIG", hint);
99
+ this.name = "ConfigError";
100
+ }
101
+ };
102
+ ProviderError = class extends MrReviewError {
103
+ constructor(message, hint) {
104
+ super(message, "PROVIDER", hint);
105
+ this.name = "ProviderError";
106
+ }
107
+ };
108
+ AuthError = class extends MrReviewError {
109
+ constructor(message, hint) {
110
+ super(message, "AUTH", hint);
111
+ this.name = "AuthError";
112
+ }
113
+ };
114
+ }
115
+ });
116
+
117
+ // src/core/diff.ts
118
+ function cleanPath(raw) {
119
+ let value = raw.trim();
120
+ if (value.startsWith('"') && value.endsWith('"')) {
121
+ value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
122
+ }
123
+ value = value.replace(/\t.*$/, "");
124
+ return value.replace(/^[ab]\//, "");
125
+ }
126
+ function splitGitHeader(line) {
127
+ const body = line.slice("diff --git ".length).trim();
128
+ const quoted = /^"(.*)"\s+"(.*)"$/.exec(body);
129
+ if (quoted) return { oldPath: cleanPath(quoted[1]), newPath: cleanPath(quoted[2]) };
130
+ const index = body.lastIndexOf(" b/");
131
+ if (index > 0) {
132
+ return { oldPath: cleanPath(body.slice(0, index)), newPath: cleanPath(body.slice(index + 1)) };
133
+ }
134
+ const [left, right] = body.split(/\s+/);
135
+ return { oldPath: cleanPath(left ?? ""), newPath: cleanPath(right ?? left ?? "") };
136
+ }
137
+ function parseHunkHeader(line) {
138
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
139
+ if (!match) throw new DiffParseError(`invalid hunk header: ${line.slice(0, 120)}`);
140
+ return {
141
+ header: line,
142
+ oldStart: Number(match[1]),
143
+ oldLines: match[2] === void 0 ? 1 : Number(match[2]),
144
+ newStart: Number(match[3]),
145
+ newLines: match[4] === void 0 ? 1 : Number(match[4]),
146
+ lines: []
147
+ };
148
+ }
149
+ function parseUnifiedDiff(patch) {
150
+ if (patch.length > MAX_DIFF_BYTES) {
151
+ throw new DiffParseError(`diff is larger than ${Math.round(MAX_DIFF_BYTES / 1024 / 1024)} MB: open a smaller range`);
152
+ }
153
+ const lines = patch.replace(/\r\n/g, "\n").split("\n");
154
+ const files = [];
155
+ let current2 = null;
156
+ let index = 0;
157
+ const start = (oldPath, newPath) => {
158
+ const file = {
159
+ path: newPath || oldPath,
160
+ oldPath: oldPath || newPath,
161
+ status: "modified",
162
+ binary: false,
163
+ hunks: []
164
+ };
165
+ files.push(file);
166
+ return file;
167
+ };
168
+ while (index < lines.length) {
169
+ const line = lines[index];
170
+ if (line.startsWith("diff --git ")) {
171
+ const { oldPath, newPath } = splitGitHeader(line);
172
+ current2 = start(oldPath, newPath);
173
+ index++;
174
+ continue;
175
+ }
176
+ if (!current2 && line.startsWith("--- ")) {
177
+ current2 = start(cleanPath(line.slice(4)), "");
178
+ current2.status = "modified";
179
+ index++;
180
+ continue;
181
+ }
182
+ if (!current2) {
183
+ index++;
184
+ continue;
185
+ }
186
+ if (line.startsWith("new file mode")) {
187
+ current2.status = "added";
188
+ index++;
189
+ continue;
190
+ }
191
+ if (line.startsWith("deleted file mode")) {
192
+ current2.status = "deleted";
193
+ index++;
194
+ continue;
195
+ }
196
+ if (line.startsWith("rename from ")) {
197
+ current2.oldPath = cleanPath(line.slice("rename from ".length));
198
+ current2.status = "renamed";
199
+ index++;
200
+ continue;
201
+ }
202
+ if (line.startsWith("rename to ")) {
203
+ current2.path = cleanPath(line.slice("rename to ".length));
204
+ current2.status = "renamed";
205
+ index++;
206
+ continue;
207
+ }
208
+ if (line.startsWith("Binary files ") || line === "GIT binary patch") {
209
+ current2.binary = true;
210
+ index++;
211
+ continue;
212
+ }
213
+ if (current2.hunks.length === 0 && line.startsWith("--- ")) {
214
+ const value = line.slice(4).trim();
215
+ if (value === "/dev/null") {
216
+ current2.status = "added";
217
+ } else {
218
+ current2.oldPath = cleanPath(value);
219
+ }
220
+ index++;
221
+ continue;
222
+ }
223
+ if (current2.hunks.length === 0 && line.startsWith("+++ ")) {
224
+ const value = line.slice(4).trim();
225
+ if (value === "/dev/null") {
226
+ current2.status = "deleted";
227
+ } else {
228
+ current2.path = cleanPath(value);
229
+ }
230
+ index++;
231
+ continue;
232
+ }
233
+ if (line.startsWith("@@")) {
234
+ const hunk = parseHunkHeader(line);
235
+ current2.hunks.push(hunk);
236
+ index++;
237
+ let oldLeft = hunk.oldLines;
238
+ let newLeft = hunk.newLines;
239
+ let oldNo = hunk.oldStart;
240
+ let newNo = hunk.newStart;
241
+ while (index < lines.length) {
242
+ const body = lines[index];
243
+ if (body.startsWith("diff --git ") || body.startsWith("@@")) break;
244
+ if (body === "" && index === lines.length - 1) break;
245
+ if (body.startsWith("\\")) {
246
+ index++;
247
+ continue;
248
+ }
249
+ const marker = body[0] ?? "";
250
+ let parsed;
251
+ if (marker === "+") {
252
+ parsed = { kind: "+", text: body.slice(1), oldLine: null, newLine: newNo };
253
+ newNo++;
254
+ newLeft--;
255
+ } else if (marker === "-") {
256
+ parsed = { kind: "-", text: body.slice(1), oldLine: oldNo, newLine: null };
257
+ oldNo++;
258
+ oldLeft--;
259
+ } else {
260
+ parsed = { kind: " ", text: marker === " " ? body.slice(1) : body, oldLine: oldNo, newLine: newNo };
261
+ oldNo++;
262
+ newNo++;
263
+ oldLeft--;
264
+ newLeft--;
265
+ }
266
+ hunk.lines.push(parsed);
267
+ index++;
268
+ if (oldLeft <= 0 && newLeft <= 0) break;
269
+ }
270
+ continue;
271
+ }
272
+ index++;
273
+ }
274
+ return files.filter((file) => file.path && file.path !== "/dev/null");
275
+ }
276
+ function diffStats(files) {
277
+ let additions = 0;
278
+ let deletions = 0;
279
+ for (const file of files) {
280
+ for (const hunk of file.hunks) {
281
+ for (const line of hunk.lines) {
282
+ if (line.kind === "+") additions++;
283
+ else if (line.kind === "-") deletions++;
284
+ }
285
+ }
286
+ }
287
+ return { files: files.length, additions, deletions };
288
+ }
289
+ function changedPaths(files) {
290
+ return files.map((file) => file.path);
291
+ }
292
+ function findFile(files, path8) {
293
+ return files.find((file) => file.path === path8) ?? files.find((file) => file.oldPath === path8);
294
+ }
295
+ function findNewLine(files, path8, line) {
296
+ const file = findFile(files, path8);
297
+ if (!file) return void 0;
298
+ for (const hunk of file.hunks) {
299
+ for (const item of hunk.lines) {
300
+ if (item.newLine === line) return item;
301
+ }
302
+ }
303
+ return void 0;
304
+ }
305
+ function snippetAround(files, path8, line, radius = 6) {
306
+ const file = findFile(files, path8);
307
+ if (!file || line == null) return null;
308
+ const flat = file.hunks.flatMap((hunk) => hunk.lines);
309
+ const target = flat.findIndex((item) => item.newLine === line);
310
+ if (target < 0) return null;
311
+ return flat.slice(Math.max(0, target - radius), target + radius + 1).map((item) => ({
312
+ kind: item.kind,
313
+ oldLine: item.oldLine,
314
+ newLine: item.newLine,
315
+ text: item.text.length > 400 ? `${item.text.slice(0, 400)}\u2026` : item.text,
316
+ target: item.newLine === line
317
+ }));
318
+ }
319
+ function linePosition(files, path8, line) {
320
+ const file = findFile(files, path8);
321
+ const found = findNewLine(files, path8, line);
322
+ if (!file || !found) return null;
323
+ return { newPath: file.path, newLine: line, oldPath: file.oldPath, oldLine: found.kind === " " ? found.oldLine : null };
324
+ }
325
+ var MAX_DIFF_BYTES, DiffParseError;
326
+ var init_diff = __esm({
327
+ "src/core/diff.ts"() {
328
+ "use strict";
329
+ MAX_DIFF_BYTES = 32 * 1024 * 1024;
330
+ DiffParseError = class extends Error {
331
+ };
332
+ }
333
+ });
334
+
335
+ // src/core/exec.ts
336
+ import { execFile } from "node:child_process";
337
+ function scrubEnv(extra) {
338
+ const env = {};
339
+ for (const [key, value] of Object.entries(process.env)) {
340
+ if (SECRET_ENV.includes(key)) continue;
341
+ env[key] = value;
342
+ }
343
+ env.PAGER = "cat";
344
+ env.GIT_PAGER = "cat";
345
+ env.GIT_TERMINAL_PROMPT = "0";
346
+ env.NO_COLOR = "1";
347
+ env.CLICOLOR = "0";
348
+ env.FORCE_COLOR = "0";
349
+ env.TERM = "dumb";
350
+ return { ...env, ...extra };
351
+ }
352
+ var SECRET_ENV, run;
353
+ var init_exec = __esm({
354
+ "src/core/exec.ts"() {
355
+ "use strict";
356
+ SECRET_ENV = [
357
+ "GITLAB_TOKEN",
358
+ "GITLAB_PRIVATE_TOKEN",
359
+ "GLAB_TOKEN",
360
+ "CI_JOB_TOKEN",
361
+ "GH_TOKEN",
362
+ "GITHUB_TOKEN",
363
+ "GITHUB_ENTERPRISE_TOKEN",
364
+ "GIT_ASKPASS",
365
+ "SSH_ASKPASS",
366
+ "REVDECK_TOKEN"
367
+ ];
368
+ run = (command, args, options = {}) => new Promise((resolve, reject) => {
369
+ const child = execFile(
370
+ command,
371
+ args,
372
+ {
373
+ cwd: options.cwd,
374
+ env: scrubEnv(options.env),
375
+ timeout: options.timeoutMs ?? 12e4,
376
+ maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
377
+ encoding: "utf8",
378
+ windowsHide: true,
379
+ shell: false
380
+ },
381
+ (error, stdout, stderr) => {
382
+ const exitCode = error?.code;
383
+ const code = typeof exitCode === "number" ? exitCode : error ? 1 : 0;
384
+ if (error && !(options.allowExitCodes ?? []).includes(code)) {
385
+ const reason = (stderr || stdout || error.message).trim();
386
+ reject(Object.assign(new Error(reason.slice(0, 4e3)), { command, code, stderr, stdout }));
387
+ return;
388
+ }
389
+ resolve({ stdout, stderr, code });
390
+ }
391
+ );
392
+ if (options.input !== void 0) {
393
+ child.stdin?.end(options.input);
394
+ }
395
+ });
396
+ }
397
+ });
398
+
399
+ // src/providers/util.ts
400
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
401
+ import { tmpdir } from "node:os";
402
+ import path5 from "node:path";
403
+ function parseJson(stdout, source) {
404
+ const text2 = stdout.trim();
405
+ if (!text2) throw new ProviderError(tr(`${source}: \u043F\u0443\u0441\u0442\u043E\u0439 \u043E\u0442\u0432\u0435\u0442`, `${source}: empty response`));
406
+ try {
407
+ return JSON.parse(text2);
408
+ } catch {
409
+ throw new ProviderError(tr(`${source}: \u043E\u0442\u0432\u0435\u0442 \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F JSON (${redact(text2).slice(0, 200)})`, `${source}: response is not JSON (${redact(text2).slice(0, 200)})`));
410
+ }
411
+ }
412
+ function cliError(error, platform, host) {
413
+ const message = redact(error?.message ?? error).trim();
414
+ const lower = message.toLowerCase();
415
+ if (/(auth|unauthor|401|403|not logged|token|credential|forbidden)/.test(lower)) {
416
+ const hint = platform === "gitlab" ? tr(`\u0432\u043E\u0439\u0434\u0438\u0442\u0435 \u0432 glab \u043D\u0430 \u044D\u0442\u043E\u043C \u0445\u043E\u0441\u0442\u0435: glab auth login --hostname ${host}`, `log in to glab for this host: glab auth login --hostname ${host}`) : tr(`\u0432\u043E\u0439\u0434\u0438\u0442\u0435 \u0432 gh: GH_HOST=${host} gh auth login`, `log in to gh: GH_HOST=${host} gh auth login`);
417
+ throw new AuthError(`${platform}: ${message || tr("\u043D\u0435\u0442 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438", "not authenticated")}`, hint);
418
+ }
419
+ if (/(not found|404)/.test(lower)) throw new ProviderError(`${platform}: ${message}`, tr("\u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u043D\u043E\u043C\u0435\u0440 \u0440\u0435\u0432\u044C\u044E \u0438 \u0434\u043E\u0441\u0442\u0443\u043F \u043A \u043F\u0440\u043E\u0435\u043A\u0442\u0443", "check the review number and project access"));
420
+ throw new ProviderError(`${platform}: ${message || tr("\u043A\u043E\u043C\u0430\u043D\u0434\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B\u0430\u0441\u044C \u0441 \u043E\u0448\u0438\u0431\u043A\u043E\u0439", "the command failed")}`);
421
+ }
422
+ async function withTempJson(data, fn) {
423
+ const dir = await mkdtemp(path5.join(tmpdir(), "revdeck-"));
424
+ const file = path5.join(dir, "payload.json");
425
+ try {
426
+ await writeFile(file, JSON.stringify(data), { mode: 384, flag: "wx" });
427
+ return await fn(file);
428
+ } finally {
429
+ await rm(dir, { recursive: true, force: true });
430
+ }
431
+ }
432
+ function query(path8, params) {
433
+ const url = new URL(path8.startsWith("/") ? path8 : `/${path8}`, "http://local");
434
+ for (const [key, value] of Object.entries(params)) {
435
+ if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
436
+ }
437
+ return `${url.pathname.replace(/^\//, "")}${url.search}`;
438
+ }
439
+ var init_util = __esm({
440
+ "src/providers/util.ts"() {
441
+ "use strict";
442
+ init_errors();
443
+ init_redact();
444
+ init_locale();
445
+ }
446
+ });
447
+
448
+ // src/providers/gitlab.ts
449
+ var gitlab_exports = {};
450
+ __export(gitlab_exports, {
451
+ GitLabProvider: () => GitLabProvider,
452
+ isAuthError: () => isAuthError
453
+ });
454
+ import { tmpdir as tmpdir2 } from "node:os";
455
+ function isAuthError(error) {
456
+ return /auth|401|403|unauthor|forbidden|not logged/i.test(redact(error));
457
+ }
458
+ var EMPTY_SHA, GitLabProvider;
459
+ var init_gitlab = __esm({
460
+ "src/providers/gitlab.ts"() {
461
+ "use strict";
462
+ init_diff();
463
+ init_errors();
464
+ init_redact();
465
+ init_exec();
466
+ init_util();
467
+ init_locale();
468
+ EMPTY_SHA = "0000000000000000000000000000000000000000";
469
+ GitLabProvider = class {
470
+ constructor(ref, options) {
471
+ this.ref = ref;
472
+ this.options = options;
473
+ this.run = options.runImpl ?? run;
474
+ }
475
+ ref;
476
+ options;
477
+ platform = "gitlab";
478
+ capabilities = { reactions: true, resolve: true, nativeDrafts: true };
479
+ run;
480
+ me = null;
481
+ infoCache = null;
482
+ get projectRef() {
483
+ return encodeURIComponent(this.ref.project);
484
+ }
485
+ endpoint(suffix) {
486
+ return `projects/${this.projectRef}/merge_requests/${this.ref.number}${suffix}`;
487
+ }
488
+ async call(args) {
489
+ const inputIndex = args.indexOf("--input");
490
+ const withHeaders = inputIndex >= 0 && !args.includes("-H") ? [...args.slice(0, inputIndex), "-H", "Content-Type: application/json", ...args.slice(inputIndex)] : args;
491
+ try {
492
+ const { stdout } = await this.run("glab", ["api", "--hostname", this.ref.host, ...withHeaders], {
493
+ // Вне рабочей копии: `.git/config`, который может менять агент, не участвует.
494
+ cwd: tmpdir2(),
495
+ timeoutMs: 9e4,
496
+ maxBuffer: 64 * 1024 * 1024
497
+ });
498
+ return stdout;
499
+ } catch (error) {
500
+ cliError(error, "gitlab", this.ref.host);
501
+ }
502
+ }
503
+ async json(args) {
504
+ return parseJson(await this.call(args), `GitLab ${this.ref.project}!${this.ref.number}`);
505
+ }
506
+ async paged(endpoint, params = {}) {
507
+ const items = [];
508
+ for (let page = 1; page <= 50; page++) {
509
+ const chunk = await this.json([query(endpoint, { ...params, per_page: 100, page })]);
510
+ if (!Array.isArray(chunk)) throw new ProviderError(tr("GitLab: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043C\u0430\u0441\u0441\u0438\u0432 \u0432 \u043E\u0442\u0432\u0435\u0442\u0435 \u043D\u0430 \u0441\u043F\u0438\u0441\u043E\u043A", "GitLab: expected an array in the list response"));
511
+ items.push(...chunk);
512
+ if (chunk.length < 100) break;
513
+ }
514
+ return items;
515
+ }
516
+ async info() {
517
+ if (this.infoCache) return this.infoCache;
518
+ const mr = await this.json([this.endpoint("")]);
519
+ const refs = mr.diff_refs ?? {};
520
+ const base = refs.base_sha || mr.sha || EMPTY_SHA;
521
+ const start = refs.start_sha || base;
522
+ const head = refs.head_sha || mr.sha || EMPTY_SHA;
523
+ const files = Number.parseInt(String(mr.changes_count ?? "0"), 10);
524
+ const info = {
525
+ ref: this.ref,
526
+ title: String(mr.title ?? ""),
527
+ description: String(mr.description ?? ""),
528
+ author: mr.author?.username ?? "",
529
+ authorName: mr.author?.name ?? "",
530
+ state: String(mr.state ?? ""),
531
+ draft: Boolean(mr.draft ?? mr.work_in_progress),
532
+ sourceBranch: String(mr.source_branch ?? ""),
533
+ targetBranch: String(mr.target_branch ?? ""),
534
+ webUrl: String(mr.web_url ?? ""),
535
+ commit: { base, start, head },
536
+ updatedAt: String(mr.updated_at ?? ""),
537
+ stats: { files: Number.isFinite(files) ? files : 0, additions: 0, deletions: 0 }
538
+ };
539
+ this.infoCache = info;
540
+ return info;
541
+ }
542
+ async diff() {
543
+ const items = await this.paged(this.endpoint("/diffs"));
544
+ const chunks = [];
545
+ for (const file of items) {
546
+ const oldPath = file.old_path;
547
+ const newPath = file.new_path;
548
+ let header = `diff --git a/${oldPath} b/${newPath}
549
+ `;
550
+ if (file.new_file) header += "new file mode 100644\n";
551
+ if (file.deleted_file) header += "deleted file mode 100644\n";
552
+ if (file.renamed_file) header += `rename from ${oldPath}
553
+ rename to ${newPath}
554
+ `;
555
+ if (file.binary || file.too_large || !file.diff) {
556
+ header += "Binary files differ\n";
557
+ chunks.push(header);
558
+ continue;
559
+ }
560
+ header += `--- ${file.new_file ? "/dev/null" : `a/${oldPath}`}
561
+ +++ ${file.deleted_file ? "/dev/null" : `b/${newPath}`}
562
+ `;
563
+ chunks.push(header + file.diff);
564
+ }
565
+ return parseUnifiedDiff(chunks.join("\n"));
566
+ }
567
+ async threads() {
568
+ const discussions = await this.paged(this.endpoint("/discussions"));
569
+ const threads = [];
570
+ for (const discussion of discussions) {
571
+ const notes = (discussion.notes ?? []).filter((note) => !note.system);
572
+ if (!notes.length) continue;
573
+ const placed = discussion.notes.find((note) => note.position) ?? discussion.notes[0];
574
+ const position = placed?.position ?? null;
575
+ const head = discussion.notes.find((note) => note.resolvable !== void 0) ?? discussion.notes[0];
576
+ threads.push({
577
+ id: String(discussion.id),
578
+ path: position?.new_path ?? position?.old_path ?? null,
579
+ line: position?.new_line ?? position?.old_line ?? null,
580
+ side: position?.new_line ? "new" : position?.old_line ? "old" : null,
581
+ resolvable: Boolean(head?.resolvable),
582
+ resolved: Boolean(head?.resolved),
583
+ notes: notes.map((note) => this.mapNote(note)),
584
+ updatedAt: String(notes[notes.length - 1]?.created_at ?? "")
585
+ });
586
+ }
587
+ return threads;
588
+ }
589
+ mapNote(note) {
590
+ return {
591
+ id: String(note.id),
592
+ author: note.author?.username ?? "?",
593
+ authorName: note.author?.name ?? "",
594
+ createdAt: String(note.created_at ?? ""),
595
+ body: String(note.body ?? ""),
596
+ system: Boolean(note.system)
597
+ };
598
+ }
599
+ async currentUser() {
600
+ if (this.me) return this.me;
601
+ const user = await this.json(["user"]);
602
+ this.me = { username: user.username, name: user.name };
603
+ return this.me;
604
+ }
605
+ async list(options = {}) {
606
+ const scope = options.scope ?? "open";
607
+ const limit = Math.min(options.limit ?? 50, 100);
608
+ const params = {
609
+ state: "opened",
610
+ order_by: "updated_at",
611
+ sort: "desc",
612
+ per_page: limit
613
+ };
614
+ if (scope === "mine") params.scope = "created_by_me";
615
+ if (scope === "reviewing") {
616
+ const me = await this.currentUser();
617
+ params.scope = "all";
618
+ params.reviewer_username = me.username;
619
+ }
620
+ const items = await this.json([query(`projects/${this.projectRef}/merge_requests`, params)]);
621
+ return (Array.isArray(items) ? items : []).map((mr) => ({
622
+ ref: { ...this.ref, number: mr.iid },
623
+ title: String(mr.title ?? ""),
624
+ author: mr.author?.username ?? "",
625
+ updatedAt: String(mr.updated_at ?? ""),
626
+ state: String(mr.state ?? ""),
627
+ draft: Boolean(mr.draft ?? mr.work_in_progress),
628
+ webUrl: String(mr.web_url ?? "")
629
+ }));
630
+ }
631
+ async findByBranch(branch) {
632
+ const items = await this.json([query(`projects/${this.projectRef}/merge_requests`, { state: "opened", source_branch: branch, per_page: 1 })]);
633
+ const first = Array.isArray(items) ? items[0] : void 0;
634
+ return first ? { ...this.ref, number: first.iid } : null;
635
+ }
636
+ async reply(thread, body) {
637
+ const endpoint = this.endpoint(`/discussions/${encodeURIComponent(thread.id)}/notes`);
638
+ const note = await withTempJson({ body }, async (file) => this.json(["--method", "POST", endpoint, "--input", file]));
639
+ return this.mapNote(note);
640
+ }
641
+ async resolve(threadId, resolved) {
642
+ await withTempJson({ resolved }, async (file) => {
643
+ await this.call(["--method", "PUT", this.endpoint(`/discussions/${encodeURIComponent(threadId)}`), "--input", file]);
644
+ });
645
+ }
646
+ async react(noteId, emoji) {
647
+ const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
648
+ const existing = await this.json([query(base, { per_page: 100 })]);
649
+ const me = await this.currentUser();
650
+ const mine = existing.find((item) => item.name === emoji && item.user?.username === me.username);
651
+ if (mine) {
652
+ await this.call(["--method", "DELETE", `${base}/${mine.id}`]);
653
+ return { active: false };
654
+ }
655
+ await withTempJson({ name: emoji }, async (file) => {
656
+ await this.call(["--method", "POST", base, "--input", file]);
657
+ });
658
+ return { active: true };
659
+ }
660
+ async createThread(input) {
661
+ const info = await this.info();
662
+ if (!info.commit.head || info.commit.head === EMPTY_SHA) {
663
+ throw new ProviderError(tr("GitLab \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B SHA \u0440\u0435\u0432\u0438\u0437\u0438\u0439: \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 \u043A \u0441\u0442\u0440\u043E\u043A\u0435 \u043D\u0435\u0432\u043E\u0437\u043C\u043E\u0436\u0435\u043D", "GitLab did not return revision SHAs: a line comment is not possible"));
664
+ }
665
+ const payload = input.line === null ? { body: input.body } : {
666
+ body: input.body,
667
+ position: {
668
+ position_type: "text",
669
+ base_sha: info.commit.base,
670
+ start_sha: info.commit.start,
671
+ head_sha: info.commit.head,
672
+ new_path: input.file,
673
+ old_path: input.file,
674
+ new_line: input.line,
675
+ ...input.oldLine !== null ? { old_line: input.oldLine } : {}
676
+ }
677
+ };
678
+ const created = await withTempJson(
679
+ payload,
680
+ async (file) => this.json(["--method", "POST", this.endpoint("/discussions"), "--input", file])
681
+ );
682
+ const note = created.notes?.[0];
683
+ if (!note) throw new ProviderError(tr("GitLab \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0441\u043E\u0437\u0434\u0430\u043D\u043D\u043E\u0435 \u043E\u0431\u0441\u0443\u0436\u0434\u0435\u043D\u0438\u0435", "GitLab did not return the created discussion"));
684
+ return { discussionId: String(created.id), noteId: String(note.id) };
685
+ }
686
+ };
687
+ }
688
+ });
689
+
690
+ // src/providers/github.ts
691
+ var github_exports = {};
692
+ __export(github_exports, {
693
+ GitHubProvider: () => GitHubProvider
694
+ });
695
+ import { tmpdir as tmpdir3 } from "node:os";
696
+ var THREADS_QUERY, REACTIONS, GitHubProvider;
697
+ var init_github = __esm({
698
+ "src/providers/github.ts"() {
699
+ "use strict";
700
+ init_diff();
701
+ init_errors();
702
+ init_exec();
703
+ init_util();
704
+ init_locale();
705
+ THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
706
+ repository(owner: $owner, name: $name) {
707
+ pullRequest(number: $number) {
708
+ reviewThreads(first: 100, after: $after) {
709
+ pageInfo { hasNextPage endCursor }
710
+ nodes {
711
+ id isResolved isOutdated path line originalLine diffSide
712
+ comments(first: 100) {
713
+ nodes { databaseId id author { login } body createdAt }
714
+ }
715
+ }
716
+ }
717
+ }
718
+ }
719
+ }`;
720
+ REACTIONS = {
721
+ thumbsup: "+1",
722
+ thumbsdown: "-1",
723
+ smile: "laugh",
724
+ tada: "hooray",
725
+ confused: "confused",
726
+ heart: "heart",
727
+ rocket: "rocket",
728
+ eyes: "eyes"
729
+ };
730
+ GitHubProvider = class {
731
+ constructor(ref, options) {
732
+ this.ref = ref;
733
+ this.options = options;
734
+ this.run = options.runImpl ?? run;
735
+ const [owner, name, ...rest] = ref.project.split("/");
736
+ if (!owner || !name || rest.length) {
737
+ throw new ProviderError(tr(`GitHub: \u043F\u0440\u043E\u0435\u043A\u0442 \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C owner/repo, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${ref.project}`, `GitHub: project must be owner/repo, got ${ref.project}`));
738
+ }
739
+ this.owner = owner;
740
+ this.name = name;
741
+ }
742
+ ref;
743
+ options;
744
+ platform = "github";
745
+ capabilities = { reactions: true, resolve: true, nativeDrafts: false };
746
+ run;
747
+ owner;
748
+ name;
749
+ me = null;
750
+ infoCache = null;
751
+ get repo() {
752
+ return `${this.owner}/${this.name}`;
753
+ }
754
+ get pullBase() {
755
+ return `repos/${this.repo}/pulls/${this.ref.number}`;
756
+ }
757
+ async call(args) {
758
+ const inputIndex = args.indexOf("--input");
759
+ const withHeaders = inputIndex >= 0 && !args.includes("-H") ? [...args.slice(0, inputIndex), "-H", "Content-Type: application/json", ...args.slice(inputIndex)] : args;
760
+ try {
761
+ const { stdout } = await this.run("gh", ["api", ...withHeaders], {
762
+ cwd: tmpdir3(),
763
+ env: { GH_HOST: this.ref.host },
764
+ timeoutMs: 9e4,
765
+ maxBuffer: 64 * 1024 * 1024
766
+ });
767
+ return stdout;
768
+ } catch (error) {
769
+ cliError(error, "github", this.ref.host);
770
+ }
771
+ }
772
+ async json(args) {
773
+ return parseJson(await this.call(args), `GitHub ${this.ref.project}#${this.ref.number}`);
774
+ }
775
+ async paged(endpoint, params = {}) {
776
+ const items = [];
777
+ for (let page = 1; page <= 50; page++) {
778
+ const chunk = await this.json([query(endpoint, { ...params, per_page: 100, page })]);
779
+ if (!Array.isArray(chunk)) throw new ProviderError(tr("GitHub: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043C\u0430\u0441\u0441\u0438\u0432 \u0432 \u043E\u0442\u0432\u0435\u0442\u0435 \u043D\u0430 \u0441\u043F\u0438\u0441\u043E\u043A", "GitHub: expected an array in the list response"));
780
+ items.push(...chunk);
781
+ if (chunk.length < 100) break;
782
+ }
783
+ return items;
784
+ }
785
+ async graphql(queryText, variables) {
786
+ const args = ["graphql", "-f", `query=${queryText}`];
787
+ for (const [key, value] of Object.entries(variables)) {
788
+ if (value === null || value === void 0) continue;
789
+ args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
790
+ }
791
+ const data = await this.json(args);
792
+ if (data.errors?.length) throw new ProviderError(`GitHub GraphQL: ${data.errors.map((item) => item.message).join("; ")}`);
793
+ if (!data.data) throw new ProviderError(tr("GitHub GraphQL: \u043F\u0443\u0441\u0442\u043E\u0439 \u043E\u0442\u0432\u0435\u0442", "GitHub GraphQL: empty response"));
794
+ return data.data;
795
+ }
796
+ async info() {
797
+ if (this.infoCache) return this.infoCache;
798
+ const pr = await this.json([this.pullBase]);
799
+ const state = pr.merged_at ? "merged" : String(pr.state ?? "");
800
+ const info = {
801
+ ref: this.ref,
802
+ title: String(pr.title ?? ""),
803
+ description: String(pr.body ?? ""),
804
+ author: pr.user?.login ?? "",
805
+ authorName: pr.user?.name ?? "",
806
+ state,
807
+ draft: Boolean(pr.draft),
808
+ sourceBranch: String(pr.head?.ref ?? ""),
809
+ targetBranch: String(pr.base?.ref ?? ""),
810
+ webUrl: String(pr.html_url ?? ""),
811
+ commit: { base: String(pr.base?.sha ?? ""), start: String(pr.base?.sha ?? ""), head: String(pr.head?.sha ?? "") },
812
+ updatedAt: String(pr.updated_at ?? ""),
813
+ stats: {
814
+ files: Number(pr.changed_files ?? 0),
815
+ additions: Number(pr.additions ?? 0),
816
+ deletions: Number(pr.deletions ?? 0)
817
+ }
818
+ };
819
+ this.infoCache = info;
820
+ return info;
821
+ }
822
+ async diff() {
823
+ const files = await this.paged(`${this.pullBase}/files`);
824
+ const chunks = [];
825
+ for (const file of files) {
826
+ const oldPath = file.previous_filename ?? file.filename;
827
+ const newPath = file.filename;
828
+ let header = `diff --git a/${oldPath} b/${newPath}
829
+ `;
830
+ if (file.status === "added") header += "new file mode 100644\n--- /dev/null\n+++ b/";
831
+ else if (file.status === "removed") header += "deleted file mode 100644\n--- a/";
832
+ else if (file.status === "renamed") header += `rename from ${oldPath}
833
+ rename to ${newPath}
834
+ --- a/${oldPath}
835
+ +++ b/`;
836
+ else header += `--- a/${oldPath}
837
+ +++ b/`;
838
+ header += file.status === "removed" ? "/dev/null\n" : `${newPath}
839
+ `;
840
+ if (!file.patch) {
841
+ chunks.push(`${header}Binary files differ
842
+ `);
843
+ continue;
844
+ }
845
+ chunks.push(header + file.patch);
846
+ }
847
+ return parseUnifiedDiff(chunks.join("\n"));
848
+ }
849
+ async threads() {
850
+ const nodes = [];
851
+ let after = null;
852
+ for (let page = 0; page < 10; page++) {
853
+ const data = await this.graphql(THREADS_QUERY, {
854
+ owner: this.owner,
855
+ name: this.name,
856
+ number: this.ref.number,
857
+ after
858
+ });
859
+ const connection = data.repository?.pullRequest?.reviewThreads;
860
+ if (!connection) throw new ProviderError(tr("GitHub: \u0442\u0440\u0435\u0434\u044B \u0440\u0435\u0432\u044C\u044E \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u044B (\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430?)", "GitHub: review threads not found (no access?)"));
861
+ nodes.push(...connection.nodes);
862
+ if (!connection.pageInfo.hasNextPage) break;
863
+ after = connection.pageInfo.endCursor;
864
+ }
865
+ return nodes.filter((node) => node.comments.nodes.length > 0).map((node) => {
866
+ const notes = node.comments.nodes.map((comment) => this.mapGqlNote(comment));
867
+ return {
868
+ id: node.id,
869
+ path: node.path,
870
+ line: node.line ?? node.originalLine,
871
+ side: node.diffSide === "LEFT" ? "old" : "new",
872
+ resolvable: true,
873
+ resolved: node.isResolved,
874
+ notes,
875
+ updatedAt: notes[notes.length - 1]?.createdAt ?? ""
876
+ };
877
+ });
878
+ }
879
+ mapGqlNote(comment) {
880
+ return {
881
+ id: String(comment.databaseId ?? comment.id),
882
+ author: comment.author?.login ?? "?",
883
+ authorName: "",
884
+ createdAt: String(comment.createdAt ?? ""),
885
+ body: String(comment.body ?? ""),
886
+ system: false
887
+ };
888
+ }
889
+ async currentUser() {
890
+ if (this.me) return this.me;
891
+ const user = await this.json(["user"]);
892
+ this.me = { username: user.login, name: user.name ?? "" };
893
+ return this.me;
894
+ }
895
+ async list(options = {}) {
896
+ const limit = Math.min(options.limit ?? 50, 100);
897
+ if (options.scope === "reviewing") {
898
+ const search = await this.json([
899
+ query("search/issues", { q: `repo:${this.repo} is:pr is:open review-requested:@me`, per_page: limit })
900
+ ]);
901
+ return (search.items ?? []).map((item) => ({
902
+ ref: { ...this.ref, number: item.number },
903
+ title: item.title,
904
+ author: item.user?.login ?? "",
905
+ updatedAt: item.updated_at,
906
+ state: item.state,
907
+ draft: Boolean(item.draft),
908
+ webUrl: item.html_url
909
+ }));
910
+ }
911
+ const pulls = await this.paged(`repos/${this.repo}/pulls`, { state: "open", sort: "updated", direction: "desc" });
912
+ const me = options.scope === "mine" ? (await this.currentUser()).username : null;
913
+ return pulls.filter((pull) => !me || pull.user?.login === me).slice(0, limit).map((pull) => ({
914
+ ref: { ...this.ref, number: pull.number },
915
+ title: pull.title,
916
+ author: pull.user?.login ?? "",
917
+ updatedAt: pull.updated_at,
918
+ state: pull.state,
919
+ draft: Boolean(pull.draft),
920
+ webUrl: pull.html_url
921
+ }));
922
+ }
923
+ async findByBranch(branch) {
924
+ const pulls = await this.json([query(`repos/${this.repo}/pulls`, { state: "open", head: `${this.owner}:${branch}`, per_page: 1 })]);
925
+ const first = Array.isArray(pulls) ? pulls[0] : void 0;
926
+ return first ? { ...this.ref, number: first.number } : null;
927
+ }
928
+ async reply(thread, body) {
929
+ const root = thread.notes[0]?.id;
930
+ if (!root) throw new ProviderError(tr("\u0432 \u0442\u0440\u0435\u0434\u0435 \u043D\u0435\u0442 \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u044F, \u043D\u0430 \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u043C\u043E\u0436\u043D\u043E \u043E\u0442\u0432\u0435\u0442\u0438\u0442\u044C", "the thread has no comment to reply to"));
931
+ const comment = await withTempJson(
932
+ { body },
933
+ async (file) => this.json(["--method", "POST", `${this.pullBase}/comments/${root}/replies`, "--input", file])
934
+ );
935
+ return {
936
+ id: String(comment.id),
937
+ author: comment.user?.login ?? "?",
938
+ authorName: "",
939
+ createdAt: comment.created_at,
940
+ body: comment.body,
941
+ system: false
942
+ };
943
+ }
944
+ async resolve(threadId, resolved) {
945
+ const mutation = resolved ? "mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }" : "mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }";
946
+ await this.graphql(mutation, { id: threadId });
947
+ }
948
+ async react(noteId, emoji) {
949
+ const content = REACTIONS[emoji];
950
+ if (!content) throw new ProviderError(tr(`GitHub \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0440\u0435\u0430\u043A\u0446\u0438\u044E ${emoji}`, `GitHub does not support reaction ${emoji}`));
951
+ const base = `repos/${this.repo}/pulls/comments/${noteId}/reactions`;
952
+ const existing = await this.json([query(base, { per_page: 100 })]);
953
+ const me = await this.currentUser();
954
+ const mine = existing.find((item) => item.content === content && item.user?.login === me.username);
955
+ if (mine) {
956
+ await this.call(["--method", "DELETE", `${base}/${mine.id}`]);
957
+ return { active: false };
958
+ }
959
+ await withTempJson({ content }, async (file) => {
960
+ await this.call(["--method", "POST", base, "--input", file]);
961
+ });
962
+ return { active: true };
963
+ }
964
+ async createThread(input) {
965
+ if (input.line === null) {
966
+ const comment2 = await withTempJson(
967
+ { body: input.body },
968
+ async (file) => this.json(["--method", "POST", `repos/${this.repo}/issues/${this.ref.number}/comments`, "--input", file])
969
+ );
970
+ return { discussionId: `issue-${comment2.id}`, noteId: String(comment2.id) };
971
+ }
972
+ const info = await this.info();
973
+ if (!info.commit.head) throw new ProviderError(tr("GitHub \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B head SHA: \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 \u043A \u0441\u0442\u0440\u043E\u043A\u0435 \u043D\u0435\u0432\u043E\u0437\u043C\u043E\u0436\u0435\u043D", "GitHub did not return the head SHA: a line comment is not possible"));
974
+ const payload = {
975
+ body: input.body,
976
+ commit_id: info.commit.head,
977
+ path: input.file,
978
+ line: input.line,
979
+ side: "RIGHT"
980
+ };
981
+ const comment = await withTempJson(
982
+ payload,
983
+ async (file) => this.json(["--method", "POST", `${this.pullBase}/comments`, "--input", file])
984
+ );
985
+ return { discussionId: `gh-${comment.id}`, noteId: String(comment.id) };
986
+ }
987
+ };
988
+ }
989
+ });
990
+
991
+ // src/daemon/main.ts
992
+ import { existsSync as existsSync4, readFileSync } from "node:fs";
993
+ import { appendFile, open as open2, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
994
+ import net2 from "node:net";
995
+ import path7 from "node:path";
996
+ import { fileURLToPath } from "node:url";
997
+ import { parseArgs } from "node:util";
998
+
999
+ // src/core/paths.ts
1000
+ import { existsSync } from "node:fs";
1001
+ import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
1002
+ import path from "node:path";
1003
+ import os from "node:os";
1004
+ function findRepoRoot(start = process.cwd()) {
1005
+ let dir = path.resolve(start);
1006
+ for (; ; ) {
1007
+ if (existsSync(path.join(dir, ".git"))) return dir;
1008
+ const parent = path.dirname(dir);
1009
+ if (parent === dir) return path.resolve(start);
1010
+ dir = parent;
1011
+ }
1012
+ }
1013
+ var CONFIG_FILES = [".revdeck.yml", ".revdeck.yaml"];
1014
+ function findConfigFile(root) {
1015
+ for (const name of CONFIG_FILES) {
1016
+ const candidate = path.join(root, name);
1017
+ if (existsSync(candidate)) return candidate;
1018
+ }
1019
+ return null;
1020
+ }
1021
+ function stateDir() {
1022
+ if (process.env.REVDECK_STATE_DIR) return path.resolve(process.env.REVDECK_STATE_DIR);
1023
+ const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
1024
+ return path.join(base, "revdeck");
1025
+ }
1026
+ function runtimeDir() {
1027
+ if (process.env.REVDECK_RUNTIME_DIR) return path.resolve(process.env.REVDECK_RUNTIME_DIR);
1028
+ const base = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), `revdeck-${process.getuid?.() ?? "user"}`);
1029
+ return path.join(base, "revdeck");
1030
+ }
1031
+ function socketPath() {
1032
+ return path.join(runtimeDir(), "daemon.sock");
1033
+ }
1034
+ function daemonInfoPath() {
1035
+ return path.join(stateDir(), "daemon.json");
1036
+ }
1037
+ function logPath() {
1038
+ return path.join(stateDir(), "daemon.log");
1039
+ }
1040
+ function databasePath() {
1041
+ return path.join(stateDir(), "revdeck.sqlite3");
1042
+ }
1043
+ async function ensureDir(dir, mode = 448) {
1044
+ await mkdir(dir, { recursive: true, mode });
1045
+ const info = await lstat(dir);
1046
+ if (!info.isDirectory()) throw new Error(`${dir}: expected a directory`);
1047
+ if (process.platform !== "win32") {
1048
+ const { chmod: chmod2 } = await import("node:fs/promises");
1049
+ await chmod2(dir, mode);
1050
+ }
1051
+ }
1052
+ async function readTextNoFollow(file, maxBytes = 4 * 1024 * 1024) {
1053
+ const info = await lstat(file);
1054
+ if (info.isSymbolicLink()) throw new Error(`${path.basename(file)}: symbolic link, read refused`);
1055
+ if (!info.isFile()) throw new Error(`${path.basename(file)}: not a regular file`);
1056
+ if (info.size > maxBytes) throw new Error(`${path.basename(file)}: file is larger than ${Math.round(maxBytes / 1024)} KB`);
1057
+ const handle = await open(file, "r");
1058
+ try {
1059
+ return await handle.readFile({ encoding: "utf8" });
1060
+ } finally {
1061
+ await handle.close();
1062
+ }
1063
+ }
1064
+ async function writeFileAtomic(file, data, mode = 384) {
1065
+ await ensureDir(path.dirname(file));
1066
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now().toString(36)}.tmp`);
1067
+ let handle;
1068
+ try {
1069
+ handle = await open(tmp, "wx", mode);
1070
+ await handle.writeFile(data, "utf8");
1071
+ await handle.sync();
1072
+ } finally {
1073
+ await handle?.close();
1074
+ }
1075
+ try {
1076
+ await rename(tmp, file);
1077
+ } catch (error) {
1078
+ await unlink(tmp).catch(() => void 0);
1079
+ throw error;
1080
+ }
1081
+ }
1082
+
1083
+ // src/daemon/main.ts
1084
+ init_redact();
1085
+
1086
+ // src/version.ts
1087
+ var VERSION = "0.1.0";
1088
+ var PROTOCOL_VERSION = 1;
1089
+
1090
+ // src/daemon/main.ts
1091
+ init_locale();
1092
+
1093
+ // src/daemon/store.ts
1094
+ import { DatabaseSync } from "node:sqlite";
1095
+ import { mkdirSync } from "node:fs";
1096
+ import path2 from "node:path";
1097
+
1098
+ // src/core/types.ts
1099
+ var LEVELS = ["blocker", "major", "minor", "question"];
1100
+
1101
+ // src/core/refs.ts
1102
+ function reviewId(ref) {
1103
+ return `${ref.platform}:${ref.host}/${ref.project}!${ref.number}`;
1104
+ }
1105
+ var GITLAB_URL = /^(?:https?:\/\/)?([^/]+)\/(.+?)\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/;
1106
+ var GITHUB_URL = /^(?:https?:\/\/)?([^/]+)\/([^/]+\/[^/]+)\/pull\/(\d+)(?:[/?#].*)?$/;
1107
+ function parseReviewUrl(input) {
1108
+ const value = input.trim();
1109
+ const gitlab = GITLAB_URL.exec(value);
1110
+ if (gitlab) {
1111
+ return { platform: "gitlab", host: gitlab[1], project: gitlab[2], number: Number(gitlab[3]) };
1112
+ }
1113
+ const github = GITHUB_URL.exec(value);
1114
+ if (github) {
1115
+ return { platform: "github", host: github[1], project: github[2], number: Number(github[3]) };
1116
+ }
1117
+ const short = /^(.+?)([#!])(\d+)$/.exec(value);
1118
+ if (short) {
1119
+ return {
1120
+ platform: short[2] === "!" ? "gitlab" : "github",
1121
+ host: short[2] === "!" ? "gitlab.com" : "github.com",
1122
+ project: short[1],
1123
+ number: Number(short[3])
1124
+ };
1125
+ }
1126
+ throw new Error(
1127
+ "could not parse the MR/PR reference: expected https://<host>/<project>/-/merge_requests/<N> or https://<host>/<owner>/<repo>/pull/<N>, or project!N / owner/repo#N"
1128
+ );
1129
+ }
1130
+ function isLevel(value) {
1131
+ return typeof value === "string" && LEVELS.includes(value);
1132
+ }
1133
+ function normalizeLevel(value) {
1134
+ return isLevel(value) ? value : "question";
1135
+ }
1136
+
1137
+ // src/daemon/store.ts
1138
+ function text(row, key) {
1139
+ const value = row[key];
1140
+ return value === null || value === void 0 ? "" : String(value);
1141
+ }
1142
+ function json(row, key, fallback) {
1143
+ const value = row[key];
1144
+ if (typeof value !== "string" || !value) return fallback;
1145
+ try {
1146
+ return JSON.parse(value);
1147
+ } catch {
1148
+ return fallback;
1149
+ }
1150
+ }
1151
+ var Store = class {
1152
+ db;
1153
+ constructor(file) {
1154
+ if (file !== ":memory:") mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
1155
+ this.db = new DatabaseSync(file);
1156
+ this.db.exec("PRAGMA journal_mode = WAL");
1157
+ this.db.exec("PRAGMA foreign_keys = ON");
1158
+ this.db.exec("PRAGMA busy_timeout = 5000");
1159
+ this.migrate();
1160
+ }
1161
+ close() {
1162
+ this.db.close();
1163
+ }
1164
+ migrate() {
1165
+ this.db.exec(`
1166
+ CREATE TABLE IF NOT EXISTS reviews (
1167
+ id TEXT PRIMARY KEY,
1168
+ platform TEXT NOT NULL,
1169
+ host TEXT NOT NULL,
1170
+ project TEXT NOT NULL,
1171
+ number INTEGER NOT NULL,
1172
+ title TEXT NOT NULL,
1173
+ author TEXT NOT NULL,
1174
+ state TEXT NOT NULL,
1175
+ web_url TEXT NOT NULL,
1176
+ updated_at TEXT NOT NULL,
1177
+ synced_at TEXT NOT NULL,
1178
+ root TEXT NOT NULL,
1179
+ info_json TEXT NOT NULL
1180
+ );
1181
+ CREATE TABLE IF NOT EXISTS review_data (
1182
+ review_id TEXT PRIMARY KEY REFERENCES reviews(id) ON DELETE CASCADE,
1183
+ diff_json TEXT NOT NULL,
1184
+ threads_json TEXT NOT NULL,
1185
+ config_json TEXT NOT NULL,
1186
+ config_path TEXT,
1187
+ errors_json TEXT NOT NULL,
1188
+ synced_at TEXT NOT NULL
1189
+ );
1190
+ CREATE TABLE IF NOT EXISTS summaries (
1191
+ review_id TEXT PRIMARY KEY REFERENCES reviews(id) ON DELETE CASCADE,
1192
+ title TEXT NOT NULL,
1193
+ scope TEXT NOT NULL,
1194
+ summary TEXT NOT NULL,
1195
+ changes_json TEXT NOT NULL,
1196
+ focus_json TEXT NOT NULL,
1197
+ updated_at TEXT NOT NULL
1198
+ );
1199
+ CREATE TABLE IF NOT EXISTS findings (
1200
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
1201
+ id TEXT NOT NULL,
1202
+ fingerprint TEXT NOT NULL,
1203
+ level TEXT NOT NULL,
1204
+ file TEXT NOT NULL,
1205
+ line INTEGER,
1206
+ end_line INTEGER,
1207
+ title TEXT NOT NULL,
1208
+ body TEXT NOT NULL,
1209
+ comment TEXT NOT NULL,
1210
+ status TEXT NOT NULL,
1211
+ discussion_id TEXT,
1212
+ published_at TEXT,
1213
+ decision TEXT,
1214
+ note TEXT NOT NULL,
1215
+ publish_error TEXT NOT NULL DEFAULT '',
1216
+ updated_at TEXT NOT NULL,
1217
+ PRIMARY KEY (review_id, id)
1218
+ );
1219
+ CREATE TABLE IF NOT EXISTS drafts (
1220
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
1221
+ thread_id TEXT NOT NULL,
1222
+ body TEXT NOT NULL,
1223
+ resolve INTEGER NOT NULL,
1224
+ note_for_agent TEXT NOT NULL,
1225
+ agent_action TEXT NOT NULL,
1226
+ posted TEXT NOT NULL,
1227
+ replied INTEGER NOT NULL,
1228
+ resolved INTEGER NOT NULL,
1229
+ error TEXT NOT NULL,
1230
+ updated_at TEXT NOT NULL,
1231
+ PRIMARY KEY (review_id, thread_id)
1232
+ );
1233
+ CREATE TABLE IF NOT EXISTS notes (
1234
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
1235
+ id TEXT NOT NULL,
1236
+ file TEXT NOT NULL,
1237
+ line INTEGER NOT NULL,
1238
+ side TEXT NOT NULL,
1239
+ text TEXT NOT NULL,
1240
+ to_agent INTEGER NOT NULL,
1241
+ agent_action TEXT NOT NULL,
1242
+ agent_reply TEXT NOT NULL,
1243
+ posted TEXT NOT NULL,
1244
+ discussion_id TEXT NOT NULL,
1245
+ note_id TEXT NOT NULL,
1246
+ error TEXT NOT NULL,
1247
+ created_at TEXT NOT NULL,
1248
+ updated_at TEXT NOT NULL,
1249
+ PRIMARY KEY (review_id, id)
1250
+ );
1251
+ CREATE TABLE IF NOT EXISTS decisions (
1252
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
1253
+ finding_id TEXT NOT NULL,
1254
+ decision TEXT,
1255
+ note TEXT NOT NULL,
1256
+ updated_at TEXT NOT NULL,
1257
+ PRIMARY KEY (review_id, finding_id)
1258
+ );
1259
+ CREATE TABLE IF NOT EXISTS approvals (
1260
+ id TEXT PRIMARY KEY,
1261
+ review_id TEXT NOT NULL,
1262
+ kind TEXT NOT NULL,
1263
+ hash TEXT NOT NULL,
1264
+ items_json TEXT NOT NULL,
1265
+ expires_at TEXT NOT NULL,
1266
+ used_at TEXT,
1267
+ created_at TEXT NOT NULL
1268
+ );
1269
+ CREATE TABLE IF NOT EXISTS audit (
1270
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1271
+ at TEXT NOT NULL,
1272
+ review_id TEXT,
1273
+ action TEXT NOT NULL,
1274
+ detail TEXT NOT NULL
1275
+ );
1276
+ CREATE INDEX IF NOT EXISTS audit_review ON audit(review_id, at);
1277
+ `);
1278
+ }
1279
+ saveReview(info, root, syncedAt = (/* @__PURE__ */ new Date()).toISOString()) {
1280
+ const id = reviewId(info.ref);
1281
+ this.db.prepare(
1282
+ `INSERT INTO reviews (id, platform, host, project, number, title, author, state, web_url, updated_at, synced_at, root, info_json)
1283
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1284
+ ON CONFLICT(id) DO UPDATE SET
1285
+ title = excluded.title, author = excluded.author, state = excluded.state,
1286
+ web_url = excluded.web_url, updated_at = excluded.updated_at,
1287
+ synced_at = excluded.synced_at, root = excluded.root, info_json = excluded.info_json`
1288
+ ).run(
1289
+ id,
1290
+ info.ref.platform,
1291
+ info.ref.host,
1292
+ info.ref.project,
1293
+ info.ref.number,
1294
+ info.title,
1295
+ info.author,
1296
+ info.state,
1297
+ info.webUrl,
1298
+ info.updatedAt,
1299
+ syncedAt,
1300
+ root,
1301
+ JSON.stringify(info)
1302
+ );
1303
+ return id;
1304
+ }
1305
+ saveSnapshot(id, snapshot) {
1306
+ this.db.prepare(
1307
+ `INSERT INTO review_data (review_id, diff_json, threads_json, config_json, config_path, errors_json, synced_at)
1308
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1309
+ ON CONFLICT(review_id) DO UPDATE SET
1310
+ diff_json = excluded.diff_json, threads_json = excluded.threads_json,
1311
+ config_json = excluded.config_json, config_path = excluded.config_path,
1312
+ errors_json = excluded.errors_json, synced_at = excluded.synced_at`
1313
+ ).run(
1314
+ id,
1315
+ JSON.stringify(snapshot.diff),
1316
+ JSON.stringify(snapshot.threads),
1317
+ JSON.stringify(snapshot.config),
1318
+ snapshot.configPath,
1319
+ JSON.stringify(snapshot.errors),
1320
+ snapshot.syncedAt
1321
+ );
1322
+ this.db.prepare("UPDATE reviews SET synced_at = ? WHERE id = ?").run(snapshot.syncedAt, id);
1323
+ }
1324
+ listReviews() {
1325
+ const rows = this.db.prepare("SELECT * FROM reviews ORDER BY synced_at DESC").all();
1326
+ return rows.map((row) => ({
1327
+ id: text(row, "id"),
1328
+ platform: text(row, "platform"),
1329
+ host: text(row, "host"),
1330
+ project: text(row, "project"),
1331
+ number: Number(row.number),
1332
+ title: text(row, "title"),
1333
+ author: text(row, "author"),
1334
+ state: text(row, "state"),
1335
+ webUrl: text(row, "web_url"),
1336
+ updatedAt: text(row, "updated_at"),
1337
+ syncedAt: text(row, "synced_at"),
1338
+ root: text(row, "root")
1339
+ }));
1340
+ }
1341
+ getReview(id) {
1342
+ const row = this.db.prepare("SELECT * FROM reviews WHERE id = ?").get(id);
1343
+ if (!row) return null;
1344
+ const data = this.db.prepare("SELECT * FROM review_data WHERE review_id = ?").get(id);
1345
+ if (!data) return null;
1346
+ return {
1347
+ row: {
1348
+ id: text(row, "id"),
1349
+ platform: text(row, "platform"),
1350
+ host: text(row, "host"),
1351
+ project: text(row, "project"),
1352
+ number: Number(row.number),
1353
+ title: text(row, "title"),
1354
+ author: text(row, "author"),
1355
+ state: text(row, "state"),
1356
+ web_url: text(row, "web_url"),
1357
+ updated_at: text(row, "updated_at"),
1358
+ synced_at: text(row, "synced_at"),
1359
+ root: text(row, "root")
1360
+ },
1361
+ snapshot: {
1362
+ diff: json(data, "diff_json", []),
1363
+ threads: json(data, "threads_json", []),
1364
+ config: json(data, "config_json", {}),
1365
+ configPath: data.config_path ? String(data.config_path) : null,
1366
+ errors: json(data, "errors_json", []),
1367
+ syncedAt: text(data, "synced_at")
1368
+ }
1369
+ };
1370
+ }
1371
+ info(id) {
1372
+ const row = this.db.prepare("SELECT info_json FROM reviews WHERE id = ?").get(id);
1373
+ if (!row) return null;
1374
+ return json(row, "info_json", null);
1375
+ }
1376
+ removeReview(id) {
1377
+ this.db.prepare("DELETE FROM reviews WHERE id = ?").run(id);
1378
+ }
1379
+ // --- summary ---
1380
+ getSummary(id) {
1381
+ const row = this.db.prepare("SELECT * FROM summaries WHERE review_id = ?").get(id);
1382
+ if (!row) return null;
1383
+ return {
1384
+ title: text(row, "title"),
1385
+ scope: text(row, "scope"),
1386
+ summary: text(row, "summary"),
1387
+ changes: json(row, "changes_json", []),
1388
+ focus: json(row, "focus_json", [])
1389
+ };
1390
+ }
1391
+ setSummary(id, summary) {
1392
+ this.db.prepare(
1393
+ `INSERT INTO summaries (review_id, title, scope, summary, changes_json, focus_json, updated_at)
1394
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1395
+ ON CONFLICT(review_id) DO UPDATE SET
1396
+ title = excluded.title, scope = excluded.scope, summary = excluded.summary,
1397
+ changes_json = excluded.changes_json, focus_json = excluded.focus_json, updated_at = excluded.updated_at`
1398
+ ).run(id, summary.title, summary.scope, summary.summary, JSON.stringify(summary.changes), JSON.stringify(summary.focus), (/* @__PURE__ */ new Date()).toISOString());
1399
+ }
1400
+ // --- findings ---
1401
+ listFindings(id) {
1402
+ const rows = this.db.prepare("SELECT * FROM findings WHERE review_id = ? ORDER BY rowid").all(id);
1403
+ return rows.map((row) => ({
1404
+ id: text(row, "id"),
1405
+ fingerprint: text(row, "fingerprint"),
1406
+ level: text(row, "level"),
1407
+ file: text(row, "file"),
1408
+ line: row.line === null || row.line === void 0 ? null : Number(row.line),
1409
+ endLine: row.end_line === null || row.end_line === void 0 ? null : Number(row.end_line),
1410
+ title: text(row, "title"),
1411
+ body: text(row, "body"),
1412
+ comment: text(row, "comment"),
1413
+ status: text(row, "status"),
1414
+ discussionId: row.discussion_id ? String(row.discussion_id) : null,
1415
+ publishedAt: row.published_at ? String(row.published_at) : null,
1416
+ decision: row.decision ? String(row.decision) : null,
1417
+ note: text(row, "note"),
1418
+ publishError: text(row, "publish_error")
1419
+ }));
1420
+ }
1421
+ replaceFindings(id, findings) {
1422
+ this.transaction(() => {
1423
+ this.db.prepare("DELETE FROM findings WHERE review_id = ?").run(id);
1424
+ const insert = this.db.prepare(
1425
+ `INSERT INTO findings (review_id, id, fingerprint, level, file, line, end_line, title, body, comment, status, discussion_id, published_at, decision, note, publish_error, updated_at)
1426
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1427
+ );
1428
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1429
+ for (const finding of findings) {
1430
+ insert.run(
1431
+ id,
1432
+ finding.id,
1433
+ finding.fingerprint,
1434
+ finding.level,
1435
+ finding.file,
1436
+ finding.line,
1437
+ finding.endLine,
1438
+ finding.title,
1439
+ finding.body,
1440
+ finding.comment,
1441
+ finding.status,
1442
+ finding.discussionId,
1443
+ finding.publishedAt,
1444
+ finding.decision,
1445
+ finding.note,
1446
+ finding.publishError,
1447
+ now
1448
+ );
1449
+ }
1450
+ });
1451
+ }
1452
+ updateFinding(id, finding) {
1453
+ this.db.prepare(
1454
+ `UPDATE findings SET level = ?, file = ?, line = ?, end_line = ?, title = ?, body = ?, comment = ?,
1455
+ status = ?, discussion_id = ?, published_at = ?, decision = ?, note = ?, publish_error = ?, updated_at = ?
1456
+ WHERE review_id = ? AND id = ?`
1457
+ ).run(
1458
+ finding.level,
1459
+ finding.file,
1460
+ finding.line,
1461
+ finding.endLine,
1462
+ finding.title,
1463
+ finding.body,
1464
+ finding.comment,
1465
+ finding.status,
1466
+ finding.discussionId,
1467
+ finding.publishedAt,
1468
+ finding.decision,
1469
+ finding.note,
1470
+ finding.publishError,
1471
+ (/* @__PURE__ */ new Date()).toISOString(),
1472
+ id,
1473
+ finding.id
1474
+ );
1475
+ }
1476
+ // --- drafts ---
1477
+ listDrafts(id) {
1478
+ const rows = this.db.prepare("SELECT * FROM drafts WHERE review_id = ? ORDER BY rowid").all(id);
1479
+ return rows.map((row) => ({
1480
+ threadId: text(row, "thread_id"),
1481
+ body: text(row, "body"),
1482
+ resolve: Number(row.resolve) === 1,
1483
+ noteForAgent: text(row, "note_for_agent"),
1484
+ agentAction: text(row, "agent_action"),
1485
+ posted: text(row, "posted"),
1486
+ replied: Number(row.replied) === 1,
1487
+ resolved: Number(row.resolved) === 1,
1488
+ error: text(row, "error"),
1489
+ updatedAt: text(row, "updated_at")
1490
+ }));
1491
+ }
1492
+ getDraft(id, threadId) {
1493
+ return this.listDrafts(id).find((draft) => draft.threadId === threadId) ?? null;
1494
+ }
1495
+ upsertDraft(id, draft) {
1496
+ this.db.prepare(
1497
+ `INSERT INTO drafts (review_id, thread_id, body, resolve, note_for_agent, agent_action, posted, replied, resolved, error, updated_at)
1498
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1499
+ ON CONFLICT(review_id, thread_id) DO UPDATE SET
1500
+ body = excluded.body, resolve = excluded.resolve, note_for_agent = excluded.note_for_agent,
1501
+ agent_action = excluded.agent_action, posted = excluded.posted, replied = excluded.replied,
1502
+ resolved = excluded.resolved, error = excluded.error, updated_at = excluded.updated_at`
1503
+ ).run(
1504
+ id,
1505
+ draft.threadId,
1506
+ draft.body,
1507
+ draft.resolve ? 1 : 0,
1508
+ draft.noteForAgent,
1509
+ draft.agentAction,
1510
+ draft.posted,
1511
+ draft.replied ? 1 : 0,
1512
+ draft.resolved ? 1 : 0,
1513
+ draft.error,
1514
+ draft.updatedAt
1515
+ );
1516
+ }
1517
+ // --- notes ---
1518
+ listNotes(id) {
1519
+ const rows = this.db.prepare("SELECT * FROM notes WHERE review_id = ? ORDER BY created_at").all(id);
1520
+ return rows.map((row) => ({
1521
+ id: text(row, "id"),
1522
+ file: text(row, "file"),
1523
+ line: Number(row.line),
1524
+ side: text(row, "side"),
1525
+ text: text(row, "text"),
1526
+ toAgent: Number(row.to_agent) === 1,
1527
+ agentAction: text(row, "agent_action"),
1528
+ agentReply: text(row, "agent_reply"),
1529
+ posted: text(row, "posted"),
1530
+ discussionId: text(row, "discussion_id"),
1531
+ noteId: text(row, "note_id"),
1532
+ error: text(row, "error"),
1533
+ createdAt: text(row, "created_at"),
1534
+ updatedAt: text(row, "updated_at")
1535
+ }));
1536
+ }
1537
+ upsertNote(id, note) {
1538
+ this.db.prepare(
1539
+ `INSERT INTO notes (review_id, id, file, line, side, text, to_agent, agent_action, agent_reply, posted, discussion_id, note_id, error, created_at, updated_at)
1540
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1541
+ ON CONFLICT(review_id, id) DO UPDATE SET
1542
+ file = excluded.file, line = excluded.line, side = excluded.side, text = excluded.text,
1543
+ to_agent = excluded.to_agent, agent_action = excluded.agent_action, agent_reply = excluded.agent_reply,
1544
+ posted = excluded.posted, discussion_id = excluded.discussion_id, note_id = excluded.note_id,
1545
+ error = excluded.error, updated_at = excluded.updated_at`
1546
+ ).run(
1547
+ id,
1548
+ note.id,
1549
+ note.file,
1550
+ note.line,
1551
+ note.side,
1552
+ note.text,
1553
+ note.toAgent ? 1 : 0,
1554
+ note.agentAction,
1555
+ note.agentReply,
1556
+ note.posted,
1557
+ note.discussionId,
1558
+ note.noteId,
1559
+ note.error,
1560
+ note.createdAt,
1561
+ note.updatedAt
1562
+ );
1563
+ }
1564
+ deleteNote(id, noteId) {
1565
+ this.db.prepare("DELETE FROM notes WHERE review_id = ? AND id = ?").run(id, noteId);
1566
+ }
1567
+ // --- decisions ---
1568
+ listDecisions(id) {
1569
+ const rows = this.db.prepare("SELECT * FROM decisions WHERE review_id = ?").all(id);
1570
+ const out = {};
1571
+ for (const row of rows) {
1572
+ out[text(row, "finding_id")] = {
1573
+ decision: row.decision ? String(row.decision) : null,
1574
+ note: text(row, "note")
1575
+ };
1576
+ }
1577
+ return out;
1578
+ }
1579
+ setDecisions(id, patch) {
1580
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1581
+ this.transaction(() => {
1582
+ const stmt = this.db.prepare(
1583
+ `INSERT INTO decisions (review_id, finding_id, decision, note, updated_at) VALUES (?, ?, ?, ?, ?)
1584
+ ON CONFLICT(review_id, finding_id) DO UPDATE SET decision = excluded.decision, note = excluded.note, updated_at = excluded.updated_at`
1585
+ );
1586
+ for (const [findingId, value] of Object.entries(patch)) {
1587
+ stmt.run(id, findingId, value.decision, value.note, now);
1588
+ }
1589
+ });
1590
+ }
1591
+ // --- approvals ---
1592
+ createApproval(record) {
1593
+ this.db.prepare("INSERT INTO approvals (id, review_id, kind, hash, items_json, expires_at, used_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(record.id, record.reviewId, record.kind, record.hash, JSON.stringify(record.items), record.expiresAt, record.usedAt, record.createdAt);
1594
+ }
1595
+ getApproval(id) {
1596
+ const row = this.db.prepare("SELECT * FROM approvals WHERE id = ?").get(id);
1597
+ if (!row) return null;
1598
+ return {
1599
+ id: text(row, "id"),
1600
+ reviewId: text(row, "review_id"),
1601
+ kind: text(row, "kind"),
1602
+ hash: text(row, "hash"),
1603
+ items: json(row, "items_json", []),
1604
+ expiresAt: text(row, "expires_at"),
1605
+ usedAt: row.used_at ? String(row.used_at) : null,
1606
+ createdAt: text(row, "created_at")
1607
+ };
1608
+ }
1609
+ markApprovalUsed(id) {
1610
+ this.db.prepare("UPDATE approvals SET used_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), id);
1611
+ }
1612
+ // --- audit ---
1613
+ audit(reviewId2, action, detail) {
1614
+ this.db.prepare("INSERT INTO audit (at, review_id, action, detail) VALUES (?, ?, ?, ?)").run((/* @__PURE__ */ new Date()).toISOString(), reviewId2, action, JSON.stringify(detail));
1615
+ }
1616
+ auditTail(limit = 100) {
1617
+ const rows = this.db.prepare("SELECT * FROM audit ORDER BY id DESC LIMIT ?").all(limit);
1618
+ return rows.map((row) => ({
1619
+ at: text(row, "at"),
1620
+ reviewId: text(row, "review_id"),
1621
+ action: text(row, "action"),
1622
+ detail: text(row, "detail")
1623
+ }));
1624
+ }
1625
+ transaction(fn) {
1626
+ this.db.exec("BEGIN");
1627
+ try {
1628
+ fn();
1629
+ this.db.exec("COMMIT");
1630
+ } catch (error) {
1631
+ this.db.exec("ROLLBACK");
1632
+ throw error;
1633
+ }
1634
+ }
1635
+ };
1636
+
1637
+ // src/daemon/service.ts
1638
+ init_redact();
1639
+ init_errors();
1640
+ import { EventEmitter } from "node:events";
1641
+ import { createHash as createHash2, randomBytes } from "node:crypto";
1642
+
1643
+ // src/core/config.ts
1644
+ import { z } from "zod";
1645
+ init_errors();
1646
+ import { parse as parseYaml } from "yaml";
1647
+ import { existsSync as existsSync2 } from "node:fs";
1648
+ import path3 from "node:path";
1649
+
1650
+ // src/core/glob.ts
1651
+ function globToRegExp(glob) {
1652
+ let source = "";
1653
+ let index = 0;
1654
+ const pattern = glob.replace(/^\.\//, "").replace(/^\/+/, "");
1655
+ while (index < pattern.length) {
1656
+ const char = pattern[index];
1657
+ if (char === "*") {
1658
+ if (pattern[index + 1] === "*") {
1659
+ if (pattern[index + 2] === "/") {
1660
+ source += "(?:[^/]+/)*";
1661
+ index += 3;
1662
+ } else {
1663
+ source += ".*";
1664
+ index += 2;
1665
+ }
1666
+ } else {
1667
+ source += "[^/]*";
1668
+ index += 1;
1669
+ }
1670
+ continue;
1671
+ }
1672
+ if (char === "?") {
1673
+ source += "[^/]";
1674
+ index += 1;
1675
+ continue;
1676
+ }
1677
+ if (char === "{") {
1678
+ const end = pattern.indexOf("}", index);
1679
+ if (end > index) {
1680
+ const options = pattern.slice(index + 1, end).split(",").map((item) => escapeRegExp(item.trim()));
1681
+ source += `(?:${options.join("|")})`;
1682
+ index = end + 1;
1683
+ continue;
1684
+ }
1685
+ }
1686
+ source += escapeRegExp(char);
1687
+ index += 1;
1688
+ }
1689
+ return new RegExp(`^${source}$`);
1690
+ }
1691
+ function escapeRegExp(value) {
1692
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1693
+ }
1694
+ function classifyFile(path8) {
1695
+ const lower = path8.toLowerCase();
1696
+ if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
1697
+ if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
1698
+ if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
1699
+ if (/\.(css|scss|sass|less|styl)$/.test(lower)) return "style";
1700
+ if (/(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|tsconfig[^/]*\.json|[^/]*\.config\.[cm]?[jt]s|\.eslintrc[^/]*|\.prettierrc[^/]*|Dockerfile|Makefile|\.github\/workflows\/|\.gitlab-ci\.yml)/.test(lower)) return "config";
1701
+ if (/\.(generated|g)\.[cm]?[jt]s$|\.d\.ts$|(^|\/)generated\//.test(lower)) return "generated";
1702
+ if (/\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte|py|go|rs|java|kt|rb|php|cs|c|cpp|h|hpp|swift|scala|ex|exs)$/.test(lower)) return "app";
1703
+ return "other";
1704
+ }
1705
+
1706
+ // src/core/config.ts
1707
+ init_locale();
1708
+ var matchSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
1709
+ var reviewConfigSchema = z.object({
1710
+ version: z.literal(1).default(1),
1711
+ provider: z.object({
1712
+ /** auto — по git remote; иначе явная платформа. */
1713
+ type: z.enum(["auto", "gitlab", "github"]).default("auto"),
1714
+ host: z.string().min(1).optional(),
1715
+ /** GitLab: group/project. GitHub: owner/repo. */
1716
+ project: z.string().min(1).optional(),
1717
+ /** База для локального diff, когда ревью идёт по ветке. */
1718
+ base: z.string().min(1).optional()
1719
+ }).prefault({}),
1720
+ output: z.object({
1721
+ language: z.enum(["auto", "ru", "en"]).default("auto")
1722
+ }).prefault({}),
1723
+ rules: z.object({
1724
+ /** Общие документы, если ни одна строка map не подошла. Пусто — автообнаружение. */
1725
+ sources: z.array(z.string().min(1)).default([]),
1726
+ /** Привязка путей к документам: match — glob, docs — пути с необязательной секцией `#…`. */
1727
+ map: z.array(
1728
+ z.object({
1729
+ match: matchSchema,
1730
+ docs: z.array(z.string().min(1)).min(1),
1731
+ note: z.string().optional()
1732
+ })
1733
+ ).default([]),
1734
+ /** Пути, которые не ревьюятся (сгенерированный код и т.п.). */
1735
+ ignore: z.array(z.string().min(1)).default([])
1736
+ }).prefault({}),
1737
+ checks: z.array(
1738
+ z.object({
1739
+ id: z.string().min(1),
1740
+ name: z.string().optional(),
1741
+ /** argv без shell: первым идёт исполняемый файл. */
1742
+ argv: z.array(z.string()).min(1),
1743
+ /** Запускать, только если diff затронул эти glob-пути. */
1744
+ when: z.array(z.string()).default([]),
1745
+ consent: z.boolean().default(true),
1746
+ timeoutMs: z.number().int().positive().max(36e5).default(3e5)
1747
+ })
1748
+ ).default([]),
1749
+ publish: z.object({
1750
+ requireApproval: z.boolean().default(true),
1751
+ maxComments: z.number().int().positive().max(200).default(50)
1752
+ }).prefault({})
1753
+ });
1754
+ var DISCOVERED_SOURCES = [
1755
+ "AGENTS.md",
1756
+ "CLAUDE.md",
1757
+ "CONTRIBUTING.md",
1758
+ "docs/ARCHITECTURE.md",
1759
+ "docs/Architecture.md",
1760
+ "docs/STYLEGUIDE.md",
1761
+ "docs/Styleguide.md",
1762
+ "docs/CONTRIBUTING.md"
1763
+ ];
1764
+ function parseConfig(text2, source = ".revdeck.yml") {
1765
+ let data;
1766
+ try {
1767
+ data = parseYaml(text2);
1768
+ } catch (error) {
1769
+ throw new ConfigError(tr(`${source}: \u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 YAML: ${error.message}`, `${source}: invalid YAML: ${error.message}`));
1770
+ }
1771
+ if (data === null || data === void 0) data = {};
1772
+ const result = reviewConfigSchema.safeParse(data);
1773
+ if (!result.success) {
1774
+ const issues = result.error.issues.slice(0, 10).map((issue) => `${issue.path.join(".") || "<\u043A\u043E\u0440\u0435\u043D\u044C>"}: ${issue.message}`).join("; ");
1775
+ throw new ConfigError(`${source}: ${issues}`, tr("\u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u0441\u0445\u0435\u043C\u0443 \u0432 docs/configuration.md", "check the schema in docs/configuration.md"));
1776
+ }
1777
+ return result.data;
1778
+ }
1779
+ async function loadConfig(root = findRepoRoot()) {
1780
+ const warnings = [];
1781
+ const file = findConfigFile(root);
1782
+ let config;
1783
+ if (file) {
1784
+ config = parseConfig(await readTextNoFollow(file), path3.relative(root, file) || file);
1785
+ } else {
1786
+ config = reviewConfigSchema.parse({});
1787
+ warnings.push(tr("\u0444\u0430\u0439\u043B .revdeck.yml \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: \u043F\u0440\u0430\u0432\u0438\u043B\u0430 \u0432\u0437\u044F\u0442\u044B \u0438\u0437 \u0430\u0432\u0442\u043E\u043E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0445 \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u043E\u0432", ".revdeck.yml not found: rules come from auto-discovered documents"));
1788
+ }
1789
+ if (!config.rules.sources.length && !config.rules.map.length) {
1790
+ const found = DISCOVERED_SOURCES.filter((item) => existsSync2(path3.join(root, item)));
1791
+ config.rules.sources = found;
1792
+ if (!found.length) {
1793
+ warnings.push(tr("\u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u044B \u0441 \u043F\u0440\u0430\u0432\u0438\u043B\u0430\u043C\u0438 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u044B: \u0440\u0435\u0432\u044C\u044E \u0431\u0443\u0434\u0435\u0442 \u043E\u043F\u0438\u0440\u0430\u0442\u044C\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0430 \u043A\u043E\u0434 \u0438 \u0437\u0434\u0440\u0430\u0432\u044B\u0439 \u0441\u043C\u044B\u0441\u043B", "no rule documents found: the review will rely on the code only"));
1794
+ }
1795
+ }
1796
+ return { config, path: file, warnings };
1797
+ }
1798
+ function assertConfigPath(root, relative) {
1799
+ const cleaned = relative.split("#")[0];
1800
+ const resolved = path3.resolve(root, cleaned);
1801
+ const rel = path3.relative(root, resolved);
1802
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
1803
+ throw new ConfigError(tr(`\u043F\u0443\u0442\u044C ${relative} \u0432\u044B\u0445\u043E\u0434\u0438\u0442 \u0437\u0430 \u043F\u0440\u0435\u0434\u0435\u043B\u044B \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u044F`, `path ${relative} escapes the repository`));
1804
+ }
1805
+ return resolved;
1806
+ }
1807
+ function checksForPaths(config, paths) {
1808
+ return config.checks.map((check) => {
1809
+ const matched = !check.when.length || paths.some((file) => check.when.some((pattern) => globToRegExp(pattern).test(file)));
1810
+ return { id: check.id, name: check.name ?? check.id, argv: check.argv, consent: check.consent, matched };
1811
+ });
1812
+ }
1813
+
1814
+ // src/core/rules.ts
1815
+ import path4 from "node:path";
1816
+ import { existsSync as existsSync3 } from "node:fs";
1817
+ init_redact();
1818
+ var MAX_DOC_BYTES = 96 * 1024;
1819
+ var MAX_TOTAL_BYTES = 320 * 1024;
1820
+ async function buildEffectiveRules(options) {
1821
+ const { root, config, configPath } = options;
1822
+ const language = options.language ?? "ru";
1823
+ const t = (ru, en) => language === "ru" ? ru : en;
1824
+ const warnings = [];
1825
+ const files = options.files.filter((file) => !config.rules.ignore.some((pattern) => globToRegExp(pattern).test(file)));
1826
+ const ignored = options.files.length - files.length;
1827
+ if (ignored > 0) warnings.push(`${ignored} ${t("\u0444\u0430\u0439\u043B(\u043E\u0432) \u0438\u0441\u043A\u043B\u044E\u0447\u0435\u043D\u044B \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u043E\u0439 rules.ignore", "file(s) excluded by rules.ignore")}`);
1828
+ const cache = /* @__PURE__ */ new Map();
1829
+ let totalBytes = 0;
1830
+ const readDoc = async (selector) => {
1831
+ const [rawPath] = selector.split("#");
1832
+ const relative = rawPath;
1833
+ let resolved;
1834
+ try {
1835
+ resolved = assertConfigPath(root, relative);
1836
+ } catch (error) {
1837
+ warnings.push(`${relative}: ${error.message}`);
1838
+ return { path: relative, exists: false, sections: [], excerpt: "", truncated: false };
1839
+ }
1840
+ let cached = cache.get(resolved);
1841
+ if (!cached) {
1842
+ if (!existsSync3(resolved)) {
1843
+ cached = { exists: false, text: "", truncated: false };
1844
+ warnings.push(`${t("\u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442", "document")} ${relative} ${t("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D", "not found")}`);
1845
+ } else {
1846
+ try {
1847
+ const raw = await readTextNoFollow(resolved, MAX_DOC_BYTES * 2);
1848
+ const truncated = raw.length > MAX_DOC_BYTES;
1849
+ cached = { exists: true, text: truncated ? raw.slice(0, MAX_DOC_BYTES) : raw, truncated };
1850
+ } catch (error) {
1851
+ cached = { exists: false, text: "", truncated: false };
1852
+ warnings.push(`${relative}: ${redactLine(error)}`);
1853
+ }
1854
+ }
1855
+ cache.set(resolved, cached);
1856
+ }
1857
+ const selectors = selector.includes("#") ? [selector.slice(selector.indexOf("#") + 1)] : [];
1858
+ const excerpt = extractExcerpt(cached.text, selectors, relative, warnings, language);
1859
+ totalBytes += excerpt.length;
1860
+ return { path: relative, exists: cached.exists, sections: selectors, excerpt, truncated: cached.truncated };
1861
+ };
1862
+ const scopes = [];
1863
+ for (const file of files) {
1864
+ const docs = /* @__PURE__ */ new Set();
1865
+ for (const entry of config.rules.map) {
1866
+ const patterns = Array.isArray(entry.match) ? entry.match : [entry.match];
1867
+ if (patterns.some((pattern) => globToRegExp(pattern).test(file))) {
1868
+ for (const doc of entry.docs) docs.add(doc);
1869
+ }
1870
+ }
1871
+ if (!docs.size) {
1872
+ for (const doc of config.rules.sources) docs.add(doc);
1873
+ if (!docs.size) {
1874
+ warnings.push(`${file}: ${t("\u043D\u0435\u0442 \u043F\u0440\u0430\u0432\u0438\u043B \u2014 \u043F\u0443\u0442\u044C \u043D\u0435 \u0441\u043E\u043F\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0432 rules.map, \u0430 rules.sources \u043F\u0443\u0441\u0442", "no rules \u2014 the path is not mapped in rules.map and rules.sources is empty")}`);
1875
+ }
1876
+ }
1877
+ const documents = [];
1878
+ for (const doc of docs) {
1879
+ if (totalBytes > MAX_TOTAL_BYTES) {
1880
+ warnings.push(t("\u043A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 \u043F\u0440\u0430\u0432\u0438\u043B \u043E\u0431\u0440\u0435\u0437\u0430\u043D: \u043F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u043B\u0438\u043C\u0438\u0442 \u0440\u0430\u0437\u043C\u0435\u0440\u0430", "rules context truncated: size limit exceeded"));
1881
+ break;
1882
+ }
1883
+ documents.push(await readDoc(doc));
1884
+ }
1885
+ scopes.push({ file, kind: classifyFile(file), documents });
1886
+ }
1887
+ if (!files.length) {
1888
+ const documents = [];
1889
+ for (const doc of config.rules.sources) documents.push(await readDoc(doc));
1890
+ if (!documents.length) warnings.push(t("\u043E\u0431\u0449\u0438\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u0430: rules.sources \u043F\u0443\u0441\u0442, \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u044B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u044B", "general rules: rules.sources is empty, no documents selected"));
1891
+ scopes.push({ file: "", kind: "other", documents });
1892
+ }
1893
+ return {
1894
+ configPath: configPath ? path4.relative(root, configPath) : null,
1895
+ scopes,
1896
+ checks: checksForPaths(config, files),
1897
+ warnings: [...new Set(warnings)]
1898
+ };
1899
+ }
1900
+ function extractExcerpt(text2, selectors, label, warnings, language = "ru") {
1901
+ const t = (ru, en) => language === "ru" ? ru : en;
1902
+ if (!text2) return "";
1903
+ if (!selectors.length) return text2;
1904
+ const parts = [];
1905
+ for (const selector of selectors) {
1906
+ const range = /^L?(\d+)(?:-L?(\d+))?$/.exec(selector.trim());
1907
+ if (range) {
1908
+ const start = Number(range[1]);
1909
+ const end = range[2] ? Number(range[2]) : start;
1910
+ const lines = text2.split("\n");
1911
+ if (start < 1 || end < start || start > lines.length) {
1912
+ warnings.push(`${label}#${selector}: ${t("\u0434\u0438\u0430\u043F\u0430\u0437\u043E\u043D \u0441\u0442\u0440\u043E\u043A \u0432\u043D\u0435 \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u0430", "line range is outside the document")}`);
1913
+ continue;
1914
+ }
1915
+ parts.push(lines.slice(start - 1, Math.min(end, lines.length)).join("\n"));
1916
+ continue;
1917
+ }
1918
+ const section = headingSection(text2, selector);
1919
+ if (section === null) {
1920
+ warnings.push(`${label}#${selector}: ${t("\u0441\u0435\u043A\u0446\u0438\u044F \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430", "section not found")}`);
1921
+ continue;
1922
+ }
1923
+ parts.push(section);
1924
+ }
1925
+ return parts.join("\n\n");
1926
+ }
1927
+ var HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
1928
+ function headingSection(text2, wanted) {
1929
+ const lines = text2.split("\n");
1930
+ const target = normalizeHeading(wanted);
1931
+ let start = -1;
1932
+ let level = 0;
1933
+ for (let index = 0; index < lines.length; index++) {
1934
+ const match = HEADING.exec(lines[index]);
1935
+ if (!match) continue;
1936
+ if (normalizeHeading(match[2]) === target) {
1937
+ start = index;
1938
+ level = match[1].length;
1939
+ break;
1940
+ }
1941
+ }
1942
+ if (start < 0) return null;
1943
+ let end = lines.length;
1944
+ for (let index = start + 1; index < lines.length; index++) {
1945
+ const match = HEADING.exec(lines[index]);
1946
+ if (match && match[1].length <= level) {
1947
+ end = index;
1948
+ break;
1949
+ }
1950
+ }
1951
+ return lines.slice(start, end).join("\n").trimEnd();
1952
+ }
1953
+ function normalizeHeading(value) {
1954
+ return value.replace(/^#+/, "").replace(/[*_`]/g, "").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").toLowerCase();
1955
+ }
1956
+ function renderRules(rules, options = {}) {
1957
+ const maxBytes = options.maxBytes ?? 2e5;
1958
+ const withExcerpts = options.withExcerpts ?? true;
1959
+ const language = options.language ?? "ru";
1960
+ const t = (ru, en) => language === "ru" ? ru : en;
1961
+ const out = [];
1962
+ out.push(`# ${t("\u041F\u0440\u0430\u0432\u0438\u043B\u0430 \u0440\u0435\u0432\u044C\u044E", "Review rules")}${rules.configPath ? ` (${rules.configPath})` : ` (${t("\u0430\u0432\u0442\u043E\u043E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D\u0438\u0435", "auto-discovery")})`}`);
1963
+ if (rules.warnings.length) out.push(`${t("\u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u044F", "Warnings")}:
1964
+ ${rules.warnings.map((item) => `- ${item}`).join("\n")}`);
1965
+ const seen = /* @__PURE__ */ new Set();
1966
+ for (const scope of rules.scopes) {
1967
+ out.push(`
1968
+ ## ${scope.file || `(${t("\u043E\u0431\u0449\u0438\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u0430", "general rules")})`} \u2014 ${scope.kind}`);
1969
+ for (const doc of scope.documents) {
1970
+ const key = `${doc.path}#${doc.sections.join(",")}`;
1971
+ const label = `${doc.path}${doc.sections.length ? `#${doc.sections.join(", ")}` : ""}`;
1972
+ out.push(`- ${label}${doc.exists ? "" : ` (${t("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D", "not found")})`}`);
1973
+ if (!withExcerpts || !doc.exists) continue;
1974
+ if (seen.has(key)) continue;
1975
+ seen.add(key);
1976
+ out.push(`
1977
+ > ${label}
1978
+
1979
+ ${doc.excerpt}${doc.truncated ? `
1980
+
1981
+ [${t("\u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442 \u043E\u0431\u0440\u0435\u0437\u0430\u043D", "document truncated")}]` : ""}`);
1982
+ if (out.join("\n").length > maxBytes) {
1983
+ out.push(`
1984
+ [${t("\u043A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 \u043F\u0440\u0430\u0432\u0438\u043B \u043E\u0431\u0440\u0435\u0437\u0430\u043D \u043F\u043E \u0440\u0430\u0437\u043C\u0435\u0440\u0443", "rules context truncated by size")}]`);
1985
+ return out.join("\n");
1986
+ }
1987
+ }
1988
+ }
1989
+ if (rules.checks.length) {
1990
+ out.push(`
1991
+ ## ${t("\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0442\u0430", "Project checks")}`);
1992
+ for (const check of rules.checks) {
1993
+ out.push(`- ${check.id}${check.matched ? "" : ` (${t("\u043D\u0435 \u043E\u0442\u043D\u043E\u0441\u0438\u0442\u0441\u044F \u043A diff", "does not match the diff")})`}: \`${check.argv.join(" ")}\`${check.consent ? ` \u2014 ${t("\u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0441\u043E\u0433\u043B\u0430\u0441\u0438\u044F \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", "requires user consent")}` : ""}`);
1994
+ }
1995
+ }
1996
+ return out.join("\n");
1997
+ }
1998
+
1999
+ // src/daemon/service.ts
2000
+ init_diff();
2001
+
2002
+ // src/core/sanitize.ts
2003
+ init_locale();
2004
+ var CONTROL_OR_HIDDEN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\u2800\u3164\ufeff\uffa0]/;
2005
+ var QUICK_ACTION = /^[\s\p{Z}\p{Cf}]*\/[a-z_]+(?=[\s\p{Z}\p{Cf}]|$)/imu;
2006
+ var MASS_MENTION = /(^|[^\w@./-])@(all|here|everyone)(?![\w-])/i;
2007
+ var MD_IMAGE = /!\[[^\]]*\]\s*\(|!\[[^\]]*\]\s*\[/;
2008
+ var HTML_TAG = /<(?:img|script|iframe|style|video|audio|object|embed|svg|math)\b/i;
2009
+ var HTML_COMMENT = /<!--/;
2010
+ var MAX_LENGTH = 64 * 1024;
2011
+ function sanitizeBody(body, platform, options = {}) {
2012
+ const text2 = String(body ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2013
+ const maxLength = options.maxLength ?? MAX_LENGTH;
2014
+ const checkQuickActions = options.quickActions ?? platform === "gitlab";
2015
+ if (text2.trim() === "") return { ok: false, reason: tr("\u0442\u0435\u043A\u0441\u0442 \u043F\u0443\u0441\u0442", "the text is empty"), text: text2 };
2016
+ if (text2.length > maxLength) return { ok: false, reason: tr(`\u0442\u0435\u043A\u0441\u0442 \u0434\u043B\u0438\u043D\u043D\u0435\u0435 ${maxLength} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432`, `the text is longer than ${maxLength} characters`), text: text2 };
2017
+ if (CONTROL_OR_HIDDEN.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u044E\u0449\u0438\u0435 \u0438\u043B\u0438 \u043D\u0435\u0432\u0438\u0434\u0438\u043C\u044B\u0435 \u0441\u0438\u043C\u0432\u043E\u043B\u044B", "the text contains control or invisible characters"), text: text2 };
2018
+ if (HTML_COMMENT.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439", "the text contains an HTML comment"), text: text2 };
2019
+ if (HTML_TAG.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u0442\u0435\u0433", "the text contains an HTML tag"), text: text2 };
2020
+ if (MD_IMAGE.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430: \u0437\u0430\u043C\u0435\u043D\u0438\u0442\u0435 \u0441\u0441\u044B\u043B\u043A\u043E\u0439", "the text contains an image: replace it with a link"), text: text2 };
2021
+ if (MASS_MENTION.test(text2)) return { ok: false, reason: tr("\u043C\u0430\u0441\u0441\u043E\u0432\u043E\u0435 \u0443\u043F\u043E\u043C\u0438\u043D\u0430\u043D\u0438\u0435: \u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043A\u043E\u043D\u043A\u0440\u0435\u0442\u043D\u044B\u0445 \u043B\u044E\u0434\u0435\u0439", "mass mention: name specific people"), text: text2 };
2022
+ if (checkQuickActions && QUICK_ACTION.test(text2)) {
2023
+ return { ok: false, reason: tr("\u0441\u0442\u0440\u043E\u043A\u0430-\u043A\u043E\u043C\u0430\u043D\u0434\u0430 GitLab (/approve, /merge \u2026): \u043F\u0435\u0440\u0435\u0444\u0440\u0430\u0437\u0438\u0440\u0443\u0439\u0442\u0435", "GitLab quick action line (/approve, /merge \u2026): rephrase it"), text: text2 };
2024
+ }
2025
+ return { ok: true, reason: "", text: text2 };
2026
+ }
2027
+
2028
+ // src/core/findings.ts
2029
+ import { createHash } from "node:crypto";
2030
+ init_locale();
2031
+ var LEVEL_ORDER = { blocker: 0, major: 1, minor: 2, question: 3 };
2032
+ function validateFinding(raw) {
2033
+ if (!raw || typeof raw !== "object") return { value: null, error: tr("\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043E\u0431\u044A\u0435\u043A\u0442\u043E\u043C", "a finding must be an object") };
2034
+ const item = raw;
2035
+ const file = typeof item.file === "string" ? item.file.trim() : "";
2036
+ const title = typeof item.title === "string" ? item.title.trim() : "";
2037
+ const body = typeof item.body === "string" ? item.body.trim() : "";
2038
+ const comment = typeof item.comment === "string" ? item.comment.trim() : body;
2039
+ if (!file) return { value: null, error: tr("\u043D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D file", "file is required") };
2040
+ if (!title) return { value: null, error: tr("\u043D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D title", "title is required") };
2041
+ if (!comment) return { value: null, error: tr("\u043F\u0443\u0441\u0442\u043E\u0439 comment", "comment is empty") };
2042
+ const line = item.line === null || item.line === void 0 || item.line === "" ? null : Number(item.line);
2043
+ if (line !== null && (!Number.isInteger(line) || line < 1)) return { value: null, error: tr(`\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 line: ${String(item.line)}`, `invalid line: ${String(item.line)}`) };
2044
+ const endLine = item.endLine === null || item.endLine === void 0 ? null : Number(item.endLine);
2045
+ if (endLine !== null && (!Number.isInteger(endLine) || line === null || endLine < line)) {
2046
+ return { value: null, error: tr("endLine \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435 line", "endLine must be greater than or equal to line") };
2047
+ }
2048
+ const level = normalizeLevel(item.level);
2049
+ if (item.level !== void 0 && !LEVELS.includes(String(item.level))) {
2050
+ return { value: null, error: tr(`\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 level: ${String(item.level)} (\u043E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F ${LEVELS.join(", ")})`, `unknown level: ${String(item.level)} (expected ${LEVELS.join(", ")})`) };
2051
+ }
2052
+ return {
2053
+ value: {
2054
+ ...typeof item.id === "string" && item.id ? { id: item.id } : {},
2055
+ level,
2056
+ file,
2057
+ line,
2058
+ endLine,
2059
+ title,
2060
+ body,
2061
+ comment
2062
+ },
2063
+ error: ""
2064
+ };
2065
+ }
2066
+ function fingerprintOf(input) {
2067
+ return createHash("sha256").update(`${input.file.trim().toLowerCase()}|${input.level}|${input.title.trim().toLowerCase().replace(/\s+/g, " ")}`).digest("hex");
2068
+ }
2069
+ function mergeFindings(previous, incoming, options = {}) {
2070
+ const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
2071
+ const byId = new Map(previous.map((item) => [item.id, item]));
2072
+ const byFingerprint = new Map(previous.map((item) => [item.fingerprint, item]));
2073
+ const byPlace = new Map(previous.filter((item) => item.line !== null).map((item) => [`${item.file}:${item.line}:${item.level}`, item]));
2074
+ const used = /* @__PURE__ */ new Set();
2075
+ const result = [];
2076
+ for (const draft of incoming) {
2077
+ const fingerprint = fingerprintOf(draft);
2078
+ const match = (draft.id ? byId.get(draft.id) : void 0) ?? byFingerprint.get(fingerprint) ?? (draft.line !== null ? byPlace.get(`${draft.file}:${draft.line}:${draft.level}`) : void 0);
2079
+ if (match) {
2080
+ used.add(match.id);
2081
+ result.push({
2082
+ ...match,
2083
+ level: draft.level,
2084
+ file: draft.file,
2085
+ line: draft.line,
2086
+ endLine: draft.endLine ?? null,
2087
+ title: draft.title,
2088
+ body: draft.body,
2089
+ // Опубликованный текст не переписываем: он уже виден автору MR.
2090
+ comment: match.status === "published" ? match.comment : draft.comment,
2091
+ status: match.status === "stale" ? "draft" : match.status,
2092
+ fingerprint
2093
+ });
2094
+ continue;
2095
+ }
2096
+ const id = draft.id && !used.has(draft.id) ? draft.id : uniqueId(fingerprint, used, byId);
2097
+ used.add(id);
2098
+ result.push({
2099
+ id,
2100
+ fingerprint,
2101
+ level: draft.level,
2102
+ file: draft.file,
2103
+ line: draft.line,
2104
+ endLine: draft.endLine ?? null,
2105
+ title: draft.title,
2106
+ body: draft.body,
2107
+ comment: draft.comment,
2108
+ status: "draft",
2109
+ discussionId: null,
2110
+ publishedAt: null,
2111
+ decision: null,
2112
+ note: "",
2113
+ publishError: ""
2114
+ });
2115
+ }
2116
+ for (const old of previous) {
2117
+ if (used.has(old.id)) continue;
2118
+ result.push({
2119
+ ...old,
2120
+ status: old.status === "published" ? "published" : "stale"
2121
+ });
2122
+ }
2123
+ return result.sort((a, b) => levelOrder(a.level) - levelOrder(b.level) || a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
2124
+ }
2125
+ function uniqueId(fingerprint, used, byId) {
2126
+ for (let length = 8; length <= fingerprint.length; length += 2) {
2127
+ const candidate = `f-${fingerprint.slice(0, length)}`;
2128
+ if (!used.has(candidate) && !byId.has(candidate)) return candidate;
2129
+ }
2130
+ return `f-${fingerprint}`;
2131
+ }
2132
+ function levelOrder(level) {
2133
+ return LEVEL_ORDER[level];
2134
+ }
2135
+ function applyDecisions(findings, decisions) {
2136
+ return findings.map((item) => {
2137
+ const decision = decisions[item.id];
2138
+ if (!decision) return item;
2139
+ return { ...item, decision: decision.decision, note: decision.note };
2140
+ });
2141
+ }
2142
+
2143
+ // src/core/canonical.ts
2144
+ function canonicalJson(value) {
2145
+ return JSON.stringify(normalize(value));
2146
+ }
2147
+ function normalize(value) {
2148
+ if (Array.isArray(value)) return value.map(normalize);
2149
+ if (value && typeof value === "object") {
2150
+ const out = {};
2151
+ for (const key of Object.keys(value).sort()) {
2152
+ const item = value[key];
2153
+ if (item !== void 0) out[key] = normalize(item);
2154
+ }
2155
+ return out;
2156
+ }
2157
+ return value;
2158
+ }
2159
+
2160
+ // src/daemon/service.ts
2161
+ init_locale();
2162
+
2163
+ // src/core/git.ts
2164
+ init_exec();
2165
+ var SSH_SCP = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^:/]+)(?::(\d+))?[:/](.+?)(?:\.git)?\/?$/;
2166
+ function parseRemote(url) {
2167
+ const value = url.trim();
2168
+ if (!value) return null;
2169
+ let host = "";
2170
+ let projectPath = "";
2171
+ const http2 = /^(?:https?|git|ssh):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/.exec(value);
2172
+ if (http2) {
2173
+ host = http2[1];
2174
+ projectPath = http2[2];
2175
+ } else {
2176
+ const scp = SSH_SCP.exec(value);
2177
+ if (!scp) return null;
2178
+ host = scp[1];
2179
+ projectPath = scp[3];
2180
+ }
2181
+ projectPath = projectPath.replace(/\/+$/, "").replace(/\.git$/, "");
2182
+ if (!host || !projectPath || !projectPath.includes("/")) return null;
2183
+ return { host, project: projectPath, platform: detectPlatform(host), url: value };
2184
+ }
2185
+ function detectPlatform(host) {
2186
+ const lower = host.toLowerCase();
2187
+ if (lower === "github.com" || lower.endsWith(".github.com") || lower.includes("github")) return "github";
2188
+ if (lower === "gitlab.com" || lower.endsWith(".gitlab.com") || lower.includes("gitlab")) return "gitlab";
2189
+ return null;
2190
+ }
2191
+ async function gitRemote(root, remote = "origin") {
2192
+ try {
2193
+ const { stdout } = await run("git", ["config", "--get", `remote.${remote}.url`], { cwd: root, timeoutMs: 1e4 });
2194
+ return parseRemote(stdout.trim());
2195
+ } catch {
2196
+ return null;
2197
+ }
2198
+ }
2199
+ async function currentBranch(root) {
2200
+ try {
2201
+ const { stdout } = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, timeoutMs: 1e4 });
2202
+ return stdout.trim();
2203
+ } catch {
2204
+ return "";
2205
+ }
2206
+ }
2207
+
2208
+ // src/providers/provider.ts
2209
+ init_errors();
2210
+ init_locale();
2211
+ async function projectContext(root, config, overrides = {}) {
2212
+ const remote = await gitRemote(root);
2213
+ const platform = overrides.platform ?? (config.provider.type !== "auto" ? config.provider.type : remote?.platform ?? void 0);
2214
+ const host = overrides.host ?? config.provider.host ?? remote?.host;
2215
+ const project = overrides.project ?? config.provider.project ?? remote?.project;
2216
+ if (!platform) {
2217
+ throw new ProviderError(
2218
+ tr("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0443: \u0443\u043A\u0430\u0436\u0438\u0442\u0435 provider.type \u0432 .revdeck.yml \u0438\u043B\u0438 --platform", "could not determine the platform: set provider.type in .revdeck.yml or pass --platform"),
2219
+ tr("git remote \u043D\u0435 \u043F\u043E\u0445\u043E\u0436 \u043D\u0430 GitHub/GitLab (\u0441\u0430\u043C\u043E\u0441\u0442\u043E\u044F\u0442\u0435\u043B\u044C\u043D\u044B\u0439 \u0445\u043E\u0441\u0442 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u0435 \u044F\u0432\u043D\u043E)", "git remote does not look like GitHub/GitLab (set a self-hosted platform explicitly)")
2220
+ );
2221
+ }
2222
+ if (!host || !project) {
2223
+ throw new ProviderError(tr("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u0445\u043E\u0441\u0442 \u0438 \u043F\u0440\u043E\u0435\u043A\u0442", "could not determine host and project"), tr("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 project \u0438 host \u0432 .revdeck.yml \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 URL \u0440\u0435\u0432\u044C\u044E", "set project and host in .revdeck.yml or pass the review URL"));
2224
+ }
2225
+ return { platform, host, project };
2226
+ }
2227
+ async function resolveReviewRef(options) {
2228
+ const { root, config, input } = options;
2229
+ if (input && /^(https?:)?\/\//.test(input.trim())) {
2230
+ return parseReviewUrl(input);
2231
+ }
2232
+ const context = await projectContext(root, config, {
2233
+ ...options.platform ? { platform: options.platform } : {},
2234
+ ...options.project ? { project: options.project } : {},
2235
+ ...options.host ? { host: options.host } : {}
2236
+ });
2237
+ if (input && /^\d+$/.test(input.trim())) {
2238
+ return { ...context, number: Number(input.trim()) };
2239
+ }
2240
+ if (input && /^[^#!]+[#!]\d+$/.test(input.trim())) {
2241
+ return parseReviewUrl(input);
2242
+ }
2243
+ if (input) throw new ProviderError(tr(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C \u0440\u0435\u0432\u044C\u044E: ${input}`, `could not parse the review: ${input}`));
2244
+ const branch = options.branch ?? await currentBranch(root);
2245
+ if (!branch || branch === "HEAD") {
2246
+ throw new ProviderError(tr("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u0432\u0435\u0442\u043A\u0443 \u0434\u043B\u044F \u043F\u043E\u0438\u0441\u043A\u0430 \u0440\u0435\u0432\u044C\u044E", "could not determine the branch to search for a review"), tr("\u043F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 \u043D\u043E\u043C\u0435\u0440 \u0440\u0435\u0432\u044C\u044E \u044F\u0432\u043D\u043E", "pass the review number explicitly"));
2247
+ }
2248
+ const probe = await createProvider({ ...context, number: 0 }, { root });
2249
+ const found = await probe.findByBranch(branch);
2250
+ if (!found) throw new ProviderError(tr(`\u043E\u0442\u043A\u0440\u044B\u0442\u043E\u0435 \u0440\u0435\u0432\u044C\u044E \u0434\u043B\u044F \u0432\u0435\u0442\u043A\u0438 ${branch} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `no open review found for branch ${branch}`), tr("\u043F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 \u043D\u043E\u043C\u0435\u0440 \u0440\u0435\u0432\u044C\u044E \u044F\u0432\u043D\u043E", "pass the review number explicitly"));
2251
+ return found;
2252
+ }
2253
+ async function createProvider(ref, options) {
2254
+ if (ref.platform === "gitlab") {
2255
+ const { GitLabProvider: GitLabProvider2 } = await Promise.resolve().then(() => (init_gitlab(), gitlab_exports));
2256
+ return new GitLabProvider2(ref, options);
2257
+ }
2258
+ const { GitHubProvider: GitHubProvider2 } = await Promise.resolve().then(() => (init_github(), github_exports));
2259
+ return new GitHubProvider2(ref, options);
2260
+ }
2261
+
2262
+ // src/daemon/service.ts
2263
+ init_exec();
2264
+
2265
+ // src/daemon/view.ts
2266
+ init_diff();
2267
+ var STALE_AFTER_MS = 9e4;
2268
+ function fileSummaries(diff) {
2269
+ return diff.map((file) => {
2270
+ let additions = 0;
2271
+ let deletions = 0;
2272
+ for (const hunk of file.hunks) {
2273
+ for (const line of hunk.lines) {
2274
+ if (line.kind === "+") additions++;
2275
+ else if (line.kind === "-") deletions++;
2276
+ }
2277
+ }
2278
+ return { path: file.path, oldPath: file.oldPath, status: file.status, binary: file.binary, additions, deletions };
2279
+ });
2280
+ }
2281
+ function toReviewView(review, now = Date.now()) {
2282
+ const { diff, threads, findings, ...rest } = review;
2283
+ return {
2284
+ ...rest,
2285
+ threads: threads.map((thread) => ({
2286
+ ...thread,
2287
+ snippet: thread.path && thread.line ? snippetAround(diff, thread.path, thread.line, 4) : null
2288
+ })),
2289
+ findings: findings.map((finding) => ({
2290
+ ...finding,
2291
+ snippet: finding.file && finding.line ? snippetAround(diff, finding.file, finding.line, 4) : null
2292
+ })),
2293
+ files: fileSummaries(diff),
2294
+ stats: diffStats(diff),
2295
+ stale: now - new Date(review.syncedAt).getTime() > STALE_AFTER_MS
2296
+ };
2297
+ }
2298
+
2299
+ // src/daemon/service.ts
2300
+ var EMPTY_DRAFT = (threadId) => ({
2301
+ threadId,
2302
+ body: "",
2303
+ resolve: false,
2304
+ noteForAgent: "",
2305
+ agentAction: "",
2306
+ posted: "",
2307
+ replied: false,
2308
+ resolved: false,
2309
+ error: "",
2310
+ updatedAt: (/* @__PURE__ */ new Date(0)).toISOString()
2311
+ });
2312
+ var APPROVAL_TTL_MS = 10 * 60 * 1e3;
2313
+ var ReviewService = class {
2314
+ events = new EventEmitter();
2315
+ store;
2316
+ providerFactory;
2317
+ runImpl;
2318
+ now;
2319
+ refreshTtlMs;
2320
+ syncIntervalMs;
2321
+ timer = null;
2322
+ syncing = false;
2323
+ constructor(options) {
2324
+ this.store = options.store;
2325
+ this.runImpl = options.runImpl ?? run;
2326
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
2327
+ this.refreshTtlMs = options.refreshTtlMs ?? 6e4;
2328
+ this.syncIntervalMs = options.syncIntervalMs ?? 12e4;
2329
+ this.providerFactory = options.providerFactory ?? (async (ref, root) => createProvider(ref, { root }));
2330
+ this.events.setMaxListeners(50);
2331
+ }
2332
+ close() {
2333
+ if (this.timer) clearInterval(this.timer);
2334
+ this.timer = null;
2335
+ this.store.close();
2336
+ }
2337
+ // ---------------------------------------------------------------- reviews
2338
+ async openReview(rootInput, options = {}) {
2339
+ const root = findRepoRoot(rootInput);
2340
+ const loaded = await loadConfig(root);
2341
+ const ref = await resolveReviewRef({
2342
+ root,
2343
+ config: loaded.config,
2344
+ ...options.input ? { input: options.input } : {},
2345
+ ...options.platform ? { platform: options.platform } : {},
2346
+ ...options.project ? { project: options.project } : {},
2347
+ ...options.host ? { host: options.host } : {},
2348
+ ...options.branch ? { branch: options.branch } : {}
2349
+ });
2350
+ const id = reviewId(ref);
2351
+ const provider = await this.providerFactory(ref, root);
2352
+ await this.refresh(id, root, provider, loaded);
2353
+ this.store.audit(id, "review.open", { ref, root });
2354
+ return this.getReview(id);
2355
+ }
2356
+ async refreshReview(id) {
2357
+ const meta = this.store.getReview(id);
2358
+ if (!meta) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E: \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0435\u0433\u043E`, `review ${id} not found: open it first`));
2359
+ const loaded = await loadConfig(meta.row.root).catch((error) => ({
2360
+ config: meta.snapshot.config,
2361
+ path: meta.snapshot.configPath,
2362
+ warnings: [tr(`\u043A\u043E\u043D\u0444\u0438\u0433 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D: ${redact(messageOf(error))}`, `config unavailable: ${redact(messageOf(error))}`)]
2363
+ }));
2364
+ const provider = await this.providerFactory(
2365
+ { platform: meta.row.platform, host: meta.row.host, project: meta.row.project, number: meta.row.number },
2366
+ meta.row.root
2367
+ );
2368
+ await this.refresh(id, meta.row.root, provider, loaded);
2369
+ return this.getReview(id);
2370
+ }
2371
+ async refresh(id, root, provider, loaded) {
2372
+ const errors = [...loaded.warnings];
2373
+ const previous = this.store.getReview(id)?.snapshot;
2374
+ let info;
2375
+ try {
2376
+ info = await provider.info();
2377
+ } catch (error) {
2378
+ throw new Error(tr(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u0440\u0435\u0432\u044C\u044E: ${redact(messageOf(error))}`, `could not read the review: ${redact(messageOf(error))}`));
2379
+ }
2380
+ const syncedAt = this.now().toISOString();
2381
+ let diff = previous?.diff ?? [];
2382
+ try {
2383
+ diff = await provider.diff();
2384
+ } catch (error) {
2385
+ errors.push(`diff: ${redact(messageOf(error))}`);
2386
+ }
2387
+ let threads = previous?.threads ?? [];
2388
+ try {
2389
+ threads = await provider.threads();
2390
+ } catch (error) {
2391
+ errors.push(`${tr("\u0442\u0440\u0435\u0434\u044B", "threads")}: ${redact(messageOf(error))}`);
2392
+ }
2393
+ info.stats = diffStats(diff);
2394
+ this.store.saveReview(info, root, syncedAt);
2395
+ this.store.saveSnapshot(id, {
2396
+ diff,
2397
+ threads,
2398
+ config: loaded.config,
2399
+ configPath: loaded.path,
2400
+ errors,
2401
+ syncedAt
2402
+ });
2403
+ if (diff.length) this.markStaleFindings(id, diff);
2404
+ this.emit(id);
2405
+ }
2406
+ markStaleFindings(id, diff) {
2407
+ const findings = this.store.listFindings(id);
2408
+ if (!findings.length) return;
2409
+ let changed = false;
2410
+ for (const finding of findings) {
2411
+ if (finding.status === "published" || finding.status === "stale") continue;
2412
+ if (finding.line === null) continue;
2413
+ if (!findNewLine(diff, finding.file, finding.line)) {
2414
+ this.store.updateFinding(id, { ...finding, status: "stale" });
2415
+ changed = true;
2416
+ }
2417
+ }
2418
+ if (changed) this.emit(id);
2419
+ }
2420
+ getReview(id) {
2421
+ const stored = this.store.getReview(id);
2422
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2423
+ const { row, snapshot } = stored;
2424
+ const info = this.store.info(id) ?? fallbackInfo(row);
2425
+ const draftMap = new Map(this.store.listDrafts(id).map((draft) => [draft.threadId, draft]));
2426
+ const threads = snapshot.threads;
2427
+ const drafts = threads.map((thread) => {
2428
+ const saved = draftMap.get(thread.id) ?? EMPTY_DRAFT(thread.id);
2429
+ const desired = saved.resolve !== saved.resolved ? saved.resolve : thread.resolved;
2430
+ return {
2431
+ ...saved,
2432
+ resolved: thread.resolved,
2433
+ resolve: desired,
2434
+ replied: saved.posted.trim() !== "" && saved.body.trim() === saved.posted.trim()
2435
+ };
2436
+ });
2437
+ for (const [threadId, draft] of draftMap) {
2438
+ if (!threads.some((thread) => thread.id === threadId)) drafts.push(draft);
2439
+ }
2440
+ const findings = applyDecisions(this.store.listFindings(id), this.store.listDecisions(id)).sort(
2441
+ (a, b) => levelOrder(a.level) - levelOrder(b.level) || a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
2442
+ );
2443
+ return {
2444
+ id,
2445
+ info,
2446
+ diff: snapshot.diff,
2447
+ threads,
2448
+ summary: this.store.getSummary(id),
2449
+ findings,
2450
+ drafts,
2451
+ notes: this.store.listNotes(id),
2452
+ decisions: this.store.listDecisions(id),
2453
+ user: null,
2454
+ syncedAt: snapshot.syncedAt,
2455
+ errors: snapshot.errors
2456
+ };
2457
+ }
2458
+ getView(id) {
2459
+ return toReviewView(this.getReview(id), this.now().getTime());
2460
+ }
2461
+ listReviews() {
2462
+ return this.store.listReviews();
2463
+ }
2464
+ async listRemote(rootInput, scope, limit = 50) {
2465
+ const root = findRepoRoot(rootInput);
2466
+ const loaded = await loadConfig(root);
2467
+ const context = await projectContext(root, loaded.config);
2468
+ const provider = await this.providerFactory({ ...context, number: 0 }, root);
2469
+ return { items: await provider.list({ scope, limit }), platform: context.platform, project: context.project };
2470
+ }
2471
+ isStale(id) {
2472
+ const stored = this.store.getReview(id);
2473
+ if (!stored) return true;
2474
+ return this.now().getTime() - new Date(stored.snapshot.syncedAt).getTime() > this.refreshTtlMs;
2475
+ }
2476
+ async refreshIfStale(id) {
2477
+ if (this.isStale(id)) {
2478
+ try {
2479
+ await this.refreshReview(id);
2480
+ } catch {
2481
+ }
2482
+ }
2483
+ return this.getView(id);
2484
+ }
2485
+ diffFile(id, file) {
2486
+ const stored = this.store.getReview(id);
2487
+ if (!stored) return null;
2488
+ const found = stored.snapshot.diff.find((item) => item.path === file);
2489
+ if (!found) return null;
2490
+ return { path: found.path, status: found.status, binary: found.binary, hunks: found.hunks };
2491
+ }
2492
+ // ---------------------------------------------------------------- summary
2493
+ setSummary(id, input) {
2494
+ this.requireReview(id);
2495
+ const value = input ?? {};
2496
+ const list = (item, limit) => (Array.isArray(item) ? item : []).map((entry) => String(entry ?? "").trim()).filter(Boolean).slice(0, limit).map((entry) => entry.slice(0, 2e3));
2497
+ const summary = {
2498
+ title: String(value.title ?? "").slice(0, 500),
2499
+ scope: String(value.scope ?? "").slice(0, 1e3),
2500
+ summary: String(value.summary ?? "").slice(0, 5e3),
2501
+ changes: list(value.changes, 50),
2502
+ focus: list(value.focus, 50)
2503
+ };
2504
+ this.store.setSummary(id, summary);
2505
+ this.emit(id);
2506
+ return summary;
2507
+ }
2508
+ // ---------------------------------------------------------------- findings
2509
+ setFindings(id, raw) {
2510
+ const review = this.getReview(id);
2511
+ if (!Array.isArray(raw)) throw new Error(tr("findings: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043C\u0430\u0441\u0441\u0438\u0432", "findings: expected an array"));
2512
+ const existing = this.store.listFindings(id);
2513
+ const knownIds = new Set(existing.map((finding) => finding.id));
2514
+ const knownPrints = new Set(existing.map((finding) => finding.fingerprint));
2515
+ const drafts = [];
2516
+ const errors = [];
2517
+ for (const [index, item] of raw.entries()) {
2518
+ const { value, error } = validateFinding(item);
2519
+ if (!value) {
2520
+ errors.push(`#${index + 1}: ${error}`);
2521
+ continue;
2522
+ }
2523
+ const known = (value.id ? knownIds.has(value.id) : false) || knownPrints.has(fingerprintOf(value));
2524
+ if (!known) {
2525
+ const file = review.diff.find((entry) => entry.path === value.file);
2526
+ if (!file) {
2527
+ errors.push(tr(`#${index + 1}: \u0444\u0430\u0439\u043B\u0430 ${value.file} \u043D\u0435\u0442 \u0432 diff`, `#${index + 1}: file ${value.file} is not in the diff`));
2528
+ continue;
2529
+ }
2530
+ if (value.line !== null && !findNewLine(review.diff, value.file, value.line)) {
2531
+ errors.push(tr(`#${index + 1}: \u0441\u0442\u0440\u043E\u043A\u0430 ${value.file}:${value.line} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 diff`, `#${index + 1}: line ${value.file}:${value.line} is not in the diff`));
2532
+ continue;
2533
+ }
2534
+ }
2535
+ drafts.push(value);
2536
+ }
2537
+ if (errors.length) throw new Error(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u044F \u043E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u044B:
2538
+ ${errors.join("\n")}`, `findings rejected:
2539
+ ${errors.join("\n")}`));
2540
+ const merged = mergeFindings(this.store.listFindings(id), drafts, { now: this.now().toISOString() });
2541
+ this.store.replaceFindings(id, merged);
2542
+ this.emit(id);
2543
+ return merged;
2544
+ }
2545
+ setDecisions(id, raw) {
2546
+ this.requireReview(id);
2547
+ const patch = {};
2548
+ const input = raw ?? {};
2549
+ for (const [findingId, value] of Object.entries(input)) {
2550
+ const item = value ?? {};
2551
+ const decision = item.decision;
2552
+ patch[findingId] = {
2553
+ decision: decision === "accept" || decision === "reject" || decision === "discuss" ? decision : null,
2554
+ note: String(item.note ?? "").slice(0, 4e3)
2555
+ };
2556
+ }
2557
+ this.store.setDecisions(id, patch);
2558
+ this.emit(id);
2559
+ return this.store.listDecisions(id);
2560
+ }
2561
+ // ---------------------------------------------------------------- drafts
2562
+ setDraft(id, threadId, patch) {
2563
+ const review = this.getReview(id);
2564
+ const thread = review.threads.find((item) => item.id === threadId);
2565
+ if (!thread) throw new Error(tr(`\u0442\u0440\u0435\u0434\u0430 ${threadId} \u043D\u0435\u0442 \u0432 \u044D\u0442\u043E\u043C \u0440\u0435\u0432\u044C\u044E`, `thread ${threadId} is not in this review`));
2566
+ const previous = this.store.getDraft(id, threadId) ?? EMPTY_DRAFT(threadId);
2567
+ const body = patch.body !== void 0 ? String(patch.body).slice(0, 64 * 1024) : previous.body;
2568
+ const resolve = patch.resolve !== void 0 ? Boolean(patch.resolve) : previous.resolve;
2569
+ const next = {
2570
+ ...previous,
2571
+ body,
2572
+ resolve,
2573
+ noteForAgent: patch.noteForAgent !== void 0 ? String(patch.noteForAgent).slice(0, 8e3) : previous.noteForAgent,
2574
+ agentAction: ["fix", "reply", "revise", "skip"].includes(String(patch.agentAction)) ? patch.agentAction : previous.agentAction,
2575
+ resolved: thread.resolved,
2576
+ updatedAt: this.now().toISOString()
2577
+ };
2578
+ next.replied = next.posted.trim() !== "" && next.body.trim() === next.posted.trim();
2579
+ if (body !== previous.body || resolve !== previous.resolve) next.error = "";
2580
+ this.store.upsertDraft(id, next);
2581
+ this.emit(id);
2582
+ return next;
2583
+ }
2584
+ // ---------------------------------------------------------------- notes
2585
+ addNote(id, input) {
2586
+ const review = this.getReview(id);
2587
+ if (!findNewLine(review.diff, input.file, input.line)) {
2588
+ throw new Error(tr(`\u0441\u0442\u0440\u043E\u043A\u0430 ${input.file}:${input.line} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 diff`, `line ${input.file}:${input.line} is not in the diff`));
2589
+ }
2590
+ const timestamp = this.now().toISOString();
2591
+ const note = {
2592
+ id: `u-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`,
2593
+ file: input.file,
2594
+ line: input.line,
2595
+ side: input.side ?? "new",
2596
+ text: String(input.text ?? "").slice(0, 16e3),
2597
+ toAgent: input.toAgent ?? true,
2598
+ agentAction: "",
2599
+ agentReply: "",
2600
+ posted: "",
2601
+ discussionId: "",
2602
+ noteId: "",
2603
+ error: "",
2604
+ createdAt: timestamp,
2605
+ updatedAt: timestamp
2606
+ };
2607
+ this.store.upsertNote(id, note);
2608
+ this.emit(id);
2609
+ return note;
2610
+ }
2611
+ updateNote(id, noteId, patch) {
2612
+ const existing = this.store.listNotes(id).find((note) => note.id === noteId);
2613
+ if (!existing) throw new Error(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u044F ${noteId} \u043D\u0435\u0442`, `comment ${noteId} not found`));
2614
+ const next = {
2615
+ ...existing,
2616
+ text: patch.text !== void 0 ? String(patch.text).slice(0, 16e3) : existing.text,
2617
+ toAgent: patch.toAgent !== void 0 ? Boolean(patch.toAgent) : existing.toAgent,
2618
+ agentAction: ["fix", "reply", ""].includes(String(patch.agentAction)) ? patch.agentAction : existing.agentAction,
2619
+ agentReply: patch.agentReply !== void 0 ? String(patch.agentReply).slice(0, 8e3) : existing.agentReply,
2620
+ posted: patch.posted !== void 0 ? String(patch.posted) : existing.posted,
2621
+ discussionId: patch.discussionId ?? existing.discussionId,
2622
+ noteId: patch.noteId ?? existing.noteId,
2623
+ error: patch.error ?? existing.error,
2624
+ updatedAt: this.now().toISOString()
2625
+ };
2626
+ if (patch.text !== void 0 && patch.text !== existing.text) next.agentReply = patch.agentReply ?? "";
2627
+ this.store.upsertNote(id, next);
2628
+ this.emit(id);
2629
+ return next;
2630
+ }
2631
+ deleteNote(id, noteId) {
2632
+ const existing = this.store.listNotes(id).find((note) => note.id === noteId);
2633
+ if (!existing) return;
2634
+ if (existing.posted) throw new Error(tr("\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D: \u0443\u0434\u0430\u043B\u044F\u0442\u044C \u0435\u0433\u043E \u043D\u0443\u0436\u043D\u043E \u0432 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0435", "the comment is already published: delete it in the platform"));
2635
+ this.store.deleteNote(id, noteId);
2636
+ this.emit(id);
2637
+ }
2638
+ // ---------------------------------------------------------------- publish
2639
+ previewPublish(id, kind, ids) {
2640
+ const review = this.getReview(id);
2641
+ const items = [];
2642
+ const warnings = [];
2643
+ const max = this.configOf(id)?.publish?.maxComments ?? 50;
2644
+ for (const itemId of ids) {
2645
+ if (items.length >= max) {
2646
+ warnings.push(tr(`\u0434\u043E\u0441\u0442\u0438\u0433\u043D\u0443\u0442 \u043B\u0438\u043C\u0438\u0442 ${max} \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0435\u0432 \u0437\u0430 \u0440\u0430\u0437`, `limit of ${max} comments per batch reached`));
2647
+ break;
2648
+ }
2649
+ if (kind === "reply") {
2650
+ const thread = review.threads.find((item) => item.id === itemId);
2651
+ const draft = review.drafts.find((item) => item.threadId === itemId);
2652
+ if (!thread || !draft) {
2653
+ warnings.push(tr(`\u0442\u0440\u0435\u0434 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`, `thread ${itemId} not found`));
2654
+ continue;
2655
+ }
2656
+ const body = draft.body.trim();
2657
+ const alreadyPosted = body !== "" && draft.posted.trim() === body;
2658
+ const resolveChanged = draft.resolve !== thread.resolved;
2659
+ if (!body && !resolveChanged) {
2660
+ warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043D\u0435\u0447\u0435\u0433\u043E \u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u0442\u044C`, `thread ${thread.id.substring(0, 8)}: nothing to publish`));
2661
+ continue;
2662
+ }
2663
+ if (body && alreadyPosted && !resolveChanged) {
2664
+ warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043E\u0442\u0432\u0435\u0442 \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `thread ${thread.id.substring(0, 8)}: reply already published`));
2665
+ continue;
2666
+ }
2667
+ if (body) {
2668
+ const check2 = sanitizeBody(body, review.info.ref.platform);
2669
+ if (!check2.ok) {
2670
+ warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: ${check2.reason}`, `thread ${thread.id.substring(0, 8)}: ${check2.reason}`));
2671
+ continue;
2672
+ }
2673
+ }
2674
+ items.push({ kind, id: thread.id, body, resolve: draft.resolve });
2675
+ continue;
2676
+ }
2677
+ if (kind === "finding") {
2678
+ const finding = review.findings.find((item) => item.id === itemId);
2679
+ if (!finding) {
2680
+ warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `finding ${itemId} not found`));
2681
+ continue;
2682
+ }
2683
+ if (finding.status === "published") {
2684
+ warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043E`, `finding ${finding.id} is already published`));
2685
+ continue;
2686
+ }
2687
+ const body = finding.comment.trim();
2688
+ const check2 = sanitizeBody(body, review.info.ref.platform);
2689
+ if (!check2.ok) {
2690
+ warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id}: ${check2.reason}`, `finding ${finding.id}: ${check2.reason}`));
2691
+ continue;
2692
+ }
2693
+ const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${body}` : body;
2694
+ items.push({
2695
+ kind,
2696
+ id: finding.id,
2697
+ body: finalBody,
2698
+ file: finding.file,
2699
+ line: finding.line,
2700
+ ...finding.line === null ? {} : { side: "new" }
2701
+ });
2702
+ continue;
2703
+ }
2704
+ const note = review.notes.find((item) => item.id === itemId);
2705
+ if (!note) {
2706
+ warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`, `comment ${itemId} not found`));
2707
+ continue;
2708
+ }
2709
+ if (note.posted) {
2710
+ warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `comment ${note.id} is already published`));
2711
+ continue;
2712
+ }
2713
+ const check = sanitizeBody(note.text, review.info.ref.platform);
2714
+ if (!check.ok) {
2715
+ warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id}: ${check.reason}`, `comment ${note.id}: ${check.reason}`));
2716
+ continue;
2717
+ }
2718
+ items.push({ kind, id: note.id, body: note.text, file: note.file, line: note.line, side: note.side });
2719
+ }
2720
+ return { plan: this.makePlan(id, kind, items), warnings };
2721
+ }
2722
+ makePlan(id, kind, items) {
2723
+ const hash = canonicalHash(id, kind, items);
2724
+ return { reviewId: id, kind, items, hash, createdAt: this.now().toISOString() };
2725
+ }
2726
+ approve(id, kind, ids, expectedHash) {
2727
+ const preview = this.previewPublish(id, kind, ids);
2728
+ if (preview.plan.hash !== expectedHash) {
2729
+ throw new Error(tr("\u043D\u0430\u0431\u043E\u0440 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439 \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u044F: \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0443 \u0438 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u0437\u0430\u043D\u043E\u0432\u043E", "the plan changed after confirmation: refresh and confirm again"));
2730
+ }
2731
+ if (!preview.plan.items.length) throw new Error(tr("\u043D\u0435\u0447\u0435\u0433\u043E \u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u0442\u044C: \u0432 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u0430\u0445 \u043D\u0435\u0442 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439", "nothing to publish: the selected items have no changes"));
2732
+ const approval = {
2733
+ id: randomBytes(16).toString("hex"),
2734
+ reviewId: id,
2735
+ hash: preview.plan.hash,
2736
+ kind,
2737
+ expiresAt: new Date(this.now().getTime() + APPROVAL_TTL_MS).toISOString(),
2738
+ usedAt: null,
2739
+ createdAt: this.now().toISOString()
2740
+ };
2741
+ this.store.createApproval({
2742
+ id: approval.id,
2743
+ reviewId: id,
2744
+ kind,
2745
+ hash: approval.hash,
2746
+ items: preview.plan.items,
2747
+ expiresAt: approval.expiresAt,
2748
+ usedAt: null,
2749
+ createdAt: approval.createdAt
2750
+ });
2751
+ this.store.audit(id, "publish.approve", { approvalId: approval.id, kind, count: preview.plan.items.length });
2752
+ return approval;
2753
+ }
2754
+ async publish(id, approvalId) {
2755
+ const approval = this.store.getApproval(approvalId);
2756
+ if (!approval) throw new Error(tr("\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E", "approval not found"));
2757
+ if (approval.reviewId !== id) throw new Error(tr("\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u0432\u044B\u0434\u0430\u043D\u043E \u0434\u043B\u044F \u0434\u0440\u0443\u0433\u043E\u0433\u043E \u0440\u0435\u0432\u044C\u044E", "the approval belongs to another review"));
2758
+ if (approval.usedAt) throw new Error(tr("\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u043E", "the approval has already been used"));
2759
+ if (new Date(approval.expiresAt).getTime() < this.now().getTime()) throw new Error(tr("\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u0438\u0441\u0442\u0435\u043A\u043B\u043E", "the approval has expired"));
2760
+ this.store.markApprovalUsed(approvalId);
2761
+ const stored = this.store.getReview(id);
2762
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2763
+ const provider = await this.providerFactory(
2764
+ { platform: stored.row.platform, host: stored.row.host, project: stored.row.project, number: stored.row.number },
2765
+ stored.row.root
2766
+ );
2767
+ const review = this.getReview(id);
2768
+ const platform = review.info.ref.platform;
2769
+ const results = [];
2770
+ const startedAt = this.now().toISOString();
2771
+ for (const item of approval.items) {
2772
+ const result = { kind: item.kind, id: item.id, ok: false };
2773
+ results.push(result);
2774
+ try {
2775
+ if (item.kind === "reply") {
2776
+ const thread = review.threads.find((entry) => entry.id === item.id);
2777
+ const draft = this.store.getDraft(id, item.id) ?? EMPTY_DRAFT(item.id);
2778
+ if (!thread) throw new Error(tr("\u0442\u0440\u0435\u0434 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442", "the thread no longer exists"));
2779
+ const body = String(item.body ?? "").trim();
2780
+ if (body && draft.posted.trim() !== body) {
2781
+ const check = sanitizeBody(body, platform);
2782
+ if (!check.ok) throw new Error(check.reason);
2783
+ await provider.reply(thread, body);
2784
+ draft.posted = body;
2785
+ draft.replied = true;
2786
+ result.ok = true;
2787
+ }
2788
+ if (typeof item.resolve === "boolean" && item.resolve !== thread.resolved) {
2789
+ await provider.resolve(thread.id, item.resolve);
2790
+ draft.resolved = item.resolve;
2791
+ thread.resolved = item.resolve;
2792
+ result.ok = true;
2793
+ }
2794
+ draft.error = "";
2795
+ draft.updatedAt = this.now().toISOString();
2796
+ this.store.upsertDraft(id, draft);
2797
+ this.saveThreads(id, review.threads);
2798
+ result.discussionId = thread.id;
2799
+ continue;
2800
+ }
2801
+ if (item.kind === "finding") {
2802
+ const finding = review.findings.find((entry) => entry.id === item.id);
2803
+ if (!finding) throw new Error(tr("\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E", "finding not found"));
2804
+ if (finding.status === "published") {
2805
+ result.ok = true;
2806
+ result.skipped = true;
2807
+ result.discussionId = finding.discussionId ?? void 0;
2808
+ continue;
2809
+ }
2810
+ const body = String(item.body ?? "").trim();
2811
+ const position2 = finding.line !== null ? linePosition(review.diff, finding.file, finding.line) : null;
2812
+ if (finding.line !== null && !position2) throw new Error(tr(`\u0441\u0442\u0440\u043E\u043A\u0430 ${finding.file}:${finding.line} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 diff`, `line ${finding.file}:${finding.line} is not in the diff`));
2813
+ const created2 = await provider.createThread({
2814
+ file: finding.file,
2815
+ line: finding.line,
2816
+ oldLine: position2?.oldLine ?? null,
2817
+ body
2818
+ });
2819
+ finding.status = "published";
2820
+ finding.discussionId = created2.discussionId;
2821
+ finding.publishedAt = this.now().toISOString();
2822
+ this.store.updateFinding(id, finding);
2823
+ result.ok = true;
2824
+ result.discussionId = created2.discussionId;
2825
+ result.noteId = created2.noteId;
2826
+ continue;
2827
+ }
2828
+ const note = review.notes.find((entry) => entry.id === item.id);
2829
+ if (!note) throw new Error(tr("\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D", "comment not found"));
2830
+ if (note.posted) {
2831
+ result.ok = true;
2832
+ result.skipped = true;
2833
+ continue;
2834
+ }
2835
+ const position = linePosition(review.diff, note.file, note.line);
2836
+ if (!position) throw new Error(tr(`\u0441\u0442\u0440\u043E\u043A\u0430 ${note.file}:${note.line} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 diff`, `line ${note.file}:${note.line} is not in the diff`));
2837
+ const created = await provider.createThread({
2838
+ file: note.file,
2839
+ line: note.line,
2840
+ oldLine: position.oldLine,
2841
+ body: String(item.body ?? "").trim()
2842
+ });
2843
+ note.posted = String(item.body ?? "").trim();
2844
+ note.discussionId = created.discussionId;
2845
+ note.noteId = created.noteId;
2846
+ note.updatedAt = this.now().toISOString();
2847
+ this.store.upsertNote(id, note);
2848
+ result.ok = true;
2849
+ result.discussionId = created.discussionId;
2850
+ result.noteId = created.noteId;
2851
+ } catch (error) {
2852
+ result.error = redact(messageOf(error));
2853
+ this.recordPublishError(id, item, result.error);
2854
+ }
2855
+ }
2856
+ const finishedAt = this.now().toISOString();
2857
+ this.store.audit(id, "publish.commit", {
2858
+ approvalId,
2859
+ kind: approval.kind,
2860
+ ok: results.filter((item) => item.ok).length,
2861
+ failed: results.filter((item) => !item.ok).length
2862
+ });
2863
+ this.emit(id);
2864
+ return { reviewId: id, results, startedAt, finishedAt };
2865
+ }
2866
+ recordPublishError(id, item, error) {
2867
+ if (item.kind === "reply") {
2868
+ const draft = this.store.getDraft(id, item.id);
2869
+ if (draft) this.store.upsertDraft(id, { ...draft, error, updatedAt: this.now().toISOString() });
2870
+ return;
2871
+ }
2872
+ if (item.kind === "finding") {
2873
+ const finding = this.store.listFindings(id).find((entry) => entry.id === item.id);
2874
+ if (finding) this.store.updateFinding(id, { ...finding, publishError: error });
2875
+ return;
2876
+ }
2877
+ const note = this.store.listNotes(id).find((entry) => entry.id === item.id);
2878
+ if (note) this.store.upsertNote(id, { ...note, error, updatedAt: this.now().toISOString() });
2879
+ }
2880
+ saveThreads(id, threads) {
2881
+ const stored = this.store.getReview(id);
2882
+ if (!stored) return;
2883
+ this.store.saveSnapshot(id, { ...stored.snapshot, threads });
2884
+ }
2885
+ // ---------------------------------------------------------------- rules & checks
2886
+ async rules(id) {
2887
+ const stored = this.store.getReview(id);
2888
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2889
+ const info = this.store.info(id);
2890
+ const texts = [
2891
+ info?.title ?? "",
2892
+ info?.description ?? "",
2893
+ ...stored.snapshot.threads.flatMap((thread) => thread.notes.map((note) => note.body))
2894
+ ];
2895
+ let config = stored.snapshot.config;
2896
+ let configPath = stored.snapshot.configPath;
2897
+ if (!config?.rules) {
2898
+ const loaded = await loadConfig(stored.row.root);
2899
+ config = loaded.config;
2900
+ configPath = loaded.path;
2901
+ }
2902
+ const language = resolveOutputLanguage(config.output?.language ?? "auto", texts);
2903
+ const effective = await buildEffectiveRules({
2904
+ root: stored.row.root,
2905
+ config,
2906
+ configPath,
2907
+ files: changedPaths(stored.snapshot.diff),
2908
+ language
2909
+ });
2910
+ return {
2911
+ markdown: renderRules(effective, { language }),
2912
+ configPath: effective.configPath,
2913
+ warnings: effective.warnings,
2914
+ checks: effective.checks,
2915
+ language
2916
+ };
2917
+ }
2918
+ async runChecks(id, options) {
2919
+ const stored = this.store.getReview(id);
2920
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2921
+ const { checks } = await this.rules(id);
2922
+ const matched = checks.filter((check) => check.matched && (!options.ids?.length || options.ids.includes(check.id)));
2923
+ const results = [];
2924
+ for (const check of matched) {
2925
+ if (!options.allowRun) {
2926
+ results.push({
2927
+ id: check.id,
2928
+ name: check.name,
2929
+ argv: check.argv,
2930
+ status: "skipped",
2931
+ output: tr("\u0437\u0430\u043F\u0443\u0441\u043A \u043D\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043D: \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 revdeck checks --run --yes", "run not confirmed: use revdeck checks --run --yes"),
2932
+ durationMs: 0
2933
+ });
2934
+ continue;
2935
+ }
2936
+ const started = Date.now();
2937
+ try {
2938
+ const { stdout, stderr, code } = await this.runImpl(check.argv[0], check.argv.slice(1), {
2939
+ cwd: stored.row.root,
2940
+ timeoutMs: stored.snapshot.config.checks?.find((item) => item.id === check.id)?.timeoutMs ?? 3e5,
2941
+ maxBuffer: 16 * 1024 * 1024
2942
+ });
2943
+ results.push({
2944
+ id: check.id,
2945
+ name: check.name,
2946
+ argv: check.argv,
2947
+ status: code === 0 ? "passed" : "failed",
2948
+ output: redact(`${stdout}
2949
+ ${stderr}`.trim()).slice(0, 2e4),
2950
+ durationMs: Date.now() - started
2951
+ });
2952
+ } catch (error) {
2953
+ results.push({
2954
+ id: check.id,
2955
+ name: check.name,
2956
+ argv: check.argv,
2957
+ status: "error",
2958
+ output: redact(messageOf(error)).slice(0, 2e4),
2959
+ durationMs: Date.now() - started
2960
+ });
2961
+ }
2962
+ }
2963
+ this.store.audit(id, "checks.run", { count: results.length });
2964
+ return results;
2965
+ }
2966
+ // ---------------------------------------------------------------- sync
2967
+ startSync() {
2968
+ if (this.timer || this.syncIntervalMs <= 0) return;
2969
+ this.timer = setInterval(() => {
2970
+ void this.syncAll();
2971
+ }, this.syncIntervalMs);
2972
+ this.timer.unref?.();
2973
+ }
2974
+ async syncAll() {
2975
+ if (this.syncing) return;
2976
+ this.syncing = true;
2977
+ try {
2978
+ const reviews = this.store.listReviews();
2979
+ for (let index = 0; index < reviews.length; index += 2) {
2980
+ const chunk = reviews.slice(index, index + 2);
2981
+ await Promise.allSettled(
2982
+ chunk.map(async (item) => {
2983
+ const age = this.now().getTime() - new Date(item.syncedAt).getTime();
2984
+ if (age < this.syncIntervalMs / 2) return;
2985
+ try {
2986
+ await this.refreshReview(item.id);
2987
+ } catch {
2988
+ }
2989
+ })
2990
+ );
2991
+ }
2992
+ } finally {
2993
+ this.syncing = false;
2994
+ }
2995
+ }
2996
+ // ---------------------------------------------------------------- helpers
2997
+ emit(id) {
2998
+ this.events.emit("review", { reviewId: id, at: this.now().toISOString() });
2999
+ }
3000
+ requireReview(id) {
3001
+ if (!this.store.getReview(id)) throw new Error(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`);
3002
+ }
3003
+ changedFiles(id) {
3004
+ const stored = this.store.getReview(id);
3005
+ return stored ? changedPaths(stored.snapshot.diff) : [];
3006
+ }
3007
+ diffFor(id) {
3008
+ return this.store.getReview(id)?.snapshot.diff ?? [];
3009
+ }
3010
+ threadSnippet(id, file, line) {
3011
+ if (line === null) return null;
3012
+ return snippetAround(this.diffFor(id), file, line, 4);
3013
+ }
3014
+ configOf(id) {
3015
+ return this.store.getReview(id)?.snapshot.config ?? null;
3016
+ }
3017
+ rootOf(id) {
3018
+ return this.store.getReview(id)?.row.root ?? null;
3019
+ }
3020
+ };
3021
+ function canonicalHash(reviewId2, kind, items) {
3022
+ return createHash2("sha256").update(canonicalJson({ reviewId: reviewId2, kind, items })).digest("hex");
3023
+ }
3024
+ function fallbackInfo(row) {
3025
+ return {
3026
+ ref: { platform: row.platform, host: row.host, project: row.project, number: row.number },
3027
+ title: row.title,
3028
+ description: "",
3029
+ author: row.author,
3030
+ authorName: "",
3031
+ state: row.state,
3032
+ draft: false,
3033
+ sourceBranch: "",
3034
+ targetBranch: "",
3035
+ webUrl: row.web_url,
3036
+ commit: { base: "", start: "", head: "" },
3037
+ updatedAt: row.updated_at,
3038
+ stats: { files: 0, additions: 0, deletions: 0 }
3039
+ };
3040
+ }
3041
+
3042
+ // src/daemon/http.ts
3043
+ init_redact();
3044
+ init_errors();
3045
+ import http from "node:http";
3046
+ import { createReadStream } from "node:fs";
3047
+ import { readFile as readFile2, stat } from "node:fs/promises";
3048
+ import path6 from "node:path";
3049
+ import { timingSafeEqual } from "node:crypto";
3050
+
3051
+ // src/daemon/rpc.ts
3052
+ import net from "node:net";
3053
+ import { chmod, unlink as unlink2 } from "node:fs/promises";
3054
+ init_redact();
3055
+ init_errors();
3056
+ var MAX_LINE = 4 * 1024 * 1024;
3057
+ async function dispatch(service, method, params) {
3058
+ switch (method) {
3059
+ case "ping":
3060
+ return { ok: true, version: VERSION, protocol: PROTOCOL_VERSION, pid: process.pid };
3061
+ case "reviews.list":
3062
+ return { reviews: service.listReviews() };
3063
+ case "reviews.open": {
3064
+ const root = String(params.root ?? "");
3065
+ if (!root) throw new MrReviewError("\u043D\u0443\u0436\u0435\u043D root \u0440\u0430\u0431\u043E\u0447\u0435\u0439 \u043A\u043E\u043F\u0438\u0438");
3066
+ const review = await service.openReview(root, {
3067
+ ...typeof params.input === "string" ? { input: params.input } : {},
3068
+ ...typeof params.platform === "string" ? { platform: params.platform } : {},
3069
+ ...typeof params.project === "string" ? { project: params.project } : {},
3070
+ ...typeof params.host === "string" ? { host: params.host } : {},
3071
+ ...typeof params.branch === "string" ? { branch: params.branch } : {}
3072
+ });
3073
+ return { review: service.getView(review.id), reviews: service.listReviews() };
3074
+ }
3075
+ case "reviews.refresh": {
3076
+ const review = await service.refreshReview(String(params.review ?? ""));
3077
+ return { review: service.getView(review.id), reviews: service.listReviews() };
3078
+ }
3079
+ case "reviews.remoteList": {
3080
+ const scope = ["open", "mine", "reviewing"].includes(params.scope) ? params.scope : "open";
3081
+ return service.listRemote(String(params.root ?? process.cwd()), scope, Number(params.limit ?? 50));
3082
+ }
3083
+ case "reviews.get": {
3084
+ const id = String(params.review ?? "");
3085
+ if (params.refresh === true) await service.refreshReview(id);
3086
+ return { review: service.getView(id), reviews: service.listReviews() };
3087
+ }
3088
+ case "reviews.remove":
3089
+ return { ok: true };
3090
+ case "diff.get": {
3091
+ const file = service.diffFile(String(params.review ?? ""), String(params.file ?? ""));
3092
+ return { file };
3093
+ }
3094
+ case "drafts.put":
3095
+ return { draft: service.setDraft(String(params.review ?? ""), String(params.threadId ?? ""), params.patch ?? {}) };
3096
+ case "findings.put":
3097
+ return { findings: service.setFindings(String(params.review ?? ""), params.findings) };
3098
+ case "findings.list":
3099
+ return { findings: service.getReview(String(params.review ?? "")).findings };
3100
+ case "summary.put":
3101
+ return { summary: service.setSummary(String(params.review ?? ""), params.summary) };
3102
+ case "decisions.put":
3103
+ return { decisions: service.setDecisions(String(params.review ?? ""), params.decisions) };
3104
+ case "notes.add":
3105
+ return {
3106
+ note: service.addNote(String(params.review ?? ""), {
3107
+ file: String(params.file ?? ""),
3108
+ line: Number(params.line ?? 0),
3109
+ side: params.side === "old" ? "old" : "new",
3110
+ text: String(params.text ?? ""),
3111
+ toAgent: params.toAgent !== false
3112
+ })
3113
+ };
3114
+ case "notes.update":
3115
+ return { note: service.updateNote(String(params.review ?? ""), String(params.noteId ?? ""), params.patch ?? {}) };
3116
+ case "notes.delete":
3117
+ service.deleteNote(String(params.review ?? ""), String(params.noteId ?? ""));
3118
+ return { ok: true };
3119
+ case "publish.preview":
3120
+ return {
3121
+ preview: service.previewPublish(
3122
+ String(params.review ?? ""),
3123
+ String(params.kind ?? "reply"),
3124
+ Array.isArray(params.ids) ? params.ids.map(String) : []
3125
+ )
3126
+ };
3127
+ case "publish.approve": {
3128
+ const approval = service.approve(
3129
+ String(params.review ?? ""),
3130
+ String(params.kind ?? "reply"),
3131
+ Array.isArray(params.ids) ? params.ids.map(String) : [],
3132
+ String(params.hash ?? "")
3133
+ );
3134
+ return { approval: { id: approval.id, expiresAt: approval.expiresAt } };
3135
+ }
3136
+ case "publish.commit":
3137
+ return { result: await service.publish(String(params.review ?? ""), String(params.approvalId ?? "")) };
3138
+ case "rules.get":
3139
+ return { rules: await service.rules(String(params.review ?? "")) };
3140
+ case "checks.run":
3141
+ return {
3142
+ results: await service.runChecks(String(params.review ?? ""), {
3143
+ allowRun: params.allowRun === true,
3144
+ ...Array.isArray(params.ids) ? { ids: params.ids.map(String) } : {}
3145
+ })
3146
+ };
3147
+ default:
3148
+ throw new MrReviewError(`\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u043C\u0435\u0442\u043E\u0434 RPC: ${method}`, "METHOD", "\u043E\u0431\u043D\u043E\u0432\u0438\u0442\u0435 revdeck: \u0432\u0435\u0440\u0441\u0438\u044F \u0434\u0435\u043C\u043E\u043D\u0430 \u0438 \u043A\u043B\u0438\u0435\u043D\u0442\u0430 \u0434\u043E\u043B\u0436\u043D\u044B \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0442\u044C");
3149
+ }
3150
+ }
3151
+ async function startRpcServer(service, socketFile) {
3152
+ await ensureDir(pathDirname(socketFile), 448);
3153
+ await unlink2(socketFile).catch(() => void 0);
3154
+ const server = net.createServer((socket) => {
3155
+ socket.setEncoding("utf8");
3156
+ let buffer = "";
3157
+ let queue = Promise.resolve();
3158
+ socket.on("data", (chunk) => {
3159
+ buffer += chunk;
3160
+ if (buffer.length > MAX_LINE) {
3161
+ socket.destroy();
3162
+ return;
3163
+ }
3164
+ let index = buffer.indexOf("\n");
3165
+ while (index >= 0) {
3166
+ const line = buffer.slice(0, index);
3167
+ buffer = buffer.slice(index + 1);
3168
+ if (line.trim()) {
3169
+ const request = parseRequest(line);
3170
+ queue = queue.then(async () => {
3171
+ if (!request) return;
3172
+ const response = await respond(service, request);
3173
+ if (!socket.destroyed) socket.write(`${JSON.stringify(response)}
3174
+ `);
3175
+ });
3176
+ }
3177
+ index = buffer.indexOf("\n");
3178
+ }
3179
+ });
3180
+ socket.on("error", () => void 0);
3181
+ });
3182
+ await new Promise((resolve, reject) => {
3183
+ server.once("error", reject);
3184
+ server.listen(socketFile, () => resolve());
3185
+ });
3186
+ await chmod(socketFile, 384);
3187
+ return {
3188
+ path: socketFile,
3189
+ close: () => new Promise((resolve) => {
3190
+ server.close(() => resolve());
3191
+ setTimeout(() => resolve(), 1e3).unref?.();
3192
+ unlink2(socketFile).catch(() => void 0);
3193
+ })
3194
+ };
3195
+ }
3196
+ function pathDirname(file) {
3197
+ return file.replace(/[/\\][^/\\]+$/, "");
3198
+ }
3199
+ function parseRequest(line) {
3200
+ try {
3201
+ const value = JSON.parse(line);
3202
+ if (!value || typeof value !== "object") return null;
3203
+ return value;
3204
+ } catch {
3205
+ return null;
3206
+ }
3207
+ }
3208
+ async function respond(service, request) {
3209
+ const id = request.id ?? null;
3210
+ if (typeof request.method !== "string") return { id, error: { message: "\u043D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D method" } };
3211
+ try {
3212
+ const result = await dispatch(service, request.method, request.params ?? {});
3213
+ return { id, result };
3214
+ } catch (error) {
3215
+ return {
3216
+ id,
3217
+ error: {
3218
+ message: redact(messageOf(error)),
3219
+ ...error instanceof MrReviewError ? { code: error.code, hint: error.hint } : {}
3220
+ }
3221
+ };
3222
+ }
3223
+ }
3224
+
3225
+ // src/daemon/http.ts
3226
+ var MAX_BODY = 4 * 1024 * 1024;
3227
+ var MIME = {
3228
+ ".html": "text/html; charset=utf-8",
3229
+ ".js": "text/javascript; charset=utf-8",
3230
+ ".mjs": "text/javascript; charset=utf-8",
3231
+ ".css": "text/css; charset=utf-8",
3232
+ ".json": "application/json; charset=utf-8",
3233
+ ".svg": "image/svg+xml",
3234
+ ".png": "image/png",
3235
+ ".woff2": "font/woff2",
3236
+ ".woff": "font/woff",
3237
+ ".ico": "image/x-icon",
3238
+ ".map": "application/json; charset=utf-8",
3239
+ ".txt": "text/plain; charset=utf-8"
3240
+ };
3241
+ var SECURITY_HEADERS = {
3242
+ "x-content-type-options": "nosniff",
3243
+ "x-frame-options": "DENY",
3244
+ "referrer-policy": "no-referrer",
3245
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
3246
+ };
3247
+ var COOKIE = "revdeck_session";
3248
+ function safeEqual(left, right) {
3249
+ const a = Buffer.from(left);
3250
+ const b = Buffer.from(right);
3251
+ return a.length === b.length && timingSafeEqual(a, b);
3252
+ }
3253
+ function cookies(header) {
3254
+ const out = {};
3255
+ for (const part of (header ?? "").split(";")) {
3256
+ const index = part.indexOf("=");
3257
+ if (index > 0) out[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim());
3258
+ }
3259
+ return out;
3260
+ }
3261
+ async function readBody(req) {
3262
+ const chunks = [];
3263
+ let size = 0;
3264
+ for await (const chunk of req) {
3265
+ size += chunk.length;
3266
+ if (size > MAX_BODY) throw Object.assign(new Error("\u0441\u043B\u0438\u0448\u043A\u043E\u043C \u0431\u043E\u043B\u044C\u0448\u043E\u0439 \u0437\u0430\u043F\u0440\u043E\u0441"), { status: 413 });
3267
+ chunks.push(chunk);
3268
+ }
3269
+ const text2 = Buffer.concat(chunks).toString("utf8").trim();
3270
+ if (!text2) return {};
3271
+ const parsed = JSON.parse(text2);
3272
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw Object.assign(new Error("\u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F JSON-\u043E\u0431\u044A\u0435\u043A\u0442"), { status: 400 });
3273
+ return parsed;
3274
+ }
3275
+ function reply(res, status, data) {
3276
+ const body = JSON.stringify(data);
3277
+ res.writeHead(status, {
3278
+ ...SECURITY_HEADERS,
3279
+ "content-type": "application/json; charset=utf-8",
3280
+ "cache-control": "no-store"
3281
+ });
3282
+ res.end(body);
3283
+ }
3284
+ async function startHttpServer(options) {
3285
+ const { service, token } = options;
3286
+ const host = options.host ?? "127.0.0.1";
3287
+ const server = http.createServer(async (req, res) => {
3288
+ const port2 = server.address()?.port ?? 0;
3289
+ const expectedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port2}`, `localhost:${port2}`, `[::1]:${port2}`]);
3290
+ if (!expectedHosts.has(String(req.headers.host ?? ""))) {
3291
+ reply(res, 403, { ok: false, error: "\u043D\u0435\u0434\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u044B\u0439 Host" });
3292
+ return;
3293
+ }
3294
+ if (req.method !== "GET" && req.headers.origin && !expectedHosts.has(String(req.headers.origin).replace(/^http:\/\//, ""))) {
3295
+ reply(res, 403, { ok: false, error: "\u043D\u0435\u0434\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u044B\u0439 Origin" });
3296
+ return;
3297
+ }
3298
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port2}`);
3299
+ const jar = cookies(req.headers.cookie);
3300
+ const authorized = safeEqual(String(jar[COOKIE] ?? ""), token);
3301
+ try {
3302
+ if (req.method === "GET" && url.pathname === "/") {
3303
+ const provided = url.searchParams.get("t");
3304
+ if (provided && safeEqual(provided, token)) {
3305
+ const next = new URL("/", url);
3306
+ next.searchParams.delete("t");
3307
+ res.writeHead(302, {
3308
+ ...SECURITY_HEADERS,
3309
+ "set-cookie": `${COOKIE}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`,
3310
+ location: `${next.pathname}${next.search}`
3311
+ });
3312
+ res.end();
3313
+ return;
3314
+ }
3315
+ if (!authorized) {
3316
+ res.writeHead(401, { ...SECURITY_HEADERS, "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
3317
+ res.end('<meta charset="utf-8"><h1>\u041D\u0443\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u043A\u0430 \u0437\u0430\u043F\u0443\u0441\u043A\u0430</h1><p>\u041E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0438\u0437 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u0430: \u043E\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043E\u0434\u043D\u043E\u0440\u0430\u0437\u043E\u0432\u044B\u0439 \u0442\u043E\u043A\u0435\u043D.</p>');
3318
+ return;
3319
+ }
3320
+ const html = await readFile2(path6.join(options.uiDir, "index.html"));
3321
+ res.writeHead(200, { ...SECURITY_HEADERS, "content-type": MIME[".html"], "cache-control": "no-store" });
3322
+ res.end(html);
3323
+ return;
3324
+ }
3325
+ if (req.method === "GET" && (url.pathname.startsWith("/assets/") || url.pathname === "/favicon.svg")) {
3326
+ const relative = url.pathname === "/favicon.svg" ? "favicon.svg" : url.pathname.slice("/assets/".length);
3327
+ const target = path6.resolve(options.uiDir, url.pathname === "/favicon.svg" ? "" : "assets", relative);
3328
+ if (!target.startsWith(path6.resolve(options.uiDir) + path6.sep) || relative.includes("..")) {
3329
+ reply(res, 404, { ok: false, error: "\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430" });
3330
+ return;
3331
+ }
3332
+ const info = await stat(target).catch(() => null);
3333
+ if (!info?.isFile()) {
3334
+ reply(res, 404, { ok: false, error: "\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430" });
3335
+ return;
3336
+ }
3337
+ res.writeHead(200, {
3338
+ ...SECURITY_HEADERS,
3339
+ "content-type": MIME[path6.extname(target)] ?? "application/octet-stream",
3340
+ "cache-control": "public, max-age=31536000, immutable"
3341
+ });
3342
+ createReadStream(target).pipe(res);
3343
+ return;
3344
+ }
3345
+ if (!url.pathname.startsWith("/api/")) {
3346
+ reply(res, 404, { ok: false, error: "\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E" });
3347
+ return;
3348
+ }
3349
+ if (req.method === "GET" && url.pathname === "/api/events") {
3350
+ if (!authorized) {
3351
+ reply(res, 401, { ok: false, error: "\u043D\u0435\u0442 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438" });
3352
+ return;
3353
+ }
3354
+ res.writeHead(200, {
3355
+ ...SECURITY_HEADERS,
3356
+ "content-type": "text/event-stream; charset=utf-8",
3357
+ "cache-control": "no-store",
3358
+ connection: "keep-alive"
3359
+ });
3360
+ res.write(`event: hello
3361
+ data: ${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() })}
3362
+
3363
+ `);
3364
+ const listener = (event) => {
3365
+ res.write(`event: review
3366
+ data: ${JSON.stringify(event)}
3367
+
3368
+ `);
3369
+ };
3370
+ service.events.on("review", listener);
3371
+ const heartbeat = setInterval(() => res.write(": ping\n\n"), 25e3);
3372
+ heartbeat.unref?.();
3373
+ req.on("close", () => {
3374
+ clearInterval(heartbeat);
3375
+ service.events.off("review", listener);
3376
+ });
3377
+ return;
3378
+ }
3379
+ if (!authorized) {
3380
+ reply(res, 401, { ok: false, error: "\u043D\u0435\u0442 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438" });
3381
+ return;
3382
+ }
3383
+ if (req.method === "GET" && url.pathname === "/api/health") {
3384
+ reply(res, 200, { ok: true, version: options.version ?? "dev", reviews: service.listReviews().length });
3385
+ return;
3386
+ }
3387
+ if (req.method === "GET" && url.pathname === "/api/reviews") {
3388
+ reply(res, 200, { ok: true, reviews: service.listReviews() });
3389
+ return;
3390
+ }
3391
+ if (req.method === "GET" && url.pathname === "/api/state") {
3392
+ const review = url.searchParams.get("review");
3393
+ if (!review) {
3394
+ reply(res, 400, { ok: false, error: "\u043D\u0443\u0436\u0435\u043D \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 review" });
3395
+ return;
3396
+ }
3397
+ const view = await service.refreshIfStale(review);
3398
+ reply(res, 200, { ok: true, review: view, reviews: service.listReviews() });
3399
+ return;
3400
+ }
3401
+ if (req.method === "GET" && url.pathname === "/api/diff") {
3402
+ const review = url.searchParams.get("review") ?? "";
3403
+ const file = url.searchParams.get("file") ?? "";
3404
+ const diff = service.diffFile(review, file);
3405
+ if (!diff) {
3406
+ reply(res, 404, { ok: false, error: "\u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442 \u0432 diff" });
3407
+ return;
3408
+ }
3409
+ reply(res, 200, { ok: true, file: diff });
3410
+ return;
3411
+ }
3412
+ if (req.method === "GET" && url.pathname === "/api/rules") {
3413
+ const review = url.searchParams.get("review") ?? "";
3414
+ reply(res, 200, { ok: true, rules: await service.rules(review) });
3415
+ return;
3416
+ }
3417
+ if (req.method === "POST") {
3418
+ const body = await readBody(req);
3419
+ const review = String(body.review ?? "");
3420
+ switch (url.pathname) {
3421
+ case "/api/reviews/open": {
3422
+ const root = String(body.root ?? "");
3423
+ if (!root) throw Object.assign(new Error("\u043D\u0443\u0436\u0435\u043D root \u0440\u0430\u0431\u043E\u0447\u0435\u0439 \u043A\u043E\u043F\u0438\u0438"), { status: 400 });
3424
+ const opened = await service.openReview(root, {
3425
+ ...typeof body.input === "string" ? { input: body.input } : {},
3426
+ ...typeof body.platform === "string" ? { platform: body.platform } : {},
3427
+ ...typeof body.project === "string" ? { project: body.project } : {},
3428
+ ...typeof body.host === "string" ? { host: body.host } : {},
3429
+ ...typeof body.branch === "string" ? { branch: body.branch } : {}
3430
+ });
3431
+ reply(res, 200, { ok: true, review: service.getView(opened.id), reviews: service.listReviews() });
3432
+ return;
3433
+ }
3434
+ case "/api/reviews/refresh": {
3435
+ const updated = await service.refreshReview(review);
3436
+ reply(res, 200, { ok: true, review: service.getView(updated.id), reviews: service.listReviews() });
3437
+ return;
3438
+ }
3439
+ case "/api/drafts": {
3440
+ const draft = service.setDraft(review, String(body.threadId ?? ""), body.patch ?? {});
3441
+ reply(res, 200, { ok: true, draft });
3442
+ return;
3443
+ }
3444
+ case "/api/findings": {
3445
+ const findings = service.setFindings(review, body.findings);
3446
+ reply(res, 200, { ok: true, findings });
3447
+ return;
3448
+ }
3449
+ case "/api/summary": {
3450
+ const summary = service.setSummary(review, body.summary);
3451
+ reply(res, 200, { ok: true, summary });
3452
+ return;
3453
+ }
3454
+ case "/api/decisions": {
3455
+ const decisions = service.setDecisions(review, body.decisions);
3456
+ reply(res, 200, { ok: true, decisions });
3457
+ return;
3458
+ }
3459
+ case "/api/notes": {
3460
+ const action = String(body.action ?? "add");
3461
+ if (action === "add") {
3462
+ const note2 = service.addNote(review, {
3463
+ file: String(body.file ?? ""),
3464
+ line: Number(body.line ?? 0),
3465
+ side: body.side === "old" ? "old" : "new",
3466
+ text: String(body.text ?? ""),
3467
+ toAgent: body.toAgent !== false
3468
+ });
3469
+ reply(res, 200, { ok: true, note: note2 });
3470
+ return;
3471
+ }
3472
+ if (action === "delete") {
3473
+ service.deleteNote(review, String(body.noteId ?? ""));
3474
+ reply(res, 200, { ok: true });
3475
+ return;
3476
+ }
3477
+ const note = service.updateNote(review, String(body.noteId ?? ""), body.patch ?? {});
3478
+ reply(res, 200, { ok: true, note });
3479
+ return;
3480
+ }
3481
+ case "/api/publish/preview": {
3482
+ const preview = service.previewPublish(review, String(body.kind ?? "reply"), Array.isArray(body.ids) ? body.ids.map(String) : []);
3483
+ reply(res, 200, { ok: true, preview });
3484
+ return;
3485
+ }
3486
+ case "/api/publish/approve": {
3487
+ const approval = service.approve(
3488
+ review,
3489
+ String(body.kind ?? "reply"),
3490
+ Array.isArray(body.ids) ? body.ids.map(String) : [],
3491
+ String(body.hash ?? "")
3492
+ );
3493
+ reply(res, 200, { ok: true, approval: { id: approval.id, expiresAt: approval.expiresAt } });
3494
+ return;
3495
+ }
3496
+ case "/api/publish/commit": {
3497
+ const result = await service.publish(review, String(body.approvalId ?? ""));
3498
+ reply(res, 200, { ok: true, result });
3499
+ return;
3500
+ }
3501
+ case "/api/rpc": {
3502
+ const result = await dispatch(service, String(body.method ?? ""), body.params ?? {});
3503
+ reply(res, 200, { ok: true, result });
3504
+ return;
3505
+ }
3506
+ case "/api/checks": {
3507
+ const results = await service.runChecks(review, { allowRun: body.run === true, ids: Array.isArray(body.ids) ? body.ids.map(String) : void 0 });
3508
+ reply(res, 200, { ok: true, results });
3509
+ return;
3510
+ }
3511
+ default:
3512
+ reply(res, 404, { ok: false, error: "\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E" });
3513
+ return;
3514
+ }
3515
+ }
3516
+ reply(res, 404, { ok: false, error: "\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E" });
3517
+ } catch (error) {
3518
+ const status = error.status ?? 500;
3519
+ reply(res, status, { ok: false, error: redact(messageOf(error)) });
3520
+ }
3521
+ });
3522
+ await new Promise((resolve, reject) => {
3523
+ server.once("error", reject);
3524
+ server.listen(options.port ?? 0, host, () => resolve());
3525
+ });
3526
+ const port = server.address().port;
3527
+ return {
3528
+ url: `http://127.0.0.1:${port}`,
3529
+ port,
3530
+ close: () => new Promise((resolve) => {
3531
+ server.close(() => resolve());
3532
+ setTimeout(() => resolve(), 1500).unref?.();
3533
+ })
3534
+ };
3535
+ }
3536
+
3537
+ // src/daemon/main.ts
3538
+ async function main() {
3539
+ setLocale(detectLocale());
3540
+ const { values } = parseArgs({
3541
+ options: {
3542
+ port: { type: "string" },
3543
+ "state-dir": { type: "string" },
3544
+ "runtime-dir": { type: "string" },
3545
+ "ui-dir": { type: "string" },
3546
+ foreground: { type: "boolean", default: false },
3547
+ "sync-interval": { type: "string" }
3548
+ },
3549
+ allowPositionals: false
3550
+ });
3551
+ if (values["state-dir"]) process.env.REVDECK_STATE_DIR = values["state-dir"];
3552
+ if (values["runtime-dir"]) process.env.REVDECK_RUNTIME_DIR = values["runtime-dir"];
3553
+ const foreground = values.foreground === true;
3554
+ const log = async (line) => {
3555
+ const message = `${(/* @__PURE__ */ new Date()).toISOString()} ${redactLine(line, 4e3)}
3556
+ `;
3557
+ if (foreground) process.stderr.write(message);
3558
+ try {
3559
+ await appendFile(logPath(), message, { mode: 384 });
3560
+ } catch {
3561
+ }
3562
+ };
3563
+ const existing = await runningDaemon();
3564
+ if (existing) {
3565
+ process.stdout.write(`revdeck daemon ${tr("\u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D", "is already running")} (pid ${existing.pid}): ${existing.url}/
3566
+ `);
3567
+ return;
3568
+ }
3569
+ await ensureDir(stateDir(), 448);
3570
+ await ensureDir(runtimeDir(), 448);
3571
+ const lock = await acquireLock();
3572
+ if (!lock) {
3573
+ process.stdout.write(`revdeck daemon ${tr("\u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F \u0438\u043B\u0438 \u0437\u0430\u043F\u0443\u0449\u0435\u043D", "is already starting or running")}
3574
+ `);
3575
+ return;
3576
+ }
3577
+ const token = (await import("node:crypto")).randomBytes(32).toString("hex");
3578
+ const store = new Store(databasePath());
3579
+ const service = new ReviewService({
3580
+ store,
3581
+ syncIntervalMs: values["sync-interval"] ? Number(values["sync-interval"]) : 12e4
3582
+ });
3583
+ const socket = socketPath();
3584
+ const rpc = await startRpcServer(service, socket);
3585
+ const uiDir = values["ui-dir"] ? path7.resolve(values["ui-dir"]) : path7.join(packageRoot(), "dist", "ui");
3586
+ const http2 = await startHttpServer({
3587
+ service,
3588
+ token,
3589
+ uiDir,
3590
+ ...values.port ? { port: Number(values.port) } : {},
3591
+ version: VERSION
3592
+ });
3593
+ const info = {
3594
+ pid: process.pid,
3595
+ port: http2.port,
3596
+ token,
3597
+ url: http2.url,
3598
+ socket,
3599
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3600
+ version: VERSION,
3601
+ protocol: PROTOCOL_VERSION
3602
+ };
3603
+ await writeFileAtomic(daemonInfoPath(), `${JSON.stringify(info, null, 2)}
3604
+ `, 384);
3605
+ service.startSync();
3606
+ process.stdout.write(
3607
+ [
3608
+ `revdeck daemon ${VERSION} (pid ${process.pid})`,
3609
+ `UI: ${http2.url}/?t=${token}`,
3610
+ `${tr("\u0441\u043E\u043A\u0435\u0442", "socket")}: ${socket}`,
3611
+ `${tr("\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435", "state")}: ${stateDir()}`,
3612
+ ""
3613
+ ].join("\n")
3614
+ );
3615
+ await log(`daemon started pid=${process.pid} port=${http2.port}`);
3616
+ let closing = false;
3617
+ const shutdown = async (signal) => {
3618
+ if (closing) return;
3619
+ closing = true;
3620
+ await log(`daemon stopping (${signal})`);
3621
+ try {
3622
+ await rpc.close();
3623
+ await http2.close();
3624
+ } finally {
3625
+ service.close();
3626
+ await unlink3(daemonInfoPath()).catch(() => void 0);
3627
+ await lock.close().catch(() => void 0);
3628
+ await unlink3(path7.join(stateDir(), "daemon.lock")).catch(() => void 0);
3629
+ process.exit(0);
3630
+ }
3631
+ };
3632
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
3633
+ process.on(signal, () => {
3634
+ if (signal === "SIGHUP" && !foreground) return;
3635
+ void shutdown(signal);
3636
+ });
3637
+ }
3638
+ }
3639
+ function packageRoot() {
3640
+ let dir = path7.dirname(fileURLToPath(import.meta.url));
3641
+ for (; ; ) {
3642
+ const candidate = path7.join(dir, "package.json");
3643
+ if (existsSync4(candidate)) {
3644
+ try {
3645
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
3646
+ if (parsed.name === "revdeck") return dir;
3647
+ } catch {
3648
+ }
3649
+ }
3650
+ const parent = path7.dirname(dir);
3651
+ if (parent === dir) return process.cwd();
3652
+ dir = parent;
3653
+ }
3654
+ }
3655
+ async function runningDaemon() {
3656
+ const infoPath = daemonInfoPath();
3657
+ if (!existsSync4(infoPath)) return null;
3658
+ let info;
3659
+ try {
3660
+ info = JSON.parse(readFileSync(infoPath, "utf8"));
3661
+ } catch {
3662
+ return null;
3663
+ }
3664
+ if (!info?.pid || !info?.socket) return null;
3665
+ try {
3666
+ process.kill(info.pid, 0);
3667
+ } catch {
3668
+ return null;
3669
+ }
3670
+ const reachable = await new Promise((resolve) => {
3671
+ const socket = net2.connect(info.socket);
3672
+ const timer = setTimeout(() => {
3673
+ socket.destroy();
3674
+ resolve(false);
3675
+ }, 500);
3676
+ socket.once("connect", () => {
3677
+ clearTimeout(timer);
3678
+ socket.destroy();
3679
+ resolve(true);
3680
+ });
3681
+ socket.once("error", () => {
3682
+ clearTimeout(timer);
3683
+ resolve(false);
3684
+ });
3685
+ });
3686
+ return reachable ? info : null;
3687
+ }
3688
+ async function acquireLock() {
3689
+ const file = path7.join(stateDir(), "daemon.lock");
3690
+ for (let attempt = 0; attempt < 2; attempt++) {
3691
+ try {
3692
+ const handle = await open2(file, "wx", 384);
3693
+ await handle.writeFile(String(process.pid));
3694
+ return handle;
3695
+ } catch (error) {
3696
+ if (error.code !== "EEXIST") throw error;
3697
+ const pid = Number(await readFile3(file, "utf8").catch(() => ""));
3698
+ if (pid) {
3699
+ try {
3700
+ process.kill(pid, 0);
3701
+ return null;
3702
+ } catch {
3703
+ }
3704
+ }
3705
+ await unlink3(file).catch(() => void 0);
3706
+ }
3707
+ }
3708
+ return null;
3709
+ }
3710
+ void main().catch((error) => {
3711
+ process.stderr.write(`revdeck: ${redactLine(error instanceof Error ? error.message : error)}
3712
+ `);
3713
+ process.exit(1);
3714
+ });
3715
+ //# sourceMappingURL=daemon.js.map