@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261020.130
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/dist/{account-alias-CprZojXd.js → account-alias-CMyXCuuq.js} +13 -8
- package/dist/{agent-cli-CThN7Q59.js → agent-cli-CahKfDT0.js} +15 -10
- package/dist/agent-cli-CvYV2R6c.js +2 -0
- package/dist/{agent-client-BOOi7EUR.js → agent-client-D2RmE9NL.js} +13 -3
- package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BLhYKrNN.js} +34 -27
- package/dist/{background-server-DLSDogkI.js → background-server-CFUU4VtN.js} +1 -1
- package/dist/{background-server-CkvxA-6O.js → background-server-k4K_Hvh1.js} +5 -5
- package/dist/build-info.json +1 -1
- package/dist/{cli-R4yfa5DT.js → cli-BUVSMGud.js} +1 -1
- package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-C7W6VA94.js} +26 -26
- package/dist/cli.js +4 -3
- package/dist/{client-BN_FDxFr.js → client-B0f4EJ7v.js} +253 -47
- package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-BbaX-dGh.js} +6 -4
- package/dist/{headless-host-Cog6SpS9.js → headless-host-C7xYMe74.js} +513 -140
- package/dist/{headless-host-CVBOQyRS.js → headless-host-cYdlRYPB.js} +1 -1
- package/dist/lens-selection-CAtsGaym.js +214 -0
- package/dist/{local-data-D_9tvdsp.js → local-data-DrNSaiS5.js} +2416 -320
- package/dist/{mcp-D1hziANa.js → mcp-yXnjUkOV.js} +6 -10
- package/dist/{package-paths-C--__jEv.js → package-paths-BWxO-UEL.js} +1 -1
- package/dist/{profile-DokFw782.js → profile-BlqZlCPZ.js} +20 -8
- package/dist/profile-CGxNfeYQ.js +2 -0
- package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-CZTrtWIk.js} +4 -4
- package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CNXwqK0D.js} +3 -3
- package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-VP-kfq0C.js} +1 -1
- package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-BiPzol99.js} +5 -5
- package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-CFTkoioA.js} +6 -6
- package/dist/{request-origin-Zcn_CjX4.js → request-origin--8lLCclv.js} +55 -14
- package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-KA2_mOeo.js} +2 -2
- package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-D8AoCdd2.js} +19 -17
- package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-B2JniTIs.js} +5 -5
- package/dist/runtime-Bj2tD33R.js +27 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
- package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
- package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
- package/dist/server/desktop-host.js +29 -76
- package/dist/{server-discovery-Bey8IqXH.js → server-discovery-CRXG48Uz.js} +3 -3
- package/dist/sharing/index.d.ts +484 -70
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DsM3jO8G.js → src-CyYCvVHx.js} +90 -17
- package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
- package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B_BijGHF.js} +270 -269
- package/dist/{thread-state-5pYr6_zM.js → thread-state-D6MlnGv7.js} +7 -2
- package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-_makh_Sr.js} +10 -3
- package/instructions/authoring.md +18 -5
- package/package.json +6 -6
- package/src/ask/agents.ts +13 -1
- package/src/ask/checkout-files.ts +2 -5
- package/src/ask/thread-state.ts +4 -0
- package/src/ask/thread.ts +16 -0
- package/src/ask/threads.ts +24 -3
- package/src/cli-install.ts +13 -2
- package/src/cli-runner.ts +4 -1
- package/src/cli.ts +4 -1
- package/src/diff-selection-migration.ts +48 -34
- package/src/heading-anchors.ts +42 -0
- package/src/lens-selection.ts +38 -2
- package/src/review-api/README.md +52 -13
- package/src/review-api/activity.ts +1 -4
- package/src/review-api/agent-cli.ts +9 -1
- package/src/review-api/anchor-quotes.ts +14 -7
- package/src/review-api/ask-history.ts +137 -51
- package/src/review-api/authoring-tools.ts +45 -30
- package/src/review-api/blocks/definition.ts +1 -1
- package/src/review-api/blocks/markdown.ts +2 -2
- package/src/review-api/board-order.ts +144 -0
- package/src/review-api/checkout-groups.ts +560 -0
- package/src/review-api/document-headings.ts +58 -27
- package/src/review-api/document-text.ts +31 -2
- package/src/review-api/document.ts +281 -34
- package/src/review-api/foreign-references.ts +328 -0
- package/src/review-api/github-provider.ts +51 -13
- package/src/review-api/http.ts +702 -123
- package/src/review-api/instructions.ts +0 -19
- package/src/review-api/link-preview.ts +113 -0
- package/src/review-api/local-data.ts +310 -46
- package/src/review-api/mcp.ts +4 -12
- package/src/review-api/onboarding.ts +1 -1
- package/src/review-api/profile.ts +37 -6
- package/src/review-api/public-tools.ts +24 -2
- package/src/review-api/pull-request.ts +82 -49
- package/src/review-api/request-origin.ts +10 -0
- package/src/review-api/review-progress.ts +61 -27
- package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
- package/src/review-api/store-migrations.ts +357 -0
- package/src/review-api/store-schema.ts +38 -29
- package/src/review-api/store.ts +2144 -480
- package/src/review-api/worktree-events.ts +3 -5
- package/src/review-api/worktree-source.ts +3 -7
- package/src/review-api/worktree-structural.integration.ts +72 -0
- package/src/review-stack.ts +15 -23
- package/src/runtime.ts +4 -30
- package/src/server/desktop-host.ts +6 -1
- package/src/server/desktop-server.ts +1 -31
- package/src/server/headless-host.ts +0 -2
- package/src/server/hono-http.ts +2 -1
- package/src/server/legacy-tutorial-cleanup.ts +2 -3
- package/src/server/review-gateway-list.ts +3 -5
- package/src/server/review-gateway-memory.ts +1 -1
- package/src/server/review-lifecycle-telemetry.ts +2 -2
- package/src/server/review-server-core.ts +1 -5
- package/src/server/structural-comparisons.ts +47 -0
- package/src/sharing/export.ts +36 -7
- package/src/sharing/host.ts +34 -14
- package/src/sharing/import.ts +156 -24
- package/src/sharing/repository.ts +128 -20
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +110 -19
- package/src/ui-telemetry-events.ts +44 -14
- package/dist/agent-cli-C6SPSII0.js +0 -2
- package/dist/profile-CYPmvBoA.js +0 -2
- package/dist/runtime-CKiWhGrf.js +0 -52
- package/instructions/scratchpad.md +0 -23
- package/src/review-preferences.ts +0 -88
package/src/sharing/host.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
const repositoryId =
|
|
231
|
+
snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
|
|
232
|
+
|
|
233
|
+
const root = repositoryId ? store.repositoryPath(repositoryId) : undefined;
|
|
229
234
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
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
|
)
|
package/src/sharing/import.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
70
|
-
head: z.string().regex(
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
532
|
-
|
|
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
|
-
:
|
|
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 });
|
|
@@ -2,12 +2,25 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import { gitAt } from "@dev.fast/local-vcs";
|
|
5
|
+
import { type VcsArg, gitAt } from "@dev.fast/local-vcs";
|
|
6
6
|
import { normalizeGitHubRemote } from "@dev.fast/review-share-protocol";
|
|
7
7
|
import { type Pins, ReviewInputError } from "@review/review-api/document.js";
|
|
8
8
|
|
|
9
|
+
/** A full commit id, SHA-1 or SHA-256. */
|
|
10
|
+
export const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
11
|
+
|
|
12
|
+
/** Refuses anything but full commit ids, before any of them reaches git:
|
|
13
|
+
* a shared board's commits come from someone else, and one spelled as an
|
|
14
|
+
* option or a refspec would make git do something else. */
|
|
15
|
+
export function assertCommitIds(commits: readonly string[]) {
|
|
16
|
+
if (!commits.every((commit) => COMMIT_ID.test(commit)))
|
|
17
|
+
throw new ReviewInputError(
|
|
18
|
+
"The shared whiteboard names a commit that isn't a full commit id.",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
/** Keep credential helpers but prevent the calling shell from redirecting Git's object store. */
|
|
10
|
-
export function sharedGit(cwd: string, args:
|
|
23
|
+
export function sharedGit(cwd: string, args: VcsArg[]) {
|
|
11
24
|
const env = Object.fromEntries(
|
|
12
25
|
Object.entries(process.env).filter(
|
|
13
26
|
([key]) =>
|
|
@@ -25,7 +38,7 @@ export function sharedGit(cwd: string, args: string[]) {
|
|
|
25
38
|
cwd,
|
|
26
39
|
["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args],
|
|
27
40
|
{
|
|
28
|
-
env
|
|
41
|
+
env,
|
|
29
42
|
timeout: 300_000,
|
|
30
43
|
},
|
|
31
44
|
);
|
|
@@ -46,18 +59,28 @@ function fetchError(stderr: string, publishing: boolean): ReviewInputError {
|
|
|
46
59
|
);
|
|
47
60
|
}
|
|
48
61
|
|
|
49
|
-
/** Recipients need complete history; publication only checks that the commits exist.
|
|
62
|
+
/** Recipients need complete history; publication only checks that the commits exist.
|
|
63
|
+
* `commits` are other commits the board's references read at; a plan has
|
|
64
|
+
* only those. */
|
|
50
65
|
export async function fetchPinnedRepository(
|
|
51
66
|
root: string,
|
|
52
67
|
url: string,
|
|
53
|
-
pins: Pick<Pins, "base" | "head"
|
|
68
|
+
pins: Pick<Pins, "base" | "head"> | undefined,
|
|
54
69
|
publishing = false,
|
|
70
|
+
commits: readonly string[] = [],
|
|
55
71
|
) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
// Each commit and the ref it is fetched to.
|
|
73
|
+
const named = [
|
|
74
|
+
...(pins
|
|
75
|
+
? [
|
|
76
|
+
{ commit: pins.base, ref: "base" },
|
|
77
|
+
{ commit: pins.head, ref: "head" },
|
|
78
|
+
]
|
|
79
|
+
: []),
|
|
80
|
+
...commits.map((commit) => ({ commit, ref: `commits/${commit}` })),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (!named.every(({ commit }) => COMMIT_ID.test(commit)))
|
|
61
84
|
throw new ReviewInputError(
|
|
62
85
|
"Sharing requires immutable base and head commits.",
|
|
63
86
|
);
|
|
@@ -67,7 +90,7 @@ export async function fetchPinnedRepository(
|
|
|
67
90
|
if (!publishing) {
|
|
68
91
|
await sharedGit(root, ["config", "core.hooksPath", "/dev/null"]);
|
|
69
92
|
await sharedGit(root, ["config", "core.fsmonitor", "false"]);
|
|
70
|
-
await sharedGit(root, ["remote", "add", "origin", url]);
|
|
93
|
+
await sharedGit(root, ["remote", "add", "origin", { remote: url }]);
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
// Branch refs make Git reject non-commit objects during the fetch itself.
|
|
@@ -79,9 +102,10 @@ export async function fetchPinnedRepository(
|
|
|
79
102
|
"--no-tags",
|
|
80
103
|
"--force",
|
|
81
104
|
...(publishing ? ["--depth=1", "--filter=tree:0"] : []),
|
|
82
|
-
publishing ? url : "origin",
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
{ remote: publishing ? url : "origin" },
|
|
106
|
+
...named.map(({ commit, ref }) => ({
|
|
107
|
+
refspec: { from: commit, to: `${refs}/${ref}` },
|
|
108
|
+
})),
|
|
85
109
|
]);
|
|
86
110
|
} catch (error) {
|
|
87
111
|
throw fetchError(
|
|
@@ -91,21 +115,104 @@ export async function fetchPinnedRepository(
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
if (!publishing)
|
|
94
|
-
for (const commit of
|
|
95
|
-
await sharedGit(root, ["cat-file", "-e", `${commit}^{commit}`]);
|
|
118
|
+
for (const { commit } of named)
|
|
119
|
+
await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Fetches the commits a shared board's links read at into a checkout of
|
|
123
|
+
* its repository, from its origin unless it has them, and keeps each under
|
|
124
|
+
* `refs/review/shared/<share>/<commit>`, so `git gc` can't prune one the
|
|
125
|
+
* board still quotes. */
|
|
126
|
+
export async function fetchSharedCommits(
|
|
127
|
+
root: string,
|
|
128
|
+
commits: readonly string[],
|
|
129
|
+
share: string,
|
|
130
|
+
) {
|
|
131
|
+
assertCommitIds(commits);
|
|
132
|
+
|
|
133
|
+
const ref = (commit: string) => `refs/review/shared/${share}/${commit}`;
|
|
134
|
+
const missing: string[] = [];
|
|
135
|
+
|
|
136
|
+
for (const commit of commits)
|
|
137
|
+
try {
|
|
138
|
+
await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
|
|
139
|
+
await sharedGit(root, ["update-ref", { ref: ref(commit) }, { commit }]);
|
|
140
|
+
} catch {
|
|
141
|
+
missing.push(commit);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!missing.length) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await sharedGit(root, [
|
|
148
|
+
"fetch",
|
|
149
|
+
"--no-tags",
|
|
150
|
+
"origin",
|
|
151
|
+
...missing.map((commit) => ({
|
|
152
|
+
refspec: { from: commit, to: ref(commit), force: true },
|
|
153
|
+
})),
|
|
154
|
+
]);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw fetchError(
|
|
157
|
+
error instanceof Error && "stderr" in error ? String(error.stderr) : "",
|
|
158
|
+
false,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Drops the refs `fetchSharedCommits` kept for a share, once the share is
|
|
164
|
+
* gone, so `git gc` can prune what only it quoted. */
|
|
165
|
+
export async function forgetSharedCommits(root: string, share: string) {
|
|
166
|
+
const { stdout } = await sharedGit(root, [
|
|
167
|
+
"for-each-ref",
|
|
168
|
+
"--format=%(refname)",
|
|
169
|
+
{ ref: `refs/review/shared/${share}/` },
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
for (const ref of stdout.split("\n").filter(Boolean))
|
|
173
|
+
await sharedGit(root, ["update-ref", "-d", { ref }]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A managed clone of a repository's default branch, for a shared board
|
|
177
|
+
* with no pins whose repository the receiver has no checkout of. */
|
|
178
|
+
export async function cloneSharedRepository(root: string, url: string) {
|
|
179
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
180
|
+
await sharedGit(root, ["init", "--quiet"]);
|
|
181
|
+
await sharedGit(root, ["config", "core.hooksPath", "/dev/null"]);
|
|
182
|
+
await sharedGit(root, ["config", "core.fsmonitor", "false"]);
|
|
183
|
+
await sharedGit(root, ["remote", "add", "origin", { remote: url }]);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await sharedGit(root, ["fetch", "--no-tags", "origin", "HEAD"]);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw fetchError(
|
|
189
|
+
error instanceof Error && "stderr" in error ? String(error.stderr) : "",
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await sharedGit(root, ["checkout", "--detach", "FETCH_HEAD"]);
|
|
96
195
|
}
|
|
97
196
|
|
|
98
197
|
export async function repositoryReady(
|
|
99
198
|
root: string,
|
|
100
199
|
pins: Pick<Pins, "base" | "head">,
|
|
200
|
+
commits: readonly string[] = [],
|
|
101
201
|
): Promise<boolean> {
|
|
202
|
+
if (
|
|
203
|
+
![pins.base, pins.head, ...commits].every((commit) =>
|
|
204
|
+
COMMIT_ID.test(commit),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return false;
|
|
208
|
+
|
|
102
209
|
try {
|
|
103
210
|
const head = await sharedGit(root, ["rev-parse", "HEAD"]);
|
|
104
211
|
|
|
105
212
|
if (head.stdout.trim() !== pins.head) return false;
|
|
106
213
|
|
|
107
|
-
for (const commit of [pins.base, pins.head])
|
|
108
|
-
await sharedGit(root, ["cat-file", "-e", `${commit}^{commit}`]);
|
|
214
|
+
for (const commit of [pins.base, pins.head, ...commits])
|
|
215
|
+
await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
|
|
109
216
|
|
|
110
217
|
return true;
|
|
111
218
|
} catch {
|
|
@@ -129,14 +236,15 @@ export async function readShareRepository(root: string) {
|
|
|
129
236
|
|
|
130
237
|
export async function verifyShareRepository(
|
|
131
238
|
root: string,
|
|
132
|
-
pins: Pins,
|
|
239
|
+
pins: Pins | undefined,
|
|
133
240
|
repository?: { cloneUrl: string },
|
|
241
|
+
commits: readonly string[] = [],
|
|
134
242
|
) {
|
|
135
243
|
const { cloneUrl } = repository ?? (await readShareRepository(root));
|
|
136
244
|
const temporary = await mkdtemp(path.join(tmpdir(), "review-share-check-"));
|
|
137
245
|
|
|
138
246
|
try {
|
|
139
|
-
await fetchPinnedRepository(temporary, cloneUrl, pins, true);
|
|
247
|
+
await fetchPinnedRepository(temporary, cloneUrl, pins, true, commits);
|
|
140
248
|
} finally {
|
|
141
249
|
await rm(temporary, { recursive: true, force: true });
|
|
142
250
|
}
|
package/src/startup-trace.ts
CHANGED
|
@@ -221,9 +221,9 @@ if (traceEnabled) {
|
|
|
221
221
|
// Every git/jj spawn inside @dev.fast/local-vcs becomes a `$ …` span under
|
|
222
222
|
// whatever span is active in the caller's async context.
|
|
223
223
|
setLocalVcsCommandObserver({
|
|
224
|
-
start({
|
|
225
|
-
const handle = startSpan(commandSpanName(
|
|
226
|
-
detail: commandDetail(
|
|
224
|
+
start({ tool, args, cwd }) {
|
|
225
|
+
const handle = startSpan(commandSpanName(tool, args), {
|
|
226
|
+
detail: commandDetail(tool, args, cwd),
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
return ({ ok }) => (ok ? handle.end() : handle.fail());
|