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

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 (118) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-B5-HYi1D.js} +16 -9
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-CohHf6Ua.js} +15 -10
  3. package/dist/agent-cli-_YyMIMRY.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-Dtc-Kn_w.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-B7KU04f7.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-C03RKxKS.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-CB9C4uoc.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CJaEb7zC.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-DGDIqh3V.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-BmVJJm9I.js} +42 -14
  14. package/dist/{headless-host-CzvYpKOX.js → headless-host-DRtjf9WA.js} +1 -1
  15. package/dist/{headless-host-CS4Gv0c5.js → headless-host-XhL1X4vX.js} +523 -146
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-Cpl4ENbJ.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-ovmiUE_x.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-BadSkCkI.js} +20 -8
  21. package/dist/profile-DJvkBJNQ.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-Dd-YobM6.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-BXk87KCr.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DuOzsvaD.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-B9qCPiVB.js} +45 -12
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-C7el2Uoy.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-BsoRMC2w.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-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-CjcLyFV5.js} +5 -3
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-CKjw3-6h.js} +6 -5
  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-DvI-gys2.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-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/review-api/README.md +52 -13
  61. package/src/review-api/activity.ts +1 -4
  62. package/src/review-api/agent-cli.ts +9 -1
  63. package/src/review-api/anchor-quotes.ts +14 -7
  64. package/src/review-api/ask-history.ts +137 -51
  65. package/src/review-api/authoring-tools.ts +45 -30
  66. package/src/review-api/blocks/definition.ts +1 -1
  67. package/src/review-api/blocks/markdown.ts +2 -2
  68. package/src/review-api/board-order.ts +144 -0
  69. package/src/review-api/checkout-groups.ts +560 -0
  70. package/src/review-api/document-headings.ts +58 -27
  71. package/src/review-api/document-text.ts +31 -2
  72. package/src/review-api/document.ts +281 -34
  73. package/src/review-api/foreign-references.ts +328 -0
  74. package/src/review-api/github-provider.ts +51 -13
  75. package/src/review-api/http.ts +702 -123
  76. package/src/review-api/instructions.ts +0 -19
  77. package/src/review-api/link-preview.ts +113 -0
  78. package/src/review-api/local-data.ts +310 -46
  79. package/src/review-api/mcp.ts +3 -11
  80. package/src/review-api/onboarding.ts +1 -1
  81. package/src/review-api/profile.ts +37 -6
  82. package/src/review-api/public-tools.ts +24 -2
  83. package/src/review-api/pull-request.ts +82 -49
  84. package/src/review-api/request-origin.ts +10 -0
  85. package/src/review-api/review-progress.ts +61 -27
  86. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  87. package/src/review-api/store-migrations.ts +357 -0
  88. package/src/review-api/store-schema.ts +38 -29
  89. package/src/review-api/store.ts +2144 -480
  90. package/src/review-api/workspaces.ts +52 -13
  91. package/src/review-api/worktree-events.ts +3 -5
  92. package/src/review-api/worktree-source.ts +3 -7
  93. package/src/review-api/worktree-structural.integration.ts +72 -0
  94. package/src/review-stack.ts +15 -23
  95. package/src/runtime.ts +4 -30
  96. package/src/server/desktop-host.ts +6 -1
  97. package/src/server/desktop-server.ts +1 -31
  98. package/src/server/headless-host.ts +21 -14
  99. package/src/server/hono-http.ts +2 -1
  100. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  101. package/src/server/review-gateway-list.ts +3 -5
  102. package/src/server/review-gateway-memory.ts +1 -1
  103. package/src/server/review-lifecycle-telemetry.ts +2 -2
  104. package/src/server/review-server-core.ts +1 -5
  105. package/src/server/structural-comparisons.ts +47 -0
  106. package/src/server-discovery.ts +4 -2
  107. package/src/sharing/export.ts +36 -7
  108. package/src/sharing/host.ts +34 -14
  109. package/src/sharing/import.ts +156 -24
  110. package/src/sharing/repository.ts +128 -20
  111. package/src/startup-trace.ts +3 -3
  112. package/src/stored-document-migration.ts +110 -19
  113. package/src/ui-telemetry-events.ts +33 -11
  114. package/dist/agent-cli-DeCrUC2s.js +0 -2
  115. package/dist/profile-Cz7L781D.js +0 -2
  116. package/dist/runtime-CKiWhGrf.js +0 -52
  117. package/instructions/scratchpad.md +0 -23
  118. package/src/review-preferences.ts +0 -88
@@ -132,7 +132,6 @@ export interface WhiteboardCoreInput {
132
132
  token: string;
133
133
  instanceId: string;
134
134
  softwareMapEnabled?: boolean;
135
- scratchpad: () => boolean;
136
135
  status: () => JsonObject;
137
136
  hooks?: ReviewApiHooks;
138
137
  ask?: {
@@ -192,7 +191,6 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
192
191
  callbacks.open,
193
192
  shared,
194
193
  callbacks.capabilities,
195
- input.scratchpad,
196
194
  () => traceMachineEnabled(),
197
195
  input.status,
198
196
  input.hooks,
@@ -236,9 +234,7 @@ export function relayReviewCallbacks(
236
234
 
237
235
  return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
238
236
  },
239
- async capabilities(): Promise<
240
- Omit<AuthoringCapabilities, "scratchpadEnabled">
241
- > {
237
+ async capabilities(): Promise<AuthoringCapabilities> {
242
238
  if (!relay.attached)
243
239
  return { desktopAvailable: false, softwareMapEnabled };
244
240
 
@@ -1,3 +1,8 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { git, gitCommonDir } from "@dev.fast/local-vcs";
1
6
  import type { StructuralDiffEvent } from "@dev.fast/review-protocol";
2
7
 
3
8
  import {
@@ -18,6 +23,7 @@ export class StructuralComparisons {
18
23
 
19
24
  const key = JSON.stringify([
20
25
  configGeneration,
26
+ await classificationStamp(input.repositoryPath),
21
27
  input.repositoryPath,
22
28
  input.comparison,
23
29
  input.paths,
@@ -66,6 +72,47 @@ export class StructuralComparisons {
66
72
 
67
73
  let configGeneration = 0;
68
74
 
75
+ const classificationFiles = new Map<string, Promise<string[]>>();
76
+
77
+ /** Git attribute and diffr config files that diffr's classifier reads. */
78
+ function classificationInputs(root: string): Promise<string[]> {
79
+ let files = classificationFiles.get(root);
80
+
81
+ if (!files) {
82
+ const config =
83
+ process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
84
+
85
+ const diffrDirectory =
86
+ process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
87
+
88
+ files = Promise.all([
89
+ gitCommonDir(root),
90
+ git(root, ["config", "--path", "core.attributesFile"], {
91
+ allowFailure: true,
92
+ }),
93
+ ]).then(([common, user]) => [
94
+ ...(common ? [path.join(common, "info", "attributes")] : []),
95
+ path.join(root, ".gitattributes"),
96
+ user.stdout.trim() || path.join(config, "git", "attributes"),
97
+ path.join(diffrDirectory, "config.toml"),
98
+ ]);
99
+ classificationFiles.set(root, files);
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ async function classificationStamp(root: string) {
106
+ return Promise.all(
107
+ (await classificationInputs(root)).map((file) =>
108
+ stat(file).then(
109
+ ({ mtimeMs, size }) => [mtimeMs, size],
110
+ () => null,
111
+ ),
112
+ ),
113
+ );
114
+ }
115
+
69
116
  export function invalidateStructuralComparisons(): void {
70
117
  configGeneration++;
71
118
  }
@@ -31,8 +31,10 @@ export function reviewServerDiscoveryPath(stateDir: string) {
31
31
  return path.join(stateDir, "review-server", "server.json");
32
32
  }
33
33
 
34
- /** Sent by a Desktop taking over the server's state directory. */
35
- export const DESKTOP_TAKEOVER_SIGNAL = "SIGUSR2";
34
+ /** Sent by a Desktop taking over the server's state directory. Windows has no
35
+ * SIGUSR2. */
36
+ export const DESKTOP_TAKEOVER_SIGNAL: NodeJS.Signals =
37
+ process.platform === "win32" ? "SIGTERM" : "SIGUSR2";
36
38
 
37
39
  export function headlessServerLockPath(stateDir: string) {
38
40
  return path.join(stateDir, "headless-server.lock");
@@ -14,10 +14,11 @@ import { lensSelections } from "@review/review-api/diff-lenses.js";
14
14
  import { ReviewInputError } from "@review/review-api/document.js";
15
15
  import {
16
16
  anchorPins,
17
+ boardRepositoryId,
17
18
  checkReferences,
18
19
  documentSchema,
19
20
  elements,
20
- hasCodeReferences,
21
+ readsAtBoardPins,
21
22
  resourceReference,
22
23
  resourceReferences,
23
24
  sourceReferences,
@@ -46,13 +47,33 @@ export function digestBytes(bytes: Uint8Array): string {
46
47
  export function validateShareSources(
47
48
  snapshot: Pick<Snapshot, "pins" | "document" | "lenses">,
48
49
  ) {
49
- if (!snapshot.pins && hasCodeReferences(snapshot))
50
+ if (!snapshot.pins && readsAtBoardPins(snapshot))
50
51
  throw new ReviewInputError(
51
52
  "Reviews with code references need source pins before sharing.",
52
53
  409,
53
54
  );
54
55
  }
55
56
 
57
+ /** The commits a board's references read at besides its base and head. A
58
+ * recipient fetches them too. A shared board names no repository of its
59
+ * own, so one stands in for its commit links. */
60
+ export function referenceCommits(
61
+ snapshot: Pick<Snapshot, "pins" | "document" | "target">,
62
+ repositoryId = boardRepositoryId(snapshot) ?? "shared",
63
+ ): string[] {
64
+ const own = new Set([snapshot.pins?.base, snapshot.pins?.head]);
65
+ const commits = new Set<string>();
66
+
67
+ for (const { source } of sourceReferences(snapshot.document, {
68
+ tolerant: true,
69
+ repositoryId,
70
+ }))
71
+ for (const commit of [source.pins?.base, source.pins?.head])
72
+ if (commit && !own.has(commit)) commits.add(commit);
73
+
74
+ return [...commits].sort();
75
+ }
76
+
56
77
  /** Freeze one stored version before any asynchronous source reads. */
57
78
  export async function exportShare(input: {
58
79
  store: ShareExportStore;
@@ -61,16 +82,24 @@ export async function exportShare(input: {
61
82
  version?: number;
62
83
  repository?: ShareManifest["repository"];
63
84
  }): Promise<ShareBundle> {
64
- // A share is the finished document: what edit last touched it stays home.
85
+ const stored = structuredClone(
86
+ input.store.read(input.reviewId, input.version),
87
+ );
88
+
89
+ // Commit links read from the board's repository, which a plan names only
90
+ // in its target.
91
+ const repositoryId = boardRepositoryId(stored);
92
+
93
+ // Local-only fields stay home; the target names a local checkout.
65
94
  const {
66
95
  target,
67
- kind: _kind,
68
96
  staleSources: _staleSources,
69
97
  sourceUnavailable: _sourceUnavailable,
70
98
  lastEdit: _lastEdit,
71
99
  trashedAt: _trashedAt,
100
+ personEdit: _personEdit,
72
101
  ...snapshot
73
- } = structuredClone(input.store.read(input.reviewId, input.version));
102
+ } = stored;
74
103
 
75
104
  if (target?.kind === "worktree")
76
105
  throw new ReviewInputError("Pin this review to commits before sharing it.");
@@ -102,7 +131,7 @@ export async function exportShare(input: {
102
131
 
103
132
  // Lens ranges travel with the snapshot and must resolve at its pins too.
104
133
  const sources = [
105
- ...sourceReferences(snapshot.document),
134
+ ...sourceReferences(snapshot.document, { repositoryId }),
106
135
  ...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
107
136
  sourceAnchors(selection.source).map((source) => ({
108
137
  ...selection,
@@ -116,7 +145,7 @@ export async function exportShare(input: {
116
145
 
117
146
  validateShareSources(snapshot);
118
147
 
119
- if (Boolean(pins) !== Boolean(input.repository))
148
+ if ((pins || referenceCommits(stored).length) && !input.repository)
120
149
  throw new ReviewInputError("Shared source pins require a repository.", 409);
121
150
 
122
151
  for (const block of elements(snapshot.document)) {
@@ -5,7 +5,7 @@ import { parseShareLink, shareIdSchema } from "@dev.fast/review-share-protocol";
5
5
  import {
6
6
  DEFAULT_STORE_ORIGIN,
7
7
  clearStoreAuth,
8
- openUrlInBrowser,
8
+ type openUrlInBrowser,
9
9
  readStoreAuth,
10
10
  runStoreLogin,
11
11
  } from "@dev.fast/trace-core";
@@ -18,13 +18,18 @@ import { z } from "zod";
18
18
 
19
19
  import { readSharingAuth } from "./auth.js";
20
20
  import { ShareAuthError, ShareClient, SharePreflightError } from "./client.js";
21
- import { exportShare, validateShareSources } from "./export.js";
21
+ import {
22
+ exportShare,
23
+ referenceCommits,
24
+ validateShareSources,
25
+ } from "./export.js";
22
26
  import { SharedReviewStore, sharedReviewId } from "./import.js";
23
27
  import { readShareRepository, verifyShareRepository } from "./repository.js";
24
28
 
25
29
  interface LoginState {
26
30
  pending: boolean;
27
31
  url?: string;
32
+ userCode?: string;
28
33
  error?: string;
29
34
  }
30
35
 
@@ -61,7 +66,6 @@ export function mountSharingHost(
61
66
  options: SharingHostOptions = {},
62
67
  ) {
63
68
  const startLogin = options.login ?? runStoreLogin;
64
- const openUrl = options.openUrl ?? openUrlInBrowser;
65
69
 
66
70
  let login: LoginState = {
67
71
  pending: false,
@@ -92,9 +96,9 @@ export function mountSharingHost(
92
96
  traces: false,
93
97
  stdout: discard,
94
98
  stderr: discard,
95
- openUrl: async (url) => {
96
- login.url = url;
97
- await openUrl(url);
99
+ openUrl: options.openUrl,
100
+ onDeviceCode: (device) => {
101
+ Object.assign(login, device);
98
102
  },
99
103
  })
100
104
  .then((code) => {
@@ -223,13 +227,26 @@ export function mountSharingPublisher(
223
227
  409,
224
228
  );
225
229
 
226
- const root = snapshot.pins
227
- ? store.repositoryPath(snapshot.pins.repositoryId)
228
- : undefined;
230
+ const repositoryId =
231
+ snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
232
+
233
+ const root = repositoryId ? store.repositoryPath(repositoryId) : undefined;
229
234
 
230
- const repository = root
231
- ? await (options.readRepository ?? readShareRepository)(root)
232
- : undefined;
235
+ const readRepository = options.readRepository ?? readShareRepository;
236
+
237
+ // Unpinned boards name their repository when they can, so receivers can
238
+ // group them.
239
+ const repository = !root
240
+ ? undefined
241
+ : snapshot.pins
242
+ ? await readRepository(root)
243
+ : await (async () => {
244
+ try {
245
+ return await readRepository(root);
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ })();
233
250
 
234
251
  const bundle = await exportShare({
235
252
  store,
@@ -239,9 +256,12 @@ export function mountSharingPublisher(
239
256
  repository,
240
257
  });
241
258
 
259
+ const commits = referenceCommits(snapshot);
260
+
261
+ // The commits must be on GitHub for a recipient to fetch them.
242
262
  const verification =
243
- root && snapshot.pins && repository
244
- ? verifyRepository(root, snapshot.pins, repository).then(
263
+ root && (snapshot.pins || commits.length) && repository
264
+ ? verifyRepository(root, snapshot.pins, repository, commits).then(
245
265
  () => ({ ok: true as const }),
246
266
  (error: Error) => ({ ok: false as const, error }),
247
267
  )
@@ -28,6 +28,9 @@ import { textIncludesQuote } from "@review/evidence.js";
28
28
  import { lensSchema } from "@review/review-api/diff-lenses.js";
29
29
  import { ReviewInputError } from "@review/review-api/document.js";
30
30
  import {
31
+ type FileLineRange,
32
+ type Pins,
33
+ anchorPins,
31
34
  checkReferences,
32
35
  documentSchema,
33
36
  pinsSchema,
@@ -52,10 +55,16 @@ import { z } from "zod";
52
55
  import {
53
56
  type ShareBundle,
54
57
  digestBytes,
58
+ referenceCommits,
55
59
  validateShareSources,
56
60
  } from "./export.js";
57
61
  import {
62
+ COMMIT_ID,
63
+ assertCommitIds,
64
+ cloneSharedRepository,
58
65
  fetchPinnedRepository,
66
+ fetchSharedCommits,
67
+ forgetSharedCommits,
59
68
  repositoryReady,
60
69
  sharedGit,
61
70
  } from "./repository.js";
@@ -63,11 +72,12 @@ import {
63
72
  export const sharedSnapshotSchema = z.strictObject({
64
73
  reviewId: z.string().min(1),
65
74
  version: z.number().int().nonnegative(),
66
- title: z.string().min(1),
75
+ /** Empty for a board never named; it reads "Untitled". */
76
+ title: z.string(),
67
77
  pins: pinsSchema
68
78
  .extend({
69
- base: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
70
- head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
79
+ base: z.string().regex(COMMIT_ID),
80
+ head: z.string().regex(COMMIT_ID),
71
81
  })
72
82
  .optional(),
73
83
  document: documentSchema,
@@ -90,7 +100,7 @@ const count = z.number().int().nonnegative();
90
100
  const counts = z.strictObject({ additions: count, deletions: count });
91
101
 
92
102
  const mapSchema = z.strictObject({
93
- commit: z.string(),
103
+ commit: z.string().regex(COMMIT_ID),
94
104
  side: z.enum(["base", "head"]),
95
105
  elements: z.array(normalizedSoftwareElementSchema),
96
106
  relationships: z.array(normalizedSoftwareRelationshipSchema),
@@ -166,8 +176,10 @@ export function validateShareBundle(bundle: ShareBundle) {
166
176
  throw new Error("Shared snapshot does not match its manifest.");
167
177
  checkReferences(snapshot.document);
168
178
  validateShareSources(snapshot);
179
+ // Every commit it quotes reaches git as an argument.
180
+ assertCommitIds(referenceCommits(snapshot));
169
181
 
170
- if (Boolean(snapshot.pins) !== Boolean(manifest.repository))
182
+ if (snapshot.pins && !manifest.repository)
171
183
  throw new Error("Shared source pins require a repository.");
172
184
 
173
185
  const presentation = sharePresentationSchema.parse(
@@ -285,6 +297,7 @@ export class SharedReviewStore {
285
297
  constructor(
286
298
  readonly root: string,
287
299
  private readonly fetchRepository = fetchPinnedRepository,
300
+ private readonly cloneRepository = cloneSharedRepository,
288
301
  ) {
289
302
  this.purgeTimer = setInterval(() => {
290
303
  void this.purgeExpired().catch((error) =>
@@ -372,7 +385,7 @@ export class SharedReviewStore {
372
385
  throw new ReviewInputError("Shared review is not available.", 404);
373
386
 
374
387
  if (!saved.snapshot.pins) {
375
- this.setStatus(id, "ready");
388
+ await this.prepareUnpinned(id, saved.manifest.repository?.cloneUrl);
376
389
 
377
390
  return;
378
391
  }
@@ -381,10 +394,12 @@ export class SharedReviewStore {
381
394
  throw new ReviewInputError("Repository service is unavailable.", 409);
382
395
  const root = this.repositoryRoot(id);
383
396
 
397
+ const commits = referenceCommits(saved.snapshot);
398
+
384
399
  try {
385
400
  this.setStatus(id, "fetching");
386
401
 
387
- if (!(await repositoryReady(root, saved.snapshot.pins))) {
402
+ if (!(await repositoryReady(root, saved.snapshot.pins, commits))) {
388
403
  const prior = this.repositories.get(id);
389
404
  await local.data.workspaces.remove(id);
390
405
 
@@ -394,11 +409,13 @@ export class SharedReviewStore {
394
409
  root,
395
410
  saved.manifest.repository!.cloneUrl,
396
411
  saved.snapshot.pins,
412
+ false,
413
+ commits,
397
414
  );
398
415
  await sharedGit(root, [
399
416
  "checkout",
400
417
  "--detach",
401
- saved.snapshot.pins.head,
418
+ { commit: saved.snapshot.pins.head },
402
419
  ]);
403
420
  }
404
421
 
@@ -413,16 +430,7 @@ export class SharedReviewStore {
413
430
  this.setStatus(id, "validating");
414
431
  await local.data.validatePins(pins);
415
432
 
416
- const references = sourceReferences(saved.snapshot.document).map(
417
- (item) => item.source,
418
- );
419
-
420
- for (const map of Object.values(saved.presentation.maps))
421
- for (const element of map.elements)
422
- for (const range of element.sourceRanges ?? [])
423
- references.push({ ...range, side: map.side });
424
-
425
- await local.data.validateSources(pins, references);
433
+ await this.validateReferences(saved, repository.id, pins);
426
434
  await local.data.workspaces.open(id, pins);
427
435
 
428
436
  if (
@@ -449,13 +457,114 @@ export class SharedReviewStore {
449
457
  }
450
458
  }
451
459
 
460
+ /** Reads each of a shared board's references at its own commits, in the
461
+ * checkout of its repository. */
462
+ private async validateReferences(
463
+ saved: ReturnType<typeof validateShareBundle>,
464
+ repositoryId: string,
465
+ pins?: Pins,
466
+ ) {
467
+ const references = new Map<
468
+ string,
469
+ { pins: Pins; ranges: FileLineRange[] }
470
+ >();
471
+
472
+ const add = (at: Pins, range: FileLineRange) => {
473
+ const key = JSON.stringify([at.base, at.head]);
474
+ const group = references.get(key) ?? { pins: at, ranges: [] };
475
+
476
+ group.ranges.push(range);
477
+ references.set(key, group);
478
+ };
479
+
480
+ for (const { source } of sourceReferences(saved.snapshot.document, {
481
+ repositoryId,
482
+ }))
483
+ add({ ...anchorPins(source, pins), repositoryId }, source);
484
+
485
+ if (pins)
486
+ for (const map of Object.values(saved.presentation.maps))
487
+ for (const element of map.elements)
488
+ for (const range of element.sourceRanges ?? [])
489
+ add(pins, { ...range, side: map.side });
490
+
491
+ for (const group of references.values())
492
+ await this.local!.data.validateSources(group.pins, group.ranges);
493
+ }
494
+
495
+ /** Files an unpinned board under a local checkout of its repository,
496
+ * else a managed clone. */
497
+ private async prepareUnpinned(id: string, cloneUrl: string | undefined) {
498
+ const local = this.local;
499
+ const saved = this.validated.get(id)!;
500
+
501
+ if (!cloneUrl || !local) {
502
+ this.setStatus(id, "ready");
503
+
504
+ return;
505
+ }
506
+
507
+ const root = this.repositoryRoot(id);
508
+
509
+ try {
510
+ this.setStatus(id, "fetching");
511
+ const managed = path.join(await realpath(this.root), ".repositories");
512
+
513
+ let repositoryId = local.store
514
+ .checkoutsOf(cloneUrl)
515
+ .find(
516
+ (checkout) =>
517
+ !local.store.repositoryPath(checkout).startsWith(managed),
518
+ );
519
+
520
+ if (!repositoryId) {
521
+ if (!existsSync(path.join(root, ".git"))) {
522
+ await rm(root, { recursive: true, force: true });
523
+ await this.cloneRepository(root, cloneUrl);
524
+ }
525
+
526
+ repositoryId = (await local.data.register(root)).id;
527
+ }
528
+
529
+ // A plan's commit links read at commits the checkout may not have.
530
+ const checkout = local.store.repositoryPath(repositoryId);
531
+
532
+ await fetchSharedCommits(
533
+ checkout,
534
+ referenceCommits(saved.snapshot, repositoryId),
535
+ id,
536
+ );
537
+ this.setStatus(id, "validating");
538
+ await this.validateReferences(saved, repositoryId);
539
+ this.repositories.set(id, repositoryId);
540
+ await writeFile(
541
+ path.join(this.root, id, "repository.json"),
542
+ JSON.stringify({ repositoryId, ready: true }),
543
+ { mode: 0o600 },
544
+ );
545
+ this.setStatus(id, "ready");
546
+ } catch (error) {
547
+ const message =
548
+ error instanceof ReviewInputError
549
+ ? error.message
550
+ : "Could not prepare the shared repository. Check Git credentials and retry.";
551
+
552
+ this.setStatus(id, "error", message);
553
+ throw new ReviewInputError(message, 409);
554
+ }
555
+ }
556
+
452
557
  async assertReady(id: string) {
453
558
  const saved = this.validated.get(id);
454
559
 
455
560
  if (
456
561
  !saved ||
457
562
  (saved.snapshot.pins &&
458
- !(await repositoryReady(this.repositoryRoot(id), saved.snapshot.pins)))
563
+ !(await repositoryReady(
564
+ this.repositoryRoot(id),
565
+ saved.snapshot.pins,
566
+ referenceCommits(saved.snapshot),
567
+ )))
459
568
  ) {
460
569
  this.setStatus(
461
570
  id,
@@ -517,9 +626,14 @@ export class SharedReviewStore {
517
626
  ),
518
627
  );
519
628
 
629
+ // A board with no pins may use the receiver's own checkout.
520
630
  if (
521
631
  this.local?.store.repositoryPath(metadata.repositoryId) ===
522
- path.join(await realpath(this.root), ".repositories", id)
632
+ path.join(await realpath(this.root), ".repositories", id) ||
633
+ (!validated.snapshot.pins &&
634
+ this.local?.store
635
+ .repositories()
636
+ .some((repository) => repository.id === metadata.repositoryId))
523
637
  ) {
524
638
  this.repositories.set(id, metadata.repositoryId);
525
639
  prepared = metadata.ready;
@@ -528,13 +642,17 @@ export class SharedReviewStore {
528
642
  /* A first or interrupted import has no local registration yet. */
529
643
  }
530
644
 
531
- if (!validated.snapshot.pins) this.setStatus(id, "ready");
532
- else if (
645
+ if (!validated.snapshot.pins) {
646
+ if (this.repositories.has(id) || !validated.manifest.repository)
647
+ this.setStatus(id, "ready");
648
+ else void this.prepare(id).catch(() => {});
649
+ } else if (
533
650
  prepared &&
534
651
  this.local &&
535
652
  (await repositoryReady(
536
653
  this.repositoryRoot(id),
537
654
  validated.snapshot.pins,
655
+ referenceCommits(validated.snapshot),
538
656
  ))
539
657
  ) {
540
658
  const repository = await this.local.data.register(
@@ -637,7 +755,9 @@ export class SharedReviewStore {
637
755
  ...validated.snapshot.pins,
638
756
  repositoryId: repositoryId!,
639
757
  }
640
- : undefined,
758
+ : repositoryId
759
+ ? { kind: "project" as const, repositoryId }
760
+ : undefined,
641
761
  shared: {
642
762
  ...bundle.attribution,
643
763
  cloneUrl: bundle.manifest.repository?.cloneUrl,
@@ -658,11 +778,16 @@ export class SharedReviewStore {
658
778
  .map((id) => {
659
779
  const { document: _document, ...snapshot } = this.get(id).snapshot;
660
780
 
781
+ const project = snapshot.target
782
+ ? this.local?.store.checkoutProject(snapshot.target.repositoryId)
783
+ : undefined;
784
+
661
785
  return {
662
786
  ...snapshot,
663
787
  firstCreatedAt:
664
788
  snapshot.version === 0 ? snapshot.createdAt : undefined,
665
789
  repositoryName: "Shared review",
790
+ ...(project && { project }),
666
791
  viewedAt: this.attention.get(id)?.viewedAt ?? null,
667
792
  trashedAt: this.attention.get(id)?.trashedAt ?? null,
668
793
  };
@@ -700,8 +825,15 @@ export class SharedReviewStore {
700
825
  const repositoryId = this.repositories.get(id);
701
826
 
702
827
  if (repositoryId) {
828
+ const checkout = this.local?.store.repositoryPath(repositoryId);
829
+
830
+ // The commits kept for its links go with it.
831
+ if (checkout)
832
+ await forgetSharedCommits(checkout, id).catch((error) =>
833
+ console.error(`Could not drop the refs kept for share ${id}:`, error),
834
+ );
703
835
  await this.local?.data.forgetRepository(repositoryId);
704
- this.local?.store.unregisterRepository(repositoryId);
836
+ await this.local?.store.unregisterRepository(repositoryId);
705
837
  }
706
838
 
707
839
  await rm(this.repositoryRoot(id), { recursive: true, force: true });