@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -0,0 +1,560 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ readdirSync,
5
+ realpathSync,
6
+ statSync,
7
+ } from "node:fs";
8
+ import path from "node:path";
9
+ import type { DatabaseSync } from "node:sqlite";
10
+
11
+ import {
12
+ type VcsArg,
13
+ gitCommonDirSync,
14
+ parseGitRemoteSlug,
15
+ resolveRepoContextSync,
16
+ runVcs,
17
+ runVcsSync,
18
+ } from "@dev.fast/local-vcs";
19
+
20
+ /** Checkouts of one repository share a group: worktrees, or clones of one
21
+ * GitHub repository. */
22
+ export interface RepositoryGroup {
23
+ key: string;
24
+ label: string;
25
+ }
26
+
27
+ /** A command's output, or undefined if it fails; the runner's rules (no
28
+ * lazy fetch, no prompt, a timeout) apply. */
29
+ function quietly(
30
+ tool: "git" | "jj",
31
+ args: VcsArg[],
32
+ options: { cwd?: string; input?: string } = {},
33
+ ): string | undefined {
34
+ try {
35
+ return runVcsSync(tool, args, {
36
+ cwd: options.cwd,
37
+ input: options.input,
38
+ encoding: "utf8",
39
+ stdio: [
40
+ options.input === undefined ? "ignore" : "pipe",
41
+ "pipe",
42
+ "ignore",
43
+ ],
44
+ maxBuffer: 64 * 1024 * 1024,
45
+ });
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** A path with its parent's symlinks resolved, for a path that may be gone. */
52
+ export function canonicalPath(file: string) {
53
+ try {
54
+ return path.join(realpathSync(path.dirname(file)), path.basename(file));
55
+ } catch {
56
+ return path.resolve(file);
57
+ }
58
+ }
59
+
60
+ /** The git directory a checkout's worktrees share, read from its `.git`
61
+ * without running git; undefined when that can't be read so. */
62
+ function commonDirOf(root: string): string | undefined {
63
+ const dotGit = path.join(root, ".git");
64
+
65
+ try {
66
+ // Real paths, as git gives them, so checkouts of one repository match.
67
+ if (statSync(dotGit).isDirectory()) return realpathSync(dotGit);
68
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
69
+
70
+ if (!gitdir) return undefined;
71
+ const own = path.resolve(root, gitdir.trim());
72
+
73
+ try {
74
+ return realpathSync(
75
+ path.resolve(
76
+ own,
77
+ readFileSync(path.join(own, "commondir"), "utf8").trim(),
78
+ ),
79
+ );
80
+ } catch {
81
+ return realpathSync(own);
82
+ }
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /** The origin remote's URL from a git config file; undefined when the file
89
+ * can't settle it (a URL rewrite, an include), so git must be asked. */
90
+ function originUrlOf(commonDir: string): string | null | undefined {
91
+ let config: string;
92
+
93
+ try {
94
+ config = readFileSync(path.join(commonDir, "config"), "utf8");
95
+ } catch {
96
+ return undefined;
97
+ }
98
+
99
+ if (/insteadof|^\s*\[include/im.test(config)) return undefined;
100
+ const section = /^\s*\[remote\s+"origin"\]([^[]*)/im.exec(config)?.[1];
101
+
102
+ return section
103
+ ? (/^\s*url\s*=\s*(.+)$/im.exec(section)?.[1]?.trim() ?? null)
104
+ : null;
105
+ }
106
+
107
+ /** A checkout's repository group; undefined if it isn't a readable git
108
+ * checkout. Reads `.git` and its config directly where it can, so many
109
+ * checkouts group in a moment; git answers the rest. */
110
+ export function repositoryGroupAt(root: string): RepositoryGroup | undefined {
111
+ // Resolving a missing checkout would spawn on every listing.
112
+ if (!existsSync(root)) return undefined;
113
+
114
+ const commonDir = commonDirOf(root);
115
+ const origin = commonDir ? originUrlOf(commonDir) : undefined;
116
+
117
+ const context =
118
+ commonDir && origin !== undefined
119
+ ? { commonDir, githubSlug: origin ? parseGitRemoteSlug(origin) : null }
120
+ : resolveRepoContextSync(root);
121
+
122
+ if (!context) return undefined;
123
+
124
+ return context.githubSlug
125
+ ? {
126
+ key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
127
+ label: context.githubSlug,
128
+ }
129
+ : {
130
+ key: `git:${context.commonDir}`,
131
+ label: path.basename(path.dirname(context.commonDir)),
132
+ };
133
+ }
134
+
135
+ /** What grouping asks of one checkout on disk: one call each. */
136
+ export interface CheckoutProbe {
137
+ /** Its repository group, or undefined for a folder that isn't one. */
138
+ groupAt(root: string): RepositoryGroup | undefined;
139
+ /** Its repository's shared git directory and the worktree folders that
140
+ * directory lists, deleted ones included until they are pruned. */
141
+ worktreesOf(
142
+ root: string,
143
+ ): { commonDir: string; worktrees: string[] } | undefined;
144
+ /** Which of these commits the repository at `commonDir` has. */
145
+ commitsIn(commonDir: string, commits: string[]): Set<string>;
146
+ /** The same, without blocking, so several repositories answer at once;
147
+ * a probe without it answers one at a time. */
148
+ findCommits?(commonDir: string, commits: string[]): Promise<Set<string>>;
149
+ }
150
+
151
+ /** The probe that reads `.git` directly, running git only where it must. */
152
+ export const gitCheckoutProbe: CheckoutProbe = {
153
+ groupAt: repositoryGroupAt,
154
+ worktreesOf(root) {
155
+ const commonDir = commonDirOf(root) ?? gitCommonDirSync(root) ?? undefined;
156
+
157
+ if (!commonDir) return undefined;
158
+
159
+ let entries: string[] = [];
160
+
161
+ try {
162
+ entries = readdirSync(path.join(commonDir, "worktrees"));
163
+ } catch {
164
+ // No worktrees.
165
+ }
166
+
167
+ const worktrees = entries.flatMap((entry) => {
168
+ try {
169
+ const gitdir = readFileSync(
170
+ path.join(commonDir, "worktrees", entry, "gitdir"),
171
+ "utf8",
172
+ ).trim();
173
+
174
+ return [path.dirname(gitdir)];
175
+ } catch {
176
+ return [];
177
+ }
178
+ });
179
+
180
+ return { commonDir, worktrees };
181
+ },
182
+ commitsIn: (commonDir, commits) =>
183
+ commitsListed(
184
+ quietly("git", ["--git-dir", commonDir, "cat-file", "--batch-check"], {
185
+ input: commits.join("\n") + "\n",
186
+ }),
187
+ ),
188
+ findCommits: async (commonDir, commits) =>
189
+ commitsListed(
190
+ await runVcs(
191
+ "git",
192
+ ["--git-dir", commonDir, "cat-file", "--batch-check"],
193
+ { input: commits.join("\n") + "\n", maxBuffer: 64 * 1024 * 1024 },
194
+ ).then(
195
+ ({ stdout }) => stdout,
196
+ () => undefined,
197
+ ),
198
+ ),
199
+ };
200
+
201
+ /** How many repositories are asked about commits at once. */
202
+ const PARALLEL_GIT = 8;
203
+
204
+ /** The commits each repository was asked about, by its git directory. */
205
+ type CommitQuestions = Map<string, string[]>;
206
+
207
+ /** Which of them each has. */
208
+ type CommitAnswers = Map<string, Set<string>>;
209
+
210
+ /**
211
+ * How a store's checkouts group into projects, worked out in one pass and
212
+ * kept for the life of the process. `prepare` asks each checkout still on
213
+ * disk for its group and its worktrees, once each, before an upgrade takes
214
+ * the write lock; projects are then assigned from the maps it builds,
215
+ * without touching git. A deleted checkout joins the checkout that lists it
216
+ * as a worktree, else one whose repository has the commits its boards
217
+ * pinned (a pruned worktree's history lives on in its repository),
218
+ * preferring the one whose folder held it; unmatched, it gets its own
219
+ * project.
220
+ */
221
+ export class CheckoutGroups {
222
+ private readonly groups = new Map<string, RepositoryGroup | undefined>();
223
+ private owners: ReadonlyMap<string, string> | undefined;
224
+
225
+ constructor(
226
+ private readonly db: DatabaseSync,
227
+ private readonly probe: CheckoutProbe = gitCheckoutProbe,
228
+ /** The grouping another connection's `prepare` already worked out. */
229
+ prepared?: ReadonlyMap<string, string>,
230
+ ) {
231
+ this.owners = prepared;
232
+ }
233
+
234
+ /** A checkout's group, asked once per path. */
235
+ group(root: string): RepositoryGroup | undefined {
236
+ if (!this.groups.has(root)) {
237
+ let group: RepositoryGroup | undefined;
238
+
239
+ // A missing folder has no group, and git needn't be asked.
240
+ if (existsSync(root))
241
+ try {
242
+ group = this.probe.groupAt(root);
243
+ } catch {
244
+ group = undefined;
245
+ }
246
+
247
+ this.groups.set(root, group);
248
+ }
249
+
250
+ return this.groups.get(root);
251
+ }
252
+
253
+ /** A file as a commit has it, or undefined if this repository can't say
254
+ * without fetching. */
255
+ fileAt(root: string, commit: string, file: string) {
256
+ return quietly("git", ["-C", root, "show", { rev: `${commit}:${file}` }]);
257
+ }
258
+
259
+ /** Works everything out now, ahead of a migration that needs it. */
260
+ prepare() {
261
+ if (this.owners) return;
262
+
263
+ const finding = this.findOwners();
264
+ let step = finding.next();
265
+
266
+ while (!step.done)
267
+ step = finding.next(
268
+ new Map(
269
+ [...step.value].map(([commonDir, commits]) => [
270
+ commonDir,
271
+ this.commitsIn(commonDir, commits),
272
+ ]),
273
+ ),
274
+ );
275
+
276
+ this.owners = step.value;
277
+ }
278
+
279
+ /** As `prepare`, asking several repositories at once: before a store
280
+ * opens, while nothing waits on its write lock yet. */
281
+ async prepareConcurrently(): Promise<void> {
282
+ if (this.owners) return;
283
+
284
+ const finding = this.findOwners();
285
+ let step = finding.next();
286
+
287
+ while (!step.done) {
288
+ const questions = [...step.value];
289
+ const answers: CommitAnswers = new Map();
290
+
291
+ const ask = async () => {
292
+ for (let next = questions.shift(); next; next = questions.shift()) {
293
+ const [commonDir, commits] = next;
294
+
295
+ answers.set(commonDir, await this.findCommits(commonDir, commits));
296
+ }
297
+ };
298
+
299
+ await Promise.all(Array.from({ length: PARALLEL_GIT }, ask));
300
+ step = finding.next(answers);
301
+ }
302
+
303
+ this.owners = step.value;
304
+ }
305
+
306
+ /** The grouping `prepare` worked out, to hand to another connection's. */
307
+ get prepared(): ReadonlyMap<string, string> | undefined {
308
+ return this.owners;
309
+ }
310
+
311
+ ownerOf(repositoryId: string): string | undefined {
312
+ this.prepare();
313
+
314
+ return this.owners!.get(repositoryId);
315
+ }
316
+
317
+ private repositories() {
318
+ return this.db.prepare("SELECT id, path FROM repositories").all();
319
+ }
320
+
321
+ private commitsIn(commonDir: string, commits: string[]) {
322
+ try {
323
+ return this.probe.commitsIn(commonDir, commits);
324
+ } catch {
325
+ return new Set<string>();
326
+ }
327
+ }
328
+
329
+ private async findCommits(commonDir: string, commits: string[]) {
330
+ try {
331
+ return await (this.probe.findCommits?.(commonDir, commits) ??
332
+ this.probe.commitsIn(commonDir, commits));
333
+ } catch {
334
+ return new Set<string>();
335
+ }
336
+ }
337
+
338
+ /** Works out each deleted checkout's owner, yielding the questions for
339
+ * git it needs answered: once about the commits boards pin now, and
340
+ * again about older ones only for a checkout those leave unsettled. */
341
+ private *findOwners(): Generator<
342
+ CommitQuestions,
343
+ Map<string, string>,
344
+ CommitAnswers
345
+ > {
346
+ const owners = new Map<string, string>();
347
+ const existing: { id: string; root: string }[] = [];
348
+ const deleted: { id: string; root: string }[] = [];
349
+
350
+ for (const { id, path: root } of this.repositories())
351
+ (existsSync(String(root)) ? existing : deleted).push({
352
+ id: String(id),
353
+ root: String(root),
354
+ });
355
+
356
+ // Each checkout on disk, asked once for its group and once for its
357
+ // worktrees.
358
+ const listed = new Map<string, string>();
359
+ const repositories = new Map<string, string>();
360
+ const repositoryOf = new Map<string, string>();
361
+
362
+ for (const checkout of existing) {
363
+ this.group(checkout.root);
364
+
365
+ if (!deleted.length) continue;
366
+
367
+ let found: ReturnType<CheckoutProbe["worktreesOf"]>;
368
+
369
+ try {
370
+ found = this.probe.worktreesOf(checkout.root);
371
+ } catch {
372
+ found = undefined;
373
+ }
374
+
375
+ if (!found) continue;
376
+ repositoryOf.set(checkout.id, found.commonDir);
377
+
378
+ if (repositories.has(found.commonDir)) continue;
379
+ repositories.set(found.commonDir, checkout.id);
380
+
381
+ for (const worktree of found.worktrees)
382
+ listed.set(canonicalPath(worktree), checkout.id);
383
+ }
384
+
385
+ for (const gone of deleted) {
386
+ const owner = listed.get(canonicalPath(gone.root));
387
+
388
+ if (owner) owners.set(gone.id, owner);
389
+ }
390
+
391
+ const folders = existing.map((checkout) => ({
392
+ id: checkout.id,
393
+ folder: canonicalPath(checkout.root) + path.sep,
394
+ }));
395
+
396
+ // Of the repositories that have its commits, the one whose folder held
397
+ // it; a folder of another repository says nothing. Else the first.
398
+ const containers = (gone: { root: string }) => {
399
+ const at = canonicalPath(gone.root);
400
+
401
+ return folders.flatMap(({ id, folder }) =>
402
+ at.startsWith(folder) && repositoryOf.has(id)
403
+ ? [{ id, commonDir: repositoryOf.get(id)! }]
404
+ : [],
405
+ );
406
+ };
407
+
408
+ const unmatched = deleted.filter(({ id }) => !owners.has(id));
409
+
410
+ // The rest by the commits their boards pinned, asked once of each
411
+ // repository: the boards' current versions first. A checkout one of
412
+ // them doesn't pin needs the history from the start.
413
+ const now = this.pinnedCommits(
414
+ new Set(unmatched.map(({ id }) => id)),
415
+ "current",
416
+ );
417
+
418
+ const historyFirst = unmatched.some(({ id }) => !now.has(id));
419
+
420
+ const pinned = historyFirst
421
+ ? this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "all")
422
+ : now;
423
+
424
+ const having = new Map<string, string[]>();
425
+
426
+ if (pinned.size) {
427
+ const answers = yield questionsFor(repositories, pinned);
428
+
429
+ for (const [gone, theirs] of pinned)
430
+ having.set(gone, repositoriesWith(repositories, answers, theirs));
431
+ }
432
+
433
+ // A checkout is settled by its current commits only if the repository
434
+ // its owner would come from has them; another repository might have
435
+ // only older ones, and rank before it.
436
+ const unsettled = historyFirst
437
+ ? []
438
+ : unmatched.filter(({ id, root }) => {
439
+ const first =
440
+ containers({ root })[0]?.commonDir ??
441
+ repositories.keys().next().value;
442
+
443
+ return !having.get(id)?.includes(first ?? "");
444
+ });
445
+
446
+ if (unsettled.length) {
447
+ const older = new Map(
448
+ [
449
+ ...this.pinnedCommits(new Set(unsettled.map(({ id }) => id)), "all"),
450
+ ].map(([gone, commits]) => [
451
+ gone,
452
+ commits.filter((commit) => !now.get(gone)?.includes(commit)),
453
+ ]),
454
+ );
455
+
456
+ const answers = yield questionsFor(repositories, older);
457
+
458
+ for (const [gone, theirs] of older) {
459
+ const more = repositoriesWith(repositories, answers, theirs);
460
+ const known = having.get(gone) ?? [];
461
+
462
+ // In the repositories' order, as one question would have found them.
463
+ having.set(
464
+ gone,
465
+ [...repositories.keys()].filter(
466
+ (commonDir) =>
467
+ known.includes(commonDir) || more.includes(commonDir),
468
+ ),
469
+ );
470
+ }
471
+ }
472
+
473
+ for (const gone of unmatched) {
474
+ const withCommits = having.get(gone.id);
475
+
476
+ if (!withCommits?.length) continue;
477
+
478
+ const container = containers(gone).find(({ commonDir }) =>
479
+ withCommits.includes(commonDir),
480
+ );
481
+
482
+ owners.set(gone.id, container?.id ?? repositories.get(withCommits[0]!)!);
483
+ }
484
+
485
+ return owners;
486
+ }
487
+
488
+ /** The commits each of these checkouts' boards pin: in their current
489
+ * versions, or in every version. */
490
+ private pinnedCommits(
491
+ checkouts: ReadonlySet<string>,
492
+ versions: "current" | "all",
493
+ ) {
494
+ const pinned = new Map<string, string[]>();
495
+
496
+ if (!checkouts.size) return pinned;
497
+
498
+ for (const row of this.db
499
+ .prepare(
500
+ `SELECT json_extract(snapshot,'$.pins.repositoryId') AS repository,
501
+ json_extract(snapshot,'$.pins.head') AS head,
502
+ json_extract(snapshot,'$.pins.base') AS base
503
+ FROM ${versions === "current" ? "reviews CROSS JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version" : "versions"}
504
+ WHERE json_valid(snapshot) AND json_type(snapshot,'$.pins')='object'`,
505
+ )
506
+ .all()) {
507
+ const repository = String(row.repository);
508
+
509
+ if (!checkouts.has(repository)) continue;
510
+ const commits = pinned.get(repository) ?? [];
511
+
512
+ for (const commit of [String(row.head), String(row.base)])
513
+ if (
514
+ /^[0-9a-f]{40}$/.test(commit) &&
515
+ !/^0+$/.test(commit) &&
516
+ !commits.includes(commit)
517
+ )
518
+ commits.push(commit);
519
+
520
+ if (commits.length) pinned.set(repository, commits);
521
+ }
522
+
523
+ return pinned;
524
+ }
525
+ }
526
+
527
+ /** Every repository asked about every commit pinned. */
528
+ function questionsFor(
529
+ repositories: ReadonlyMap<string, string>,
530
+ pinned: ReadonlyMap<string, string[]>,
531
+ ): CommitQuestions {
532
+ const commits = [...new Set([...pinned.values()].flat())];
533
+
534
+ return new Map(
535
+ commits.length
536
+ ? [...repositories.keys()].map((commonDir) => [commonDir, commits])
537
+ : [],
538
+ );
539
+ }
540
+
541
+ /** The repositories, in order, that have any of these commits. */
542
+ function repositoriesWith(
543
+ repositories: ReadonlyMap<string, string>,
544
+ answers: CommitAnswers,
545
+ commits: string[],
546
+ ) {
547
+ return [...repositories.keys()].filter((commonDir) =>
548
+ commits.some((commit) => answers.get(commonDir)?.has(commit)),
549
+ );
550
+ }
551
+
552
+ /** The commits `git cat-file --batch-check` found. */
553
+ function commitsListed(output: string | undefined) {
554
+ return new Set(
555
+ (output ?? "")
556
+ .split("\n")
557
+ .filter((line) => / commit /.test(line))
558
+ .map((line) => line.split(" ")[0]!),
559
+ );
560
+ }
@@ -1,7 +1,11 @@
1
+ import {
2
+ headingAnchors,
3
+ isBoardHeading,
4
+ markdownHeadingDepth,
5
+ } from "@review/heading-anchors.js";
1
6
  import { markdownText, parseMarkdown } from "@review/markdown.js";
2
- import { slugify, uniqueId } from "@review/slug.js";
3
7
 
4
- import { type Block, elements } from "./document.js";
8
+ import type { Block } from "./document.js";
5
9
 
6
10
  export type ReviewHeadingLevel = "h2" | "h3";
7
11
 
@@ -14,38 +18,65 @@ export interface DocumentHeading {
14
18
  block: Block;
15
19
  /** Position among the markdown block's own headings; absent for a section. */
16
20
  index?: number;
21
+ /** How deep it sits, 1 at the top: a section by its nesting, a Markdown
22
+ * heading one step under its section and under the block's bigger
23
+ * headings before it. */
24
+ depth: number;
17
25
  }
18
26
 
19
27
  /** Heading ids by the rule the MDX renderer published, `slugify(text)` made
20
28
  * unique in document order, so imported `#fragment` links still land. */
21
29
  export function documentHeadings(blocks: Block[]): DocumentHeading[] {
22
- const used = new Set<string>();
30
+ const anchor = headingAnchors();
31
+ const out: DocumentHeading[] = [];
23
32
 
24
- const assign = (heading: Omit<DocumentHeading, "id">): DocumentHeading => {
25
- const id = uniqueId(slugify(heading.text) || "section", used);
26
- used.add(id);
33
+ const assign = (heading: Omit<DocumentHeading, "id">) => {
34
+ out.push({ id: anchor(heading.text), ...heading });
35
+ };
36
+
37
+ /** The Markdown headings open under the current section, by `#` count. */
38
+ let open: number[] = [];
39
+
40
+ const visit = (list: Block[], depth: number) => {
41
+ for (const block of list) {
42
+ if (block.type === "section") {
43
+ assign({
44
+ text: block.title,
45
+ level: depth === 1 ? "h2" : "h3",
46
+ block,
47
+ depth,
48
+ });
49
+ open = [];
50
+ visit(block.children, depth + 1);
51
+ open = [];
52
+ continue;
53
+ }
54
+
55
+ if (block.type === "markdown") {
56
+ let index = 0;
57
+
58
+ for (const node of parseMarkdown(block.markdown).children ?? []) {
59
+ if (node.type !== "heading" || !isBoardHeading(node.depth)) continue;
27
60
 
28
- return { id, ...heading };
61
+ const at = markdownHeadingDepth(open, node.depth!, depth);
62
+
63
+ assign({
64
+ text: markdownText(node),
65
+ level: node.depth === 2 ? "h2" : "h3",
66
+ block,
67
+ index: index++,
68
+ depth: at,
69
+ });
70
+ }
71
+
72
+ continue;
73
+ }
74
+
75
+ if ("children" in block) visit(block.children, depth);
76
+ }
29
77
  };
30
78
 
31
- return elements(blocks).flatMap((block): DocumentHeading[] => {
32
- if (block.type === "section")
33
- return [assign({ text: block.title, level: "h2", block })];
34
-
35
- if (block.type !== "markdown") return [];
36
- let index = 0;
37
-
38
- return (parseMarkdown(block.markdown).children ?? []).flatMap((node) =>
39
- node.type === "heading" && (node.depth === 2 || node.depth === 3)
40
- ? [
41
- assign({
42
- text: markdownText(node),
43
- level: node.depth === 2 ? "h2" : "h3",
44
- block,
45
- index: index++,
46
- }),
47
- ]
48
- : [],
49
- );
50
- });
79
+ visit(blocks, 1);
80
+
81
+ return out;
51
82
  }