revdeck 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/CONTRIBUTING.md +17 -7
- package/README.md +50 -6
- package/README.ru.md +50 -5
- package/dist/cli.js +687 -69
- package/dist/cli.js.map +4 -4
- package/dist/daemon.js +386 -54
- package/dist/daemon.js.map +4 -4
- package/dist/mcp.js +20 -4
- package/dist/mcp.js.map +2 -2
- package/dist/ui/assets/index-BAejn_X6.js +10 -0
- package/dist/ui/assets/{index-ByfGHwCb.css → index-xv-zA2Sf.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/docs/adr/0001-daemon-first.md +3 -1
- package/docs/architecture.md +3 -1
- package/docs/configuration.md +2 -0
- package/docs/integrations.md +53 -11
- package/docs/protocol.md +11 -2
- package/docs/ru/adr/0001-daemon-first.md +3 -1
- package/docs/ru/architecture.md +3 -1
- package/docs/ru/configuration.md +2 -0
- package/docs/ru/integrations.md +52 -10
- package/docs/ru/protocol.md +11 -2
- package/docs/ru/threat-model.md +3 -1
- package/docs/threat-model.md +3 -1
- package/package.json +2 -1
- package/skills/revdeck/en/agents/openai.yaml +4 -0
- package/skills/revdeck/ru/agents/openai.yaml +4 -0
- package/dist/ui/assets/index-mvPA8Avw.js +0 -10
package/dist/daemon.js
CHANGED
|
@@ -74,6 +74,16 @@ var init_locale = __esm({
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
// src/core/types.ts
|
|
78
|
+
var LEVELS, REACTIONS;
|
|
79
|
+
var init_types = __esm({
|
|
80
|
+
"src/core/types.ts"() {
|
|
81
|
+
"use strict";
|
|
82
|
+
LEVELS = ["blocker", "major", "minor", "question"];
|
|
83
|
+
REACTIONS = ["thumbsup", "thumbsdown", "smile", "tada", "confused", "heart", "rocket", "eyes"];
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
77
87
|
// src/core/errors.ts
|
|
78
88
|
function messageOf(error) {
|
|
79
89
|
if (error instanceof Error) return error.message;
|
|
@@ -289,11 +299,11 @@ function diffStats(files) {
|
|
|
289
299
|
function changedPaths(files) {
|
|
290
300
|
return files.map((file) => file.path);
|
|
291
301
|
}
|
|
292
|
-
function findFile(files,
|
|
293
|
-
return files.find((file) => file.path ===
|
|
302
|
+
function findFile(files, path9) {
|
|
303
|
+
return files.find((file) => file.path === path9) ?? files.find((file) => file.oldPath === path9);
|
|
294
304
|
}
|
|
295
|
-
function findNewLine(files,
|
|
296
|
-
const file = findFile(files,
|
|
305
|
+
function findNewLine(files, path9, line) {
|
|
306
|
+
const file = findFile(files, path9);
|
|
297
307
|
if (!file) return void 0;
|
|
298
308
|
for (const hunk of file.hunks) {
|
|
299
309
|
for (const item of hunk.lines) {
|
|
@@ -302,8 +312,8 @@ function findNewLine(files, path8, line) {
|
|
|
302
312
|
}
|
|
303
313
|
return void 0;
|
|
304
314
|
}
|
|
305
|
-
function snippetAround(files,
|
|
306
|
-
const file = findFile(files,
|
|
315
|
+
function snippetAround(files, path9, line, radius = 6) {
|
|
316
|
+
const file = findFile(files, path9);
|
|
307
317
|
if (!file || line == null) return null;
|
|
308
318
|
const flat = file.hunks.flatMap((hunk) => hunk.lines);
|
|
309
319
|
const target = flat.findIndex((item) => item.newLine === line);
|
|
@@ -316,9 +326,9 @@ function snippetAround(files, path8, line, radius = 6) {
|
|
|
316
326
|
target: item.newLine === line
|
|
317
327
|
}));
|
|
318
328
|
}
|
|
319
|
-
function linePosition(files,
|
|
320
|
-
const file = findFile(files,
|
|
321
|
-
const found = findNewLine(files,
|
|
329
|
+
function linePosition(files, path9, line) {
|
|
330
|
+
const file = findFile(files, path9);
|
|
331
|
+
const found = findNewLine(files, path9, line);
|
|
322
332
|
if (!file || !found) return null;
|
|
323
333
|
return { newPath: file.path, newLine: line, oldPath: file.oldPath, oldLine: found.kind === " " ? found.oldLine : null };
|
|
324
334
|
}
|
|
@@ -429,8 +439,8 @@ async function withTempJson(data, fn) {
|
|
|
429
439
|
await rm(dir, { recursive: true, force: true });
|
|
430
440
|
}
|
|
431
441
|
}
|
|
432
|
-
function query(
|
|
433
|
-
const url = new URL(
|
|
442
|
+
function query(path9, params) {
|
|
443
|
+
const url = new URL(path9.startsWith("/") ? path9 : `/${path9}`, "http://local");
|
|
434
444
|
for (const [key, value] of Object.entries(params)) {
|
|
435
445
|
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
436
446
|
}
|
|
@@ -459,6 +469,7 @@ var EMPTY_SHA, GitLabProvider;
|
|
|
459
469
|
var init_gitlab = __esm({
|
|
460
470
|
"src/providers/gitlab.ts"() {
|
|
461
471
|
"use strict";
|
|
472
|
+
init_types();
|
|
462
473
|
init_diff();
|
|
463
474
|
init_errors();
|
|
464
475
|
init_redact();
|
|
@@ -475,7 +486,7 @@ var init_gitlab = __esm({
|
|
|
475
486
|
ref;
|
|
476
487
|
options;
|
|
477
488
|
platform = "gitlab";
|
|
478
|
-
capabilities = { reactions: true, resolve: true, nativeDrafts: true };
|
|
489
|
+
capabilities = { reactions: true, resolve: true, nativeDrafts: true, attachments: true };
|
|
479
490
|
run;
|
|
480
491
|
me = null;
|
|
481
492
|
infoCache = null;
|
|
@@ -643,6 +654,24 @@ rename to ${newPath}
|
|
|
643
654
|
await this.call(["--method", "PUT", this.endpoint(`/discussions/${encodeURIComponent(threadId)}`), "--input", file]);
|
|
644
655
|
});
|
|
645
656
|
}
|
|
657
|
+
async reactions(noteId) {
|
|
658
|
+
const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
|
|
659
|
+
const items = await this.paged(base);
|
|
660
|
+
const me = await this.currentUser();
|
|
661
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
662
|
+
for (const item of items) {
|
|
663
|
+
if (!REACTIONS.includes(item.name)) continue;
|
|
664
|
+
const reaction = grouped.get(item.name) ?? { name: item.name, count: 0, users: [], mine: false };
|
|
665
|
+
reaction.count += 1;
|
|
666
|
+
const username = item.user?.username;
|
|
667
|
+
if (username) {
|
|
668
|
+
reaction.users.push(username);
|
|
669
|
+
if (username === me.username) reaction.mine = true;
|
|
670
|
+
}
|
|
671
|
+
grouped.set(item.name, reaction);
|
|
672
|
+
}
|
|
673
|
+
return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
674
|
+
}
|
|
646
675
|
async react(noteId, emoji) {
|
|
647
676
|
const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
|
|
648
677
|
const existing = await this.json([query(base, { per_page: 100 })]);
|
|
@@ -657,6 +686,16 @@ rename to ${newPath}
|
|
|
657
686
|
});
|
|
658
687
|
return { active: true };
|
|
659
688
|
}
|
|
689
|
+
async upload(input) {
|
|
690
|
+
const endpoint = `projects/${this.projectRef}/uploads`;
|
|
691
|
+
const uploaded = await this.json(["--method", "POST", endpoint, "--form", `file=@${input.path}`]);
|
|
692
|
+
const raw = uploaded.full_path || uploaded.url || "";
|
|
693
|
+
if (!raw) throw new ProviderError(tr("GitLab \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B", "GitLab did not return a link to the uploaded file"));
|
|
694
|
+
const absolute = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${this.ref.host}${raw.startsWith("/") ? "" : "/"}${raw}`;
|
|
695
|
+
const label = input.name.replace(/[[\]\r\n]/g, " ").trim() || "attachment";
|
|
696
|
+
const markdown = input.contentType.startsWith("image/") || input.contentType.startsWith("video/") ? `` : `[${label}](${absolute})`;
|
|
697
|
+
return { name: input.name, url: absolute, markdown };
|
|
698
|
+
}
|
|
660
699
|
async createThread(input) {
|
|
661
700
|
const info = await this.info();
|
|
662
701
|
if (!info.commit.head || info.commit.head === EMPTY_SHA) {
|
|
@@ -693,7 +732,7 @@ __export(github_exports, {
|
|
|
693
732
|
GitHubProvider: () => GitHubProvider
|
|
694
733
|
});
|
|
695
734
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
696
|
-
var THREADS_QUERY,
|
|
735
|
+
var THREADS_QUERY, REACTIONS2, CONTENT_TO_NAME, GitHubProvider;
|
|
697
736
|
var init_github = __esm({
|
|
698
737
|
"src/providers/github.ts"() {
|
|
699
738
|
"use strict";
|
|
@@ -717,7 +756,7 @@ var init_github = __esm({
|
|
|
717
756
|
}
|
|
718
757
|
}
|
|
719
758
|
}`;
|
|
720
|
-
|
|
759
|
+
REACTIONS2 = {
|
|
721
760
|
thumbsup: "+1",
|
|
722
761
|
thumbsdown: "-1",
|
|
723
762
|
smile: "laugh",
|
|
@@ -727,6 +766,7 @@ var init_github = __esm({
|
|
|
727
766
|
rocket: "rocket",
|
|
728
767
|
eyes: "eyes"
|
|
729
768
|
};
|
|
769
|
+
CONTENT_TO_NAME = Object.fromEntries(Object.entries(REACTIONS2).map(([name, content]) => [content, name]));
|
|
730
770
|
GitHubProvider = class {
|
|
731
771
|
constructor(ref, options) {
|
|
732
772
|
this.ref = ref;
|
|
@@ -742,7 +782,7 @@ var init_github = __esm({
|
|
|
742
782
|
ref;
|
|
743
783
|
options;
|
|
744
784
|
platform = "github";
|
|
745
|
-
capabilities = { reactions: true, resolve: true, nativeDrafts: false };
|
|
785
|
+
capabilities = { reactions: true, resolve: true, nativeDrafts: false, attachments: false };
|
|
746
786
|
run;
|
|
747
787
|
owner;
|
|
748
788
|
name;
|
|
@@ -945,8 +985,27 @@ rename to ${newPath}
|
|
|
945
985
|
const mutation = resolved ? "mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }" : "mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }";
|
|
946
986
|
await this.graphql(mutation, { id: threadId });
|
|
947
987
|
}
|
|
988
|
+
async reactions(noteId) {
|
|
989
|
+
const base = `repos/${this.repo}/pulls/comments/${encodeURIComponent(noteId)}/reactions`;
|
|
990
|
+
const items = await this.paged(base);
|
|
991
|
+
const me = await this.currentUser();
|
|
992
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
993
|
+
for (const item of items) {
|
|
994
|
+
const name = CONTENT_TO_NAME[item.content];
|
|
995
|
+
if (!name) continue;
|
|
996
|
+
const reaction = grouped.get(name) ?? { name, count: 0, users: [], mine: false };
|
|
997
|
+
reaction.count += 1;
|
|
998
|
+
const login = item.user?.login;
|
|
999
|
+
if (login) {
|
|
1000
|
+
reaction.users.push(login);
|
|
1001
|
+
if (login === me.username) reaction.mine = true;
|
|
1002
|
+
}
|
|
1003
|
+
grouped.set(name, reaction);
|
|
1004
|
+
}
|
|
1005
|
+
return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
1006
|
+
}
|
|
948
1007
|
async react(noteId, emoji) {
|
|
949
|
-
const content =
|
|
1008
|
+
const content = REACTIONS2[emoji];
|
|
950
1009
|
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
1010
|
const base = `repos/${this.repo}/pulls/comments/${noteId}/reactions`;
|
|
952
1011
|
const existing = await this.json([query(base, { per_page: 100 })]);
|
|
@@ -961,6 +1020,12 @@ rename to ${newPath}
|
|
|
961
1020
|
});
|
|
962
1021
|
return { active: true };
|
|
963
1022
|
}
|
|
1023
|
+
async upload(_input) {
|
|
1024
|
+
throw new ProviderError(
|
|
1025
|
+
tr("GitHub \u043D\u0435 \u0434\u0430\u0451\u0442 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0433\u043E API \u0434\u043B\u044F \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439", "GitHub has no public API for uploading attachments"),
|
|
1026
|
+
tr("\u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0444\u0430\u0439\u043B \u0432\u0440\u0443\u0447\u043D\u0443\u044E; \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F GitLab", "paste an absolute file URL manually; uploads are supported for GitLab only")
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
964
1029
|
async createThread(input) {
|
|
965
1030
|
if (input.line === null) {
|
|
966
1031
|
const comment2 = await withTempJson(
|
|
@@ -990,9 +1055,9 @@ rename to ${newPath}
|
|
|
990
1055
|
|
|
991
1056
|
// src/daemon/main.ts
|
|
992
1057
|
import { existsSync as existsSync4, readFileSync } from "node:fs";
|
|
993
|
-
import { appendFile, open as open2, readFile as readFile3, unlink as
|
|
1058
|
+
import { appendFile, open as open2, readFile as readFile3, unlink as unlink4 } from "node:fs/promises";
|
|
994
1059
|
import net2 from "node:net";
|
|
995
|
-
import
|
|
1060
|
+
import path8 from "node:path";
|
|
996
1061
|
import { fileURLToPath } from "node:url";
|
|
997
1062
|
import { parseArgs } from "node:util";
|
|
998
1063
|
|
|
@@ -1034,6 +1099,22 @@ function socketPath() {
|
|
|
1034
1099
|
function daemonInfoPath() {
|
|
1035
1100
|
return path.join(stateDir(), "daemon.json");
|
|
1036
1101
|
}
|
|
1102
|
+
function devcontainerBridgeDir() {
|
|
1103
|
+
if (process.env.REVDECK_DEVCONTAINER_DIR) return path.resolve(process.env.REVDECK_DEVCONTAINER_DIR);
|
|
1104
|
+
return path.join(os.homedir(), ".local", "state", "revdeck", "devcontainer");
|
|
1105
|
+
}
|
|
1106
|
+
function devcontainerRunDir() {
|
|
1107
|
+
return path.join(devcontainerBridgeDir(), "run");
|
|
1108
|
+
}
|
|
1109
|
+
function devcontainerStateDir() {
|
|
1110
|
+
return path.join(devcontainerBridgeDir(), "state");
|
|
1111
|
+
}
|
|
1112
|
+
function devcontainerSocketPath() {
|
|
1113
|
+
return path.join(devcontainerRunDir(), "daemon.sock");
|
|
1114
|
+
}
|
|
1115
|
+
function devcontainerInfoPath() {
|
|
1116
|
+
return path.join(devcontainerStateDir(), "daemon.json");
|
|
1117
|
+
}
|
|
1037
1118
|
function logPath() {
|
|
1038
1119
|
return path.join(stateDir(), "daemon.log");
|
|
1039
1120
|
}
|
|
@@ -1084,7 +1165,7 @@ async function writeFileAtomic(file, data, mode = 384) {
|
|
|
1084
1165
|
init_redact();
|
|
1085
1166
|
|
|
1086
1167
|
// src/version.ts
|
|
1087
|
-
var VERSION = "0.1
|
|
1168
|
+
var VERSION = "0.2.1";
|
|
1088
1169
|
var PROTOCOL_VERSION = 1;
|
|
1089
1170
|
|
|
1090
1171
|
// src/daemon/main.ts
|
|
@@ -1095,10 +1176,8 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
1095
1176
|
import { mkdirSync } from "node:fs";
|
|
1096
1177
|
import path2 from "node:path";
|
|
1097
1178
|
|
|
1098
|
-
// src/core/types.ts
|
|
1099
|
-
var LEVELS = ["blocker", "major", "minor", "question"];
|
|
1100
|
-
|
|
1101
1179
|
// src/core/refs.ts
|
|
1180
|
+
init_types();
|
|
1102
1181
|
function reviewId(ref) {
|
|
1103
1182
|
return `${ref.platform}:${ref.host}/${ref.project}!${ref.number}`;
|
|
1104
1183
|
}
|
|
@@ -1639,6 +1718,9 @@ init_redact();
|
|
|
1639
1718
|
init_errors();
|
|
1640
1719
|
import { EventEmitter } from "node:events";
|
|
1641
1720
|
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
1721
|
+
import { mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1722
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
1723
|
+
import path6 from "node:path";
|
|
1642
1724
|
|
|
1643
1725
|
// src/core/config.ts
|
|
1644
1726
|
import { z } from "zod";
|
|
@@ -1691,8 +1773,8 @@ function globToRegExp(glob) {
|
|
|
1691
1773
|
function escapeRegExp(value) {
|
|
1692
1774
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1693
1775
|
}
|
|
1694
|
-
function classifyFile(
|
|
1695
|
-
const lower =
|
|
1776
|
+
function classifyFile(path9) {
|
|
1777
|
+
const lower = path9.toLowerCase();
|
|
1696
1778
|
if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
|
|
1697
1779
|
if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
|
|
1698
1780
|
if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
|
|
@@ -2004,7 +2086,8 @@ init_locale();
|
|
|
2004
2086
|
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
2087
|
var QUICK_ACTION = /^[\s\p{Z}\p{Cf}]*\/[a-z_]+(?=[\s\p{Z}\p{Cf}]|$)/imu;
|
|
2006
2088
|
var MASS_MENTION = /(^|[^\w@./-])@(all|here|everyone)(?![\w-])/i;
|
|
2007
|
-
var
|
|
2089
|
+
var MD_IMAGE_REF = /!\[[^\]]*\]\s*\[/;
|
|
2090
|
+
var MD_IMAGE_LINK = /!\[[^\]]*\]\s*\(\s*<?([^)\s>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
2008
2091
|
var HTML_TAG = /<(?:img|script|iframe|style|video|audio|object|embed|svg|math)\b/i;
|
|
2009
2092
|
var HTML_COMMENT = /<!--/;
|
|
2010
2093
|
var MAX_LENGTH = 64 * 1024;
|
|
@@ -2017,15 +2100,113 @@ function sanitizeBody(body, platform, options = {}) {
|
|
|
2017
2100
|
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
2101
|
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
2102
|
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
|
-
|
|
2103
|
+
const imageProblem = imageIssue(text2, options.host);
|
|
2104
|
+
if (imageProblem) return { ok: false, reason: imageProblem, text: text2 };
|
|
2021
2105
|
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
2106
|
if (checkQuickActions && QUICK_ACTION.test(text2)) {
|
|
2023
2107
|
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
2108
|
}
|
|
2025
2109
|
return { ok: true, reason: "", text: text2 };
|
|
2026
2110
|
}
|
|
2111
|
+
function imageIssue(text2, host) {
|
|
2112
|
+
if (MD_IMAGE_REF.test(text2)) {
|
|
2113
|
+
return tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 \u0441\u043E \u0441\u0441\u044B\u043B\u043E\u0447\u043D\u043E\u0439 \u0441\u043D\u043E\u0441\u043A\u043E\u0439: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443", "the text contains a reference-style image: use an absolute link");
|
|
2114
|
+
}
|
|
2115
|
+
for (const match of text2.matchAll(MD_IMAGE_LINK)) {
|
|
2116
|
+
const url = match[1];
|
|
2117
|
+
if (!host) {
|
|
2118
|
+
return tr("\u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0438 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0441 \u0445\u043E\u0441\u0442\u0430 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u044B", "images are allowed only from the platform host");
|
|
2119
|
+
}
|
|
2120
|
+
if (url.startsWith("/uploads/")) continue;
|
|
2121
|
+
try {
|
|
2122
|
+
const parsed = new URL(url);
|
|
2123
|
+
if ((parsed.protocol === "https:" || parsed.protocol === "http:") && parsed.host === host) continue;
|
|
2124
|
+
} catch {
|
|
2125
|
+
}
|
|
2126
|
+
return tr(`\u0432\u043D\u0435\u0448\u043D\u044F\u044F \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 ${url}: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0441 ${host}`, `external image ${url}: only uploads from ${host} are allowed`);
|
|
2127
|
+
}
|
|
2128
|
+
return null;
|
|
2129
|
+
}
|
|
2130
|
+
function normalizeMediaLinks(body, platform, host) {
|
|
2131
|
+
if (platform !== "gitlab" || !host) return body;
|
|
2132
|
+
const base = `https://${host}`;
|
|
2133
|
+
return body.replace(/(!?\[[^\]]*\]\(\s*)(\/?uploads\/[^)\s]+)/g, (_match, prefix, path9) => {
|
|
2134
|
+
return `${prefix}${path9.startsWith("/") ? base : `${base}/`}${path9}`;
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/core/attachments.ts
|
|
2139
|
+
var MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
2140
|
+
var MIME_BY_EXTENSION = {
|
|
2141
|
+
".png": "image/png",
|
|
2142
|
+
".jpg": "image/jpeg",
|
|
2143
|
+
".jpeg": "image/jpeg",
|
|
2144
|
+
".gif": "image/gif",
|
|
2145
|
+
".webp": "image/webp",
|
|
2146
|
+
".avif": "image/avif",
|
|
2147
|
+
".mp4": "video/mp4",
|
|
2148
|
+
".m4v": "video/mp4",
|
|
2149
|
+
".webm": "video/webm",
|
|
2150
|
+
".ogv": "video/ogg",
|
|
2151
|
+
".ogg": "video/ogg",
|
|
2152
|
+
".mov": "video/quicktime"
|
|
2153
|
+
};
|
|
2154
|
+
function extensionOf(name) {
|
|
2155
|
+
const index = name.lastIndexOf(".");
|
|
2156
|
+
return index >= 0 ? name.slice(index).toLowerCase() : "";
|
|
2157
|
+
}
|
|
2158
|
+
function attachmentSpec(name, contentType = "") {
|
|
2159
|
+
if (/[/\\]/.test(name)) return null;
|
|
2160
|
+
const base = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
2161
|
+
if (!base || base.length > 240) return null;
|
|
2162
|
+
const extension = extensionOf(base);
|
|
2163
|
+
const expected = MIME_BY_EXTENSION[extension];
|
|
2164
|
+
if (!expected) return null;
|
|
2165
|
+
const provided = normalizeContentType(String(contentType));
|
|
2166
|
+
if (provided && provided !== expected) return null;
|
|
2167
|
+
return {
|
|
2168
|
+
name: base,
|
|
2169
|
+
contentType: expected,
|
|
2170
|
+
extension,
|
|
2171
|
+
kind: expected.startsWith("image/") ? "image" : "video"
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
var CONTENT_TYPE_ALIASES = {
|
|
2175
|
+
"image/jpg": "image/jpeg",
|
|
2176
|
+
"image/pjpeg": "image/jpeg",
|
|
2177
|
+
"video/x-m4v": "video/mp4",
|
|
2178
|
+
"video/x-ogg": "video/ogg"
|
|
2179
|
+
};
|
|
2180
|
+
function normalizeContentType(value) {
|
|
2181
|
+
const type = value.split(";")[0].trim().toLowerCase();
|
|
2182
|
+
return CONTENT_TYPE_ALIASES[type] ?? type;
|
|
2183
|
+
}
|
|
2184
|
+
function decodeAttachment(data) {
|
|
2185
|
+
const clean = String(data ?? "").replace(/\s+/g, "");
|
|
2186
|
+
if (!clean || clean.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(clean)) {
|
|
2187
|
+
throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F base64");
|
|
2188
|
+
}
|
|
2189
|
+
const buffer = Buffer.from(clean, "base64");
|
|
2190
|
+
if (!buffer.length) throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043F\u0443\u0441\u0442\u043E\u0435");
|
|
2191
|
+
if (buffer.length > MAX_ATTACHMENT_BYTES) {
|
|
2192
|
+
throw new Error(`\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} \u041C\u0411`);
|
|
2193
|
+
}
|
|
2194
|
+
return buffer;
|
|
2195
|
+
}
|
|
2196
|
+
function matchesSignature(buffer, contentType) {
|
|
2197
|
+
if (contentType === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
2198
|
+
if (contentType === "image/jpeg") return buffer.length > 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255;
|
|
2199
|
+
if (contentType === "image/gif") return buffer.subarray(0, 4).toString("latin1") === "GIF8";
|
|
2200
|
+
if (contentType === "image/webp") return buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP";
|
|
2201
|
+
if (contentType === "image/avif") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
|
|
2202
|
+
if (contentType === "video/webm") return buffer.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
|
|
2203
|
+
if (contentType === "video/ogg") return buffer.subarray(0, 4).toString("latin1") === "OggS";
|
|
2204
|
+
if (contentType === "video/mp4" || contentType === "video/quicktime") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
|
|
2205
|
+
return false;
|
|
2206
|
+
}
|
|
2027
2207
|
|
|
2028
2208
|
// src/core/findings.ts
|
|
2209
|
+
init_types();
|
|
2029
2210
|
import { createHash } from "node:crypto";
|
|
2030
2211
|
init_locale();
|
|
2031
2212
|
var LEVEL_ORDER = { blocker: 0, major: 1, minor: 2, question: 3 };
|
|
@@ -2159,6 +2340,7 @@ function normalize(value) {
|
|
|
2159
2340
|
|
|
2160
2341
|
// src/daemon/service.ts
|
|
2161
2342
|
init_locale();
|
|
2343
|
+
init_types();
|
|
2162
2344
|
|
|
2163
2345
|
// src/core/git.ts
|
|
2164
2346
|
init_exec();
|
|
@@ -2368,6 +2550,14 @@ var ReviewService = class {
|
|
|
2368
2550
|
await this.refresh(id, meta.row.root, provider, loaded);
|
|
2369
2551
|
return this.getReview(id);
|
|
2370
2552
|
}
|
|
2553
|
+
async providerFor(id) {
|
|
2554
|
+
const stored = this.store.getReview(id);
|
|
2555
|
+
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`));
|
|
2556
|
+
return this.providerFactory(
|
|
2557
|
+
{ platform: stored.row.platform, host: stored.row.host, project: stored.row.project, number: stored.row.number },
|
|
2558
|
+
stored.row.root
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2371
2561
|
async refresh(id, root, provider, loaded) {
|
|
2372
2562
|
const errors = [...loaded.warnings];
|
|
2373
2563
|
const previous = this.store.getReview(id)?.snapshot;
|
|
@@ -2581,6 +2771,77 @@ ${errors.join("\n")}`));
|
|
|
2581
2771
|
this.emit(id);
|
|
2582
2772
|
return next;
|
|
2583
2773
|
}
|
|
2774
|
+
// ---------------------------------------------------------------- reactions
|
|
2775
|
+
/** Реакции для набора комментариев; отсутствующие id пропускаются. */
|
|
2776
|
+
async reactions(id, noteIds) {
|
|
2777
|
+
const review = this.getReview(id);
|
|
2778
|
+
const known = new Set(review.threads.flatMap((thread) => thread.notes.map((note) => note.id)));
|
|
2779
|
+
const requested = [...new Set(noteIds.filter((noteId) => known.has(noteId)))].slice(0, 500);
|
|
2780
|
+
if (!requested.length) return {};
|
|
2781
|
+
const provider = await this.providerFor(id);
|
|
2782
|
+
const result = {};
|
|
2783
|
+
for (const noteId of requested) {
|
|
2784
|
+
try {
|
|
2785
|
+
result[noteId] = await provider.reactions(noteId);
|
|
2786
|
+
} catch {
|
|
2787
|
+
result[noteId] = [];
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
return result;
|
|
2791
|
+
}
|
|
2792
|
+
async react(id, noteId, emoji) {
|
|
2793
|
+
if (!REACTIONS.includes(emoji)) {
|
|
2794
|
+
throw new Error(tr(`\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430\u044F \u0440\u0435\u0430\u043A\u0446\u0438\u044F ${emoji}: ${REACTIONS.join(", ")}`, `unknown reaction ${emoji}: ${REACTIONS.join(", ")}`));
|
|
2795
|
+
}
|
|
2796
|
+
const review = this.getReview(id);
|
|
2797
|
+
const note = review.threads.flatMap((thread) => thread.notes).find((item) => item.id === noteId);
|
|
2798
|
+
if (!note) throw new Error(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u044F ${noteId} \u043D\u0435\u0442 \u0432 \u044D\u0442\u043E\u043C \u0440\u0435\u0432\u044C\u044E`, `comment ${noteId} is not in this review`));
|
|
2799
|
+
const provider = await this.providerFor(id);
|
|
2800
|
+
const result = await provider.react(noteId, emoji);
|
|
2801
|
+
let reactions = [];
|
|
2802
|
+
try {
|
|
2803
|
+
reactions = await provider.reactions(noteId);
|
|
2804
|
+
} catch {
|
|
2805
|
+
}
|
|
2806
|
+
this.store.audit(id, "reaction.toggle", { noteId, emoji, active: result.active });
|
|
2807
|
+
return { active: result.active, reactions };
|
|
2808
|
+
}
|
|
2809
|
+
async uploadAttachment(id, input) {
|
|
2810
|
+
const stored = this.store.getReview(id);
|
|
2811
|
+
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`));
|
|
2812
|
+
const spec = attachmentSpec(String(input.name ?? ""), String(input.contentType ?? ""));
|
|
2813
|
+
if (!spec) {
|
|
2814
|
+
throw new Error(
|
|
2815
|
+
tr(
|
|
2816
|
+
"\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV \u0438 MOV",
|
|
2817
|
+
"attachment: PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV and MOV are allowed"
|
|
2818
|
+
)
|
|
2819
|
+
);
|
|
2820
|
+
}
|
|
2821
|
+
const provider = await this.providerFor(id);
|
|
2822
|
+
if (!provider.capabilities.attachments) {
|
|
2823
|
+
throw new Error(
|
|
2824
|
+
tr(
|
|
2825
|
+
"\u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0430 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 \u0438\u0437 revdeck: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u0432\u0440\u0443\u0447\u043D\u0443\u044E",
|
|
2826
|
+
"the platform does not support attachments from revdeck: paste an absolute URL manually"
|
|
2827
|
+
)
|
|
2828
|
+
);
|
|
2829
|
+
}
|
|
2830
|
+
const buffer = decodeAttachment(input.data);
|
|
2831
|
+
if (!matchesSignature(buffer, spec.contentType)) {
|
|
2832
|
+
throw new Error(tr("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u0435\u043C", "attachment: content does not match the extension"));
|
|
2833
|
+
}
|
|
2834
|
+
const dir = await mkdtemp2(path6.join(tmpdir4(), "revdeck-attachment-"));
|
|
2835
|
+
const file = path6.join(dir, spec.name);
|
|
2836
|
+
try {
|
|
2837
|
+
await writeFile2(file, buffer, { mode: 384, flag: "wx" });
|
|
2838
|
+
const uploaded = await provider.upload({ path: file, name: spec.name, contentType: spec.contentType });
|
|
2839
|
+
this.store.audit(id, "attachment.upload", { name: spec.name, contentType: spec.contentType, bytes: buffer.length, url: uploaded.url });
|
|
2840
|
+
return uploaded;
|
|
2841
|
+
} finally {
|
|
2842
|
+
await rm2(dir, { recursive: true, force: true });
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2584
2845
|
// ---------------------------------------------------------------- notes
|
|
2585
2846
|
addNote(id, input) {
|
|
2586
2847
|
const review = this.getReview(id);
|
|
@@ -2653,25 +2914,27 @@ ${errors.join("\n")}`));
|
|
|
2653
2914
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`, `thread ${itemId} not found`));
|
|
2654
2915
|
continue;
|
|
2655
2916
|
}
|
|
2656
|
-
const
|
|
2657
|
-
const
|
|
2917
|
+
const rawBody = draft.body.trim();
|
|
2918
|
+
const body2 = normalizeMediaLinks(rawBody, review.info.ref.platform, review.info.ref.host);
|
|
2919
|
+
const posted = normalizeMediaLinks(draft.posted.trim(), review.info.ref.platform, review.info.ref.host);
|
|
2920
|
+
const alreadyPosted = rawBody !== "" && posted === body2;
|
|
2658
2921
|
const resolveChanged = draft.resolve !== thread.resolved;
|
|
2659
|
-
if (!
|
|
2922
|
+
if (!body2 && !resolveChanged) {
|
|
2660
2923
|
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
2924
|
continue;
|
|
2662
2925
|
}
|
|
2663
|
-
if (
|
|
2926
|
+
if (body2 && alreadyPosted && !resolveChanged) {
|
|
2664
2927
|
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
2928
|
continue;
|
|
2666
2929
|
}
|
|
2667
|
-
if (
|
|
2668
|
-
const check2 = sanitizeBody(
|
|
2930
|
+
if (body2) {
|
|
2931
|
+
const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
|
|
2669
2932
|
if (!check2.ok) {
|
|
2670
2933
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: ${check2.reason}`, `thread ${thread.id.substring(0, 8)}: ${check2.reason}`));
|
|
2671
2934
|
continue;
|
|
2672
2935
|
}
|
|
2673
2936
|
}
|
|
2674
|
-
items.push({ kind, id: thread.id, body, resolve: draft.resolve });
|
|
2937
|
+
items.push({ kind, id: thread.id, body: body2, resolve: draft.resolve });
|
|
2675
2938
|
continue;
|
|
2676
2939
|
}
|
|
2677
2940
|
if (kind === "finding") {
|
|
@@ -2684,13 +2947,13 @@ ${errors.join("\n")}`));
|
|
|
2684
2947
|
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
2948
|
continue;
|
|
2686
2949
|
}
|
|
2687
|
-
const
|
|
2688
|
-
const check2 = sanitizeBody(
|
|
2950
|
+
const body2 = normalizeMediaLinks(finding.comment.trim(), review.info.ref.platform, review.info.ref.host);
|
|
2951
|
+
const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
|
|
2689
2952
|
if (!check2.ok) {
|
|
2690
2953
|
warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id}: ${check2.reason}`, `finding ${finding.id}: ${check2.reason}`));
|
|
2691
2954
|
continue;
|
|
2692
2955
|
}
|
|
2693
|
-
const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${
|
|
2956
|
+
const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${body2}` : body2;
|
|
2694
2957
|
items.push({
|
|
2695
2958
|
kind,
|
|
2696
2959
|
id: finding.id,
|
|
@@ -2710,12 +2973,13 @@ ${errors.join("\n")}`));
|
|
|
2710
2973
|
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
2974
|
continue;
|
|
2712
2975
|
}
|
|
2713
|
-
const
|
|
2976
|
+
const body = normalizeMediaLinks(note.text, review.info.ref.platform, review.info.ref.host);
|
|
2977
|
+
const check = sanitizeBody(body, review.info.ref.platform, { host: review.info.ref.host });
|
|
2714
2978
|
if (!check.ok) {
|
|
2715
2979
|
warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id}: ${check.reason}`, `comment ${note.id}: ${check.reason}`));
|
|
2716
2980
|
continue;
|
|
2717
2981
|
}
|
|
2718
|
-
items.push({ kind, id: note.id, body
|
|
2982
|
+
items.push({ kind, id: note.id, body, file: note.file, line: note.line, side: note.side });
|
|
2719
2983
|
}
|
|
2720
2984
|
return { plan: this.makePlan(id, kind, items), warnings };
|
|
2721
2985
|
}
|
|
@@ -2778,7 +3042,7 @@ ${errors.join("\n")}`));
|
|
|
2778
3042
|
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
3043
|
const body = String(item.body ?? "").trim();
|
|
2780
3044
|
if (body && draft.posted.trim() !== body) {
|
|
2781
|
-
const check = sanitizeBody(body, platform);
|
|
3045
|
+
const check = sanitizeBody(body, platform, { host: review.info.ref.host });
|
|
2782
3046
|
if (!check.ok) throw new Error(check.reason);
|
|
2783
3047
|
await provider.reply(thread, body);
|
|
2784
3048
|
draft.posted = body;
|
|
@@ -3045,7 +3309,7 @@ init_errors();
|
|
|
3045
3309
|
import http from "node:http";
|
|
3046
3310
|
import { createReadStream } from "node:fs";
|
|
3047
3311
|
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
3048
|
-
import
|
|
3312
|
+
import path7 from "node:path";
|
|
3049
3313
|
import { timingSafeEqual } from "node:crypto";
|
|
3050
3314
|
|
|
3051
3315
|
// src/daemon/rpc.ts
|
|
@@ -3053,7 +3317,7 @@ import net from "node:net";
|
|
|
3053
3317
|
import { chmod, unlink as unlink2 } from "node:fs/promises";
|
|
3054
3318
|
init_redact();
|
|
3055
3319
|
init_errors();
|
|
3056
|
-
var MAX_LINE =
|
|
3320
|
+
var MAX_LINE = 40 * 1024 * 1024;
|
|
3057
3321
|
async function dispatch(service, method, params) {
|
|
3058
3322
|
switch (method) {
|
|
3059
3323
|
case "ping":
|
|
@@ -3091,6 +3355,23 @@ async function dispatch(service, method, params) {
|
|
|
3091
3355
|
const file = service.diffFile(String(params.review ?? ""), String(params.file ?? ""));
|
|
3092
3356
|
return { file };
|
|
3093
3357
|
}
|
|
3358
|
+
case "reactions.list":
|
|
3359
|
+
return {
|
|
3360
|
+
reactions: await service.reactions(
|
|
3361
|
+
String(params.review ?? ""),
|
|
3362
|
+
Array.isArray(params.noteIds) ? params.noteIds.map(String) : []
|
|
3363
|
+
)
|
|
3364
|
+
};
|
|
3365
|
+
case "reactions.toggle":
|
|
3366
|
+
return service.react(String(params.review ?? ""), String(params.noteId ?? ""), String(params.emoji ?? ""));
|
|
3367
|
+
case "attachments.upload":
|
|
3368
|
+
return {
|
|
3369
|
+
attachment: await service.uploadAttachment(String(params.review ?? ""), {
|
|
3370
|
+
name: String(params.name ?? ""),
|
|
3371
|
+
contentType: String(params.contentType ?? ""),
|
|
3372
|
+
data: String(params.data ?? "")
|
|
3373
|
+
})
|
|
3374
|
+
};
|
|
3094
3375
|
case "drafts.put":
|
|
3095
3376
|
return { draft: service.setDraft(String(params.review ?? ""), String(params.threadId ?? ""), params.patch ?? {}) };
|
|
3096
3377
|
case "findings.put":
|
|
@@ -3223,7 +3504,7 @@ async function respond(service, request) {
|
|
|
3223
3504
|
}
|
|
3224
3505
|
|
|
3225
3506
|
// src/daemon/http.ts
|
|
3226
|
-
var MAX_BODY =
|
|
3507
|
+
var MAX_BODY = 40 * 1024 * 1024;
|
|
3227
3508
|
var MIME = {
|
|
3228
3509
|
".html": "text/html; charset=utf-8",
|
|
3229
3510
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3317,15 +3598,15 @@ async function startHttpServer(options) {
|
|
|
3317
3598
|
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
3599
|
return;
|
|
3319
3600
|
}
|
|
3320
|
-
const html = await readFile2(
|
|
3601
|
+
const html = await readFile2(path7.join(options.uiDir, "index.html"));
|
|
3321
3602
|
res.writeHead(200, { ...SECURITY_HEADERS, "content-type": MIME[".html"], "cache-control": "no-store" });
|
|
3322
3603
|
res.end(html);
|
|
3323
3604
|
return;
|
|
3324
3605
|
}
|
|
3325
3606
|
if (req.method === "GET" && (url.pathname.startsWith("/assets/") || url.pathname === "/favicon.svg")) {
|
|
3326
3607
|
const relative = url.pathname === "/favicon.svg" ? "favicon.svg" : url.pathname.slice("/assets/".length);
|
|
3327
|
-
const target =
|
|
3328
|
-
if (!target.startsWith(
|
|
3608
|
+
const target = path7.resolve(options.uiDir, url.pathname === "/favicon.svg" ? "" : "assets", relative);
|
|
3609
|
+
if (!target.startsWith(path7.resolve(options.uiDir) + path7.sep) || relative.includes("..")) {
|
|
3329
3610
|
reply(res, 404, { ok: false, error: "\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430" });
|
|
3330
3611
|
return;
|
|
3331
3612
|
}
|
|
@@ -3336,7 +3617,7 @@ async function startHttpServer(options) {
|
|
|
3336
3617
|
}
|
|
3337
3618
|
res.writeHead(200, {
|
|
3338
3619
|
...SECURITY_HEADERS,
|
|
3339
|
-
"content-type": MIME[
|
|
3620
|
+
"content-type": MIME[path7.extname(target)] ?? "application/octet-stream",
|
|
3340
3621
|
"cache-control": "public, max-age=31536000, immutable"
|
|
3341
3622
|
});
|
|
3342
3623
|
createReadStream(target).pipe(res);
|
|
@@ -3414,6 +3695,12 @@ data: ${JSON.stringify(event)}
|
|
|
3414
3695
|
reply(res, 200, { ok: true, rules: await service.rules(review) });
|
|
3415
3696
|
return;
|
|
3416
3697
|
}
|
|
3698
|
+
if (req.method === "GET" && url.pathname === "/api/reactions") {
|
|
3699
|
+
const review = url.searchParams.get("review") ?? "";
|
|
3700
|
+
const noteIds = (url.searchParams.get("notes") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
3701
|
+
reply(res, 200, { ok: true, reactions: await service.reactions(review, noteIds) });
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3417
3704
|
if (req.method === "POST") {
|
|
3418
3705
|
const body = await readBody(req);
|
|
3419
3706
|
const review = String(body.review ?? "");
|
|
@@ -3436,6 +3723,20 @@ data: ${JSON.stringify(event)}
|
|
|
3436
3723
|
reply(res, 200, { ok: true, review: service.getView(updated.id), reviews: service.listReviews() });
|
|
3437
3724
|
return;
|
|
3438
3725
|
}
|
|
3726
|
+
case "/api/attachments": {
|
|
3727
|
+
const attachment = await service.uploadAttachment(review, {
|
|
3728
|
+
name: String(body.name ?? ""),
|
|
3729
|
+
contentType: String(body.type ?? ""),
|
|
3730
|
+
data: String(body.data ?? "")
|
|
3731
|
+
});
|
|
3732
|
+
reply(res, 200, { ok: true, attachment });
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
case "/api/reactions": {
|
|
3736
|
+
const result = await service.react(review, String(body.noteId ?? ""), String(body.emoji ?? ""));
|
|
3737
|
+
reply(res, 200, { ok: true, ...result });
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3439
3740
|
case "/api/drafts": {
|
|
3440
3741
|
const draft = service.setDraft(review, String(body.threadId ?? ""), body.patch ?? {});
|
|
3441
3742
|
reply(res, 200, { ok: true, draft });
|
|
@@ -3534,12 +3835,35 @@ data: ${JSON.stringify(event)}
|
|
|
3534
3835
|
};
|
|
3535
3836
|
}
|
|
3536
3837
|
|
|
3838
|
+
// src/daemon/bridge.ts
|
|
3839
|
+
import { unlink as unlink3 } from "node:fs/promises";
|
|
3840
|
+
async function startDevcontainerBridge(service, info) {
|
|
3841
|
+
await ensureDir(devcontainerRunDir(), 448);
|
|
3842
|
+
await ensureDir(devcontainerStateDir(), 448);
|
|
3843
|
+
const rpc = await startRpcServer(service, devcontainerSocketPath());
|
|
3844
|
+
try {
|
|
3845
|
+
const payload = { ...info, socket: devcontainerSocketPath() };
|
|
3846
|
+
await writeFileAtomic(devcontainerInfoPath(), `${JSON.stringify(payload, null, 2)}
|
|
3847
|
+
`, 384);
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
await rpc.close();
|
|
3850
|
+
throw error;
|
|
3851
|
+
}
|
|
3852
|
+
return {
|
|
3853
|
+
close: async () => {
|
|
3854
|
+
await rpc.close();
|
|
3855
|
+
await unlink3(devcontainerInfoPath()).catch(() => void 0);
|
|
3856
|
+
}
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3537
3860
|
// src/daemon/main.ts
|
|
3538
3861
|
async function main() {
|
|
3539
3862
|
setLocale(detectLocale());
|
|
3540
3863
|
const { values } = parseArgs({
|
|
3541
3864
|
options: {
|
|
3542
3865
|
port: { type: "string" },
|
|
3866
|
+
host: { type: "string" },
|
|
3543
3867
|
"state-dir": { type: "string" },
|
|
3544
3868
|
"runtime-dir": { type: "string" },
|
|
3545
3869
|
"ui-dir": { type: "string" },
|
|
@@ -3582,12 +3906,13 @@ async function main() {
|
|
|
3582
3906
|
});
|
|
3583
3907
|
const socket = socketPath();
|
|
3584
3908
|
const rpc = await startRpcServer(service, socket);
|
|
3585
|
-
const uiDir = values["ui-dir"] ?
|
|
3909
|
+
const uiDir = values["ui-dir"] ? path8.resolve(values["ui-dir"]) : path8.join(packageRoot(), "dist", "ui");
|
|
3586
3910
|
const http2 = await startHttpServer({
|
|
3587
3911
|
service,
|
|
3588
3912
|
token,
|
|
3589
3913
|
uiDir,
|
|
3590
3914
|
...values.port ? { port: Number(values.port) } : {},
|
|
3915
|
+
...values.host ? { host: String(values.host) } : {},
|
|
3591
3916
|
version: VERSION
|
|
3592
3917
|
});
|
|
3593
3918
|
const info = {
|
|
@@ -3602,6 +3927,12 @@ async function main() {
|
|
|
3602
3927
|
};
|
|
3603
3928
|
await writeFileAtomic(daemonInfoPath(), `${JSON.stringify(info, null, 2)}
|
|
3604
3929
|
`, 384);
|
|
3930
|
+
let bridge = null;
|
|
3931
|
+
try {
|
|
3932
|
+
bridge = await startDevcontainerBridge(service, info);
|
|
3933
|
+
} catch (error) {
|
|
3934
|
+
await log(`devcontainer bridge unavailable: ${redactLine(error instanceof Error ? error.message : error)}`);
|
|
3935
|
+
}
|
|
3605
3936
|
service.startSync();
|
|
3606
3937
|
process.stdout.write(
|
|
3607
3938
|
[
|
|
@@ -3619,13 +3950,14 @@ async function main() {
|
|
|
3619
3950
|
closing = true;
|
|
3620
3951
|
await log(`daemon stopping (${signal})`);
|
|
3621
3952
|
try {
|
|
3953
|
+
await bridge?.close();
|
|
3622
3954
|
await rpc.close();
|
|
3623
3955
|
await http2.close();
|
|
3624
3956
|
} finally {
|
|
3625
3957
|
service.close();
|
|
3626
|
-
await
|
|
3958
|
+
await unlink4(daemonInfoPath()).catch(() => void 0);
|
|
3627
3959
|
await lock.close().catch(() => void 0);
|
|
3628
|
-
await
|
|
3960
|
+
await unlink4(path8.join(stateDir(), "daemon.lock")).catch(() => void 0);
|
|
3629
3961
|
process.exit(0);
|
|
3630
3962
|
}
|
|
3631
3963
|
};
|
|
@@ -3637,9 +3969,9 @@ async function main() {
|
|
|
3637
3969
|
}
|
|
3638
3970
|
}
|
|
3639
3971
|
function packageRoot() {
|
|
3640
|
-
let dir =
|
|
3972
|
+
let dir = path8.dirname(fileURLToPath(import.meta.url));
|
|
3641
3973
|
for (; ; ) {
|
|
3642
|
-
const candidate =
|
|
3974
|
+
const candidate = path8.join(dir, "package.json");
|
|
3643
3975
|
if (existsSync4(candidate)) {
|
|
3644
3976
|
try {
|
|
3645
3977
|
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
@@ -3647,7 +3979,7 @@ function packageRoot() {
|
|
|
3647
3979
|
} catch {
|
|
3648
3980
|
}
|
|
3649
3981
|
}
|
|
3650
|
-
const parent =
|
|
3982
|
+
const parent = path8.dirname(dir);
|
|
3651
3983
|
if (parent === dir) return process.cwd();
|
|
3652
3984
|
dir = parent;
|
|
3653
3985
|
}
|
|
@@ -3686,7 +4018,7 @@ async function runningDaemon() {
|
|
|
3686
4018
|
return reachable ? info : null;
|
|
3687
4019
|
}
|
|
3688
4020
|
async function acquireLock() {
|
|
3689
|
-
const file =
|
|
4021
|
+
const file = path8.join(stateDir(), "daemon.lock");
|
|
3690
4022
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3691
4023
|
try {
|
|
3692
4024
|
const handle = await open2(file, "wx", 384);
|
|
@@ -3702,7 +4034,7 @@ async function acquireLock() {
|
|
|
3702
4034
|
} catch {
|
|
3703
4035
|
}
|
|
3704
4036
|
}
|
|
3705
|
-
await
|
|
4037
|
+
await unlink4(file).catch(() => void 0);
|
|
3706
4038
|
}
|
|
3707
4039
|
}
|
|
3708
4040
|
return null;
|