claude-plan-review 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -4
- package/package.json +2 -1
- package/skill/SKILL.md +86 -0
- package/src/cli.js +192 -7
- package/src/hook.js +31 -8
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-parse.js +238 -0
- package/src/server.js +261 -10
- package/src/stop-gate.js +73 -0
- package/src/store.js +309 -33
- package/src/ui/app.js +649 -108
- package/src/ui/index.html +51 -15
- package/src/ui/style.css +73 -18
package/src/store.js
CHANGED
|
@@ -8,7 +8,19 @@ import {
|
|
|
8
8
|
rmSync,
|
|
9
9
|
} from "node:fs";
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
PROJECTS_DIR,
|
|
13
|
+
REVIEWS_DIR,
|
|
14
|
+
docPath,
|
|
15
|
+
manifestPath,
|
|
16
|
+
projectDir,
|
|
17
|
+
projectKey,
|
|
18
|
+
versionDir,
|
|
19
|
+
} from "./paths.js";
|
|
20
|
+
|
|
21
|
+
/** Auto-rejection reason written when a pending review is force-deleted in the UI. */
|
|
22
|
+
const DELETE_REASON =
|
|
23
|
+
"This plan was deleted in the review UI — re-present or ask the user how to proceed";
|
|
12
24
|
|
|
13
25
|
// ---------- helpers ----------
|
|
14
26
|
const enc = (o) => JSON.stringify(o, null, 2);
|
|
@@ -59,15 +71,42 @@ export function setStorage(key, channelId, info) {
|
|
|
59
71
|
return meta.storage[channelId];
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/** Canonical content hash for dedup — stable across both creation paths. */
|
|
75
|
+
function hashTree(tree) {
|
|
76
|
+
if (tree.kind === "tree") {
|
|
77
|
+
const recs = [...tree.docs]
|
|
78
|
+
.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0))
|
|
79
|
+
.map((d) => `${d.slug} ${d.title} ${d.parent || ""} ${d.body}`);
|
|
80
|
+
return sha(recs.join(" "));
|
|
81
|
+
}
|
|
82
|
+
return sha(tree.markdown ?? "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Persist a tree version: manifest.json + one <slug>.md per doc. */
|
|
86
|
+
function writeTree(key, version, tree) {
|
|
87
|
+
mkdirSync(versionDir(key, version), { recursive: true });
|
|
88
|
+
const docs = tree.docs.map((d) => {
|
|
89
|
+
const file = `${d.slug}.md`;
|
|
90
|
+
writeFileSync(docPath(key, version, file), d.body ?? "");
|
|
91
|
+
return { slug: d.slug, title: d.title, parent: d.parent ?? null, file, order: d.order };
|
|
92
|
+
});
|
|
93
|
+
writeFileSync(manifestPath(key, version), enc({ schema: 1, root: tree.root || "root", docs }));
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
/**
|
|
63
97
|
* Record a freshly-presented plan. De-dupes by content hash: an identical
|
|
64
98
|
* re-presentation reuses the existing version (and keeps its comments),
|
|
65
99
|
* otherwise a new immutable version is written. Always creates a new review.
|
|
100
|
+
*
|
|
101
|
+
* Takes a normalized `tree` (parsePlan output — {kind:"single",markdown} or
|
|
102
|
+
* {kind:"tree",root,docs}); a legacy `plan` string is accepted as a single doc.
|
|
66
103
|
*/
|
|
67
104
|
export function recordPlan(opts) {
|
|
68
105
|
const key = projectKey(opts.cwd);
|
|
69
106
|
ensureDirs(key);
|
|
70
|
-
const
|
|
107
|
+
const tree =
|
|
108
|
+
opts.tree ?? { kind: "single", markdown: opts.plan != null ? opts.plan : "" };
|
|
109
|
+
const hash = hashTree(tree);
|
|
71
110
|
|
|
72
111
|
let meta = getProjectMeta(key);
|
|
73
112
|
let version;
|
|
@@ -78,7 +117,10 @@ export function recordPlan(opts) {
|
|
|
78
117
|
meta.updatedAt = now();
|
|
79
118
|
} else {
|
|
80
119
|
version = (meta?.currentVersion ?? 0) + 1;
|
|
81
|
-
|
|
120
|
+
const kind = tree.kind === "tree" ? "tree" : "single";
|
|
121
|
+
const docCount = kind === "tree" ? tree.docs.length : 1;
|
|
122
|
+
if (kind === "tree") writeTree(key, version, tree);
|
|
123
|
+
else writeFileSync(versionMd(key, version), tree.markdown ?? "");
|
|
82
124
|
writeFileSync(
|
|
83
125
|
versionMeta(key, version),
|
|
84
126
|
enc({
|
|
@@ -87,6 +129,8 @@ export function recordPlan(opts) {
|
|
|
87
129
|
sessionId: opts.sessionId,
|
|
88
130
|
toolUseId: opts.toolUseId,
|
|
89
131
|
hash,
|
|
132
|
+
kind,
|
|
133
|
+
docCount,
|
|
90
134
|
planFilePath: opts.planFilePath,
|
|
91
135
|
}),
|
|
92
136
|
);
|
|
@@ -115,6 +159,10 @@ export function recordPlan(opts) {
|
|
|
115
159
|
sessionId: opts.sessionId,
|
|
116
160
|
toolUseId: opts.toolUseId,
|
|
117
161
|
status: "pending",
|
|
162
|
+
// provenance of the submission: "hook" (plan-mode ExitPlanMode), "mcp"
|
|
163
|
+
// (plan_review_submit tool) or "api" (POST /api/plans). Only mcp/api
|
|
164
|
+
// reviews are gated by the Stop hook; missing ⇒ "hook" (never gated).
|
|
165
|
+
origin: opts.origin || "hook",
|
|
118
166
|
createdAt: now(),
|
|
119
167
|
}),
|
|
120
168
|
);
|
|
@@ -136,7 +184,8 @@ export function listProjects() {
|
|
|
136
184
|
versions: listVersions(m.key).length,
|
|
137
185
|
pending: pendingByKey.get(m.key) ?? 0,
|
|
138
186
|
}))
|
|
139
|
-
|
|
187
|
+
// guard legacy metas that predate updatedAt
|
|
188
|
+
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
|
140
189
|
}
|
|
141
190
|
|
|
142
191
|
export function listVersions(key) {
|
|
@@ -146,28 +195,121 @@ export function listVersions(key) {
|
|
|
146
195
|
.filter((f) => f.endsWith(".json"))
|
|
147
196
|
.map((f) => readJSON(join(dir, f), null))
|
|
148
197
|
.filter(Boolean)
|
|
198
|
+
// defaults for legacy metas (missing kind ⇒ single)
|
|
199
|
+
.map((m) => ({ kind: "single", docCount: 1, ...m }))
|
|
149
200
|
.sort((a, b) => a.n - b.n);
|
|
150
201
|
}
|
|
151
202
|
|
|
203
|
+
/** True when version n is stored as a tree (manifest present). */
|
|
204
|
+
function isTree(key, n) {
|
|
205
|
+
return existsSync(manifestPath(key, n));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Flatten a tree version into one markdown string — each doc's body preceded by
|
|
210
|
+
* a title heading (level by depth), in manifest order. Feeds diff / gist save /
|
|
211
|
+
* the flat comment-snippet fallback.
|
|
212
|
+
*/
|
|
213
|
+
function flattenManifest(key, n, manifest) {
|
|
214
|
+
const bySlug = new Map(manifest.docs.map((d) => [d.slug, d]));
|
|
215
|
+
const depth = (slug) => {
|
|
216
|
+
let d = 0;
|
|
217
|
+
let cur = bySlug.get(slug);
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
while (cur && cur.parent && !seen.has(cur.slug)) {
|
|
220
|
+
seen.add(cur.slug);
|
|
221
|
+
d++;
|
|
222
|
+
cur = bySlug.get(cur.parent);
|
|
223
|
+
}
|
|
224
|
+
return d;
|
|
225
|
+
};
|
|
226
|
+
const parts = [];
|
|
227
|
+
for (const d of manifest.docs) {
|
|
228
|
+
const h = "#".repeat(Math.min(6, depth(d.slug) + 1));
|
|
229
|
+
const body = existsSync(docPath(key, n, d.file))
|
|
230
|
+
? readFileSync(docPath(key, n, d.file), "utf8")
|
|
231
|
+
: "";
|
|
232
|
+
// Skip the synthesized heading when the body already opens with a heading
|
|
233
|
+
// whose text equals the doc title (avoids a duplicate "# Title").
|
|
234
|
+
const first = body.split("\n").find((l) => l.trim());
|
|
235
|
+
const firstHeading = first && first.match(/^\s*#{1,6}\s+(.+?)\s*$/);
|
|
236
|
+
const chunk =
|
|
237
|
+
firstHeading && firstHeading[1].trim() === String(d.title).trim()
|
|
238
|
+
? body
|
|
239
|
+
: `${h} ${d.title}\n\n${body}`;
|
|
240
|
+
parts.push(chunk.replace(/\s+$/, ""));
|
|
241
|
+
}
|
|
242
|
+
return parts.join("\n\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
152
245
|
export function getVersion(key, n) {
|
|
246
|
+
if (isTree(key, n)) {
|
|
247
|
+
const manifest = readJSON(manifestPath(key, n), null);
|
|
248
|
+
if (!manifest) return null;
|
|
249
|
+
return {
|
|
250
|
+
kind: "tree",
|
|
251
|
+
meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "tree" }),
|
|
252
|
+
manifest,
|
|
253
|
+
markdown: flattenManifest(key, n, manifest),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
153
256
|
if (!existsSync(versionMd(key, n))) return null;
|
|
154
257
|
return {
|
|
258
|
+
kind: "single",
|
|
155
259
|
markdown: readFileSync(versionMd(key, n), "utf8"),
|
|
156
|
-
meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "" }),
|
|
260
|
+
meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "single" }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Return the doc manifest; single-doc versions synthesize a single root doc. */
|
|
265
|
+
export function getManifest(key, n) {
|
|
266
|
+
if (isTree(key, n)) return readJSON(manifestPath(key, n), null);
|
|
267
|
+
if (!existsSync(versionMd(key, n))) return null;
|
|
268
|
+
const md = readFileSync(versionMd(key, n), "utf8");
|
|
269
|
+
const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
|
|
270
|
+
return {
|
|
271
|
+
schema: 1,
|
|
272
|
+
root: "root",
|
|
273
|
+
docs: [{ slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, file: `${pad(n)}.md`, order: 0 }],
|
|
157
274
|
};
|
|
158
275
|
}
|
|
159
276
|
|
|
277
|
+
/** One document's content; single-doc "root" resolves to the flat markdown. */
|
|
278
|
+
export function getDoc(key, n, slug) {
|
|
279
|
+
if (isTree(key, n)) {
|
|
280
|
+
const manifest = readJSON(manifestPath(key, n), null);
|
|
281
|
+
const d = manifest?.docs.find((x) => x.slug === slug);
|
|
282
|
+
if (!d) return null;
|
|
283
|
+
const md = existsSync(docPath(key, n, d.file)) ? readFileSync(docPath(key, n, d.file), "utf8") : "";
|
|
284
|
+
return { slug: d.slug, title: d.title, parent: d.parent ?? null, markdown: md };
|
|
285
|
+
}
|
|
286
|
+
if (slug !== "root" || !existsSync(versionMd(key, n))) return null;
|
|
287
|
+
const md = readFileSync(versionMd(key, n), "utf8");
|
|
288
|
+
const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
|
|
289
|
+
return { slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, markdown: md };
|
|
290
|
+
}
|
|
291
|
+
|
|
160
292
|
// ---------- comments ----------
|
|
161
|
-
|
|
293
|
+
function readComments(key, n) {
|
|
162
294
|
return readJSON(commentsPath(key, n), []);
|
|
163
295
|
}
|
|
164
296
|
|
|
297
|
+
export function listComments(key, n, doc) {
|
|
298
|
+
// legacy comments predate `doc` → default to "root"
|
|
299
|
+
const all = readComments(key, n).map((c) => ({ ...c, doc: c.doc || "root" }));
|
|
300
|
+
return doc == null ? all : all.filter((c) => c.doc === doc);
|
|
301
|
+
}
|
|
302
|
+
|
|
165
303
|
export function addComment(key, n, c) {
|
|
166
|
-
const comments =
|
|
304
|
+
const comments = readComments(key, n);
|
|
167
305
|
const comment = {
|
|
168
306
|
id: randomBytes(6).toString("hex"),
|
|
307
|
+
doc: c.doc || "root",
|
|
169
308
|
line: c.line,
|
|
170
309
|
lineEnd: c.line == null ? null : (c.lineEnd ?? c.line),
|
|
310
|
+
// the exact rendered text the reviewer selected in the preview (if any) —
|
|
311
|
+
// quoted back to Claude verbatim, which is more precise than a line slice.
|
|
312
|
+
quote: c.quote ? String(c.quote).slice(0, 2000) : null,
|
|
171
313
|
body: c.body,
|
|
172
314
|
author: c.author || "me",
|
|
173
315
|
createdAt: now(),
|
|
@@ -178,7 +320,7 @@ export function addComment(key, n, c) {
|
|
|
178
320
|
}
|
|
179
321
|
|
|
180
322
|
export function deleteComment(key, n, id) {
|
|
181
|
-
const comments =
|
|
323
|
+
const comments = readComments(key, n);
|
|
182
324
|
const next = comments.filter((c) => c.id !== id);
|
|
183
325
|
if (next.length === comments.length) return false;
|
|
184
326
|
writeFileSync(commentsPath(key, n), enc(next));
|
|
@@ -222,19 +364,26 @@ export function resolveReview(id, decision) {
|
|
|
222
364
|
}
|
|
223
365
|
|
|
224
366
|
/**
|
|
225
|
-
* Format a version's comments for the model
|
|
367
|
+
* Format a version's comments for the model, grouped per document (labeled by
|
|
368
|
+
* doc title when the plan is a tree). Line snippets are read from THAT doc's own
|
|
369
|
+
* markdown, not the flattened blob.
|
|
226
370
|
* mode "reject" → "address these, then re-present" (always returns text, even with no comments).
|
|
227
371
|
* mode "approve" → "plan is approved, incorporate these notes" (returns "" when there are none).
|
|
228
372
|
*/
|
|
229
373
|
function compileComments(key, n, mode) {
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
const
|
|
374
|
+
const all = listComments(key, n);
|
|
375
|
+
const anyLine = all.some((c) => c.line != null);
|
|
376
|
+
const anyGeneral = all.some((c) => c.line == null);
|
|
233
377
|
|
|
234
|
-
if (mode === "approve" && !
|
|
378
|
+
if (mode === "approve" && !anyLine && !anyGeneral) return "";
|
|
235
379
|
|
|
236
|
-
const
|
|
237
|
-
const
|
|
380
|
+
const manifest = getManifest(key, n);
|
|
381
|
+
const orderOf = new Map();
|
|
382
|
+
const titleOf = new Map();
|
|
383
|
+
for (const d of manifest?.docs ?? []) {
|
|
384
|
+
orderOf.set(d.slug, d.order ?? 0);
|
|
385
|
+
titleOf.set(d.slug, d.title);
|
|
386
|
+
}
|
|
238
387
|
|
|
239
388
|
const parts = [
|
|
240
389
|
mode === "approve"
|
|
@@ -243,31 +392,158 @@ function compileComments(key, n, mode) {
|
|
|
243
392
|
: "The reviewer requested changes to this plan in the plan-review UI. " +
|
|
244
393
|
"Address each comment below, then re-present the revised plan with ExitPlanMode.",
|
|
245
394
|
];
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
395
|
+
|
|
396
|
+
const docSlugs = [...new Set(all.map((c) => c.doc))].sort(
|
|
397
|
+
(a, b) => (orderOf.get(a) ?? 1e9) - (orderOf.get(b) ?? 1e9) || (a < b ? -1 : a > b ? 1 : 0),
|
|
398
|
+
);
|
|
399
|
+
const multi = docSlugs.length > 1;
|
|
400
|
+
|
|
401
|
+
for (const slug of docSlugs) {
|
|
402
|
+
const docComments = all.filter((c) => c.doc === slug);
|
|
403
|
+
const lineComments = docComments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
|
|
404
|
+
const general = docComments.filter((c) => c.line == null);
|
|
405
|
+
if (!lineComments.length && !general.length) continue;
|
|
406
|
+
|
|
407
|
+
const lines = (getDoc(key, n, slug)?.markdown ?? "").split("\n");
|
|
408
|
+
|
|
409
|
+
if (multi) parts.push(`\n## ${titleOf.get(slug) || slug}`);
|
|
410
|
+
if (lineComments.length) {
|
|
411
|
+
parts.push("\nLine comments:");
|
|
412
|
+
for (const c of lineComments) {
|
|
413
|
+
const s = c.line;
|
|
414
|
+
const e = c.lineEnd ?? s;
|
|
415
|
+
const label = e !== s ? `lines ${s}-${e}` : `line ${s}`;
|
|
416
|
+
// Prefer the reviewer's actual selected text; fall back to the source lines.
|
|
417
|
+
const snippet = c.quote
|
|
418
|
+
? c.quote.replace(/\s+/g, " ").trim().slice(0, 300)
|
|
419
|
+
: lines
|
|
420
|
+
.slice(s - 1, e)
|
|
421
|
+
.map((l) => l.trim())
|
|
422
|
+
.filter(Boolean)
|
|
423
|
+
.slice(0, 3)
|
|
424
|
+
.join(" / ");
|
|
425
|
+
parts.push(`- [${label}] "${snippet}"\n → ${c.body}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (general.length) {
|
|
429
|
+
parts.push("\nGeneral comments:");
|
|
430
|
+
for (const c of general) parts.push(`- ${c.body}`);
|
|
259
431
|
}
|
|
260
432
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
for (const c of general) parts.push(`- ${c.body}`);
|
|
264
|
-
}
|
|
265
|
-
if (mode === "reject" && !lineComments.length && !general.length) {
|
|
433
|
+
|
|
434
|
+
if (mode === "reject" && !anyLine && !anyGeneral) {
|
|
266
435
|
parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
|
|
267
436
|
}
|
|
268
437
|
return parts.join("\n");
|
|
269
438
|
}
|
|
270
439
|
|
|
440
|
+
// ---------- deletion ----------
|
|
441
|
+
/** Remove every on-disk artifact for one version (flat file, tree dir, comments). */
|
|
442
|
+
function removeVersionFiles(key, n) {
|
|
443
|
+
for (const path of [versionMd(key, n), versionMeta(key, n), commentsPath(key, n)]) {
|
|
444
|
+
try {
|
|
445
|
+
rmSync(path, { force: true });
|
|
446
|
+
} catch {}
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
rmSync(versionDir(key, n), { recursive: true, force: true });
|
|
450
|
+
} catch {}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Recompute a project's meta after deletions.
|
|
455
|
+
* currentVersion = max surviving version; latestHash = that meta's stored hash.
|
|
456
|
+
* Unparsable metas are skipped; latestHash is never set to "".
|
|
457
|
+
* Zero versions left ⇒ the whole project dir is removed and null is returned.
|
|
458
|
+
*/
|
|
459
|
+
export function recomputeMeta(key) {
|
|
460
|
+
const versions = listVersions(key); // parseable metas only, sorted asc
|
|
461
|
+
if (!versions.length) {
|
|
462
|
+
try {
|
|
463
|
+
rmSync(projectDir(key), { recursive: true, force: true });
|
|
464
|
+
} catch {}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const meta = getProjectMeta(key);
|
|
468
|
+
if (!meta) return null;
|
|
469
|
+
const latest = versions[versions.length - 1];
|
|
470
|
+
meta.currentVersion = latest.n;
|
|
471
|
+
if (latest.hash) meta.latestHash = latest.hash; // never blank it out
|
|
472
|
+
meta.updatedAt = now();
|
|
473
|
+
writeFileSync(metaPath(key), enc(meta));
|
|
474
|
+
return meta;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Delete a single version. A pending review blocks the delete unless force, in
|
|
479
|
+
* which case the review is force-rejected first (a live polling hook then
|
|
480
|
+
* unblocks via its normal deny path).
|
|
481
|
+
*/
|
|
482
|
+
export function deleteVersion(key, n, opts = {}) {
|
|
483
|
+
const force = !!opts.force;
|
|
484
|
+
const pending = listPendingReviews(key, n);
|
|
485
|
+
if (pending.length && !force) {
|
|
486
|
+
return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
|
|
487
|
+
}
|
|
488
|
+
for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
|
|
489
|
+
removeVersionFiles(key, n);
|
|
490
|
+
return { deleted: true, blocked: false, meta: recomputeMeta(key) };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Delete an entire project (all versions + its reviews). */
|
|
494
|
+
export function deleteProject(key, opts = {}) {
|
|
495
|
+
const force = !!opts.force;
|
|
496
|
+
const pending = listPendingReviews(key);
|
|
497
|
+
if (pending.length && !force) {
|
|
498
|
+
return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
|
|
499
|
+
}
|
|
500
|
+
for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
|
|
501
|
+
try {
|
|
502
|
+
rmSync(projectDir(key), { recursive: true, force: true });
|
|
503
|
+
} catch {}
|
|
504
|
+
deleteReviewsForProject(key);
|
|
505
|
+
return { deleted: true, blocked: false, meta: null };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------- review helpers ----------
|
|
509
|
+
export function listPendingReviews(key, n) {
|
|
510
|
+
return listReviews().filter(
|
|
511
|
+
(r) => r.projectKey === key && r.status === "pending" && (n == null || r.version === n),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Force-reject a review with a fixed reason (does not touch version files). */
|
|
516
|
+
export function rejectReviewForced(id, reason) {
|
|
517
|
+
const review = getReview(id);
|
|
518
|
+
if (!review) return null;
|
|
519
|
+
review.status = "rejected";
|
|
520
|
+
review.resolvedAt = now();
|
|
521
|
+
review.reason = reason;
|
|
522
|
+
review.forced = true;
|
|
523
|
+
writeFileSync(reviewPath(id), enc(review));
|
|
524
|
+
return review;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function deleteReviewsForProject(key) {
|
|
528
|
+
for (const r of listReviews()) {
|
|
529
|
+
if (r.projectKey === key) {
|
|
530
|
+
try {
|
|
531
|
+
rmSync(reviewPath(r.id), { force: true });
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function deleteReviewsForVersion(key, n) {
|
|
538
|
+
for (const r of listReviews()) {
|
|
539
|
+
if (r.projectKey === key && r.version === n) {
|
|
540
|
+
try {
|
|
541
|
+
rmSync(reviewPath(r.id), { force: true });
|
|
542
|
+
} catch {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
271
547
|
// ---------- maintenance ----------
|
|
272
548
|
export function pruneResolvedReviews(maxAgeMs = 1000 * 60 * 60 * 24 * 7) {
|
|
273
549
|
const cutoff = Date.now() - maxAgeMs;
|