@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261021.131
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-CisiYUZT.js} +13 -8
- package/dist/agent-cli-BglNRDZN.js +2 -0
- package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
- package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
- package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
- package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
- package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
- package/dist/build-info.json +1 -1
- package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
- package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
- package/dist/cli.js +4 -3
- package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
- package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
- package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
- package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
- package/dist/lens-selection-BxPk53c8.js +214 -0
- package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
- package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
- package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
- package/dist/profile-Dbyidrcr.js +2 -0
- package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
- package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
- package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
- package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
- package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
- package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
- package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
- package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
- package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
- package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
- package/dist/runtime-CF4JXdb8.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 +28 -104
- package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
- package/dist/sharing/index.d.ts +484 -74
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
- package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
- package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
- package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
- package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
- package/instructions/authoring.md +20 -5
- package/package.json +4 -4
- package/src/agent-selection.ts +6 -0
- 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 +61 -31
- 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/markdown.ts +42 -0
- package/src/posthog-capture-client.ts +1 -1
- 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 +118 -51
- package/src/review-api/authoring-tools.ts +45 -30
- package/src/review-api/azure-devops-provider.ts +2 -20
- package/src/review-api/azure-remote.ts +21 -0
- 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 +586 -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 +362 -67
- package/src/review-api/foreign-references.ts +328 -0
- package/src/review-api/github-provider.ts +51 -13
- package/src/review-api/http.ts +734 -135
- 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 +7 -13
- 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 +355 -0
- package/src/review-api/store-schema.ts +38 -29
- package/src/review-api/store.ts +2176 -495
- 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 -36
- package/src/server/headless-host.ts +0 -2
- package/src/server/hono-http.ts +2 -1
- 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 +143 -24
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +110 -19
- package/src/ui-telemetry-events.ts +44 -18
- 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/server/legacy-tutorial-cleanup.ts +0 -38
package/src/review-api/store.ts
CHANGED
|
@@ -2,17 +2,20 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import { type JsonValue, isJsonObject } from "@dev.fast/json";
|
|
8
|
+
import { runVcs } from "@dev.fast/local-vcs";
|
|
7
9
|
import {
|
|
10
|
+
type BoardIconKind,
|
|
8
11
|
type ReviewApiSummary,
|
|
9
|
-
|
|
12
|
+
boardIconKind,
|
|
13
|
+
boardTitle,
|
|
10
14
|
} from "@dev.fast/review-protocol";
|
|
11
15
|
import { sourceAnchors } from "@review/lens-selection.js";
|
|
12
16
|
import {
|
|
13
|
-
liftFileLenses,
|
|
14
|
-
migrateStoredDocument,
|
|
15
17
|
migrateStoredLenses,
|
|
18
|
+
readStoredDocument,
|
|
16
19
|
} from "@review/stored-document-migration.js";
|
|
17
20
|
import {
|
|
18
21
|
type Coverage,
|
|
@@ -24,6 +27,12 @@ import { z } from "zod";
|
|
|
24
27
|
|
|
25
28
|
import { type ActivitySurface, ReviewActivity } from "./activity.js";
|
|
26
29
|
import { AskHistory } from "./ask-history.js";
|
|
30
|
+
import {
|
|
31
|
+
type SiblingFacts,
|
|
32
|
+
dependencyCycle,
|
|
33
|
+
orderSiblings,
|
|
34
|
+
} from "./board-order.js";
|
|
35
|
+
import { CheckoutGroups, remoteGroup } from "./checkout-groups.js";
|
|
27
36
|
import {
|
|
28
37
|
type Lens,
|
|
29
38
|
applyLensEdit,
|
|
@@ -33,49 +42,54 @@ import {
|
|
|
33
42
|
import {
|
|
34
43
|
type Applied,
|
|
35
44
|
type Block,
|
|
45
|
+
type BoardLinkRef,
|
|
36
46
|
type EditSummary,
|
|
37
47
|
type Element,
|
|
38
48
|
type FileLineRange,
|
|
39
49
|
type Pins,
|
|
40
50
|
ReviewInputError,
|
|
41
51
|
type ReviewTarget,
|
|
52
|
+
type SourcePins,
|
|
42
53
|
type WrittenComponent,
|
|
54
|
+
adoptFlowUnits,
|
|
43
55
|
anchorPins,
|
|
44
56
|
applyEdit,
|
|
45
57
|
assignFreshIds,
|
|
58
|
+
assignMissingIds,
|
|
59
|
+
boardLinks,
|
|
60
|
+
boardRepositoryId,
|
|
46
61
|
checkReferences,
|
|
62
|
+
commitLinkCommits,
|
|
47
63
|
documentAdvice,
|
|
48
64
|
documentSchema,
|
|
49
65
|
editSchema,
|
|
50
66
|
elements,
|
|
51
67
|
explicitPins,
|
|
52
|
-
|
|
68
|
+
pinDocumentReferences,
|
|
53
69
|
pinnedRepositoryIds,
|
|
54
70
|
resourceReferences,
|
|
71
|
+
respellCommitLinks,
|
|
72
|
+
respellDocumentLinks,
|
|
73
|
+
respellEditLinks,
|
|
55
74
|
reviewTargetSchema,
|
|
56
75
|
sourceReferences,
|
|
57
76
|
summarizeEdit,
|
|
58
77
|
} from "./document.js";
|
|
78
|
+
import { type BoardLinkPreview, boardLinkPreview } from "./link-preview.js";
|
|
59
79
|
import { pullRequestKey, pullRequestUrl, setPullRequest } from "./origin.js";
|
|
60
|
-
import {
|
|
80
|
+
import { upgradeStore } from "./store-migrations.js";
|
|
81
|
+
import {
|
|
82
|
+
BOARD_REPOSITORY_ID_SQL,
|
|
83
|
+
initializeReviewStoreSchema,
|
|
84
|
+
} from "./store-schema.js";
|
|
61
85
|
|
|
62
86
|
const reviewId = z.string().min(1);
|
|
63
87
|
|
|
64
88
|
/** The key older hosts read and write. */
|
|
65
89
|
const statsKey = (pins: Pins) => JSON.stringify([pins, "structural"]);
|
|
66
90
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const DIAGRAM_TYPES = new Set([
|
|
71
|
-
"sequence",
|
|
72
|
-
"flow_diagram",
|
|
73
|
-
"call_stack_diff",
|
|
74
|
-
"database_lens",
|
|
75
|
-
"software_map",
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
export const SCRATCHPAD_TITLE = "Scratchpad";
|
|
91
|
+
/** How long a board stays in Trash before it is deleted. */
|
|
92
|
+
const TRASH_DAYS = 30;
|
|
79
93
|
|
|
80
94
|
const activityId = z
|
|
81
95
|
.string()
|
|
@@ -85,6 +99,16 @@ const activityId = z
|
|
|
85
99
|
"The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.",
|
|
86
100
|
);
|
|
87
101
|
|
|
102
|
+
const parentId = reviewId.describe(
|
|
103
|
+
"The reviewId of a top-level review in the same project to nest this one under.",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const dependsOn = z
|
|
107
|
+
.array(reviewId)
|
|
108
|
+
.describe(
|
|
109
|
+
"The reviewIds of reviews in the same project this one builds on. Siblings list after what they depend on.",
|
|
110
|
+
);
|
|
111
|
+
|
|
88
112
|
export const commandSchema = z.strictObject({
|
|
89
113
|
operation: z.discriminatedUnion("type", [
|
|
90
114
|
z.strictObject({ type: z.literal("delete"), reviewId }),
|
|
@@ -100,8 +124,10 @@ export const commandSchema = z.strictObject({
|
|
|
100
124
|
}),
|
|
101
125
|
z.strictObject({
|
|
102
126
|
type: z.literal("create"),
|
|
103
|
-
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
104
|
-
|
|
127
|
+
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
128
|
+
* title. A person's new board may start with none; it reads
|
|
129
|
+
* "Untitled" until they name it. */
|
|
130
|
+
title: z.string().trim().optional(),
|
|
105
131
|
target: reviewTargetSchema.optional(),
|
|
106
132
|
pullRequestUrl: pullRequestUrl.optional(),
|
|
107
133
|
/** With pullRequestUrl and no target: the checkout to fetch the PR into. */
|
|
@@ -110,7 +136,7 @@ export const commandSchema = z.strictObject({
|
|
|
110
136
|
.min(1)
|
|
111
137
|
.optional()
|
|
112
138
|
.describe(
|
|
113
|
-
"Only with pullRequestUrl and no target: the registered checkout to fetch the PR into.
|
|
139
|
+
"Only with pullRequestUrl and no target: the registered checkout to fetch the PR into.",
|
|
114
140
|
),
|
|
115
141
|
/** Return the existing review for pullRequestUrl instead of creating one. */
|
|
116
142
|
reuseExisting: z
|
|
@@ -119,8 +145,25 @@ export const commandSchema = z.strictObject({
|
|
|
119
145
|
.describe(
|
|
120
146
|
"Default true: return the existing review for pullRequestUrl. false creates a separate review.",
|
|
121
147
|
),
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
projectId: z
|
|
149
|
+
.string()
|
|
150
|
+
.min(1)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe(
|
|
153
|
+
"The project to file the whiteboard in: a project.id from session_list. Defaults to its checkout's.",
|
|
154
|
+
),
|
|
155
|
+
parentId: parentId.optional(),
|
|
156
|
+
dependsOn: dependsOn.optional(),
|
|
157
|
+
}),
|
|
158
|
+
z.strictObject({
|
|
159
|
+
type: z.literal("set_parent"),
|
|
160
|
+
reviewId,
|
|
161
|
+
parentId: parentId.nullable(),
|
|
162
|
+
}),
|
|
163
|
+
z.strictObject({
|
|
164
|
+
type: z.literal("set_dependencies"),
|
|
165
|
+
reviewId,
|
|
166
|
+
dependsOn,
|
|
124
167
|
}),
|
|
125
168
|
z.strictObject({
|
|
126
169
|
type: z.literal("set_target"),
|
|
@@ -153,6 +196,50 @@ export const commandSchema = z.strictObject({
|
|
|
153
196
|
]),
|
|
154
197
|
});
|
|
155
198
|
|
|
199
|
+
/** A person's save of a whole board from the canvas. A block they added
|
|
200
|
+
* has no id and may carry a clientKey, which the response maps to its id. */
|
|
201
|
+
export const personEditSchema = z.strictObject({
|
|
202
|
+
baseVersion: z.number().int().nonnegative(),
|
|
203
|
+
/** Empty until the person names the board; it reads "Untitled". */
|
|
204
|
+
title: z.string().trim(),
|
|
205
|
+
document: z.array(z.json()),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** The block a person's canvas has focus in; null clears it. */
|
|
209
|
+
export const focusLeaseSchema = z.strictObject({
|
|
210
|
+
clientId: z.string().min(1),
|
|
211
|
+
blockId: z.string().min(1).nullable(),
|
|
212
|
+
/** false when the canvas closes the board. */
|
|
213
|
+
open: z.boolean().optional(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/** A focus lease lasts this long unless renewed. The canvas renews it every
|
|
217
|
+
* few seconds, so a closed or crashed app releases its block on its own. */
|
|
218
|
+
export const FOCUS_LEASE_TTL_MS = 15_000;
|
|
219
|
+
|
|
220
|
+
/** How often a discard looks again for a canvas letting go of the board. */
|
|
221
|
+
const DISCARD_POLL_MS = 100;
|
|
222
|
+
|
|
223
|
+
/** What a person's save wrote: the version, and the id each new block got
|
|
224
|
+
* by its clientKey. */
|
|
225
|
+
export interface PersonEditResult {
|
|
226
|
+
reviewId: string;
|
|
227
|
+
version: number;
|
|
228
|
+
ids: Record<string, string>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One row of a board's history. */
|
|
232
|
+
export interface HistoryEntry {
|
|
233
|
+
version: number;
|
|
234
|
+
title: string;
|
|
235
|
+
createdAt: string;
|
|
236
|
+
/** "you" for a version a person saved from the canvas; absent for one
|
|
237
|
+
* saved before Whiteboard recorded who wrote it. */
|
|
238
|
+
author?: "you" | "agent";
|
|
239
|
+
/** The Ask thread whose edit made this version, if any. */
|
|
240
|
+
threadId?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
156
243
|
/** Source identity displayed in the review header and Home, alongside immutable pins. */
|
|
157
244
|
export interface SnapshotOrigin {
|
|
158
245
|
branch?: string;
|
|
@@ -167,6 +254,68 @@ export interface SnapshotOrigin {
|
|
|
167
254
|
revision?: string;
|
|
168
255
|
}
|
|
169
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The project of a registered checkout, made on first use. Checkouts of one
|
|
259
|
+
* repository (worktrees, or clones of one GitHub repository) share it. A
|
|
260
|
+
* deleted checkout joins the project of the existing one it belonged to
|
|
261
|
+
* (see `CheckoutGroups.ownerOf`), else gets its own.
|
|
262
|
+
*/
|
|
263
|
+
export function projectOfCheckout(
|
|
264
|
+
db: DatabaseSync,
|
|
265
|
+
repositoryId: string,
|
|
266
|
+
groups = new CheckoutGroups(db),
|
|
267
|
+
): string {
|
|
268
|
+
const {
|
|
269
|
+
path: root,
|
|
270
|
+
name,
|
|
271
|
+
project_id: projectOf,
|
|
272
|
+
} = db
|
|
273
|
+
.prepare("SELECT path, name, project_id FROM repositories WHERE id=?")
|
|
274
|
+
.get(repositoryId)!;
|
|
275
|
+
|
|
276
|
+
if (projectOf) return String(projectOf);
|
|
277
|
+
|
|
278
|
+
const join = (projectId: string) => {
|
|
279
|
+
db.prepare("UPDATE repositories SET project_id=? WHERE id=?").run(
|
|
280
|
+
projectId,
|
|
281
|
+
repositoryId,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return projectId;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
if (!existsSync(String(root))) {
|
|
288
|
+
const owner = groups.ownerOf(repositoryId);
|
|
289
|
+
|
|
290
|
+
if (owner) return join(projectOfCheckout(db, owner, groups));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const group = groups.group(String(root));
|
|
294
|
+
|
|
295
|
+
const sibling =
|
|
296
|
+
group &&
|
|
297
|
+
db
|
|
298
|
+
.prepare(
|
|
299
|
+
"SELECT project_id, path FROM repositories WHERE project_id IS NOT NULL",
|
|
300
|
+
)
|
|
301
|
+
.all()
|
|
302
|
+
.find(
|
|
303
|
+
({ path: checkout }) =>
|
|
304
|
+
groups.group(String(checkout))?.key === group.key,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
if (sibling) return join(String(sibling.project_id));
|
|
308
|
+
|
|
309
|
+
const projectId = randomUUID();
|
|
310
|
+
|
|
311
|
+
db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(
|
|
312
|
+
projectId,
|
|
313
|
+
group?.label ?? String(name),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
return join(projectId);
|
|
317
|
+
}
|
|
318
|
+
|
|
170
319
|
export interface Snapshot {
|
|
171
320
|
/** Current lifecycle state, projected onto every version when read. */
|
|
172
321
|
trashedAt?: string | null;
|
|
@@ -174,8 +323,6 @@ export interface Snapshot {
|
|
|
174
323
|
reviewId: string;
|
|
175
324
|
version: number;
|
|
176
325
|
title: string;
|
|
177
|
-
/** Absent for a review. The scratchpad has no pins, target or lifecycle. */
|
|
178
|
-
kind?: "scratchpad";
|
|
179
326
|
/** The default pins for references that name none. A document whose
|
|
180
327
|
* references all carry their own pins has neither pins nor target. */
|
|
181
328
|
pins?: Pins;
|
|
@@ -191,8 +338,27 @@ export interface Snapshot {
|
|
|
191
338
|
/** The edit that produced this version, when one did; absent for a
|
|
192
339
|
* rename, set_target, restore or import, which the canvas does not draw. */
|
|
193
340
|
lastEdit?: EditSummary;
|
|
341
|
+
/** The Ask thread that caused this version when it made no edit to
|
|
342
|
+
* name it on: a create, rename, retarget or restore. */
|
|
343
|
+
threadId?: string;
|
|
344
|
+
/** A person saved this version from the canvas: the blocks they added,
|
|
345
|
+
* removed or changed. Absent on every other version. */
|
|
346
|
+
personEdit?: { changed: string[]; ids?: Record<string, string> };
|
|
194
347
|
}
|
|
195
348
|
|
|
349
|
+
/** The Ask thread a version names, on its edit or on itself; stored in
|
|
350
|
+
* `versions.thread_id` so a thread's boards are one index away. */
|
|
351
|
+
const versionThread = (snapshot: Snapshot) =>
|
|
352
|
+
snapshot.lastEdit?.threadId ?? snapshot.threadId ?? null;
|
|
353
|
+
|
|
354
|
+
/** Who is writing: the rules a write keeps depend on it. */
|
|
355
|
+
export type Author =
|
|
356
|
+
/** An agent, through the API; a thread's agent stamps its versions
|
|
357
|
+
* with the thread. */
|
|
358
|
+
| { kind: "agent"; threadId?: string }
|
|
359
|
+
/** A person, from Desktop or the canvas. */
|
|
360
|
+
| { kind: "person" };
|
|
361
|
+
|
|
196
362
|
export interface Result {
|
|
197
363
|
/** Create only: false when an existing review for the same PR came back. */
|
|
198
364
|
created?: boolean;
|
|
@@ -216,9 +382,38 @@ export interface Result {
|
|
|
216
382
|
attention?: true;
|
|
217
383
|
lifecycle?: true;
|
|
218
384
|
deleted?: true;
|
|
385
|
+
/** After set_parent or set_dependencies: the board's relations. */
|
|
386
|
+
relations?: BoardRelations;
|
|
219
387
|
warnings?: string[];
|
|
220
388
|
}
|
|
221
389
|
|
|
390
|
+
export interface StoreChange extends Result {
|
|
391
|
+
titleChanged?: true;
|
|
392
|
+
/** Project, repository or kind (icon) changed. */
|
|
393
|
+
placeChanged?: true;
|
|
394
|
+
threadId?: string;
|
|
395
|
+
/** Saved by another process; changes unknown. */
|
|
396
|
+
external?: true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
type ShownChange = Omit<StoreChange, keyof Result>;
|
|
400
|
+
|
|
401
|
+
/** A board as its place in a project shows it. */
|
|
402
|
+
export interface PlacedBoard {
|
|
403
|
+
reviewId: string;
|
|
404
|
+
title: string;
|
|
405
|
+
target?: ReviewTarget;
|
|
406
|
+
origin?: SnapshotOrigin;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Where a board sits among its project's boards. Kept outside its versions. */
|
|
410
|
+
export interface BoardRelations {
|
|
411
|
+
parentId: string | null;
|
|
412
|
+
/** In sibling order. */
|
|
413
|
+
children: string[];
|
|
414
|
+
dependsOn: string[];
|
|
415
|
+
}
|
|
416
|
+
|
|
222
417
|
/** A PR's current comparison: GitHub's head and diff base, fetched locally. */
|
|
223
418
|
export interface ResolvedPullRequest {
|
|
224
419
|
target: ReviewTarget;
|
|
@@ -232,11 +427,10 @@ export interface ResolvedPullRequest {
|
|
|
232
427
|
export interface ReviewProviders {
|
|
233
428
|
headBranch?(pins: Pins, headRef?: string): Promise<string | undefined>;
|
|
234
429
|
projectSource?(snapshot: Snapshot, pins: Pins): Promise<Snapshot>;
|
|
235
|
-
/** Fetch a PR into
|
|
236
|
-
* preferred one that still matches, else any whose remote is the PR's. */
|
|
430
|
+
/** Fetch a PR into the registered checkout the caller names. */
|
|
237
431
|
resolvePullRequest?(
|
|
238
432
|
url: string,
|
|
239
|
-
repository: { id
|
|
433
|
+
repository: { id: string },
|
|
240
434
|
): Promise<ResolvedPullRequest>;
|
|
241
435
|
/** Undefined when the forge can't be read. */
|
|
242
436
|
pullRequestTitle?(url: string): Promise<string | undefined>;
|
|
@@ -250,6 +444,11 @@ export interface ReviewProviders {
|
|
|
250
444
|
/** Ids of references whose own pins no longer name a usable checkout. */
|
|
251
445
|
unavailableAnchors?(snapshot: Snapshot): Promise<string[]>;
|
|
252
446
|
validatePins(pins: Pins): Promise<void>;
|
|
447
|
+
/** The full commit a commit ID or its prefix names, if any. */
|
|
448
|
+
resolveCommit?(
|
|
449
|
+
repositoryId: string,
|
|
450
|
+
commit: string,
|
|
451
|
+
): Promise<string | undefined>;
|
|
253
452
|
/** The files each side's commit changed from one set of pins to another,
|
|
254
453
|
* or undefined when they can't be diffed commit to commit. */
|
|
255
454
|
filesChangedBetween?(
|
|
@@ -262,6 +461,8 @@ export interface ReviewProviders {
|
|
|
262
461
|
options: { peek: boolean },
|
|
263
462
|
): Promise<void>;
|
|
264
463
|
validateResource(pins: Pins | undefined, block: Block): Promise<void>;
|
|
464
|
+
/** A PR's base branch, as last read from GitHub; undefined until read. */
|
|
465
|
+
pullRequestBaseBranch?(url: string): string | undefined;
|
|
265
466
|
}
|
|
266
467
|
|
|
267
468
|
/** One instance owned by the desktop server. All writers go through execute().
|
|
@@ -482,7 +683,7 @@ export class ReviewStore {
|
|
|
482
683
|
|
|
483
684
|
return this.refreshPending;
|
|
484
685
|
}
|
|
485
|
-
private readonly listeners = new Set<(
|
|
686
|
+
private readonly listeners = new Set<(change: StoreChange) => void>();
|
|
486
687
|
private readonly catalogListeners = new Set<() => void>();
|
|
487
688
|
private readonly externalChanges: ReturnType<typeof setInterval>;
|
|
488
689
|
private observedDataVersion: number;
|
|
@@ -495,16 +696,7 @@ export class ReviewStore {
|
|
|
495
696
|
this.catalogListeners.delete(listener);
|
|
496
697
|
};
|
|
497
698
|
}
|
|
498
|
-
|
|
499
|
-
invalidateCatalog(): void {
|
|
500
|
-
for (const listener of this.catalogListeners)
|
|
501
|
-
try {
|
|
502
|
-
listener();
|
|
503
|
-
} catch {
|
|
504
|
-
// A disconnected viewer must not block other catalog subscribers.
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
subscribe(listener: (result: Result) => void) {
|
|
699
|
+
subscribe(listener: (change: StoreChange) => void) {
|
|
508
700
|
this.listeners.add(listener);
|
|
509
701
|
|
|
510
702
|
return () => {
|
|
@@ -514,14 +706,48 @@ export class ReviewStore {
|
|
|
514
706
|
constructor(
|
|
515
707
|
databasePath: string,
|
|
516
708
|
private readonly providers: ReviewProviders,
|
|
709
|
+
/** How the checkouts group, if `groupCheckoutsAhead` worked it out. */
|
|
710
|
+
private readonly checkoutOwners?: ReadonlyMap<string, string>,
|
|
517
711
|
) {
|
|
518
712
|
// WAL plus a busy timeout: another host on the same home waits instead of failing.
|
|
519
713
|
this.db = new DatabaseSync(databasePath, { timeout: 5000 });
|
|
520
714
|
initializeReviewStoreSchema(this.db);
|
|
715
|
+
// Each upgrade reads a quoted file once, before its write lock and in it.
|
|
716
|
+
const files = new Map<string, string | undefined>();
|
|
717
|
+
|
|
718
|
+
upgradeStore({
|
|
719
|
+
db: this.db,
|
|
720
|
+
prepareCheckouts: () => this.checkouts.prepare(),
|
|
721
|
+
repositories: () => this.repositories(),
|
|
722
|
+
repositoryPath: (repositoryId) => this.repositoryPath(repositoryId),
|
|
723
|
+
repositoryName: (repositoryId) => this.repositoryName(repositoryId),
|
|
724
|
+
fileAtCommit: (repositoryId, commit, file) => {
|
|
725
|
+
const key = JSON.stringify([repositoryId, commit, file]);
|
|
726
|
+
|
|
727
|
+
if (!files.has(key))
|
|
728
|
+
files.set(key, this.fileAtCommit(repositoryId, commit, file));
|
|
729
|
+
|
|
730
|
+
return files.get(key);
|
|
731
|
+
},
|
|
732
|
+
projectOfRepository: (repositoryId) =>
|
|
733
|
+
this.projectOfRepository(repositoryId),
|
|
734
|
+
fallbackProject: () => this.fallbackProject(),
|
|
735
|
+
rewriteVersion: (reviewId, version, snapshot) =>
|
|
736
|
+
this.rewriteVersion(reviewId, version, snapshot),
|
|
737
|
+
firstRepositoryProject: () => {
|
|
738
|
+
const [first] = this.repositories();
|
|
739
|
+
|
|
740
|
+
return first
|
|
741
|
+
? this.projectOfRepository(first.id)
|
|
742
|
+
: this.fallbackProject();
|
|
743
|
+
},
|
|
744
|
+
});
|
|
521
745
|
this.activity = new ReviewActivity(this.db, (id) =>
|
|
522
746
|
this.assertWritable(id),
|
|
523
747
|
);
|
|
524
|
-
this.askHistory = new AskHistory(this.db)
|
|
748
|
+
this.askHistory = new AskHistory(this.db, (id) =>
|
|
749
|
+
this.assertOutOfTrash(id),
|
|
750
|
+
);
|
|
525
751
|
|
|
526
752
|
this.purgeExpired();
|
|
527
753
|
this.purgeTimer = setInterval(() => this.purgeExpired(), 60_000);
|
|
@@ -537,6 +763,117 @@ export class ReviewStore {
|
|
|
537
763
|
this.externalChanges.unref();
|
|
538
764
|
}
|
|
539
765
|
|
|
766
|
+
/** Saves a version over what it was; the current one re-indexes its
|
|
767
|
+
* board's links, as every written version does. */
|
|
768
|
+
private rewriteVersion(
|
|
769
|
+
reviewId: string,
|
|
770
|
+
version: number,
|
|
771
|
+
snapshot: JsonValue,
|
|
772
|
+
) {
|
|
773
|
+
this.db
|
|
774
|
+
.prepare("UPDATE versions SET snapshot=? WHERE review_id=? AND version=?")
|
|
775
|
+
.run(JSON.stringify(snapshot), reviewId, version);
|
|
776
|
+
|
|
777
|
+
if (version !== this.currentVersion(reviewId)) return;
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
this.indexBoardLinks(reviewId, this.read(reviewId).document);
|
|
781
|
+
} catch {
|
|
782
|
+
// A version that no longer reads indexes nothing.
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** The project of a registered checkout, made on first use. Checkouts of
|
|
787
|
+
* one repository (worktrees, or clones with one origin) share it. */
|
|
788
|
+
projectOfRepository(repositoryId: string): string {
|
|
789
|
+
const row = this.db
|
|
790
|
+
.prepare("SELECT project_id FROM repositories WHERE id=?")
|
|
791
|
+
.get(repositoryId);
|
|
792
|
+
|
|
793
|
+
if (!row) return this.fallbackProject();
|
|
794
|
+
|
|
795
|
+
if (row.project_id) return String(row.project_id);
|
|
796
|
+
|
|
797
|
+
return projectOfCheckout(this.db, repositoryId, this.checkouts);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private fallbackProject(): string {
|
|
801
|
+
const found = this.db
|
|
802
|
+
.prepare(
|
|
803
|
+
"SELECT id FROM projects WHERE name='Whiteboards' AND id NOT IN (SELECT project_id FROM repositories WHERE project_id IS NOT NULL) ORDER BY rowid LIMIT 1",
|
|
804
|
+
)
|
|
805
|
+
.get();
|
|
806
|
+
|
|
807
|
+
if (found) return String(found.id);
|
|
808
|
+
|
|
809
|
+
const projectId = randomUUID();
|
|
810
|
+
|
|
811
|
+
this.db
|
|
812
|
+
.prepare("INSERT INTO projects(id,name) VALUES(?,?)")
|
|
813
|
+
.run(projectId, "Whiteboards");
|
|
814
|
+
|
|
815
|
+
return projectId;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** A checkout's project, as the catalog lists it; undefined once the
|
|
819
|
+
* checkout is unregistered. */
|
|
820
|
+
checkoutProject(
|
|
821
|
+
repositoryId: string,
|
|
822
|
+
): ReviewApiSummary["project"] | undefined {
|
|
823
|
+
if (!this.repositories().some(({ id }) => id === repositoryId))
|
|
824
|
+
return undefined;
|
|
825
|
+
|
|
826
|
+
const id = this.projectOfRepository(repositoryId);
|
|
827
|
+
|
|
828
|
+
const { name } = this.db
|
|
829
|
+
.prepare("SELECT name FROM projects WHERE id=?")
|
|
830
|
+
.get(id)!;
|
|
831
|
+
|
|
832
|
+
return { id, name: String(name) };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Undefined when the file can't be read. */
|
|
836
|
+
private fileAtCommit(
|
|
837
|
+
repositoryId: string,
|
|
838
|
+
commit: string,
|
|
839
|
+
file: string,
|
|
840
|
+
): string | undefined {
|
|
841
|
+
try {
|
|
842
|
+
return this.checkouts.fileAt(
|
|
843
|
+
this.repositoryPath(repositoryId),
|
|
844
|
+
commit,
|
|
845
|
+
file,
|
|
846
|
+
);
|
|
847
|
+
} catch {
|
|
848
|
+
return undefined;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** A checkout's name as its project labels it, else its folder. */
|
|
853
|
+
private repositoryName(repositoryId: string): string {
|
|
854
|
+
try {
|
|
855
|
+
const root = this.repositoryPath(repositoryId);
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
this.repositoryGroup(root)?.label.split("/").at(-1) ??
|
|
859
|
+
path.basename(root)
|
|
860
|
+
);
|
|
861
|
+
} catch {
|
|
862
|
+
return "another repository";
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** A board's project. */
|
|
867
|
+
projectOf(id: string): string {
|
|
868
|
+
const row = this.db
|
|
869
|
+
.prepare("SELECT project_id FROM reviews WHERE id=?")
|
|
870
|
+
.get(id);
|
|
871
|
+
|
|
872
|
+
if (!row) this.assertExists(id);
|
|
873
|
+
|
|
874
|
+
return String(row!.project_id);
|
|
875
|
+
}
|
|
876
|
+
|
|
540
877
|
private dataVersion() {
|
|
541
878
|
return Number(this.db.prepare("PRAGMA data_version").get()!.data_version);
|
|
542
879
|
}
|
|
@@ -574,7 +911,7 @@ export class ReviewStore {
|
|
|
574
911
|
|
|
575
912
|
for (const [reviewId, savedVersion] of current) {
|
|
576
913
|
if (previous.get(reviewId) !== savedVersion)
|
|
577
|
-
this.notify({ reviewId, version: savedVersion });
|
|
914
|
+
this.notify({ reviewId, version: savedVersion, external: true });
|
|
578
915
|
else if (previousDeletions.get(reviewId) !== deletions.get(reviewId))
|
|
579
916
|
this.notify({
|
|
580
917
|
reviewId,
|
|
@@ -658,34 +995,19 @@ export class ReviewStore {
|
|
|
658
995
|
throw error;
|
|
659
996
|
}
|
|
660
997
|
}
|
|
661
|
-
private
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const context = resolveRepoContextSync(root);
|
|
673
|
-
|
|
674
|
-
if (!context) return undefined;
|
|
675
|
-
|
|
676
|
-
const group = context.githubSlug
|
|
677
|
-
? {
|
|
678
|
-
key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
|
|
679
|
-
label: context.githubSlug,
|
|
680
|
-
}
|
|
681
|
-
: {
|
|
682
|
-
key: `git:${context.commonDir}`,
|
|
683
|
-
label: path.basename(path.dirname(context.commonDir)),
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
this.repositoryGroups.set(root, group);
|
|
998
|
+
private checkoutGroups: CheckoutGroups | undefined;
|
|
999
|
+
|
|
1000
|
+
/** How the store's checkouts group into projects, kept for the open. */
|
|
1001
|
+
private get checkouts() {
|
|
1002
|
+
return (this.checkoutGroups ??= new CheckoutGroups(
|
|
1003
|
+
this.db,
|
|
1004
|
+
undefined,
|
|
1005
|
+
this.checkoutOwners,
|
|
1006
|
+
));
|
|
1007
|
+
}
|
|
687
1008
|
|
|
688
|
-
|
|
1009
|
+
private repositoryGroup(root: string) {
|
|
1010
|
+
return this.checkouts.group(root);
|
|
689
1011
|
}
|
|
690
1012
|
|
|
691
1013
|
registerRepository(root: string) {
|
|
@@ -697,16 +1019,68 @@ export class ReviewStore {
|
|
|
697
1019
|
.prepare("SELECT id,name FROM repositories WHERE path=?")
|
|
698
1020
|
.get(root)!;
|
|
699
1021
|
|
|
1022
|
+
this.projectOfRepository(String(row.id));
|
|
1023
|
+
|
|
700
1024
|
return { id: String(row.id), name: String(row.name) };
|
|
701
1025
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1026
|
+
/** Forgets a checkout no board uses, after every write queued before it,
|
|
1027
|
+
* so a board being made with it keeps it. */
|
|
1028
|
+
unregisterRepository(id: string): Promise<void> {
|
|
1029
|
+
const run = this.pending.then(async () => {
|
|
1030
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1031
|
+
|
|
1032
|
+
try {
|
|
1033
|
+
const used = this.db
|
|
1034
|
+
// Document pins and per-reference pins both spell the id in the snapshot.
|
|
1035
|
+
.prepare(`SELECT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
1036
|
+
OR EXISTS (SELECT 1 FROM resources WHERE repository_id=?) AS used`)
|
|
1037
|
+
.get(`"repositoryId":${JSON.stringify(id)}`, id)!.used;
|
|
1038
|
+
|
|
1039
|
+
// Its project stays, with any boards in it.
|
|
1040
|
+
if (!used)
|
|
1041
|
+
this.db.prepare("DELETE FROM repositories WHERE id=?").run(id);
|
|
1042
|
+
|
|
1043
|
+
this.db.exec("COMMIT");
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
this.pending = run.catch(() => {});
|
|
1051
|
+
|
|
1052
|
+
return run;
|
|
1053
|
+
}
|
|
1054
|
+
/** Checkouts whose remote is cloneUrl, most recently used first. */
|
|
1055
|
+
checkoutsOf(cloneUrl: string): string[] {
|
|
1056
|
+
const key = remoteGroup(cloneUrl)?.key;
|
|
1057
|
+
|
|
1058
|
+
if (!key) return [];
|
|
1059
|
+
|
|
1060
|
+
const used = (repositoryId: string) =>
|
|
1061
|
+
String(
|
|
1062
|
+
this.db
|
|
1063
|
+
.prepare(
|
|
1064
|
+
`SELECT MAX(json_extract(versions.snapshot,'$.createdAt')) AS at
|
|
1065
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
1066
|
+
WHERE json_extract(versions.snapshot,'$.target.repositoryId')=?`,
|
|
1067
|
+
)
|
|
1068
|
+
.get(repositoryId)?.at ?? "",
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
return this.repositories()
|
|
1072
|
+
.filter((repository) => {
|
|
1073
|
+
try {
|
|
1074
|
+
return this.repositoryGroup(repository.path)?.key === key;
|
|
1075
|
+
} catch {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
})
|
|
1079
|
+
.map((repository) => ({ id: repository.id, at: used(repository.id) }))
|
|
1080
|
+
.sort((left, right) => right.at.localeCompare(left.at))
|
|
1081
|
+
.map((repository) => repository.id);
|
|
709
1082
|
}
|
|
1083
|
+
|
|
710
1084
|
/** Registered checkouts, oldest registration first. */
|
|
711
1085
|
repositories() {
|
|
712
1086
|
return this.db
|
|
@@ -808,8 +1182,14 @@ export class ReviewStore {
|
|
|
808
1182
|
404,
|
|
809
1183
|
);
|
|
810
1184
|
}
|
|
1185
|
+
/** A board that exists and isn't in Trash: the one rule every write
|
|
1186
|
+
* about a board keeps. */
|
|
811
1187
|
assertWritable(id: string) {
|
|
812
1188
|
this.assertExists(id);
|
|
1189
|
+
this.assertOutOfTrash(id);
|
|
1190
|
+
}
|
|
1191
|
+
/** Refuses a board in Trash, which is read-only until it is restored. */
|
|
1192
|
+
assertOutOfTrash(id: string) {
|
|
813
1193
|
const trashedAt = this.trashTime(id);
|
|
814
1194
|
|
|
815
1195
|
if (trashedAt)
|
|
@@ -825,45 +1205,57 @@ export class ReviewStore {
|
|
|
825
1205
|
|
|
826
1206
|
return row?.deleted_at ? String(row.deleted_at) : null;
|
|
827
1207
|
}
|
|
1208
|
+
/** Removes a board for good, from Delete… or Trash's purge. Links into
|
|
1209
|
+
* it stay, and show it as deleted. */
|
|
828
1210
|
private removePermanently(id: string) {
|
|
1211
|
+
const { title } = this.read(id);
|
|
1212
|
+
|
|
829
1213
|
for (const table of [
|
|
830
|
-
"ask_conversations",
|
|
831
1214
|
"authoring_presences",
|
|
832
1215
|
"review_coverage",
|
|
833
1216
|
"review_attention",
|
|
834
1217
|
"versions",
|
|
835
1218
|
])
|
|
836
1219
|
this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
|
|
1220
|
+
this.askHistory.removeReview(id);
|
|
1221
|
+
// Its children move up to top level, its dependencies and the links
|
|
1222
|
+
// on it go, by the schema's foreign keys. Links into it stay, and the
|
|
1223
|
+
// tombstone shows it as deleted, in the project it was in.
|
|
1224
|
+
this.db
|
|
1225
|
+
.prepare(
|
|
1226
|
+
"INSERT OR REPLACE INTO deleted_boards(id,title,deleted_at,project_id) VALUES(?,?,?,(SELECT project_id FROM reviews WHERE id=?))",
|
|
1227
|
+
)
|
|
1228
|
+
.run(id, title, new Date().toISOString(), id);
|
|
837
1229
|
this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
|
|
838
1230
|
this.liveSources.delete(id);
|
|
839
1231
|
}
|
|
1232
|
+
/** Deletes the boards 30 days in Trash, each as Delete… would. */
|
|
840
1233
|
private purgeExpired() {
|
|
841
|
-
const cutoff = new Date(Date.now() -
|
|
842
|
-
const expired: Result[] = [];
|
|
843
|
-
this.db.exec("BEGIN IMMEDIATE");
|
|
1234
|
+
const cutoff = new Date(Date.now() - TRASH_DAYS * 86400000).toISOString();
|
|
844
1235
|
|
|
845
|
-
|
|
846
|
-
|
|
1236
|
+
const expired = () =>
|
|
1237
|
+
this.db
|
|
847
1238
|
.prepare(
|
|
848
|
-
"SELECT review_id
|
|
1239
|
+
"SELECT review_id FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
|
|
849
1240
|
)
|
|
850
|
-
.all(cutoff)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
this.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1241
|
+
.all(cutoff)
|
|
1242
|
+
.map((row) => String(row.review_id));
|
|
1243
|
+
|
|
1244
|
+
for (const reviewId of expired())
|
|
1245
|
+
// Restored meanwhile, by another host on this home: it stays.
|
|
1246
|
+
// Trash's own deadline, which no hold or thread stops.
|
|
1247
|
+
this.deleteBoard(
|
|
1248
|
+
this.read(reviewId),
|
|
1249
|
+
{ kind: "person" },
|
|
1250
|
+
{
|
|
1251
|
+
guard: () => {
|
|
1252
|
+
if (!expired().includes(reviewId))
|
|
1253
|
+
throw new ReviewInputError("Restored from Trash.", 409);
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
);
|
|
866
1257
|
}
|
|
1258
|
+
|
|
867
1259
|
read(id: string, version?: number): Snapshot {
|
|
868
1260
|
const row =
|
|
869
1261
|
version === undefined
|
|
@@ -890,9 +1282,7 @@ export class ReviewStore {
|
|
|
890
1282
|
// retired attachment representations with their canonical equivalent and
|
|
891
1283
|
// drops retired fields.
|
|
892
1284
|
// Lenses saved as document blocks read as the snapshot's own.
|
|
893
|
-
const { document, lenses } =
|
|
894
|
-
migrateStoredDocument(snapshot.document),
|
|
895
|
-
);
|
|
1285
|
+
const { document, lenses } = readStoredDocument(snapshot.document);
|
|
896
1286
|
|
|
897
1287
|
// SAFETY: stored blocks were validated on write; migration only replaces
|
|
898
1288
|
// retired representations and lifts retired lens blocks out.
|
|
@@ -938,24 +1328,17 @@ export class ReviewStore {
|
|
|
938
1328
|
}
|
|
939
1329
|
}
|
|
940
1330
|
|
|
941
|
-
/** Reviews of the tutorial earlier versions shipped, for their removal. */
|
|
942
|
-
legacyTutorialIds(): string[] {
|
|
943
|
-
return this.db
|
|
944
|
-
.prepare(
|
|
945
|
-
`SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version WHERE json_extract(versions.snapshot,'$.origin.tutorial') = 1`,
|
|
946
|
-
)
|
|
947
|
-
.all()
|
|
948
|
-
.map((row) => String(row.id));
|
|
949
|
-
}
|
|
950
|
-
|
|
951
1331
|
list(): ReviewApiSummary[] {
|
|
952
1332
|
return this.summaries();
|
|
953
1333
|
}
|
|
1334
|
+
listProject(projectId: string): ReviewApiSummary[] {
|
|
1335
|
+
return this.summaries("reviews.project_id=?", projectId);
|
|
1336
|
+
}
|
|
954
1337
|
/** One review's catalog entry, as review_list shows it. */
|
|
955
1338
|
summary(id: string): ReviewApiSummary | undefined {
|
|
956
|
-
return this.summaries(id)[0];
|
|
1339
|
+
return this.summaries("reviews.id=?", id)[0];
|
|
957
1340
|
}
|
|
958
|
-
private summaries(
|
|
1341
|
+
private summaries(where = "1", ...params: string[]): ReviewApiSummary[] {
|
|
959
1342
|
// One query, and the document never leaves SQLite: every catalog watcher
|
|
960
1343
|
// re-lists on every command.
|
|
961
1344
|
|
|
@@ -963,15 +1346,17 @@ export class ReviewStore {
|
|
|
963
1346
|
.prepare(
|
|
964
1347
|
`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
965
1348
|
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS first_created_at,
|
|
966
|
-
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
1349
|
+
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path,
|
|
1350
|
+
projects.id AS project_id, projects.name AS project_name
|
|
967
1351
|
FROM reviews
|
|
1352
|
+
JOIN projects ON projects.id=reviews.project_id
|
|
968
1353
|
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
969
1354
|
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
970
|
-
LEFT JOIN repositories ON repositories.id
|
|
971
|
-
${
|
|
1355
|
+
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
1356
|
+
WHERE ${where}
|
|
972
1357
|
ORDER BY reviews.rowid`,
|
|
973
1358
|
)
|
|
974
|
-
.all(...
|
|
1359
|
+
.all(...params)
|
|
975
1360
|
.map((row) => {
|
|
976
1361
|
// SAFETY: versions contains only snapshots validated by execute before committing.
|
|
977
1362
|
const summary = JSON.parse(String(row.summary)) as Omit<
|
|
@@ -1003,23 +1388,37 @@ export class ReviewStore {
|
|
|
1003
1388
|
repositoryPath: row.repository_path
|
|
1004
1389
|
? String(row.repository_path)
|
|
1005
1390
|
: undefined,
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
:
|
|
1391
|
+
project: {
|
|
1392
|
+
id: String(row.project_id),
|
|
1393
|
+
name: String(row.project_name),
|
|
1394
|
+
},
|
|
1009
1395
|
repositoryName: row.repository_name
|
|
1010
1396
|
? String(row.repository_name)
|
|
1011
|
-
: (
|
|
1397
|
+
: "(unknown repository)",
|
|
1012
1398
|
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
1013
1399
|
trashedAt: row.deleted_at ? String(row.deleted_at) : null,
|
|
1400
|
+
// Any unseen version counts, whoever saved it.
|
|
1401
|
+
changed: !row.viewed_at || summary.createdAt > String(row.viewed_at),
|
|
1014
1402
|
working: this.activity.isWorking(summary.reviewId),
|
|
1015
1403
|
};
|
|
1016
1404
|
|
|
1017
|
-
if (summary.kind === "scratchpad")
|
|
1018
|
-
listed.contents = this.scratchpadContents();
|
|
1019
|
-
|
|
1020
1405
|
return listed;
|
|
1021
1406
|
});
|
|
1022
1407
|
|
|
1408
|
+
const graph = this.relationGraph();
|
|
1409
|
+
|
|
1410
|
+
for (const listed of reviews) {
|
|
1411
|
+
const parentId = graph.parents.get(listed.reviewId);
|
|
1412
|
+
const children = graph.children.get(listed.reviewId);
|
|
1413
|
+
const dependsOn = graph.dependsOn.get(listed.reviewId);
|
|
1414
|
+
|
|
1415
|
+
if (parentId) listed.parentId = parentId;
|
|
1416
|
+
|
|
1417
|
+
if (children) listed.children = this.orderChildren(children, graph);
|
|
1418
|
+
|
|
1419
|
+
if (dependsOn) listed.dependsOn = dependsOn;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1023
1422
|
return this.withDiffStats(reviews);
|
|
1024
1423
|
}
|
|
1025
1424
|
|
|
@@ -1046,34 +1445,6 @@ export class ReviewStore {
|
|
|
1046
1445
|
}));
|
|
1047
1446
|
}
|
|
1048
1447
|
|
|
1049
|
-
/** What the pad holds, for its Home card: blocks, and the diagrams among them. */
|
|
1050
|
-
private scratchpadContents(): NonNullable<ReviewApiSummary["contents"]> {
|
|
1051
|
-
const blocks = elements(this.read(SCRATCHPAD_ID).document).filter(
|
|
1052
|
-
(element) => !isUnit(element),
|
|
1053
|
-
);
|
|
1054
|
-
|
|
1055
|
-
return {
|
|
1056
|
-
blocks: blocks.length,
|
|
1057
|
-
diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length,
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
/** The one scratchpad, made on first use. A host that loses the race to
|
|
1061
|
-
* another on the same home finds it made. */
|
|
1062
|
-
async ensureScratchpad(): Promise<void> {
|
|
1063
|
-
if (this.has(SCRATCHPAD_ID)) return;
|
|
1064
|
-
|
|
1065
|
-
try {
|
|
1066
|
-
await this.execute({
|
|
1067
|
-
operation: {
|
|
1068
|
-
type: "create",
|
|
1069
|
-
title: SCRATCHPAD_TITLE,
|
|
1070
|
-
kind: "scratchpad",
|
|
1071
|
-
},
|
|
1072
|
-
});
|
|
1073
|
-
} catch (error) {
|
|
1074
|
-
if (!this.has(SCRATCHPAD_ID)) throw error;
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
1448
|
/** Two saved board/day pairs in the last fourteen days. */
|
|
1078
1449
|
feedbackEligible(now = Date.now()): boolean {
|
|
1079
1450
|
const row = this.db
|
|
@@ -1081,8 +1452,6 @@ export class ReviewStore {
|
|
|
1081
1452
|
SELECT DISTINCT review_id, date(json_extract(snapshot, '$.createdAt')) AS utc_date
|
|
1082
1453
|
FROM versions
|
|
1083
1454
|
WHERE (version = 0 OR json_type(snapshot, '$.lastEdit') = 'object')
|
|
1084
|
-
AND json_extract(snapshot, '$.kind') IS NOT 'scratchpad'
|
|
1085
|
-
AND json_extract(snapshot, '$.origin.tutorial') IS NOT 1
|
|
1086
1455
|
AND julianday(json_extract(snapshot, '$.createdAt')) BETWEEN julianday(?) AND julianday(?)
|
|
1087
1456
|
LIMIT 2
|
|
1088
1457
|
)`)
|
|
@@ -1094,17 +1463,33 @@ export class ReviewStore {
|
|
|
1094
1463
|
return Number(row?.pairs) >= 2;
|
|
1095
1464
|
}
|
|
1096
1465
|
|
|
1097
|
-
history(id: string) {
|
|
1466
|
+
history(id: string): HistoryEntry[] {
|
|
1098
1467
|
return this.db
|
|
1099
1468
|
.prepare(
|
|
1100
|
-
|
|
1469
|
+
`SELECT version,
|
|
1470
|
+
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.title') END AS title,
|
|
1471
|
+
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.createdAt') END AS created_at,
|
|
1472
|
+
author_kind,
|
|
1473
|
+
thread_id
|
|
1474
|
+
FROM versions WHERE review_id=? ORDER BY version`,
|
|
1101
1475
|
)
|
|
1102
1476
|
.all(id)
|
|
1103
|
-
.map((row) =>
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1477
|
+
.map((row) => {
|
|
1478
|
+
const entry: HistoryEntry = {
|
|
1479
|
+
version: Number(row.version),
|
|
1480
|
+
// A version that won't parse still has its place in history.
|
|
1481
|
+
title: String(row.title ?? ""),
|
|
1482
|
+
createdAt: String(row.created_at ?? ""),
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
if (row.author_kind !== null)
|
|
1486
|
+
entry.author = row.author_kind === "person" ? "you" : "agent";
|
|
1487
|
+
|
|
1488
|
+
if (row.author_kind !== "person" && row.thread_id !== null)
|
|
1489
|
+
entry.threadId = String(row.thread_id);
|
|
1490
|
+
|
|
1491
|
+
return entry;
|
|
1492
|
+
});
|
|
1108
1493
|
}
|
|
1109
1494
|
inspect(id: string, targetId?: string, version?: number) {
|
|
1110
1495
|
const snapshot = this.read(id, version);
|
|
@@ -1116,6 +1501,7 @@ export class ReviewStore {
|
|
|
1116
1501
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
|
|
1117
1502
|
input: unknown,
|
|
1118
1503
|
initial?: { document: Block[]; origin: SnapshotOrigin },
|
|
1504
|
+
author: Author = { kind: "agent" },
|
|
1119
1505
|
): Promise<Result> {
|
|
1120
1506
|
if (this.closing)
|
|
1121
1507
|
return Promise.reject(new Error("Review store is closing."));
|
|
@@ -1136,37 +1522,10 @@ export class ReviewStore {
|
|
|
1136
1522
|
)
|
|
1137
1523
|
: this.startPullRequestTitle(command);
|
|
1138
1524
|
|
|
1139
|
-
|
|
1525
|
+
return this.enqueue(async () => {
|
|
1140
1526
|
const op = command.operation;
|
|
1141
|
-
this.purgeExpired();
|
|
1142
|
-
|
|
1143
|
-
if (
|
|
1144
|
-
op.type !== "create" &&
|
|
1145
|
-
op.type !== "delete" &&
|
|
1146
|
-
op.type !== "lifecycle"
|
|
1147
|
-
)
|
|
1148
|
-
this.assertWritable(op.reviewId);
|
|
1149
|
-
|
|
1150
|
-
// The scratchpad is edited and restored like a review, and nothing else.
|
|
1151
|
-
if (
|
|
1152
|
-
op.type !== "create" &&
|
|
1153
|
-
op.type !== "edit" &&
|
|
1154
|
-
op.type !== "lens_edit" &&
|
|
1155
|
-
op.type !== "restore" &&
|
|
1156
|
-
this.read(op.reviewId).kind === "scratchpad"
|
|
1157
|
-
)
|
|
1158
|
-
throw new ReviewInputError(
|
|
1159
|
-
"The scratchpad has no lifecycle, title or pins of its own.",
|
|
1160
|
-
409,
|
|
1161
|
-
);
|
|
1162
1527
|
|
|
1163
|
-
if (op.type === "create"
|
|
1164
|
-
if (op.target)
|
|
1165
|
-
throw new ReviewInputError("A scratchpad has no target of its own.");
|
|
1166
|
-
|
|
1167
|
-
if (this.has(SCRATCHPAD_ID))
|
|
1168
|
-
throw new ReviewInputError("The scratchpad already exists.", 409);
|
|
1169
|
-
} else if (op.type === "create") {
|
|
1528
|
+
if (op.type === "create") {
|
|
1170
1529
|
if (!op.target && !op.pullRequestUrl)
|
|
1171
1530
|
throw new ReviewInputError("Supply a target or a pullRequestUrl.");
|
|
1172
1531
|
|
|
@@ -1175,7 +1534,7 @@ export class ReviewStore {
|
|
|
1175
1534
|
"A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.",
|
|
1176
1535
|
);
|
|
1177
1536
|
|
|
1178
|
-
if (!op.title && op.target)
|
|
1537
|
+
if (!op.title && op.target && author.kind !== "person")
|
|
1179
1538
|
throw new ReviewInputError("Supply a title.");
|
|
1180
1539
|
}
|
|
1181
1540
|
|
|
@@ -1186,10 +1545,15 @@ export class ReviewStore {
|
|
|
1186
1545
|
|
|
1187
1546
|
const fromPullRequest = await pullRequest;
|
|
1188
1547
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1548
|
+
// No commits to resolve; repositoryPath throws if it is unregistered.
|
|
1549
|
+
const resolvedTarget: { target: ReviewTarget; pins?: Pins } | undefined =
|
|
1550
|
+
requestedTarget?.kind === "project"
|
|
1551
|
+
? (this.repositoryPath(requestedTarget.repositoryId),
|
|
1552
|
+
{ target: requestedTarget })
|
|
1553
|
+
: requestedTarget
|
|
1554
|
+
? await (this.providers.resolveTarget?.(requestedTarget) ??
|
|
1555
|
+
namedCommits(requestedTarget))
|
|
1556
|
+
: fromPullRequest;
|
|
1193
1557
|
|
|
1194
1558
|
if (requestedTarget && !resolvedTarget)
|
|
1195
1559
|
throw new ReviewInputError("Review targets are unavailable.");
|
|
@@ -1207,93 +1571,53 @@ export class ReviewStore {
|
|
|
1207
1571
|
const result = this.existingReview(
|
|
1208
1572
|
found,
|
|
1209
1573
|
others,
|
|
1210
|
-
|
|
1574
|
+
// A PR always resolves to commits, so pins are set.
|
|
1575
|
+
resolvedTarget!.pins!,
|
|
1211
1576
|
);
|
|
1212
1577
|
|
|
1578
|
+
if (op.parentId || op.dependsOn)
|
|
1579
|
+
result.note += ` Its parent and dependencies were not changed either: set them with session_set_parent and session_set_dependencies.`;
|
|
1580
|
+
|
|
1213
1581
|
return result;
|
|
1214
1582
|
}
|
|
1215
1583
|
}
|
|
1216
1584
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1585
|
+
// What a board is outside its versions.
|
|
1586
|
+
if (op.type === "set_parent")
|
|
1587
|
+
return this.commitBoardFacts(op.reviewId, author, {
|
|
1588
|
+
parentId: op.parentId,
|
|
1589
|
+
});
|
|
1219
1590
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
);
|
|
1591
|
+
if (op.type === "set_dependencies")
|
|
1592
|
+
return this.commitBoardFacts(op.reviewId, author, {
|
|
1593
|
+
dependsOn: op.dependsOn,
|
|
1594
|
+
});
|
|
1225
1595
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
version: this.read(op.reviewId).version,
|
|
1229
|
-
deleted: true,
|
|
1230
|
-
};
|
|
1596
|
+
if (op.type === "lifecycle")
|
|
1597
|
+
return this.commitBoardFacts(op.reviewId, author, { state: op.state });
|
|
1231
1598
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
() => this.removePermanently(op.reviewId),
|
|
1235
|
-
() => this.assertMutation(op.reviewId, result.version),
|
|
1236
|
-
);
|
|
1599
|
+
if (op.type === "attention")
|
|
1600
|
+
return this.commitBoardFacts(op.reviewId, author, { viewed: true });
|
|
1237
1601
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1602
|
+
if (op.type === "delete")
|
|
1603
|
+
return this.deleteBoard(this.read(op.reviewId), author, {
|
|
1604
|
+
fromTrash: true,
|
|
1605
|
+
});
|
|
1240
1606
|
|
|
1241
|
-
|
|
1242
|
-
const result: Result = {
|
|
1243
|
-
reviewId: op.reviewId,
|
|
1244
|
-
version: this.read(op.reviewId).version,
|
|
1245
|
-
attention: true,
|
|
1246
|
-
...(op.type === "lifecycle" && {
|
|
1247
|
-
lifecycle: true as const,
|
|
1248
|
-
}),
|
|
1249
|
-
};
|
|
1607
|
+
const id = op.type === "create" ? randomUUID() : op.reviewId;
|
|
1250
1608
|
|
|
1251
|
-
|
|
1252
|
-
this.db
|
|
1253
|
-
.prepare(
|
|
1254
|
-
"INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
|
|
1255
|
-
)
|
|
1256
|
-
.run(op.reviewId);
|
|
1257
|
-
|
|
1258
|
-
if (op.type === "attention")
|
|
1259
|
-
this.db
|
|
1260
|
-
.prepare(
|
|
1261
|
-
"UPDATE review_attention SET viewed_at=? WHERE review_id=?",
|
|
1262
|
-
)
|
|
1263
|
-
.run(new Date().toISOString(), op.reviewId);
|
|
1264
|
-
else {
|
|
1265
|
-
const timestamp =
|
|
1266
|
-
op.state === "trash" ? new Date().toISOString() : null;
|
|
1267
|
-
|
|
1268
|
-
this.db
|
|
1269
|
-
.prepare(
|
|
1270
|
-
"UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?",
|
|
1271
|
-
)
|
|
1272
|
-
.run(timestamp, timestamp, op.reviewId);
|
|
1273
|
-
}
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
return result;
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
const id =
|
|
1280
|
-
op.type !== "create"
|
|
1281
|
-
? op.reviewId
|
|
1282
|
-
: op.kind === "scratchpad"
|
|
1283
|
-
? SCRATCHPAD_ID
|
|
1284
|
-
: randomUUID();
|
|
1285
|
-
|
|
1286
|
-
const previous = op.type === "create" ? undefined : this.read(id);
|
|
1609
|
+
const previous = op.type === "create" ? undefined : this.read(id);
|
|
1287
1610
|
|
|
1288
1611
|
let snapshot: Snapshot =
|
|
1289
1612
|
op.type === "create"
|
|
1290
1613
|
? createdSnapshot(
|
|
1291
1614
|
id,
|
|
1292
1615
|
op,
|
|
1293
|
-
resolvedTarget
|
|
1616
|
+
resolvedTarget!,
|
|
1294
1617
|
fromPullRequest &&
|
|
1295
1618
|
(fromPullRequest.title ||
|
|
1296
1619
|
`PR #${op.pullRequestUrl!.split("/").at(-1)}`),
|
|
1620
|
+
author.kind === "person",
|
|
1297
1621
|
)
|
|
1298
1622
|
: structuredClone(previous!);
|
|
1299
1623
|
|
|
@@ -1311,235 +1635,1390 @@ export class ReviewStore {
|
|
|
1311
1635
|
)
|
|
1312
1636
|
: 0;
|
|
1313
1637
|
|
|
1314
|
-
let applied: Applied | undefined;
|
|
1315
|
-
let lensTarget: { targetId: string; type: "lens" } | undefined;
|
|
1638
|
+
let applied: Applied | undefined;
|
|
1639
|
+
let lensTarget: { targetId: string; type: "lens" } | undefined;
|
|
1640
|
+
/** The head a worktree board read at, when it becomes a plan. */
|
|
1641
|
+
let unpinnedWorktree: string | undefined;
|
|
1642
|
+
|
|
1643
|
+
if (
|
|
1644
|
+
(op.type === "create" || op.type === "set_target") &&
|
|
1645
|
+
this.providers.headBranch
|
|
1646
|
+
) {
|
|
1647
|
+
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
1648
|
+
|
|
1649
|
+
if (pins) {
|
|
1650
|
+
const headRef =
|
|
1651
|
+
requestedTarget?.kind === "commits"
|
|
1652
|
+
? requestedTarget.head
|
|
1653
|
+
: undefined;
|
|
1654
|
+
|
|
1655
|
+
const branch = await this.providers.headBranch(pins, headRef);
|
|
1656
|
+
|
|
1657
|
+
snapshot.origin = { ...snapshot.origin, branch };
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (!requestedTarget && fromPullRequest)
|
|
1662
|
+
snapshot.origin = {
|
|
1663
|
+
...snapshot.origin,
|
|
1664
|
+
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
1665
|
+
baseBranch: fromPullRequest.baseBranch,
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// A base named rather than given as a commit, whole or abbreviated;
|
|
1669
|
+
// a stacked review names the branch below it. A plan compares nothing.
|
|
1670
|
+
if (requestedTarget && requestedTarget.kind !== "project") {
|
|
1671
|
+
const named = requestedTarget.base;
|
|
1672
|
+
const base = resolvedTarget?.pins?.base;
|
|
1673
|
+
|
|
1674
|
+
snapshot.origin = {
|
|
1675
|
+
...snapshot.origin,
|
|
1676
|
+
baseBranch:
|
|
1677
|
+
named && !base?.startsWith(named.toLowerCase()) ? named : undefined,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
switch (op.type) {
|
|
1682
|
+
case "create":
|
|
1683
|
+
if (initial) {
|
|
1684
|
+
snapshot.document = await this.resolveCommitLinks(
|
|
1685
|
+
documentSchema.parse(initial.document),
|
|
1686
|
+
snapshot,
|
|
1687
|
+
);
|
|
1688
|
+
snapshot.origin = {
|
|
1689
|
+
...snapshot.origin,
|
|
1690
|
+
...structuredClone(initial.origin),
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
for (const block of snapshot.document)
|
|
1694
|
+
assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
setPullRequest(snapshot, op.pullRequestUrl, await pullRequestTitle);
|
|
1698
|
+
break;
|
|
1699
|
+
case "rename":
|
|
1700
|
+
snapshot.title = op.title;
|
|
1701
|
+
break;
|
|
1702
|
+
case "set_target":
|
|
1703
|
+
setPullRequest(
|
|
1704
|
+
snapshot,
|
|
1705
|
+
op.pullRequestUrl ??
|
|
1706
|
+
(op.pullRequestUrl === null ||
|
|
1707
|
+
snapshot.target?.repositoryId !==
|
|
1708
|
+
resolvedTarget!.target.repositoryId
|
|
1709
|
+
? null
|
|
1710
|
+
: undefined),
|
|
1711
|
+
await pullRequestTitle,
|
|
1712
|
+
);
|
|
1713
|
+
snapshot.staleSources = [];
|
|
1714
|
+
|
|
1715
|
+
// A board that loses its pins keeps each reference pointing where it
|
|
1716
|
+
// did.
|
|
1717
|
+
if (snapshot.pins && !resolvedTarget!.pins) {
|
|
1718
|
+
const pinned = pinDocumentReferences(
|
|
1719
|
+
snapshot.document,
|
|
1720
|
+
snapshot.pins,
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
// A worktree quotes uncommitted lines its head commit lacks.
|
|
1724
|
+
if (
|
|
1725
|
+
snapshot.target?.kind === "worktree" &&
|
|
1726
|
+
JSON.stringify(pinned) !== JSON.stringify(snapshot.document)
|
|
1727
|
+
)
|
|
1728
|
+
unpinnedWorktree = snapshot.pins.head;
|
|
1729
|
+
snapshot.document = pinned;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
snapshot.target = resolvedTarget!.target;
|
|
1733
|
+
|
|
1734
|
+
if (resolvedTarget!.pins) snapshot.pins = resolvedTarget!.pins;
|
|
1735
|
+
else delete snapshot.pins;
|
|
1736
|
+
break;
|
|
1737
|
+
case "restore":
|
|
1738
|
+
snapshot = this.read(id, op.version);
|
|
1739
|
+
delete snapshot.lastEdit;
|
|
1740
|
+
break;
|
|
1741
|
+
case "lens_edit": {
|
|
1742
|
+
const lenses = snapshot.lenses ?? [];
|
|
1743
|
+
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
1744
|
+
|
|
1745
|
+
if (lenses.length) snapshot.lenses = lenses;
|
|
1746
|
+
else delete snapshot.lenses;
|
|
1747
|
+
lensTarget = { targetId: lens.id, type: "lens" };
|
|
1748
|
+
snapshot.lastEdit = {
|
|
1749
|
+
type: op.edit.type,
|
|
1750
|
+
targetId: lens.id,
|
|
1751
|
+
blockId: lens.id,
|
|
1752
|
+
kind: "lens",
|
|
1753
|
+
...(op.edit.type === "update" && {
|
|
1754
|
+
fields: Object.keys(op.edit).filter(
|
|
1755
|
+
(key) => key !== "type" && key !== "targetId",
|
|
1756
|
+
),
|
|
1757
|
+
}),
|
|
1758
|
+
};
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
case "edit": {
|
|
1763
|
+
const edit = respellEditLinks(op.edit);
|
|
1764
|
+
|
|
1765
|
+
applied = applyEdit(
|
|
1766
|
+
snapshot.document,
|
|
1767
|
+
edit,
|
|
1768
|
+
(prefix) => `${prefix}-${++nextId}`,
|
|
1769
|
+
);
|
|
1770
|
+
|
|
1771
|
+
snapshot.lastEdit = summarizeEdit(
|
|
1772
|
+
edit,
|
|
1773
|
+
applied,
|
|
1774
|
+
previous!.document,
|
|
1775
|
+
snapshot.document,
|
|
1776
|
+
);
|
|
1777
|
+
|
|
1778
|
+
// commitVersion normalizes it, as it does every version.
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const warnings = unpinnedWorktree
|
|
1784
|
+
? [
|
|
1785
|
+
`Its references now read at ${unpinnedWorktree.slice(0, 7)}, the checkout's last commit, which lacks any uncommitted lines they quoted. Check them.`,
|
|
1786
|
+
]
|
|
1787
|
+
: [];
|
|
1788
|
+
|
|
1789
|
+
return this.commitVersion({
|
|
1790
|
+
snapshot,
|
|
1791
|
+
previous,
|
|
1792
|
+
nextId,
|
|
1793
|
+
author,
|
|
1794
|
+
warnings,
|
|
1795
|
+
restore: op.type === "restore",
|
|
1796
|
+
repin: op.type === "set_target",
|
|
1797
|
+
projectLive: op.type !== "restore" && !resolvedTarget,
|
|
1798
|
+
result: {
|
|
1799
|
+
...(op.type === "create" && { created: true }),
|
|
1800
|
+
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
1801
|
+
...(applied && { type: applied.type }),
|
|
1802
|
+
...(lensTarget && { type: lensTarget.type }),
|
|
1803
|
+
...(applied?.children && { children: applied.children }),
|
|
1804
|
+
},
|
|
1805
|
+
...(op.type === "create" && {
|
|
1806
|
+
projectId: op.projectId,
|
|
1807
|
+
relations: { parentId: op.parentId, dependsOn: op.dependsOn },
|
|
1808
|
+
}),
|
|
1809
|
+
...((op.type === "edit" || op.type === "lens_edit") && {
|
|
1810
|
+
courier: {
|
|
1811
|
+
surface: op.type === "edit" ? "document" : "lenses",
|
|
1812
|
+
activityId: op.activityId,
|
|
1813
|
+
},
|
|
1814
|
+
}),
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Commits a board's next version: the one place a version is written.
|
|
1820
|
+
* Whoever writes it, it keeps the same rules:
|
|
1821
|
+
* - an agent may not change a block a person holds;
|
|
1822
|
+
* - the version names its author: a thread's names the thread, a
|
|
1823
|
+
* person's is theirs and never inherits a thread;
|
|
1824
|
+
* - only a change of repository moves a board to another project, and an
|
|
1825
|
+
* existing board never takes the project of the board opened last;
|
|
1826
|
+
* - only links the change adds are checked;
|
|
1827
|
+
* - the version, its link index, the board's project and its relations
|
|
1828
|
+
* are written together, and only if the board is still at the version
|
|
1829
|
+
* read and the rules still hold.
|
|
1830
|
+
* Runs inside the write queue.
|
|
1831
|
+
*/
|
|
1832
|
+
private async commitVersion(change: {
|
|
1833
|
+
snapshot: Snapshot;
|
|
1834
|
+
previous: Snapshot | undefined;
|
|
1835
|
+
nextId: number;
|
|
1836
|
+
author: Author;
|
|
1837
|
+
warnings?: string[];
|
|
1838
|
+
restore?: boolean;
|
|
1839
|
+
repin?: boolean;
|
|
1840
|
+
projectLive: boolean;
|
|
1841
|
+
/** The ids a person's save gave new blocks, by client key. */
|
|
1842
|
+
personIds?: Record<string, string>;
|
|
1843
|
+
/** Base version a person's save was made against. */
|
|
1844
|
+
baseVersion?: number;
|
|
1845
|
+
/** A save that, normalized, changes nothing saves no version. */
|
|
1846
|
+
unchangedSavesNothing?: boolean;
|
|
1847
|
+
result?: Omit<Result, "reviewId" | "version">;
|
|
1848
|
+
projectId?: string;
|
|
1849
|
+
relations?: { parentId?: string | null; dependsOn?: readonly string[] };
|
|
1850
|
+
courier?: { surface: ActivitySurface; activityId?: string };
|
|
1851
|
+
}): Promise<Result> {
|
|
1852
|
+
const { previous, author } = change;
|
|
1853
|
+
const id = change.snapshot.reviewId;
|
|
1854
|
+
let snapshot = change.snapshot;
|
|
1855
|
+
|
|
1856
|
+
// Attribution: stripped, then stamped for this author alone.
|
|
1857
|
+
delete snapshot.threadId;
|
|
1858
|
+
delete snapshot.personEdit;
|
|
1859
|
+
|
|
1860
|
+
if (snapshot.lastEdit) delete snapshot.lastEdit.threadId;
|
|
1861
|
+
|
|
1862
|
+
if (previous && !change.restore)
|
|
1863
|
+
await this.normalizeDocument(snapshot, previous);
|
|
1864
|
+
|
|
1865
|
+
if (
|
|
1866
|
+
change.unchangedSavesNothing &&
|
|
1867
|
+
previous &&
|
|
1868
|
+
snapshot.title === previous.title &&
|
|
1869
|
+
isDeepStrictEqual(snapshot.document, previous.document)
|
|
1870
|
+
)
|
|
1871
|
+
return { reviewId: id, version: previous.version };
|
|
1872
|
+
|
|
1873
|
+
const touched = previous
|
|
1874
|
+
? changedBlocks(previous.document, snapshot.document)
|
|
1875
|
+
: [];
|
|
1876
|
+
|
|
1877
|
+
// Refused before any slow check, and again as it commits.
|
|
1878
|
+
const holds = () => {
|
|
1879
|
+
if (author.kind !== "person") this.assertUnleased(id, touched);
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
holds();
|
|
1883
|
+
|
|
1884
|
+
if (author.kind === "person")
|
|
1885
|
+
snapshot.personEdit = {
|
|
1886
|
+
changed: touched,
|
|
1887
|
+
...(change.personIds && { ids: change.personIds }),
|
|
1888
|
+
};
|
|
1889
|
+
else if (author.threadId) {
|
|
1890
|
+
if (snapshot.lastEdit) snapshot.lastEdit.threadId = author.threadId;
|
|
1891
|
+
else snapshot.threadId = author.threadId;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
let warnings: string[];
|
|
1895
|
+
|
|
1896
|
+
({ snapshot, warnings } = await this.prepareVersion(snapshot, previous, {
|
|
1897
|
+
restore: change.restore,
|
|
1898
|
+
repin: change.repin,
|
|
1899
|
+
projectLive: change.projectLive,
|
|
1900
|
+
person: author.kind === "person",
|
|
1901
|
+
}));
|
|
1902
|
+
warnings = [...(change.warnings ?? []), ...warnings];
|
|
1903
|
+
|
|
1904
|
+
const result: Result = {
|
|
1905
|
+
...change.result,
|
|
1906
|
+
reviewId: id,
|
|
1907
|
+
version: snapshot.version,
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
if (warnings.length) result.warnings = warnings;
|
|
1911
|
+
|
|
1912
|
+
const projectId = this.projectFor(snapshot, previous, change.projectId);
|
|
1913
|
+
|
|
1914
|
+
const previousProject = previous ? this.projectOf(id) : undefined;
|
|
1915
|
+
const threadId = versionThread(snapshot);
|
|
1916
|
+
|
|
1917
|
+
const shown: ShownChange = {
|
|
1918
|
+
...(previous &&
|
|
1919
|
+
snapshot.title !== previous.title && { titleChanged: true }),
|
|
1920
|
+
...(previous &&
|
|
1921
|
+
(projectId !== previousProject ||
|
|
1922
|
+
boardRepositoryId(snapshot) !== boardRepositoryId(previous) ||
|
|
1923
|
+
boardIconKind(snapshot.target) !==
|
|
1924
|
+
boardIconKind(previous.target)) && {
|
|
1925
|
+
placeChanged: true,
|
|
1926
|
+
}),
|
|
1927
|
+
...(threadId && { threadId }),
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
this.commitCommand(
|
|
1931
|
+
result,
|
|
1932
|
+
() => {
|
|
1933
|
+
this.saveVersion(snapshot, change.nextId, projectId, change.author);
|
|
1934
|
+
|
|
1935
|
+
if (change.relations?.parentId)
|
|
1936
|
+
this.setParent(id, change.relations.parentId);
|
|
1937
|
+
|
|
1938
|
+
if (change.relations?.dependsOn)
|
|
1939
|
+
this.setDependencies(id, change.relations.dependsOn);
|
|
1940
|
+
|
|
1941
|
+
// Relations stay inside a project: a retarget out of it drops them.
|
|
1942
|
+
if (
|
|
1943
|
+
previous &&
|
|
1944
|
+
projectId !== previousProject &&
|
|
1945
|
+
this.dropRelationsOutside(id, projectId)
|
|
1946
|
+
)
|
|
1947
|
+
result.warnings = [
|
|
1948
|
+
...(result.warnings ?? []),
|
|
1949
|
+
"Its parent, children and dependencies were in its old project, so they were dropped: whiteboards relate only within a project.",
|
|
1950
|
+
];
|
|
1951
|
+
},
|
|
1952
|
+
previous
|
|
1953
|
+
? () => {
|
|
1954
|
+
// A board in Trash is read-only, whoever writes.
|
|
1955
|
+
this.assertWritable(id);
|
|
1956
|
+
|
|
1957
|
+
if (change.baseVersion !== undefined)
|
|
1958
|
+
assertBaseVersion(change.baseVersion, this.currentVersion(id));
|
|
1959
|
+
else this.assertMutation(id, previous.version);
|
|
1960
|
+
holds();
|
|
1961
|
+
}
|
|
1962
|
+
: undefined,
|
|
1963
|
+
change.courier && {
|
|
1964
|
+
...change.courier,
|
|
1965
|
+
// The version names the agent whose courier draws it.
|
|
1966
|
+
owned: (owner) => {
|
|
1967
|
+
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
shown,
|
|
1971
|
+
);
|
|
1972
|
+
|
|
1973
|
+
return result;
|
|
1974
|
+
}
|
|
1975
|
+
/** A create's named project, else its repository's; an edit keeps its
|
|
1976
|
+
* project unless it moves repository. */
|
|
1977
|
+
private projectFor(
|
|
1978
|
+
snapshot: Snapshot,
|
|
1979
|
+
previous: Snapshot | undefined,
|
|
1980
|
+
named?: string,
|
|
1981
|
+
) {
|
|
1982
|
+
if (named !== undefined) {
|
|
1983
|
+
if (!this.db.prepare("SELECT 1 FROM projects WHERE id=?").get(named))
|
|
1984
|
+
throw new ReviewInputError(
|
|
1985
|
+
`Unknown project ${named}; use a project.id from session_list.`,
|
|
1986
|
+
);
|
|
1987
|
+
|
|
1988
|
+
return named;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const repository = snapshot.target?.repositoryId;
|
|
1992
|
+
|
|
1993
|
+
if (
|
|
1994
|
+
previous &&
|
|
1995
|
+
(!repository ||
|
|
1996
|
+
repository === previous.target?.repositoryId ||
|
|
1997
|
+
!this.db
|
|
1998
|
+
.prepare("SELECT 1 FROM repositories WHERE id=?")
|
|
1999
|
+
.get(repository))
|
|
2000
|
+
)
|
|
2001
|
+
return this.projectOf(snapshot.reviewId);
|
|
2002
|
+
|
|
2003
|
+
return repository
|
|
2004
|
+
? this.projectOfRepository(repository)
|
|
2005
|
+
: this.fallbackProject();
|
|
2006
|
+
}
|
|
2007
|
+
private currentVersion(reviewId: string) {
|
|
2008
|
+
return Number(
|
|
2009
|
+
this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId)!
|
|
2010
|
+
.version,
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
/** Runs one write after every write queued before it. */
|
|
2014
|
+
private enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
2015
|
+
if (this.closing)
|
|
2016
|
+
return Promise.reject(new Error("Review store is closing."));
|
|
2017
|
+
|
|
2018
|
+
const run = this.pending.then(work);
|
|
2019
|
+
|
|
2020
|
+
this.pending = run.catch(() => {});
|
|
2021
|
+
|
|
2022
|
+
return run;
|
|
2023
|
+
}
|
|
2024
|
+
/** Respells a document's links and resolves its commit links, as every
|
|
2025
|
+
* save does. A changed reference is no longer stale. */
|
|
2026
|
+
private async normalizeDocument(snapshot: Snapshot, previous: Snapshot) {
|
|
2027
|
+
snapshot.document = await this.resolveCommitLinks(
|
|
2028
|
+
respellDocumentLinks(snapshot.document),
|
|
2029
|
+
snapshot,
|
|
2030
|
+
previous.document,
|
|
2031
|
+
);
|
|
2032
|
+
|
|
2033
|
+
if (snapshot.staleSources?.length) {
|
|
2034
|
+
const oldSources = new Map(
|
|
2035
|
+
sourceReferences(previous.document, {
|
|
2036
|
+
repositoryId: boardRepositoryId(previous),
|
|
2037
|
+
}).map((item) => [item.id, JSON.stringify(item.source)]),
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
const newSources = new Map(
|
|
2041
|
+
sourceReferences(snapshot.document, {
|
|
2042
|
+
repositoryId: boardRepositoryId(snapshot),
|
|
2043
|
+
}).map((item) => [item.id, JSON.stringify(item.source)]),
|
|
2044
|
+
);
|
|
2045
|
+
|
|
2046
|
+
snapshot.staleSources = snapshot.staleSources.filter(
|
|
2047
|
+
(id) => oldSources.get(id) === newSources.get(id) && newSources.has(id),
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/** Checks a candidate version and numbers it. A worktree board's version
|
|
2052
|
+
* may come back with its live source projected. */
|
|
2053
|
+
private async prepareVersion(
|
|
2054
|
+
candidate: Snapshot,
|
|
2055
|
+
previous: Snapshot | undefined,
|
|
2056
|
+
options: {
|
|
2057
|
+
restore?: boolean;
|
|
2058
|
+
repin?: boolean;
|
|
2059
|
+
projectLive: boolean;
|
|
2060
|
+
/** A person's save: links may name boards deleted since. */
|
|
2061
|
+
person?: boolean;
|
|
2062
|
+
},
|
|
2063
|
+
): Promise<{ snapshot: Snapshot; warnings: string[] }> {
|
|
2064
|
+
const snapshot =
|
|
2065
|
+
candidate.target?.kind === "worktree" && options.projectLive
|
|
2066
|
+
? await this.projectLiveSource(candidate)
|
|
2067
|
+
: candidate;
|
|
2068
|
+
|
|
2069
|
+
// Component shapes were checked at entry (or when merging a field patch).
|
|
2070
|
+
// Check cross-references here; do not reparse the whole stored document.
|
|
2071
|
+
checkReferences(snapshot.document);
|
|
2072
|
+
|
|
2073
|
+
// A restore may bring back links to deleted boards, so skip the check.
|
|
2074
|
+
if (!options.restore)
|
|
2075
|
+
this.checkBoardLinks(snapshot, previous, options.person);
|
|
2076
|
+
|
|
2077
|
+
if (
|
|
2078
|
+
snapshot.pins &&
|
|
2079
|
+
(!previous ||
|
|
2080
|
+
JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))
|
|
2081
|
+
)
|
|
2082
|
+
await this.providers.validatePins(snapshot.pins);
|
|
2083
|
+
|
|
2084
|
+
// Advice is about what this command wrote; earlier advice stays said.
|
|
2085
|
+
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
2086
|
+
|
|
2087
|
+
const warnings = [
|
|
2088
|
+
...documentAdvice(snapshot.document).filter(
|
|
2089
|
+
(advice) => !advised.has(advice),
|
|
2090
|
+
),
|
|
2091
|
+
...(await this.validateExternal(snapshot, previous, options.repin)),
|
|
2092
|
+
];
|
|
2093
|
+
|
|
2094
|
+
snapshot.version = previous ? previous.version + 1 : 0;
|
|
2095
|
+
snapshot.createdAt = new Date().toISOString();
|
|
2096
|
+
|
|
2097
|
+
return { snapshot, warnings };
|
|
2098
|
+
}
|
|
2099
|
+
/** Writes a version; call inside commitCommand. */
|
|
2100
|
+
private saveVersion(
|
|
2101
|
+
snapshot: Snapshot,
|
|
2102
|
+
nextId: number,
|
|
2103
|
+
projectId: string,
|
|
2104
|
+
author: Author,
|
|
2105
|
+
) {
|
|
2106
|
+
const id = snapshot.reviewId;
|
|
2107
|
+
|
|
2108
|
+
this.db
|
|
2109
|
+
.prepare(
|
|
2110
|
+
"INSERT INTO reviews(id,version,next_id,project_id) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id,project_id=excluded.project_id",
|
|
2111
|
+
)
|
|
2112
|
+
.run(id, snapshot.version, nextId, projectId);
|
|
2113
|
+
this.db
|
|
2114
|
+
.prepare(
|
|
2115
|
+
"INSERT INTO versions(review_id,version,snapshot,thread_id,author_kind) VALUES(?,?,?,?,?)",
|
|
2116
|
+
)
|
|
2117
|
+
.run(
|
|
2118
|
+
id,
|
|
2119
|
+
snapshot.version,
|
|
2120
|
+
JSON.stringify(snapshot),
|
|
2121
|
+
versionThread(snapshot),
|
|
2122
|
+
author.kind,
|
|
2123
|
+
);
|
|
2124
|
+
this.indexBoardLinks(id, snapshot.document);
|
|
2125
|
+
}
|
|
2126
|
+
/** Deletes a board and its history, if it is still at the version read
|
|
2127
|
+
* and passes `guard`: Delete… from Trash, Trash's purge and a discarded
|
|
2128
|
+
* empty board all end here. An agent can't delete a board while a person
|
|
2129
|
+
* holds a block in it. */
|
|
2130
|
+
private deleteBoard(
|
|
2131
|
+
{ reviewId, version }: Snapshot,
|
|
2132
|
+
author: Author,
|
|
2133
|
+
{
|
|
2134
|
+
guard,
|
|
2135
|
+
fromTrash = false,
|
|
2136
|
+
}: {
|
|
2137
|
+
guard?: () => void;
|
|
2138
|
+
/** Delete…, which is only for a board in Trash. */
|
|
2139
|
+
fromTrash?: boolean;
|
|
2140
|
+
} = {},
|
|
2141
|
+
): Result {
|
|
2142
|
+
const result: Result = { reviewId, version, deleted: true };
|
|
2143
|
+
|
|
2144
|
+
const rules = () => {
|
|
2145
|
+
guard?.();
|
|
2146
|
+
|
|
2147
|
+
if (fromTrash && !this.trashTime(reviewId))
|
|
2148
|
+
throw new ReviewInputError(
|
|
2149
|
+
"Move this whiteboard to Trash before deleting it.",
|
|
2150
|
+
409,
|
|
2151
|
+
);
|
|
2152
|
+
this.assertMutation(reviewId, version);
|
|
2153
|
+
|
|
2154
|
+
if (author.kind !== "person") this.assertUnheld(reviewId);
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
rules();
|
|
2158
|
+
|
|
2159
|
+
this.commitCommand(result, () => this.removePermanently(reviewId), rules);
|
|
2160
|
+
this.leases.delete(reviewId);
|
|
2161
|
+
|
|
2162
|
+
return result;
|
|
2163
|
+
}
|
|
2164
|
+
/** Writes what a board is outside its versions: its parent or what it
|
|
2165
|
+
* depends on, Trash or back, or that it was viewed. No version is saved,
|
|
2166
|
+
* but the rules are a version's: a board in Trash takes nothing but a
|
|
2167
|
+
* restore, and an agent can't put a board a person is in to Trash. */
|
|
2168
|
+
private commitBoardFacts(
|
|
2169
|
+
reviewId: string,
|
|
2170
|
+
author: Author,
|
|
2171
|
+
fact:
|
|
2172
|
+
| { parentId: string | null }
|
|
2173
|
+
| { dependsOn: readonly string[] }
|
|
2174
|
+
| { state: "active" | "trash" }
|
|
2175
|
+
| { viewed: true },
|
|
2176
|
+
): Result {
|
|
2177
|
+
const lifecycle = "state" in fact;
|
|
2178
|
+
|
|
2179
|
+
const rules = () => {
|
|
2180
|
+
this.assertExists(reviewId);
|
|
2181
|
+
|
|
2182
|
+
if (!lifecycle) this.assertOutOfTrash(reviewId);
|
|
2183
|
+
else if (fact.state === "trash" && author.kind !== "person")
|
|
2184
|
+
this.assertUnheld(reviewId);
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
rules();
|
|
2188
|
+
|
|
2189
|
+
const result: Result = { reviewId, version: this.read(reviewId).version };
|
|
2190
|
+
|
|
2191
|
+
if (lifecycle || "viewed" in fact) result.attention = true;
|
|
2192
|
+
|
|
2193
|
+
if (lifecycle) result.lifecycle = true;
|
|
2194
|
+
|
|
2195
|
+
this.commitCommand(
|
|
2196
|
+
result,
|
|
2197
|
+
() => {
|
|
2198
|
+
if ("parentId" in fact) this.setParent(reviewId, fact.parentId);
|
|
2199
|
+
else if ("dependsOn" in fact)
|
|
2200
|
+
this.setDependencies(reviewId, fact.dependsOn);
|
|
2201
|
+
else {
|
|
2202
|
+
this.db
|
|
2203
|
+
.prepare(
|
|
2204
|
+
"INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
|
|
2205
|
+
)
|
|
2206
|
+
.run(reviewId);
|
|
2207
|
+
|
|
2208
|
+
if ("viewed" in fact)
|
|
2209
|
+
this.db
|
|
2210
|
+
.prepare(
|
|
2211
|
+
"UPDATE review_attention SET viewed_at=? WHERE review_id=?",
|
|
2212
|
+
)
|
|
2213
|
+
.run(new Date().toISOString(), reviewId);
|
|
2214
|
+
else {
|
|
2215
|
+
// A second move to Trash keeps the first deadline.
|
|
2216
|
+
const timestamp =
|
|
2217
|
+
fact.state === "trash" ? new Date().toISOString() : null;
|
|
2218
|
+
|
|
2219
|
+
this.db
|
|
2220
|
+
.prepare(
|
|
2221
|
+
"UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?",
|
|
2222
|
+
)
|
|
2223
|
+
.run(timestamp, timestamp, reviewId);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
if ("parentId" in fact || "dependsOn" in fact)
|
|
2228
|
+
result.relations = this.relations(reviewId);
|
|
2229
|
+
},
|
|
2230
|
+
rules,
|
|
2231
|
+
);
|
|
2232
|
+
|
|
2233
|
+
return result;
|
|
2234
|
+
}
|
|
2235
|
+
/** Deletes a board the person made and left as it was: with no title
|
|
2236
|
+
* (it reads "Untitled"), no blocks and no child boards. A title typed as
|
|
2237
|
+
* "Untitled" is a title. A board still open on a canvas, or
|
|
2238
|
+
* one an agent is working on, is kept. A closing canvas saves, then lets
|
|
2239
|
+
* go of the board, so the discard waits for that first. */
|
|
2240
|
+
async discard(reviewId: string): Promise<{ deleted: boolean }> {
|
|
2241
|
+
const deadline = Date.now() + FOCUS_LEASE_TTL_MS;
|
|
2242
|
+
|
|
2243
|
+
while (this.leased(reviewId) && Date.now() < deadline)
|
|
2244
|
+
await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
|
|
2245
|
+
|
|
2246
|
+
return this.enqueue(async () => {
|
|
2247
|
+
if (!this.has(reviewId)) return { deleted: false };
|
|
2248
|
+
const snapshot = this.read(reviewId);
|
|
2249
|
+
|
|
2250
|
+
if (
|
|
2251
|
+
this.leased(reviewId) ||
|
|
2252
|
+
this.activity.isWorking(reviewId) ||
|
|
2253
|
+
snapshot.title !== "" ||
|
|
2254
|
+
snapshot.document.length ||
|
|
2255
|
+
this.relationGraph(true).children.get(reviewId)?.length
|
|
2256
|
+
)
|
|
2257
|
+
return { deleted: false };
|
|
2258
|
+
|
|
2259
|
+
this.deleteBoard(snapshot, { kind: "person" });
|
|
2260
|
+
|
|
2261
|
+
return { deleted: true };
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
/** Each board's canvas leases by client: the board is open there, and
|
|
2265
|
+
* `blockId` is the block the person is in, if any. In memory: a lease
|
|
2266
|
+
* outlives neither its renewals nor the host. */
|
|
2267
|
+
private readonly leases = new Map<
|
|
2268
|
+
string,
|
|
2269
|
+
Map<string, { blockId: string | null; expiresAt: number }>
|
|
2270
|
+
>();
|
|
2271
|
+
|
|
2272
|
+
/** Whether a canvas still holds the board open. */
|
|
2273
|
+
private leased(reviewId: string) {
|
|
2274
|
+
const now = Date.now();
|
|
2275
|
+
|
|
2276
|
+
return [...(this.leases.get(reviewId)?.values() ?? [])].some(
|
|
2277
|
+
(lease) => lease.expiresAt > now,
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** Records the block a person's canvas has focus in (null for none), or
|
|
2282
|
+
* with `open: false` that the canvas closed the board. */
|
|
2283
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Lease boundary: focusLeaseSchema.parse below validates incoming JSON.
|
|
2284
|
+
focus(reviewId: string, input: unknown) {
|
|
2285
|
+
const { clientId, blockId, open } = focusLeaseSchema.parse(input);
|
|
2286
|
+
|
|
2287
|
+
this.assertExists(reviewId);
|
|
2288
|
+
const leases = this.leases.get(reviewId) ?? new Map();
|
|
2289
|
+
|
|
2290
|
+
if (open === false) leases.delete(clientId);
|
|
2291
|
+
else
|
|
2292
|
+
leases.set(clientId, {
|
|
2293
|
+
blockId,
|
|
2294
|
+
expiresAt: Date.now() + FOCUS_LEASE_TTL_MS,
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
if (leases.size) this.leases.set(reviewId, leases);
|
|
2298
|
+
else this.leases.delete(reviewId);
|
|
2299
|
+
|
|
2300
|
+
return { ok: true as const };
|
|
2301
|
+
}
|
|
2302
|
+
/** Refuses an agent's change to a board while a person holds any block
|
|
2303
|
+
* in it. */
|
|
2304
|
+
private assertUnheld(reviewId: string) {
|
|
2305
|
+
const now = Date.now();
|
|
2306
|
+
|
|
2307
|
+
for (const lease of this.leases.get(reviewId)?.values() ?? [])
|
|
2308
|
+
if (lease.expiresAt > now && lease.blockId)
|
|
2309
|
+
throw new ReviewInputError(
|
|
2310
|
+
`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`,
|
|
2311
|
+
409,
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** Refuses an agent's edit to a block a person is editing. */
|
|
2316
|
+
private assertUnleased(reviewId: string, changed: readonly string[]) {
|
|
2317
|
+
const leases = this.leases.get(reviewId);
|
|
2318
|
+
|
|
2319
|
+
if (!leases || !changed.length) return;
|
|
2320
|
+
const now = Date.now();
|
|
2321
|
+
|
|
2322
|
+
for (const [clientId, lease] of leases)
|
|
2323
|
+
if (lease.expiresAt <= now) leases.delete(clientId);
|
|
2324
|
+
else if (lease.blockId && changed.includes(lease.blockId))
|
|
2325
|
+
throw new ReviewInputError(
|
|
2326
|
+
`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`,
|
|
2327
|
+
409,
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
/** Saves a person's whole board as their own version, against the version
|
|
2331
|
+
* they started from. Blocks without ids get fresh ones. */
|
|
2332
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Save boundary: personEditSchema and documentSchema parse below validate incoming JSON.
|
|
2333
|
+
personEdit(reviewId: string, input: unknown): Promise<PersonEditResult> {
|
|
2334
|
+
const body = personEditSchema.parse(input);
|
|
2335
|
+
|
|
2336
|
+
return this.enqueue(async () => {
|
|
2337
|
+
const previous = this.read(reviewId);
|
|
2338
|
+
|
|
2339
|
+
assertBaseVersion(body.baseVersion, previous.version);
|
|
2340
|
+
|
|
2341
|
+
const { document, nextId, ids } = personDocument(
|
|
2342
|
+
body.document,
|
|
2343
|
+
Number(
|
|
2344
|
+
this.db
|
|
2345
|
+
.prepare("SELECT next_id FROM reviews WHERE id=?")
|
|
2346
|
+
.get(reviewId)!.next_id,
|
|
2347
|
+
),
|
|
2348
|
+
);
|
|
2349
|
+
|
|
2350
|
+
const snapshot = structuredClone(previous);
|
|
2351
|
+
|
|
2352
|
+
delete snapshot.sourceUnavailable;
|
|
2353
|
+
delete snapshot.lastEdit;
|
|
2354
|
+
snapshot.title = body.title;
|
|
2355
|
+
snapshot.document = document;
|
|
2356
|
+
|
|
2357
|
+
// `ids` lets the canvas recognise its own save on the stream, even
|
|
2358
|
+
// when the save's response was lost.
|
|
2359
|
+
const { version } = await this.commitVersion({
|
|
2360
|
+
snapshot,
|
|
2361
|
+
previous,
|
|
2362
|
+
nextId,
|
|
2363
|
+
author: { kind: "person" },
|
|
2364
|
+
projectLive: true,
|
|
2365
|
+
personIds: ids,
|
|
2366
|
+
baseVersion: body.baseVersion,
|
|
2367
|
+
unchangedSavesNothing: true,
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
return { reviewId, version, ids };
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
/** Where a board sits: its parent, and the parent's children in order,
|
|
2374
|
+
* each with what its header shows. */
|
|
2375
|
+
place(id: string) {
|
|
2376
|
+
const graph = this.relationGraph(true);
|
|
2377
|
+
const parentId = graph.parents.get(id);
|
|
2378
|
+
|
|
2379
|
+
const placed = (reviewId: string): PlacedBoard => {
|
|
2380
|
+
const row = this.db
|
|
2381
|
+
.prepare(
|
|
2382
|
+
`SELECT json_extract(versions.snapshot,'$.title') AS title,
|
|
2383
|
+
json_extract(versions.snapshot,'$.target') AS target,
|
|
2384
|
+
json_extract(versions.snapshot,'$.origin') AS origin
|
|
2385
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2386
|
+
WHERE reviews.id=?`,
|
|
2387
|
+
)
|
|
2388
|
+
.get(reviewId)!;
|
|
2389
|
+
|
|
2390
|
+
return {
|
|
2391
|
+
reviewId,
|
|
2392
|
+
title: String(row.title),
|
|
2393
|
+
...(row.target && {
|
|
2394
|
+
// SAFETY: the target was validated when the version was saved.
|
|
2395
|
+
target: JSON.parse(String(row.target)) as ReviewTarget,
|
|
2396
|
+
}),
|
|
2397
|
+
...(row.origin && {
|
|
2398
|
+
// SAFETY: the origin was validated when the version was saved.
|
|
2399
|
+
origin: JSON.parse(String(row.origin)) as SnapshotOrigin,
|
|
2400
|
+
}),
|
|
2401
|
+
};
|
|
2402
|
+
};
|
|
2403
|
+
|
|
2404
|
+
return {
|
|
2405
|
+
board: placed(id),
|
|
2406
|
+
...(parentId && { parent: placed(parentId) }),
|
|
2407
|
+
siblings: parentId
|
|
2408
|
+
? this.orderChildren(
|
|
2409
|
+
this.listedSiblings(graph.children.get(parentId)!, id),
|
|
2410
|
+
graph,
|
|
2411
|
+
).map(placed)
|
|
2412
|
+
: [],
|
|
2413
|
+
// A top-level board's own, in the order the sidebar lists them.
|
|
2414
|
+
...(!parentId && {
|
|
2415
|
+
children: this.orderChildren(
|
|
2416
|
+
this.listedSiblings(graph.children.get(id) ?? [], id),
|
|
2417
|
+
graph,
|
|
2418
|
+
).map(placed),
|
|
2419
|
+
}),
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
/** The siblings Home and the sidebar list, which a board's place counts
|
|
2423
|
+
* among: all but those in Trash, and the board itself. */
|
|
2424
|
+
private listedSiblings(ids: readonly string[], self: string) {
|
|
2425
|
+
const trashed = new Set(this.trashedIds());
|
|
2426
|
+
|
|
2427
|
+
return ids.filter((id) => id === self || !trashed.has(id));
|
|
2428
|
+
}
|
|
2429
|
+
/** A board's parent, its children in sibling order, and what it depends on. */
|
|
2430
|
+
relations(id: string): BoardRelations {
|
|
2431
|
+
const graph = this.relationGraph();
|
|
2432
|
+
|
|
2433
|
+
return {
|
|
2434
|
+
parentId: graph.parents.get(id) ?? null,
|
|
2435
|
+
children: this.orderChildren(graph.children.get(id) ?? [], graph),
|
|
2436
|
+
dependsOn: graph.dependsOn.get(id) ?? [],
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
/** Parents, children and dependencies. A board in Trash is out of them
|
|
2440
|
+
* until it is restored, its rows kept; only its own place sees it. */
|
|
2441
|
+
private relationGraph(withTrash = false) {
|
|
2442
|
+
const parents = new Map<string, string>();
|
|
2443
|
+
const children = new Map<string, string[]>();
|
|
2444
|
+
const dependsOn = new Map<string, string[]>();
|
|
2445
|
+
const trashed = new Set(withTrash ? [] : this.trashedIds());
|
|
2446
|
+
|
|
2447
|
+
for (const row of this.db
|
|
2448
|
+
.prepare(
|
|
2449
|
+
"SELECT id, parent_id FROM reviews WHERE parent_id IS NOT NULL ORDER BY rowid",
|
|
2450
|
+
)
|
|
2451
|
+
.all()) {
|
|
2452
|
+
const board = String(row.id);
|
|
2453
|
+
const parent = String(row.parent_id);
|
|
2454
|
+
|
|
2455
|
+
if (trashed.has(board) || trashed.has(parent)) continue;
|
|
2456
|
+
|
|
2457
|
+
parents.set(board, parent);
|
|
2458
|
+
children.set(parent, [...(children.get(parent) ?? []), board]);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
for (const row of this.db
|
|
2462
|
+
.prepare(
|
|
2463
|
+
"SELECT review_id, depends_on FROM board_dependencies ORDER BY rowid",
|
|
2464
|
+
)
|
|
2465
|
+
.all()) {
|
|
2466
|
+
const board = String(row.review_id);
|
|
2467
|
+
const dependency = String(row.depends_on);
|
|
2468
|
+
|
|
2469
|
+
if (trashed.has(board) || trashed.has(dependency)) continue;
|
|
2470
|
+
|
|
2471
|
+
dependsOn.set(board, [...(dependsOn.get(board) ?? []), dependency]);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
return { parents, children, dependsOn };
|
|
2475
|
+
}
|
|
2476
|
+
/** Drops a board's parent, children and dependencies in other projects,
|
|
2477
|
+
* after it moved to `projectId`. Whether it dropped any. */
|
|
2478
|
+
private dropRelationsOutside(id: string, projectId: string): boolean {
|
|
2479
|
+
const outside = "SELECT id FROM reviews WHERE project_id!=?";
|
|
2480
|
+
|
|
2481
|
+
const parents = this.db
|
|
2482
|
+
.prepare(
|
|
2483
|
+
`UPDATE reviews SET parent_id=NULL WHERE (id=? AND parent_id IN (${outside})) OR (parent_id=? AND project_id!=?)`,
|
|
2484
|
+
)
|
|
2485
|
+
.run(id, projectId, id, projectId).changes;
|
|
2486
|
+
|
|
2487
|
+
const dependencies = this.db
|
|
2488
|
+
.prepare(
|
|
2489
|
+
`DELETE FROM board_dependencies WHERE (review_id=? AND depends_on IN (${outside})) OR (depends_on=? AND review_id IN (${outside}))`,
|
|
2490
|
+
)
|
|
2491
|
+
.run(id, projectId, id, projectId).changes;
|
|
2492
|
+
|
|
2493
|
+
return Number(parents) + Number(dependencies) > 0;
|
|
2494
|
+
}
|
|
2495
|
+
private orderChildren(
|
|
2496
|
+
children: readonly string[],
|
|
2497
|
+
graph: ReturnType<ReviewStore["relationGraph"]>,
|
|
2498
|
+
): string[] {
|
|
2499
|
+
if (children.length < 2) return [...children];
|
|
2500
|
+
|
|
2501
|
+
return orderSiblings(
|
|
2502
|
+
this.siblingFacts(children),
|
|
2503
|
+
(id) => graph.dependsOn.get(id) ?? [],
|
|
2504
|
+
(repositoryId, ancestor, of) =>
|
|
2505
|
+
this.isAncestor(repositoryId, ancestor, of),
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
private siblingFacts(ids: readonly string[]): SiblingFacts[] {
|
|
2509
|
+
return this.db
|
|
2510
|
+
.prepare(
|
|
2511
|
+
`SELECT reviews.id,
|
|
2512
|
+
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first
|
|
2513
|
+
WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS created_at,
|
|
2514
|
+
json_extract(versions.snapshot,'$.pins') AS pins,
|
|
2515
|
+
json_extract(versions.snapshot,'$.origin.branch') AS branch,
|
|
2516
|
+
json_extract(versions.snapshot,'$.origin.pullRequestUrl') AS url
|
|
2517
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2518
|
+
WHERE reviews.id IN (${ids.map(() => "?").join(",")})`,
|
|
2519
|
+
)
|
|
2520
|
+
.all(...ids)
|
|
2521
|
+
.map((row) => {
|
|
2522
|
+
const id = String(row.id);
|
|
2523
|
+
|
|
2524
|
+
// SAFETY: pins were validated when the version was saved.
|
|
2525
|
+
const saved = row.pins
|
|
2526
|
+
? (JSON.parse(String(row.pins)) as Pins)
|
|
2527
|
+
: undefined;
|
|
2528
|
+
|
|
2529
|
+
// A worktree's pins follow its checkout.
|
|
2530
|
+
const pins = this.liveSources.get(id)?.pins ?? saved;
|
|
2531
|
+
const url = row.url ? String(row.url) : undefined;
|
|
2532
|
+
const baseBranch = url && this.providers.pullRequestBaseBranch?.(url);
|
|
2533
|
+
|
|
2534
|
+
return {
|
|
2535
|
+
id,
|
|
2536
|
+
createdAt: String(row.created_at),
|
|
2537
|
+
...(pins && {
|
|
2538
|
+
repositoryId: pins.repositoryId,
|
|
2539
|
+
base: pins.base,
|
|
2540
|
+
head: pins.head,
|
|
2541
|
+
}),
|
|
2542
|
+
...(url && row.branch && { headBranch: String(row.branch) }),
|
|
2543
|
+
...(baseBranch && { baseBranch }),
|
|
2544
|
+
};
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
/** Commits never change, so a definite answer is kept. */
|
|
2548
|
+
private readonly ancestry = new Map<string, boolean>();
|
|
2549
|
+
|
|
2550
|
+
/** When git was last asked, for questions with no answer yet. */
|
|
2551
|
+
private readonly ancestryAsked = new Map<string, number>();
|
|
2552
|
+
|
|
2553
|
+
/** Whether `ancestor` is an ancestor of `of`, as far as is known. git is
|
|
2554
|
+
* asked in the background, never on the server thread: until it answers,
|
|
2555
|
+
* or when it fails, the answer is no, and a yes re-sorts the catalog. */
|
|
2556
|
+
private isAncestor(repositoryId: string, ancestor: string, of: string) {
|
|
2557
|
+
const key = `${repositoryId}:${ancestor}:${of}`;
|
|
2558
|
+
const known = this.ancestry.get(key);
|
|
2559
|
+
|
|
2560
|
+
if (known !== undefined) return known;
|
|
2561
|
+
|
|
2562
|
+
const asked = this.ancestryAsked.get(key);
|
|
2563
|
+
|
|
2564
|
+
// A commit not fetched yet may arrive: ask again, but not on every read.
|
|
2565
|
+
if (asked !== undefined && Date.now() - asked < 60_000) return false;
|
|
2566
|
+
|
|
2567
|
+
this.ancestryAsked.set(key, Date.now());
|
|
2568
|
+
let root: string;
|
|
2569
|
+
|
|
2570
|
+
try {
|
|
2571
|
+
root = this.repositoryPath(repositoryId);
|
|
2572
|
+
} catch {
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// At most a few at once: a parent with many children asks many pairs.
|
|
2577
|
+
this.ancestryQueue.push(() =>
|
|
2578
|
+
runVcs(
|
|
2579
|
+
"git",
|
|
2580
|
+
[
|
|
2581
|
+
"-C",
|
|
2582
|
+
root,
|
|
2583
|
+
"merge-base",
|
|
2584
|
+
"--is-ancestor",
|
|
2585
|
+
{ rev: ancestor },
|
|
2586
|
+
{ rev: of },
|
|
2587
|
+
],
|
|
2588
|
+
{ timeout: 10_000 },
|
|
2589
|
+
).then(
|
|
2590
|
+
() => this.knowAncestry(key, true),
|
|
2591
|
+
(error: { code?: unknown }) => {
|
|
2592
|
+
// Exit 1 means no; anything else (a missing commit, a timeout, a
|
|
2593
|
+
// refused operand) is no answer.
|
|
2594
|
+
if (error.code === 1) this.knowAncestry(key, false);
|
|
2595
|
+
},
|
|
2596
|
+
),
|
|
2597
|
+
);
|
|
2598
|
+
this.askNextAncestry();
|
|
2599
|
+
|
|
2600
|
+
return false;
|
|
2601
|
+
}
|
|
2602
|
+
private readonly ancestryQueue: (() => Promise<void>)[] = [];
|
|
2603
|
+
private ancestryRunning = 0;
|
|
2604
|
+
private askNextAncestry() {
|
|
2605
|
+
while (
|
|
2606
|
+
this.ancestryRunning < ANCESTRY_CONCURRENCY &&
|
|
2607
|
+
this.ancestryQueue.length
|
|
2608
|
+
) {
|
|
2609
|
+
this.ancestryRunning++;
|
|
2610
|
+
void this.ancestryQueue.shift()!().finally(() => {
|
|
2611
|
+
this.ancestryRunning--;
|
|
2612
|
+
this.askNextAncestry();
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
private knowAncestry(key: string, ancestor: boolean) {
|
|
2617
|
+
this.ancestry.set(key, ancestor);
|
|
2618
|
+
this.ancestryAsked.delete(key);
|
|
2619
|
+
|
|
2620
|
+
if (ancestor && !this.closing) this.catalogChanged();
|
|
2621
|
+
}
|
|
2622
|
+
private catalogChanged() {
|
|
2623
|
+
for (const listener of this.catalogListeners)
|
|
2624
|
+
try {
|
|
2625
|
+
listener();
|
|
2626
|
+
} catch {
|
|
2627
|
+
// A disconnected viewer must not block other catalog subscribers.
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
private boardName(id: string) {
|
|
2631
|
+
return `"${boardTitle(this.read(id).title)}" (${id})`;
|
|
2632
|
+
}
|
|
2633
|
+
/** Exists, out of Trash, same project. */
|
|
2634
|
+
private checkRelated(id: string, other: string) {
|
|
2635
|
+
if (!this.has(other))
|
|
2636
|
+
throw new ReviewInputError(
|
|
2637
|
+
`Whiteboard ${other} does not exist. Name a whiteboard by its sessionId from session_list.`,
|
|
2638
|
+
);
|
|
2639
|
+
this.assertOutOfTrash(other);
|
|
2640
|
+
|
|
2641
|
+
if (this.projectOf(id) !== this.projectOf(other))
|
|
2642
|
+
throw new ReviewInputError(
|
|
2643
|
+
`${this.boardName(other)} is in another project than ${this.boardName(id)}. A whiteboard's parent and dependencies are in its own project.`,
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
/** One parent, one level: a parent is top-level and a child has no
|
|
2647
|
+
* children, Trash included. Null makes the board top-level. */
|
|
2648
|
+
private setParent(id: string, parentId: string | null) {
|
|
2649
|
+
if (parentId !== null) {
|
|
2650
|
+
if (parentId === id)
|
|
2651
|
+
throw new ReviewInputError("A whiteboard can't be its own parent.");
|
|
2652
|
+
this.checkRelated(id, parentId);
|
|
2653
|
+
const { parents, children } = this.relationGraph(true);
|
|
2654
|
+
const grandparent = parents.get(parentId);
|
|
2655
|
+
|
|
2656
|
+
if (grandparent)
|
|
2657
|
+
throw new ReviewInputError(
|
|
2658
|
+
`${this.boardName(parentId)} is a child of ${this.boardName(grandparent)}, and whiteboards nest one level deep. Use ${grandparent} as the parent instead.`,
|
|
2659
|
+
);
|
|
2660
|
+
const own = children.get(id) ?? [];
|
|
2661
|
+
|
|
2662
|
+
if (own.length)
|
|
2663
|
+
throw new ReviewInputError(
|
|
2664
|
+
`${this.boardName(id)} has children (${own.map((child) => this.boardName(child)).join(", ")}), and whiteboards nest one level deep, so it can't become a child. Move its children first with session_set_parent.`,
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
this.db
|
|
2669
|
+
.prepare("UPDATE reviews SET parent_id=? WHERE id=?")
|
|
2670
|
+
.run(parentId, id);
|
|
2671
|
+
}
|
|
2672
|
+
private setDependencies(id: string, dependsOn: readonly string[]) {
|
|
2673
|
+
const unique = [...new Set(dependsOn)];
|
|
2674
|
+
const graph = this.relationGraph(true);
|
|
2675
|
+
|
|
2676
|
+
for (const dependency of unique) {
|
|
2677
|
+
if (dependency === id)
|
|
2678
|
+
throw new ReviewInputError("A whiteboard can't depend on itself.");
|
|
2679
|
+
|
|
2680
|
+
const cycle = dependencyCycle(
|
|
2681
|
+
id,
|
|
2682
|
+
dependency,
|
|
2683
|
+
(board) => graph.dependsOn.get(board) ?? [],
|
|
2684
|
+
);
|
|
2685
|
+
|
|
2686
|
+
if (cycle)
|
|
2687
|
+
throw new ReviewInputError(
|
|
2688
|
+
`Depending on ${this.boardName(dependency)} would close a cycle: ${cycle.map((board) => this.boardName(board)).join(" → ")}. Drop one of those dependencies first.`,
|
|
2689
|
+
);
|
|
2690
|
+
this.checkRelated(id, dependency);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
this.db.prepare("DELETE FROM board_dependencies WHERE review_id=?").run(id);
|
|
2694
|
+
|
|
2695
|
+
const insert = this.db.prepare(
|
|
2696
|
+
"INSERT INTO board_dependencies(review_id,depends_on) VALUES(?,?)",
|
|
2697
|
+
);
|
|
2698
|
+
|
|
2699
|
+
for (const dependency of unique) insert.run(id, dependency);
|
|
2700
|
+
}
|
|
2701
|
+
/** New links must point at a board and block that exist. Saved links are
|
|
2702
|
+
* not rechecked, since their target may have been deleted. */
|
|
2703
|
+
private checkBoardLinks(
|
|
2704
|
+
snapshot: Snapshot,
|
|
2705
|
+
previous?: Snapshot,
|
|
2706
|
+
/** ⌘Z can bring back a link to a board or block deleted since; it
|
|
2707
|
+
* shows as deleted, like any link whose target went away. */
|
|
2708
|
+
person = false,
|
|
2709
|
+
) {
|
|
2710
|
+
const key = ({ to }: BoardLinkRef) => `${to.board}#${to.block ?? ""}`;
|
|
2711
|
+
|
|
2712
|
+
const kept = new Set(
|
|
2713
|
+
boardLinks(snapshot.reviewId, previous?.document ?? []).map(key),
|
|
2714
|
+
);
|
|
2715
|
+
|
|
2716
|
+
for (const link of boardLinks(snapshot.reviewId, snapshot.document)) {
|
|
2717
|
+
if (kept.has(key(link))) continue;
|
|
2718
|
+
const { from, to } = link;
|
|
2719
|
+
|
|
2720
|
+
const document =
|
|
2721
|
+
to.board === snapshot.reviewId
|
|
2722
|
+
? snapshot.document
|
|
2723
|
+
: this.has(to.board)
|
|
2724
|
+
? this.read(to.board).document
|
|
2725
|
+
: undefined;
|
|
2726
|
+
|
|
2727
|
+
if (!document && person && this.wasDeleted(to.board)) continue;
|
|
2728
|
+
|
|
2729
|
+
if (!document)
|
|
2730
|
+
throw new ReviewInputError(
|
|
2731
|
+
`Block ${from.block} links to board ${to.board}, which does not exist. Link a board by its sessionId from session_list.`,
|
|
2732
|
+
);
|
|
2733
|
+
|
|
2734
|
+
if (
|
|
2735
|
+
!person &&
|
|
2736
|
+
to.block !== undefined &&
|
|
2737
|
+
!elements(document).some((element) => element.id === to.block)
|
|
2738
|
+
)
|
|
2739
|
+
throw new ReviewInputError(
|
|
2740
|
+
`Block ${from.block} links to block ${to.block}, which is not on board ${to.board}. Read the board with session_get for its block ids.`,
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
private wasDeleted(id: string) {
|
|
2745
|
+
return (
|
|
2746
|
+
this.db.prepare("SELECT 1 FROM deleted_boards WHERE id=?").get(id) !==
|
|
2747
|
+
undefined
|
|
2748
|
+
);
|
|
2749
|
+
}
|
|
2750
|
+
/** The boards that link to a board or its blocks. */
|
|
2751
|
+
linkingBoards(id: string): string[] {
|
|
2752
|
+
return this.db
|
|
2753
|
+
.prepare("SELECT DISTINCT from_review FROM board_links WHERE to_review=?")
|
|
2754
|
+
.all(id)
|
|
2755
|
+
.map((row) => String(row.from_review));
|
|
2756
|
+
}
|
|
2757
|
+
/** Rebuilds the board_links rows for one board. */
|
|
2758
|
+
private indexBoardLinks(id: string, document: Block[]) {
|
|
2759
|
+
this.db.prepare("DELETE FROM board_links WHERE from_review=?").run(id);
|
|
2760
|
+
|
|
2761
|
+
const insert = this.db.prepare(
|
|
2762
|
+
"INSERT INTO board_links(from_review,from_block,to_review,to_block) VALUES(?,?,?,?)",
|
|
2763
|
+
);
|
|
1316
2764
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
2765
|
+
for (const { from, to } of boardLinks(id, document))
|
|
2766
|
+
insert.run(from.board, from.block, to.board, to.block ?? null);
|
|
2767
|
+
}
|
|
2768
|
+
/** Link previews by board, version and block; a version never changes. */
|
|
2769
|
+
private readonly linkPreviews = new Map<string, BoardLinkPreview>();
|
|
1322
2770
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
? requestedTarget.head
|
|
1327
|
-
: undefined;
|
|
2771
|
+
private linkPreview(target: Snapshot, block?: string): BoardLinkPreview {
|
|
2772
|
+
const key = `${target.reviewId}@${target.version}#${block ?? ""}`;
|
|
2773
|
+
let preview = this.linkPreviews.get(key);
|
|
1328
2774
|
|
|
1329
|
-
|
|
2775
|
+
if (!preview) {
|
|
2776
|
+
preview = boardLinkPreview(target.document, block);
|
|
1330
2777
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
}
|
|
2778
|
+
if (this.linkPreviews.size >= 500)
|
|
2779
|
+
this.linkPreviews.delete(this.linkPreviews.keys().next().value!);
|
|
1334
2780
|
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
...snapshot.origin,
|
|
1338
|
-
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
1339
|
-
baseBranch: fromPullRequest.baseBranch,
|
|
1340
|
-
};
|
|
2781
|
+
this.linkPreviews.set(key, preview);
|
|
2782
|
+
}
|
|
1341
2783
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
if (requestedTarget)
|
|
1345
|
-
snapshot.origin = {
|
|
1346
|
-
...snapshot.origin,
|
|
1347
|
-
baseBranch:
|
|
1348
|
-
requestedTarget.base === resolvedTarget?.pins.base
|
|
1349
|
-
? undefined
|
|
1350
|
-
: requestedTarget.base,
|
|
1351
|
-
};
|
|
2784
|
+
return preview;
|
|
2785
|
+
}
|
|
1352
2786
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
2787
|
+
/** What a link's hover card shows of the board or block it links to,
|
|
2788
|
+
* read when the card opens rather than with every link. Only for a link
|
|
2789
|
+
* the version has. */
|
|
2790
|
+
linkPeek(
|
|
2791
|
+
id: string,
|
|
2792
|
+
to: { board: string; block?: string },
|
|
2793
|
+
version?: number,
|
|
2794
|
+
): BoardLinkPreview {
|
|
2795
|
+
const linked = boardLinks(id, this.read(id, version).document).some(
|
|
2796
|
+
(link) => link.to.board === to.board && link.to.block === to.block,
|
|
2797
|
+
);
|
|
1361
2798
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
}
|
|
2799
|
+
if (!linked || !this.has(to.board))
|
|
2800
|
+
throw new ReviewInputError("The board has no such link.", 404);
|
|
1365
2801
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
case "rename":
|
|
1369
|
-
snapshot.title = op.title;
|
|
1370
|
-
break;
|
|
1371
|
-
case "set_target":
|
|
1372
|
-
setPullRequest(
|
|
1373
|
-
snapshot,
|
|
1374
|
-
op.pullRequestUrl ??
|
|
1375
|
-
(op.pullRequestUrl === null ||
|
|
1376
|
-
snapshot.pins?.repositoryId !== resolvedTarget!.pins.repositoryId
|
|
1377
|
-
? null
|
|
1378
|
-
: undefined),
|
|
1379
|
-
await pullRequestTitle,
|
|
1380
|
-
);
|
|
1381
|
-
snapshot.staleSources = [];
|
|
1382
|
-
snapshot.target = resolvedTarget!.target;
|
|
1383
|
-
snapshot.pins = resolvedTarget!.pins;
|
|
1384
|
-
break;
|
|
1385
|
-
case "restore":
|
|
1386
|
-
snapshot = this.read(id, op.version);
|
|
1387
|
-
delete snapshot.lastEdit;
|
|
1388
|
-
break;
|
|
1389
|
-
case "lens_edit": {
|
|
1390
|
-
if (snapshot.kind === "scratchpad")
|
|
1391
|
-
throw new ReviewInputError(
|
|
1392
|
-
"The scratchpad has no changes of its own to lens.",
|
|
1393
|
-
409,
|
|
1394
|
-
);
|
|
2802
|
+
return this.linkPreview(this.read(to.board), to.block);
|
|
2803
|
+
}
|
|
1395
2804
|
|
|
1396
|
-
|
|
1397
|
-
|
|
2805
|
+
/** "owner / repo" for a board whose origin names one, else its folder. */
|
|
2806
|
+
private repositoryLabel(board: Snapshot): string | undefined {
|
|
2807
|
+
const repositoryId = board.target?.repositoryId;
|
|
1398
2808
|
|
|
1399
|
-
|
|
1400
|
-
else delete snapshot.lenses;
|
|
1401
|
-
lensTarget = { targetId: lens.id, type: "lens" };
|
|
1402
|
-
snapshot.lastEdit = {
|
|
1403
|
-
type: op.edit.type,
|
|
1404
|
-
targetId: lens.id,
|
|
1405
|
-
blockId: lens.id,
|
|
1406
|
-
kind: "lens",
|
|
1407
|
-
...(op.edit.type === "update" && {
|
|
1408
|
-
fields: Object.keys(op.edit).filter(
|
|
1409
|
-
(key) => key !== "type" && key !== "targetId",
|
|
1410
|
-
),
|
|
1411
|
-
}),
|
|
1412
|
-
};
|
|
1413
|
-
break;
|
|
1414
|
-
}
|
|
2809
|
+
if (!repositoryId) return undefined;
|
|
1415
2810
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
2811
|
+
try {
|
|
2812
|
+
return this.repositoryGroup(this.repositoryPath(repositoryId))
|
|
2813
|
+
?.label.split("/")
|
|
2814
|
+
.join(" / ");
|
|
2815
|
+
} catch {
|
|
2816
|
+
return undefined;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
1424
2819
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
snapshot.document,
|
|
1430
|
-
);
|
|
2820
|
+
/** A version's outgoing links and the board's current backlinks. Links to a
|
|
2821
|
+
* deleted board or block are marked missing. */
|
|
2822
|
+
links(id: string, version?: number) {
|
|
2823
|
+
const titles = new Map<string, Snapshot | undefined>();
|
|
1431
2824
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
);
|
|
2825
|
+
const board = (reviewId: string) => {
|
|
2826
|
+
if (!titles.has(reviewId))
|
|
2827
|
+
titles.set(
|
|
2828
|
+
reviewId,
|
|
2829
|
+
this.has(reviewId) ? this.read(reviewId) : undefined,
|
|
2830
|
+
);
|
|
1439
2831
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
item.id,
|
|
1443
|
-
JSON.stringify(item.source),
|
|
1444
|
-
]),
|
|
1445
|
-
);
|
|
2832
|
+
return titles.get(reviewId);
|
|
2833
|
+
};
|
|
1446
2834
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
2835
|
+
const outgoing = boardLinks(id, this.read(id, version).document).map(
|
|
2836
|
+
({ from, to }) => {
|
|
2837
|
+
const target = board(to.board);
|
|
2838
|
+
|
|
2839
|
+
const missing =
|
|
2840
|
+
!target ||
|
|
2841
|
+
(to.block !== undefined &&
|
|
2842
|
+
!elements(target.document).some(
|
|
2843
|
+
(element) => element.id === to.block,
|
|
2844
|
+
));
|
|
2845
|
+
|
|
2846
|
+
const deleted = target
|
|
2847
|
+
? undefined
|
|
2848
|
+
: this.db
|
|
2849
|
+
.prepare("SELECT title,deleted_at FROM deleted_boards WHERE id=?")
|
|
2850
|
+
.get(to.board);
|
|
2851
|
+
|
|
2852
|
+
return {
|
|
2853
|
+
from,
|
|
2854
|
+
to,
|
|
2855
|
+
...(target && {
|
|
2856
|
+
title: target.title,
|
|
2857
|
+
icon: boardIconKind(target.target),
|
|
2858
|
+
...(this.repositoryLabel(target) && {
|
|
2859
|
+
repository: this.repositoryLabel(target),
|
|
2860
|
+
}),
|
|
2861
|
+
...(target.origin?.pullRequestNumber && {
|
|
2862
|
+
pullRequestNumber: target.origin.pullRequestNumber,
|
|
2863
|
+
pullRequestUrl: target.origin.pullRequestUrl,
|
|
2864
|
+
}),
|
|
2865
|
+
updatedAt: target.createdAt,
|
|
2866
|
+
}),
|
|
2867
|
+
missing,
|
|
2868
|
+
...(deleted && {
|
|
2869
|
+
title: String(deleted.title),
|
|
2870
|
+
deletedAt: String(deleted.deleted_at),
|
|
2871
|
+
}),
|
|
2872
|
+
};
|
|
2873
|
+
},
|
|
2874
|
+
);
|
|
1452
2875
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2876
|
+
const backlinks = this.db
|
|
2877
|
+
.prepare(
|
|
2878
|
+
"SELECT DISTINCT from_review,from_block,to_block FROM board_links WHERE to_review=? AND from_review<>? ORDER BY from_review",
|
|
2879
|
+
)
|
|
2880
|
+
.all(id, id)
|
|
2881
|
+
.map((row) => {
|
|
2882
|
+
const from = board(String(row.from_review));
|
|
1456
2883
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2884
|
+
return {
|
|
2885
|
+
from: {
|
|
2886
|
+
board: String(row.from_review),
|
|
2887
|
+
block: String(row.from_block),
|
|
2888
|
+
},
|
|
2889
|
+
to:
|
|
2890
|
+
row.to_block === null
|
|
2891
|
+
? { board: id }
|
|
2892
|
+
: { board: id, block: String(row.to_block) },
|
|
2893
|
+
title: from?.title ?? "",
|
|
2894
|
+
...(from && { icon: boardIconKind(from.target) }),
|
|
2895
|
+
...(from?.origin?.pullRequestNumber && {
|
|
2896
|
+
pullRequestNumber: from.origin.pullRequestNumber,
|
|
2897
|
+
pullRequestUrl: from.origin.pullRequestUrl,
|
|
2898
|
+
}),
|
|
2899
|
+
};
|
|
2900
|
+
});
|
|
1464
2901
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2902
|
+
return { outgoing, backlinks };
|
|
2903
|
+
}
|
|
2904
|
+
/** Boards edited by an Ask thread, most recent first. */
|
|
2905
|
+
boardsChangedBy(threadId: string): {
|
|
2906
|
+
reviewId: string;
|
|
2907
|
+
title: string;
|
|
2908
|
+
icon: BoardIconKind;
|
|
2909
|
+
blockId?: string;
|
|
2910
|
+
}[] {
|
|
2911
|
+
return this.db
|
|
2912
|
+
.prepare(
|
|
2913
|
+
`SELECT edits.review_id, MAX(edits.version) AS last
|
|
2914
|
+
FROM versions edits
|
|
2915
|
+
JOIN reviews ON reviews.id=edits.review_id
|
|
2916
|
+
WHERE edits.thread_id=?
|
|
2917
|
+
GROUP BY edits.review_id ORDER BY MAX(json_extract(edits.snapshot,'$.createdAt')) DESC`,
|
|
2918
|
+
)
|
|
2919
|
+
.all(threadId)
|
|
2920
|
+
.map((row) => {
|
|
2921
|
+
const board = this.read(String(row.review_id));
|
|
2922
|
+
|
|
2923
|
+
// The block its last edit there touched, if the board still has it.
|
|
2924
|
+
const blockId = this.read(board.reviewId, Number(row.last)).lastEdit
|
|
2925
|
+
?.blockId;
|
|
2926
|
+
|
|
2927
|
+
return {
|
|
2928
|
+
reviewId: board.reviewId,
|
|
2929
|
+
title: board.title,
|
|
2930
|
+
icon: boardIconKind(board.target),
|
|
2931
|
+
...(blockId &&
|
|
2932
|
+
elements(board.document).some(
|
|
2933
|
+
(element) => element.id === blockId,
|
|
2934
|
+
) && { blockId }),
|
|
2935
|
+
};
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
/** The boards each thread changed, newest first, for many threads in one
|
|
2939
|
+
* query. */
|
|
2940
|
+
/** A board's title and icon, read without its document. */
|
|
2941
|
+
/** Where a board lives, as Recent names it: its checkout's folder, else
|
|
2942
|
+
* its project's name. */
|
|
2943
|
+
boardPlace(reviewId: string): string {
|
|
2944
|
+
const row = this.db
|
|
2945
|
+
.prepare(
|
|
2946
|
+
`SELECT repositories.path, projects.name FROM reviews
|
|
2947
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2948
|
+
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
2949
|
+
LEFT JOIN projects ON projects.id=reviews.project_id
|
|
2950
|
+
WHERE reviews.id=?`,
|
|
2951
|
+
)
|
|
2952
|
+
.get(reviewId);
|
|
1468
2953
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2954
|
+
return (
|
|
2955
|
+
(row?.path && path.basename(String(row.path))) ||
|
|
2956
|
+
(row?.name ? String(row.name) : "")
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
boardFace(reviewId: string) {
|
|
2960
|
+
const row = this.db
|
|
2961
|
+
.prepare(
|
|
2962
|
+
`SELECT json_extract(versions.snapshot,'$.title') AS title, json_extract(versions.snapshot,'$.target') AS target
|
|
2963
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2964
|
+
WHERE reviews.id=?`,
|
|
1473
2965
|
)
|
|
1474
|
-
|
|
2966
|
+
.get(reviewId)!;
|
|
1475
2967
|
|
|
1476
|
-
|
|
1477
|
-
|
|
2968
|
+
return {
|
|
2969
|
+
reviewId,
|
|
2970
|
+
title: String(row.title),
|
|
2971
|
+
icon: boardIconKind(
|
|
2972
|
+
// SAFETY: the target was validated when the version was saved.
|
|
2973
|
+
row.target
|
|
2974
|
+
? (JSON.parse(String(row.target)) as ReviewTarget)
|
|
2975
|
+
: undefined,
|
|
2976
|
+
),
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
boardsChangedByThreads(
|
|
2980
|
+
threadIds: readonly string[],
|
|
2981
|
+
): Map<string, { reviewId: string; title: string; icon: BoardIconKind }[]> {
|
|
2982
|
+
const changed = new Map<
|
|
2983
|
+
string,
|
|
2984
|
+
{ reviewId: string; title: string; icon: BoardIconKind }[]
|
|
2985
|
+
>();
|
|
1478
2986
|
|
|
1479
|
-
|
|
1480
|
-
...documentAdvice(snapshot.document).filter(
|
|
1481
|
-
(advice) => !advised.has(advice),
|
|
1482
|
-
),
|
|
1483
|
-
...(await this.validateExternal(
|
|
1484
|
-
snapshot,
|
|
1485
|
-
previous,
|
|
1486
|
-
op.type === "set_target",
|
|
1487
|
-
)),
|
|
1488
|
-
];
|
|
2987
|
+
if (!threadIds.length) return changed;
|
|
1489
2988
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
...(applied?.children && { children: applied.children }),
|
|
1501
|
-
};
|
|
2989
|
+
const rows = this.db
|
|
2990
|
+
.prepare(
|
|
2991
|
+
// The thread_id index finds the rows; rowid orders them by when
|
|
2992
|
+
// they were saved without parsing them.
|
|
2993
|
+
`SELECT thread_id AS thread, review_id
|
|
2994
|
+
FROM versions
|
|
2995
|
+
WHERE thread_id IN (${threadIds.map(() => "?").join(",")})
|
|
2996
|
+
GROUP BY thread, review_id ORDER BY MAX(rowid) DESC`,
|
|
2997
|
+
)
|
|
2998
|
+
.all(...threadIds);
|
|
1502
2999
|
|
|
1503
|
-
|
|
3000
|
+
const boards = new Map<
|
|
3001
|
+
string,
|
|
3002
|
+
{ reviewId: string; title: string; icon: BoardIconKind } | undefined
|
|
3003
|
+
>();
|
|
1504
3004
|
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
() => {
|
|
1508
|
-
this.db
|
|
1509
|
-
.prepare(
|
|
1510
|
-
"INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id",
|
|
1511
|
-
)
|
|
1512
|
-
.run(id, snapshot.version, nextId);
|
|
1513
|
-
this.db
|
|
1514
|
-
.prepare(
|
|
1515
|
-
"INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)",
|
|
1516
|
-
)
|
|
1517
|
-
.run(id, snapshot.version, JSON.stringify(snapshot));
|
|
1518
|
-
},
|
|
1519
|
-
previous
|
|
1520
|
-
? () => {
|
|
1521
|
-
this.assertWritable(id);
|
|
1522
|
-
this.assertMutation(id, previous.version);
|
|
1523
|
-
}
|
|
1524
|
-
: undefined,
|
|
1525
|
-
op.type === "edit" || op.type === "lens_edit"
|
|
1526
|
-
? {
|
|
1527
|
-
surface: op.type === "edit" ? "document" : "lenses",
|
|
1528
|
-
activityId: op.activityId,
|
|
1529
|
-
// The version names the agent whose courier draws it.
|
|
1530
|
-
owned: (owner) => {
|
|
1531
|
-
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
1532
|
-
},
|
|
1533
|
-
}
|
|
1534
|
-
: undefined,
|
|
1535
|
-
);
|
|
3005
|
+
for (const row of rows) {
|
|
3006
|
+
const reviewId = String(row.review_id);
|
|
1536
3007
|
|
|
1537
|
-
|
|
1538
|
-
|
|
3008
|
+
if (!boards.has(reviewId))
|
|
3009
|
+
boards.set(
|
|
3010
|
+
reviewId,
|
|
3011
|
+
this.has(reviewId) ? this.boardFace(reviewId) : undefined,
|
|
3012
|
+
);
|
|
3013
|
+
const board = boards.get(reviewId);
|
|
1539
3014
|
|
|
1540
|
-
|
|
3015
|
+
if (!board) continue;
|
|
3016
|
+
const thread = String(row.thread);
|
|
1541
3017
|
|
|
1542
|
-
|
|
3018
|
+
changed.set(thread, [...(changed.get(thread) ?? []), board]);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
return changed;
|
|
1543
3022
|
}
|
|
1544
3023
|
/** Resolve a target-less PR create before it queues, when it will need it. */
|
|
1545
3024
|
private startPullRequest(
|
|
@@ -1547,7 +3026,7 @@ export class ReviewStore {
|
|
|
1547
3026
|
): Promise<ResolvedPullRequest> | undefined {
|
|
1548
3027
|
const op = command.operation;
|
|
1549
3028
|
|
|
1550
|
-
if (op.type !== "create" || op.
|
|
3029
|
+
if (op.type !== "create" || op.target || !op.pullRequestUrl)
|
|
1551
3030
|
return undefined;
|
|
1552
3031
|
|
|
1553
3032
|
if (!this.providers.resolvePullRequest)
|
|
@@ -1555,7 +3034,14 @@ export class ReviewStore {
|
|
|
1555
3034
|
new ReviewInputError("Pull request targets are unavailable."),
|
|
1556
3035
|
);
|
|
1557
3036
|
|
|
1558
|
-
|
|
3037
|
+
if (!op.repositoryId)
|
|
3038
|
+
return Promise.reject(
|
|
3039
|
+
new ReviewInputError(
|
|
3040
|
+
'Name the checkout to fetch the PR into: target {kind:"pullRequest", repositoryPath, pullRequest}.',
|
|
3041
|
+
),
|
|
3042
|
+
);
|
|
3043
|
+
|
|
3044
|
+
// A PR whose review is in Trash is refused before it is fetched.
|
|
1559
3045
|
const [existing] =
|
|
1560
3046
|
op.reuseExisting === false
|
|
1561
3047
|
? []
|
|
@@ -1571,7 +3057,6 @@ export class ReviewStore {
|
|
|
1571
3057
|
|
|
1572
3058
|
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
1573
3059
|
id: op.repositoryId,
|
|
1574
|
-
preferred: existing && this.read(existing).pins?.repositoryId,
|
|
1575
3060
|
});
|
|
1576
3061
|
}
|
|
1577
3062
|
private startPullRequestTitle(
|
|
@@ -1620,9 +3105,8 @@ export class ReviewStore {
|
|
|
1620
3105
|
|
|
1621
3106
|
this.assertReusableReview(reviewId);
|
|
1622
3107
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
snapshot.pins?.head !== requested.head;
|
|
3108
|
+
// Worktrees share commits, so comparing heads is enough.
|
|
3109
|
+
const headMoved = snapshot.pins?.head !== requested.head;
|
|
1626
3110
|
|
|
1627
3111
|
const working = this.activity.read(reviewId).activities ?? [];
|
|
1628
3112
|
|
|
@@ -1659,6 +3143,7 @@ export class ReviewStore {
|
|
|
1659
3143
|
activityId?: string;
|
|
1660
3144
|
owned(activityId: string): void;
|
|
1661
3145
|
},
|
|
3146
|
+
shown?: ShownChange,
|
|
1662
3147
|
) {
|
|
1663
3148
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1664
3149
|
let owner: string | undefined;
|
|
@@ -1690,7 +3175,7 @@ export class ReviewStore {
|
|
|
1690
3175
|
|
|
1691
3176
|
if (result.deleted) this.activity.deleted(result.reviewId);
|
|
1692
3177
|
else if (owner) this.activity.extended(result.reviewId);
|
|
1693
|
-
this.notify(result);
|
|
3178
|
+
this.notify({ ...result, ...shown });
|
|
1694
3179
|
}
|
|
1695
3180
|
private assertMutation(reviewId: string, version: number | undefined) {
|
|
1696
3181
|
const current = this.db
|
|
@@ -1704,12 +3189,12 @@ export class ReviewStore {
|
|
|
1704
3189
|
);
|
|
1705
3190
|
}
|
|
1706
3191
|
|
|
1707
|
-
private notify(result:
|
|
3192
|
+
private notify(result: StoreChange) {
|
|
1708
3193
|
if (result.deleted) this.observedVersions.delete(result.reviewId);
|
|
1709
3194
|
else if (!result.attention)
|
|
1710
3195
|
this.observedVersions.set(result.reviewId, result.version);
|
|
1711
3196
|
|
|
1712
|
-
if (!result.attention || result.lifecycle)
|
|
3197
|
+
if ((!result.attention || result.lifecycle) && !result.relations)
|
|
1713
3198
|
for (const listener of this.listeners)
|
|
1714
3199
|
try {
|
|
1715
3200
|
listener(result);
|
|
@@ -1739,6 +3224,42 @@ export class ReviewStore {
|
|
|
1739
3224
|
undefined
|
|
1740
3225
|
);
|
|
1741
3226
|
}
|
|
3227
|
+
/** Saves each new commit link's commit in full, resolved in the board's
|
|
3228
|
+
* repository; one it does not have rejects the edit, as pins do. A link the
|
|
3229
|
+
* board already had stays as it was saved, even if its commit is gone. */
|
|
3230
|
+
private async resolveCommitLinks(
|
|
3231
|
+
document: Block[],
|
|
3232
|
+
board: Pick<Snapshot, "target" | "pins">,
|
|
3233
|
+
previous?: Block[],
|
|
3234
|
+
): Promise<Block[]> {
|
|
3235
|
+
const saved = new Set(previous ? commitLinkCommits(previous) : []);
|
|
3236
|
+
|
|
3237
|
+
const commits = commitLinkCommits(document).filter(
|
|
3238
|
+
(commit) => !saved.has(commit),
|
|
3239
|
+
);
|
|
3240
|
+
|
|
3241
|
+
const repositoryId = boardRepositoryId(board);
|
|
3242
|
+
|
|
3243
|
+
// Without a repository the links are rejected as references.
|
|
3244
|
+
if (!commits.length || !repositoryId || !this.providers.resolveCommit)
|
|
3245
|
+
return document;
|
|
3246
|
+
|
|
3247
|
+
const resolved = new Map<string, string>();
|
|
3248
|
+
|
|
3249
|
+
await Promise.all(
|
|
3250
|
+
commits.map(async (commit) => {
|
|
3251
|
+
const full = await this.providers.resolveCommit!(repositoryId, commit);
|
|
3252
|
+
|
|
3253
|
+
if (!full)
|
|
3254
|
+
throw new ReviewInputError(
|
|
3255
|
+
`Commit ${commit} is not in the board's repository. Fetch it first, or link a commit the repository has.`,
|
|
3256
|
+
);
|
|
3257
|
+
resolved.set(commit, full);
|
|
3258
|
+
}),
|
|
3259
|
+
);
|
|
3260
|
+
|
|
3261
|
+
return respellCommitLinks(document, resolved);
|
|
3262
|
+
}
|
|
1742
3263
|
private async validateExternal(
|
|
1743
3264
|
snapshot: Snapshot,
|
|
1744
3265
|
previous?: Snapshot,
|
|
@@ -1747,10 +3268,11 @@ export class ReviewStore {
|
|
|
1747
3268
|
const warnings: string[] = [];
|
|
1748
3269
|
|
|
1749
3270
|
const references = (
|
|
1750
|
-
|
|
3271
|
+
board: Pick<Snapshot, "document" | "target" | "pins" | "lenses">,
|
|
1751
3272
|
tolerant = false,
|
|
1752
|
-
lenses: readonly Lens[] = [],
|
|
1753
3273
|
) => {
|
|
3274
|
+
const { document, lenses = [] } = board;
|
|
3275
|
+
|
|
1754
3276
|
const sources = new Map<
|
|
1755
3277
|
string,
|
|
1756
3278
|
{ source: FileLineRange; peek: boolean }
|
|
@@ -1764,7 +3286,10 @@ export class ReviewStore {
|
|
|
1764
3286
|
sources.set(key, { source, peek: peek || (kept?.peek ?? false) });
|
|
1765
3287
|
};
|
|
1766
3288
|
|
|
1767
|
-
for (const { source, peek } of sourceReferences(document, {
|
|
3289
|
+
for (const { source, peek } of sourceReferences(document, {
|
|
3290
|
+
tolerant,
|
|
3291
|
+
repositoryId: boardRepositoryId(board),
|
|
3292
|
+
}))
|
|
1768
3293
|
add(source, peek === true);
|
|
1769
3294
|
|
|
1770
3295
|
// A lens range is a prose-like link: it must exist, not read as a peek.
|
|
@@ -1777,7 +3302,7 @@ export class ReviewStore {
|
|
|
1777
3302
|
return { sources, resources };
|
|
1778
3303
|
};
|
|
1779
3304
|
|
|
1780
|
-
const current = references(snapshot
|
|
3305
|
+
const current = references(snapshot, repin);
|
|
1781
3306
|
|
|
1782
3307
|
// Stored content is not re-validated: an edit may fix a link that the
|
|
1783
3308
|
// current rules reject.
|
|
@@ -1794,11 +3319,7 @@ export class ReviewStore {
|
|
|
1794
3319
|
.catch(() => undefined)
|
|
1795
3320
|
: undefined;
|
|
1796
3321
|
|
|
1797
|
-
const retained = references(
|
|
1798
|
-
previous?.document ?? [],
|
|
1799
|
-
true,
|
|
1800
|
-
previous?.lenses,
|
|
1801
|
-
);
|
|
3322
|
+
const retained = references(previous ?? { document: [] }, true);
|
|
1802
3323
|
|
|
1803
3324
|
// Independent reads of immutable commits: run them concurrently.
|
|
1804
3325
|
const checks: Promise<void>[] = [];
|
|
@@ -1831,6 +3352,7 @@ export class ReviewStore {
|
|
|
1831
3352
|
() => {
|
|
1832
3353
|
if (
|
|
1833
3354
|
repin &&
|
|
3355
|
+
!readsWhereItDid(source, previous?.pins) &&
|
|
1834
3356
|
(!changed || changed[source.side].has(source.file))
|
|
1835
3357
|
)
|
|
1836
3358
|
warnings.push(
|
|
@@ -1872,6 +3394,167 @@ export class ReviewStore {
|
|
|
1872
3394
|
}
|
|
1873
3395
|
}
|
|
1874
3396
|
|
|
3397
|
+
/** The highest number in a document's `prefix-N` ids, units included. */
|
|
3398
|
+
function highestIdNumber(document: Block[]): number {
|
|
3399
|
+
let highest = 0;
|
|
3400
|
+
|
|
3401
|
+
for (const [, number] of JSON.stringify(document).matchAll(
|
|
3402
|
+
/"id":"[^"]*?-(\d+)"/g,
|
|
3403
|
+
))
|
|
3404
|
+
highest = Math.max(highest, Number(number));
|
|
3405
|
+
|
|
3406
|
+
return highest;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/** The title of a board a person made and has not named. */
|
|
3410
|
+
|
|
3411
|
+
/** A person's save must start from the board's current version. */
|
|
3412
|
+
function assertBaseVersion(base: number, current: number) {
|
|
3413
|
+
if (base !== current)
|
|
3414
|
+
throw new ReviewInputError(
|
|
3415
|
+
`The board changed since version ${base}; it is now at version ${current}. Merge the latest version and save again.`,
|
|
3416
|
+
409,
|
|
3417
|
+
);
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
/**
|
|
3421
|
+
* A person's whole document as the store saves it: each new block's
|
|
3422
|
+
* clientKey taken, the blocks parsed, a flow's units adopted and every block
|
|
3423
|
+
* without an id given the next one after `nextId`. Every save then respells
|
|
3424
|
+
* its links (respellDocumentLinks) and resolves commit links it has not
|
|
3425
|
+
* saved before. Mutates `input`.
|
|
3426
|
+
*/
|
|
3427
|
+
export function personDocument(input: JsonValue[], nextId: number) {
|
|
3428
|
+
const keys = takeClientKeys(input);
|
|
3429
|
+
const document = documentSchema.parse(input);
|
|
3430
|
+
const allocate = (prefix: string) => `${prefix}-${++nextId}`;
|
|
3431
|
+
|
|
3432
|
+
adoptFlowUnits(document, allocate);
|
|
3433
|
+
|
|
3434
|
+
for (const block of document) assignMissingIds(block, allocate);
|
|
3435
|
+
assertUniqueIds(document);
|
|
3436
|
+
|
|
3437
|
+
return {
|
|
3438
|
+
document,
|
|
3439
|
+
// An id ⌘Z brought back stays; the counter moves past it, so an
|
|
3440
|
+
// agent's next insert can't be given it again.
|
|
3441
|
+
nextId: Math.max(nextId, highestIdNumber(document)),
|
|
3442
|
+
ids: Object.fromEntries(
|
|
3443
|
+
[...keys.byPath].map(([path, key]) => [key, blockAt(document, path).id!]),
|
|
3444
|
+
),
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
const clientKeySchema = z.string().min(1).optional();
|
|
3449
|
+
|
|
3450
|
+
/** Removes each block's clientKey from a person's raw document, keeping it
|
|
3451
|
+
* by the block's path when the block has no id. Counts the bare blocks. */
|
|
3452
|
+
function takeClientKeys(document: JsonValue[]) {
|
|
3453
|
+
const byPath = new Map<string, string>();
|
|
3454
|
+
const seen = new Set<string>();
|
|
3455
|
+
let bare = 0;
|
|
3456
|
+
|
|
3457
|
+
const visit = (list: JsonValue[], prefix: string) =>
|
|
3458
|
+
list.forEach((block, index) => {
|
|
3459
|
+
if (!isJsonObject(block)) return;
|
|
3460
|
+
const path = `${prefix}${index}`;
|
|
3461
|
+
const clientKey = clientKeySchema.parse(block.clientKey);
|
|
3462
|
+
|
|
3463
|
+
delete block.clientKey;
|
|
3464
|
+
|
|
3465
|
+
if (block.id === undefined) {
|
|
3466
|
+
bare++;
|
|
3467
|
+
|
|
3468
|
+
if (clientKey !== undefined) {
|
|
3469
|
+
if (seen.has(clientKey))
|
|
3470
|
+
throw new ReviewInputError(
|
|
3471
|
+
`clientKey ${clientKey} names more than one block. Give each new block its own clientKey.`,
|
|
3472
|
+
);
|
|
3473
|
+
seen.add(clientKey);
|
|
3474
|
+
byPath.set(path, clientKey);
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
if (Array.isArray(block.children)) visit(block.children, `${path}/`);
|
|
3479
|
+
});
|
|
3480
|
+
|
|
3481
|
+
visit(document, "");
|
|
3482
|
+
|
|
3483
|
+
return { byPath, bare };
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
/** The block at a path takeClientKeys recorded. */
|
|
3487
|
+
function blockAt(document: Block[], path: string): Block {
|
|
3488
|
+
const [first, ...rest] = path.split("/").map(Number);
|
|
3489
|
+
let block = document[first!]!;
|
|
3490
|
+
|
|
3491
|
+
for (const index of rest)
|
|
3492
|
+
if ("children" in block) block = block.children[index]!;
|
|
3493
|
+
|
|
3494
|
+
return block;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
/** Ids name one component each across the whole document. */
|
|
3498
|
+
function assertUniqueIds(document: Block[]) {
|
|
3499
|
+
const seen = new Set<string>();
|
|
3500
|
+
|
|
3501
|
+
for (const { id } of elements(document)) {
|
|
3502
|
+
if (id === undefined) continue;
|
|
3503
|
+
|
|
3504
|
+
if (seen.has(id))
|
|
3505
|
+
throw new ReviewInputError(
|
|
3506
|
+
`Id ${id} names more than one component. Give each block its own id, or leave a new block's id out.`,
|
|
3507
|
+
);
|
|
3508
|
+
seen.add(id);
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
/** Each block's own content by id, nested blocks included. A section or
|
|
3513
|
+
* callout is kept without its children, so a change to a child is only the
|
|
3514
|
+
* child's. */
|
|
3515
|
+
function ownBlocks(document: Block[], into = new Map<string, unknown>()) {
|
|
3516
|
+
for (const block of document) {
|
|
3517
|
+
if ("children" in block) {
|
|
3518
|
+
const { children, ...own } = block;
|
|
3519
|
+
|
|
3520
|
+
if (block.id !== undefined) into.set(block.id, own);
|
|
3521
|
+
ownBlocks(children, into);
|
|
3522
|
+
} else if (block.id !== undefined) into.set(block.id, block);
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
return into;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
/** The blocks added, removed or changed from one document to the next. A
|
|
3529
|
+
* block moved with its content unchanged did not change. */
|
|
3530
|
+
function changedBlocks(before: Block[], after: Block[]): string[] {
|
|
3531
|
+
const old = ownBlocks(before);
|
|
3532
|
+
const now = ownBlocks(after);
|
|
3533
|
+
|
|
3534
|
+
return [...new Set([...old.keys(), ...now.keys()])].filter(
|
|
3535
|
+
(id) => !isDeepStrictEqual(old.get(id), now.get(id)),
|
|
3536
|
+
);
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
/** Whether a reference's own pins read the commit the board's previous
|
|
3540
|
+
* pins did. */
|
|
3541
|
+
function readsWhereItDid(
|
|
3542
|
+
source: { side: "base" | "head"; pins?: SourcePins },
|
|
3543
|
+
previous: Pins | undefined,
|
|
3544
|
+
): boolean {
|
|
3545
|
+
if (!source.pins || !previous) return false;
|
|
3546
|
+
|
|
3547
|
+
const commit =
|
|
3548
|
+
source.side === "base"
|
|
3549
|
+
? (source.pins.base ?? source.pins.head)
|
|
3550
|
+
: source.pins.head;
|
|
3551
|
+
|
|
3552
|
+
return (
|
|
3553
|
+
source.pins.repositoryId === previous.repositoryId &&
|
|
3554
|
+
(commit === previous.head || commit === previous.base)
|
|
3555
|
+
);
|
|
3556
|
+
}
|
|
3557
|
+
|
|
1875
3558
|
/** Without a resolver, a commits target that names both commits pins them as
|
|
1876
3559
|
* given; validatePins still checks they exist. */
|
|
1877
3560
|
function namedCommits(target: ReviewTarget) {
|
|
@@ -1886,33 +3569,28 @@ function namedCommits(target: ReviewTarget) {
|
|
|
1886
3569
|
* is kept so stored JSON reads as it always has. */
|
|
1887
3570
|
function createdSnapshot(
|
|
1888
3571
|
id: string,
|
|
1889
|
-
op: { title?: string
|
|
1890
|
-
resolved: { target: ReviewTarget; pins
|
|
3572
|
+
op: { title?: string },
|
|
3573
|
+
resolved: { target: ReviewTarget; pins?: Pins },
|
|
1891
3574
|
defaultTitle?: string,
|
|
3575
|
+
/** A person's new board may start untitled. */
|
|
3576
|
+
untitled = false,
|
|
1892
3577
|
): Snapshot {
|
|
1893
|
-
const title = op.title
|
|
3578
|
+
const title = op.title || defaultTitle || (untitled ? "" : undefined);
|
|
1894
3579
|
|
|
1895
|
-
if (
|
|
3580
|
+
if (title === undefined) throw new ReviewInputError("Supply a title.");
|
|
1896
3581
|
|
|
1897
|
-
|
|
1898
|
-
return {
|
|
1899
|
-
reviewId: id,
|
|
1900
|
-
version: 0,
|
|
1901
|
-
title,
|
|
1902
|
-
kind: op.kind,
|
|
1903
|
-
document: [],
|
|
1904
|
-
createdAt: "",
|
|
1905
|
-
};
|
|
1906
|
-
|
|
1907
|
-
return {
|
|
3582
|
+
const snapshot: Snapshot = {
|
|
1908
3583
|
reviewId: id,
|
|
1909
3584
|
version: 0,
|
|
1910
3585
|
title,
|
|
1911
|
-
pins: resolved.pins,
|
|
1912
3586
|
target: resolved.target,
|
|
1913
3587
|
document: [],
|
|
1914
3588
|
createdAt: "",
|
|
1915
3589
|
};
|
|
3590
|
+
|
|
3591
|
+
if (resolved.pins) snapshot.pins = resolved.pins;
|
|
3592
|
+
|
|
3593
|
+
return snapshot;
|
|
1916
3594
|
}
|
|
1917
3595
|
|
|
1918
3596
|
export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
|
|
@@ -1940,3 +3618,6 @@ export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
|
|
|
1940
3618
|
: undefined,
|
|
1941
3619
|
}));
|
|
1942
3620
|
}
|
|
3621
|
+
|
|
3622
|
+
/** How many ancestry questions git answers at once. */
|
|
3623
|
+
const ANCESTRY_CONCURRENCY = 8;
|