prreviewbuddy 0.92.0 → 0.95.2
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 +101 -0
- package/dist/main.js +597 -136
- package/dist/{mcp_tools-BKPpd9dN.js → mcp_tools-DDbcXys2.js} +1274 -571
- package/dist/server.js +317 -413
- package/package.json +1 -1
- package/static/{chunk-CIJm20ER.js → chunk-CQIPqX7U.js} +1 -1
- package/static/chunk-Cs33-tV3.js +1 -0
- package/static/{chunk-Ahis0VaG.js → chunk-Cum6sHaC.js} +1 -1
- package/static/{chunk-DMxtqzNj.js → chunk-D1LBeOJc.js} +1 -1
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/runs.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +11 -0
- package/static/workspace.js +64 -64
- package/static/chunk-DRtin0n-.js +0 -1
|
@@ -1,13 +1,425 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
2
|
import { accessSync, appendFileSync, closeSync, constants, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { platform } from "node:process";
|
|
9
9
|
import { connect } from "node:net";
|
|
10
10
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
11
|
+
//#region ../../packages/review-harness/src/workspace/store_root.ts
|
|
12
|
+
/**
|
|
13
|
+
* Where everything this product keeps on a machine lives, and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* A leaf on purpose. It was a line in `store.ts`, which is the right place for it right up until
|
|
16
|
+
* something needs the path without needing reviews: the postinstall script asks one question of
|
|
17
|
+
* this directory (has this machine used PR Review Buddy before) and must not pull a review model,
|
|
18
|
+
* a diff parser and a pull request extractor into its bundle to ask it. `store.ts` re-exports this,
|
|
19
|
+
* so every existing import is unchanged and there is still one definition.
|
|
20
|
+
*
|
|
21
|
+
* It is also the one path `uninstall.ts` is allowed to delete outright, which is a property worth
|
|
22
|
+
* being able to point at a single file for.
|
|
23
|
+
*/
|
|
24
|
+
var STORE_ROOT = join(homedir(), ".prreviewbuddy");
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region ../../packages/review-harness/src/workspace/write_lock.ts
|
|
27
|
+
/**
|
|
28
|
+
* One lock file per name, taken exclusively, so a read-change-write in one process cannot land in
|
|
29
|
+
* the middle of another's.
|
|
30
|
+
*
|
|
31
|
+
* Lifted out of `store.ts` unchanged, so the review queue can use it too. `queue.ts` cannot import
|
|
32
|
+
* the store (the store's prune imports the queue, and the reverse import would close the cycle), and
|
|
33
|
+
* a second lock written for the queue alone would be a second set of rules about stale holders to
|
|
34
|
+
* get right. This module depends on `store_root.ts` and on Node, nothing else, for the same reason.
|
|
35
|
+
*
|
|
36
|
+
* Names share one directory. A review is locked under its id, a UUID, so a fixed word such as
|
|
37
|
+
* `queue` can never collide with one.
|
|
38
|
+
*/
|
|
39
|
+
/** Raised when another process has held a review's write lock for longer than any write takes. */
|
|
40
|
+
var WorkspaceWriteBusyError = class extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("This review is being written by another process. Try again in a moment.");
|
|
43
|
+
this.name = "WorkspaceWriteBusyError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var WRITE_LOCKS_DIR = join(STORE_ROOT, "locks");
|
|
47
|
+
/** Every write under the lock is one read and one rename, milliseconds. This is a hang, not a queue. */
|
|
48
|
+
var WRITE_LOCK_WAIT_MS = 5e3;
|
|
49
|
+
/**
|
|
50
|
+
* A lock this old is broken whoever holds it.
|
|
51
|
+
*
|
|
52
|
+
* Every hold is synchronous and lasts one read and one rename, so no live writer is anywhere near
|
|
53
|
+
* this. It is the fallback for what a pid cannot say: a holder on another machine, a lock not yet
|
|
54
|
+
* written out, and a pid that died and was reused by some unrelated long-lived process, which a
|
|
55
|
+
* liveness check alone would read as the holder still working, for ever.
|
|
56
|
+
*/
|
|
57
|
+
var WRITE_LOCK_STALE_MS = 3e4;
|
|
58
|
+
var heldWriteLocks = /* @__PURE__ */ new Map();
|
|
59
|
+
var sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
60
|
+
/**
|
|
61
|
+
* One review's writes, one at a time across every process on the machine.
|
|
62
|
+
*
|
|
63
|
+
* The CLI and the workspace server are separate processes over one store. Without this, each could
|
|
64
|
+
* load the same review, change its own fields and rename its result into place, and whichever
|
|
65
|
+
* renamed last would discard the other: an update finishing in the terminal would put back a
|
|
66
|
+
* question the reviewer had just ticked on the page. A file created exclusively is the lock, so it
|
|
67
|
+
* holds between processes and needs nothing running to arbitrate.
|
|
68
|
+
*
|
|
69
|
+
* Synchronous, including the wait, because every write it guards is. It is held for a read and a
|
|
70
|
+
* rename, so a wait is milliseconds; one that runs to `WRITE_LOCK_WAIT_MS` means a holder has hung,
|
|
71
|
+
* and that is raised rather than written through. Re-entrant within a process, so a change made
|
|
72
|
+
* under it can save without deadlocking itself. A lock left by a process that died is broken: at
|
|
73
|
+
* once when its holder is on this machine and gone, and after `WRITE_LOCK_STALE_MS` in every case,
|
|
74
|
+
* because a pid can be reused and a live one proves nothing about who took the lock.
|
|
75
|
+
*/
|
|
76
|
+
function withWriteLock(id, fn) {
|
|
77
|
+
const depth = heldWriteLocks.get(id) ?? 0;
|
|
78
|
+
if (depth > 0) {
|
|
79
|
+
heldWriteLocks.set(id, depth + 1);
|
|
80
|
+
try {
|
|
81
|
+
return fn();
|
|
82
|
+
} finally {
|
|
83
|
+
heldWriteLocks.set(id, depth);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(WRITE_LOCKS_DIR, { recursive: true });
|
|
87
|
+
const path = join(WRITE_LOCKS_DIR, `${id}.lock`);
|
|
88
|
+
const mine = {
|
|
89
|
+
token: randomUUID(),
|
|
90
|
+
pid: process.pid,
|
|
91
|
+
host: hostname(),
|
|
92
|
+
at: Date.now()
|
|
93
|
+
};
|
|
94
|
+
const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
|
|
95
|
+
for (;;) {
|
|
96
|
+
try {
|
|
97
|
+
const fd = openSync(path, "wx", 384);
|
|
98
|
+
try {
|
|
99
|
+
writeSync(fd, JSON.stringify(mine));
|
|
100
|
+
} finally {
|
|
101
|
+
closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code !== "EEXIST") throw error;
|
|
106
|
+
}
|
|
107
|
+
if (breakIfAbandoned(path)) continue;
|
|
108
|
+
if (Date.now() >= deadline) throw new WorkspaceWriteBusyError();
|
|
109
|
+
Atomics.wait(sleeper, 0, 0, 2);
|
|
110
|
+
}
|
|
111
|
+
heldWriteLocks.set(id, 1);
|
|
112
|
+
try {
|
|
113
|
+
return fn();
|
|
114
|
+
} finally {
|
|
115
|
+
heldWriteLocks.delete(id);
|
|
116
|
+
if (readHolder(path)?.token === mine.token) try {
|
|
117
|
+
unlinkSync(path);
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function readHolder(path) {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Removes a lock whose holder is gone, and says whether it did. */
|
|
129
|
+
function breakIfAbandoned(path) {
|
|
130
|
+
const holder = readHolder(path);
|
|
131
|
+
let age = 0;
|
|
132
|
+
try {
|
|
133
|
+
age = Date.now() - Math.min(statSync(path).mtimeMs, holder?.at ?? Infinity);
|
|
134
|
+
} catch {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (!(age > WRITE_LOCK_STALE_MS || holder !== null && holder.host === hostname() && !processAlive(holder.pid))) return false;
|
|
138
|
+
if (readHolder(path)?.token !== holder?.token) return true;
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(path);
|
|
141
|
+
} catch {}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
function processAlive(pid) {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 0);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return error.code === "EPERM";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region ../../packages/review-harness/src/workspace/collections.ts
|
|
154
|
+
/**
|
|
155
|
+
* Collections: the reviewer's own groups of reviews, such as everything in one refactor.
|
|
156
|
+
*
|
|
157
|
+
* A collection says what belongs together and nothing else. Whether a member still needs attention
|
|
158
|
+
* is its lifecycle, which this file never reads or writes: assigning, removing, renaming and
|
|
159
|
+
* deleting all leave every review's state exactly where it was.
|
|
160
|
+
*
|
|
161
|
+
* **Members are lineage keys, not review ids.** A card is a branch, and analysing the branch again
|
|
162
|
+
* makes a new record with a new id. The queue stores ids and has to forward them (`forwardQueue`);
|
|
163
|
+
* a lineage key is already what the card stands for, so membership survives updates, re-analysis
|
|
164
|
+
* and restarts with nothing to keep in step.
|
|
165
|
+
*
|
|
166
|
+
* **Its own file**, for the reasons `queue.ts` gives: a record is rewritten whole by whoever loaded
|
|
167
|
+
* it last and carries its diff inline, and `ui.json` is best-effort. And because a write here never
|
|
168
|
+
* touches a record, it never moves the record's mtime, which is the clock retention runs on.
|
|
169
|
+
* Organising a review is not opening or marking it.
|
|
170
|
+
*
|
|
171
|
+
* `STORE_ROOT` from `store_root.ts` rather than `store.ts`, for the import cycle `queue.ts` explains:
|
|
172
|
+
* the prune in `store.ts` reads this file.
|
|
173
|
+
*/
|
|
174
|
+
var COLLECTIONS_PATH = join(STORE_ROOT, "collections.json");
|
|
175
|
+
/** The lock's name. Reviews are locked under UUIDs in the same directory, so a word cannot collide. */
|
|
176
|
+
var COLLECTIONS_LOCK = "collections";
|
|
177
|
+
var VERSION$2 = 1;
|
|
178
|
+
var ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
179
|
+
/** Whether a value is shaped like a collection's id, which is what a fold key and a route accept. */
|
|
180
|
+
function isCollectionId$1(value) {
|
|
181
|
+
return typeof value === "string" && ID_PATTERN.test(value);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Every collection, in the order they were created.
|
|
185
|
+
*
|
|
186
|
+
* Best-effort like `readQueue`: missing, unreadable and corrupt all mean "no collections". An entry
|
|
187
|
+
* this code did not write is dropped rather than guessed at, and so is a second entry claiming an id
|
|
188
|
+
* or a name already taken, so the rules `createCollection` keeps hold for a hand-edited file too.
|
|
189
|
+
*/
|
|
190
|
+
function readCollections() {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(readFileSync(COLLECTIONS_PATH, "utf8"));
|
|
193
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
194
|
+
const collections = parsed.collections;
|
|
195
|
+
return Array.isArray(collections) ? normalise$1(collections) : [];
|
|
196
|
+
} catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function normalise$1(raw) {
|
|
201
|
+
const kept = [];
|
|
202
|
+
for (const entry of raw) {
|
|
203
|
+
if (kept.length >= 100) break;
|
|
204
|
+
if (!entry || typeof entry !== "object") continue;
|
|
205
|
+
const { id, name, colour, createdAt, members } = entry;
|
|
206
|
+
const clean = typeof name === "string" ? cleanName(name) : "";
|
|
207
|
+
if (!isCollectionId$1(id) || clean === "" || clean.length > 60) continue;
|
|
208
|
+
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) continue;
|
|
209
|
+
if (kept.some((other) => other.id === id || sameName(other.name, clean))) continue;
|
|
210
|
+
const keys = Array.isArray(members) ? members.filter((key) => typeof key === "string" && key !== "") : [];
|
|
211
|
+
kept.push({
|
|
212
|
+
id,
|
|
213
|
+
name: clean,
|
|
214
|
+
...typeof colour === "string" && colour !== "" ? { colour } : {},
|
|
215
|
+
createdAt,
|
|
216
|
+
members: [...new Set(keys)].slice(0, 500)
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return kept;
|
|
220
|
+
}
|
|
221
|
+
/** Spaces trimmed and runs of them collapsed, so " Release 0.82 " is "Release 0.82". */
|
|
222
|
+
function cleanName(name) {
|
|
223
|
+
return name.trim().replace(/\s+/g, " ");
|
|
224
|
+
}
|
|
225
|
+
/** Two names are one collection's name regardless of case: "refactor" is not a second Refactor. */
|
|
226
|
+
function sameName(a, b) {
|
|
227
|
+
return a.localeCompare(b, void 0, { sensitivity: "base" }) === 0;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The collection a typed name means, by the same rule that keeps names unique, so at most one can
|
|
231
|
+
* match. By name only: a name that happens to look like an id is still a name.
|
|
232
|
+
*/
|
|
233
|
+
function collectionNamed(name, collections) {
|
|
234
|
+
const clean = cleanName(name);
|
|
235
|
+
return collections.find((collection) => sameName(collection.name, clean));
|
|
236
|
+
}
|
|
237
|
+
function nameProblem(name, collections, except) {
|
|
238
|
+
const clean = cleanName(name);
|
|
239
|
+
if (clean === "") return "empty";
|
|
240
|
+
if (clean.length > 60) return "too-long";
|
|
241
|
+
if (collections.some((collection) => collection.id !== except && sameName(collection.name, clean))) return "duplicate";
|
|
242
|
+
}
|
|
243
|
+
/** What a refused change says, in the words the page shows. */
|
|
244
|
+
function nameProblemText(problem) {
|
|
245
|
+
if (problem === "empty") return "Give the collection a name.";
|
|
246
|
+
if (problem === "too-long") return `Use at most 60 characters for the name.`;
|
|
247
|
+
return "A collection with that name already exists.";
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Change the collections as they are on disk now, under the lock: the only way they are written.
|
|
251
|
+
* Same contract as `updateQueue`: `change` returns what to store or null for nothing, and null from
|
|
252
|
+
* here means the write failed.
|
|
253
|
+
*/
|
|
254
|
+
function updateCollections(change) {
|
|
255
|
+
try {
|
|
256
|
+
return withWriteLock(COLLECTIONS_LOCK, () => {
|
|
257
|
+
const current = readCollections();
|
|
258
|
+
const next = change(current);
|
|
259
|
+
return next === null ? current : writeUnlocked(next);
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** To a temporary file and renamed into place, for the reason `writeQueueUnlocked` gives. */
|
|
266
|
+
function writeUnlocked(collections) {
|
|
267
|
+
const next = normalise$1(collections);
|
|
268
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
269
|
+
const stored = {
|
|
270
|
+
version: VERSION$2,
|
|
271
|
+
collections: next
|
|
272
|
+
};
|
|
273
|
+
const temporary = `${COLLECTIONS_PATH}.${process.pid}.tmp`;
|
|
274
|
+
try {
|
|
275
|
+
writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
|
|
276
|
+
renameSync(temporary, COLLECTIONS_PATH);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
try {
|
|
279
|
+
unlinkSync(temporary);
|
|
280
|
+
} catch {}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
285
|
+
function run$3(change) {
|
|
286
|
+
const outcome = {};
|
|
287
|
+
const stored = updateCollections((current) => {
|
|
288
|
+
const result = change(current);
|
|
289
|
+
if ("refused" in result) {
|
|
290
|
+
outcome.refused = result.refused;
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
outcome.id = result.id;
|
|
294
|
+
return result.next;
|
|
295
|
+
});
|
|
296
|
+
if (outcome.refused !== void 0) return {
|
|
297
|
+
ok: false,
|
|
298
|
+
reason: outcome.refused
|
|
299
|
+
};
|
|
300
|
+
if (stored === null) return {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: "unsaved"
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
collections: stored,
|
|
307
|
+
...outcome.id === void 0 ? {} : { id: outcome.id }
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function createCollection(name, now = Date.now()) {
|
|
311
|
+
return run$3((current) => {
|
|
312
|
+
const problem = nameProblem(name, current);
|
|
313
|
+
if (problem) return { refused: problem };
|
|
314
|
+
if (current.length >= 100) return { refused: "full" };
|
|
315
|
+
const id = randomUUID();
|
|
316
|
+
return {
|
|
317
|
+
next: [...current, {
|
|
318
|
+
id,
|
|
319
|
+
name: cleanName(name),
|
|
320
|
+
createdAt: now,
|
|
321
|
+
members: []
|
|
322
|
+
}],
|
|
323
|
+
id
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A new collection holding these lineages from the start: one transaction, so it is made with all of
|
|
329
|
+
* them or not at all. "Create Refactor and add these three" never leaves an empty Refactor behind a
|
|
330
|
+
* failure, and a retry never meets a duplicate name of its own making.
|
|
331
|
+
*/
|
|
332
|
+
function createWithMembers(name, keys, now = Date.now()) {
|
|
333
|
+
const members = [...new Set(keys.filter((key) => key !== ""))];
|
|
334
|
+
return run$3((current) => {
|
|
335
|
+
const problem = nameProblem(name, current);
|
|
336
|
+
if (problem) return { refused: problem };
|
|
337
|
+
if (current.length >= 100 || members.length > 500) return { refused: "full" };
|
|
338
|
+
const id = randomUUID();
|
|
339
|
+
return {
|
|
340
|
+
next: [...current, {
|
|
341
|
+
id,
|
|
342
|
+
name: cleanName(name),
|
|
343
|
+
createdAt: now,
|
|
344
|
+
members
|
|
345
|
+
}],
|
|
346
|
+
id
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** A new name on the same collection: its id, its members and every fold kept against the id stay. */
|
|
351
|
+
function renameCollection(id, name) {
|
|
352
|
+
return run$3((current) => {
|
|
353
|
+
if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
|
|
354
|
+
const problem = nameProblem(name, current, id);
|
|
355
|
+
if (problem) return { refused: problem };
|
|
356
|
+
return {
|
|
357
|
+
next: current.map((collection) => collection.id === id ? {
|
|
358
|
+
...collection,
|
|
359
|
+
name: cleanName(name)
|
|
360
|
+
} : collection),
|
|
361
|
+
id
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/** The collection and its memberships go. No review is touched: they were never inside it. */
|
|
366
|
+
function deleteCollection(id) {
|
|
367
|
+
return run$3((current) => {
|
|
368
|
+
if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
|
|
369
|
+
return { next: current.filter((collection) => collection.id !== id) };
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Add the given lineages to one collection, or take them out of it.
|
|
374
|
+
*
|
|
375
|
+
* **Only the named collection is touched.** Assigning a selection to Refactor leaves every other
|
|
376
|
+
* membership those reviews have exactly as it was, which is what makes a bulk assignment additive.
|
|
377
|
+
* Adding a member already there and removing one that is not are both nothing, not errors: a mixed
|
|
378
|
+
* selection is the ordinary case.
|
|
379
|
+
*/
|
|
380
|
+
function setMembership(keys, id, action) {
|
|
381
|
+
return run$3((current) => {
|
|
382
|
+
const target = current.find((collection) => collection.id === id);
|
|
383
|
+
if (!target) return { refused: "missing" };
|
|
384
|
+
const members = action === "add" ? [...target.members, ...keys.filter((key, at) => key !== "" && !target.members.includes(key) && keys.indexOf(key) === at)] : target.members.filter((key) => !keys.includes(key));
|
|
385
|
+
if (members.length === target.members.length && members.every((key, at) => key === target.members[at])) return {
|
|
386
|
+
next: null,
|
|
387
|
+
id
|
|
388
|
+
};
|
|
389
|
+
if (members.length > 500) return { refused: "full" };
|
|
390
|
+
return {
|
|
391
|
+
next: current.map((collection) => collection.id === id ? {
|
|
392
|
+
...collection,
|
|
393
|
+
members
|
|
394
|
+
} : collection),
|
|
395
|
+
id
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Take lineages out of every collection, because their reviews are gone. Stores nothing when none
|
|
401
|
+
* of them was a member, so a deletion of an uncollected review never writes this file.
|
|
402
|
+
*/
|
|
403
|
+
function dropMembers(keys) {
|
|
404
|
+
const gone = new Set(keys);
|
|
405
|
+
return updateCollections((current) => current.some((collection) => collection.members.some((key) => gone.has(key))) ? current.map((collection) => ({
|
|
406
|
+
...collection,
|
|
407
|
+
members: collection.members.filter((key) => !gone.has(key))
|
|
408
|
+
})) : null);
|
|
409
|
+
}
|
|
410
|
+
/** Every lineage in at least one collection. What retention asks: collected is kept. */
|
|
411
|
+
function collectedKeys(collections = readCollections()) {
|
|
412
|
+
return new Set(collections.flatMap((collection) => collection.members));
|
|
413
|
+
}
|
|
414
|
+
/** A token for the index's revision, as `queueStamp` is: it changes only when a person changes it. */
|
|
415
|
+
function collectionsStamp() {
|
|
416
|
+
try {
|
|
417
|
+
return String(statSync(COLLECTIONS_PATH).mtimeMs);
|
|
418
|
+
} catch {
|
|
419
|
+
return "";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
11
423
|
//#region ../../packages/review-harness/src/agents/agent.ts
|
|
12
424
|
/**
|
|
13
425
|
* The variable every spawned agent runs under, and the structural half of containment.
|
|
@@ -327,7 +739,7 @@ function defaultConfigEnv(base) {
|
|
|
327
739
|
return env;
|
|
328
740
|
}
|
|
329
741
|
/** `claude` with these arguments, never through a shell. Resolves with its output. */
|
|
330
|
-
function run$
|
|
742
|
+
function run$2(args, env) {
|
|
331
743
|
return new Promise((resolvePromise, reject) => {
|
|
332
744
|
execFile("claude", [...args], configCommandOptions(env), (error, stdout, stderr) => {
|
|
333
745
|
const settled = settleConfigCommand("claude", error, stdout, stderr);
|
|
@@ -396,10 +808,10 @@ var claudeMcpRegistration = {
|
|
|
396
808
|
configPath: configPath$1,
|
|
397
809
|
read: read$1,
|
|
398
810
|
async add(entry, base = process.env) {
|
|
399
|
-
await run$
|
|
811
|
+
await run$2(addCommand$1(entry).slice(1), defaultConfigEnv(base));
|
|
400
812
|
},
|
|
401
813
|
async remove(base = process.env) {
|
|
402
|
-
await run$
|
|
814
|
+
await run$2(removeCommand$1().slice(1), defaultConfigEnv(base));
|
|
403
815
|
},
|
|
404
816
|
addCommand: addCommand$1,
|
|
405
817
|
removeCommand: removeCommand$1,
|
|
@@ -1140,7 +1552,7 @@ function configPath(base = process.env) {
|
|
|
1140
1552
|
return join(base.CODEX_HOME || join(base.HOME || homedir(), ".codex"), "config.toml");
|
|
1141
1553
|
}
|
|
1142
1554
|
/** `codex` with these arguments, never through a shell. Resolves with its output. */
|
|
1143
|
-
function run$
|
|
1555
|
+
function run$1(args, env) {
|
|
1144
1556
|
return new Promise((resolvePromise, reject) => {
|
|
1145
1557
|
execFile("codex", [...args], configCommandOptions(env), (error, stdout, stderr) => {
|
|
1146
1558
|
const settled = settleConfigCommand("codex", error, stdout, stderr);
|
|
@@ -1153,7 +1565,7 @@ function run$2(args, env) {
|
|
|
1153
1565
|
async function read(base = process.env) {
|
|
1154
1566
|
let stdout;
|
|
1155
1567
|
try {
|
|
1156
|
-
stdout = await run$
|
|
1568
|
+
stdout = await run$1([
|
|
1157
1569
|
"mcp",
|
|
1158
1570
|
"get",
|
|
1159
1571
|
MCP_SERVER_NAME,
|
|
@@ -1208,10 +1620,10 @@ var codexMcpRegistration = {
|
|
|
1208
1620
|
configPath,
|
|
1209
1621
|
read,
|
|
1210
1622
|
async add(entry, base = process.env) {
|
|
1211
|
-
await run$
|
|
1623
|
+
await run$1(addCommand(entry).slice(1), base);
|
|
1212
1624
|
},
|
|
1213
1625
|
async remove(base = process.env) {
|
|
1214
|
-
await run$
|
|
1626
|
+
await run$1(removeCommand().slice(1), base);
|
|
1215
1627
|
},
|
|
1216
1628
|
addCommand,
|
|
1217
1629
|
removeCommand,
|
|
@@ -1714,21 +2126,6 @@ async function detectAgents() {
|
|
|
1714
2126
|
return detectAgents$1(AGENT_IDS.map((id) => AGENTS[id]));
|
|
1715
2127
|
}
|
|
1716
2128
|
//#endregion
|
|
1717
|
-
//#region ../../packages/review-harness/src/workspace/store_root.ts
|
|
1718
|
-
/**
|
|
1719
|
-
* Where everything this product keeps on a machine lives, and nothing else.
|
|
1720
|
-
*
|
|
1721
|
-
* A leaf on purpose. It was a line in `store.ts`, which is the right place for it right up until
|
|
1722
|
-
* something needs the path without needing reviews: the postinstall script asks one question of
|
|
1723
|
-
* this directory (has this machine used PR Review Buddy before) and must not pull a review model,
|
|
1724
|
-
* a diff parser and a pull request extractor into its bundle to ask it. `store.ts` re-exports this,
|
|
1725
|
-
* so every existing import is unchanged and there is still one definition.
|
|
1726
|
-
*
|
|
1727
|
-
* It is also the one path `uninstall.ts` is allowed to delete outright, which is a property worth
|
|
1728
|
-
* being able to point at a single file for.
|
|
1729
|
-
*/
|
|
1730
|
-
var STORE_ROOT = join(homedir(), ".prreviewbuddy");
|
|
1731
|
-
//#endregion
|
|
1732
2129
|
//#region ../../packages/review-harness/src/workspace/config_file.ts
|
|
1733
2130
|
/**
|
|
1734
2131
|
* `config.json`, read and written as a whole so that no module can lose another's setting.
|
|
@@ -5800,183 +6197,56 @@ function isIssueOutstanding(issue, dispositions) {
|
|
|
5800
6197
|
/**
|
|
5801
6198
|
* A question is done when the reviewer has ticked it, or when the new commits answered it.
|
|
5802
6199
|
*
|
|
5803
|
-
* Either alone is enough. The reviewer's tick is the older of the two and the only one the first
|
|
5804
|
-
* analysis can produce; `reconciliationStatus === 'answered'` arrives with an update and carries
|
|
5805
|
-
* `reconciliationAnswer` beside it, which is the answer itself. Requiring the tick as well would
|
|
5806
|
-
* make the reviewer hand-close seven questions the page had already answered in front of them.
|
|
5807
|
-
*
|
|
5808
|
-
* No `affected` expiry here, unlike an issue's dismissal. An issue is a claim about the code and a
|
|
5809
|
-
* dismissal is a bet on that code; a question the reviewer has answered was answered out of their
|
|
5810
|
-
* own head, and a commit somewhere near it does not un-know what they now know.
|
|
5811
|
-
*/
|
|
5812
|
-
function isQuestionOutstanding(question, answered, closedByVerdict) {
|
|
5813
|
-
return !answered.has(question.id) && question.reconciliationStatus !== "answered" && !closedByVerdict(question.id);
|
|
5814
|
-
}
|
|
5815
|
-
function tally(items, outstanding, byReviewer) {
|
|
5816
|
-
let open = 0;
|
|
5817
|
-
let reviewerClosed = 0;
|
|
5818
|
-
for (const item of items) if (outstanding(item)) open += 1;
|
|
5819
|
-
else if (byReviewer(item)) reviewerClosed += 1;
|
|
5820
|
-
return {
|
|
5821
|
-
total: items.length,
|
|
5822
|
-
open,
|
|
5823
|
-
done: items.length - open,
|
|
5824
|
-
reviewerClosed
|
|
5825
|
-
};
|
|
5826
|
-
}
|
|
5827
|
-
function issuesOutstanding(issues, dispositions) {
|
|
5828
|
-
const counts = tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && (dispositions.isClosedByVerdict(issue) || dispositions.isDismissed(issue)));
|
|
5829
|
-
const byConversation = issues.filter(resolvedByConversation).length;
|
|
5830
|
-
const byVerdict = issues.filter((issue) => issue.reconciliationStatus !== "resolved" && dispositions.isClosedByVerdict(issue)).length;
|
|
5831
|
-
return {
|
|
5832
|
-
...counts,
|
|
5833
|
-
...byConversation > 0 ? { byConversation } : {},
|
|
5834
|
-
...byVerdict > 0 ? { byVerdict } : {}
|
|
5835
|
-
};
|
|
5836
|
-
}
|
|
5837
|
-
/** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
|
|
5838
|
-
function resolvedByConversation(issue) {
|
|
5839
|
-
return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
|
|
5840
|
-
}
|
|
5841
|
-
function questionsOutstanding(questions, answered, closedByVerdict) {
|
|
5842
|
-
return tally(questions, (question) => isQuestionOutstanding(question, answered, closedByVerdict), (question) => question.reconciliationStatus !== "answered" && (answered.has(question.id) || closedByVerdict(question.id)));
|
|
5843
|
-
}
|
|
5844
|
-
/**
|
|
5845
|
-
* What to call the pile of findings nobody has to act on any more.
|
|
5846
|
-
*
|
|
5847
|
-
* "Resolved" is a claim about the code and is only true while the commits did all the closing. One
|
|
5848
|
-
* dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
|
|
5849
|
-
* is acceptable did not resolve anything.
|
|
5850
|
-
*/
|
|
5851
|
-
function doneVerb(counts) {
|
|
5852
|
-
return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
|
|
5853
|
-
}
|
|
5854
|
-
//#endregion
|
|
5855
|
-
//#region ../../packages/review-harness/src/workspace/write_lock.ts
|
|
5856
|
-
/**
|
|
5857
|
-
* One lock file per name, taken exclusively, so a read-change-write in one process cannot land in
|
|
5858
|
-
* the middle of another's.
|
|
5859
|
-
*
|
|
5860
|
-
* Lifted out of `store.ts` unchanged, so the review queue can use it too. `queue.ts` cannot import
|
|
5861
|
-
* the store (the store's prune imports the queue, and the reverse import would close the cycle), and
|
|
5862
|
-
* a second lock written for the queue alone would be a second set of rules about stale holders to
|
|
5863
|
-
* get right. This module depends on `store_root.ts` and on Node, nothing else, for the same reason.
|
|
5864
|
-
*
|
|
5865
|
-
* Names share one directory. A review is locked under its id, a UUID, so a fixed word such as
|
|
5866
|
-
* `queue` can never collide with one.
|
|
5867
|
-
*/
|
|
5868
|
-
/** Raised when another process has held a review's write lock for longer than any write takes. */
|
|
5869
|
-
var WorkspaceWriteBusyError = class extends Error {
|
|
5870
|
-
constructor() {
|
|
5871
|
-
super("This review is being written by another process. Try again in a moment.");
|
|
5872
|
-
this.name = "WorkspaceWriteBusyError";
|
|
5873
|
-
}
|
|
5874
|
-
};
|
|
5875
|
-
var WRITE_LOCKS_DIR = join(STORE_ROOT, "locks");
|
|
5876
|
-
/** Every write under the lock is one read and one rename, milliseconds. This is a hang, not a queue. */
|
|
5877
|
-
var WRITE_LOCK_WAIT_MS = 5e3;
|
|
5878
|
-
/**
|
|
5879
|
-
* A lock this old is broken whoever holds it.
|
|
5880
|
-
*
|
|
5881
|
-
* Every hold is synchronous and lasts one read and one rename, so no live writer is anywhere near
|
|
5882
|
-
* this. It is the fallback for what a pid cannot say: a holder on another machine, a lock not yet
|
|
5883
|
-
* written out, and a pid that died and was reused by some unrelated long-lived process, which a
|
|
5884
|
-
* liveness check alone would read as the holder still working, for ever.
|
|
5885
|
-
*/
|
|
5886
|
-
var WRITE_LOCK_STALE_MS = 3e4;
|
|
5887
|
-
var heldWriteLocks = /* @__PURE__ */ new Map();
|
|
5888
|
-
var sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
5889
|
-
/**
|
|
5890
|
-
* One review's writes, one at a time across every process on the machine.
|
|
5891
|
-
*
|
|
5892
|
-
* The CLI and the workspace server are separate processes over one store. Without this, each could
|
|
5893
|
-
* load the same review, change its own fields and rename its result into place, and whichever
|
|
5894
|
-
* renamed last would discard the other: an update finishing in the terminal would put back a
|
|
5895
|
-
* question the reviewer had just ticked on the page. A file created exclusively is the lock, so it
|
|
5896
|
-
* holds between processes and needs nothing running to arbitrate.
|
|
5897
|
-
*
|
|
5898
|
-
* Synchronous, including the wait, because every write it guards is. It is held for a read and a
|
|
5899
|
-
* rename, so a wait is milliseconds; one that runs to `WRITE_LOCK_WAIT_MS` means a holder has hung,
|
|
5900
|
-
* and that is raised rather than written through. Re-entrant within a process, so a change made
|
|
5901
|
-
* under it can save without deadlocking itself. A lock left by a process that died is broken: at
|
|
5902
|
-
* once when its holder is on this machine and gone, and after `WRITE_LOCK_STALE_MS` in every case,
|
|
5903
|
-
* because a pid can be reused and a live one proves nothing about who took the lock.
|
|
5904
|
-
*/
|
|
5905
|
-
function withWriteLock(id, fn) {
|
|
5906
|
-
const depth = heldWriteLocks.get(id) ?? 0;
|
|
5907
|
-
if (depth > 0) {
|
|
5908
|
-
heldWriteLocks.set(id, depth + 1);
|
|
5909
|
-
try {
|
|
5910
|
-
return fn();
|
|
5911
|
-
} finally {
|
|
5912
|
-
heldWriteLocks.set(id, depth);
|
|
5913
|
-
}
|
|
5914
|
-
}
|
|
5915
|
-
mkdirSync(WRITE_LOCKS_DIR, { recursive: true });
|
|
5916
|
-
const path = join(WRITE_LOCKS_DIR, `${id}.lock`);
|
|
5917
|
-
const mine = {
|
|
5918
|
-
token: randomUUID(),
|
|
5919
|
-
pid: process.pid,
|
|
5920
|
-
host: hostname(),
|
|
5921
|
-
at: Date.now()
|
|
6200
|
+
* Either alone is enough. The reviewer's tick is the older of the two and the only one the first
|
|
6201
|
+
* analysis can produce; `reconciliationStatus === 'answered'` arrives with an update and carries
|
|
6202
|
+
* `reconciliationAnswer` beside it, which is the answer itself. Requiring the tick as well would
|
|
6203
|
+
* make the reviewer hand-close seven questions the page had already answered in front of them.
|
|
6204
|
+
*
|
|
6205
|
+
* No `affected` expiry here, unlike an issue's dismissal. An issue is a claim about the code and a
|
|
6206
|
+
* dismissal is a bet on that code; a question the reviewer has answered was answered out of their
|
|
6207
|
+
* own head, and a commit somewhere near it does not un-know what they now know.
|
|
6208
|
+
*/
|
|
6209
|
+
function isQuestionOutstanding(question, answered, closedByVerdict) {
|
|
6210
|
+
return !answered.has(question.id) && question.reconciliationStatus !== "answered" && !closedByVerdict(question.id);
|
|
6211
|
+
}
|
|
6212
|
+
function tally(items, outstanding, byReviewer) {
|
|
6213
|
+
let open = 0;
|
|
6214
|
+
let reviewerClosed = 0;
|
|
6215
|
+
for (const item of items) if (outstanding(item)) open += 1;
|
|
6216
|
+
else if (byReviewer(item)) reviewerClosed += 1;
|
|
6217
|
+
return {
|
|
6218
|
+
total: items.length,
|
|
6219
|
+
open,
|
|
6220
|
+
done: items.length - open,
|
|
6221
|
+
reviewerClosed
|
|
5922
6222
|
};
|
|
5923
|
-
const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
|
|
5924
|
-
for (;;) {
|
|
5925
|
-
try {
|
|
5926
|
-
const fd = openSync(path, "wx", 384);
|
|
5927
|
-
try {
|
|
5928
|
-
writeSync(fd, JSON.stringify(mine));
|
|
5929
|
-
} finally {
|
|
5930
|
-
closeSync(fd);
|
|
5931
|
-
}
|
|
5932
|
-
break;
|
|
5933
|
-
} catch (error) {
|
|
5934
|
-
if (error.code !== "EEXIST") throw error;
|
|
5935
|
-
}
|
|
5936
|
-
if (breakIfAbandoned(path)) continue;
|
|
5937
|
-
if (Date.now() >= deadline) throw new WorkspaceWriteBusyError();
|
|
5938
|
-
Atomics.wait(sleeper, 0, 0, 2);
|
|
5939
|
-
}
|
|
5940
|
-
heldWriteLocks.set(id, 1);
|
|
5941
|
-
try {
|
|
5942
|
-
return fn();
|
|
5943
|
-
} finally {
|
|
5944
|
-
heldWriteLocks.delete(id);
|
|
5945
|
-
if (readHolder(path)?.token === mine.token) try {
|
|
5946
|
-
unlinkSync(path);
|
|
5947
|
-
} catch {}
|
|
5948
|
-
}
|
|
5949
6223
|
}
|
|
5950
|
-
function
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
6224
|
+
function issuesOutstanding(issues, dispositions) {
|
|
6225
|
+
const counts = tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && (dispositions.isClosedByVerdict(issue) || dispositions.isDismissed(issue)));
|
|
6226
|
+
const byConversation = issues.filter(resolvedByConversation).length;
|
|
6227
|
+
const byVerdict = issues.filter((issue) => issue.reconciliationStatus !== "resolved" && dispositions.isClosedByVerdict(issue)).length;
|
|
6228
|
+
return {
|
|
6229
|
+
...counts,
|
|
6230
|
+
...byConversation > 0 ? { byConversation } : {},
|
|
6231
|
+
...byVerdict > 0 ? { byVerdict } : {}
|
|
6232
|
+
};
|
|
5956
6233
|
}
|
|
5957
|
-
/**
|
|
5958
|
-
function
|
|
5959
|
-
|
|
5960
|
-
let age = 0;
|
|
5961
|
-
try {
|
|
5962
|
-
age = Date.now() - Math.min(statSync(path).mtimeMs, holder?.at ?? Infinity);
|
|
5963
|
-
} catch {
|
|
5964
|
-
return true;
|
|
5965
|
-
}
|
|
5966
|
-
if (!(age > WRITE_LOCK_STALE_MS || holder !== null && holder.host === hostname() && !processAlive(holder.pid))) return false;
|
|
5967
|
-
if (readHolder(path)?.token !== holder?.token) return true;
|
|
5968
|
-
try {
|
|
5969
|
-
unlinkSync(path);
|
|
5970
|
-
} catch {}
|
|
5971
|
-
return true;
|
|
6234
|
+
/** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
|
|
6235
|
+
function resolvedByConversation(issue) {
|
|
6236
|
+
return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
|
|
5972
6237
|
}
|
|
5973
|
-
function
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
6238
|
+
function questionsOutstanding(questions, answered, closedByVerdict) {
|
|
6239
|
+
return tally(questions, (question) => isQuestionOutstanding(question, answered, closedByVerdict), (question) => question.reconciliationStatus !== "answered" && (answered.has(question.id) || closedByVerdict(question.id)));
|
|
6240
|
+
}
|
|
6241
|
+
/**
|
|
6242
|
+
* What to call the pile of findings nobody has to act on any more.
|
|
6243
|
+
*
|
|
6244
|
+
* "Resolved" is a claim about the code and is only true while the commits did all the closing. One
|
|
6245
|
+
* dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
|
|
6246
|
+
* is acceptable did not resolve anything.
|
|
6247
|
+
*/
|
|
6248
|
+
function doneVerb(counts) {
|
|
6249
|
+
return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
|
|
5980
6250
|
}
|
|
5981
6251
|
//#endregion
|
|
5982
6252
|
//#region ../../packages/review-harness/src/workspace/queue.ts
|
|
@@ -6011,260 +6281,76 @@ var QUEUE_LOCK = "queue";
|
|
|
6011
6281
|
* `version` is not branched on today. It exists so that a later shape change is a decision rather
|
|
6012
6282
|
* than a guess about what an old file meant.
|
|
6013
6283
|
*/
|
|
6014
|
-
var VERSION$2 = 1;
|
|
6015
|
-
/**
|
|
6016
|
-
* What is queued, in order.
|
|
6017
|
-
*
|
|
6018
|
-
* Best-effort in the same way as `readPreferences`, and for the same reason: missing, unreadable
|
|
6019
|
-
* and corrupt all mean "nothing queued", and none of them is worth an error standing between the
|
|
6020
|
-
* reviewer and the list. Position is the array index throughout -- never stored per entry, because
|
|
6021
|
-
* two representations of one order eventually disagree about it.
|
|
6022
|
-
*/
|
|
6023
|
-
function readQueue() {
|
|
6024
|
-
try {
|
|
6025
|
-
const parsed = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
|
|
6026
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
6027
|
-
const ids = parsed.ids;
|
|
6028
|
-
return Array.isArray(ids) ? normalise$1(ids) : [];
|
|
6029
|
-
} catch {
|
|
6030
|
-
return [];
|
|
6031
|
-
}
|
|
6032
|
-
}
|
|
6033
|
-
/**
|
|
6034
|
-
* Change the queue as it is on disk now: read, change, write, with nothing able to land in between.
|
|
6035
|
-
*
|
|
6036
|
-
* **The only way the queue is written.** The workspace server and the CLI are separate processes
|
|
6037
|
-
* over this one file, and a `--queue` from the terminal can arrive while somebody is adding or
|
|
6038
|
-
* removing on the page. Each of them reading the queue, changing its copy and writing it back would
|
|
6039
|
-
* keep whichever wrote last and drop the other's change -- while both reported success. So the
|
|
6040
|
-
* whole read-change-write runs under the queue's cross-process lock, the same lock a review's own
|
|
6041
|
-
* writes take (see `write_lock.ts`).
|
|
6042
|
-
*
|
|
6043
|
-
* One lock, taken once, here. Every mutation below is one call to this and nothing inside `change`
|
|
6044
|
-
* writes, so the transaction is visibly this function's body and nothing depends on the lock being
|
|
6045
|
-
* re-entrant. `change` may read other files -- `queue_live.ts` asks which reviews still exist --
|
|
6046
|
-
* but must not write the queue or take another lock.
|
|
6047
|
-
*
|
|
6048
|
-
* `change` returns the queue to store, or null to store nothing, in which case the queue as it was
|
|
6049
|
-
* read is the answer. Null from this function means the write failed, including a lock held past
|
|
6050
|
-
* any real write, which is the same thing to the caller: it did not happen.
|
|
6051
|
-
*/
|
|
6052
|
-
function updateQueue(change) {
|
|
6053
|
-
try {
|
|
6054
|
-
return withWriteLock(QUEUE_LOCK, () => {
|
|
6055
|
-
const current = readQueue();
|
|
6056
|
-
const next = change(current);
|
|
6057
|
-
return next === null ? current : writeQueueUnlocked(next);
|
|
6058
|
-
});
|
|
6059
|
-
} catch {
|
|
6060
|
-
return null;
|
|
6061
|
-
}
|
|
6062
|
-
}
|
|
6063
|
-
/**
|
|
6064
|
-
* The write itself, for `updateQueue` only: the caller holds the lock.
|
|
6065
|
-
*
|
|
6066
|
-
* To a temporary file and renamed into place, because a rename is atomic and a write is not.
|
|
6067
|
-
* `writeFileSync` empties the file before it fills it, and `readQueue` reads a file it cannot parse
|
|
6068
|
-
* as "nothing queued" -- so a reader in another process that caught it half-written would take the
|
|
6069
|
-
* queue to be empty and, on its next write, make it so. Found doing exactly that: four processes
|
|
6070
|
-
* adding twelve reviews each left one in the queue. The temporary name carries the pid so two
|
|
6071
|
-
* processes never share one, although under the lock they never write at once anyway.
|
|
6072
|
-
*/
|
|
6073
|
-
function writeQueueUnlocked(ids) {
|
|
6074
|
-
const next = normalise$1(ids);
|
|
6075
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
6076
|
-
const stored = {
|
|
6077
|
-
version: VERSION$2,
|
|
6078
|
-
ids: next
|
|
6079
|
-
};
|
|
6080
|
-
const temporary = `${QUEUE_PATH}.${process.pid}.tmp`;
|
|
6081
|
-
try {
|
|
6082
|
-
writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
|
|
6083
|
-
renameSync(temporary, QUEUE_PATH);
|
|
6084
|
-
} catch (error) {
|
|
6085
|
-
try {
|
|
6086
|
-
unlinkSync(temporary);
|
|
6087
|
-
} catch {}
|
|
6088
|
-
throw error;
|
|
6089
|
-
}
|
|
6090
|
-
return next;
|
|
6091
|
-
}
|
|
6092
|
-
/**
|
|
6093
|
-
* Append, and leave a review already queued exactly where it is.
|
|
6094
|
-
*
|
|
6095
|
-
* Adding something twice is a no-op rather than a move. Nothing in this UI reorders a queue, so a
|
|
6096
|
-
* second Add that silently promoted a review would be the only control in the product that did --
|
|
6097
|
-
* and it would do it without ever saying so.
|
|
6098
|
-
*/
|
|
6099
|
-
function queueAdd(id) {
|
|
6100
|
-
return updateQueue((current) => current.includes(id) ? null : [...current, id]);
|
|
6101
|
-
}
|
|
6102
|
-
/**
|
|
6103
|
-
* Append several, in the order given, for the reviews index's bulk bar.
|
|
6104
|
-
*
|
|
6105
|
-
* The same rules as `queueAdd` for each one, in one write. The cap is where this differs: one Add
|
|
6106
|
-
* at a time never meets it in practice, but a selection can hold a hundred cards, and `normalise`
|
|
6107
|
-
* would drop everything past fifty without a word. So what does not fit is left out *here* and
|
|
6108
|
-
* named in `full`, and the caller has something true to say about it.
|
|
6109
|
-
*/
|
|
6110
|
-
function queueAddMany(ids) {
|
|
6111
|
-
let full = [];
|
|
6112
|
-
const queue = updateQueue((current) => {
|
|
6113
|
-
const fresh = ids.filter((id, at) => !current.includes(id) && ids.indexOf(id) === at);
|
|
6114
|
-
const room = Math.max(0, 50 - current.length);
|
|
6115
|
-
full = fresh.slice(room);
|
|
6116
|
-
return fresh.length <= full.length ? null : [...current, ...fresh.slice(0, room)];
|
|
6117
|
-
});
|
|
6118
|
-
return queue === null ? null : {
|
|
6119
|
-
queue,
|
|
6120
|
-
full
|
|
6121
|
-
};
|
|
6122
|
-
}
|
|
6123
|
-
function queueRemove(id) {
|
|
6124
|
-
return updateQueue((current) => current.includes(id) ? current.filter((queued) => queued !== id) : null);
|
|
6125
|
-
}
|
|
6126
|
-
/**
|
|
6127
|
-
* A token for the index's revision, equality only.
|
|
6128
|
-
*
|
|
6129
|
-
* The queue changes only when a person deliberately changes it, so unlike a running job's progress
|
|
6130
|
-
* this cannot make the page reload itself in a loop. Absent reads as a stable empty string rather
|
|
6131
|
-
* than as a change, for the reason `refreshAction` gives: a missing field that reads as "different"
|
|
6132
|
-
* is a reload that asks again and reloads again.
|
|
6133
|
-
*/
|
|
6134
|
-
function queueStamp() {
|
|
6135
|
-
try {
|
|
6136
|
-
return String(statSync(QUEUE_PATH).mtimeMs);
|
|
6137
|
-
} catch {
|
|
6138
|
-
return "";
|
|
6139
|
-
}
|
|
6140
|
-
}
|
|
6141
|
-
/** Strings only, first position wins, and never more than the cap. */
|
|
6142
|
-
function normalise$1(ids) {
|
|
6143
|
-
const seen = [];
|
|
6144
|
-
for (const id of ids) {
|
|
6145
|
-
if (typeof id !== "string" || id === "" || seen.includes(id)) continue;
|
|
6146
|
-
seen.push(id);
|
|
6147
|
-
if (seen.length === 50) break;
|
|
6148
|
-
}
|
|
6149
|
-
return seen;
|
|
6150
|
-
}
|
|
6151
|
-
//#endregion
|
|
6152
|
-
//#region ../../packages/review-harness/src/workspace/collections.ts
|
|
6153
|
-
/**
|
|
6154
|
-
* Collections: the reviewer's own groups of reviews, such as everything in one refactor.
|
|
6155
|
-
*
|
|
6156
|
-
* A collection says what belongs together and nothing else. Whether a member still needs attention
|
|
6157
|
-
* is its lifecycle, which this file never reads or writes: assigning, removing, renaming and
|
|
6158
|
-
* deleting all leave every review's state exactly where it was.
|
|
6159
|
-
*
|
|
6160
|
-
* **Members are lineage keys, not review ids.** A card is a branch, and analysing the branch again
|
|
6161
|
-
* makes a new record with a new id. The queue stores ids and has to forward them (`forwardQueue`);
|
|
6162
|
-
* a lineage key is already what the card stands for, so membership survives updates, re-analysis
|
|
6163
|
-
* and restarts with nothing to keep in step.
|
|
6164
|
-
*
|
|
6165
|
-
* **Its own file**, for the reasons `queue.ts` gives: a record is rewritten whole by whoever loaded
|
|
6166
|
-
* it last and carries its diff inline, and `ui.json` is best-effort. And because a write here never
|
|
6167
|
-
* touches a record, it never moves the record's mtime, which is the clock retention runs on.
|
|
6168
|
-
* Organising a review is not opening or marking it.
|
|
6169
|
-
*
|
|
6170
|
-
* `STORE_ROOT` from `store_root.ts` rather than `store.ts`, for the import cycle `queue.ts` explains:
|
|
6171
|
-
* the prune in `store.ts` reads this file.
|
|
6172
|
-
*/
|
|
6173
|
-
var COLLECTIONS_PATH = join(STORE_ROOT, "collections.json");
|
|
6174
|
-
/** The lock's name. Reviews are locked under UUIDs in the same directory, so a word cannot collide. */
|
|
6175
|
-
var COLLECTIONS_LOCK = "collections";
|
|
6176
6284
|
var VERSION$1 = 1;
|
|
6177
|
-
var ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
6178
|
-
/** Whether a value is shaped like a collection's id, which is what a fold key and a route accept. */
|
|
6179
|
-
function isCollectionId(value) {
|
|
6180
|
-
return typeof value === "string" && ID_PATTERN.test(value);
|
|
6181
|
-
}
|
|
6182
6285
|
/**
|
|
6183
|
-
*
|
|
6286
|
+
* What is queued, in order.
|
|
6184
6287
|
*
|
|
6185
|
-
* Best-effort
|
|
6186
|
-
*
|
|
6187
|
-
*
|
|
6288
|
+
* Best-effort in the same way as `readPreferences`, and for the same reason: missing, unreadable
|
|
6289
|
+
* and corrupt all mean "nothing queued", and none of them is worth an error standing between the
|
|
6290
|
+
* reviewer and the list. Position is the array index throughout -- never stored per entry, because
|
|
6291
|
+
* two representations of one order eventually disagree about it.
|
|
6188
6292
|
*/
|
|
6189
|
-
function
|
|
6293
|
+
function readQueue() {
|
|
6190
6294
|
try {
|
|
6191
|
-
const parsed = JSON.parse(readFileSync(
|
|
6295
|
+
const parsed = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
|
|
6192
6296
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
6193
|
-
const
|
|
6194
|
-
return Array.isArray(
|
|
6297
|
+
const ids = parsed.ids;
|
|
6298
|
+
return Array.isArray(ids) ? normalise(ids) : [];
|
|
6195
6299
|
} catch {
|
|
6196
6300
|
return [];
|
|
6197
6301
|
}
|
|
6198
6302
|
}
|
|
6199
|
-
function normalise(raw) {
|
|
6200
|
-
const kept = [];
|
|
6201
|
-
for (const entry of raw) {
|
|
6202
|
-
if (kept.length >= 100) break;
|
|
6203
|
-
if (!entry || typeof entry !== "object") continue;
|
|
6204
|
-
const { id, name, colour, createdAt, members } = entry;
|
|
6205
|
-
const clean = typeof name === "string" ? cleanName(name) : "";
|
|
6206
|
-
if (!isCollectionId(id) || clean === "" || clean.length > 60) continue;
|
|
6207
|
-
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) continue;
|
|
6208
|
-
if (kept.some((other) => other.id === id || sameName(other.name, clean))) continue;
|
|
6209
|
-
const keys = Array.isArray(members) ? members.filter((key) => typeof key === "string" && key !== "") : [];
|
|
6210
|
-
kept.push({
|
|
6211
|
-
id,
|
|
6212
|
-
name: clean,
|
|
6213
|
-
...typeof colour === "string" && colour !== "" ? { colour } : {},
|
|
6214
|
-
createdAt,
|
|
6215
|
-
members: [...new Set(keys)].slice(0, 500)
|
|
6216
|
-
});
|
|
6217
|
-
}
|
|
6218
|
-
return kept;
|
|
6219
|
-
}
|
|
6220
|
-
/** Spaces trimmed and runs of them collapsed, so " Release 0.82 " is "Release 0.82". */
|
|
6221
|
-
function cleanName(name) {
|
|
6222
|
-
return name.trim().replace(/\s+/g, " ");
|
|
6223
|
-
}
|
|
6224
|
-
/** Two names are one collection's name regardless of case: "refactor" is not a second Refactor. */
|
|
6225
|
-
function sameName(a, b) {
|
|
6226
|
-
return a.localeCompare(b, void 0, { sensitivity: "base" }) === 0;
|
|
6227
|
-
}
|
|
6228
|
-
function nameProblem(name, collections, except) {
|
|
6229
|
-
const clean = cleanName(name);
|
|
6230
|
-
if (clean === "") return "empty";
|
|
6231
|
-
if (clean.length > 60) return "too-long";
|
|
6232
|
-
if (collections.some((collection) => collection.id !== except && sameName(collection.name, clean))) return "duplicate";
|
|
6233
|
-
}
|
|
6234
|
-
/** What a refused change says, in the words the page shows. */
|
|
6235
|
-
function nameProblemText(problem) {
|
|
6236
|
-
if (problem === "empty") return "Give the collection a name.";
|
|
6237
|
-
if (problem === "too-long") return `Keep the name under 61 characters.`;
|
|
6238
|
-
return "A collection with that name already exists.";
|
|
6239
|
-
}
|
|
6240
6303
|
/**
|
|
6241
|
-
* Change the
|
|
6242
|
-
*
|
|
6243
|
-
*
|
|
6304
|
+
* Change the queue as it is on disk now: read, change, write, with nothing able to land in between.
|
|
6305
|
+
*
|
|
6306
|
+
* **The only way the queue is written.** The workspace server and the CLI are separate processes
|
|
6307
|
+
* over this one file, and a `--queue` from the terminal can arrive while somebody is adding or
|
|
6308
|
+
* removing on the page. Each of them reading the queue, changing its copy and writing it back would
|
|
6309
|
+
* keep whichever wrote last and drop the other's change -- while both reported success. So the
|
|
6310
|
+
* whole read-change-write runs under the queue's cross-process lock, the same lock a review's own
|
|
6311
|
+
* writes take (see `write_lock.ts`).
|
|
6312
|
+
*
|
|
6313
|
+
* One lock, taken once, here. Every mutation below is one call to this and nothing inside `change`
|
|
6314
|
+
* writes, so the transaction is visibly this function's body and nothing depends on the lock being
|
|
6315
|
+
* re-entrant. `change` may read other files -- `queue_live.ts` asks which reviews still exist --
|
|
6316
|
+
* but must not write the queue or take another lock.
|
|
6317
|
+
*
|
|
6318
|
+
* `change` returns the queue to store, or null to store nothing, in which case the queue as it was
|
|
6319
|
+
* read is the answer. Null from this function means the write failed, including a lock held past
|
|
6320
|
+
* any real write, which is the same thing to the caller: it did not happen.
|
|
6244
6321
|
*/
|
|
6245
|
-
function
|
|
6322
|
+
function updateQueue(change) {
|
|
6246
6323
|
try {
|
|
6247
|
-
return withWriteLock(
|
|
6248
|
-
const current =
|
|
6324
|
+
return withWriteLock(QUEUE_LOCK, () => {
|
|
6325
|
+
const current = readQueue();
|
|
6249
6326
|
const next = change(current);
|
|
6250
|
-
return next === null ? current :
|
|
6327
|
+
return next === null ? current : writeQueueUnlocked(next);
|
|
6251
6328
|
});
|
|
6252
6329
|
} catch {
|
|
6253
6330
|
return null;
|
|
6254
6331
|
}
|
|
6255
6332
|
}
|
|
6256
|
-
/**
|
|
6257
|
-
|
|
6258
|
-
|
|
6333
|
+
/**
|
|
6334
|
+
* The write itself, for `updateQueue` only: the caller holds the lock.
|
|
6335
|
+
*
|
|
6336
|
+
* To a temporary file and renamed into place, because a rename is atomic and a write is not.
|
|
6337
|
+
* `writeFileSync` empties the file before it fills it, and `readQueue` reads a file it cannot parse
|
|
6338
|
+
* as "nothing queued" -- so a reader in another process that caught it half-written would take the
|
|
6339
|
+
* queue to be empty and, on its next write, make it so. Found doing exactly that: four processes
|
|
6340
|
+
* adding twelve reviews each left one in the queue. The temporary name carries the pid so two
|
|
6341
|
+
* processes never share one, although under the lock they never write at once anyway.
|
|
6342
|
+
*/
|
|
6343
|
+
function writeQueueUnlocked(ids) {
|
|
6344
|
+
const next = normalise(ids);
|
|
6259
6345
|
mkdirSync(STORE_ROOT, { recursive: true });
|
|
6260
6346
|
const stored = {
|
|
6261
6347
|
version: VERSION$1,
|
|
6262
|
-
|
|
6348
|
+
ids: next
|
|
6263
6349
|
};
|
|
6264
|
-
const temporary = `${
|
|
6350
|
+
const temporary = `${QUEUE_PATH}.${process.pid}.tmp`;
|
|
6265
6351
|
try {
|
|
6266
6352
|
writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
|
|
6267
|
-
renameSync(temporary,
|
|
6353
|
+
renameSync(temporary, QUEUE_PATH);
|
|
6268
6354
|
} catch (error) {
|
|
6269
6355
|
try {
|
|
6270
6356
|
unlinkSync(temporary);
|
|
@@ -6273,143 +6359,65 @@ function writeUnlocked(collections) {
|
|
|
6273
6359
|
}
|
|
6274
6360
|
return next;
|
|
6275
6361
|
}
|
|
6276
|
-
function run$1(change) {
|
|
6277
|
-
const outcome = {};
|
|
6278
|
-
const stored = updateCollections((current) => {
|
|
6279
|
-
const result = change(current);
|
|
6280
|
-
if ("refused" in result) {
|
|
6281
|
-
outcome.refused = result.refused;
|
|
6282
|
-
return null;
|
|
6283
|
-
}
|
|
6284
|
-
outcome.id = result.id;
|
|
6285
|
-
return result.next;
|
|
6286
|
-
});
|
|
6287
|
-
if (outcome.refused !== void 0) return {
|
|
6288
|
-
ok: false,
|
|
6289
|
-
reason: outcome.refused
|
|
6290
|
-
};
|
|
6291
|
-
if (stored === null) return {
|
|
6292
|
-
ok: false,
|
|
6293
|
-
reason: "unsaved"
|
|
6294
|
-
};
|
|
6295
|
-
return {
|
|
6296
|
-
ok: true,
|
|
6297
|
-
collections: stored,
|
|
6298
|
-
...outcome.id === void 0 ? {} : { id: outcome.id }
|
|
6299
|
-
};
|
|
6300
|
-
}
|
|
6301
|
-
function createCollection(name, now = Date.now()) {
|
|
6302
|
-
return run$1((current) => {
|
|
6303
|
-
const problem = nameProblem(name, current);
|
|
6304
|
-
if (problem) return { refused: problem };
|
|
6305
|
-
if (current.length >= 100) return { refused: "full" };
|
|
6306
|
-
const id = randomUUID();
|
|
6307
|
-
return {
|
|
6308
|
-
next: [...current, {
|
|
6309
|
-
id,
|
|
6310
|
-
name: cleanName(name),
|
|
6311
|
-
createdAt: now,
|
|
6312
|
-
members: []
|
|
6313
|
-
}],
|
|
6314
|
-
id
|
|
6315
|
-
};
|
|
6316
|
-
});
|
|
6317
|
-
}
|
|
6318
6362
|
/**
|
|
6319
|
-
*
|
|
6320
|
-
*
|
|
6321
|
-
*
|
|
6363
|
+
* Append, and leave a review already queued exactly where it is.
|
|
6364
|
+
*
|
|
6365
|
+
* Adding something twice is a no-op rather than a move. Nothing in this UI reorders a queue, so a
|
|
6366
|
+
* second Add that silently promoted a review would be the only control in the product that did --
|
|
6367
|
+
* and it would do it without ever saying so.
|
|
6322
6368
|
*/
|
|
6323
|
-
function
|
|
6324
|
-
|
|
6325
|
-
return run$1((current) => {
|
|
6326
|
-
const problem = nameProblem(name, current);
|
|
6327
|
-
if (problem) return { refused: problem };
|
|
6328
|
-
if (current.length >= 100 || members.length > 500) return { refused: "full" };
|
|
6329
|
-
const id = randomUUID();
|
|
6330
|
-
return {
|
|
6331
|
-
next: [...current, {
|
|
6332
|
-
id,
|
|
6333
|
-
name: cleanName(name),
|
|
6334
|
-
createdAt: now,
|
|
6335
|
-
members
|
|
6336
|
-
}],
|
|
6337
|
-
id
|
|
6338
|
-
};
|
|
6339
|
-
});
|
|
6340
|
-
}
|
|
6341
|
-
/** A new name on the same collection: its id, its members and every fold kept against the id stay. */
|
|
6342
|
-
function renameCollection(id, name) {
|
|
6343
|
-
return run$1((current) => {
|
|
6344
|
-
if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
|
|
6345
|
-
const problem = nameProblem(name, current, id);
|
|
6346
|
-
if (problem) return { refused: problem };
|
|
6347
|
-
return {
|
|
6348
|
-
next: current.map((collection) => collection.id === id ? {
|
|
6349
|
-
...collection,
|
|
6350
|
-
name: cleanName(name)
|
|
6351
|
-
} : collection),
|
|
6352
|
-
id
|
|
6353
|
-
};
|
|
6354
|
-
});
|
|
6355
|
-
}
|
|
6356
|
-
/** The collection and its memberships go. No review is touched: they were never inside it. */
|
|
6357
|
-
function deleteCollection(id) {
|
|
6358
|
-
return run$1((current) => {
|
|
6359
|
-
if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
|
|
6360
|
-
return { next: current.filter((collection) => collection.id !== id) };
|
|
6361
|
-
});
|
|
6369
|
+
function queueAdd(id) {
|
|
6370
|
+
return updateQueue((current) => current.includes(id) ? null : [...current, id]);
|
|
6362
6371
|
}
|
|
6363
6372
|
/**
|
|
6364
|
-
*
|
|
6373
|
+
* Append several, in the order given, for the reviews index's bulk bar.
|
|
6365
6374
|
*
|
|
6366
|
-
*
|
|
6367
|
-
*
|
|
6368
|
-
*
|
|
6369
|
-
*
|
|
6375
|
+
* The same rules as `queueAdd` for each one, in one write. The cap is where this differs: one Add
|
|
6376
|
+
* at a time never meets it in practice, but a selection can hold a hundred cards, and `normalise`
|
|
6377
|
+
* would drop everything past fifty without a word. So what does not fit is left out *here* and
|
|
6378
|
+
* named in `full`, and the caller has something true to say about it.
|
|
6370
6379
|
*/
|
|
6371
|
-
function
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
const
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
id
|
|
6379
|
-
};
|
|
6380
|
-
if (members.length > 500) return { refused: "full" };
|
|
6381
|
-
return {
|
|
6382
|
-
next: current.map((collection) => collection.id === id ? {
|
|
6383
|
-
...collection,
|
|
6384
|
-
members
|
|
6385
|
-
} : collection),
|
|
6386
|
-
id
|
|
6387
|
-
};
|
|
6380
|
+
function queueAddMany(ids) {
|
|
6381
|
+
let full = [];
|
|
6382
|
+
const queue = updateQueue((current) => {
|
|
6383
|
+
const fresh = ids.filter((id, at) => !current.includes(id) && ids.indexOf(id) === at);
|
|
6384
|
+
const room = Math.max(0, 50 - current.length);
|
|
6385
|
+
full = fresh.slice(room);
|
|
6386
|
+
return fresh.length <= full.length ? null : [...current, ...fresh.slice(0, room)];
|
|
6388
6387
|
});
|
|
6388
|
+
return queue === null ? null : {
|
|
6389
|
+
queue,
|
|
6390
|
+
full
|
|
6391
|
+
};
|
|
6392
|
+
}
|
|
6393
|
+
function queueRemove(id) {
|
|
6394
|
+
return updateQueue((current) => current.includes(id) ? current.filter((queued) => queued !== id) : null);
|
|
6389
6395
|
}
|
|
6390
6396
|
/**
|
|
6391
|
-
*
|
|
6392
|
-
*
|
|
6397
|
+
* A token for the index's revision, equality only.
|
|
6398
|
+
*
|
|
6399
|
+
* The queue changes only when a person deliberately changes it, so unlike a running job's progress
|
|
6400
|
+
* this cannot make the page reload itself in a loop. Absent reads as a stable empty string rather
|
|
6401
|
+
* than as a change, for the reason `refreshAction` gives: a missing field that reads as "different"
|
|
6402
|
+
* is a reload that asks again and reloads again.
|
|
6393
6403
|
*/
|
|
6394
|
-
function
|
|
6395
|
-
const gone = new Set(keys);
|
|
6396
|
-
return updateCollections((current) => current.some((collection) => collection.members.some((key) => gone.has(key))) ? current.map((collection) => ({
|
|
6397
|
-
...collection,
|
|
6398
|
-
members: collection.members.filter((key) => !gone.has(key))
|
|
6399
|
-
})) : null);
|
|
6400
|
-
}
|
|
6401
|
-
/** Every lineage in at least one collection. What retention asks: collected is kept. */
|
|
6402
|
-
function collectedKeys(collections = readCollections()) {
|
|
6403
|
-
return new Set(collections.flatMap((collection) => collection.members));
|
|
6404
|
-
}
|
|
6405
|
-
/** A token for the index's revision, as `queueStamp` is: it changes only when a person changes it. */
|
|
6406
|
-
function collectionsStamp() {
|
|
6404
|
+
function queueStamp() {
|
|
6407
6405
|
try {
|
|
6408
|
-
return String(statSync(
|
|
6406
|
+
return String(statSync(QUEUE_PATH).mtimeMs);
|
|
6409
6407
|
} catch {
|
|
6410
6408
|
return "";
|
|
6411
6409
|
}
|
|
6412
6410
|
}
|
|
6411
|
+
/** Strings only, first position wins, and never more than the cap. */
|
|
6412
|
+
function normalise(ids) {
|
|
6413
|
+
const seen = [];
|
|
6414
|
+
for (const id of ids) {
|
|
6415
|
+
if (typeof id !== "string" || id === "" || seen.includes(id)) continue;
|
|
6416
|
+
seen.push(id);
|
|
6417
|
+
if (seen.length === 50) break;
|
|
6418
|
+
}
|
|
6419
|
+
return seen;
|
|
6420
|
+
}
|
|
6413
6421
|
//#endregion
|
|
6414
6422
|
//#region ../../packages/review-harness/src/workspace/lifecycle.ts
|
|
6415
6423
|
/**
|
|
@@ -6458,7 +6466,7 @@ function lifecycleAction(lifecycle) {
|
|
|
6458
6466
|
return LIFECYCLES.find((entry) => entry.id === lifecycle).action;
|
|
6459
6467
|
}
|
|
6460
6468
|
/** A lifecycle's name, which is what its pill says on the card and on the review page. */
|
|
6461
|
-
function lifecycleName(lifecycle) {
|
|
6469
|
+
function lifecycleName$1(lifecycle) {
|
|
6462
6470
|
return LIFECYCLES.find((entry) => entry.id === (lifecycle?.state ?? "active")).name;
|
|
6463
6471
|
}
|
|
6464
6472
|
/**
|
|
@@ -6475,7 +6483,7 @@ function lifecyclePillAction(lifecycle) {
|
|
|
6475
6483
|
/** The pill's accessible name: the state it shows, then what pressing it does. */
|
|
6476
6484
|
function lifecyclePillLabel(lifecycle) {
|
|
6477
6485
|
const action = lifecyclePillAction(lifecycle);
|
|
6478
|
-
return `${lifecycleName(lifecycle)}. ${action === "menu" ? "Choose what happens next" : lifecycleAction(action)}`;
|
|
6486
|
+
return `${lifecycleName$1(lifecycle)}. ${action === "menu" ? "Choose what happens next" : lifecycleAction(action)}`;
|
|
6479
6487
|
}
|
|
6480
6488
|
var ACTIVITY_LABELS = {
|
|
6481
6489
|
created: "Created",
|
|
@@ -7741,9 +7749,34 @@ var UPDATE_ORDER = [
|
|
|
7741
7749
|
"context",
|
|
7742
7750
|
"analysing"
|
|
7743
7751
|
];
|
|
7752
|
+
/**
|
|
7753
|
+
* The same phases, as a Re-check of the pull request discussion runs them. Nothing is fetched, so
|
|
7754
|
+
* there is no `preparing` step to show, and the checkout is of the commit the review read.
|
|
7755
|
+
*/
|
|
7756
|
+
var RECHECK_PHASES = {
|
|
7757
|
+
...UPDATE_PHASES,
|
|
7758
|
+
conversation: {
|
|
7759
|
+
doing: "Fetching the PR discussion",
|
|
7760
|
+
done: "Fetched the PR discussion"
|
|
7761
|
+
},
|
|
7762
|
+
context: {
|
|
7763
|
+
doing: "Preparing the reviewed code",
|
|
7764
|
+
done: "Prepared the reviewed code"
|
|
7765
|
+
},
|
|
7766
|
+
analysing: {
|
|
7767
|
+
doing: "Reassessing issues and questions",
|
|
7768
|
+
done: "Reassessed issues and questions"
|
|
7769
|
+
}
|
|
7770
|
+
};
|
|
7771
|
+
var RECHECK_ORDER = [
|
|
7772
|
+
"conversation",
|
|
7773
|
+
"context",
|
|
7774
|
+
"analysing"
|
|
7775
|
+
];
|
|
7744
7776
|
/** The names this job's phases go by. */
|
|
7745
7777
|
function phasesFor(job) {
|
|
7746
|
-
|
|
7778
|
+
if (purposeOf(job) !== "update") return PHASES;
|
|
7779
|
+
return job.scope === "conversation" ? RECHECK_PHASES : UPDATE_PHASES;
|
|
7747
7780
|
}
|
|
7748
7781
|
/**
|
|
7749
7782
|
* Whether a phase is part of *this* review, rather than part of the pipeline in general.
|
|
@@ -7783,7 +7816,7 @@ function progressSteps(job) {
|
|
|
7783
7816
|
const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
|
|
7784
7817
|
const update = purposeOf(job) === "update";
|
|
7785
7818
|
const names = phasesFor(job);
|
|
7786
|
-
const order = (update ? UPDATE_ORDER : WORK_PHASES).filter((phase) => appliesTo(job, phase));
|
|
7819
|
+
const order = (update ? job.scope === "conversation" ? RECHECK_ORDER : UPDATE_ORDER : WORK_PHASES).filter((phase) => appliesTo(job, phase));
|
|
7787
7820
|
return (update && job.phase === "done" ? order.filter((phase) => job.completed.includes(phase)) : order).map((phase) => {
|
|
7788
7821
|
const complete = job.completed.includes(phase) || !update && job.phase === "done";
|
|
7789
7822
|
const failed = failedAt === phase;
|
|
@@ -7876,6 +7909,7 @@ function strippedForDeletedReview(job) {
|
|
|
7876
7909
|
},
|
|
7877
7910
|
...job.purpose ? { purpose: job.purpose } : {},
|
|
7878
7911
|
...job.reanalysis ? { reanalysis: true } : {},
|
|
7912
|
+
...job.scope ? { scope: job.scope } : {},
|
|
7879
7913
|
...job.reviewType ? { reviewType: job.reviewType } : {},
|
|
7880
7914
|
phase: job.phase,
|
|
7881
7915
|
startedAt: job.startedAt,
|
|
@@ -17050,7 +17084,7 @@ function send(record) {
|
|
|
17050
17084
|
* a yes it can trust, which is a 2xx and nothing else. Asks the same question `send` does before
|
|
17051
17085
|
* anything goes over the wire.
|
|
17052
17086
|
*/
|
|
17053
|
-
async function deliver(record) {
|
|
17087
|
+
async function deliver$1(record) {
|
|
17054
17088
|
try {
|
|
17055
17089
|
if (!telemetryUploadEnabled()) return false;
|
|
17056
17090
|
const payload = project(record);
|
|
@@ -18774,6 +18808,13 @@ function countUpdateChanges(previous, result) {
|
|
|
18774
18808
|
...reopened ? { reopened } : {}
|
|
18775
18809
|
};
|
|
18776
18810
|
}
|
|
18811
|
+
/**
|
|
18812
|
+
* Whether a reconciliation moved any finding or put anything to the reviewer. Still-open findings
|
|
18813
|
+
* are the ones it left alone, so they are the one count that is not a change.
|
|
18814
|
+
*/
|
|
18815
|
+
function findingsChanged(counts) {
|
|
18816
|
+
return counts.resolved + counts.affected + counts.newIssues + counts.questionsAnswered + (counts.reopened ?? 0) + (counts.gradeChanges ?? 0) + (counts.decisionsChallenged ?? 0) > 0;
|
|
18817
|
+
}
|
|
18777
18818
|
//#endregion
|
|
18778
18819
|
//#region ../../packages/review-harness/src/domain/ai_review/update_reconciliation.ts
|
|
18779
18820
|
function reconciledAgainstOf(basis) {
|
|
@@ -19099,8 +19140,93 @@ async function updateReview(workspace, runners = {}) {
|
|
|
19099
19140
|
if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
|
|
19100
19141
|
const parked = parkedUpdateRefusal(workspace);
|
|
19101
19142
|
if (parked) return refuse(reviewedSha, false, parked);
|
|
19143
|
+
let fetchStartedAt = 0;
|
|
19144
|
+
return driveUpdateJob(workspace, runners, reviewedSha, {
|
|
19145
|
+
startedAt,
|
|
19146
|
+
now
|
|
19147
|
+
}, void 0, async (held, step, signal, clock) => {
|
|
19148
|
+
fetchStartedAt = Date.now();
|
|
19149
|
+
const outcome = await bringForward(workspace, runners, reviewedSha, held, step, signal, clock);
|
|
19150
|
+
if (!signal.aborted && outcome.ok && !outcome.fetchFailed && outcome.toSha) workspace.headSeenAt = fetchStartedAt;
|
|
19151
|
+
return outcome;
|
|
19152
|
+
});
|
|
19153
|
+
}
|
|
19154
|
+
/**
|
|
19155
|
+
* Reconcile the review's findings with its pull request discussion as it is now, at the commit the
|
|
19156
|
+
* review read: the Re-check button.
|
|
19157
|
+
*
|
|
19158
|
+
* The discussion half of an update and nothing else. The conversation is re-read exactly as an
|
|
19159
|
+
* update re-reads it, so every "already raised" line is corrected first; then, when something in it
|
|
19160
|
+
* is new to the analysis, the same reconciliation an update runs is asked of the same agent with no
|
|
19161
|
+
* code delta. It may answer a question, resolve or reopen an issue on the evidence the discussion
|
|
19162
|
+
* rules allow, suggest a grade, or raise a concern as a new issue once it has confirmed it in the
|
|
19163
|
+
* reviewed code. It never fetches and never moves the review to a newer commit: that is an update's,
|
|
19164
|
+
* and the review's commit stays where it was so the next update still sees those commits as new.
|
|
19165
|
+
*
|
|
19166
|
+
* Computes and mutates in memory, like `updateReview`; the route saves.
|
|
19167
|
+
*/
|
|
19168
|
+
async function recheckConversation(workspace, runners = {}) {
|
|
19169
|
+
const now = runners.now ?? Date.now;
|
|
19170
|
+
const startedAt = now();
|
|
19171
|
+
const reviewedSha = workspace.session.metadata?.commitSha || null;
|
|
19172
|
+
const recheck = (outcome) => ({
|
|
19173
|
+
...outcome,
|
|
19174
|
+
scope: "conversation"
|
|
19175
|
+
});
|
|
19176
|
+
if (!workspace.prContext) return recheck(refuse(reviewedSha, false, "This review has no pull request conversation attached, so there is nothing to re-read."));
|
|
19177
|
+
const cannotReassess = parkedUpdateRefusal(workspace) ?? (!workspace.session.result || !reviewedSha ? "This review has no analysis to reassess." : null);
|
|
19178
|
+
if (cannotReassess) {
|
|
19179
|
+
adoptLegacyBaseline(workspace);
|
|
19180
|
+
const conversationOk = await reReadConversation(workspace, runners.runRefresh);
|
|
19181
|
+
return recheck(settled(reviewedSha, reviewedSha, false, conversationOk, "refused", conversationOk ? cannotReassess : rereadFailureOf(workspace)));
|
|
19182
|
+
}
|
|
19183
|
+
return driveUpdateJob(workspace, runners, reviewedSha, {
|
|
19184
|
+
startedAt,
|
|
19185
|
+
now
|
|
19186
|
+
}, "conversation", async (held, step, signal, clock) => recheck(await reconcileConversation(workspace, runners, reviewedSha, held, step, signal, clock)));
|
|
19187
|
+
}
|
|
19188
|
+
/** What a failed re-read said, for an outcome that reports it. */
|
|
19189
|
+
function rereadFailureOf(workspace) {
|
|
19190
|
+
return workspace.lastPrRefresh && !workspace.lastPrRefresh.ok ? workspace.lastPrRefresh.message : "The pull request conversation could not be re-read. Nothing here has changed.";
|
|
19191
|
+
}
|
|
19192
|
+
/** Said when the conversation holds nothing the findings have not already been weighed against. */
|
|
19193
|
+
var NOTHING_NEW_IN_DISCUSSION = "Nothing in the PR discussion is new since the findings were last assessed, so no findings changed.";
|
|
19194
|
+
async function reconcileConversation(workspace, runners, reviewedSha, held, step, signal, clock) {
|
|
19195
|
+
adoptLegacyBaseline(workspace);
|
|
19196
|
+
step("conversation");
|
|
19197
|
+
const conversationOk = await reReadConversation(workspace, runners.runRefresh);
|
|
19198
|
+
signal.throwIfAborted();
|
|
19199
|
+
if (!conversationOk) return settled(reviewedSha, reviewedSha, false, false, "refused", rereadFailureOf(workspace));
|
|
19200
|
+
const { prepared } = await prepareConversation(workspace, {
|
|
19201
|
+
reread: false,
|
|
19202
|
+
...workspace.lastPrRefresh ? { rereadOutcome: workspace.lastPrRefresh } : {}
|
|
19203
|
+
});
|
|
19204
|
+
if (!conversationMoved(prepared)) return settled(reviewedSha, reviewedSha, false, true, "already-current", NOTHING_NEW_IN_DISCUSSION);
|
|
19205
|
+
if (!await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, reviewedSha, false, true, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
19206
|
+
step("context");
|
|
19207
|
+
let worktreePath;
|
|
19208
|
+
try {
|
|
19209
|
+
({path: worktreePath} = await pinCheckout(workspace, reviewedSha, held));
|
|
19210
|
+
} catch (error) {
|
|
19211
|
+
if (error instanceof ReviewBeingDeletedError) throw error;
|
|
19212
|
+
return settled(reviewedSha, reviewedSha, false, true, "analysis-failed", "A checkout of the reviewed commit could not be prepared, so the findings were not reassessed.");
|
|
19213
|
+
}
|
|
19214
|
+
signal.throwIfAborted();
|
|
19215
|
+
return reconcile(workspace, runners, reviewedSha, {
|
|
19216
|
+
sha: reviewedSha,
|
|
19217
|
+
fetchFailed: false
|
|
19218
|
+
}, true, worktreePath, step, held.job, signal, clock, prepared, true, "conversation");
|
|
19219
|
+
}
|
|
19220
|
+
/**
|
|
19221
|
+
* The life of an update's job, whatever the update does inside it: the claim, the cancel, the steps,
|
|
19222
|
+
* how it ends and what it announces. Shared by Update and Re-check so the two cannot disagree about
|
|
19223
|
+
* how a run is watched, cancelled or reported.
|
|
19224
|
+
*/
|
|
19225
|
+
async function driveUpdateJob(workspace, runners, reviewedSha, clock, scope, work) {
|
|
19226
|
+
const { startedAt, now } = clock;
|
|
19102
19227
|
const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
|
|
19103
19228
|
purpose: "update",
|
|
19229
|
+
...scope ? { scope } : {},
|
|
19104
19230
|
...runners.source ? { source: runners.source } : {},
|
|
19105
19231
|
...workspace.prContext ? { conversation: {
|
|
19106
19232
|
kind: "attached",
|
|
@@ -19114,16 +19240,11 @@ async function updateReview(workspace, runners = {}) {
|
|
|
19114
19240
|
const current = () => loadJob(held.job.id) ?? held.job;
|
|
19115
19241
|
const step = (phase) => saveJob(advance(current(), phase));
|
|
19116
19242
|
try {
|
|
19117
|
-
const
|
|
19118
|
-
|
|
19119
|
-
|
|
19120
|
-
now
|
|
19121
|
-
});
|
|
19122
|
-
if (signal.aborted) return cancelled(held, reviewedSha);
|
|
19123
|
-
if (outcome.ok && !outcome.fetchFailed && outcome.toSha) workspace.headSeenAt = fetchStartedAt;
|
|
19124
|
-
const ended = outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message);
|
|
19243
|
+
const outcome = await work(held, step, signal, clock);
|
|
19244
|
+
if (signal.aborted) return scoped(cancelled(held, reviewedSha), scope);
|
|
19245
|
+
const ended = outcome.stop === "analysis-failed" || scope && outcome.conversationOk === false ? fail(current(), outcome.message) : advance(current(), "done", outcome.message);
|
|
19125
19246
|
saveJob(ended);
|
|
19126
|
-
if (outcome.ok && outcome.stop === null) {
|
|
19247
|
+
if (!scope && outcome.ok && outcome.stop === null) {
|
|
19127
19248
|
const completedAt = now();
|
|
19128
19249
|
recordRuntimeBestEffort({
|
|
19129
19250
|
operation: "update",
|
|
@@ -19139,7 +19260,7 @@ async function updateReview(workspace, runners = {}) {
|
|
|
19139
19260
|
announceJob(ended, updateFacts(outcome, workspace));
|
|
19140
19261
|
return outcome;
|
|
19141
19262
|
} catch (error) {
|
|
19142
|
-
if (signal.aborted) return cancelled(held, reviewedSha);
|
|
19263
|
+
if (signal.aborted) return scoped(cancelled(held, reviewedSha), scope);
|
|
19143
19264
|
const failed = fail(current(), error instanceof Error ? error.message : String(error));
|
|
19144
19265
|
saveJob(failed);
|
|
19145
19266
|
announceJob(failed);
|
|
@@ -19149,6 +19270,12 @@ async function updateReview(workspace, runners = {}) {
|
|
|
19149
19270
|
held.claim.release();
|
|
19150
19271
|
}
|
|
19151
19272
|
}
|
|
19273
|
+
function scoped(outcome, scope) {
|
|
19274
|
+
return scope ? {
|
|
19275
|
+
...outcome,
|
|
19276
|
+
scope
|
|
19277
|
+
} : outcome;
|
|
19278
|
+
}
|
|
19152
19279
|
/**
|
|
19153
19280
|
* The reviewer cancelled. Recorded as a stop, not a failure, so nothing announces it or offers a
|
|
19154
19281
|
* retry, and answered as an outcome the page reports on the review it left untouched.
|
|
@@ -19192,7 +19319,7 @@ async function bringForward(workspace, runners, reviewedSha, held, step, signal,
|
|
|
19192
19319
|
signal.throwIfAborted();
|
|
19193
19320
|
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job, signal, clock, prepared, discussionMoved);
|
|
19194
19321
|
}
|
|
19195
|
-
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, job, signal, clock, prepared, discussionMoved) {
|
|
19322
|
+
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, job, signal, clock, prepared, discussionMoved, scope) {
|
|
19196
19323
|
const { id: jobId, env } = job;
|
|
19197
19324
|
const newHead = remote.sha;
|
|
19198
19325
|
let delta = null;
|
|
@@ -19227,7 +19354,7 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
|
|
|
19227
19354
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
19228
19355
|
let raw;
|
|
19229
19356
|
try {
|
|
19230
|
-
const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath);
|
|
19357
|
+
const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath, scope);
|
|
19231
19358
|
raw = runners.run ? await runners.run(prompt, worktreePath, env, onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, env, onProgress, requestedModel, onObservedModel, signal);
|
|
19232
19359
|
} catch (error) {
|
|
19233
19360
|
return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, env, requestedModel));
|
|
@@ -19444,11 +19571,36 @@ function mergeUpdateInto(fresh, updated) {
|
|
|
19444
19571
|
for (const field of UPDATE_OWNED_FIELDS) if (field in updated) target[field] = source[field];
|
|
19445
19572
|
else delete target[field];
|
|
19446
19573
|
merged.session = keepReviewerFields(fresh, updated.session);
|
|
19574
|
+
keepNewerConversation(merged, fresh, updated);
|
|
19447
19575
|
carryUpdateSuggestions(merged, updated);
|
|
19448
19576
|
dropStaleChangeMap(merged);
|
|
19449
19577
|
return merged;
|
|
19450
19578
|
}
|
|
19451
19579
|
/**
|
|
19580
|
+
* A conversation re-read that landed on disk while this ran, kept rather than put back.
|
|
19581
|
+
*
|
|
19582
|
+
* An Ask turn re-reads the conversation and saves it, and an update holds its copy for minutes. Laid
|
|
19583
|
+
* over whole, the update's older copy replaced the newer one and the "already raised" lines went
|
|
19584
|
+
* back with it. The newer copy stays, and the attributions are derived again from it: arithmetic,
|
|
19585
|
+
* the same pass the re-read itself runs. `analysedConversation` is untouched either way: it is the
|
|
19586
|
+
* snapshot this run actually reconciled against, so whatever arrived since is still new to the next.
|
|
19587
|
+
*/
|
|
19588
|
+
function keepNewerConversation(merged, fresh, updated) {
|
|
19589
|
+
const theirs = fresh.prContext ? orderOf(fresh.prContext.discussion) : void 0;
|
|
19590
|
+
const ours = updated.prContext ? orderOf(updated.prContext.discussion) : void 0;
|
|
19591
|
+
if (theirs === void 0 || ours === void 0 || theirs <= ours || !fresh.prContext) return;
|
|
19592
|
+
merged.prContext = fresh.prContext;
|
|
19593
|
+
if (fresh.lastPrRefresh) merged.lastPrRefresh = fresh.lastPrRefresh;
|
|
19594
|
+
const result = merged.session.result;
|
|
19595
|
+
if (result) {
|
|
19596
|
+
const { result: attributed } = attributeIssuesToDiscussion(result, processDiscussion(fresh.prContext.discussion));
|
|
19597
|
+
merged.session = {
|
|
19598
|
+
...merged.session,
|
|
19599
|
+
result: attributed
|
|
19600
|
+
};
|
|
19601
|
+
}
|
|
19602
|
+
}
|
|
19603
|
+
/**
|
|
19452
19604
|
* The grade changes this update suggested, onto the review as it is on disk.
|
|
19453
19605
|
*
|
|
19454
19606
|
* The ledger is the reviewer's and comes from the fresh read, so the update's proposals, written on
|
|
@@ -19725,20 +19877,20 @@ function stillPresent(counts) {
|
|
|
19725
19877
|
* Everything after it is the shared builder, so the two clients cannot drift into asking for
|
|
19726
19878
|
* different shapes.
|
|
19727
19879
|
*/
|
|
19728
|
-
function buildUpdatePrompt(workspace, delta, conversation, reviewedSha, newHead, worktreePath) {
|
|
19880
|
+
function buildUpdatePrompt(workspace, delta, conversation, reviewedSha, newHead, worktreePath, scope) {
|
|
19729
19881
|
const previous = workspace.session.result;
|
|
19730
19882
|
const discussionChanged = conversationMoved(conversation);
|
|
19731
19883
|
const conversationBlock = updateConversationBlock(conversation);
|
|
19732
|
-
const opening = delta ? `An earlier review of this branch is being brought up to date with the commits pushed since it was written${discussionChanged ? ", and with what has changed in its pull request conversation since" : ""}.` : "An earlier review of this branch is being brought up to date with what has changed in its pull request conversation since it was written. No commits have been pushed since.";
|
|
19884
|
+
const opening = delta ? `An earlier review of this branch is being brought up to date with the commits pushed since it was written${discussionChanged ? ", and with what has changed in its pull request conversation since" : ""}.` : scope === "conversation" ? "An earlier review of this branch is being reconciled with what has changed in its pull request conversation since it was written. Only the conversation is reassessed in this pass, against the commit the review read. Newer commits on the branch, if any, are not part of it: a comment saying a fix landed in one cannot be confirmed from this checkout, so that finding stays open for a later update to check against the code." : "An earlier review of this branch is being brought up to date with what has changed in its pull request conversation since it was written. No commits have been pushed since.";
|
|
19733
19885
|
const code = delta ? `The change below is what happened between ${reviewedSha} and ${newHead}. It is not the whole branch: the earlier review already covered everything up to ${reviewedSha}.
|
|
19734
19886
|
|
|
19735
19887
|
${renderChangeInventory(delta.changeSet.data.files, inlinePathsOf(delta))}
|
|
19736
19888
|
|
|
19737
|
-
${delta.diff}` : `The code is unchanged since the earlier review: ${newHead} is the commit it read.`;
|
|
19889
|
+
${delta.diff}` : scope === "conversation" ? `There is no code delta in this pass: ${newHead} is the commit the earlier review read.` : `The code is unchanged since the earlier review: ${newHead} is the commit it read.`;
|
|
19738
19890
|
const commits = delta ? (delta.changeSet.authors ?? []).reduce((total, author) => total + author.commits, 0) : 0;
|
|
19739
19891
|
return `${opening}
|
|
19740
19892
|
|
|
19741
|
-
The working tree at ${worktreePath} is checked out at ${newHead}, which is the code as it now stands. You may read any file in it, and you should: the point of doing this here rather than from a diff alone is that you can open the code and see whether a fix actually holds. Read only. Change nothing, commit nothing, and run no command that writes.
|
|
19893
|
+
The working tree at ${worktreePath} is checked out at ${newHead}, which is ${scope === "conversation" ? "the code the review read" : "the code as it now stands"}. You may read any file in it, and you should: the point of doing this here rather than from a diff alone is that you can open the code and see whether a fix actually holds. Read only. Change nothing, commit nothing, and run no command that writes.
|
|
19742
19894
|
|
|
19743
19895
|
${code}
|
|
19744
19896
|
|
|
@@ -20865,6 +21017,557 @@ function livePart(queue) {
|
|
|
20865
21017
|
return live.length === queue.length ? null : live;
|
|
20866
21018
|
}
|
|
20867
21019
|
//#endregion
|
|
21020
|
+
//#region ../../packages/review-harness/src/workspace/collections_live.ts
|
|
21021
|
+
/**
|
|
21022
|
+
* Collections as they relate to reviews that actually exist.
|
|
21023
|
+
*
|
|
21024
|
+
* `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
|
|
21025
|
+
* `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
|
|
21026
|
+
* lives here, which imports both and is imported by neither.
|
|
21027
|
+
*/
|
|
21028
|
+
/**
|
|
21029
|
+
* Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
|
|
21030
|
+
* the result if that changed anything. Read against every group, not the windowed list, so a branch
|
|
21031
|
+
* the recency window happens to hide keeps its place.
|
|
21032
|
+
*
|
|
21033
|
+
* Compacted again under the lock rather than written from what was read, so a member somebody added
|
|
21034
|
+
* since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
|
|
21035
|
+
*/
|
|
21036
|
+
function settleCollections(groups) {
|
|
21037
|
+
const live = new Set(groups.map((group) => group.key));
|
|
21038
|
+
const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
|
|
21039
|
+
...collection,
|
|
21040
|
+
members: collection.members.filter((key) => live.has(key))
|
|
21041
|
+
})) : null;
|
|
21042
|
+
const read = readCollections();
|
|
21043
|
+
const compacted = compact(read);
|
|
21044
|
+
if (compacted === null) return read;
|
|
21045
|
+
return updateCollections((current) => compact(current)) ?? compacted;
|
|
21046
|
+
}
|
|
21047
|
+
/**
|
|
21048
|
+
* The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
|
|
21049
|
+
* the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
|
|
21050
|
+
* left out, and the caller says so.
|
|
21051
|
+
*/
|
|
21052
|
+
function lineageKeysFor(ids) {
|
|
21053
|
+
const keys = /* @__PURE__ */ new Map();
|
|
21054
|
+
for (const id of new Set(ids)) {
|
|
21055
|
+
const workspace = loadWorkspace(id);
|
|
21056
|
+
if (!workspace) continue;
|
|
21057
|
+
try {
|
|
21058
|
+
keys.set(id, lineageKey(summarise(workspace)));
|
|
21059
|
+
} catch {}
|
|
21060
|
+
}
|
|
21061
|
+
return keys;
|
|
21062
|
+
}
|
|
21063
|
+
/**
|
|
21064
|
+
* Add one review's lineage to one collection, and say how that went. The membership check and the
|
|
21065
|
+
* add are one transaction, so `already` and `added` are told apart under the lock rather than
|
|
21066
|
+
* guessed from a read made before it. Touches no other collection and no review's lifecycle.
|
|
21067
|
+
*/
|
|
21068
|
+
function collectForReview(reviewId, collectionId) {
|
|
21069
|
+
const key = lineageKeysFor([reviewId]).get(reviewId);
|
|
21070
|
+
if (key === void 0) return "gone";
|
|
21071
|
+
let result = "added";
|
|
21072
|
+
if (updateCollections((current) => {
|
|
21073
|
+
const target = current.find((collection) => collection.id === collectionId);
|
|
21074
|
+
if (!target) {
|
|
21075
|
+
result = "missing";
|
|
21076
|
+
return null;
|
|
21077
|
+
}
|
|
21078
|
+
if (target.members.includes(key)) {
|
|
21079
|
+
result = "already";
|
|
21080
|
+
return null;
|
|
21081
|
+
}
|
|
21082
|
+
if (target.members.length >= 500) {
|
|
21083
|
+
result = "full";
|
|
21084
|
+
return null;
|
|
21085
|
+
}
|
|
21086
|
+
return current.map((collection) => collection.id === collectionId ? {
|
|
21087
|
+
...collection,
|
|
21088
|
+
members: [...collection.members, key]
|
|
21089
|
+
} : collection);
|
|
21090
|
+
}) === null) return "unsaved";
|
|
21091
|
+
return result;
|
|
21092
|
+
}
|
|
21093
|
+
//#endregion
|
|
21094
|
+
//#region ../../packages/review-harness/src/workspace/batch_destination.ts
|
|
21095
|
+
/**
|
|
21096
|
+
* Settle where the batch goes, before any review is started. An existing collection is found, and a
|
|
21097
|
+
* new one is made now, empty, through `createCollection` and so through the one name rule. Made up
|
|
21098
|
+
* front so a bad or taken name stops the batch before anything has been reviewed, and so a batch
|
|
21099
|
+
* that then partly fails has a collection to point its retry at.
|
|
21100
|
+
*/
|
|
21101
|
+
function openDestination(request) {
|
|
21102
|
+
const queue = request.queue;
|
|
21103
|
+
if ("collectionId" in request) {
|
|
21104
|
+
const collections = readCollections();
|
|
21105
|
+
const found = collections.find((collection) => collection.id === request.collectionId);
|
|
21106
|
+
if (!found) return {
|
|
21107
|
+
ok: false,
|
|
21108
|
+
reason: "missing",
|
|
21109
|
+
requested: request.collectionId,
|
|
21110
|
+
by: "id",
|
|
21111
|
+
candidates: collections.map((one) => one.name)
|
|
21112
|
+
};
|
|
21113
|
+
return {
|
|
21114
|
+
ok: true,
|
|
21115
|
+
destination: {
|
|
21116
|
+
queue,
|
|
21117
|
+
collection: {
|
|
21118
|
+
id: found.id,
|
|
21119
|
+
name: found.name,
|
|
21120
|
+
created: false
|
|
21121
|
+
}
|
|
21122
|
+
}
|
|
21123
|
+
};
|
|
21124
|
+
}
|
|
21125
|
+
if ("collectionName" in request) {
|
|
21126
|
+
const collections = readCollections();
|
|
21127
|
+
const found = collectionNamed(request.collectionName, collections);
|
|
21128
|
+
if (!found) return {
|
|
21129
|
+
ok: false,
|
|
21130
|
+
reason: "missing",
|
|
21131
|
+
requested: request.collectionName,
|
|
21132
|
+
by: "name",
|
|
21133
|
+
candidates: collections.map((one) => one.name)
|
|
21134
|
+
};
|
|
21135
|
+
return {
|
|
21136
|
+
ok: true,
|
|
21137
|
+
destination: {
|
|
21138
|
+
queue,
|
|
21139
|
+
collection: {
|
|
21140
|
+
id: found.id,
|
|
21141
|
+
name: found.name,
|
|
21142
|
+
created: false
|
|
21143
|
+
}
|
|
21144
|
+
}
|
|
21145
|
+
};
|
|
21146
|
+
}
|
|
21147
|
+
if ("newCollection" in request) {
|
|
21148
|
+
const made = createCollection(request.newCollection);
|
|
21149
|
+
if (!made.ok) return {
|
|
21150
|
+
ok: false,
|
|
21151
|
+
reason: made.reason === "missing" ? "unsaved" : made.reason,
|
|
21152
|
+
requested: request.newCollection
|
|
21153
|
+
};
|
|
21154
|
+
const created = made.collections.find((collection) => collection.id === made.id);
|
|
21155
|
+
if (!created) return {
|
|
21156
|
+
ok: false,
|
|
21157
|
+
reason: "unsaved",
|
|
21158
|
+
requested: request.newCollection
|
|
21159
|
+
};
|
|
21160
|
+
return {
|
|
21161
|
+
ok: true,
|
|
21162
|
+
destination: {
|
|
21163
|
+
queue,
|
|
21164
|
+
collection: {
|
|
21165
|
+
id: created.id,
|
|
21166
|
+
name: created.name,
|
|
21167
|
+
created: true
|
|
21168
|
+
}
|
|
21169
|
+
}
|
|
21170
|
+
};
|
|
21171
|
+
}
|
|
21172
|
+
return {
|
|
21173
|
+
ok: true,
|
|
21174
|
+
destination: { queue }
|
|
21175
|
+
};
|
|
21176
|
+
}
|
|
21177
|
+
/**
|
|
21178
|
+
* Put one review where the batch goes, as soon as it exists. Idempotent: a membership or a queue
|
|
21179
|
+
* entry already there is `already`, which counts as delivered, and anything missing is added, so a
|
|
21180
|
+
* retry of a partly delivered batch fills in exactly what is missing. Synchronous, like
|
|
21181
|
+
* `queueForReview`, so two lanes of one batch cannot interleave a read and a write.
|
|
21182
|
+
*/
|
|
21183
|
+
function deliver(reviewId, destination) {
|
|
21184
|
+
return {
|
|
21185
|
+
...destination.collection ? { collection: collectForReview(reviewId, destination.collection.id) } : {},
|
|
21186
|
+
...destination.queue ? { queue: queueForReview(reviewId) } : {}
|
|
21187
|
+
};
|
|
21188
|
+
}
|
|
21189
|
+
/** Whether a destination has the review: it went in now, or it was there already. */
|
|
21190
|
+
function isDelivered(result) {
|
|
21191
|
+
return result === "added" || result === "already";
|
|
21192
|
+
}
|
|
21193
|
+
/**
|
|
21194
|
+
* A miss that trying again could fix: the destination is still there, and it was full or its write
|
|
21195
|
+
* failed. A deleted collection (`missing`) or a deleted review (`gone`) fails the same way every time.
|
|
21196
|
+
*/
|
|
21197
|
+
function canTryAgain(result) {
|
|
21198
|
+
return result === "full" || result === "unsaved";
|
|
21199
|
+
}
|
|
21200
|
+
/**
|
|
21201
|
+
* The part of a destination that is worth trying again for one review, or null when none is. Only
|
|
21202
|
+
* what missed, and only what can still succeed: a retry never re-adds what is already there, and
|
|
21203
|
+
* never asks a deleted collection again.
|
|
21204
|
+
*/
|
|
21205
|
+
function retryableMiss(delivered, destination) {
|
|
21206
|
+
const collection = destination.collection && canTryAgain(delivered?.collection) ? destination.collection : void 0;
|
|
21207
|
+
const queue = destination.queue && canTryAgain(delivered?.queue);
|
|
21208
|
+
if (!collection && !queue) return null;
|
|
21209
|
+
return {
|
|
21210
|
+
queue,
|
|
21211
|
+
...collection ? { collection } : {}
|
|
21212
|
+
};
|
|
21213
|
+
}
|
|
21214
|
+
/** Whether a destination that was asked for does not have the review. */
|
|
21215
|
+
function missedDelivery(delivered, destination) {
|
|
21216
|
+
if (destination.collection && !isDelivered(delivered?.collection)) return true;
|
|
21217
|
+
return destination.queue && !isDelivered(delivered?.queue);
|
|
21218
|
+
}
|
|
21219
|
+
//#endregion
|
|
21220
|
+
//#region ../../packages/review-harness/src/workspace/reviews_index.ts
|
|
21221
|
+
/**
|
|
21222
|
+
* The sections of the list, top to bottom.
|
|
21223
|
+
*
|
|
21224
|
+
* Pinned is not a lifecycle state. It is where a card is drawn, on top of a lifecycle it keeps: a
|
|
21225
|
+
* pinned review can be Active, Waiting or Done, and unpinning it puts it back in the section that
|
|
21226
|
+
* says. That is why it lives here, beside the other placement rules, and not in `LIFECYCLES`.
|
|
21227
|
+
*/
|
|
21228
|
+
var INDEX_SECTIONS = [
|
|
21229
|
+
"pinned",
|
|
21230
|
+
"active",
|
|
21231
|
+
"waiting",
|
|
21232
|
+
"done"
|
|
21233
|
+
];
|
|
21234
|
+
/**
|
|
21235
|
+
* Every section the list draws, top to bottom: In progress, then the card sections.
|
|
21236
|
+
*
|
|
21237
|
+
* In progress is not in `INDEX_SECTIONS` because no card is ever placed there. It lists work, one
|
|
21238
|
+
* entry per running job or waiting row (`deriveOperations`), and a review being updated stays a
|
|
21239
|
+
* card in its own section while its update is listed here. Windows, Load more, the selection and
|
|
21240
|
+
* the counts are all about cards, so they keep reading `INDEX_SECTIONS`; folding, its memory and its
|
|
21241
|
+
* keyboard commands are about headings, so they read this.
|
|
21242
|
+
*/
|
|
21243
|
+
var LIST_SECTIONS = ["in-progress", ...INDEX_SECTIONS];
|
|
21244
|
+
/**
|
|
21245
|
+
* Each section's name, as its heading says it, and the key that folds it from the keyboard
|
|
21246
|
+
* (⌘K L and this key). The commands are made from this table, so a section added here is a section
|
|
21247
|
+
* the keyboard can fold, with no second list to remember.
|
|
21248
|
+
*/
|
|
21249
|
+
var lifecycleName = (id) => LIFECYCLES.find((lifecycle) => lifecycle.id === id).name;
|
|
21250
|
+
var INDEX_SECTION_NAMES = {
|
|
21251
|
+
"in-progress": {
|
|
21252
|
+
title: "In progress",
|
|
21253
|
+
key: "i"
|
|
21254
|
+
},
|
|
21255
|
+
pinned: {
|
|
21256
|
+
title: "Pinned",
|
|
21257
|
+
key: "p"
|
|
21258
|
+
},
|
|
21259
|
+
active: {
|
|
21260
|
+
title: lifecycleName("active"),
|
|
21261
|
+
key: "a"
|
|
21262
|
+
},
|
|
21263
|
+
waiting: {
|
|
21264
|
+
title: lifecycleName("waiting"),
|
|
21265
|
+
key: "w"
|
|
21266
|
+
},
|
|
21267
|
+
done: {
|
|
21268
|
+
title: lifecycleName("done"),
|
|
21269
|
+
key: "d"
|
|
21270
|
+
}
|
|
21271
|
+
};
|
|
21272
|
+
/**
|
|
21273
|
+
* Whether a section starts open. The reviewer's choice wins, and otherwise everything is open but
|
|
21274
|
+
* the archives: Done, and each collection's completed reviews. A new collection starts open. The
|
|
21275
|
+
* one place that default is written.
|
|
21276
|
+
*/
|
|
21277
|
+
function sectionOpen(section, collapsed) {
|
|
21278
|
+
return !(collapsed?.[section] ?? (section === "done" || section.startsWith("collection-done:")));
|
|
21279
|
+
}
|
|
21280
|
+
/** A section the list draws a heading for, and so one whose fold can be stored. */
|
|
21281
|
+
function isListSection(value) {
|
|
21282
|
+
return typeof value === "string" && LIST_SECTIONS.includes(value);
|
|
21283
|
+
}
|
|
21284
|
+
/** A fold the store accepts: a list section, or a collection's, by an id shaped like one. */
|
|
21285
|
+
function isFoldKey(value) {
|
|
21286
|
+
if (isListSection(value)) return true;
|
|
21287
|
+
if (typeof value !== "string") return false;
|
|
21288
|
+
const match = /^collection(?:-done)?:(.*)$/.exec(value);
|
|
21289
|
+
return match !== null && isCollectionId(match[1]);
|
|
21290
|
+
}
|
|
21291
|
+
function collectionFold(id) {
|
|
21292
|
+
return `collection:${id}`;
|
|
21293
|
+
}
|
|
21294
|
+
function completedFold(id) {
|
|
21295
|
+
return `collection-done:${id}`;
|
|
21296
|
+
}
|
|
21297
|
+
var COLLECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
21298
|
+
/**
|
|
21299
|
+
* Whether a value is shaped like a collection's id. Here, in the module both halves share, because
|
|
21300
|
+
* the hash and the fold store check it in the browser and on the server alike.
|
|
21301
|
+
*/
|
|
21302
|
+
function isCollectionId(value) {
|
|
21303
|
+
return typeof value === "string" && COLLECTION_ID.test(value);
|
|
21304
|
+
}
|
|
21305
|
+
/**
|
|
21306
|
+
* Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
|
|
21307
|
+
*
|
|
21308
|
+
* Pinning controls prominence in the general list and never overrides an ordering the reviewer
|
|
21309
|
+
* asked for. Under such a filter a pinned card stays in its lifecycle section, in the order the
|
|
21310
|
+
* filter gives, and only its star says it is pinned.
|
|
21311
|
+
*/
|
|
21312
|
+
function indexSectionOf(pinned, lifecycle, activity, filter) {
|
|
21313
|
+
return pinned && !ordersExplicitly(filter) ? "pinned" : sectionOf(lifecycle, activity);
|
|
21314
|
+
}
|
|
21315
|
+
/**
|
|
21316
|
+
* Where a review's own card is drawn, or absent when it is drawn only inside its collections.
|
|
21317
|
+
*
|
|
21318
|
+
* Pinned first and exclusive, as it always was: a pinned review is drawn in Pinned and nowhere else,
|
|
21319
|
+
* collected or not, and unpinning it puts it back where the rest of these rules say. A review in a
|
|
21320
|
+
* collection is drawn there instead of in Active, Waiting or Done, so those three list what nobody
|
|
21321
|
+
* has grouped. Under the Queue filter neither applies: the list is the queue in its order, each card
|
|
21322
|
+
* in its lifecycle section, the same reason `indexSectionOf` gives for Pinned.
|
|
21323
|
+
*/
|
|
21324
|
+
function homeSection(pinned, lifecycle, activity, collected, filter) {
|
|
21325
|
+
if (collected && !pinned && !ordersExplicitly(filter)) return void 0;
|
|
21326
|
+
return indexSectionOf(pinned, lifecycle, activity, filter);
|
|
21327
|
+
}
|
|
21328
|
+
/**
|
|
21329
|
+
* Where a review is drawn inside each collection it belongs to: with the collection's reviews still
|
|
21330
|
+
* needing attention, under its completed reviews, or not at all.
|
|
21331
|
+
*
|
|
21332
|
+
* Completed means Done, by the same derivation that places the card everywhere else, so Waiting is
|
|
21333
|
+
* still in play and the Reviewed mark has nothing to do with it. One state for every collection the
|
|
21334
|
+
* review is in: marking it Done moves it in all of them at once. Not drawn while pinned (Pinned is
|
|
21335
|
+
* exclusive) or under the Queue filter (see `homeSection`).
|
|
21336
|
+
*/
|
|
21337
|
+
function collectionPlacement(pinned, lifecycle, activity, filter) {
|
|
21338
|
+
if (pinned || ordersExplicitly(filter)) return void 0;
|
|
21339
|
+
return sectionOf(lifecycle, activity) === "done" ? "completed" : "active";
|
|
21340
|
+
}
|
|
21341
|
+
/** A collection heading's count: what still needs attention, and the members drawn in Pinned. */
|
|
21342
|
+
function collectionCountLabel(active, pinned) {
|
|
21343
|
+
const label = `${active} active`;
|
|
21344
|
+
return pinned === 0 ? label : `${label} · ${pinned} pinned`;
|
|
21345
|
+
}
|
|
21346
|
+
/**
|
|
21347
|
+
* The quiet line on a card naming its collections, or absent when it would say nothing new.
|
|
21348
|
+
*
|
|
21349
|
+
* Inside a collection the heading already names that one, so the line says only where else the
|
|
21350
|
+
* review is ("Also in Release 0.82"), and nothing for a review in one collection. Outside every
|
|
21351
|
+
* collection heading, which is Pinned and the Queue filter, it names them all.
|
|
21352
|
+
*/
|
|
21353
|
+
function collectionsLine(memberOf, collections, current) {
|
|
21354
|
+
const names = collections.filter((collection) => collection.id !== current && memberOf.includes(collection.id)).map((collection) => collection.name);
|
|
21355
|
+
if (names.length === 0) return void 0;
|
|
21356
|
+
return `${current === void 0 ? "In" : "Also in"} ${names.join(", ")}`;
|
|
21357
|
+
}
|
|
21358
|
+
/**
|
|
21359
|
+
* What a collection says when its main list is empty, or absent when it is not. A collection is
|
|
21360
|
+
* never hidden for having nothing active: it is still somewhere its finished reviews live, and
|
|
21361
|
+
* somewhere to put the next one.
|
|
21362
|
+
*/
|
|
21363
|
+
function collectionEmptyText(counts, filtered) {
|
|
21364
|
+
if (counts.active > 0) return void 0;
|
|
21365
|
+
if (filtered) return "Nothing in this collection matches the filters.";
|
|
21366
|
+
if (counts.members === 0) return "Nothing here yet. Add reviews from a card’s menu, or select several and choose Manage collections.";
|
|
21367
|
+
if (counts.pinned > 0 && counts.completed === 0) return "Its reviews are pinned, so they are listed under Pinned.";
|
|
21368
|
+
return "No active reviews";
|
|
21369
|
+
}
|
|
21370
|
+
/** Said under a collection whose oldest members are not drawn, so it never looks smaller than it is. */
|
|
21371
|
+
function omittedText(omitted) {
|
|
21372
|
+
return omitted === 1 ? "1 older review in this collection is not listed." : `${omitted} older reviews in this collection are not listed.`;
|
|
21373
|
+
}
|
|
21374
|
+
/** The disclosure holding a collection's Done reviews. */
|
|
21375
|
+
function completedLabel(completed) {
|
|
21376
|
+
return `Show completed (${completed})`;
|
|
21377
|
+
}
|
|
21378
|
+
/**
|
|
21379
|
+
* Whether a filter replaces the activity order with one the reviewer set. Queue is the only one:
|
|
21380
|
+
* the list is the queue, in its order.
|
|
21381
|
+
* `orderIndex` and `indexSectionOf` both ask this, so the ordering and the placement cannot disagree.
|
|
21382
|
+
*/
|
|
21383
|
+
function ordersExplicitly(filter) {
|
|
21384
|
+
return filter === "queue";
|
|
21385
|
+
}
|
|
21386
|
+
/** Keys this page reads, and `page`, which it once wrote and now deliberately forgets. */
|
|
21387
|
+
var KNOWN_KEYS = [
|
|
21388
|
+
"q",
|
|
21389
|
+
"f",
|
|
21390
|
+
"by",
|
|
21391
|
+
"pr",
|
|
21392
|
+
"col",
|
|
21393
|
+
"page"
|
|
21394
|
+
];
|
|
21395
|
+
/**
|
|
21396
|
+
* "45 reviews · 13 branches", and "13 reviews" when those are the same thirteen things.
|
|
21397
|
+
*
|
|
21398
|
+
* The two numbers are different questions and only both worth asking once they differ. A page of
|
|
21399
|
+
* thirteen branches reviewed once each would read "13 reviews · 13 branches", which invites the
|
|
21400
|
+
* reader to work out what the distinction is on the one page where there isn't one.
|
|
21401
|
+
*
|
|
21402
|
+
* `of` is every review on the page, given while the search or a filter is narrowing the list:
|
|
21403
|
+
* "12 of 71 reviews" says the list is filtered and by how much, where "12 reviews" reads as all
|
|
21404
|
+
* there are.
|
|
21405
|
+
*/
|
|
21406
|
+
function countLabel(reviews, branches, of) {
|
|
21407
|
+
const label = of === void 0 ? `${reviews} review${reviews === 1 ? "" : "s"}` : `${reviews} of ${of} review${of === 1 ? "" : "s"}`;
|
|
21408
|
+
if (reviews === branches) return label;
|
|
21409
|
+
return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
|
|
21410
|
+
}
|
|
21411
|
+
/**
|
|
21412
|
+
* The URL for a state, and nothing at all for the state a visitor arrives in.
|
|
21413
|
+
*
|
|
21414
|
+
* An empty hash for the defaults keeps `http://localhost:7726/` the address of the index rather
|
|
21415
|
+
* than of one particular way of looking at it, and keeps a link somebody copies as short as what
|
|
21416
|
+
* they actually did.
|
|
21417
|
+
*/
|
|
21418
|
+
function formatIndexHash(state) {
|
|
21419
|
+
const params = new URLSearchParams();
|
|
21420
|
+
if (state.query !== "") params.set("q", state.query);
|
|
21421
|
+
if (state.filter !== "all") params.set("f", state.filter);
|
|
21422
|
+
if (state.author !== "") params.set("by", state.author);
|
|
21423
|
+
if (state.pr !== "any") params.set("pr", state.pr);
|
|
21424
|
+
if (state.collections.length > 0) params.set("col", state.collections.join(","));
|
|
21425
|
+
for (const [key, value] of new URLSearchParams(state.extra ?? "")) if (!KNOWN_KEYS.includes(key)) params.append(key, value);
|
|
21426
|
+
const encoded = params.toString();
|
|
21427
|
+
return encoded === "" ? "" : `#${encoded}`;
|
|
21428
|
+
}
|
|
21429
|
+
/**
|
|
21430
|
+
* The Reviews page showing one collection: what a batch's Open collection link opens, from the
|
|
21431
|
+
* workspace and from the CLI's summary alike.
|
|
21432
|
+
*/
|
|
21433
|
+
function collectionHash(id) {
|
|
21434
|
+
return formatIndexHash({
|
|
21435
|
+
query: "",
|
|
21436
|
+
filter: "all",
|
|
21437
|
+
author: "",
|
|
21438
|
+
pr: "any",
|
|
21439
|
+
collections: [id]
|
|
21440
|
+
});
|
|
21441
|
+
}
|
|
21442
|
+
/**
|
|
21443
|
+
* Every login the author dropdown offers, once each, in alphabetical order of what the reader sees:
|
|
21444
|
+
* the name where there is one, the login otherwise, regardless of case. Read from the cards the page
|
|
21445
|
+
* is showing, so it never lists somebody with nothing to show.
|
|
21446
|
+
*
|
|
21447
|
+
* A login's name is the one on its newest review that has a name, chosen here by `createdAt` rather
|
|
21448
|
+
* than by the order the caller happens to pass, so an account renamed upstream reads as it does now.
|
|
21449
|
+
*/
|
|
21450
|
+
function authorOptions(cards) {
|
|
21451
|
+
const newest = /* @__PURE__ */ new Map();
|
|
21452
|
+
for (const card of cards) {
|
|
21453
|
+
if (!card.handle) continue;
|
|
21454
|
+
const seen = newest.get(card.handle);
|
|
21455
|
+
if (!seen) newest.set(card.handle, {
|
|
21456
|
+
name: card.name || void 0,
|
|
21457
|
+
createdAt: card.createdAt
|
|
21458
|
+
});
|
|
21459
|
+
else if (card.name && (!seen.name || card.createdAt > seen.createdAt)) newest.set(card.handle, {
|
|
21460
|
+
name: card.name,
|
|
21461
|
+
createdAt: card.createdAt
|
|
21462
|
+
});
|
|
21463
|
+
}
|
|
21464
|
+
return [...newest].map(([handle, { name }]) => name && name !== handle ? {
|
|
21465
|
+
handle,
|
|
21466
|
+
name
|
|
21467
|
+
} : { handle }).sort((a, b) => (a.name ?? a.handle).localeCompare(b.name ?? b.handle, void 0, { sensitivity: "base" }));
|
|
21468
|
+
}
|
|
21469
|
+
/** An author option as the dropdown spells it: the name and the login, or the login alone. */
|
|
21470
|
+
function authorOptionLabel(option) {
|
|
21471
|
+
return option.name ? `${option.name} · @${option.handle}` : `@${option.handle}`;
|
|
21472
|
+
}
|
|
21473
|
+
/**
|
|
21474
|
+
* A card menu's status item, which sets the state it names. The bulk bar's words for the same two
|
|
21475
|
+
* actions, so a card and a selection never disagree. Here rather than in the page renderer because
|
|
21476
|
+
* the browser relabels the item after a change, and that renderer reaches the store.
|
|
21477
|
+
*/
|
|
21478
|
+
function markItemLabel(mark) {
|
|
21479
|
+
return mark === "complete" ? "Mark reviewed" : "Mark not reviewed";
|
|
21480
|
+
}
|
|
21481
|
+
/**
|
|
21482
|
+
* The review page's Pin item, which says what pressing it will do. The words of the index's star,
|
|
21483
|
+
* so pinning from either place reads as the same act. Here because the browser relabels the item
|
|
21484
|
+
* after the change, for the reason `markItemLabel` is here.
|
|
21485
|
+
*/
|
|
21486
|
+
function pinItemLabel(pinned) {
|
|
21487
|
+
return pinned ? "Unpin review" : "Pin review";
|
|
21488
|
+
}
|
|
21489
|
+
/**
|
|
21490
|
+
* The queue item, on a card and on the review's own page. "review queue" rather than "queue",
|
|
21491
|
+
* because a menu is where somebody meets the idea for the first time. Here because the browser
|
|
21492
|
+
* relabels it after a change, on both pages.
|
|
21493
|
+
*/
|
|
21494
|
+
function queueItemLabel(queued) {
|
|
21495
|
+
return queued ? "Remove from review queue" : "Add to review queue";
|
|
21496
|
+
}
|
|
21497
|
+
/**
|
|
21498
|
+
* The Queue pill, which carries a number only once there is one.
|
|
21499
|
+
*
|
|
21500
|
+
* "Queue 0" is a count of something that is not there, and reads as a fault. Bare "Queue" reads as
|
|
21501
|
+
* the place it is -- empty, and still the thing you would click to put something in it.
|
|
21502
|
+
*/
|
|
21503
|
+
function queueOptionLabel(queued) {
|
|
21504
|
+
return queued === 0 ? "Queue" : `Queue (${queued})`;
|
|
21505
|
+
}
|
|
21506
|
+
/**
|
|
21507
|
+
* The words on the status chip.
|
|
21508
|
+
*
|
|
21509
|
+
* Here rather than in the renderer because the browser now writes this chip too: marking a
|
|
21510
|
+
* selection reviewed repaints it without asking the server for the page again, and two copies of
|
|
21511
|
+
* these three words is how a page ends up saying "Reviewed" in one place and "Complete" in the
|
|
21512
|
+
* other about the same review.
|
|
21513
|
+
*/
|
|
21514
|
+
function statusLabel(status) {
|
|
21515
|
+
if (status === "complete") return "Reviewed";
|
|
21516
|
+
return status === "in-progress" ? "Not reviewed" : "New";
|
|
21517
|
+
}
|
|
21518
|
+
/**
|
|
21519
|
+
* The status pill's accessible name: where the reviewer is, then what pressing it does. The visible
|
|
21520
|
+
* words are the state, so a screen reader hears both rather than only a state it cannot act on.
|
|
21521
|
+
*/
|
|
21522
|
+
function statusPillLabel(status) {
|
|
21523
|
+
return `${statusLabel(status)}. Mark as ${status === "complete" ? "not reviewed" : "reviewed"}`;
|
|
21524
|
+
}
|
|
21525
|
+
/**
|
|
21526
|
+
* The bulk bar's way to tick everything the filters show, across pages.
|
|
21527
|
+
*
|
|
21528
|
+
* Counted ("Select all 7") only while the headline is not already saying "of 7", so the number is
|
|
21529
|
+
* on the bar once. With PR set to Merged this is how a reviewer clears the merged reviews in one go,
|
|
21530
|
+
* by way of the existing Mark reviewed.
|
|
21531
|
+
*/
|
|
21532
|
+
function selectAllLabel(inView, scopeShown = false) {
|
|
21533
|
+
return inView === 0 || scopeShown ? "Select all" : `Select all ${inView}`;
|
|
21534
|
+
}
|
|
21535
|
+
/** The PR filter's options, in order, as the select shows them. */
|
|
21536
|
+
var PR_FILTER_OPTIONS = [
|
|
21537
|
+
{
|
|
21538
|
+
value: "any",
|
|
21539
|
+
label: "Any"
|
|
21540
|
+
},
|
|
21541
|
+
{
|
|
21542
|
+
value: "open",
|
|
21543
|
+
label: "Open"
|
|
21544
|
+
},
|
|
21545
|
+
{
|
|
21546
|
+
value: "merged",
|
|
21547
|
+
label: "Merged"
|
|
21548
|
+
},
|
|
21549
|
+
{
|
|
21550
|
+
value: "closed",
|
|
21551
|
+
label: "Closed"
|
|
21552
|
+
}
|
|
21553
|
+
];
|
|
21554
|
+
/**
|
|
21555
|
+
* Whether any of that is work still going on, which is what sets the polling cadence.
|
|
21556
|
+
*
|
|
21557
|
+
* Here rather than beside `analysisStates` because this module is the DOM-free, filesystem-free
|
|
21558
|
+
* arithmetic both halves of the index share, and the renderer needs this one answer without
|
|
21559
|
+
* needing an import that reads the jobs directory to get it.
|
|
21560
|
+
*
|
|
21561
|
+
* A stopped analysis is a state, not an activity. Counting it as live would leave the index asking
|
|
21562
|
+
* once a second about a review that finished failing an hour ago, for as long as the tab stayed
|
|
21563
|
+
* open.
|
|
21564
|
+
*/
|
|
21565
|
+
function anythingRunning(states, operations = []) {
|
|
21566
|
+
if (operations.length > 0) return true;
|
|
21567
|
+
for (const state of states.values()) if (state.phase !== "failed") return true;
|
|
21568
|
+
return false;
|
|
21569
|
+
}
|
|
21570
|
+
//#endregion
|
|
20868
21571
|
//#region ../../packages/review-harness/src/workspace/relative_time.ts
|
|
20869
21572
|
/**
|
|
20870
21573
|
* Relative time, in the one wording this product uses.
|
|
@@ -21472,4 +22175,4 @@ var MCP_TOOL_NAMES = [
|
|
|
21472
22175
|
"ask_buddy"
|
|
21473
22176
|
];
|
|
21474
22177
|
//#endregion
|
|
21475
|
-
export {
|
|
22178
|
+
export { statusPillLabel as $, TargetError as $a, lifecycleItemLabel as $i, writeJsonAtomically as $n, findExistingReview as $r, statedWorkspacePort as $t, anythingRunning as A, currentGradeIn as Aa, indexReviewGroups as Ai, BUILD_VERSION as An, deleteCollection as Ao, chatConversationSection as Ar, isRequestId as At, completedLabel as B, statusLookup as Ba, previousKissResult as Bi, checkArchitecture as Bn, modelHelpLines as Br, readServerRecord as Bt, parsePullRequestLink as C, isExplanationAction as Ca, driverGone as Ci, readEvents as Cn, AGENT_RUN_MARKER as Co, ASK_REREAD_CEILING_MS as Cr, findingRefs as Ct, INDEX_SECTIONS as D, analysisRefOf as Da, followedRefName as Di, recordTelemetryUploadConsent as Dn, collectionsStamp as Do, isUnchanged as Dr, endAskRequest as Dt, relativeTime as E, NO_STATUS as Ea, decideKissRecommendation as Ei, deliver$1 as En, cleanName as Eo, storedConversation as Er, reviewTagsIn as Et, collectionFold as F, isGradeFor as Fa, loadWorkspace as Fi, recordRuntimeBestEffort as Fn, STORE_ROOT as Fo, renderMarkdown as Fr, readIndexToken as Ft, markItemLabel as G, extractJsonText as Ga, touchWorkspace as Gi, storeArchitecture as Gn, createWorktree as Gr, stopServer as Gt, homeSection as H, followUpFindingId as Ha, reviewedCommit as Hi, partFiles as Hn, isBusy as Hr, serverEntryExists as Ht, collectionHash as I, isOffered as Ia, matchingWorkspaceIds as Ii, RUNTIME_WORDS as In, CODE_BLOCK_CLASS as Ir, bootstrapUrl as It, queueItemLabel as J, WITHHELD_REASON as Ja, LIFECYCLES as Ji, ownersOf as Jn, readMarker as Jr, DEFAULT_WORKSPACE_PORT as Jt, omittedText as K, objectWithAnyOf as Ka, workspaceRevision as Ki, storedScopes as Kn, holdWorktree as Kr, workspaceUrl as Kt, collectionPlacement as L, promptGrade as La, mutateWorkspace as Li, describeRuntimes as Ln, CODE_COPY_CLASS as Lr, clearServerRecordOwnedBy as Lt, authorOptions as M, findingIn as Ma, kissDecisionsOf as Mi, feedbackUrl as Mn, readCollections as Mo, authorPreview as Mr, markAskRunning as Mt, collectionCountLabel as N, findingStatus as Na, latestKissRun as Ni, waitingHistory as Nn, renameCollection as No, hasAuthorContent as Nr, readAskRequest as Nt, INDEX_SECTION_NAMES as O, assessmentLookup as Oa, groupByLineage as Oi, telemetryUploadConsent as On, createCollection as Oo, agentEnvOf as Or, forgetAskRequest as Ot, collectionEmptyText as P, gradeScale as Pa, lineagePosition as Pi, mapRuntimes as Pn, setMembership as Po, GITHUB_MARKDOWN as Pr, recordAskAccepted as Pt, statusLabel as Q, DetachedHeadError as Qa, lifecycleChip as Qi, isArchitectureId as Qn, describeSubject as Qr, resolveWorkspacePort as Qt, collectionsLine as R, reassessIn as Ra, positionInLineage as Ri, formatDuration as Rn, escapeHtml as Rr, ensureServer as Rt, extractPullRequestLinks as S, explanationOf as Sa, clearClaim as Si, append as Sn, versionFrom as So, existingPaths as Sr, findingRef as St, runBatch as T, ANALYSIS_ONLY as Ta, CLAIM_LEASE_MS as Ti, stamp as Tn, AgentUnavailableError as To, saveRefresh as Tr, reviewTag as Tt, isCollectionId as U, SEVERITIES as Ua, reviewedRepositories as Ui, readArchitecture as Un, withReviewLock as Ur, spawnServer as Ut, countLabel as V, wasReassessed as Va, recentWorkspaces as Vi, loadArchitecture as Vn, ReviewBeingDeletedError as Vr, reviewsUrl as Vt, isFoldKey as W, asString$1 as Wa, summarise as Wi, repositoryKey as Wn, MANAGED_ROOT as Wr, stopChild as Wt, sectionOpen as X, resolveTarget as Xa, activityLabel as Xi, REPOSITORY_SCOPE as Xn, requestJobStop as Xr, MIN_WORKSPACE_PORT as Xt, queueOptionLabel as Y, kissOutcome as Ya, activityAttributes as Yi, QUOTE_LIMITS as Yn, removeWorktree as Yr, MAX_WORKSPACE_PORT as Yt, selectAllLabel as Z, originRemote as Za, isLifecycle as Zi, ancestorsOf as Zn, describeMovement as Zr, parseWorkspacePort as Zt, endRun as _, reviewerDispositions as _a, isLiveJob as _i, prepareReview as _n, agentIdentityOf as _o, retireNotification as _r, modelProvenanceOf as _t, lineageIds as a, sectionOf as aa, forgeResolver as ai, cancellableHere as an, git as ao, createPrState as ar, lineageKeysFor as at, stopRun as b, recommendationsOf as ba, progressSteps as bi, runJob as bn, parkedReason as bo, unreadCount as br, humanizeModelId as bt, discardJob as c, queueRemove as ca, allJobs as ci, recheckConversation as cn, resolveModel as co, listNotifications as cr, compactQueue as ct, startKissJob as d, doneVerb as da, fail as di, findingsChanged as dn, readAgentPreference as do, markReadForReview as dr, RUNNER_KINDS as dt, lifecycleName$1 as ea, checkCodeFreshness as ei, writePortPreference as en, isCommitReview as eo, announceJob as er, deliver as et, isDriving as f, isIssueOutstanding as fa, isTerminal as fi, UpdateAlreadyRunningError as fn, writeAgentPreference as fo, moveIds as fr, RUNNER_MARKER as ft, completeRun as g, resolvedByConversation as ga, PHASES as gi, observeReview as gn, agentFor as go, removeNotification as gr, applyUpdate as gt, beginRun as h, questionsOutstanding as ha, saveJob as hi, failureOf as hn, agentById as ho, readStateOf as hr, spawnRunner as ht, deleteReview as i, parseLifecycle as ia, fetchHead as ii, cancelUpdate as in, shorthandOf as io, createArchitectureNotification as ir, retryableMiss as it, authorOptionLabel as j, findingAssessment as ja, kissDecisionRefusal as ji, PACKAGE_NAME as jn, nameProblemText as jo, UNESTABLISHED_MOTIVATION as jr, isTerminalAsk as jt, PR_FILTER_OPTIONS as k, closesFinding as ka, indexKeptIds as ki, telemetryUploadEnabled as kn, createWithMembers as ko, EXPLAIN_SIMPLY_PROMPT as kr, interruption as kt, kissAgentId as l, queueStamp as la, attemptIdOf as li, updateReview as ln, agentIdOf as lo, markAllRead as lr, queueForReview as lt, RUN_KINDS as m, issuesOutstanding as ma, pruneJobResidue as mi, runningJobs as mn, AGENT_IDS as mo, readNotification as mr, runDetached as mt, MCP_TOOL_NAMES as n, lifecyclePillLabel as na, followedBranch as ni, FETCH_TIMEOUT_MS as nn, AmbiguousRefError as no, subjectOf as nr, missedDelivery as nt, liveJobsFor as o, queueAdd as oa, fillFileUrlTemplate as oi, mergeUpdateInto as on, refExists as oo, createPrUpdate as or, settleCollections as ot, track as p, isQuestionOutstanding as pa, loadJob as pi, liveUpdateFor as pn, CONFIG_PATH as po, pruneNotifications as pr, cliEntry as pt, pinItemLabel as q, describeAuthorship as qa, workspaceStamps as qi, trackedFiles as qn, isWorktreeHeld as qr, writeServerRecord as qt, deleteLineage as r, offersLifecycle as ra, reviewedShaOf as ri, RefreshUnavailableError as rn, shortNameOf as ro, categoryOf as rr, openDestination as rt, wasBlocked as s, queueAddMany as sa, RUN_RECORD_RETENTION_MS as si, parkedUpdateRefusal as sn, resolveAgent as so, eventName as sr, activateQueued as st, MCP_SERVER_INFO_NAME as t, lifecyclePillAction as ta, checkReview as ti, COMMIT_REVIEW_CANNOT_UPDATE as tn, resolveBase as to, branchOf as tr, isDelivered as tt, recordKissRun as u, readQueue as ua, endedAt as ui, askCheckout as un, clearAgentPreference as uo, markRead as ur, settleQueue as ut, heldRuns as v, verdictClosure as va, isTerminalJobPhase as vi, settleReview as vn, detectAgents as vo, toneOf as vr, provenanceDetailLines as vt, unsupportedReason as w, withExplanation as wa, isClaimed as wi, record as wn, AgentCancelledError as wo, prepareConversationWithin as wr, refOf as wt, batchIdentity as x, explanationActionOf as xa, purposeOf as xi, startJob as xn, runnableAgents as xo, withUsageRecorded as xr, findingAtRef as xt, reportRun as y, processDiscussion as ya, phasesFor as yi, reanalyseReview as yn, isAgentId as yo, unreadAround as yr, agentDisplayName as yt, completedFold as z, sourcePhrase as za, presentedKissRunOf as zi, ARCHITECTURE_DIR as zn, renderInlineText as zr, healthyPort as zt };
|