@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
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import { _ as
|
|
2
|
-
import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
-
import {
|
|
4
|
-
import { s as safeStorageSegment } from "./review-home-paths-
|
|
5
|
-
import {
|
|
1
|
+
import { C as renderVcsArgs, T as runVcsSync, _ as listTrackedFilesAtCommit, a as diff, b as resolveRepoContext, c as diffFileSummariesWorkingTree, d as git, g as listCommitRange, h as jjRevisionIsConflicted, i as detectLocalVcs, l as diffTrees, m as gitCommonDirSync, n as createBlobBatchReader, p as gitCommonDir, s as diffFileSummariesTrees, t as LocalVcsToolsMissingError, u as diffWorkingTree, v as parseGitRemote, w as runVcs, x as resolveRevision, y as readFileAtCommit } from "./src-DtfSndqS.js";
|
|
2
|
+
import { c as jsonString, m as isStringValue, n as isJsonObject, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
+
import { F as structuralChangeCounts, P as decodeStructuralDiffEvent, c as boardIconKind, l as boardTitle } from "./src-DYZLCUP_.js";
|
|
4
|
+
import { s as safeStorageSegment } from "./review-home-paths-BwH08ihj.js";
|
|
5
|
+
import { b as updateCoverage, c as parseAnchor, f as sourceAnchors, g as intersectIntervals, h as emptyCoverage, p as coverageSchema, v as subtractIntervals, y as unionIntervals } from "./lens-selection-BxPk53c8.js";
|
|
6
|
+
import { $ as parseMarkdown, A as resourceReferences, E as pinsSchema, G as lensSelections, H as applyLensEdit, I as sourceReferences, J as fileLineRangeSchema, K as SourceRangeError, L as summarizeEdit, M as respellDocumentLinks, N as respellEditLinks, P as reviewTargetSchema, Q as markdownText, R as checkReferences, S as explicitPins, T as pinnedRepositoryIds, U as lensEditSchema, X as sliceSourceRange, Y as requireVisibleSource, Z as markdownNodes, _ as boardRepositoryId, a as SoftwareModelValidationError, b as editSchema, d as adoptFlowUnits, f as anchorPins, g as boardLinks, h as assignMissingIds, i as readStoredDocument, j as respellCommitLinks, k as resourceReference, l as traceSchema, m as assignFreshIds, n as migrateStoredDocument, o as defineSoftwareMap, p as applyEdit, q as checkSourcePath, r as migrateStoredLenses, u as textIncludesQuote, v as commitLinkCommits, w as pinDocumentReferences, x as elements, y as documentSchema, z as documentAdvice } from "./stored-document-migration-B29lve9-.js";
|
|
6
7
|
import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
|
|
7
|
-
import { A as processStartIdentity, L as writePrivateJsonAtomic, O as pidReused, j as withFileLock, k as processIsAlive } from "./s3-config-
|
|
8
|
+
import { A as processStartIdentity, L as writePrivateJsonAtomic, O as pidReused, j as withFileLock, k as processIsAlive } from "./s3-config-CjcLyFV5.js";
|
|
8
9
|
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
9
10
|
import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
|
|
10
|
-
import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-
|
|
11
|
+
import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-BPpvKFwQ.js";
|
|
11
12
|
import { createRequire } from "node:module";
|
|
12
13
|
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
13
14
|
import { z } from "zod";
|
|
14
|
-
import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
15
16
|
import * as fs$1 from "node:fs/promises";
|
|
16
17
|
import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
17
18
|
import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
18
20
|
import { execFile, spawn } from "node:child_process";
|
|
19
|
-
import { promisify } from "node:util";
|
|
21
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
20
22
|
import { DatabaseSync } from "node:sqlite";
|
|
21
23
|
import { createInterface } from "node:readline";
|
|
22
24
|
//#region ../../diffr/diffr-ts/dist/binary.js
|
|
@@ -93,6 +95,366 @@ function structuralRows(diff) {
|
|
|
93
95
|
return rows;
|
|
94
96
|
}
|
|
95
97
|
//#endregion
|
|
98
|
+
//#region src/review-api/azure-remote.ts
|
|
99
|
+
const REMOTE_URLS = [
|
|
100
|
+
/^https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
|
|
101
|
+
/^https:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
|
|
102
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
|
|
103
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
|
|
104
|
+
];
|
|
105
|
+
/** org/project/repository, decoded; undefined for malformed escapes. */
|
|
106
|
+
function slugOf(patterns, url) {
|
|
107
|
+
const match = patterns.map((pattern) => pattern.exec(url)).find(Boolean);
|
|
108
|
+
try {
|
|
109
|
+
return match && match.slice(1, 4).map(decodeURIComponent).join("/");
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function azureRepositoryOf(remoteUrl) {
|
|
115
|
+
return slugOf(REMOTE_URLS, remoteUrl.trim());
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/review-api/checkout-groups.ts
|
|
119
|
+
/** A command's output, or undefined if it fails; the runner's rules (no
|
|
120
|
+
* lazy fetch, no prompt, a timeout) apply. */
|
|
121
|
+
function quietly(tool, args, options = {}) {
|
|
122
|
+
try {
|
|
123
|
+
return runVcsSync(tool, args, {
|
|
124
|
+
cwd: options.cwd,
|
|
125
|
+
input: options.input,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
stdio: [
|
|
128
|
+
options.input === void 0 ? "ignore" : "pipe",
|
|
129
|
+
"pipe",
|
|
130
|
+
"ignore"
|
|
131
|
+
],
|
|
132
|
+
maxBuffer: 67108864
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A path with its parent's symlinks resolved, for a path that may be gone. */
|
|
139
|
+
function canonicalPath(file) {
|
|
140
|
+
try {
|
|
141
|
+
return path.join(realpathSync(path.dirname(file)), path.basename(file));
|
|
142
|
+
} catch {
|
|
143
|
+
return path.resolve(file);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The git directory a checkout's worktrees share, read from its `.git`
|
|
147
|
+
* without running git; undefined when that can't be read so. */
|
|
148
|
+
function commonDirOf(root) {
|
|
149
|
+
const dotGit = path.join(root, ".git");
|
|
150
|
+
try {
|
|
151
|
+
if (statSync(dotGit).isDirectory()) return realpathSync(dotGit);
|
|
152
|
+
const gitdir = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
|
|
153
|
+
if (!gitdir) return void 0;
|
|
154
|
+
const own = path.resolve(root, gitdir.trim());
|
|
155
|
+
try {
|
|
156
|
+
return realpathSync(path.resolve(own, readFileSync(path.join(own, "commondir"), "utf8").trim()));
|
|
157
|
+
} catch {
|
|
158
|
+
return realpathSync(own);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** The origin remote's URL from a git config file; undefined when the file
|
|
165
|
+
* can't settle it (a URL rewrite, an include), so git must be asked. */
|
|
166
|
+
function originUrlOf(commonDir) {
|
|
167
|
+
let config;
|
|
168
|
+
try {
|
|
169
|
+
config = readFileSync(path.join(commonDir, "config"), "utf8");
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (/insteadof|^\s*\[include/im.test(config)) return void 0;
|
|
174
|
+
const section = /^\s*\[remote\s+"origin"\]([^[]*)/im.exec(config)?.[1];
|
|
175
|
+
return section ? /^\s*url\s*=\s*(.+)$/im.exec(section)?.[1]?.trim() ?? null : null;
|
|
176
|
+
}
|
|
177
|
+
/** Undefined for a URL no forge parses. */
|
|
178
|
+
function remoteGroup(url) {
|
|
179
|
+
const azure = azureRepositoryOf(url);
|
|
180
|
+
if (azure) return {
|
|
181
|
+
key: `remote:dev.azure.com/${azure.toLowerCase()}`,
|
|
182
|
+
label: azure
|
|
183
|
+
};
|
|
184
|
+
const remote = parseGitRemote(url);
|
|
185
|
+
return remote ? {
|
|
186
|
+
key: `remote:${remote.host}/${remote.slug.toLowerCase()}`,
|
|
187
|
+
label: remote.slug
|
|
188
|
+
} : void 0;
|
|
189
|
+
}
|
|
190
|
+
function groupOf(commonDir, origin) {
|
|
191
|
+
return origin && remoteGroup(origin) || {
|
|
192
|
+
key: `git:${commonDir}`,
|
|
193
|
+
label: path.basename(path.dirname(commonDir))
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** A checkout's repository group; undefined if it isn't a readable git
|
|
197
|
+
* checkout. Reads `.git` and its config directly where it can, so many
|
|
198
|
+
* checkouts group in a moment; git answers the rest. */
|
|
199
|
+
function repositoryGroupAt(root) {
|
|
200
|
+
if (!existsSync(root)) return void 0;
|
|
201
|
+
const commonDir = commonDirOf(root);
|
|
202
|
+
const origin = commonDir ? originUrlOf(commonDir) : void 0;
|
|
203
|
+
if (commonDir && origin !== void 0) return groupOf(commonDir, origin);
|
|
204
|
+
const resolved = gitCommonDirSync(root);
|
|
205
|
+
if (!resolved) return void 0;
|
|
206
|
+
return groupOf(resolved, quietly("git", [
|
|
207
|
+
"--git-dir",
|
|
208
|
+
resolved,
|
|
209
|
+
"remote",
|
|
210
|
+
"get-url",
|
|
211
|
+
"origin"
|
|
212
|
+
])?.trim() || null);
|
|
213
|
+
}
|
|
214
|
+
/** The probe that reads `.git` directly, running git only where it must. */
|
|
215
|
+
const gitCheckoutProbe = {
|
|
216
|
+
groupAt: repositoryGroupAt,
|
|
217
|
+
worktreesOf(root) {
|
|
218
|
+
const commonDir = commonDirOf(root) ?? gitCommonDirSync(root) ?? void 0;
|
|
219
|
+
if (!commonDir) return void 0;
|
|
220
|
+
let entries = [];
|
|
221
|
+
try {
|
|
222
|
+
entries = readdirSync(path.join(commonDir, "worktrees"));
|
|
223
|
+
} catch {}
|
|
224
|
+
return {
|
|
225
|
+
commonDir,
|
|
226
|
+
worktrees: entries.flatMap((entry) => {
|
|
227
|
+
try {
|
|
228
|
+
const gitdir = readFileSync(path.join(commonDir, "worktrees", entry, "gitdir"), "utf8").trim();
|
|
229
|
+
return [path.dirname(gitdir)];
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
commitsIn: (commonDir, commits) => commitsListed(quietly("git", [
|
|
237
|
+
"--git-dir",
|
|
238
|
+
commonDir,
|
|
239
|
+
"cat-file",
|
|
240
|
+
"--batch-check"
|
|
241
|
+
], { input: commits.join("\n") + "\n" })),
|
|
242
|
+
findCommits: async (commonDir, commits) => commitsListed(await runVcs("git", [
|
|
243
|
+
"--git-dir",
|
|
244
|
+
commonDir,
|
|
245
|
+
"cat-file",
|
|
246
|
+
"--batch-check"
|
|
247
|
+
], {
|
|
248
|
+
input: commits.join("\n") + "\n",
|
|
249
|
+
maxBuffer: 67108864
|
|
250
|
+
}).then(({ stdout }) => stdout, () => void 0))
|
|
251
|
+
};
|
|
252
|
+
/** How many repositories are asked about commits at once. */
|
|
253
|
+
const PARALLEL_GIT = 8;
|
|
254
|
+
/**
|
|
255
|
+
* How a store's checkouts group into projects, worked out in one pass and
|
|
256
|
+
* kept for the life of the process. `prepare` asks each checkout still on
|
|
257
|
+
* disk for its group and its worktrees, once each, before an upgrade takes
|
|
258
|
+
* the write lock; projects are then assigned from the maps it builds,
|
|
259
|
+
* without touching git. A deleted checkout joins the checkout that lists it
|
|
260
|
+
* as a worktree, else one whose repository has the commits its boards
|
|
261
|
+
* pinned (a pruned worktree's history lives on in its repository),
|
|
262
|
+
* preferring the one whose folder held it; unmatched, it gets its own
|
|
263
|
+
* project.
|
|
264
|
+
*/
|
|
265
|
+
var CheckoutGroups = class {
|
|
266
|
+
db;
|
|
267
|
+
probe;
|
|
268
|
+
groups = /* @__PURE__ */ new Map();
|
|
269
|
+
owners;
|
|
270
|
+
constructor(db, probe = gitCheckoutProbe, prepared) {
|
|
271
|
+
this.db = db;
|
|
272
|
+
this.probe = probe;
|
|
273
|
+
this.owners = prepared;
|
|
274
|
+
}
|
|
275
|
+
/** A checkout's group, asked once per path. */
|
|
276
|
+
group(root) {
|
|
277
|
+
if (!this.groups.has(root)) {
|
|
278
|
+
let group;
|
|
279
|
+
if (existsSync(root)) try {
|
|
280
|
+
group = this.probe.groupAt(root);
|
|
281
|
+
} catch {
|
|
282
|
+
group = void 0;
|
|
283
|
+
}
|
|
284
|
+
this.groups.set(root, group);
|
|
285
|
+
}
|
|
286
|
+
return this.groups.get(root);
|
|
287
|
+
}
|
|
288
|
+
/** A file as a commit has it, or undefined if this repository can't say
|
|
289
|
+
* without fetching. */
|
|
290
|
+
fileAt(root, commit, file) {
|
|
291
|
+
return quietly("git", [
|
|
292
|
+
"-C",
|
|
293
|
+
root,
|
|
294
|
+
"show",
|
|
295
|
+
{ rev: `${commit}:${file}` }
|
|
296
|
+
]);
|
|
297
|
+
}
|
|
298
|
+
/** Works everything out now, ahead of a migration that needs it. */
|
|
299
|
+
prepare() {
|
|
300
|
+
if (this.owners) return;
|
|
301
|
+
const finding = this.findOwners();
|
|
302
|
+
let step = finding.next();
|
|
303
|
+
while (!step.done) step = finding.next(new Map([...step.value].map(([commonDir, commits]) => [commonDir, this.commitsIn(commonDir, commits)])));
|
|
304
|
+
this.owners = step.value;
|
|
305
|
+
}
|
|
306
|
+
/** As `prepare`, asking several repositories at once: before a store
|
|
307
|
+
* opens, while nothing waits on its write lock yet. */
|
|
308
|
+
async prepareConcurrently() {
|
|
309
|
+
if (this.owners) return;
|
|
310
|
+
const finding = this.findOwners();
|
|
311
|
+
let step = finding.next();
|
|
312
|
+
while (!step.done) {
|
|
313
|
+
const questions = [...step.value];
|
|
314
|
+
const answers = /* @__PURE__ */ new Map();
|
|
315
|
+
const ask = async () => {
|
|
316
|
+
for (let next = questions.shift(); next; next = questions.shift()) {
|
|
317
|
+
const [commonDir, commits] = next;
|
|
318
|
+
answers.set(commonDir, await this.findCommits(commonDir, commits));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
await Promise.all(Array.from({ length: PARALLEL_GIT }, ask));
|
|
322
|
+
step = finding.next(answers);
|
|
323
|
+
}
|
|
324
|
+
this.owners = step.value;
|
|
325
|
+
}
|
|
326
|
+
/** The grouping `prepare` worked out, to hand to another connection's. */
|
|
327
|
+
get prepared() {
|
|
328
|
+
return this.owners;
|
|
329
|
+
}
|
|
330
|
+
ownerOf(repositoryId) {
|
|
331
|
+
this.prepare();
|
|
332
|
+
return this.owners.get(repositoryId);
|
|
333
|
+
}
|
|
334
|
+
repositories() {
|
|
335
|
+
return this.db.prepare("SELECT id, path FROM repositories").all();
|
|
336
|
+
}
|
|
337
|
+
commitsIn(commonDir, commits) {
|
|
338
|
+
try {
|
|
339
|
+
return this.probe.commitsIn(commonDir, commits);
|
|
340
|
+
} catch {
|
|
341
|
+
return /* @__PURE__ */ new Set();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async findCommits(commonDir, commits) {
|
|
345
|
+
try {
|
|
346
|
+
return await (this.probe.findCommits?.(commonDir, commits) ?? this.probe.commitsIn(commonDir, commits));
|
|
347
|
+
} catch {
|
|
348
|
+
return /* @__PURE__ */ new Set();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Works out each deleted checkout's owner, yielding the questions for
|
|
352
|
+
* git it needs answered: once about the commits boards pin now, and
|
|
353
|
+
* again about older ones only for a checkout those leave unsettled. */
|
|
354
|
+
*findOwners() {
|
|
355
|
+
const owners = /* @__PURE__ */ new Map();
|
|
356
|
+
const existing = [];
|
|
357
|
+
const deleted = [];
|
|
358
|
+
for (const { id, path: root } of this.repositories()) (existsSync(String(root)) ? existing : deleted).push({
|
|
359
|
+
id: String(id),
|
|
360
|
+
root: String(root)
|
|
361
|
+
});
|
|
362
|
+
const listed = /* @__PURE__ */ new Map();
|
|
363
|
+
const repositories = /* @__PURE__ */ new Map();
|
|
364
|
+
const repositoryOf = /* @__PURE__ */ new Map();
|
|
365
|
+
for (const checkout of existing) {
|
|
366
|
+
this.group(checkout.root);
|
|
367
|
+
if (!deleted.length) continue;
|
|
368
|
+
let found;
|
|
369
|
+
try {
|
|
370
|
+
found = this.probe.worktreesOf(checkout.root);
|
|
371
|
+
} catch {
|
|
372
|
+
found = void 0;
|
|
373
|
+
}
|
|
374
|
+
if (!found) continue;
|
|
375
|
+
repositoryOf.set(checkout.id, found.commonDir);
|
|
376
|
+
if (repositories.has(found.commonDir)) continue;
|
|
377
|
+
repositories.set(found.commonDir, checkout.id);
|
|
378
|
+
for (const worktree of found.worktrees) listed.set(canonicalPath(worktree), checkout.id);
|
|
379
|
+
}
|
|
380
|
+
for (const gone of deleted) {
|
|
381
|
+
const owner = listed.get(canonicalPath(gone.root));
|
|
382
|
+
if (owner) owners.set(gone.id, owner);
|
|
383
|
+
}
|
|
384
|
+
const folders = existing.map((checkout) => ({
|
|
385
|
+
id: checkout.id,
|
|
386
|
+
folder: canonicalPath(checkout.root) + path.sep
|
|
387
|
+
}));
|
|
388
|
+
const containers = (gone) => {
|
|
389
|
+
const at = canonicalPath(gone.root);
|
|
390
|
+
return folders.flatMap(({ id, folder }) => at.startsWith(folder) && repositoryOf.has(id) ? [{
|
|
391
|
+
id,
|
|
392
|
+
commonDir: repositoryOf.get(id)
|
|
393
|
+
}] : []);
|
|
394
|
+
};
|
|
395
|
+
const unmatched = deleted.filter(({ id }) => !owners.has(id));
|
|
396
|
+
const now = this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "current");
|
|
397
|
+
const historyFirst = unmatched.some(({ id }) => !now.has(id));
|
|
398
|
+
const pinned = historyFirst ? this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "all") : now;
|
|
399
|
+
const having = /* @__PURE__ */ new Map();
|
|
400
|
+
if (pinned.size) {
|
|
401
|
+
const answers = yield questionsFor(repositories, pinned);
|
|
402
|
+
for (const [gone, theirs] of pinned) having.set(gone, repositoriesWith(repositories, answers, theirs));
|
|
403
|
+
}
|
|
404
|
+
const unsettled = historyFirst ? [] : unmatched.filter(({ id, root }) => {
|
|
405
|
+
const first = containers({ root })[0]?.commonDir ?? repositories.keys().next().value;
|
|
406
|
+
return !having.get(id)?.includes(first ?? "");
|
|
407
|
+
});
|
|
408
|
+
if (unsettled.length) {
|
|
409
|
+
const older = new Map([...this.pinnedCommits(new Set(unsettled.map(({ id }) => id)), "all")].map(([gone, commits]) => [gone, commits.filter((commit) => !now.get(gone)?.includes(commit))]));
|
|
410
|
+
const answers = yield questionsFor(repositories, older);
|
|
411
|
+
for (const [gone, theirs] of older) {
|
|
412
|
+
const more = repositoriesWith(repositories, answers, theirs);
|
|
413
|
+
const known = having.get(gone) ?? [];
|
|
414
|
+
having.set(gone, [...repositories.keys()].filter((commonDir) => known.includes(commonDir) || more.includes(commonDir)));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const gone of unmatched) {
|
|
418
|
+
const withCommits = having.get(gone.id);
|
|
419
|
+
if (!withCommits?.length) continue;
|
|
420
|
+
const container = containers(gone).find(({ commonDir }) => withCommits.includes(commonDir));
|
|
421
|
+
owners.set(gone.id, container?.id ?? repositories.get(withCommits[0]));
|
|
422
|
+
}
|
|
423
|
+
return owners;
|
|
424
|
+
}
|
|
425
|
+
/** The commits each of these checkouts' boards pin: in their current
|
|
426
|
+
* versions, or in every version. */
|
|
427
|
+
pinnedCommits(checkouts, versions) {
|
|
428
|
+
const pinned = /* @__PURE__ */ new Map();
|
|
429
|
+
if (!checkouts.size) return pinned;
|
|
430
|
+
for (const row of this.db.prepare(`SELECT json_extract(snapshot,'$.pins.repositoryId') AS repository,
|
|
431
|
+
json_extract(snapshot,'$.pins.head') AS head,
|
|
432
|
+
json_extract(snapshot,'$.pins.base') AS base
|
|
433
|
+
FROM ${versions === "current" ? "reviews CROSS JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version" : "versions"}
|
|
434
|
+
WHERE json_valid(snapshot) AND json_type(snapshot,'$.pins')='object'`).all()) {
|
|
435
|
+
const repository = String(row.repository);
|
|
436
|
+
if (!checkouts.has(repository)) continue;
|
|
437
|
+
const commits = pinned.get(repository) ?? [];
|
|
438
|
+
for (const commit of [String(row.head), String(row.base)]) if (/^[0-9a-f]{40}$/.test(commit) && !/^0+$/.test(commit) && !commits.includes(commit)) commits.push(commit);
|
|
439
|
+
if (commits.length) pinned.set(repository, commits);
|
|
440
|
+
}
|
|
441
|
+
return pinned;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/** Every repository asked about every commit pinned. */
|
|
445
|
+
function questionsFor(repositories, pinned) {
|
|
446
|
+
const commits = [...new Set([...pinned.values()].flat())];
|
|
447
|
+
return new Map(commits.length ? [...repositories.keys()].map((commonDir) => [commonDir, commits]) : []);
|
|
448
|
+
}
|
|
449
|
+
/** The repositories, in order, that have any of these commits. */
|
|
450
|
+
function repositoriesWith(repositories, answers, commits) {
|
|
451
|
+
return [...repositories.keys()].filter((commonDir) => commits.some((commit) => answers.get(commonDir)?.has(commit)));
|
|
452
|
+
}
|
|
453
|
+
/** The commits `git cat-file --batch-check` found. */
|
|
454
|
+
function commitsListed(output) {
|
|
455
|
+
return new Set((output ?? "").split("\n").filter((line) => / commit /.test(line)).map((line) => line.split(" ")[0]));
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
96
458
|
//#region src/review-checkout-paths.ts
|
|
97
459
|
/**
|
|
98
460
|
* Root for Review-owned checkouts. A Review UUID identifies one subtree.
|
|
@@ -500,6 +862,7 @@ var StructuralComparisons = class {
|
|
|
500
862
|
input.signal.throwIfAborted();
|
|
501
863
|
const key = JSON.stringify([
|
|
502
864
|
configGeneration,
|
|
865
|
+
await classificationStamp(input.repositoryPath),
|
|
503
866
|
input.repositoryPath,
|
|
504
867
|
input.comparison,
|
|
505
868
|
input.paths
|
|
@@ -530,6 +893,30 @@ var StructuralComparisons = class {
|
|
|
530
893
|
}
|
|
531
894
|
};
|
|
532
895
|
let configGeneration = 0;
|
|
896
|
+
const classificationFiles = /* @__PURE__ */ new Map();
|
|
897
|
+
/** Git attribute and diffr config files that diffr's classifier reads. */
|
|
898
|
+
function classificationInputs(root) {
|
|
899
|
+
let files = classificationFiles.get(root);
|
|
900
|
+
if (!files) {
|
|
901
|
+
const config = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
|
|
902
|
+
const diffrDirectory = process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
|
|
903
|
+
files = Promise.all([gitCommonDir(root), git(root, [
|
|
904
|
+
"config",
|
|
905
|
+
"--path",
|
|
906
|
+
"core.attributesFile"
|
|
907
|
+
], { allowFailure: true })]).then(([common, user]) => [
|
|
908
|
+
...common ? [path.join(common, "info", "attributes")] : [],
|
|
909
|
+
path.join(root, ".gitattributes"),
|
|
910
|
+
user.stdout.trim() || path.join(config, "git", "attributes"),
|
|
911
|
+
path.join(diffrDirectory, "config.toml")
|
|
912
|
+
]);
|
|
913
|
+
classificationFiles.set(root, files);
|
|
914
|
+
}
|
|
915
|
+
return files;
|
|
916
|
+
}
|
|
917
|
+
async function classificationStamp(root) {
|
|
918
|
+
return Promise.all((await classificationInputs(root)).map((file) => stat(file).then(({ mtimeMs, size }) => [mtimeMs, size], () => null)));
|
|
919
|
+
}
|
|
533
920
|
function invalidateStructuralComparisons() {
|
|
534
921
|
configGeneration++;
|
|
535
922
|
}
|
|
@@ -1226,26 +1613,31 @@ const mapInputSchema = z.strictObject({
|
|
|
1226
1613
|
});
|
|
1227
1614
|
//#endregion
|
|
1228
1615
|
//#region src/review-api/pull-request.ts
|
|
1616
|
+
const execFileAsync = promisify(execFile);
|
|
1229
1617
|
const FETCH_TIMEOUT_MS = 12e4;
|
|
1230
1618
|
const LOCAL_TIMEOUT_MS = 3e4;
|
|
1231
1619
|
const defaultPullRequestDeps = {
|
|
1232
|
-
run: (file, args, options) =>
|
|
1233
|
-
|
|
1620
|
+
run: async (file, args, options) => {
|
|
1621
|
+
const spawnOptions = {
|
|
1234
1622
|
cwd: options.cwd,
|
|
1235
1623
|
timeout: options.timeoutMs,
|
|
1236
1624
|
maxBuffer: 16777216,
|
|
1237
1625
|
env: {
|
|
1238
1626
|
...process.env,
|
|
1239
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
1240
|
-
GH_PROMPT_DISABLED: "1",
|
|
1241
1627
|
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes"
|
|
1242
1628
|
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1629
|
+
};
|
|
1630
|
+
try {
|
|
1631
|
+
return (file === "az" || file === "cmd.exe" ? await execFileAsync(file, renderVcsArgs(args), {
|
|
1632
|
+
...spawnOptions,
|
|
1633
|
+
encoding: "utf8"
|
|
1634
|
+
}) : await runVcs(file, args, spawnOptions)).stdout;
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
const failure = error;
|
|
1637
|
+
const reason = failure.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(failure.stderr ?? "").trim() || errorMessage(error);
|
|
1638
|
+
throw new Error(`${file} ${String(args[0])}: ${reason}`);
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1249
1641
|
fetch: (input, init) => fetch(input, init)
|
|
1250
1642
|
};
|
|
1251
1643
|
/** Remotes by their configured URL, not the insteadOf rewrite: a mirror
|
|
@@ -1298,32 +1690,45 @@ async function fetchPullRequest(input, deps) {
|
|
|
1298
1690
|
"--no-tags",
|
|
1299
1691
|
"--no-write-fetch-head",
|
|
1300
1692
|
"--quiet",
|
|
1301
|
-
input.remote,
|
|
1693
|
+
{ remote: input.remote },
|
|
1302
1694
|
...refspecs
|
|
1303
1695
|
], FETCH_TIMEOUT_MS);
|
|
1304
1696
|
const commit = (ref) => git([
|
|
1305
1697
|
"rev-parse",
|
|
1306
1698
|
"--verify",
|
|
1307
1699
|
"--quiet",
|
|
1308
|
-
`${ref}^{commit}`
|
|
1700
|
+
{ rev: `${ref}^{commit}` }
|
|
1309
1701
|
]).catch(() => void 0);
|
|
1310
1702
|
const mergeBase = (left, right) => git([
|
|
1311
1703
|
"merge-base",
|
|
1312
|
-
left,
|
|
1313
|
-
right
|
|
1704
|
+
{ rev: left },
|
|
1705
|
+
{ rev: right }
|
|
1314
1706
|
]).catch(() => void 0);
|
|
1315
1707
|
const frozenBase = async () => {
|
|
1316
1708
|
if (!pr.baseRefOid) return void 0;
|
|
1317
|
-
if (!await commit(pr.baseRefOid)) await fetchRefs(
|
|
1709
|
+
if (!await commit(pr.baseRefOid)) await fetchRefs({ refspec: {
|
|
1710
|
+
from: pr.baseRefOid,
|
|
1711
|
+
to: refs.frozenBase,
|
|
1712
|
+
force: true
|
|
1713
|
+
} }).catch(() => void 0);
|
|
1318
1714
|
return commit(pr.baseRefOid);
|
|
1319
1715
|
};
|
|
1320
1716
|
let baseTip;
|
|
1717
|
+
const pullHead = { refspec: {
|
|
1718
|
+
from: pr.headRef,
|
|
1719
|
+
to: refs.head,
|
|
1720
|
+
force: true
|
|
1721
|
+
} };
|
|
1321
1722
|
try {
|
|
1322
|
-
await fetchRefs(
|
|
1723
|
+
await fetchRefs(pullHead, { refspec: {
|
|
1724
|
+
from: `refs/heads/${pr.baseRefName}`,
|
|
1725
|
+
to: refs.base,
|
|
1726
|
+
force: true
|
|
1727
|
+
} });
|
|
1323
1728
|
baseTip = await commit(refs.base);
|
|
1324
1729
|
} catch (error) {
|
|
1325
1730
|
try {
|
|
1326
|
-
await fetchRefs(
|
|
1731
|
+
await fetchRefs(pullHead);
|
|
1327
1732
|
} catch {
|
|
1328
1733
|
throw new ReviewInputError(`Could not fetch PR #${pr.number} from remote "${input.remote}" (${pr.slug}). Check \`git fetch ${input.remote}\` works with your Git credentials, then retry. Git said: ${firstLine(errorMessage(error))}`, 409);
|
|
1329
1734
|
}
|
|
@@ -1365,8 +1770,8 @@ async function indexForJj(rootPath, commits, git, deps) {
|
|
|
1365
1770
|
try {
|
|
1366
1771
|
for (const [index, tag] of tags.entries()) await git([
|
|
1367
1772
|
"update-ref",
|
|
1368
|
-
tag,
|
|
1369
|
-
commits[index]
|
|
1773
|
+
{ ref: tag },
|
|
1774
|
+
{ commit: commits[index] }
|
|
1370
1775
|
]);
|
|
1371
1776
|
await jjImport();
|
|
1372
1777
|
} finally {
|
|
@@ -1378,6 +1783,15 @@ async function indexForJj(rootPath, commits, git, deps) {
|
|
|
1378
1783
|
await jjImport().catch(() => void 0);
|
|
1379
1784
|
}
|
|
1380
1785
|
}
|
|
1786
|
+
/** GitHub refused a read, as it does past a rate limit; it takes reads
|
|
1787
|
+
* again at `retryAt`. */
|
|
1788
|
+
var GitHubRefusedRead = class extends ReviewInputError {
|
|
1789
|
+
retryAt;
|
|
1790
|
+
constructor(message, retryAt) {
|
|
1791
|
+
super(message, 409);
|
|
1792
|
+
this.retryAt = retryAt;
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1381
1795
|
function firstLine(text) {
|
|
1382
1796
|
return text.split("\n")[0].slice(0, 300);
|
|
1383
1797
|
}
|
|
@@ -1385,12 +1799,6 @@ function firstLine(text) {
|
|
|
1385
1799
|
//#region src/review-api/azure-devops-provider.ts
|
|
1386
1800
|
const AZ_TIMEOUT_MS = 3e4;
|
|
1387
1801
|
const PULL_REQUEST_URLS = [/^https:\/\/dev\.azure\.com\/([A-Za-z0-9-]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/([1-9]\d*)$/, /^https:\/\/([A-Za-z0-9-]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/([1-9]\d*)$/];
|
|
1388
|
-
const REMOTE_URLS = [
|
|
1389
|
-
/^https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
|
|
1390
|
-
/^https:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
|
|
1391
|
-
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
|
|
1392
|
-
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
|
|
1393
|
-
];
|
|
1394
1802
|
const metadataSchema$1 = z.object({
|
|
1395
1803
|
title: z.string(),
|
|
1396
1804
|
sourceRefName: z.string().min(1),
|
|
@@ -1402,15 +1810,6 @@ const metadataSchema$1 = z.object({
|
|
|
1402
1810
|
project: z.object({ name: z.string() })
|
|
1403
1811
|
})
|
|
1404
1812
|
});
|
|
1405
|
-
/** org/project/repository, decoded; undefined for malformed escapes. */
|
|
1406
|
-
function slugOf(patterns, url) {
|
|
1407
|
-
const match = patterns.map((pattern) => pattern.exec(url)).find(Boolean);
|
|
1408
|
-
try {
|
|
1409
|
-
return match && match.slice(1, 4).map(decodeURIComponent).join("/");
|
|
1410
|
-
} catch {
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
1813
|
function azFailure(url, failure) {
|
|
1415
1814
|
const unreadable = (hint) => new ReviewInputError(`Could not read ${url}: az failed (${failure}). ${hint}`, 409);
|
|
1416
1815
|
if (/not recognized as an internal or external command|command not found|enoent/i.test(failure)) return unreadable("Install the Azure CLI and the azure-devops extension, then retry.");
|
|
@@ -1480,7 +1879,7 @@ const azureDevOpsProvider = {
|
|
|
1480
1879
|
};
|
|
1481
1880
|
},
|
|
1482
1881
|
matchesRemote(remoteUrl, address) {
|
|
1483
|
-
return
|
|
1882
|
+
return azureRepositoryOf(remoteUrl)?.toLowerCase() === address.slug.toLowerCase();
|
|
1484
1883
|
}
|
|
1485
1884
|
};
|
|
1486
1885
|
//#endregion
|
|
@@ -1494,8 +1893,20 @@ const metadataSchema = z.object({
|
|
|
1494
1893
|
baseRefName: z.string().min(1),
|
|
1495
1894
|
baseRefOid: z.string().optional(),
|
|
1496
1895
|
headRefName: z.string().min(1),
|
|
1497
|
-
isCrossRepository: z.boolean()
|
|
1896
|
+
isCrossRepository: z.boolean(),
|
|
1897
|
+
state: z.enum([
|
|
1898
|
+
"OPEN",
|
|
1899
|
+
"CLOSED",
|
|
1900
|
+
"MERGED"
|
|
1901
|
+
]).optional(),
|
|
1902
|
+
isDraft: z.boolean().optional()
|
|
1498
1903
|
});
|
|
1904
|
+
/** gh's state and draft flag as one state. */
|
|
1905
|
+
function ghState(state, isDraft) {
|
|
1906
|
+
if (state === "MERGED") return "merged";
|
|
1907
|
+
if (state === "CLOSED") return "closed";
|
|
1908
|
+
if (state === "OPEN") return isDraft ? "draft" : "open";
|
|
1909
|
+
}
|
|
1499
1910
|
function unreadable(url, ghFailure, fallback) {
|
|
1500
1911
|
return new ReviewInputError(`Could not read ${url}: gh failed (${ghFailure}) and ${fallback}. Run \`gh auth status\` and sign in with an account that can read the repository, then retry.`, 409);
|
|
1501
1912
|
}
|
|
@@ -1523,19 +1934,21 @@ const providers = [{
|
|
|
1523
1934
|
const stdout = await deps.run("gh", [
|
|
1524
1935
|
"pr",
|
|
1525
1936
|
"view",
|
|
1526
|
-
String(number),
|
|
1937
|
+
{ value: String(number) },
|
|
1527
1938
|
"--repo",
|
|
1528
|
-
`${host}/${slug}
|
|
1939
|
+
{ value: `${host}/${slug}` },
|
|
1529
1940
|
"--json",
|
|
1530
|
-
"number,title,baseRefName,baseRefOid,headRefName,isCrossRepository"
|
|
1941
|
+
"number,title,baseRefName,baseRefOid,headRefName,isCrossRepository,state,isDraft"
|
|
1531
1942
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
1532
|
-
const { headRefName, isCrossRepository, ...metadata } = metadataSchema.parse(JSON.parse(stdout));
|
|
1943
|
+
const { headRefName, isCrossRepository, state, isDraft, ...metadata } = metadataSchema.parse(JSON.parse(stdout));
|
|
1944
|
+
const pullRequestState = ghState(state, isDraft);
|
|
1533
1945
|
return {
|
|
1534
1946
|
host,
|
|
1535
1947
|
slug,
|
|
1536
1948
|
headRef,
|
|
1537
1949
|
...metadata,
|
|
1538
|
-
headBranch: isCrossRepository ? void 0 : headRefName
|
|
1950
|
+
headBranch: isCrossRepository ? void 0 : headRefName,
|
|
1951
|
+
...pullRequestState && { state: pullRequestState }
|
|
1539
1952
|
};
|
|
1540
1953
|
} catch (error) {
|
|
1541
1954
|
ghFailure = firstLine(errorMessage(error));
|
|
@@ -1554,7 +1967,10 @@ const providers = [{
|
|
|
1554
1967
|
throw unreadable(url, ghFailure, `the GitHub API is unreachable (${firstLine(errorMessage(error))})`);
|
|
1555
1968
|
}
|
|
1556
1969
|
if (response.status === 404) throw new ReviewInputError(`${url} was not found: it does not exist, or it is private and gh is not signed in to an account that can read it (run \`gh auth status\`). gh said: ${ghFailure}`, 404);
|
|
1557
|
-
if (response.status === 403 || response.status === 429)
|
|
1970
|
+
if (response.status === 403 || response.status === 429) {
|
|
1971
|
+
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
1972
|
+
throw new GitHubRefusedRead(unreadable(url, ghFailure, response.headers.get("x-ratelimit-remaining") === "0" || response.status === 429 ? "the unauthenticated GitHub API rate limit is exhausted" : "the GitHub API refused the request").message, reset > 0 ? reset * 1e3 : Date.now() + 9e5);
|
|
1973
|
+
}
|
|
1558
1974
|
if (!response.ok) throw unreadable(url, ghFailure, `the GitHub API returned HTTP ${response.status}`);
|
|
1559
1975
|
const parsed = z.object({
|
|
1560
1976
|
number: z.number(),
|
|
@@ -1567,7 +1983,10 @@ const providers = [{
|
|
|
1567
1983
|
head: z.object({
|
|
1568
1984
|
ref: z.string().min(1),
|
|
1569
1985
|
repo: z.object({ id: z.number() }).nullable()
|
|
1570
|
-
})
|
|
1986
|
+
}),
|
|
1987
|
+
state: z.enum(["open", "closed"]).optional(),
|
|
1988
|
+
draft: z.boolean().optional(),
|
|
1989
|
+
merged_at: z.string().nullable().optional()
|
|
1571
1990
|
}).safeParse(await response.json().catch(() => void 0));
|
|
1572
1991
|
if (!parsed.success) throw unreadable(url, ghFailure, "the GitHub API response was malformed");
|
|
1573
1992
|
return {
|
|
@@ -1578,7 +1997,8 @@ const providers = [{
|
|
|
1578
1997
|
title: parsed.data.title,
|
|
1579
1998
|
baseRefName: parsed.data.base.ref,
|
|
1580
1999
|
baseRefOid: parsed.data.base.sha,
|
|
1581
|
-
headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0
|
|
2000
|
+
headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0,
|
|
2001
|
+
...parsed.data.state && { state: parsed.data.merged_at ? "merged" : parsed.data.state === "closed" ? "closed" : parsed.data.draft ? "draft" : "open" }
|
|
1582
2002
|
};
|
|
1583
2003
|
},
|
|
1584
2004
|
matchesRemote(remoteUrl, address) {
|
|
@@ -1630,8 +2050,7 @@ var ReviewActivity = class {
|
|
|
1630
2050
|
this.assertReview = assertReview;
|
|
1631
2051
|
db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
|
|
1632
2052
|
activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
|
|
1633
|
-
started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
|
|
1634
|
-
DROP TABLE IF EXISTS authoring_sessions;`);
|
|
2053
|
+
started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);`);
|
|
1635
2054
|
for (const row of db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) {
|
|
1636
2055
|
this.working.add(String(row.review_id));
|
|
1637
2056
|
this.scheduleExpiry(String(row.review_id));
|
|
@@ -1774,7 +2193,13 @@ var ReviewActivity = class {
|
|
|
1774
2193
|
//#region src/review-api/ask-history.ts
|
|
1775
2194
|
/** A saved Ask conversation: its history entry, and how to reopen it. */
|
|
1776
2195
|
const askRecordSchema = askHistoryEntrySchema.extend({
|
|
1777
|
-
|
|
2196
|
+
/** The board the conversation started on, and the block if any. */
|
|
2197
|
+
anchor: z.object({
|
|
2198
|
+
board: z.string(),
|
|
2199
|
+
block: z.string().optional()
|
|
2200
|
+
}),
|
|
2201
|
+
/** Absent for a shared board. */
|
|
2202
|
+
projectId: z.string().optional(),
|
|
1778
2203
|
/** The ACP session the agent keeps the transcript under. */
|
|
1779
2204
|
sessionId: z.string(),
|
|
1780
2205
|
/** The review version asked about; its pins recreate the checkout. */
|
|
@@ -1787,7 +2212,9 @@ const askRecordSchema = askHistoryEntrySchema.extend({
|
|
|
1787
2212
|
});
|
|
1788
2213
|
const rowSchema = z.object({
|
|
1789
2214
|
id: z.string(),
|
|
1790
|
-
|
|
2215
|
+
anchor_board: z.string(),
|
|
2216
|
+
anchor_block: z.string().nullable(),
|
|
2217
|
+
project_id: z.string().nullable(),
|
|
1791
2218
|
agent: z.string(),
|
|
1792
2219
|
session_id: z.string(),
|
|
1793
2220
|
version: z.number(),
|
|
@@ -1802,7 +2229,11 @@ const rowSchema = z.object({
|
|
|
1802
2229
|
permissions: z.string().nullable()
|
|
1803
2230
|
}).transform((row) => askRecordSchema.parse({
|
|
1804
2231
|
id: row.id,
|
|
1805
|
-
|
|
2232
|
+
anchor: row.anchor_block === null ? { board: row.anchor_board } : {
|
|
2233
|
+
board: row.anchor_board,
|
|
2234
|
+
block: row.anchor_block
|
|
2235
|
+
},
|
|
2236
|
+
projectId: row.project_id ?? void 0,
|
|
1806
2237
|
agent: row.agent,
|
|
1807
2238
|
sessionId: row.session_id,
|
|
1808
2239
|
version: row.version,
|
|
@@ -1815,19 +2246,28 @@ const rowSchema = z.object({
|
|
|
1815
2246
|
entries: row.entries === null ? void 0 : JSON.parse(row.entries),
|
|
1816
2247
|
permissions: row.permissions ?? (row.bypass === 1 ? "bypass" : "read-only")
|
|
1817
2248
|
}));
|
|
1818
|
-
/**
|
|
1819
|
-
|
|
2249
|
+
/** A thread's project is always its anchor board's. */
|
|
2250
|
+
const THREAD_PROJECT_SQL = "(SELECT project_id FROM reviews WHERE reviews.id=ask_conversations.anchor_board)";
|
|
2251
|
+
/** Every column of a thread, with its project. */
|
|
2252
|
+
const THREAD_COLUMNS = `ask_conversations.*, ${THREAD_PROJECT_SQL} AS project_id`;
|
|
2253
|
+
/** Saved Ask conversations by project, each anchored on the board it started
|
|
2254
|
+
* on. No foreign key on the anchor: shared reviews are not in `reviews`.
|
|
2255
|
+
* So deleting a board removes its threads by hand (removeReview). */
|
|
1820
2256
|
var AskHistory = class {
|
|
1821
2257
|
db;
|
|
2258
|
+
writable;
|
|
2259
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1822
2260
|
externalWriteGuard;
|
|
1823
2261
|
setExternalWriteGuard(guard) {
|
|
1824
2262
|
this.externalWriteGuard = guard;
|
|
1825
2263
|
}
|
|
1826
|
-
constructor(db) {
|
|
2264
|
+
constructor(db, writable = () => {}) {
|
|
1827
2265
|
this.db = db;
|
|
2266
|
+
this.writable = writable;
|
|
1828
2267
|
db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
|
|
1829
2268
|
id TEXT PRIMARY KEY,
|
|
1830
|
-
|
|
2269
|
+
anchor_board TEXT NOT NULL,
|
|
2270
|
+
anchor_block TEXT,
|
|
1831
2271
|
agent TEXT NOT NULL,
|
|
1832
2272
|
session_id TEXT NOT NULL,
|
|
1833
2273
|
version INTEGER NOT NULL,
|
|
@@ -1840,30 +2280,39 @@ var AskHistory = class {
|
|
|
1840
2280
|
entries TEXT,
|
|
1841
2281
|
bypass INTEGER NOT NULL DEFAULT 0,
|
|
1842
2282
|
permissions TEXT,
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
if (!columns.has("bypass")) db.exec("ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0");
|
|
1847
|
-
if (!columns.has("permissions")) db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
|
|
2283
|
+
viewed_at TEXT,
|
|
2284
|
+
UNIQUE(agent, session_id));`);
|
|
2285
|
+
db.exec("CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(anchor_board, updated_at)");
|
|
1848
2286
|
db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
|
|
1849
2287
|
db.exec("CREATE TABLE IF NOT EXISTS ask_agent_model_offers(agent TEXT NOT NULL, model TEXT NOT NULL, offer TEXT NOT NULL, PRIMARY KEY(agent, model))");
|
|
1850
2288
|
}
|
|
2289
|
+
/** Calls back when a conversation is saved, renamed, used or deleted. */
|
|
2290
|
+
subscribe(listener) {
|
|
2291
|
+
this.listeners.add(listener);
|
|
2292
|
+
return () => {
|
|
2293
|
+
this.listeners.delete(listener);
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
changed() {
|
|
2297
|
+
for (const listener of this.listeners) listener();
|
|
2298
|
+
}
|
|
1851
2299
|
assertWritable(reviewId) {
|
|
1852
2300
|
if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
|
|
1853
|
-
|
|
1854
|
-
if (row?.deleted_at) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(String(row.deleted_at)).toLocaleDateString("en-US", {
|
|
1855
|
-
month: "short",
|
|
1856
|
-
day: "numeric"
|
|
1857
|
-
})}. Restore it with session_lifecycle({"sessionId":"${reviewId}","state":"active"}) to continue.`, 409);
|
|
2301
|
+
else this.writable(reviewId);
|
|
1858
2302
|
}
|
|
1859
2303
|
assertThreadWritable(id) {
|
|
1860
2304
|
const record = this.get(id);
|
|
1861
|
-
if (record) this.assertWritable(record.
|
|
2305
|
+
if (record) this.assertWritable(record.anchor.board);
|
|
1862
2306
|
}
|
|
1863
2307
|
save(record) {
|
|
1864
|
-
this.assertWritable(record.
|
|
1865
|
-
this.db.prepare(`INSERT
|
|
1866
|
-
VALUES(
|
|
2308
|
+
this.assertWritable(record.anchor.board);
|
|
2309
|
+
this.db.prepare(`INSERT INTO ask_conversations(id,anchor_board,anchor_block,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
|
|
2310
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
2311
|
+
ON CONFLICT(id) DO UPDATE SET anchor_board=excluded.anchor_board, anchor_block=excluded.anchor_block,
|
|
2312
|
+
agent=excluded.agent, session_id=excluded.session_id, version=excluded.version, head=excluded.head,
|
|
2313
|
+
cwd=excluded.cwd, selection=excluded.selection, title=excluded.title, created_at=excluded.created_at,
|
|
2314
|
+
updated_at=excluded.updated_at, bypass=excluded.bypass, permissions=excluded.permissions`).run(record.id, record.anchor.board, record.anchor.block ?? null, record.agent, record.sessionId, record.version, record.head, record.cwd, JSON.stringify(record.selection), record.title, record.createdAt, record.updatedAt, record.permissions === "bypass" ? 1 : 0, record.permissions ?? null);
|
|
2315
|
+
this.changed();
|
|
1867
2316
|
}
|
|
1868
2317
|
/** What the agent may do without asking when the conversation reopens. */
|
|
1869
2318
|
setPermissions(id, permissions) {
|
|
@@ -1880,11 +2329,13 @@ var AskHistory = class {
|
|
|
1880
2329
|
saveEntries(id, entries) {
|
|
1881
2330
|
this.assertThreadWritable(id);
|
|
1882
2331
|
this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
|
|
2332
|
+
this.changed();
|
|
1883
2333
|
}
|
|
1884
2334
|
/** The agent's name for the conversation, which the list shows. */
|
|
1885
2335
|
rename(id, title) {
|
|
1886
2336
|
this.assertThreadWritable(id);
|
|
1887
2337
|
this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
|
|
2338
|
+
this.changed();
|
|
1888
2339
|
}
|
|
1889
2340
|
saveOffer(agent, offer) {
|
|
1890
2341
|
this.db.prepare("INSERT OR REPLACE INTO ask_agent_offers(agent, offer) VALUES(?, ?)").run(agent, JSON.stringify(offer));
|
|
@@ -1905,29 +2356,195 @@ var AskHistory = class {
|
|
|
1905
2356
|
touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
1906
2357
|
this.assertThreadWritable(id);
|
|
1907
2358
|
this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2359
|
+
this.changed();
|
|
2360
|
+
}
|
|
2361
|
+
/** Conversations in a board's project, or started on the board when it
|
|
2362
|
+
* has none. Newest first. */
|
|
2363
|
+
list(board, projectId) {
|
|
2364
|
+
return this.entries("anchor_board=? OR anchor_board IN (SELECT id FROM reviews WHERE project_id=?)", board, projectId ?? null);
|
|
2365
|
+
}
|
|
2366
|
+
/** Every project's conversations, newest first. */
|
|
2367
|
+
listAll() {
|
|
2368
|
+
return this.entries("1");
|
|
2369
|
+
}
|
|
2370
|
+
entries(where, ...params) {
|
|
2371
|
+
return this.db.prepare(`SELECT id, anchor_board, anchor_block, ${THREAD_PROJECT_SQL} AS project_id, agent, session_id, version, head, cwd, selection, title,
|
|
2372
|
+
created_at, updated_at, NULL AS entries, bypass, permissions, viewed_at,
|
|
2373
|
+
(SELECT json_extract(value,'$.text') FROM json_each(entries)
|
|
2374
|
+
WHERE json_extract(value,'$.kind')='user' LIMIT 1) AS question
|
|
2375
|
+
FROM ask_conversations WHERE ${where} ORDER BY updated_at DESC`).all(...params).map((row) => {
|
|
2376
|
+
const { anchor, sessionId: _sessionId, version: _version, cwd: _cwd, entries: _entries, permissions: _permissions, ...entry } = rowSchema.parse(row);
|
|
2377
|
+
const question = z.string().nullable().parse(row.question) ?? "";
|
|
2378
|
+
if (question.trim()) entry.question = question;
|
|
2379
|
+
const viewedAt = z.string().nullable().parse(row.viewed_at);
|
|
2380
|
+
return {
|
|
2381
|
+
...entry,
|
|
2382
|
+
anchor,
|
|
2383
|
+
...viewedAt !== null && { viewedAt }
|
|
2384
|
+
};
|
|
1916
2385
|
});
|
|
1917
2386
|
}
|
|
1918
2387
|
get(id) {
|
|
1919
|
-
const row = this.db.prepare(
|
|
2388
|
+
const row = this.db.prepare(`SELECT ${THREAD_COLUMNS} FROM ask_conversations WHERE id=?`).get(id);
|
|
1920
2389
|
return row ? rowSchema.parse(row) : void 0;
|
|
1921
2390
|
}
|
|
2391
|
+
/** The reviewer saw the thread, on screen with the app focused. Only
|
|
2392
|
+
* when it was seen changes: not its order, nor when it was used. */
|
|
2393
|
+
markViewed(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2394
|
+
this.db.prepare("UPDATE ask_conversations SET viewed_at=? WHERE id=?").run(at, id);
|
|
2395
|
+
this.changed();
|
|
2396
|
+
}
|
|
2397
|
+
/** Forgets the threads that started on a board gone for good. */
|
|
1922
2398
|
removeReview(reviewId) {
|
|
1923
|
-
this.db.prepare("DELETE FROM ask_conversations WHERE
|
|
2399
|
+
this.db.prepare("DELETE FROM ask_conversations WHERE anchor_board=?").run(reviewId);
|
|
2400
|
+
this.changed();
|
|
1924
2401
|
}
|
|
1925
2402
|
delete(id) {
|
|
1926
2403
|
this.assertThreadWritable(id);
|
|
1927
2404
|
this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
|
|
2405
|
+
this.changed();
|
|
1928
2406
|
}
|
|
1929
2407
|
};
|
|
1930
2408
|
//#endregion
|
|
2409
|
+
//#region src/review-api/board-order.ts
|
|
2410
|
+
/**
|
|
2411
|
+
* Siblings in reading order. A comes after B when A reaches B through
|
|
2412
|
+
* depends_on, directly or through boards outside the siblings. Between two
|
|
2413
|
+
* siblings with no such chain either way, commits decide: A comes after B
|
|
2414
|
+
* when A's base is, or descends from, B's head, or A's PR merges into B's
|
|
2415
|
+
* branch. Remaining ties go newest made first, then by id, so an edit
|
|
2416
|
+
* never reorders them.
|
|
2417
|
+
*/
|
|
2418
|
+
function orderSiblings(siblings, dependsOn, isAncestor) {
|
|
2419
|
+
const reach = new Map(siblings.map((board) => [board.id, reachable(board.id, dependsOn)]));
|
|
2420
|
+
const after = (a, b) => {
|
|
2421
|
+
if (reach.get(a.id).has(b.id)) return true;
|
|
2422
|
+
if (reach.get(b.id).has(a.id)) return false;
|
|
2423
|
+
return buildsOn(a, b, isAncestor) && !buildsOn(b, a, isAncestor);
|
|
2424
|
+
};
|
|
2425
|
+
const before = new Map(siblings.map((a) => [a.id, new Set(siblings.flatMap((b) => b !== a && after(a, b) ? [b.id] : []))]));
|
|
2426
|
+
const newestFirst = [...siblings].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || a.id.localeCompare(b.id));
|
|
2427
|
+
const placed = /* @__PURE__ */ new Set();
|
|
2428
|
+
const order = [];
|
|
2429
|
+
while (order.length < siblings.length) {
|
|
2430
|
+
const remaining = newestFirst.filter((board) => !placed.has(board.id));
|
|
2431
|
+
const next = remaining.find((board) => [...before.get(board.id)].every((id) => placed.has(id))) ?? remaining[0];
|
|
2432
|
+
placed.add(next.id);
|
|
2433
|
+
order.push(next.id);
|
|
2434
|
+
}
|
|
2435
|
+
return order;
|
|
2436
|
+
}
|
|
2437
|
+
function reachable(id, dependsOn) {
|
|
2438
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2439
|
+
const stack = [...dependsOn(id)];
|
|
2440
|
+
while (stack.length) {
|
|
2441
|
+
const next = stack.pop();
|
|
2442
|
+
if (seen.has(next)) continue;
|
|
2443
|
+
seen.add(next);
|
|
2444
|
+
stack.push(...dependsOn(next));
|
|
2445
|
+
}
|
|
2446
|
+
return seen;
|
|
2447
|
+
}
|
|
2448
|
+
/** Whether a's change sits on top of b's. */
|
|
2449
|
+
function buildsOn(a, b, isAncestor) {
|
|
2450
|
+
if (a.repositoryId !== b.repositoryId) return false;
|
|
2451
|
+
if (a.baseBranch && a.baseBranch === b.headBranch) return true;
|
|
2452
|
+
if (!a.base || !b.head) return false;
|
|
2453
|
+
if (a.base === b.head) return true;
|
|
2454
|
+
return a.base !== a.head && isAncestor(a.repositoryId, b.head, a.base);
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* The path that adding `from → to` would close, from `from` back to itself,
|
|
2458
|
+
* or undefined when it closes none.
|
|
2459
|
+
*/
|
|
2460
|
+
function dependencyCycle(from, to, dependsOn) {
|
|
2461
|
+
if (from === to) return [from, from];
|
|
2462
|
+
const previous = /* @__PURE__ */ new Map();
|
|
2463
|
+
const queue = [to];
|
|
2464
|
+
const seen = /* @__PURE__ */ new Set([to]);
|
|
2465
|
+
while (queue.length) {
|
|
2466
|
+
const current = queue.shift();
|
|
2467
|
+
if (current === from) {
|
|
2468
|
+
const path = [from];
|
|
2469
|
+
for (let at = from; at !== to; at = previous.get(at)) path.unshift(previous.get(at));
|
|
2470
|
+
return [from, ...path];
|
|
2471
|
+
}
|
|
2472
|
+
for (const next of dependsOn(current)) if (!seen.has(next)) {
|
|
2473
|
+
seen.add(next);
|
|
2474
|
+
previous.set(next, current);
|
|
2475
|
+
queue.push(next);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
//#endregion
|
|
2480
|
+
//#region src/review-api/link-preview.ts
|
|
2481
|
+
/** About what the card shows before it fades; more is cut, not sent. */
|
|
2482
|
+
const PROSE_CHARACTERS = 700;
|
|
2483
|
+
const CODE_LINES = 6;
|
|
2484
|
+
function childBlocks(block) {
|
|
2485
|
+
return "children" in block ? block.children : [];
|
|
2486
|
+
}
|
|
2487
|
+
/** Paragraphs of markdown, without headings. */
|
|
2488
|
+
function paragraphs(markdown) {
|
|
2489
|
+
return markdown.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter((paragraph) => paragraph && !paragraph.startsWith("#"));
|
|
2490
|
+
}
|
|
2491
|
+
/** The prose from the first markdown block on, in reading order, until the
|
|
2492
|
+
* card has enough. Diagrams and code before it are skipped. */
|
|
2493
|
+
function openingProse(blocks) {
|
|
2494
|
+
const prose = [];
|
|
2495
|
+
let length = 0;
|
|
2496
|
+
const walk = (list) => {
|
|
2497
|
+
for (const block of list) if (block.type === "markdown") for (const paragraph of paragraphs(block.markdown)) {
|
|
2498
|
+
prose.push(paragraph);
|
|
2499
|
+
length += paragraph.length;
|
|
2500
|
+
if (length >= PROSE_CHARACTERS) return true;
|
|
2501
|
+
}
|
|
2502
|
+
else if (walk(childBlocks(block))) return true;
|
|
2503
|
+
return false;
|
|
2504
|
+
};
|
|
2505
|
+
walk(blocks);
|
|
2506
|
+
return prose;
|
|
2507
|
+
}
|
|
2508
|
+
/** The block and the title of the section it is in. */
|
|
2509
|
+
function find(blocks, id, section) {
|
|
2510
|
+
for (const block of blocks) {
|
|
2511
|
+
if (block.id === id) return {
|
|
2512
|
+
block,
|
|
2513
|
+
section
|
|
2514
|
+
};
|
|
2515
|
+
const found = find(childBlocks(block), id, block.type === "section" ? block.title : section);
|
|
2516
|
+
if (found) return found;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
/** The opening prose of a board, or one block of it. */
|
|
2520
|
+
function boardLinkPreview(document, blockId) {
|
|
2521
|
+
if (blockId === void 0) return { prose: openingProse(document) };
|
|
2522
|
+
const found = find(document, blockId);
|
|
2523
|
+
if (!found) return {};
|
|
2524
|
+
const { block } = found;
|
|
2525
|
+
const section = block.type === "section" ? block.title : found.section;
|
|
2526
|
+
if (block.type === "code") return {
|
|
2527
|
+
...section && { section },
|
|
2528
|
+
code: {
|
|
2529
|
+
language: block.language,
|
|
2530
|
+
lines: block.text.split("\n").slice(0, CODE_LINES)
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
const prose = openingProse([block]);
|
|
2534
|
+
if (prose.length) return {
|
|
2535
|
+
...section && { section },
|
|
2536
|
+
prose
|
|
2537
|
+
};
|
|
2538
|
+
const title = "title" in block ? block.title : void 0;
|
|
2539
|
+
return {
|
|
2540
|
+
...section && { section },
|
|
2541
|
+
figure: {
|
|
2542
|
+
type: block.type,
|
|
2543
|
+
title: title ?? ""
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
//#endregion
|
|
1931
2548
|
//#region src/review-api/origin.ts
|
|
1932
2549
|
const pullRequestUrl = z.string().refine((url) => pullRequestProvider(url) !== void 0, "Use a canonical PR URL: https://github.com/owner/repository/pull/123, the same path on a GitHub Enterprise host, or https://dev.azure.com/org/project/_git/repository/pullrequest/123.");
|
|
1933
2550
|
/** One key per PR: GitHub owner and repository names are case-insensitive,
|
|
@@ -1954,36 +2571,496 @@ function setPullRequest(snapshot, url, title) {
|
|
|
1954
2571
|
};
|
|
1955
2572
|
}
|
|
1956
2573
|
//#endregion
|
|
2574
|
+
//#region src/review-api/foreign-references.ts
|
|
2575
|
+
const LANGUAGES = /* @__PURE__ */ new Map([
|
|
2576
|
+
[".ts", "typescript"],
|
|
2577
|
+
[".tsx", "tsx"],
|
|
2578
|
+
[".js", "javascript"],
|
|
2579
|
+
[".jsx", "jsx"],
|
|
2580
|
+
[".mjs", "javascript"],
|
|
2581
|
+
[".rs", "rust"],
|
|
2582
|
+
[".go", "go"],
|
|
2583
|
+
[".py", "python"],
|
|
2584
|
+
[".rb", "ruby"],
|
|
2585
|
+
[".java", "java"],
|
|
2586
|
+
[".kt", "kotlin"],
|
|
2587
|
+
[".swift", "swift"],
|
|
2588
|
+
[".c", "c"],
|
|
2589
|
+
[".h", "c"],
|
|
2590
|
+
[".cc", "cpp"],
|
|
2591
|
+
[".cpp", "cpp"],
|
|
2592
|
+
[".css", "css"],
|
|
2593
|
+
[".html", "html"],
|
|
2594
|
+
[".json", "json"],
|
|
2595
|
+
[".md", "markdown"],
|
|
2596
|
+
[".sh", "bash"],
|
|
2597
|
+
[".sql", "sql"],
|
|
2598
|
+
[".toml", "toml"],
|
|
2599
|
+
[".yaml", "yaml"],
|
|
2600
|
+
[".yml", "yaml"]
|
|
2601
|
+
]);
|
|
2602
|
+
/** A source link in Markdown, in either spelling. */
|
|
2603
|
+
const SOURCE_LINK = /^(?:whiteboard:source\/|review-source:)/i;
|
|
2604
|
+
/**
|
|
2605
|
+
* Migrates the old global scratchpad, which quoted several repositories.
|
|
2606
|
+
* A board may only quote its own, so each quote of another repository is
|
|
2607
|
+
* replaced:
|
|
2608
|
+
* - a code peek becomes a `code` block of those lines;
|
|
2609
|
+
* - a source link becomes its text, then what it pointed at in code;
|
|
2610
|
+
* - a diagram attachment leaves the diagram and its lines follow it as
|
|
2611
|
+
* a `code` block;
|
|
2612
|
+
* - a step keeps its label and takes the lines as its code;
|
|
2613
|
+
* - a call stack or database lens becomes a line saying what it was,
|
|
2614
|
+
* then a `code` block for each range it quoted, in any repository; the
|
|
2615
|
+
* first takes its id. A lens that quotes only its own repository is
|
|
2616
|
+
* kept.
|
|
2617
|
+
* New blocks take ids from `allocate`.
|
|
2618
|
+
*/
|
|
2619
|
+
function snapshotForeignReferences(document, own, read, repositoryName, allocate) {
|
|
2620
|
+
const foreign = (pins) => isJsonObject(pins) && isStringValue(pins.repositoryId) && pins.repositoryId !== own ? pins : void 0;
|
|
2621
|
+
const caption = (pins, anchor) => {
|
|
2622
|
+
const selection = parseAnchor(anchor);
|
|
2623
|
+
const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
|
|
2624
|
+
const range = selection ? `${selection.file}:${selection.start.line}-${selection.end.line}` : anchor;
|
|
2625
|
+
return `${repositoryName(pins.repositoryId)} · ${range} @ ${commit.slice(0, 7)}`;
|
|
2626
|
+
};
|
|
2627
|
+
const snapshot = (id, pins, anchor) => {
|
|
2628
|
+
const selection = parseAnchor(anchor);
|
|
2629
|
+
const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
|
|
2630
|
+
const file = selection ? read(pins.repositoryId, commit, selection.file) : void 0;
|
|
2631
|
+
const label = caption(pins, anchor);
|
|
2632
|
+
const text = file !== void 0 && selection ? file.split("\n").slice(selection.start.line - 1, selection.end.line).join("\n") : `${label}\n(source unavailable)`;
|
|
2633
|
+
return {
|
|
2634
|
+
type: "code",
|
|
2635
|
+
...isStringValue(id) && { id },
|
|
2636
|
+
language: selection ? LANGUAGES.get(path.extname(selection.file).toLowerCase()) ?? "text" : "text",
|
|
2637
|
+
text,
|
|
2638
|
+
caption: label
|
|
2639
|
+
};
|
|
2640
|
+
};
|
|
2641
|
+
const pinsIn = (pins) => {
|
|
2642
|
+
return isJsonObject(pins) && isStringValue(pins.repositoryId) && isStringValue(pins.head) ? {
|
|
2643
|
+
repositoryId: pins.repositoryId,
|
|
2644
|
+
head: pins.head,
|
|
2645
|
+
...isStringValue(pins.base) && { base: pins.base }
|
|
2646
|
+
} : void 0;
|
|
2647
|
+
};
|
|
2648
|
+
const pinsOf = (value) => pinsIn(foreign(value));
|
|
2649
|
+
const peeks = (blocks) => blocks.map((block) => {
|
|
2650
|
+
if (!isJsonObject(block)) return block;
|
|
2651
|
+
if (Array.isArray(block.children)) return {
|
|
2652
|
+
...block,
|
|
2653
|
+
children: peeks(block.children)
|
|
2654
|
+
};
|
|
2655
|
+
const pins = pinsOf(block.pins);
|
|
2656
|
+
if (block.type === "code_peek" && pins && isStringValue(block.source)) return snapshot(block.id, pins, block.source);
|
|
2657
|
+
return block;
|
|
2658
|
+
});
|
|
2659
|
+
const rest = (blocks) => blocks.flatMap((block) => {
|
|
2660
|
+
if (!isJsonObject(block)) return block;
|
|
2661
|
+
if (Array.isArray(block.children)) return {
|
|
2662
|
+
...block,
|
|
2663
|
+
children: rest(block.children)
|
|
2664
|
+
};
|
|
2665
|
+
const blockPins = pinsOf(block.pins);
|
|
2666
|
+
if (block.type === "markdown" && blockPins && isStringValue(block.markdown)) {
|
|
2667
|
+
const { pins: _pins, ...kept } = block;
|
|
2668
|
+
return {
|
|
2669
|
+
...kept,
|
|
2670
|
+
markdown: unlinkSourceLinks(block.markdown, blockPins)
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
if (block.type === "sequence" && Array.isArray(block.steps)) return {
|
|
2674
|
+
...block,
|
|
2675
|
+
steps: block.steps.map((step) => {
|
|
2676
|
+
if (!isJsonObject(step) || !isStringValue(step.source)) return step;
|
|
2677
|
+
const pins = pinsOf(step.pins ?? block.pins);
|
|
2678
|
+
if (!pins) return step;
|
|
2679
|
+
const { source, pins: _pins, ...kept } = step;
|
|
2680
|
+
const quoted = snapshot(void 0, pins, source);
|
|
2681
|
+
return isJsonObject(quoted) ? {
|
|
2682
|
+
...kept,
|
|
2683
|
+
code: {
|
|
2684
|
+
language: quoted.language,
|
|
2685
|
+
text: quoted.text
|
|
2686
|
+
}
|
|
2687
|
+
} : kept;
|
|
2688
|
+
})
|
|
2689
|
+
};
|
|
2690
|
+
if (block.type === "flow_diagram" && Array.isArray(block.nodes)) {
|
|
2691
|
+
const moved = [];
|
|
2692
|
+
return [{
|
|
2693
|
+
...block,
|
|
2694
|
+
nodes: block.nodes.map((node) => isJsonObject(node) && Array.isArray(node.attachments) ? {
|
|
2695
|
+
...node,
|
|
2696
|
+
attachments: node.attachments.filter((attachment) => {
|
|
2697
|
+
if (!isJsonObject(attachment) || !pinsOf(attachment.pins ?? block.pins)) return true;
|
|
2698
|
+
for (const [pins, anchor] of quotes(attachment, pinsIn, block.pins)) moved.push(snapshot(allocate("block"), pins, anchor));
|
|
2699
|
+
return false;
|
|
2700
|
+
})
|
|
2701
|
+
} : node)
|
|
2702
|
+
}, ...moved];
|
|
2703
|
+
}
|
|
2704
|
+
if (block.type === "call_stack_diff" || block.type === "database_lens") {
|
|
2705
|
+
const foreignQuote = quotes(block, pinsOf)[0];
|
|
2706
|
+
if (!foreignQuote) return block;
|
|
2707
|
+
const what = block.type === "call_stack_diff" ? "Call stack" : "Database lens";
|
|
2708
|
+
const title = isStringValue(block.title) ? ` “${block.title}”` : "";
|
|
2709
|
+
return [{
|
|
2710
|
+
type: "markdown",
|
|
2711
|
+
id: allocate("block"),
|
|
2712
|
+
markdown: `${what}${title} (from ${repositoryName(foreignQuote[0].repositoryId)}), kept as snapshots below.`
|
|
2713
|
+
}, ...quotes(block, pinsIn).map(([pins, anchor], index) => snapshot(index === 0 ? block.id : allocate("block"), pins, anchor))];
|
|
2714
|
+
}
|
|
2715
|
+
return block;
|
|
2716
|
+
});
|
|
2717
|
+
/** The ranges a block's frames or operations quote at the pins `read`
|
|
2718
|
+
* accepts. */
|
|
2719
|
+
function quotes(block, read, inherited) {
|
|
2720
|
+
if (Array.isArray(block)) return block.flatMap((item) => quotes(item, read, inherited));
|
|
2721
|
+
if (!isJsonObject(block)) return [];
|
|
2722
|
+
const scope = block.pins ?? inherited;
|
|
2723
|
+
const pins = read(scope);
|
|
2724
|
+
return [...pins ? [
|
|
2725
|
+
block.source,
|
|
2726
|
+
block.callSite,
|
|
2727
|
+
...Array.isArray(block.contextSources) ? block.contextSources : [],
|
|
2728
|
+
...Array.isArray(block.sources) ? block.sources : []
|
|
2729
|
+
].flatMap((anchor) => isStringValue(anchor) ? [[pins, anchor]] : []) : [], ...Object.entries(block).filter(([key]) => key !== "pins").flatMap(([, value]) => Array.isArray(value) || isJsonObject(value) ? quotes(value, read, scope) : [])];
|
|
2730
|
+
}
|
|
2731
|
+
function unlinkSourceLinks(markdown, pins) {
|
|
2732
|
+
let result = markdown;
|
|
2733
|
+
const links = [...markdownNodes(parseMarkdown(markdown))].filter((node) => node.type === "link" && SOURCE_LINK.test(node.url ?? "") && node.position?.start.offset !== void 0 && node.position.end.offset !== void 0).reverse();
|
|
2734
|
+
for (const node of links) result = result.slice(0, node.position.start.offset) + `${markdownText(node)} (\`${caption(pins, node.url.replace(SOURCE_LINK, ""))}\`)` + result.slice(node.position.end.offset);
|
|
2735
|
+
return result;
|
|
2736
|
+
}
|
|
2737
|
+
return rest(peeks(document));
|
|
2738
|
+
}
|
|
2739
|
+
//#endregion
|
|
1957
2740
|
//#region src/review-api/store-schema.ts
|
|
2741
|
+
/** A board version's repository in SQL over `versions.snapshot`, as
|
|
2742
|
+
* `boardRepositoryId` reads it: its target's, else its pins'. */
|
|
2743
|
+
const BOARD_REPOSITORY_ID_SQL = "CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END";
|
|
2744
|
+
const columnsOf = (db, table) => new Set(db.prepare("SELECT name FROM pragma_table_info(?)").all(table).map(({ name }) => String(name)));
|
|
1958
2745
|
function initializeReviewStoreSchema(db) {
|
|
1959
2746
|
db.exec(`
|
|
1960
2747
|
PRAGMA journal_mode=WAL;
|
|
1961
2748
|
PRAGMA foreign_keys=ON;
|
|
1962
|
-
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL
|
|
2749
|
+
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
|
|
2750
|
+
project_id TEXT NOT NULL REFERENCES projects(id), parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL);
|
|
1963
2751
|
CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
|
|
2752
|
+
thread_id TEXT, author_kind TEXT,
|
|
1964
2753
|
PRIMARY KEY(review_id,version));
|
|
1965
|
-
DROP TABLE IF EXISTS receipts;
|
|
1966
2754
|
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
|
|
1967
|
-
CREATE TABLE IF NOT EXISTS
|
|
2755
|
+
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
|
2756
|
+
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
2757
|
+
project_id TEXT REFERENCES projects(id));
|
|
1968
2758
|
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
|
|
1969
2759
|
kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
|
|
1970
2760
|
CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
|
|
1971
2761
|
PRIMARY KEY(review_id,file));
|
|
1972
|
-
DROP TABLE IF EXISTS review_viewed;
|
|
1973
2762
|
CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
|
|
1974
2763
|
CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
|
|
1975
|
-
|
|
2764
|
+
CREATE TABLE IF NOT EXISTS board_dependencies(review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
2765
|
+
depends_on TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, PRIMARY KEY(review_id,depends_on));
|
|
2766
|
+
CREATE INDEX IF NOT EXISTS board_dependencies_depends_on ON board_dependencies(depends_on);
|
|
2767
|
+
-- A link goes with the board it is on. Where it points stays plain
|
|
2768
|
+
-- text, no foreign key: a link to a deleted board stays, and reads
|
|
2769
|
+
-- "Board deleted" from the deleted_boards tombstone.
|
|
2770
|
+
CREATE TABLE IF NOT EXISTS board_links(from_review TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, from_block TEXT NOT NULL,
|
|
2771
|
+
to_review TEXT NOT NULL, to_block TEXT);
|
|
2772
|
+
CREATE INDEX IF NOT EXISTS board_links_from ON board_links(from_review);
|
|
2773
|
+
CREATE INDEX IF NOT EXISTS board_links_to ON board_links(to_review);
|
|
2774
|
+
CREATE TABLE IF NOT EXISTS deleted_boards(id TEXT PRIMARY KEY, title TEXT NOT NULL, deleted_at TEXT NOT NULL,
|
|
2775
|
+
project_id TEXT);
|
|
1976
2776
|
`);
|
|
1977
|
-
|
|
2777
|
+
}
|
|
2778
|
+
//#endregion
|
|
2779
|
+
//#region src/review-api/scratchpad-plan-upgrade.ts
|
|
2780
|
+
const OLD_SCRATCHPAD_ID = "scratchpad";
|
|
2781
|
+
const hasScratchpadVersions = ({ db }) => Boolean(db.prepare("SELECT 1 FROM versions WHERE review_id=? LIMIT 1").get(OLD_SCRATCHPAD_ID));
|
|
2782
|
+
/** A pad version's snapshot with its document in today's form (old pads
|
|
2783
|
+
* held anchors as objects); undefined for one that won't parse. */
|
|
2784
|
+
function parsePadVersion(text) {
|
|
1978
2785
|
try {
|
|
1979
|
-
|
|
2786
|
+
const snapshot = JSON.parse(text);
|
|
2787
|
+
const document = migrateStoredDocument(snapshot.document);
|
|
2788
|
+
snapshot.document = Array.isArray(document) ? document : [];
|
|
2789
|
+
return snapshot;
|
|
2790
|
+
} catch {
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
/** Each pad version as parsed, by version, for one open. The run, inside
|
|
2795
|
+
* the write lock, reads the rows again: it reuses a parse only of text that
|
|
2796
|
+
* hasn't changed since. */
|
|
2797
|
+
const parsedPads = /* @__PURE__ */ new WeakMap();
|
|
2798
|
+
function padVersion(context, version, text) {
|
|
2799
|
+
let parsed = parsedPads.get(context);
|
|
2800
|
+
if (!parsed) parsedPads.set(context, parsed = /* @__PURE__ */ new Map());
|
|
2801
|
+
const known = parsed.get(version);
|
|
2802
|
+
if (known?.text === text) return known.snapshot;
|
|
2803
|
+
const snapshot = parsePadVersion(text);
|
|
2804
|
+
parsed.set(version, {
|
|
2805
|
+
text,
|
|
2806
|
+
snapshot
|
|
2807
|
+
});
|
|
2808
|
+
return snapshot;
|
|
2809
|
+
}
|
|
2810
|
+
/** The pad's versions, oldest first, each parsed once per open. */
|
|
2811
|
+
const scratchpadVersions = (context) => context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version").all(OLD_SCRATCHPAD_ID).map((row) => ({
|
|
2812
|
+
version: Number(row.version),
|
|
2813
|
+
snapshot: padVersion(context, Number(row.version), String(row.snapshot))
|
|
2814
|
+
}));
|
|
2815
|
+
/** The pad's newest version that parses, reading no further back. */
|
|
2816
|
+
function latestScratchpadDocument(context) {
|
|
2817
|
+
for (const row of context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version DESC").iterate(OLD_SCRATCHPAD_ID)) {
|
|
2818
|
+
const snapshot = padVersion(context, Number(row.version), String(row.snapshot));
|
|
2819
|
+
if (snapshot) return snapshot.document;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
/** The checkout the pad quotes most, else the one opened last. Only a
|
|
2823
|
+
* checkout that still exists: a plan opens on it. */
|
|
2824
|
+
function scratchpadRepository(context) {
|
|
2825
|
+
const exists = (repositoryId) => {
|
|
2826
|
+
try {
|
|
2827
|
+
return existsSync(context.repositoryPath(repositoryId));
|
|
2828
|
+
} catch {
|
|
2829
|
+
return false;
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2833
|
+
const latest = latestScratchpadDocument(context);
|
|
2834
|
+
for (const { source } of sourceReferences(latest ?? [], { tolerant: true })) {
|
|
2835
|
+
const repositoryId = source.pins?.repositoryId;
|
|
2836
|
+
if (repositoryId && exists(repositoryId)) counts.set(repositoryId, (counts.get(repositoryId) ?? 0) + 1);
|
|
2837
|
+
}
|
|
2838
|
+
const [quoted] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
2839
|
+
if (quoted) return quoted;
|
|
2840
|
+
return [...context.db.prepare(`SELECT ${BOARD_REPOSITORY_ID_SQL} AS repository
|
|
2841
|
+
FROM review_attention
|
|
2842
|
+
JOIN reviews ON reviews.id=review_attention.review_id
|
|
2843
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2844
|
+
WHERE review_attention.viewed_at IS NOT NULL AND repository IS NOT NULL
|
|
2845
|
+
ORDER BY review_attention.viewed_at DESC`).all().map(({ repository }) => String(repository)), ...context.repositories().map(({ id }) => id)].find(exists);
|
|
2846
|
+
}
|
|
2847
|
+
const snapshots = (context, own, document, allocate) => snapshotForeignReferences(document, own, context.fileAtCommit, context.repositoryName, allocate);
|
|
2848
|
+
/** Reads the files the pad quotes, which the context keeps, before the
|
|
2849
|
+
* write lock other hosts on this home wait on. */
|
|
2850
|
+
function readScratchpadQuotes(context) {
|
|
2851
|
+
if (!hasScratchpadVersions(context)) return;
|
|
2852
|
+
const quoting = scratchpadRepository(context);
|
|
2853
|
+
if (quoting) {
|
|
2854
|
+
for (const { snapshot } of scratchpadVersions(context)) if (snapshot) snapshots(context, quoting, snapshot.document, (prefix) => prefix);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
function planFromScratchpad(context) {
|
|
2858
|
+
const { db } = context;
|
|
2859
|
+
if (!hasScratchpadVersions(context)) return;
|
|
2860
|
+
const own = scratchpadRepository(context);
|
|
2861
|
+
const id = randomUUID();
|
|
2862
|
+
db.prepare("INSERT INTO reviews(id,version,next_id,project_id) SELECT ?,version,next_id,? FROM reviews WHERE id=?").run(id, own ? context.projectOfRepository(own) : context.fallbackProject(), OLD_SCRATCHPAD_ID);
|
|
2863
|
+
let nextId = Number(db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id);
|
|
2864
|
+
const old = scratchpadVersions(context);
|
|
2865
|
+
parsedPads.delete(context);
|
|
2866
|
+
db.prepare("UPDATE versions SET review_id=? WHERE review_id=?").run(id, OLD_SCRATCHPAD_ID);
|
|
2867
|
+
for (const { version, snapshot } of old) {
|
|
2868
|
+
if (!snapshot) continue;
|
|
2869
|
+
delete snapshot.kind;
|
|
2870
|
+
snapshot.reviewId = id;
|
|
2871
|
+
snapshot.title = "Scratchpad";
|
|
2872
|
+
if (own) {
|
|
2873
|
+
snapshot.target = {
|
|
2874
|
+
kind: "project",
|
|
2875
|
+
repositoryId: own
|
|
2876
|
+
};
|
|
2877
|
+
snapshot.document = snapshots(context, own, snapshot.document, (prefix) => `${prefix}-${++nextId}`);
|
|
2878
|
+
}
|
|
2879
|
+
context.rewriteVersion(id, version, snapshot);
|
|
2880
|
+
}
|
|
2881
|
+
db.prepare("UPDATE reviews SET next_id=? WHERE id=?").run(nextId, id);
|
|
2882
|
+
for (const [table, ...names] of [
|
|
2883
|
+
[
|
|
2884
|
+
"ask_conversations",
|
|
2885
|
+
"review_id",
|
|
2886
|
+
"anchor_board"
|
|
2887
|
+
],
|
|
2888
|
+
["authoring_presences", "review_id"],
|
|
2889
|
+
["review_coverage", "review_id"],
|
|
2890
|
+
["review_attention", "review_id"]
|
|
2891
|
+
]) {
|
|
2892
|
+
const columns = columnsOf(db, table);
|
|
2893
|
+
const column = names.find((name) => columns.has(name));
|
|
2894
|
+
if (column) db.prepare(`UPDATE ${table} SET ${column}=? WHERE ${column}=?`).run(id, OLD_SCRATCHPAD_ID);
|
|
2895
|
+
}
|
|
2896
|
+
db.prepare("DELETE FROM reviews WHERE id=?").run(OLD_SCRATCHPAD_ID);
|
|
2897
|
+
}
|
|
2898
|
+
//#endregion
|
|
2899
|
+
//#region src/review-api/store-migrations.ts
|
|
2900
|
+
/** Keep versions.author_kind to its values, as a CHECK would. */
|
|
2901
|
+
const VERSION_AUTHOR_TRIGGERS = `
|
|
2902
|
+
CREATE TRIGGER IF NOT EXISTS versions_author_kind_insert BEFORE INSERT ON versions
|
|
2903
|
+
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
2904
|
+
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;
|
|
2905
|
+
CREATE TRIGGER IF NOT EXISTS versions_author_kind_update BEFORE UPDATE OF author_kind ON versions
|
|
2906
|
+
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
2907
|
+
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;`;
|
|
2908
|
+
const STORE_UPGRADES = [
|
|
2909
|
+
{
|
|
2910
|
+
version: 1,
|
|
2911
|
+
name: "retired-tables",
|
|
2912
|
+
run: ({ db }) => db.exec(`DROP TABLE IF EXISTS receipts;
|
|
2913
|
+
DROP TABLE IF EXISTS review_viewed;
|
|
2914
|
+
DROP TABLE IF EXISTS authoring_drafts;
|
|
2915
|
+
DROP TABLE IF EXISTS authoring_sessions;`)
|
|
2916
|
+
},
|
|
2917
|
+
{
|
|
2918
|
+
version: 2,
|
|
2919
|
+
name: "archive-to-trash",
|
|
2920
|
+
run: ({ db }) => {
|
|
2921
|
+
if (!columnsOf(db, "review_attention").has("dismissed_at")) return;
|
|
1980
2922
|
db.exec("ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at");
|
|
1981
2923
|
db.prepare("UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL").run((/* @__PURE__ */ new Date()).toISOString());
|
|
1982
2924
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2925
|
+
},
|
|
2926
|
+
{
|
|
2927
|
+
version: 3,
|
|
2928
|
+
name: "board-projects",
|
|
2929
|
+
groupsCheckouts: true,
|
|
2930
|
+
foreignKeysOff: true,
|
|
2931
|
+
run: ({ db, repositories, projectOfRepository, firstRepositoryProject }) => {
|
|
2932
|
+
const parents = () => db.exec("CREATE INDEX IF NOT EXISTS reviews_parent ON reviews(parent_id)");
|
|
2933
|
+
if (columnsOf(db, "reviews").has("project_id")) return parents();
|
|
2934
|
+
if (!columnsOf(db, "repositories").has("project_id")) db.exec("ALTER TABLE repositories ADD COLUMN project_id TEXT REFERENCES projects(id)");
|
|
2935
|
+
const boards = db.prepare(`SELECT reviews.id, CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END AS repository_id,
|
|
2936
|
+
review_attention.viewed_at
|
|
2937
|
+
FROM reviews LEFT JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2938
|
+
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
2939
|
+
ORDER BY reviews.rowid`).all();
|
|
2940
|
+
const registered = new Set(repositories().map(({ id }) => id));
|
|
2941
|
+
const projects = /* @__PURE__ */ new Map();
|
|
2942
|
+
for (const { id, repository_id: repository } of boards) if (repository && registered.has(String(repository))) projects.set(String(id), projectOfRepository(String(repository)));
|
|
2943
|
+
const [opened] = boards.filter(({ id, viewed_at }) => viewed_at && projects.has(String(id))).sort((a, b) => String(b.viewed_at).localeCompare(String(a.viewed_at)));
|
|
2944
|
+
let fallback = opened && projects.get(String(opened.id));
|
|
2945
|
+
const brokenReferences = () => db.prepare("PRAGMA foreign_key_check").all().filter(({ parent }) => parent === "reviews").length;
|
|
2946
|
+
const broken = brokenReferences();
|
|
2947
|
+
db.exec(`CREATE TABLE reviews_with_projects(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
|
|
2948
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
2949
|
+
parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL)`);
|
|
2950
|
+
const insert = db.prepare("INSERT INTO reviews_with_projects(id,version,next_id,project_id) SELECT id,version,next_id,? FROM reviews WHERE id=?");
|
|
2951
|
+
for (const { id } of boards) insert.run(projects.get(String(id)) ?? (fallback ??= firstRepositoryProject()), String(id));
|
|
2952
|
+
db.exec(`DROP TABLE reviews;
|
|
2953
|
+
ALTER TABLE reviews_with_projects RENAME TO reviews;`);
|
|
2954
|
+
parents();
|
|
2955
|
+
if (brokenReferences() > broken) throw new Error("Giving boards projects broke a reference.");
|
|
2956
|
+
}
|
|
2957
|
+
},
|
|
2958
|
+
{
|
|
2959
|
+
version: 4,
|
|
2960
|
+
name: "scratchpad-plan",
|
|
2961
|
+
beforeLock: readScratchpadQuotes,
|
|
2962
|
+
run: planFromScratchpad
|
|
2963
|
+
},
|
|
2964
|
+
{
|
|
2965
|
+
version: 5,
|
|
2966
|
+
name: "ask-threads-by-project",
|
|
2967
|
+
run: ({ db }) => {
|
|
2968
|
+
db.exec("CREATE INDEX IF NOT EXISTS reviews_project ON reviews(project_id)");
|
|
2969
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
2970
|
+
if (columns.size === 0) return;
|
|
2971
|
+
if (columns.has("review_id")) db.exec("ALTER TABLE ask_conversations RENAME COLUMN review_id TO anchor_board");
|
|
2972
|
+
if (!columns.has("anchor_block")) db.exec("ALTER TABLE ask_conversations ADD COLUMN anchor_block TEXT");
|
|
2973
|
+
}
|
|
2974
|
+
},
|
|
2975
|
+
{
|
|
2976
|
+
version: 6,
|
|
2977
|
+
name: "version-threads",
|
|
2978
|
+
run: ({ db }) => {
|
|
2979
|
+
if (!columnsOf(db, "versions").has("thread_id")) db.exec("ALTER TABLE versions ADD COLUMN thread_id TEXT");
|
|
2980
|
+
db.exec("CREATE INDEX IF NOT EXISTS versions_thread_id ON versions(thread_id)");
|
|
2981
|
+
}
|
|
2982
|
+
},
|
|
2983
|
+
{
|
|
2984
|
+
version: 7,
|
|
2985
|
+
name: "viewed-on-upgrade",
|
|
2986
|
+
run: ({ db }) => db.exec(`INSERT OR IGNORE INTO review_attention(review_id) SELECT id FROM reviews;
|
|
2987
|
+
UPDATE review_attention SET viewed_at=COALESCE(NULLIF((
|
|
2988
|
+
SELECT MAX(COALESCE(review_attention.viewed_at, ''), COALESCE(CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.createdAt') END, ''))
|
|
2989
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2990
|
+
WHERE reviews.id=review_attention.review_id), ''), review_attention.viewed_at)
|
|
2991
|
+
WHERE review_id IN (SELECT id FROM reviews);`)
|
|
2992
|
+
},
|
|
2993
|
+
{
|
|
2994
|
+
version: 8,
|
|
2995
|
+
name: "ask-thread-viewed",
|
|
2996
|
+
run: ({ db }) => {
|
|
2997
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
2998
|
+
if (columns.size > 0 && !columns.has("viewed_at")) db.exec("ALTER TABLE ask_conversations ADD COLUMN viewed_at TEXT");
|
|
2999
|
+
}
|
|
3000
|
+
},
|
|
3001
|
+
{
|
|
3002
|
+
version: 9,
|
|
3003
|
+
name: "version-authors",
|
|
3004
|
+
run: ({ db }) => {
|
|
3005
|
+
if (!columnsOf(db, "versions").has("author_kind")) db.exec("ALTER TABLE versions ADD COLUMN author_kind TEXT");
|
|
3006
|
+
db.exec(VERSION_AUTHOR_TRIGGERS);
|
|
3007
|
+
}
|
|
3008
|
+
},
|
|
3009
|
+
{
|
|
3010
|
+
version: 10,
|
|
3011
|
+
name: "ask-permissions",
|
|
3012
|
+
run: ({ db }) => {
|
|
3013
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
3014
|
+
if (columns.size > 0 && !columns.has("permissions")) db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
];
|
|
3018
|
+
STORE_UPGRADES.at(-1).version;
|
|
3019
|
+
const storeVersion = (db) => Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
3020
|
+
/** Runs every step the store hasn't, each in its own transaction with the
|
|
3021
|
+
* version it reaches, so a failed step rolls back alone and stops the store
|
|
3022
|
+
* opening, naming the step; the ones before it stay done. A store at a
|
|
3023
|
+
* later version than this build knows is left as it is. */
|
|
3024
|
+
function upgradeStore(context) {
|
|
3025
|
+
const { db } = context;
|
|
3026
|
+
for (const step of STORE_UPGRADES) {
|
|
3027
|
+
if (storeVersion(db) >= step.version) continue;
|
|
3028
|
+
if (step.groupsCheckouts) context.prepareCheckouts();
|
|
3029
|
+
step.beforeLock?.(context);
|
|
3030
|
+
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=OFF");
|
|
3031
|
+
db.exec("BEGIN IMMEDIATE");
|
|
3032
|
+
try {
|
|
3033
|
+
if (storeVersion(db) < step.version) {
|
|
3034
|
+
step.run(context);
|
|
3035
|
+
db.exec(`PRAGMA user_version=${step.version}`);
|
|
3036
|
+
}
|
|
3037
|
+
db.exec("COMMIT");
|
|
3038
|
+
} catch (error) {
|
|
3039
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
3040
|
+
throw new Error(`Could not upgrade the whiteboard store: step ${step.version} (${step.name}) failed and was rolled back. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
3041
|
+
} finally {
|
|
3042
|
+
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=ON");
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
/** How the store's checkouts group, worked out before it opens, asking
|
|
3047
|
+
* several repositories at once, when an upgrade still to run needs it;
|
|
3048
|
+
* undefined otherwise, or when the store can't be read so, and the step
|
|
3049
|
+
* works it out itself. */
|
|
3050
|
+
async function groupCheckoutsAhead(databasePath) {
|
|
3051
|
+
if (!existsSync(databasePath)) return void 0;
|
|
3052
|
+
let db;
|
|
3053
|
+
try {
|
|
3054
|
+
db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
3055
|
+
const version = storeVersion(db);
|
|
3056
|
+
if (!STORE_UPGRADES.some((step) => step.groupsCheckouts && step.version > version) || !columnsOf(db, "repositories").size) return void 0;
|
|
3057
|
+
const groups = new CheckoutGroups(db);
|
|
3058
|
+
await groups.prepareConcurrently();
|
|
3059
|
+
return groups.prepared;
|
|
3060
|
+
} catch {
|
|
3061
|
+
return;
|
|
3062
|
+
} finally {
|
|
3063
|
+
db?.close();
|
|
1987
3064
|
}
|
|
1988
3065
|
}
|
|
1989
3066
|
//#endregion
|
|
@@ -1991,17 +3068,11 @@ function initializeReviewStoreSchema(db) {
|
|
|
1991
3068
|
const reviewId = z.string().min(1);
|
|
1992
3069
|
/** The key older hosts read and write. */
|
|
1993
3070
|
const statsKey = (pins) => JSON.stringify([pins, "structural"]);
|
|
1994
|
-
/**
|
|
1995
|
-
const
|
|
1996
|
-
const DIAGRAM_TYPES = /* @__PURE__ */ new Set([
|
|
1997
|
-
"sequence",
|
|
1998
|
-
"flow_diagram",
|
|
1999
|
-
"call_stack_diff",
|
|
2000
|
-
"database_lens",
|
|
2001
|
-
"software_map"
|
|
2002
|
-
]);
|
|
2003
|
-
const SCRATCHPAD_TITLE = "Scratchpad";
|
|
3071
|
+
/** How long a board stays in Trash before it is deleted. */
|
|
3072
|
+
const TRASH_DAYS = 30;
|
|
2004
3073
|
const activityId = z.string().min(1).optional().describe("The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.");
|
|
3074
|
+
const parentId = reviewId.describe("The reviewId of a top-level review in the same project to nest this one under.");
|
|
3075
|
+
const dependsOn = z.array(reviewId).describe("The reviewIds of reviews in the same project this one builds on. Siblings list after what they depend on.");
|
|
2005
3076
|
const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
2006
3077
|
z.strictObject({
|
|
2007
3078
|
type: z.literal("delete"),
|
|
@@ -2019,16 +3090,29 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
|
2019
3090
|
}),
|
|
2020
3091
|
z.strictObject({
|
|
2021
3092
|
type: z.literal("create"),
|
|
2022
|
-
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
2023
|
-
title
|
|
3093
|
+
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
3094
|
+
* title. A person's new board may start with none; it reads
|
|
3095
|
+
* "Untitled" until they name it. */
|
|
3096
|
+
title: z.string().trim().optional(),
|
|
2024
3097
|
target: reviewTargetSchema.optional(),
|
|
2025
3098
|
pullRequestUrl: pullRequestUrl.optional(),
|
|
2026
3099
|
/** With pullRequestUrl and no target: the checkout to fetch the PR into. */
|
|
2027
|
-
repositoryId: z.string().min(1).optional().describe("Only with pullRequestUrl and no target: the registered checkout to fetch the PR into.
|
|
3100
|
+
repositoryId: z.string().min(1).optional().describe("Only with pullRequestUrl and no target: the registered checkout to fetch the PR into."),
|
|
2028
3101
|
/** Return the existing review for pullRequestUrl instead of creating one. */
|
|
2029
3102
|
reuseExisting: z.boolean().optional().describe("Default true: return the existing review for pullRequestUrl. false creates a separate review."),
|
|
2030
|
-
|
|
2031
|
-
|
|
3103
|
+
projectId: z.string().min(1).optional().describe("The project to file the whiteboard in: a project.id from session_list. Defaults to its checkout's."),
|
|
3104
|
+
parentId: parentId.optional(),
|
|
3105
|
+
dependsOn: dependsOn.optional()
|
|
3106
|
+
}),
|
|
3107
|
+
z.strictObject({
|
|
3108
|
+
type: z.literal("set_parent"),
|
|
3109
|
+
reviewId,
|
|
3110
|
+
parentId: parentId.nullable()
|
|
3111
|
+
}),
|
|
3112
|
+
z.strictObject({
|
|
3113
|
+
type: z.literal("set_dependencies"),
|
|
3114
|
+
reviewId,
|
|
3115
|
+
dependsOn
|
|
2032
3116
|
}),
|
|
2033
3117
|
z.strictObject({
|
|
2034
3118
|
type: z.literal("set_target"),
|
|
@@ -2059,12 +3143,60 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
|
2059
3143
|
version: z.number().int().nonnegative()
|
|
2060
3144
|
})
|
|
2061
3145
|
]) });
|
|
3146
|
+
/** A person's save of a whole board from the canvas. A block they added
|
|
3147
|
+
* has no id and may carry a clientKey, which the response maps to its id. */
|
|
3148
|
+
const personEditSchema = z.strictObject({
|
|
3149
|
+
baseVersion: z.number().int().nonnegative(),
|
|
3150
|
+
/** Empty until the person names the board; it reads "Untitled". */
|
|
3151
|
+
title: z.string().trim(),
|
|
3152
|
+
document: z.array(z.json())
|
|
3153
|
+
});
|
|
3154
|
+
/** The block a person's canvas has focus in; null clears it. */
|
|
3155
|
+
const focusLeaseSchema = z.strictObject({
|
|
3156
|
+
clientId: z.string().min(1),
|
|
3157
|
+
blockId: z.string().min(1).nullable(),
|
|
3158
|
+
/** false when the canvas closes the board. */
|
|
3159
|
+
open: z.boolean().optional()
|
|
3160
|
+
});
|
|
3161
|
+
/** A focus lease lasts this long unless renewed. The canvas renews it every
|
|
3162
|
+
* few seconds, so a closed or crashed app releases its block on its own. */
|
|
3163
|
+
const FOCUS_LEASE_TTL_MS = 15e3;
|
|
3164
|
+
/** How often a discard looks again for a canvas letting go of the board. */
|
|
3165
|
+
const DISCARD_POLL_MS = 100;
|
|
3166
|
+
/**
|
|
3167
|
+
* The project of a registered checkout, made on first use. Checkouts of one
|
|
3168
|
+
* repository (worktrees, or clones of one GitHub repository) share it. A
|
|
3169
|
+
* deleted checkout joins the project of the existing one it belonged to
|
|
3170
|
+
* (see `CheckoutGroups.ownerOf`), else gets its own.
|
|
3171
|
+
*/
|
|
3172
|
+
function projectOfCheckout(db, repositoryId, groups = new CheckoutGroups(db)) {
|
|
3173
|
+
const { path: root, name, project_id: projectOf } = db.prepare("SELECT path, name, project_id FROM repositories WHERE id=?").get(repositoryId);
|
|
3174
|
+
if (projectOf) return String(projectOf);
|
|
3175
|
+
const join = (projectId) => {
|
|
3176
|
+
db.prepare("UPDATE repositories SET project_id=? WHERE id=?").run(projectId, repositoryId);
|
|
3177
|
+
return projectId;
|
|
3178
|
+
};
|
|
3179
|
+
if (!existsSync(String(root))) {
|
|
3180
|
+
const owner = groups.ownerOf(repositoryId);
|
|
3181
|
+
if (owner) return join(projectOfCheckout(db, owner, groups));
|
|
3182
|
+
}
|
|
3183
|
+
const group = groups.group(String(root));
|
|
3184
|
+
const sibling = group && db.prepare("SELECT project_id, path FROM repositories WHERE project_id IS NOT NULL").all().find(({ path: checkout }) => groups.group(String(checkout))?.key === group.key);
|
|
3185
|
+
if (sibling) return join(String(sibling.project_id));
|
|
3186
|
+
const projectId = randomUUID();
|
|
3187
|
+
db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, group?.label ?? String(name));
|
|
3188
|
+
return join(projectId);
|
|
3189
|
+
}
|
|
3190
|
+
/** The Ask thread a version names, on its edit or on itself; stored in
|
|
3191
|
+
* `versions.thread_id` so a thread's boards are one index away. */
|
|
3192
|
+
const versionThread = (snapshot) => snapshot.lastEdit?.threadId ?? snapshot.threadId ?? null;
|
|
2062
3193
|
/** One instance owned by the desktop server. All writers go through execute().
|
|
2063
3194
|
* The queue includes async validation; SQLite transactions contain only writes.
|
|
2064
3195
|
* This prototype uses a new, explicitly supplied database, never an existing profile.
|
|
2065
3196
|
*/
|
|
2066
3197
|
var ReviewStore = class {
|
|
2067
3198
|
providers;
|
|
3199
|
+
checkoutOwners;
|
|
2068
3200
|
activity;
|
|
2069
3201
|
askHistory;
|
|
2070
3202
|
db;
|
|
@@ -2212,24 +3344,43 @@ var ReviewStore = class {
|
|
|
2212
3344
|
this.catalogListeners.delete(listener);
|
|
2213
3345
|
};
|
|
2214
3346
|
}
|
|
2215
|
-
/** For a host whose listing changed without the store: a preference flip. */
|
|
2216
|
-
invalidateCatalog() {
|
|
2217
|
-
for (const listener of this.catalogListeners) try {
|
|
2218
|
-
listener();
|
|
2219
|
-
} catch {}
|
|
2220
|
-
}
|
|
2221
3347
|
subscribe(listener) {
|
|
2222
3348
|
this.listeners.add(listener);
|
|
2223
3349
|
return () => {
|
|
2224
3350
|
this.listeners.delete(listener);
|
|
2225
3351
|
};
|
|
2226
3352
|
}
|
|
2227
|
-
constructor(databasePath, providers) {
|
|
3353
|
+
constructor(databasePath, providers, checkoutOwners) {
|
|
2228
3354
|
this.providers = providers;
|
|
3355
|
+
this.checkoutOwners = checkoutOwners;
|
|
2229
3356
|
this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
2230
3357
|
initializeReviewStoreSchema(this.db);
|
|
3358
|
+
const files = /* @__PURE__ */ new Map();
|
|
3359
|
+
upgradeStore({
|
|
3360
|
+
db: this.db,
|
|
3361
|
+
prepareCheckouts: () => this.checkouts.prepare(),
|
|
3362
|
+
repositories: () => this.repositories(),
|
|
3363
|
+
repositoryPath: (repositoryId) => this.repositoryPath(repositoryId),
|
|
3364
|
+
repositoryName: (repositoryId) => this.repositoryName(repositoryId),
|
|
3365
|
+
fileAtCommit: (repositoryId, commit, file) => {
|
|
3366
|
+
const key = JSON.stringify([
|
|
3367
|
+
repositoryId,
|
|
3368
|
+
commit,
|
|
3369
|
+
file
|
|
3370
|
+
]);
|
|
3371
|
+
if (!files.has(key)) files.set(key, this.fileAtCommit(repositoryId, commit, file));
|
|
3372
|
+
return files.get(key);
|
|
3373
|
+
},
|
|
3374
|
+
projectOfRepository: (repositoryId) => this.projectOfRepository(repositoryId),
|
|
3375
|
+
fallbackProject: () => this.fallbackProject(),
|
|
3376
|
+
rewriteVersion: (reviewId, version, snapshot) => this.rewriteVersion(reviewId, version, snapshot),
|
|
3377
|
+
firstRepositoryProject: () => {
|
|
3378
|
+
const [first] = this.repositories();
|
|
3379
|
+
return first ? this.projectOfRepository(first.id) : this.fallbackProject();
|
|
3380
|
+
}
|
|
3381
|
+
});
|
|
2231
3382
|
this.activity = new ReviewActivity(this.db, (id) => this.assertWritable(id));
|
|
2232
|
-
this.askHistory = new AskHistory(this.db);
|
|
3383
|
+
this.askHistory = new AskHistory(this.db, (id) => this.assertOutOfTrash(id));
|
|
2233
3384
|
this.purgeExpired();
|
|
2234
3385
|
this.purgeTimer = setInterval(() => this.purgeExpired(), 6e4);
|
|
2235
3386
|
this.purgeTimer.unref();
|
|
@@ -2239,6 +3390,64 @@ var ReviewStore = class {
|
|
|
2239
3390
|
this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
|
|
2240
3391
|
this.externalChanges.unref();
|
|
2241
3392
|
}
|
|
3393
|
+
/** Saves a version over what it was; the current one re-indexes its
|
|
3394
|
+
* board's links, as every written version does. */
|
|
3395
|
+
rewriteVersion(reviewId, version, snapshot) {
|
|
3396
|
+
this.db.prepare("UPDATE versions SET snapshot=? WHERE review_id=? AND version=?").run(JSON.stringify(snapshot), reviewId, version);
|
|
3397
|
+
if (version !== this.currentVersion(reviewId)) return;
|
|
3398
|
+
try {
|
|
3399
|
+
this.indexBoardLinks(reviewId, this.read(reviewId).document);
|
|
3400
|
+
} catch {}
|
|
3401
|
+
}
|
|
3402
|
+
/** The project of a registered checkout, made on first use. Checkouts of
|
|
3403
|
+
* one repository (worktrees, or clones with one origin) share it. */
|
|
3404
|
+
projectOfRepository(repositoryId) {
|
|
3405
|
+
const row = this.db.prepare("SELECT project_id FROM repositories WHERE id=?").get(repositoryId);
|
|
3406
|
+
if (!row) return this.fallbackProject();
|
|
3407
|
+
if (row.project_id) return String(row.project_id);
|
|
3408
|
+
return projectOfCheckout(this.db, repositoryId, this.checkouts);
|
|
3409
|
+
}
|
|
3410
|
+
fallbackProject() {
|
|
3411
|
+
const found = this.db.prepare("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").get();
|
|
3412
|
+
if (found) return String(found.id);
|
|
3413
|
+
const projectId = randomUUID();
|
|
3414
|
+
this.db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, "Whiteboards");
|
|
3415
|
+
return projectId;
|
|
3416
|
+
}
|
|
3417
|
+
/** A checkout's project, as the catalog lists it; undefined once the
|
|
3418
|
+
* checkout is unregistered. */
|
|
3419
|
+
checkoutProject(repositoryId) {
|
|
3420
|
+
if (!this.repositories().some(({ id }) => id === repositoryId)) return void 0;
|
|
3421
|
+
const id = this.projectOfRepository(repositoryId);
|
|
3422
|
+
const { name } = this.db.prepare("SELECT name FROM projects WHERE id=?").get(id);
|
|
3423
|
+
return {
|
|
3424
|
+
id,
|
|
3425
|
+
name: String(name)
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
/** Undefined when the file can't be read. */
|
|
3429
|
+
fileAtCommit(repositoryId, commit, file) {
|
|
3430
|
+
try {
|
|
3431
|
+
return this.checkouts.fileAt(this.repositoryPath(repositoryId), commit, file);
|
|
3432
|
+
} catch {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
/** A checkout's name as its project labels it, else its folder. */
|
|
3437
|
+
repositoryName(repositoryId) {
|
|
3438
|
+
try {
|
|
3439
|
+
const root = this.repositoryPath(repositoryId);
|
|
3440
|
+
return this.repositoryGroup(root)?.label.split("/").at(-1) ?? path.basename(root);
|
|
3441
|
+
} catch {
|
|
3442
|
+
return "another repository";
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
/** A board's project. */
|
|
3446
|
+
projectOf(id) {
|
|
3447
|
+
const row = this.db.prepare("SELECT project_id FROM reviews WHERE id=?").get(id);
|
|
3448
|
+
if (!row) this.assertExists(id);
|
|
3449
|
+
return String(row.project_id);
|
|
3450
|
+
}
|
|
2242
3451
|
dataVersion() {
|
|
2243
3452
|
return Number(this.db.prepare("PRAGMA data_version").get().data_version);
|
|
2244
3453
|
}
|
|
@@ -2260,7 +3469,8 @@ var ReviewStore = class {
|
|
|
2260
3469
|
this.observedVersions = current;
|
|
2261
3470
|
for (const [reviewId, savedVersion] of current) if (previous.get(reviewId) !== savedVersion) this.notify({
|
|
2262
3471
|
reviewId,
|
|
2263
|
-
version: savedVersion
|
|
3472
|
+
version: savedVersion,
|
|
3473
|
+
external: true
|
|
2264
3474
|
});
|
|
2265
3475
|
else if (previousDeletions.get(reviewId) !== deletions.get(reviewId)) this.notify({
|
|
2266
3476
|
reviewId,
|
|
@@ -2306,34 +3516,57 @@ var ReviewStore = class {
|
|
|
2306
3516
|
throw error;
|
|
2307
3517
|
}
|
|
2308
3518
|
}
|
|
2309
|
-
|
|
3519
|
+
checkoutGroups;
|
|
3520
|
+
/** How the store's checkouts group into projects, kept for the open. */
|
|
3521
|
+
get checkouts() {
|
|
3522
|
+
return this.checkoutGroups ??= new CheckoutGroups(this.db, void 0, this.checkoutOwners);
|
|
3523
|
+
}
|
|
2310
3524
|
repositoryGroup(root) {
|
|
2311
|
-
|
|
2312
|
-
if (!existsSync(root)) return void 0;
|
|
2313
|
-
const context = resolveRepoContextSync(root);
|
|
2314
|
-
if (!context) return void 0;
|
|
2315
|
-
const group = context.githubSlug ? {
|
|
2316
|
-
key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
|
|
2317
|
-
label: context.githubSlug
|
|
2318
|
-
} : {
|
|
2319
|
-
key: `git:${context.commonDir}`,
|
|
2320
|
-
label: path.basename(path.dirname(context.commonDir))
|
|
2321
|
-
};
|
|
2322
|
-
this.repositoryGroups.set(root, group);
|
|
2323
|
-
return group;
|
|
3525
|
+
return this.checkouts.group(root);
|
|
2324
3526
|
}
|
|
2325
3527
|
registerRepository(root) {
|
|
2326
3528
|
this.db.prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)").run(randomUUID(), root, root.split(/[\\/]/).at(-1));
|
|
2327
3529
|
const row = this.db.prepare("SELECT id,name FROM repositories WHERE path=?").get(root);
|
|
3530
|
+
this.projectOfRepository(String(row.id));
|
|
2328
3531
|
return {
|
|
2329
3532
|
id: String(row.id),
|
|
2330
3533
|
name: String(row.name)
|
|
2331
3534
|
};
|
|
2332
3535
|
}
|
|
3536
|
+
/** Forgets a checkout no board uses, after every write queued before it,
|
|
3537
|
+
* so a board being made with it keeps it. */
|
|
2333
3538
|
unregisterRepository(id) {
|
|
2334
|
-
this.
|
|
2335
|
-
|
|
2336
|
-
|
|
3539
|
+
const run = this.pending.then(async () => {
|
|
3540
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3541
|
+
try {
|
|
3542
|
+
if (!this.db.prepare(`SELECT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
3543
|
+
OR EXISTS (SELECT 1 FROM resources WHERE repository_id=?) AS used`).get(`"repositoryId":${JSON.stringify(id)}`, id).used) this.db.prepare("DELETE FROM repositories WHERE id=?").run(id);
|
|
3544
|
+
this.db.exec("COMMIT");
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
3547
|
+
throw error;
|
|
3548
|
+
}
|
|
3549
|
+
});
|
|
3550
|
+
this.pending = run.catch(() => {});
|
|
3551
|
+
return run;
|
|
3552
|
+
}
|
|
3553
|
+
/** Checkouts whose remote is cloneUrl, most recently used first. */
|
|
3554
|
+
checkoutsOf(cloneUrl) {
|
|
3555
|
+
const key = remoteGroup(cloneUrl)?.key;
|
|
3556
|
+
if (!key) return [];
|
|
3557
|
+
const used = (repositoryId) => String(this.db.prepare(`SELECT MAX(json_extract(versions.snapshot,'$.createdAt')) AS at
|
|
3558
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
3559
|
+
WHERE json_extract(versions.snapshot,'$.target.repositoryId')=?`).get(repositoryId)?.at ?? "");
|
|
3560
|
+
return this.repositories().filter((repository) => {
|
|
3561
|
+
try {
|
|
3562
|
+
return this.repositoryGroup(repository.path)?.key === key;
|
|
3563
|
+
} catch {
|
|
3564
|
+
return false;
|
|
3565
|
+
}
|
|
3566
|
+
}).map((repository) => ({
|
|
3567
|
+
id: repository.id,
|
|
3568
|
+
at: used(repository.id)
|
|
3569
|
+
})).sort((left, right) => right.at.localeCompare(left.at)).map((repository) => repository.id);
|
|
2337
3570
|
}
|
|
2338
3571
|
/** Registered checkouts, oldest registration first. */
|
|
2339
3572
|
repositories() {
|
|
@@ -2398,8 +3631,14 @@ var ReviewStore = class {
|
|
|
2398
3631
|
assertExists(id) {
|
|
2399
3632
|
if (!this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(id)) throw new ReviewInputError("Review not found. If this is an old Whiteboard review, ask your agent to migrate your old Whiteboard reviews.", 404);
|
|
2400
3633
|
}
|
|
3634
|
+
/** A board that exists and isn't in Trash: the one rule every write
|
|
3635
|
+
* about a board keeps. */
|
|
2401
3636
|
assertWritable(id) {
|
|
2402
3637
|
this.assertExists(id);
|
|
3638
|
+
this.assertOutOfTrash(id);
|
|
3639
|
+
}
|
|
3640
|
+
/** Refuses a board in Trash, which is read-only until it is restored. */
|
|
3641
|
+
assertOutOfTrash(id) {
|
|
2403
3642
|
const trashedAt = this.trashTime(id);
|
|
2404
3643
|
if (trashedAt) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", {
|
|
2405
3644
|
month: "short",
|
|
@@ -2410,40 +3649,28 @@ var ReviewStore = class {
|
|
|
2410
3649
|
const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(id);
|
|
2411
3650
|
return row?.deleted_at ? String(row.deleted_at) : null;
|
|
2412
3651
|
}
|
|
3652
|
+
/** Removes a board for good, from Delete… or Trash's purge. Links into
|
|
3653
|
+
* it stay, and show it as deleted. */
|
|
2413
3654
|
removePermanently(id) {
|
|
3655
|
+
const { title } = this.read(id);
|
|
2414
3656
|
for (const table of [
|
|
2415
|
-
"ask_conversations",
|
|
2416
3657
|
"authoring_presences",
|
|
2417
3658
|
"review_coverage",
|
|
2418
3659
|
"review_attention",
|
|
2419
3660
|
"versions"
|
|
2420
3661
|
]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
|
|
3662
|
+
this.askHistory.removeReview(id);
|
|
3663
|
+
this.db.prepare("INSERT OR REPLACE INTO deleted_boards(id,title,deleted_at,project_id) VALUES(?,?,?,(SELECT project_id FROM reviews WHERE id=?))").run(id, title, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
2421
3664
|
this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
|
|
2422
3665
|
this.liveSources.delete(id);
|
|
2423
3666
|
}
|
|
3667
|
+
/** Deletes the boards 30 days in Trash, each as Delete… would. */
|
|
2424
3668
|
purgeExpired() {
|
|
2425
|
-
const cutoff = (/* @__PURE__ */ new Date(Date.now() -
|
|
2426
|
-
const expired =
|
|
2427
|
-
this.
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
const reviewId = String(row.review_id);
|
|
2431
|
-
expired.push({
|
|
2432
|
-
reviewId,
|
|
2433
|
-
version: Number(row.version),
|
|
2434
|
-
deleted: true
|
|
2435
|
-
});
|
|
2436
|
-
this.removePermanently(reviewId);
|
|
2437
|
-
}
|
|
2438
|
-
this.db.exec("COMMIT");
|
|
2439
|
-
} catch (error) {
|
|
2440
|
-
this.db.exec("ROLLBACK");
|
|
2441
|
-
throw error;
|
|
2442
|
-
}
|
|
2443
|
-
for (const result of expired) {
|
|
2444
|
-
this.activity.deleted(result.reviewId);
|
|
2445
|
-
this.notify(result);
|
|
2446
|
-
}
|
|
3669
|
+
const cutoff = (/* @__PURE__ */ new Date(Date.now() - TRASH_DAYS * 864e5)).toISOString();
|
|
3670
|
+
const expired = () => this.db.prepare("SELECT review_id FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?").all(cutoff).map((row) => String(row.review_id));
|
|
3671
|
+
for (const reviewId of expired()) this.deleteBoard(this.read(reviewId), { kind: "person" }, { guard: () => {
|
|
3672
|
+
if (!expired().includes(reviewId)) throw new ReviewInputError("Restored from Trash.", 409);
|
|
3673
|
+
} });
|
|
2447
3674
|
}
|
|
2448
3675
|
read(id, version) {
|
|
2449
3676
|
const row = version === void 0 ? this.db.prepare("SELECT snapshot FROM versions JOIN reviews ON reviews.id=review_id AND reviews.version=versions.version WHERE reviews.id=?").get(id) : this.db.prepare("SELECT snapshot FROM versions WHERE review_id=? AND version=?").get(id, version);
|
|
@@ -2452,7 +3679,7 @@ var ReviewStore = class {
|
|
|
2452
3679
|
throw new ReviewInputError("Review version not found.", 404);
|
|
2453
3680
|
}
|
|
2454
3681
|
const snapshot = JSON.parse(String(row.snapshot));
|
|
2455
|
-
const { document, lenses } =
|
|
3682
|
+
const { document, lenses } = readStoredDocument(snapshot.document);
|
|
2456
3683
|
snapshot.document = document;
|
|
2457
3684
|
if (snapshot.lenses) snapshot.lenses = migrateStoredLenses(snapshot.lenses);
|
|
2458
3685
|
if (lenses.length) snapshot.lenses = [...snapshot.lenses ?? [], ...lenses];
|
|
@@ -2477,27 +3704,28 @@ var ReviewStore = class {
|
|
|
2477
3704
|
listener();
|
|
2478
3705
|
} catch {}
|
|
2479
3706
|
}
|
|
2480
|
-
/** Reviews of the tutorial earlier versions shipped, for their removal. */
|
|
2481
|
-
legacyTutorialIds() {
|
|
2482
|
-
return this.db.prepare(`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`).all().map((row) => String(row.id));
|
|
2483
|
-
}
|
|
2484
3707
|
list() {
|
|
2485
3708
|
return this.summaries();
|
|
2486
3709
|
}
|
|
3710
|
+
listProject(projectId) {
|
|
3711
|
+
return this.summaries("reviews.project_id=?", projectId);
|
|
3712
|
+
}
|
|
2487
3713
|
/** One review's catalog entry, as review_list shows it. */
|
|
2488
3714
|
summary(id) {
|
|
2489
|
-
return this.summaries(id)[0];
|
|
3715
|
+
return this.summaries("reviews.id=?", id)[0];
|
|
2490
3716
|
}
|
|
2491
|
-
summaries(
|
|
3717
|
+
summaries(where = "1", ...params) {
|
|
2492
3718
|
const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
2493
3719
|
(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,
|
|
2494
|
-
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
3720
|
+
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path,
|
|
3721
|
+
projects.id AS project_id, projects.name AS project_name
|
|
2495
3722
|
FROM reviews
|
|
3723
|
+
JOIN projects ON projects.id=reviews.project_id
|
|
2496
3724
|
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2497
3725
|
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
2498
|
-
LEFT JOIN repositories ON repositories.id
|
|
2499
|
-
${
|
|
2500
|
-
ORDER BY reviews.rowid`).all(...
|
|
3726
|
+
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
3727
|
+
WHERE ${where}
|
|
3728
|
+
ORDER BY reviews.rowid`).all(...params).map((row) => {
|
|
2501
3729
|
const summary = JSON.parse(String(row.summary));
|
|
2502
3730
|
if (summary.pins) summary.target ??= {
|
|
2503
3731
|
kind: "commits",
|
|
@@ -2507,19 +3735,30 @@ var ReviewStore = class {
|
|
|
2507
3735
|
};
|
|
2508
3736
|
const live = this.liveSources.get(summary.reviewId);
|
|
2509
3737
|
if (live?.version === summary.version && summary.target?.kind === "worktree") summary.pins = live.pins;
|
|
2510
|
-
|
|
3738
|
+
return {
|
|
2511
3739
|
...summary,
|
|
2512
3740
|
firstCreatedAt: row.first_created_at ? String(row.first_created_at) : void 0,
|
|
2513
3741
|
repositoryPath: row.repository_path ? String(row.repository_path) : void 0,
|
|
2514
|
-
|
|
2515
|
-
|
|
3742
|
+
project: {
|
|
3743
|
+
id: String(row.project_id),
|
|
3744
|
+
name: String(row.project_name)
|
|
3745
|
+
},
|
|
3746
|
+
repositoryName: row.repository_name ? String(row.repository_name) : "(unknown repository)",
|
|
2516
3747
|
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
2517
3748
|
trashedAt: row.deleted_at ? String(row.deleted_at) : null,
|
|
3749
|
+
changed: !row.viewed_at || summary.createdAt > String(row.viewed_at),
|
|
2518
3750
|
working: this.activity.isWorking(summary.reviewId)
|
|
2519
3751
|
};
|
|
2520
|
-
if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
|
|
2521
|
-
return listed;
|
|
2522
3752
|
});
|
|
3753
|
+
const graph = this.relationGraph();
|
|
3754
|
+
for (const listed of reviews) {
|
|
3755
|
+
const parentId = graph.parents.get(listed.reviewId);
|
|
3756
|
+
const children = graph.children.get(listed.reviewId);
|
|
3757
|
+
const dependsOn = graph.dependsOn.get(listed.reviewId);
|
|
3758
|
+
if (parentId) listed.parentId = parentId;
|
|
3759
|
+
if (children) listed.children = this.orderChildren(children, graph);
|
|
3760
|
+
if (dependsOn) listed.dependsOn = dependsOn;
|
|
3761
|
+
}
|
|
2523
3762
|
return this.withDiffStats(reviews);
|
|
2524
3763
|
}
|
|
2525
3764
|
/** Local and imported summaries use the same persisted counts. */
|
|
@@ -2530,117 +3769,80 @@ var ReviewStore = class {
|
|
|
2530
3769
|
diffStats: review.pins ? stats.get(statsKey(review.pins)) ?? null : null
|
|
2531
3770
|
}));
|
|
2532
3771
|
}
|
|
2533
|
-
/** What the pad holds, for its Home card: blocks, and the diagrams among them. */
|
|
2534
|
-
scratchpadContents() {
|
|
2535
|
-
const blocks = elements(this.read(SCRATCHPAD_ID).document).filter((element) => !isUnit(element));
|
|
2536
|
-
return {
|
|
2537
|
-
blocks: blocks.length,
|
|
2538
|
-
diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length
|
|
2539
|
-
};
|
|
2540
|
-
}
|
|
2541
|
-
/** The one scratchpad, made on first use. A host that loses the race to
|
|
2542
|
-
* another on the same home finds it made. */
|
|
2543
|
-
async ensureScratchpad() {
|
|
2544
|
-
if (this.has(SCRATCHPAD_ID)) return;
|
|
2545
|
-
try {
|
|
2546
|
-
await this.execute({ operation: {
|
|
2547
|
-
type: "create",
|
|
2548
|
-
title: SCRATCHPAD_TITLE,
|
|
2549
|
-
kind: "scratchpad"
|
|
2550
|
-
} });
|
|
2551
|
-
} catch (error) {
|
|
2552
|
-
if (!this.has(SCRATCHPAD_ID)) throw error;
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
3772
|
/** Two saved board/day pairs in the last fourteen days. */
|
|
2556
3773
|
feedbackEligible(now = Date.now()) {
|
|
2557
3774
|
const row = this.db.prepare(`SELECT count(*) AS pairs FROM (
|
|
2558
3775
|
SELECT DISTINCT review_id, date(json_extract(snapshot, '$.createdAt')) AS utc_date
|
|
2559
3776
|
FROM versions
|
|
2560
3777
|
WHERE (version = 0 OR json_type(snapshot, '$.lastEdit') = 'object')
|
|
2561
|
-
AND json_extract(snapshot, '$.kind') IS NOT 'scratchpad'
|
|
2562
|
-
AND json_extract(snapshot, '$.origin.tutorial') IS NOT 1
|
|
2563
3778
|
AND julianday(json_extract(snapshot, '$.createdAt')) BETWEEN julianday(?) AND julianday(?)
|
|
2564
3779
|
LIMIT 2
|
|
2565
3780
|
)`).get((/* @__PURE__ */ new Date(now - 12096e5)).toISOString(), new Date(now).toISOString());
|
|
2566
3781
|
return Number(row?.pairs) >= 2;
|
|
2567
3782
|
}
|
|
2568
3783
|
history(id) {
|
|
2569
|
-
return this.db.prepare(
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
3784
|
+
return this.db.prepare(`SELECT version,
|
|
3785
|
+
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.title') END AS title,
|
|
3786
|
+
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.createdAt') END AS created_at,
|
|
3787
|
+
author_kind,
|
|
3788
|
+
thread_id
|
|
3789
|
+
FROM versions WHERE review_id=? ORDER BY version`).all(id).map((row) => {
|
|
3790
|
+
const entry = {
|
|
3791
|
+
version: Number(row.version),
|
|
3792
|
+
title: String(row.title ?? ""),
|
|
3793
|
+
createdAt: String(row.created_at ?? "")
|
|
3794
|
+
};
|
|
3795
|
+
if (row.author_kind !== null) entry.author = row.author_kind === "person" ? "you" : "agent";
|
|
3796
|
+
if (row.author_kind !== "person" && row.thread_id !== null) entry.threadId = String(row.thread_id);
|
|
3797
|
+
return entry;
|
|
3798
|
+
});
|
|
2574
3799
|
}
|
|
2575
3800
|
inspect(id, targetId, version) {
|
|
2576
3801
|
return inspectSnapshot(this.read(id, version), targetId);
|
|
2577
3802
|
}
|
|
2578
3803
|
/** The host can seed a document; transport callers only supply a command. */
|
|
2579
|
-
execute(input, initial) {
|
|
3804
|
+
execute(input, initial, author = { kind: "agent" }) {
|
|
2580
3805
|
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
|
|
2581
3806
|
const command = commandSchema.parse(input);
|
|
2582
3807
|
if (initial && command.operation.type !== "create") throw new ReviewInputError("Initial content requires a create command.");
|
|
2583
3808
|
const pullRequest = this.startPullRequest(command);
|
|
2584
3809
|
pullRequest?.catch(() => {});
|
|
2585
3810
|
const pullRequestTitle = pullRequest ? pullRequest.then(({ title }) => title || void 0, () => void 0) : this.startPullRequestTitle(command);
|
|
2586
|
-
|
|
3811
|
+
return this.enqueue(async () => {
|
|
2587
3812
|
const op = command.operation;
|
|
2588
|
-
|
|
2589
|
-
if (op.type !== "create" && op.type !== "delete" && op.type !== "lifecycle") this.assertWritable(op.reviewId);
|
|
2590
|
-
if (op.type !== "create" && op.type !== "edit" && op.type !== "lens_edit" && op.type !== "restore" && this.read(op.reviewId).kind === "scratchpad") throw new ReviewInputError("The scratchpad has no lifecycle, title or pins of its own.", 409);
|
|
2591
|
-
if (op.type === "create" && op.kind === "scratchpad") {
|
|
2592
|
-
if (op.target) throw new ReviewInputError("A scratchpad has no target of its own.");
|
|
2593
|
-
if (this.has(SCRATCHPAD_ID)) throw new ReviewInputError("The scratchpad already exists.", 409);
|
|
2594
|
-
} else if (op.type === "create") {
|
|
3813
|
+
if (op.type === "create") {
|
|
2595
3814
|
if (!op.target && !op.pullRequestUrl) throw new ReviewInputError("Supply a target or a pullRequestUrl.");
|
|
2596
3815
|
if (op.repositoryId && op.target) throw new ReviewInputError("A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.");
|
|
2597
|
-
if (!op.title && op.target) throw new ReviewInputError("Supply a title.");
|
|
3816
|
+
if (!op.title && op.target && author.kind !== "person") throw new ReviewInputError("Supply a title.");
|
|
2598
3817
|
}
|
|
2599
3818
|
const requestedTarget = op.type === "create" || op.type === "set_target" ? op.target : void 0;
|
|
2600
3819
|
const fromPullRequest = await pullRequest;
|
|
2601
|
-
const resolvedTarget = requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
|
|
3820
|
+
const resolvedTarget = requestedTarget?.kind === "project" ? (this.repositoryPath(requestedTarget.repositoryId), { target: requestedTarget }) : requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
|
|
2602
3821
|
if (requestedTarget && !resolvedTarget) throw new ReviewInputError("Review targets are unavailable.");
|
|
2603
3822
|
if (op.type === "create" && op.pullRequestUrl && op.reuseExisting !== false) {
|
|
2604
3823
|
const [found, ...others] = this.reviewsForPullRequest(op.pullRequestUrl);
|
|
2605
|
-
if (found)
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
const result = {
|
|
2611
|
-
reviewId: op.reviewId,
|
|
2612
|
-
version: this.read(op.reviewId).version,
|
|
2613
|
-
deleted: true
|
|
2614
|
-
};
|
|
2615
|
-
this.commitCommand(result, () => this.removePermanently(op.reviewId), () => this.assertMutation(op.reviewId, result.version));
|
|
2616
|
-
return result;
|
|
2617
|
-
}
|
|
2618
|
-
if (op.type === "attention" || op.type === "lifecycle") {
|
|
2619
|
-
const result = {
|
|
2620
|
-
reviewId: op.reviewId,
|
|
2621
|
-
version: this.read(op.reviewId).version,
|
|
2622
|
-
attention: true,
|
|
2623
|
-
...op.type === "lifecycle" && { lifecycle: true }
|
|
2624
|
-
};
|
|
2625
|
-
this.commitCommand(result, () => {
|
|
2626
|
-
this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(op.reviewId);
|
|
2627
|
-
if (op.type === "attention") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
|
|
2628
|
-
else {
|
|
2629
|
-
const timestamp = op.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
2630
|
-
this.db.prepare("UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?").run(timestamp, timestamp, op.reviewId);
|
|
2631
|
-
}
|
|
2632
|
-
});
|
|
2633
|
-
return result;
|
|
3824
|
+
if (found) {
|
|
3825
|
+
const result = this.existingReview(found, others, resolvedTarget.pins);
|
|
3826
|
+
if (op.parentId || op.dependsOn) result.note += ` Its parent and dependencies were not changed either: set them with session_set_parent and session_set_dependencies.`;
|
|
3827
|
+
return result;
|
|
3828
|
+
}
|
|
2634
3829
|
}
|
|
2635
|
-
|
|
3830
|
+
if (op.type === "set_parent") return this.commitBoardFacts(op.reviewId, author, { parentId: op.parentId });
|
|
3831
|
+
if (op.type === "set_dependencies") return this.commitBoardFacts(op.reviewId, author, { dependsOn: op.dependsOn });
|
|
3832
|
+
if (op.type === "lifecycle") return this.commitBoardFacts(op.reviewId, author, { state: op.state });
|
|
3833
|
+
if (op.type === "attention") return this.commitBoardFacts(op.reviewId, author, { viewed: true });
|
|
3834
|
+
if (op.type === "delete") return this.deleteBoard(this.read(op.reviewId), author, { fromTrash: true });
|
|
3835
|
+
const id = op.type === "create" ? randomUUID() : op.reviewId;
|
|
2636
3836
|
const previous = op.type === "create" ? void 0 : this.read(id);
|
|
2637
|
-
let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`)) : structuredClone(previous);
|
|
3837
|
+
let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`), author.kind === "person") : structuredClone(previous);
|
|
2638
3838
|
delete snapshot.trashedAt;
|
|
2639
3839
|
delete snapshot.sourceUnavailable;
|
|
2640
3840
|
delete snapshot.lastEdit;
|
|
2641
3841
|
let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
|
|
2642
3842
|
let applied;
|
|
2643
3843
|
let lensTarget;
|
|
3844
|
+
/** The head a worktree board read at, when it becomes a plan. */
|
|
3845
|
+
let unpinnedWorktree;
|
|
2644
3846
|
if ((op.type === "create" || op.type === "set_target") && this.providers.headBranch) {
|
|
2645
3847
|
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
2646
3848
|
if (pins) {
|
|
@@ -2657,14 +3859,18 @@ var ReviewStore = class {
|
|
|
2657
3859
|
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
2658
3860
|
baseBranch: fromPullRequest.baseBranch
|
|
2659
3861
|
};
|
|
2660
|
-
if (requestedTarget
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
3862
|
+
if (requestedTarget && requestedTarget.kind !== "project") {
|
|
3863
|
+
const named = requestedTarget.base;
|
|
3864
|
+
const base = resolvedTarget?.pins?.base;
|
|
3865
|
+
snapshot.origin = {
|
|
3866
|
+
...snapshot.origin,
|
|
3867
|
+
baseBranch: named && !base?.startsWith(named.toLowerCase()) ? named : void 0
|
|
3868
|
+
};
|
|
3869
|
+
}
|
|
2664
3870
|
switch (op.type) {
|
|
2665
3871
|
case "create":
|
|
2666
3872
|
if (initial) {
|
|
2667
|
-
snapshot.document = documentSchema.parse(initial.document);
|
|
3873
|
+
snapshot.document = await this.resolveCommitLinks(documentSchema.parse(initial.document), snapshot);
|
|
2668
3874
|
snapshot.origin = {
|
|
2669
3875
|
...snapshot.origin,
|
|
2670
3876
|
...structuredClone(initial.origin)
|
|
@@ -2677,17 +3883,22 @@ var ReviewStore = class {
|
|
|
2677
3883
|
snapshot.title = op.title;
|
|
2678
3884
|
break;
|
|
2679
3885
|
case "set_target":
|
|
2680
|
-
setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.
|
|
3886
|
+
setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.target?.repositoryId !== resolvedTarget.target.repositoryId ? null : void 0), await pullRequestTitle);
|
|
2681
3887
|
snapshot.staleSources = [];
|
|
3888
|
+
if (snapshot.pins && !resolvedTarget.pins) {
|
|
3889
|
+
const pinned = pinDocumentReferences(snapshot.document, snapshot.pins);
|
|
3890
|
+
if (snapshot.target?.kind === "worktree" && JSON.stringify(pinned) !== JSON.stringify(snapshot.document)) unpinnedWorktree = snapshot.pins.head;
|
|
3891
|
+
snapshot.document = pinned;
|
|
3892
|
+
}
|
|
2682
3893
|
snapshot.target = resolvedTarget.target;
|
|
2683
|
-
snapshot.pins = resolvedTarget.pins;
|
|
3894
|
+
if (resolvedTarget.pins) snapshot.pins = resolvedTarget.pins;
|
|
3895
|
+
else delete snapshot.pins;
|
|
2684
3896
|
break;
|
|
2685
3897
|
case "restore":
|
|
2686
3898
|
snapshot = this.read(id, op.version);
|
|
2687
3899
|
delete snapshot.lastEdit;
|
|
2688
3900
|
break;
|
|
2689
3901
|
case "lens_edit": {
|
|
2690
|
-
if (snapshot.kind === "scratchpad") throw new ReviewInputError("The scratchpad has no changes of its own to lens.", 409);
|
|
2691
3902
|
const lenses = snapshot.lenses ?? [];
|
|
2692
3903
|
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
2693
3904
|
if (lenses.length) snapshot.lenses = lenses;
|
|
@@ -2705,65 +3916,691 @@ var ReviewStore = class {
|
|
|
2705
3916
|
};
|
|
2706
3917
|
break;
|
|
2707
3918
|
}
|
|
2708
|
-
case "edit":
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
const newSources = new Map(sourceReferences(snapshot.document).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
2714
|
-
snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
if (snapshot.target?.kind === "worktree" && op.type !== "restore" && !resolvedTarget) snapshot = await this.projectLiveSource(snapshot);
|
|
2718
|
-
checkReferences(snapshot.document);
|
|
2719
|
-
if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
|
|
2720
|
-
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
2721
|
-
const warnings = [...documentAdvice(snapshot.document).filter((advice) => !advised.has(advice)), ...await this.validateExternal(snapshot, previous, op.type === "set_target")];
|
|
2722
|
-
snapshot.version = previous ? previous.version + 1 : 0;
|
|
2723
|
-
snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2724
|
-
const result = {
|
|
2725
|
-
...op.type === "create" && { created: true },
|
|
2726
|
-
reviewId: id,
|
|
2727
|
-
version: snapshot.version,
|
|
2728
|
-
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
2729
|
-
...applied && { type: applied.type },
|
|
2730
|
-
...lensTarget && { type: lensTarget.type },
|
|
2731
|
-
...applied?.children && { children: applied.children }
|
|
2732
|
-
};
|
|
2733
|
-
if (warnings.length) result.warnings = warnings;
|
|
2734
|
-
this.commitCommand(result, () => {
|
|
2735
|
-
this.db.prepare("INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id").run(id, snapshot.version, nextId);
|
|
2736
|
-
this.db.prepare("INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot));
|
|
2737
|
-
}, previous ? () => {
|
|
2738
|
-
this.assertWritable(id);
|
|
2739
|
-
this.assertMutation(id, previous.version);
|
|
2740
|
-
} : void 0, op.type === "edit" || op.type === "lens_edit" ? {
|
|
2741
|
-
surface: op.type === "edit" ? "document" : "lenses",
|
|
2742
|
-
activityId: op.activityId,
|
|
2743
|
-
owned: (owner) => {
|
|
2744
|
-
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
3919
|
+
case "edit": {
|
|
3920
|
+
const edit = respellEditLinks(op.edit);
|
|
3921
|
+
applied = applyEdit(snapshot.document, edit, (prefix) => `${prefix}-${++nextId}`);
|
|
3922
|
+
snapshot.lastEdit = summarizeEdit(edit, applied, previous.document, snapshot.document);
|
|
3923
|
+
break;
|
|
2745
3924
|
}
|
|
2746
|
-
}
|
|
2747
|
-
|
|
3925
|
+
}
|
|
3926
|
+
const warnings = unpinnedWorktree ? [`Its references now read at ${unpinnedWorktree.slice(0, 7)}, the checkout's last commit, which lacks any uncommitted lines they quoted. Check them.`] : [];
|
|
3927
|
+
return this.commitVersion({
|
|
3928
|
+
snapshot,
|
|
3929
|
+
previous,
|
|
3930
|
+
nextId,
|
|
3931
|
+
author,
|
|
3932
|
+
warnings,
|
|
3933
|
+
restore: op.type === "restore",
|
|
3934
|
+
repin: op.type === "set_target",
|
|
3935
|
+
projectLive: op.type !== "restore" && !resolvedTarget,
|
|
3936
|
+
result: {
|
|
3937
|
+
...op.type === "create" && { created: true },
|
|
3938
|
+
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
3939
|
+
...applied && { type: applied.type },
|
|
3940
|
+
...lensTarget && { type: lensTarget.type },
|
|
3941
|
+
...applied?.children && { children: applied.children }
|
|
3942
|
+
},
|
|
3943
|
+
...op.type === "create" && {
|
|
3944
|
+
projectId: op.projectId,
|
|
3945
|
+
relations: {
|
|
3946
|
+
parentId: op.parentId,
|
|
3947
|
+
dependsOn: op.dependsOn
|
|
3948
|
+
}
|
|
3949
|
+
},
|
|
3950
|
+
...(op.type === "edit" || op.type === "lens_edit") && { courier: {
|
|
3951
|
+
surface: op.type === "edit" ? "document" : "lenses",
|
|
3952
|
+
activityId: op.activityId
|
|
3953
|
+
} }
|
|
3954
|
+
});
|
|
2748
3955
|
});
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Commits a board's next version: the one place a version is written.
|
|
3959
|
+
* Whoever writes it, it keeps the same rules:
|
|
3960
|
+
* - an agent may not change a block a person holds;
|
|
3961
|
+
* - the version names its author: a thread's names the thread, a
|
|
3962
|
+
* person's is theirs and never inherits a thread;
|
|
3963
|
+
* - only a change of repository moves a board to another project, and an
|
|
3964
|
+
* existing board never takes the project of the board opened last;
|
|
3965
|
+
* - only links the change adds are checked;
|
|
3966
|
+
* - the version, its link index, the board's project and its relations
|
|
3967
|
+
* are written together, and only if the board is still at the version
|
|
3968
|
+
* read and the rules still hold.
|
|
3969
|
+
* Runs inside the write queue.
|
|
3970
|
+
*/
|
|
3971
|
+
async commitVersion(change) {
|
|
3972
|
+
const { previous, author } = change;
|
|
3973
|
+
const id = change.snapshot.reviewId;
|
|
3974
|
+
let snapshot = change.snapshot;
|
|
3975
|
+
delete snapshot.threadId;
|
|
3976
|
+
delete snapshot.personEdit;
|
|
3977
|
+
if (snapshot.lastEdit) delete snapshot.lastEdit.threadId;
|
|
3978
|
+
if (previous && !change.restore) await this.normalizeDocument(snapshot, previous);
|
|
3979
|
+
if (change.unchangedSavesNothing && previous && snapshot.title === previous.title && isDeepStrictEqual(snapshot.document, previous.document)) return {
|
|
3980
|
+
reviewId: id,
|
|
3981
|
+
version: previous.version
|
|
3982
|
+
};
|
|
3983
|
+
const touched = previous ? changedBlocks(previous.document, snapshot.document) : [];
|
|
3984
|
+
const holds = () => {
|
|
3985
|
+
if (author.kind !== "person") this.assertUnleased(id, touched);
|
|
3986
|
+
};
|
|
3987
|
+
holds();
|
|
3988
|
+
if (author.kind === "person") snapshot.personEdit = {
|
|
3989
|
+
changed: touched,
|
|
3990
|
+
...change.personIds && { ids: change.personIds }
|
|
3991
|
+
};
|
|
3992
|
+
else if (author.threadId) {
|
|
3993
|
+
if (snapshot.lastEdit) snapshot.lastEdit.threadId = author.threadId;
|
|
3994
|
+
else snapshot.threadId = author.threadId;
|
|
3995
|
+
}
|
|
3996
|
+
let warnings;
|
|
3997
|
+
({snapshot, warnings} = await this.prepareVersion(snapshot, previous, {
|
|
3998
|
+
restore: change.restore,
|
|
3999
|
+
repin: change.repin,
|
|
4000
|
+
projectLive: change.projectLive,
|
|
4001
|
+
person: author.kind === "person"
|
|
4002
|
+
}));
|
|
4003
|
+
warnings = [...change.warnings ?? [], ...warnings];
|
|
4004
|
+
const result = {
|
|
4005
|
+
...change.result,
|
|
4006
|
+
reviewId: id,
|
|
4007
|
+
version: snapshot.version
|
|
4008
|
+
};
|
|
4009
|
+
if (warnings.length) result.warnings = warnings;
|
|
4010
|
+
const projectId = this.projectFor(snapshot, previous, change.projectId);
|
|
4011
|
+
const previousProject = previous ? this.projectOf(id) : void 0;
|
|
4012
|
+
const threadId = versionThread(snapshot);
|
|
4013
|
+
const shown = {
|
|
4014
|
+
...previous && snapshot.title !== previous.title && { titleChanged: true },
|
|
4015
|
+
...previous && (projectId !== previousProject || boardRepositoryId(snapshot) !== boardRepositoryId(previous) || boardIconKind(snapshot.target) !== boardIconKind(previous.target)) && { placeChanged: true },
|
|
4016
|
+
...threadId && { threadId }
|
|
4017
|
+
};
|
|
4018
|
+
this.commitCommand(result, () => {
|
|
4019
|
+
this.saveVersion(snapshot, change.nextId, projectId, change.author);
|
|
4020
|
+
if (change.relations?.parentId) this.setParent(id, change.relations.parentId);
|
|
4021
|
+
if (change.relations?.dependsOn) this.setDependencies(id, change.relations.dependsOn);
|
|
4022
|
+
if (previous && projectId !== previousProject && this.dropRelationsOutside(id, projectId)) result.warnings = [...result.warnings ?? [], "Its parent, children and dependencies were in its old project, so they were dropped: whiteboards relate only within a project."];
|
|
4023
|
+
}, previous ? () => {
|
|
4024
|
+
this.assertWritable(id);
|
|
4025
|
+
if (change.baseVersion !== void 0) assertBaseVersion(change.baseVersion, this.currentVersion(id));
|
|
4026
|
+
else this.assertMutation(id, previous.version);
|
|
4027
|
+
holds();
|
|
4028
|
+
} : void 0, change.courier && {
|
|
4029
|
+
...change.courier,
|
|
4030
|
+
owned: (owner) => {
|
|
4031
|
+
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
4032
|
+
}
|
|
4033
|
+
}, shown);
|
|
4034
|
+
return result;
|
|
4035
|
+
}
|
|
4036
|
+
/** A create's named project, else its repository's; an edit keeps its
|
|
4037
|
+
* project unless it moves repository. */
|
|
4038
|
+
projectFor(snapshot, previous, named) {
|
|
4039
|
+
if (named !== void 0) {
|
|
4040
|
+
if (!this.db.prepare("SELECT 1 FROM projects WHERE id=?").get(named)) throw new ReviewInputError(`Unknown project ${named}; use a project.id from session_list.`);
|
|
4041
|
+
return named;
|
|
4042
|
+
}
|
|
4043
|
+
const repository = snapshot.target?.repositoryId;
|
|
4044
|
+
if (previous && (!repository || repository === previous.target?.repositoryId || !this.db.prepare("SELECT 1 FROM repositories WHERE id=?").get(repository))) return this.projectOf(snapshot.reviewId);
|
|
4045
|
+
return repository ? this.projectOfRepository(repository) : this.fallbackProject();
|
|
4046
|
+
}
|
|
4047
|
+
currentVersion(reviewId) {
|
|
4048
|
+
return Number(this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId).version);
|
|
4049
|
+
}
|
|
4050
|
+
/** Runs one write after every write queued before it. */
|
|
4051
|
+
enqueue(work) {
|
|
4052
|
+
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
|
|
4053
|
+
const run = this.pending.then(work);
|
|
2749
4054
|
this.pending = run.catch(() => {});
|
|
2750
4055
|
return run;
|
|
2751
4056
|
}
|
|
4057
|
+
/** Respells a document's links and resolves its commit links, as every
|
|
4058
|
+
* save does. A changed reference is no longer stale. */
|
|
4059
|
+
async normalizeDocument(snapshot, previous) {
|
|
4060
|
+
snapshot.document = await this.resolveCommitLinks(respellDocumentLinks(snapshot.document), snapshot, previous.document);
|
|
4061
|
+
if (snapshot.staleSources?.length) {
|
|
4062
|
+
const oldSources = new Map(sourceReferences(previous.document, { repositoryId: boardRepositoryId(previous) }).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
4063
|
+
const newSources = new Map(sourceReferences(snapshot.document, { repositoryId: boardRepositoryId(snapshot) }).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
4064
|
+
snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
/** Checks a candidate version and numbers it. A worktree board's version
|
|
4068
|
+
* may come back with its live source projected. */
|
|
4069
|
+
async prepareVersion(candidate, previous, options) {
|
|
4070
|
+
const snapshot = candidate.target?.kind === "worktree" && options.projectLive ? await this.projectLiveSource(candidate) : candidate;
|
|
4071
|
+
checkReferences(snapshot.document);
|
|
4072
|
+
if (!options.restore) this.checkBoardLinks(snapshot, previous, options.person);
|
|
4073
|
+
if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
|
|
4074
|
+
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
4075
|
+
const warnings = [...documentAdvice(snapshot.document).filter((advice) => !advised.has(advice)), ...await this.validateExternal(snapshot, previous, options.repin)];
|
|
4076
|
+
snapshot.version = previous ? previous.version + 1 : 0;
|
|
4077
|
+
snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4078
|
+
return {
|
|
4079
|
+
snapshot,
|
|
4080
|
+
warnings
|
|
4081
|
+
};
|
|
4082
|
+
}
|
|
4083
|
+
/** Writes a version; call inside commitCommand. */
|
|
4084
|
+
saveVersion(snapshot, nextId, projectId, author) {
|
|
4085
|
+
const id = snapshot.reviewId;
|
|
4086
|
+
this.db.prepare("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").run(id, snapshot.version, nextId, projectId);
|
|
4087
|
+
this.db.prepare("INSERT INTO versions(review_id,version,snapshot,thread_id,author_kind) VALUES(?,?,?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot), versionThread(snapshot), author.kind);
|
|
4088
|
+
this.indexBoardLinks(id, snapshot.document);
|
|
4089
|
+
}
|
|
4090
|
+
/** Deletes a board and its history, if it is still at the version read
|
|
4091
|
+
* and passes `guard`: Delete… from Trash, Trash's purge and a discarded
|
|
4092
|
+
* empty board all end here. An agent can't delete a board while a person
|
|
4093
|
+
* holds a block in it. */
|
|
4094
|
+
deleteBoard({ reviewId, version }, author, { guard, fromTrash = false } = {}) {
|
|
4095
|
+
const result = {
|
|
4096
|
+
reviewId,
|
|
4097
|
+
version,
|
|
4098
|
+
deleted: true
|
|
4099
|
+
};
|
|
4100
|
+
const rules = () => {
|
|
4101
|
+
guard?.();
|
|
4102
|
+
if (fromTrash && !this.trashTime(reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
|
|
4103
|
+
this.assertMutation(reviewId, version);
|
|
4104
|
+
if (author.kind !== "person") this.assertUnheld(reviewId);
|
|
4105
|
+
};
|
|
4106
|
+
rules();
|
|
4107
|
+
this.commitCommand(result, () => this.removePermanently(reviewId), rules);
|
|
4108
|
+
this.leases.delete(reviewId);
|
|
4109
|
+
return result;
|
|
4110
|
+
}
|
|
4111
|
+
/** Writes what a board is outside its versions: its parent or what it
|
|
4112
|
+
* depends on, Trash or back, or that it was viewed. No version is saved,
|
|
4113
|
+
* but the rules are a version's: a board in Trash takes nothing but a
|
|
4114
|
+
* restore, and an agent can't put a board a person is in to Trash. */
|
|
4115
|
+
commitBoardFacts(reviewId, author, fact) {
|
|
4116
|
+
const lifecycle = "state" in fact;
|
|
4117
|
+
const rules = () => {
|
|
4118
|
+
this.assertExists(reviewId);
|
|
4119
|
+
if (!lifecycle) this.assertOutOfTrash(reviewId);
|
|
4120
|
+
else if (fact.state === "trash" && author.kind !== "person") this.assertUnheld(reviewId);
|
|
4121
|
+
};
|
|
4122
|
+
rules();
|
|
4123
|
+
const result = {
|
|
4124
|
+
reviewId,
|
|
4125
|
+
version: this.read(reviewId).version
|
|
4126
|
+
};
|
|
4127
|
+
if (lifecycle || "viewed" in fact) result.attention = true;
|
|
4128
|
+
if (lifecycle) result.lifecycle = true;
|
|
4129
|
+
this.commitCommand(result, () => {
|
|
4130
|
+
if ("parentId" in fact) this.setParent(reviewId, fact.parentId);
|
|
4131
|
+
else if ("dependsOn" in fact) this.setDependencies(reviewId, fact.dependsOn);
|
|
4132
|
+
else {
|
|
4133
|
+
this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(reviewId);
|
|
4134
|
+
if ("viewed" in fact) this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), reviewId);
|
|
4135
|
+
else {
|
|
4136
|
+
const timestamp = fact.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
4137
|
+
this.db.prepare("UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?").run(timestamp, timestamp, reviewId);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
if ("parentId" in fact || "dependsOn" in fact) result.relations = this.relations(reviewId);
|
|
4141
|
+
}, rules);
|
|
4142
|
+
return result;
|
|
4143
|
+
}
|
|
4144
|
+
/** Deletes a board the person made and left as it was: with no title
|
|
4145
|
+
* (it reads "Untitled"), no blocks and no child boards. A title typed as
|
|
4146
|
+
* "Untitled" is a title. A board still open on a canvas, or
|
|
4147
|
+
* one an agent is working on, is kept. A closing canvas saves, then lets
|
|
4148
|
+
* go of the board, so the discard waits for that first. */
|
|
4149
|
+
async discard(reviewId) {
|
|
4150
|
+
const deadline = Date.now() + FOCUS_LEASE_TTL_MS;
|
|
4151
|
+
while (this.leased(reviewId) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
|
|
4152
|
+
return this.enqueue(async () => {
|
|
4153
|
+
if (!this.has(reviewId)) return { deleted: false };
|
|
4154
|
+
const snapshot = this.read(reviewId);
|
|
4155
|
+
if (this.leased(reviewId) || this.activity.isWorking(reviewId) || snapshot.title !== "" || snapshot.document.length || this.relationGraph(true).children.get(reviewId)?.length) return { deleted: false };
|
|
4156
|
+
this.deleteBoard(snapshot, { kind: "person" });
|
|
4157
|
+
return { deleted: true };
|
|
4158
|
+
});
|
|
4159
|
+
}
|
|
4160
|
+
/** Each board's canvas leases by client: the board is open there, and
|
|
4161
|
+
* `blockId` is the block the person is in, if any. In memory: a lease
|
|
4162
|
+
* outlives neither its renewals nor the host. */
|
|
4163
|
+
leases = /* @__PURE__ */ new Map();
|
|
4164
|
+
/** Whether a canvas still holds the board open. */
|
|
4165
|
+
leased(reviewId) {
|
|
4166
|
+
const now = Date.now();
|
|
4167
|
+
return [...this.leases.get(reviewId)?.values() ?? []].some((lease) => lease.expiresAt > now);
|
|
4168
|
+
}
|
|
4169
|
+
/** Records the block a person's canvas has focus in (null for none), or
|
|
4170
|
+
* with `open: false` that the canvas closed the board. */
|
|
4171
|
+
focus(reviewId, input) {
|
|
4172
|
+
const { clientId, blockId, open } = focusLeaseSchema.parse(input);
|
|
4173
|
+
this.assertExists(reviewId);
|
|
4174
|
+
const leases = this.leases.get(reviewId) ?? /* @__PURE__ */ new Map();
|
|
4175
|
+
if (open === false) leases.delete(clientId);
|
|
4176
|
+
else leases.set(clientId, {
|
|
4177
|
+
blockId,
|
|
4178
|
+
expiresAt: Date.now() + FOCUS_LEASE_TTL_MS
|
|
4179
|
+
});
|
|
4180
|
+
if (leases.size) this.leases.set(reviewId, leases);
|
|
4181
|
+
else this.leases.delete(reviewId);
|
|
4182
|
+
return { ok: true };
|
|
4183
|
+
}
|
|
4184
|
+
/** Refuses an agent's change to a board while a person holds any block
|
|
4185
|
+
* in it. */
|
|
4186
|
+
assertUnheld(reviewId) {
|
|
4187
|
+
const now = Date.now();
|
|
4188
|
+
for (const lease of this.leases.get(reviewId)?.values() ?? []) if (lease.expiresAt > now && lease.blockId) throw new ReviewInputError(`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`, 409);
|
|
4189
|
+
}
|
|
4190
|
+
/** Refuses an agent's edit to a block a person is editing. */
|
|
4191
|
+
assertUnleased(reviewId, changed) {
|
|
4192
|
+
const leases = this.leases.get(reviewId);
|
|
4193
|
+
if (!leases || !changed.length) return;
|
|
4194
|
+
const now = Date.now();
|
|
4195
|
+
for (const [clientId, lease] of leases) if (lease.expiresAt <= now) leases.delete(clientId);
|
|
4196
|
+
else if (lease.blockId && changed.includes(lease.blockId)) throw new ReviewInputError(`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`, 409);
|
|
4197
|
+
}
|
|
4198
|
+
/** Saves a person's whole board as their own version, against the version
|
|
4199
|
+
* they started from. Blocks without ids get fresh ones. */
|
|
4200
|
+
personEdit(reviewId, input) {
|
|
4201
|
+
const body = personEditSchema.parse(input);
|
|
4202
|
+
return this.enqueue(async () => {
|
|
4203
|
+
const previous = this.read(reviewId);
|
|
4204
|
+
assertBaseVersion(body.baseVersion, previous.version);
|
|
4205
|
+
const { document, nextId, ids } = personDocument(body.document, Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(reviewId).next_id));
|
|
4206
|
+
const snapshot = structuredClone(previous);
|
|
4207
|
+
delete snapshot.sourceUnavailable;
|
|
4208
|
+
delete snapshot.lastEdit;
|
|
4209
|
+
snapshot.title = body.title;
|
|
4210
|
+
snapshot.document = document;
|
|
4211
|
+
const { version } = await this.commitVersion({
|
|
4212
|
+
snapshot,
|
|
4213
|
+
previous,
|
|
4214
|
+
nextId,
|
|
4215
|
+
author: { kind: "person" },
|
|
4216
|
+
projectLive: true,
|
|
4217
|
+
personIds: ids,
|
|
4218
|
+
baseVersion: body.baseVersion,
|
|
4219
|
+
unchangedSavesNothing: true
|
|
4220
|
+
});
|
|
4221
|
+
return {
|
|
4222
|
+
reviewId,
|
|
4223
|
+
version,
|
|
4224
|
+
ids
|
|
4225
|
+
};
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
4228
|
+
/** Where a board sits: its parent, and the parent's children in order,
|
|
4229
|
+
* each with what its header shows. */
|
|
4230
|
+
place(id) {
|
|
4231
|
+
const graph = this.relationGraph(true);
|
|
4232
|
+
const parentId = graph.parents.get(id);
|
|
4233
|
+
const placed = (reviewId) => {
|
|
4234
|
+
const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title,
|
|
4235
|
+
json_extract(versions.snapshot,'$.target') AS target,
|
|
4236
|
+
json_extract(versions.snapshot,'$.origin') AS origin
|
|
4237
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4238
|
+
WHERE reviews.id=?`).get(reviewId);
|
|
4239
|
+
return {
|
|
4240
|
+
reviewId,
|
|
4241
|
+
title: String(row.title),
|
|
4242
|
+
...row.target && { target: JSON.parse(String(row.target)) },
|
|
4243
|
+
...row.origin && { origin: JSON.parse(String(row.origin)) }
|
|
4244
|
+
};
|
|
4245
|
+
};
|
|
4246
|
+
return {
|
|
4247
|
+
board: placed(id),
|
|
4248
|
+
...parentId && { parent: placed(parentId) },
|
|
4249
|
+
siblings: parentId ? this.orderChildren(this.listedSiblings(graph.children.get(parentId), id), graph).map(placed) : [],
|
|
4250
|
+
...!parentId && { children: this.orderChildren(this.listedSiblings(graph.children.get(id) ?? [], id), graph).map(placed) }
|
|
4251
|
+
};
|
|
4252
|
+
}
|
|
4253
|
+
/** The siblings Home and the sidebar list, which a board's place counts
|
|
4254
|
+
* among: all but those in Trash, and the board itself. */
|
|
4255
|
+
listedSiblings(ids, self) {
|
|
4256
|
+
const trashed = new Set(this.trashedIds());
|
|
4257
|
+
return ids.filter((id) => id === self || !trashed.has(id));
|
|
4258
|
+
}
|
|
4259
|
+
/** A board's parent, its children in sibling order, and what it depends on. */
|
|
4260
|
+
relations(id) {
|
|
4261
|
+
const graph = this.relationGraph();
|
|
4262
|
+
return {
|
|
4263
|
+
parentId: graph.parents.get(id) ?? null,
|
|
4264
|
+
children: this.orderChildren(graph.children.get(id) ?? [], graph),
|
|
4265
|
+
dependsOn: graph.dependsOn.get(id) ?? []
|
|
4266
|
+
};
|
|
4267
|
+
}
|
|
4268
|
+
/** Parents, children and dependencies. A board in Trash is out of them
|
|
4269
|
+
* until it is restored, its rows kept; only its own place sees it. */
|
|
4270
|
+
relationGraph(withTrash = false) {
|
|
4271
|
+
const parents = /* @__PURE__ */ new Map();
|
|
4272
|
+
const children = /* @__PURE__ */ new Map();
|
|
4273
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
4274
|
+
const trashed = new Set(withTrash ? [] : this.trashedIds());
|
|
4275
|
+
for (const row of this.db.prepare("SELECT id, parent_id FROM reviews WHERE parent_id IS NOT NULL ORDER BY rowid").all()) {
|
|
4276
|
+
const board = String(row.id);
|
|
4277
|
+
const parent = String(row.parent_id);
|
|
4278
|
+
if (trashed.has(board) || trashed.has(parent)) continue;
|
|
4279
|
+
parents.set(board, parent);
|
|
4280
|
+
children.set(parent, [...children.get(parent) ?? [], board]);
|
|
4281
|
+
}
|
|
4282
|
+
for (const row of this.db.prepare("SELECT review_id, depends_on FROM board_dependencies ORDER BY rowid").all()) {
|
|
4283
|
+
const board = String(row.review_id);
|
|
4284
|
+
const dependency = String(row.depends_on);
|
|
4285
|
+
if (trashed.has(board) || trashed.has(dependency)) continue;
|
|
4286
|
+
dependsOn.set(board, [...dependsOn.get(board) ?? [], dependency]);
|
|
4287
|
+
}
|
|
4288
|
+
return {
|
|
4289
|
+
parents,
|
|
4290
|
+
children,
|
|
4291
|
+
dependsOn
|
|
4292
|
+
};
|
|
4293
|
+
}
|
|
4294
|
+
/** Drops a board's parent, children and dependencies in other projects,
|
|
4295
|
+
* after it moved to `projectId`. Whether it dropped any. */
|
|
4296
|
+
dropRelationsOutside(id, projectId) {
|
|
4297
|
+
const outside = "SELECT id FROM reviews WHERE project_id!=?";
|
|
4298
|
+
const parents = this.db.prepare(`UPDATE reviews SET parent_id=NULL WHERE (id=? AND parent_id IN (${outside})) OR (parent_id=? AND project_id!=?)`).run(id, projectId, id, projectId).changes;
|
|
4299
|
+
const dependencies = this.db.prepare(`DELETE FROM board_dependencies WHERE (review_id=? AND depends_on IN (${outside})) OR (depends_on=? AND review_id IN (${outside}))`).run(id, projectId, id, projectId).changes;
|
|
4300
|
+
return Number(parents) + Number(dependencies) > 0;
|
|
4301
|
+
}
|
|
4302
|
+
orderChildren(children, graph) {
|
|
4303
|
+
if (children.length < 2) return [...children];
|
|
4304
|
+
return orderSiblings(this.siblingFacts(children), (id) => graph.dependsOn.get(id) ?? [], (repositoryId, ancestor, of) => this.isAncestor(repositoryId, ancestor, of));
|
|
4305
|
+
}
|
|
4306
|
+
siblingFacts(ids) {
|
|
4307
|
+
return this.db.prepare(`SELECT reviews.id,
|
|
4308
|
+
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first
|
|
4309
|
+
WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS created_at,
|
|
4310
|
+
json_extract(versions.snapshot,'$.pins') AS pins,
|
|
4311
|
+
json_extract(versions.snapshot,'$.origin.branch') AS branch,
|
|
4312
|
+
json_extract(versions.snapshot,'$.origin.pullRequestUrl') AS url
|
|
4313
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4314
|
+
WHERE reviews.id IN (${ids.map(() => "?").join(",")})`).all(...ids).map((row) => {
|
|
4315
|
+
const id = String(row.id);
|
|
4316
|
+
const saved = row.pins ? JSON.parse(String(row.pins)) : void 0;
|
|
4317
|
+
const pins = this.liveSources.get(id)?.pins ?? saved;
|
|
4318
|
+
const url = row.url ? String(row.url) : void 0;
|
|
4319
|
+
const baseBranch = url && this.providers.pullRequestBaseBranch?.(url);
|
|
4320
|
+
return {
|
|
4321
|
+
id,
|
|
4322
|
+
createdAt: String(row.created_at),
|
|
4323
|
+
...pins && {
|
|
4324
|
+
repositoryId: pins.repositoryId,
|
|
4325
|
+
base: pins.base,
|
|
4326
|
+
head: pins.head
|
|
4327
|
+
},
|
|
4328
|
+
...url && row.branch && { headBranch: String(row.branch) },
|
|
4329
|
+
...baseBranch && { baseBranch }
|
|
4330
|
+
};
|
|
4331
|
+
});
|
|
4332
|
+
}
|
|
4333
|
+
/** Commits never change, so a definite answer is kept. */
|
|
4334
|
+
ancestry = /* @__PURE__ */ new Map();
|
|
4335
|
+
/** When git was last asked, for questions with no answer yet. */
|
|
4336
|
+
ancestryAsked = /* @__PURE__ */ new Map();
|
|
4337
|
+
/** Whether `ancestor` is an ancestor of `of`, as far as is known. git is
|
|
4338
|
+
* asked in the background, never on the server thread: until it answers,
|
|
4339
|
+
* or when it fails, the answer is no, and a yes re-sorts the catalog. */
|
|
4340
|
+
isAncestor(repositoryId, ancestor, of) {
|
|
4341
|
+
const key = `${repositoryId}:${ancestor}:${of}`;
|
|
4342
|
+
const known = this.ancestry.get(key);
|
|
4343
|
+
if (known !== void 0) return known;
|
|
4344
|
+
const asked = this.ancestryAsked.get(key);
|
|
4345
|
+
if (asked !== void 0 && Date.now() - asked < 6e4) return false;
|
|
4346
|
+
this.ancestryAsked.set(key, Date.now());
|
|
4347
|
+
let root;
|
|
4348
|
+
try {
|
|
4349
|
+
root = this.repositoryPath(repositoryId);
|
|
4350
|
+
} catch {
|
|
4351
|
+
return false;
|
|
4352
|
+
}
|
|
4353
|
+
this.ancestryQueue.push(() => runVcs("git", [
|
|
4354
|
+
"-C",
|
|
4355
|
+
root,
|
|
4356
|
+
"merge-base",
|
|
4357
|
+
"--is-ancestor",
|
|
4358
|
+
{ rev: ancestor },
|
|
4359
|
+
{ rev: of }
|
|
4360
|
+
], { timeout: 1e4 }).then(() => this.knowAncestry(key, true), (error) => {
|
|
4361
|
+
if (error.code === 1) this.knowAncestry(key, false);
|
|
4362
|
+
}));
|
|
4363
|
+
this.askNextAncestry();
|
|
4364
|
+
return false;
|
|
4365
|
+
}
|
|
4366
|
+
ancestryQueue = [];
|
|
4367
|
+
ancestryRunning = 0;
|
|
4368
|
+
askNextAncestry() {
|
|
4369
|
+
while (this.ancestryRunning < ANCESTRY_CONCURRENCY && this.ancestryQueue.length) {
|
|
4370
|
+
this.ancestryRunning++;
|
|
4371
|
+
this.ancestryQueue.shift()().finally(() => {
|
|
4372
|
+
this.ancestryRunning--;
|
|
4373
|
+
this.askNextAncestry();
|
|
4374
|
+
});
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
knowAncestry(key, ancestor) {
|
|
4378
|
+
this.ancestry.set(key, ancestor);
|
|
4379
|
+
this.ancestryAsked.delete(key);
|
|
4380
|
+
if (ancestor && !this.closing) this.catalogChanged();
|
|
4381
|
+
}
|
|
4382
|
+
catalogChanged() {
|
|
4383
|
+
for (const listener of this.catalogListeners) try {
|
|
4384
|
+
listener();
|
|
4385
|
+
} catch {}
|
|
4386
|
+
}
|
|
4387
|
+
boardName(id) {
|
|
4388
|
+
return `"${boardTitle(this.read(id).title)}" (${id})`;
|
|
4389
|
+
}
|
|
4390
|
+
/** Exists, out of Trash, same project. */
|
|
4391
|
+
checkRelated(id, other) {
|
|
4392
|
+
if (!this.has(other)) throw new ReviewInputError(`Whiteboard ${other} does not exist. Name a whiteboard by its sessionId from session_list.`);
|
|
4393
|
+
this.assertOutOfTrash(other);
|
|
4394
|
+
if (this.projectOf(id) !== this.projectOf(other)) throw new ReviewInputError(`${this.boardName(other)} is in another project than ${this.boardName(id)}. A whiteboard's parent and dependencies are in its own project.`);
|
|
4395
|
+
}
|
|
4396
|
+
/** One parent, one level: a parent is top-level and a child has no
|
|
4397
|
+
* children, Trash included. Null makes the board top-level. */
|
|
4398
|
+
setParent(id, parentId) {
|
|
4399
|
+
if (parentId !== null) {
|
|
4400
|
+
if (parentId === id) throw new ReviewInputError("A whiteboard can't be its own parent.");
|
|
4401
|
+
this.checkRelated(id, parentId);
|
|
4402
|
+
const { parents, children } = this.relationGraph(true);
|
|
4403
|
+
const grandparent = parents.get(parentId);
|
|
4404
|
+
if (grandparent) throw new ReviewInputError(`${this.boardName(parentId)} is a child of ${this.boardName(grandparent)}, and whiteboards nest one level deep. Use ${grandparent} as the parent instead.`);
|
|
4405
|
+
const own = children.get(id) ?? [];
|
|
4406
|
+
if (own.length) throw new ReviewInputError(`${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.`);
|
|
4407
|
+
}
|
|
4408
|
+
this.db.prepare("UPDATE reviews SET parent_id=? WHERE id=?").run(parentId, id);
|
|
4409
|
+
}
|
|
4410
|
+
setDependencies(id, dependsOn) {
|
|
4411
|
+
const unique = [...new Set(dependsOn)];
|
|
4412
|
+
const graph = this.relationGraph(true);
|
|
4413
|
+
for (const dependency of unique) {
|
|
4414
|
+
if (dependency === id) throw new ReviewInputError("A whiteboard can't depend on itself.");
|
|
4415
|
+
const cycle = dependencyCycle(id, dependency, (board) => graph.dependsOn.get(board) ?? []);
|
|
4416
|
+
if (cycle) throw new ReviewInputError(`Depending on ${this.boardName(dependency)} would close a cycle: ${cycle.map((board) => this.boardName(board)).join(" → ")}. Drop one of those dependencies first.`);
|
|
4417
|
+
this.checkRelated(id, dependency);
|
|
4418
|
+
}
|
|
4419
|
+
this.db.prepare("DELETE FROM board_dependencies WHERE review_id=?").run(id);
|
|
4420
|
+
const insert = this.db.prepare("INSERT INTO board_dependencies(review_id,depends_on) VALUES(?,?)");
|
|
4421
|
+
for (const dependency of unique) insert.run(id, dependency);
|
|
4422
|
+
}
|
|
4423
|
+
/** New links must point at a board and block that exist. Saved links are
|
|
4424
|
+
* not rechecked, since their target may have been deleted. */
|
|
4425
|
+
checkBoardLinks(snapshot, previous, person = false) {
|
|
4426
|
+
const key = ({ to }) => `${to.board}#${to.block ?? ""}`;
|
|
4427
|
+
const kept = new Set(boardLinks(snapshot.reviewId, previous?.document ?? []).map(key));
|
|
4428
|
+
for (const link of boardLinks(snapshot.reviewId, snapshot.document)) {
|
|
4429
|
+
if (kept.has(key(link))) continue;
|
|
4430
|
+
const { from, to } = link;
|
|
4431
|
+
const document = to.board === snapshot.reviewId ? snapshot.document : this.has(to.board) ? this.read(to.board).document : void 0;
|
|
4432
|
+
if (!document && person && this.wasDeleted(to.board)) continue;
|
|
4433
|
+
if (!document) throw new ReviewInputError(`Block ${from.block} links to board ${to.board}, which does not exist. Link a board by its sessionId from session_list.`);
|
|
4434
|
+
if (!person && to.block !== void 0 && !elements(document).some((element) => element.id === to.block)) throw new ReviewInputError(`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.`);
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
wasDeleted(id) {
|
|
4438
|
+
return this.db.prepare("SELECT 1 FROM deleted_boards WHERE id=?").get(id) !== void 0;
|
|
4439
|
+
}
|
|
4440
|
+
/** The boards that link to a board or its blocks. */
|
|
4441
|
+
linkingBoards(id) {
|
|
4442
|
+
return this.db.prepare("SELECT DISTINCT from_review FROM board_links WHERE to_review=?").all(id).map((row) => String(row.from_review));
|
|
4443
|
+
}
|
|
4444
|
+
/** Rebuilds the board_links rows for one board. */
|
|
4445
|
+
indexBoardLinks(id, document) {
|
|
4446
|
+
this.db.prepare("DELETE FROM board_links WHERE from_review=?").run(id);
|
|
4447
|
+
const insert = this.db.prepare("INSERT INTO board_links(from_review,from_block,to_review,to_block) VALUES(?,?,?,?)");
|
|
4448
|
+
for (const { from, to } of boardLinks(id, document)) insert.run(from.board, from.block, to.board, to.block ?? null);
|
|
4449
|
+
}
|
|
4450
|
+
/** Link previews by board, version and block; a version never changes. */
|
|
4451
|
+
linkPreviews = /* @__PURE__ */ new Map();
|
|
4452
|
+
linkPreview(target, block) {
|
|
4453
|
+
const key = `${target.reviewId}@${target.version}#${block ?? ""}`;
|
|
4454
|
+
let preview = this.linkPreviews.get(key);
|
|
4455
|
+
if (!preview) {
|
|
4456
|
+
preview = boardLinkPreview(target.document, block);
|
|
4457
|
+
if (this.linkPreviews.size >= 500) this.linkPreviews.delete(this.linkPreviews.keys().next().value);
|
|
4458
|
+
this.linkPreviews.set(key, preview);
|
|
4459
|
+
}
|
|
4460
|
+
return preview;
|
|
4461
|
+
}
|
|
4462
|
+
/** What a link's hover card shows of the board or block it links to,
|
|
4463
|
+
* read when the card opens rather than with every link. Only for a link
|
|
4464
|
+
* the version has. */
|
|
4465
|
+
linkPeek(id, to, version) {
|
|
4466
|
+
if (!boardLinks(id, this.read(id, version).document).some((link) => link.to.board === to.board && link.to.block === to.block) || !this.has(to.board)) throw new ReviewInputError("The board has no such link.", 404);
|
|
4467
|
+
return this.linkPreview(this.read(to.board), to.block);
|
|
4468
|
+
}
|
|
4469
|
+
/** "owner / repo" for a board whose origin names one, else its folder. */
|
|
4470
|
+
repositoryLabel(board) {
|
|
4471
|
+
const repositoryId = board.target?.repositoryId;
|
|
4472
|
+
if (!repositoryId) return void 0;
|
|
4473
|
+
try {
|
|
4474
|
+
return this.repositoryGroup(this.repositoryPath(repositoryId))?.label.split("/").join(" / ");
|
|
4475
|
+
} catch {
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
/** A version's outgoing links and the board's current backlinks. Links to a
|
|
4480
|
+
* deleted board or block are marked missing. */
|
|
4481
|
+
links(id, version) {
|
|
4482
|
+
const titles = /* @__PURE__ */ new Map();
|
|
4483
|
+
const board = (reviewId) => {
|
|
4484
|
+
if (!titles.has(reviewId)) titles.set(reviewId, this.has(reviewId) ? this.read(reviewId) : void 0);
|
|
4485
|
+
return titles.get(reviewId);
|
|
4486
|
+
};
|
|
4487
|
+
return {
|
|
4488
|
+
outgoing: boardLinks(id, this.read(id, version).document).map(({ from, to }) => {
|
|
4489
|
+
const target = board(to.board);
|
|
4490
|
+
const missing = !target || to.block !== void 0 && !elements(target.document).some((element) => element.id === to.block);
|
|
4491
|
+
const deleted = target ? void 0 : this.db.prepare("SELECT title,deleted_at FROM deleted_boards WHERE id=?").get(to.board);
|
|
4492
|
+
return {
|
|
4493
|
+
from,
|
|
4494
|
+
to,
|
|
4495
|
+
...target && {
|
|
4496
|
+
title: target.title,
|
|
4497
|
+
icon: boardIconKind(target.target),
|
|
4498
|
+
...this.repositoryLabel(target) && { repository: this.repositoryLabel(target) },
|
|
4499
|
+
...target.origin?.pullRequestNumber && {
|
|
4500
|
+
pullRequestNumber: target.origin.pullRequestNumber,
|
|
4501
|
+
pullRequestUrl: target.origin.pullRequestUrl
|
|
4502
|
+
},
|
|
4503
|
+
updatedAt: target.createdAt
|
|
4504
|
+
},
|
|
4505
|
+
missing,
|
|
4506
|
+
...deleted && {
|
|
4507
|
+
title: String(deleted.title),
|
|
4508
|
+
deletedAt: String(deleted.deleted_at)
|
|
4509
|
+
}
|
|
4510
|
+
};
|
|
4511
|
+
}),
|
|
4512
|
+
backlinks: this.db.prepare("SELECT DISTINCT from_review,from_block,to_block FROM board_links WHERE to_review=? AND from_review<>? ORDER BY from_review").all(id, id).map((row) => {
|
|
4513
|
+
const from = board(String(row.from_review));
|
|
4514
|
+
return {
|
|
4515
|
+
from: {
|
|
4516
|
+
board: String(row.from_review),
|
|
4517
|
+
block: String(row.from_block)
|
|
4518
|
+
},
|
|
4519
|
+
to: row.to_block === null ? { board: id } : {
|
|
4520
|
+
board: id,
|
|
4521
|
+
block: String(row.to_block)
|
|
4522
|
+
},
|
|
4523
|
+
title: from?.title ?? "",
|
|
4524
|
+
...from && { icon: boardIconKind(from.target) },
|
|
4525
|
+
...from?.origin?.pullRequestNumber && {
|
|
4526
|
+
pullRequestNumber: from.origin.pullRequestNumber,
|
|
4527
|
+
pullRequestUrl: from.origin.pullRequestUrl
|
|
4528
|
+
}
|
|
4529
|
+
};
|
|
4530
|
+
})
|
|
4531
|
+
};
|
|
4532
|
+
}
|
|
4533
|
+
/** Boards edited by an Ask thread, most recent first. */
|
|
4534
|
+
boardsChangedBy(threadId) {
|
|
4535
|
+
return this.db.prepare(`SELECT edits.review_id, MAX(edits.version) AS last
|
|
4536
|
+
FROM versions edits
|
|
4537
|
+
JOIN reviews ON reviews.id=edits.review_id
|
|
4538
|
+
WHERE edits.thread_id=?
|
|
4539
|
+
GROUP BY edits.review_id ORDER BY MAX(json_extract(edits.snapshot,'$.createdAt')) DESC`).all(threadId).map((row) => {
|
|
4540
|
+
const board = this.read(String(row.review_id));
|
|
4541
|
+
const blockId = this.read(board.reviewId, Number(row.last)).lastEdit?.blockId;
|
|
4542
|
+
return {
|
|
4543
|
+
reviewId: board.reviewId,
|
|
4544
|
+
title: board.title,
|
|
4545
|
+
icon: boardIconKind(board.target),
|
|
4546
|
+
...blockId && elements(board.document).some((element) => element.id === blockId) && { blockId }
|
|
4547
|
+
};
|
|
4548
|
+
});
|
|
4549
|
+
}
|
|
4550
|
+
/** The boards each thread changed, newest first, for many threads in one
|
|
4551
|
+
* query. */
|
|
4552
|
+
/** A board's title and icon, read without its document. */
|
|
4553
|
+
/** Where a board lives, as Recent names it: its checkout's folder, else
|
|
4554
|
+
* its project's name. */
|
|
4555
|
+
boardPlace(reviewId) {
|
|
4556
|
+
const row = this.db.prepare(`SELECT repositories.path, projects.name FROM reviews
|
|
4557
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4558
|
+
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
4559
|
+
LEFT JOIN projects ON projects.id=reviews.project_id
|
|
4560
|
+
WHERE reviews.id=?`).get(reviewId);
|
|
4561
|
+
return row?.path && path.basename(String(row.path)) || (row?.name ? String(row.name) : "");
|
|
4562
|
+
}
|
|
4563
|
+
boardFace(reviewId) {
|
|
4564
|
+
const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title, json_extract(versions.snapshot,'$.target') AS target
|
|
4565
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4566
|
+
WHERE reviews.id=?`).get(reviewId);
|
|
4567
|
+
return {
|
|
4568
|
+
reviewId,
|
|
4569
|
+
title: String(row.title),
|
|
4570
|
+
icon: boardIconKind(row.target ? JSON.parse(String(row.target)) : void 0)
|
|
4571
|
+
};
|
|
4572
|
+
}
|
|
4573
|
+
boardsChangedByThreads(threadIds) {
|
|
4574
|
+
const changed = /* @__PURE__ */ new Map();
|
|
4575
|
+
if (!threadIds.length) return changed;
|
|
4576
|
+
const rows = this.db.prepare(`SELECT thread_id AS thread, review_id
|
|
4577
|
+
FROM versions
|
|
4578
|
+
WHERE thread_id IN (${threadIds.map(() => "?").join(",")})
|
|
4579
|
+
GROUP BY thread, review_id ORDER BY MAX(rowid) DESC`).all(...threadIds);
|
|
4580
|
+
const boards = /* @__PURE__ */ new Map();
|
|
4581
|
+
for (const row of rows) {
|
|
4582
|
+
const reviewId = String(row.review_id);
|
|
4583
|
+
if (!boards.has(reviewId)) boards.set(reviewId, this.has(reviewId) ? this.boardFace(reviewId) : void 0);
|
|
4584
|
+
const board = boards.get(reviewId);
|
|
4585
|
+
if (!board) continue;
|
|
4586
|
+
const thread = String(row.thread);
|
|
4587
|
+
changed.set(thread, [...changed.get(thread) ?? [], board]);
|
|
4588
|
+
}
|
|
4589
|
+
return changed;
|
|
4590
|
+
}
|
|
2752
4591
|
/** Resolve a target-less PR create before it queues, when it will need it. */
|
|
2753
4592
|
startPullRequest(command) {
|
|
2754
4593
|
const op = command.operation;
|
|
2755
|
-
if (op.type !== "create" || op.
|
|
4594
|
+
if (op.type !== "create" || op.target || !op.pullRequestUrl) return void 0;
|
|
2756
4595
|
if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
|
|
4596
|
+
if (!op.repositoryId) return Promise.reject(new ReviewInputError("Name the checkout to fetch the PR into: target {kind:\"pullRequest\", repositoryPath, pullRequest}."));
|
|
2757
4597
|
const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
|
|
2758
4598
|
if (existing) try {
|
|
2759
4599
|
this.assertReusableReview(existing);
|
|
2760
4600
|
} catch (error) {
|
|
2761
4601
|
return Promise.reject(error);
|
|
2762
4602
|
}
|
|
2763
|
-
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
2764
|
-
id: op.repositoryId,
|
|
2765
|
-
preferred: existing && this.read(existing).pins?.repositoryId
|
|
2766
|
-
});
|
|
4603
|
+
return this.providers.resolvePullRequest(op.pullRequestUrl, { id: op.repositoryId });
|
|
2767
4604
|
}
|
|
2768
4605
|
startPullRequestTitle(command) {
|
|
2769
4606
|
const op = command.operation;
|
|
@@ -2785,7 +4622,7 @@ var ReviewStore = class {
|
|
|
2785
4622
|
existingReview(reviewId, others, requested) {
|
|
2786
4623
|
const snapshot = this.read(reviewId);
|
|
2787
4624
|
this.assertReusableReview(reviewId);
|
|
2788
|
-
const headMoved = snapshot.pins?.
|
|
4625
|
+
const headMoved = snapshot.pins?.head !== requested.head;
|
|
2789
4626
|
const working = this.activity.read(reviewId).activities ?? [];
|
|
2790
4627
|
return {
|
|
2791
4628
|
created: false,
|
|
@@ -2803,7 +4640,7 @@ var ReviewStore = class {
|
|
|
2803
4640
|
...others.length > 0 && { otherReviewIds: others }
|
|
2804
4641
|
};
|
|
2805
4642
|
}
|
|
2806
|
-
commitCommand(result, apply, guard, attribution) {
|
|
4643
|
+
commitCommand(result, apply, guard, attribution, shown) {
|
|
2807
4644
|
this.db.exec("BEGIN IMMEDIATE");
|
|
2808
4645
|
let owner;
|
|
2809
4646
|
try {
|
|
@@ -2821,7 +4658,10 @@ var ReviewStore = class {
|
|
|
2821
4658
|
}
|
|
2822
4659
|
if (result.deleted) this.activity.deleted(result.reviewId);
|
|
2823
4660
|
else if (owner) this.activity.extended(result.reviewId);
|
|
2824
|
-
this.notify(
|
|
4661
|
+
this.notify({
|
|
4662
|
+
...result,
|
|
4663
|
+
...shown
|
|
4664
|
+
});
|
|
2825
4665
|
}
|
|
2826
4666
|
assertMutation(reviewId, version) {
|
|
2827
4667
|
const current = this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId);
|
|
@@ -2830,7 +4670,7 @@ var ReviewStore = class {
|
|
|
2830
4670
|
notify(result) {
|
|
2831
4671
|
if (result.deleted) this.observedVersions.delete(result.reviewId);
|
|
2832
4672
|
else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
|
|
2833
|
-
if (!result.attention || result.lifecycle) for (const listener of this.listeners) try {
|
|
4673
|
+
if ((!result.attention || result.lifecycle) && !result.relations) for (const listener of this.listeners) try {
|
|
2834
4674
|
listener(result);
|
|
2835
4675
|
} catch {}
|
|
2836
4676
|
for (const listener of this.catalogListeners) try {
|
|
@@ -2844,9 +4684,26 @@ var ReviewStore = class {
|
|
|
2844
4684
|
has(reviewId) {
|
|
2845
4685
|
return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
|
|
2846
4686
|
}
|
|
4687
|
+
/** Saves each new commit link's commit in full, resolved in the board's
|
|
4688
|
+
* repository; one it does not have rejects the edit, as pins do. A link the
|
|
4689
|
+
* board already had stays as it was saved, even if its commit is gone. */
|
|
4690
|
+
async resolveCommitLinks(document, board, previous) {
|
|
4691
|
+
const saved = new Set(previous ? commitLinkCommits(previous) : []);
|
|
4692
|
+
const commits = commitLinkCommits(document).filter((commit) => !saved.has(commit));
|
|
4693
|
+
const repositoryId = boardRepositoryId(board);
|
|
4694
|
+
if (!commits.length || !repositoryId || !this.providers.resolveCommit) return document;
|
|
4695
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
4696
|
+
await Promise.all(commits.map(async (commit) => {
|
|
4697
|
+
const full = await this.providers.resolveCommit(repositoryId, commit);
|
|
4698
|
+
if (!full) throw new ReviewInputError(`Commit ${commit} is not in the board's repository. Fetch it first, or link a commit the repository has.`);
|
|
4699
|
+
resolved.set(commit, full);
|
|
4700
|
+
}));
|
|
4701
|
+
return respellCommitLinks(document, resolved);
|
|
4702
|
+
}
|
|
2847
4703
|
async validateExternal(snapshot, previous, repin = false) {
|
|
2848
4704
|
const warnings = [];
|
|
2849
|
-
const references = (
|
|
4705
|
+
const references = (board, tolerant = false) => {
|
|
4706
|
+
const { document, lenses = [] } = board;
|
|
2850
4707
|
const sources = /* @__PURE__ */ new Map();
|
|
2851
4708
|
const resources = /* @__PURE__ */ new Map();
|
|
2852
4709
|
const add = (source, peek) => {
|
|
@@ -2857,7 +4714,10 @@ var ReviewStore = class {
|
|
|
2857
4714
|
peek: peek || (kept?.peek ?? false)
|
|
2858
4715
|
});
|
|
2859
4716
|
};
|
|
2860
|
-
for (const { source, peek } of sourceReferences(document, {
|
|
4717
|
+
for (const { source, peek } of sourceReferences(document, {
|
|
4718
|
+
tolerant,
|
|
4719
|
+
repositoryId: boardRepositoryId(board)
|
|
4720
|
+
})) add(source, peek === true);
|
|
2861
4721
|
for (const { source } of lensSelections(lenses)) for (const anchor of sourceAnchors(source)) add(anchor, false);
|
|
2862
4722
|
for (const block of resourceReferences(document)) resources.set(JSON.stringify(block), block);
|
|
2863
4723
|
return {
|
|
@@ -2865,18 +4725,18 @@ var ReviewStore = class {
|
|
|
2865
4725
|
resources
|
|
2866
4726
|
};
|
|
2867
4727
|
};
|
|
2868
|
-
const current = references(snapshot
|
|
4728
|
+
const current = references(snapshot, repin);
|
|
2869
4729
|
const pinsChanged = previous && JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
|
|
2870
4730
|
const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
|
|
2871
4731
|
const changed = repin && pinsChanged && previous.pins && snapshot.pins ? await this.providers.filesChangedBetween?.(previous.pins, snapshot.pins).catch(() => void 0) : void 0;
|
|
2872
|
-
const retained = references(previous
|
|
4732
|
+
const retained = references(previous ?? { document: [] }, true);
|
|
2873
4733
|
const checks = [];
|
|
2874
4734
|
const retainedPins = new Set(explicitPins([...retained.sources.values()]).map((pins) => JSON.stringify(pins)));
|
|
2875
4735
|
for (const pins of explicitPins([...current.sources.values()])) if (!retainedPins.has(JSON.stringify(pins))) checks.push(this.providers.validatePins(pins));
|
|
2876
4736
|
for (const [key, { source, peek }] of current.sources) {
|
|
2877
4737
|
const kept = retained.sources.get(key);
|
|
2878
4738
|
if (pinsChanged && !source.pins || !kept || peek && !kept.peek) checks.push(this.providers.validateSource(anchorPins(source, snapshot.pins), source, { peek }).then(() => {
|
|
2879
|
-
if (repin && (!changed || changed[source.side].has(source.file))) warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: source pins changed; verify that this range still supports the document.`);
|
|
4739
|
+
if (repin && !readsWhereItDid(source, previous?.pins) && (!changed || changed[source.side].has(source.file))) warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: source pins changed; verify that this range still supports the document.`);
|
|
2880
4740
|
}, (error) => {
|
|
2881
4741
|
if (!repin && !(worktreeMoved && kept) || !(error instanceof ReviewInputError)) throw error;
|
|
2882
4742
|
warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`);
|
|
@@ -2890,6 +4750,106 @@ var ReviewStore = class {
|
|
|
2890
4750
|
return warnings.sort();
|
|
2891
4751
|
}
|
|
2892
4752
|
};
|
|
4753
|
+
/** The highest number in a document's `prefix-N` ids, units included. */
|
|
4754
|
+
function highestIdNumber(document) {
|
|
4755
|
+
let highest = 0;
|
|
4756
|
+
for (const [, number] of JSON.stringify(document).matchAll(/"id":"[^"]*?-(\d+)"/g)) highest = Math.max(highest, Number(number));
|
|
4757
|
+
return highest;
|
|
4758
|
+
}
|
|
4759
|
+
/** The title of a board a person made and has not named. */
|
|
4760
|
+
/** A person's save must start from the board's current version. */
|
|
4761
|
+
function assertBaseVersion(base, current) {
|
|
4762
|
+
if (base !== current) throw new ReviewInputError(`The board changed since version ${base}; it is now at version ${current}. Merge the latest version and save again.`, 409);
|
|
4763
|
+
}
|
|
4764
|
+
/**
|
|
4765
|
+
* A person's whole document as the store saves it: each new block's
|
|
4766
|
+
* clientKey taken, the blocks parsed, a flow's units adopted and every block
|
|
4767
|
+
* without an id given the next one after `nextId`. Every save then respells
|
|
4768
|
+
* its links (respellDocumentLinks) and resolves commit links it has not
|
|
4769
|
+
* saved before. Mutates `input`.
|
|
4770
|
+
*/
|
|
4771
|
+
function personDocument(input, nextId) {
|
|
4772
|
+
const keys = takeClientKeys(input);
|
|
4773
|
+
const document = documentSchema.parse(input);
|
|
4774
|
+
const allocate = (prefix) => `${prefix}-${++nextId}`;
|
|
4775
|
+
adoptFlowUnits(document, allocate);
|
|
4776
|
+
for (const block of document) assignMissingIds(block, allocate);
|
|
4777
|
+
assertUniqueIds(document);
|
|
4778
|
+
return {
|
|
4779
|
+
document,
|
|
4780
|
+
nextId: Math.max(nextId, highestIdNumber(document)),
|
|
4781
|
+
ids: Object.fromEntries([...keys.byPath].map(([path, key]) => [key, blockAt(document, path).id]))
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
const clientKeySchema = z.string().min(1).optional();
|
|
4785
|
+
/** Removes each block's clientKey from a person's raw document, keeping it
|
|
4786
|
+
* by the block's path when the block has no id. Counts the bare blocks. */
|
|
4787
|
+
function takeClientKeys(document) {
|
|
4788
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
4789
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4790
|
+
let bare = 0;
|
|
4791
|
+
const visit = (list, prefix) => list.forEach((block, index) => {
|
|
4792
|
+
if (!isJsonObject(block)) return;
|
|
4793
|
+
const path = `${prefix}${index}`;
|
|
4794
|
+
const clientKey = clientKeySchema.parse(block.clientKey);
|
|
4795
|
+
delete block.clientKey;
|
|
4796
|
+
if (block.id === void 0) {
|
|
4797
|
+
bare++;
|
|
4798
|
+
if (clientKey !== void 0) {
|
|
4799
|
+
if (seen.has(clientKey)) throw new ReviewInputError(`clientKey ${clientKey} names more than one block. Give each new block its own clientKey.`);
|
|
4800
|
+
seen.add(clientKey);
|
|
4801
|
+
byPath.set(path, clientKey);
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
if (Array.isArray(block.children)) visit(block.children, `${path}/`);
|
|
4805
|
+
});
|
|
4806
|
+
visit(document, "");
|
|
4807
|
+
return {
|
|
4808
|
+
byPath,
|
|
4809
|
+
bare
|
|
4810
|
+
};
|
|
4811
|
+
}
|
|
4812
|
+
/** The block at a path takeClientKeys recorded. */
|
|
4813
|
+
function blockAt(document, path) {
|
|
4814
|
+
const [first, ...rest] = path.split("/").map(Number);
|
|
4815
|
+
let block = document[first];
|
|
4816
|
+
for (const index of rest) if ("children" in block) block = block.children[index];
|
|
4817
|
+
return block;
|
|
4818
|
+
}
|
|
4819
|
+
/** Ids name one component each across the whole document. */
|
|
4820
|
+
function assertUniqueIds(document) {
|
|
4821
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4822
|
+
for (const { id } of elements(document)) {
|
|
4823
|
+
if (id === void 0) continue;
|
|
4824
|
+
if (seen.has(id)) throw new ReviewInputError(`Id ${id} names more than one component. Give each block its own id, or leave a new block's id out.`);
|
|
4825
|
+
seen.add(id);
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
/** Each block's own content by id, nested blocks included. A section or
|
|
4829
|
+
* callout is kept without its children, so a change to a child is only the
|
|
4830
|
+
* child's. */
|
|
4831
|
+
function ownBlocks(document, into = /* @__PURE__ */ new Map()) {
|
|
4832
|
+
for (const block of document) if ("children" in block) {
|
|
4833
|
+
const { children, ...own } = block;
|
|
4834
|
+
if (block.id !== void 0) into.set(block.id, own);
|
|
4835
|
+
ownBlocks(children, into);
|
|
4836
|
+
} else if (block.id !== void 0) into.set(block.id, block);
|
|
4837
|
+
return into;
|
|
4838
|
+
}
|
|
4839
|
+
/** The blocks added, removed or changed from one document to the next. A
|
|
4840
|
+
* block moved with its content unchanged did not change. */
|
|
4841
|
+
function changedBlocks(before, after) {
|
|
4842
|
+
const old = ownBlocks(before);
|
|
4843
|
+
const now = ownBlocks(after);
|
|
4844
|
+
return [.../* @__PURE__ */ new Set([...old.keys(), ...now.keys()])].filter((id) => !isDeepStrictEqual(old.get(id), now.get(id)));
|
|
4845
|
+
}
|
|
4846
|
+
/** Whether a reference's own pins read the commit the board's previous
|
|
4847
|
+
* pins did. */
|
|
4848
|
+
function readsWhereItDid(source, previous) {
|
|
4849
|
+
if (!source.pins || !previous) return false;
|
|
4850
|
+
const commit = source.side === "base" ? source.pins.base ?? source.pins.head : source.pins.head;
|
|
4851
|
+
return source.pins.repositoryId === previous.repositoryId && (commit === previous.head || commit === previous.base);
|
|
4852
|
+
}
|
|
2893
4853
|
/** Without a resolver, a commits target that names both commits pins them as
|
|
2894
4854
|
* given; validatePins still checks they exist. */
|
|
2895
4855
|
function namedCommits(target) {
|
|
@@ -2906,26 +4866,19 @@ function namedCommits(target) {
|
|
|
2906
4866
|
}
|
|
2907
4867
|
/** A new document's first version, before its initial content. Field order
|
|
2908
4868
|
* is kept so stored JSON reads as it always has. */
|
|
2909
|
-
function createdSnapshot(id, op, resolved, defaultTitle) {
|
|
2910
|
-
const title = op.title
|
|
2911
|
-
if (
|
|
2912
|
-
|
|
2913
|
-
reviewId: id,
|
|
2914
|
-
version: 0,
|
|
2915
|
-
title,
|
|
2916
|
-
kind: op.kind,
|
|
2917
|
-
document: [],
|
|
2918
|
-
createdAt: ""
|
|
2919
|
-
};
|
|
2920
|
-
return {
|
|
4869
|
+
function createdSnapshot(id, op, resolved, defaultTitle, untitled = false) {
|
|
4870
|
+
const title = op.title || defaultTitle || (untitled ? "" : void 0);
|
|
4871
|
+
if (title === void 0) throw new ReviewInputError("Supply a title.");
|
|
4872
|
+
const snapshot = {
|
|
2921
4873
|
reviewId: id,
|
|
2922
4874
|
version: 0,
|
|
2923
4875
|
title,
|
|
2924
|
-
pins: resolved.pins,
|
|
2925
4876
|
target: resolved.target,
|
|
2926
4877
|
document: [],
|
|
2927
4878
|
createdAt: ""
|
|
2928
4879
|
};
|
|
4880
|
+
if (resolved.pins) snapshot.pins = resolved.pins;
|
|
4881
|
+
return snapshot;
|
|
2929
4882
|
}
|
|
2930
4883
|
function inspectSnapshot(snapshot, targetId) {
|
|
2931
4884
|
if (targetId !== void 0) {
|
|
@@ -2939,6 +4892,8 @@ function inspectSnapshot(snapshot, targetId) {
|
|
|
2939
4892
|
label: "title" in element ? element.title : "label" in element ? element.label : element.type === "markdown" ? element.markdown.slice(0, 120) : void 0
|
|
2940
4893
|
}));
|
|
2941
4894
|
}
|
|
4895
|
+
/** How many ancestry questions git answers at once. */
|
|
4896
|
+
const ANCESTRY_CONCURRENCY = 8;
|
|
2942
4897
|
//#endregion
|
|
2943
4898
|
//#region src/review-api/version-cache.ts
|
|
2944
4899
|
/** A stored version never changes, so work derived from it runs once. Keeps
|
|
@@ -3371,7 +5326,6 @@ var ReviewWorkspaces = class {
|
|
|
3371
5326
|
};
|
|
3372
5327
|
//#endregion
|
|
3373
5328
|
//#region src/review-api/worktree-events.ts
|
|
3374
|
-
const exec$1 = promisify(execFile);
|
|
3375
5329
|
const normalize = (name) => name.replaceAll("\\", "/").normalize("NFC");
|
|
3376
5330
|
const parent = (file) => file.slice(0, Math.max(0, file.lastIndexOf("/")));
|
|
3377
5331
|
/** Directories that hold tracked files, the root as "". */
|
|
@@ -3394,7 +5348,7 @@ function atTrackedEdge(name, tracked) {
|
|
|
3394
5348
|
/** Wholly ignored directories, so watchers can skip them. */
|
|
3395
5349
|
async function ignoredDirectories(rootPath, gitDirectory) {
|
|
3396
5350
|
try {
|
|
3397
|
-
const { stdout } = await
|
|
5351
|
+
const { stdout } = await runVcs("git", [
|
|
3398
5352
|
"-C",
|
|
3399
5353
|
rootPath,
|
|
3400
5354
|
...gitDirectory ? [
|
|
@@ -3433,7 +5387,7 @@ function gitEventMatters(name, prefix) {
|
|
|
3433
5387
|
/** Where this checkout's git directory sits in the common one: "" for the main checkout. */
|
|
3434
5388
|
async function gitDirectoryPrefix(rootPath, common) {
|
|
3435
5389
|
try {
|
|
3436
|
-
const { stdout } = await
|
|
5390
|
+
const { stdout } = await runVcs("git", [
|
|
3437
5391
|
"-C",
|
|
3438
5392
|
rootPath,
|
|
3439
5393
|
"rev-parse",
|
|
@@ -3449,7 +5403,6 @@ async function gitDirectoryPrefix(rootPath, common) {
|
|
|
3449
5403
|
//#endregion
|
|
3450
5404
|
//#region src/review-api/worktree-source.ts
|
|
3451
5405
|
const { lstat: lstat$1, readFile: readFile$1, readlink, realpath: realpath$1 } = checkoutFs;
|
|
3452
|
-
const exec = promisify(execFile);
|
|
3453
5406
|
const EMPTY_SOURCE = "empty-worktree-baseline";
|
|
3454
5407
|
/** Never follow a working-copy link outside the selected checkout. */
|
|
3455
5408
|
async function localSourcePath(root, file) {
|
|
@@ -3464,7 +5417,7 @@ async function localSourcePath(root, file) {
|
|
|
3464
5417
|
async function workingFiles(vcs) {
|
|
3465
5418
|
const gitDirectory = vcs.kind === "jj" ? await gitCommonDir(vcs.rootPath) : void 0;
|
|
3466
5419
|
if (vcs.kind === "jj" && !gitDirectory) throw new ReviewInputError("Working source requires a Git-backed jj repository.");
|
|
3467
|
-
const { stdout } = await
|
|
5420
|
+
const { stdout } = await runVcs("git", [
|
|
3468
5421
|
"-C",
|
|
3469
5422
|
vcs.rootPath,
|
|
3470
5423
|
...gitDirectory ? [
|
|
@@ -3500,7 +5453,7 @@ async function workingFiles(vcs) {
|
|
|
3500
5453
|
async function untrackedFileCount(vcs) {
|
|
3501
5454
|
if (vcs.kind !== "git") return void 0;
|
|
3502
5455
|
try {
|
|
3503
|
-
const { stdout } = await
|
|
5456
|
+
const { stdout } = await runVcs("git", [
|
|
3504
5457
|
"-C",
|
|
3505
5458
|
vcs.rootPath,
|
|
3506
5459
|
"ls-files",
|
|
@@ -3580,6 +5533,10 @@ function sliceRange(file, source) {
|
|
|
3580
5533
|
text: inputError(() => sliceSourceRange(file.text, source))
|
|
3581
5534
|
};
|
|
3582
5535
|
}
|
|
5536
|
+
/** How long a PR's state is shown before it is read again. */
|
|
5537
|
+
const PULL_REQUEST_STATE_TTL_MS = 3e5;
|
|
5538
|
+
/** How many PR states are read from GitHub at once. */
|
|
5539
|
+
const PULL_REQUEST_STATE_READS = 2;
|
|
3583
5540
|
/** Local source/resource boundary, including Desktop-only local language context. */
|
|
3584
5541
|
var LocalReviewData = class {
|
|
3585
5542
|
store;
|
|
@@ -3657,7 +5614,7 @@ var LocalReviewData = class {
|
|
|
3657
5614
|
};
|
|
3658
5615
|
}
|
|
3659
5616
|
if (commit && !anchor) await this.comparison(await this.documentPins(snapshot), commit);
|
|
3660
|
-
const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
|
|
5617
|
+
const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId ?? (snapshot.target?.kind === "project" ? snapshot.target.repositoryId : void 0);
|
|
3661
5618
|
if (!repositoryId) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
3662
5619
|
const unavailable = {
|
|
3663
5620
|
rootPath: null,
|
|
@@ -3712,7 +5669,7 @@ var LocalReviewData = class {
|
|
|
3712
5669
|
const context = await resolveRepoContext(repository);
|
|
3713
5670
|
if (!rootPath || !context) throw new ReviewInputError("Could not open the selected source checkout.", 409);
|
|
3714
5671
|
if (!live) {
|
|
3715
|
-
const { stdout } = await
|
|
5672
|
+
const { stdout } = await runVcs("git", [
|
|
3716
5673
|
"-C",
|
|
3717
5674
|
rootPath,
|
|
3718
5675
|
"status",
|
|
@@ -3727,7 +5684,7 @@ var LocalReviewData = class {
|
|
|
3727
5684
|
const current = await readWorkspace(workspacePath);
|
|
3728
5685
|
if (current !== null) {
|
|
3729
5686
|
const previous = current ?? await readWorkspace(path.join(workspaceDirectory, `${path.basename(repository)}.code-workspace`));
|
|
3730
|
-
const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
5687
|
+
const title = `${boardTitle(snapshot.title)} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
3731
5688
|
const workspace = previous ?? {
|
|
3732
5689
|
folders: [],
|
|
3733
5690
|
settings: { "files.readonlyInclude": { "**/*": true } }
|
|
@@ -3764,12 +5721,25 @@ var LocalReviewData = class {
|
|
|
3764
5721
|
rootPath
|
|
3765
5722
|
};
|
|
3766
5723
|
}
|
|
3767
|
-
/** A document read that needs default pins
|
|
5724
|
+
/** A document read that needs default pins: its own, or for a plan its
|
|
5725
|
+
* repository's live checkout with base at head; 409 when it has neither. */
|
|
3768
5726
|
async documentPins(snapshot) {
|
|
3769
|
-
const pins = await this.sourcePins(snapshot);
|
|
5727
|
+
const pins = await this.sourcePins(snapshot) ?? (snapshot.target?.kind === "project" ? await this.livePins(snapshot.target.repositoryId) : void 0);
|
|
3770
5728
|
if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
3771
5729
|
return pins;
|
|
3772
5730
|
}
|
|
5731
|
+
/** A checkout's current state, base at head: what a plan reads. */
|
|
5732
|
+
async livePins(repositoryId) {
|
|
5733
|
+
const vcs = await this.vcs(repositoryId);
|
|
5734
|
+
if (!vcs) throw unavailableCheckout();
|
|
5735
|
+
const { revision, commit } = await this.worktreeState(repositoryId, vcs);
|
|
5736
|
+
return {
|
|
5737
|
+
repositoryId,
|
|
5738
|
+
base: commit,
|
|
5739
|
+
head: commit,
|
|
5740
|
+
worktreeRevision: revision
|
|
5741
|
+
};
|
|
5742
|
+
}
|
|
3773
5743
|
/** Pins a reference names itself, checked against the registered checkout. */
|
|
3774
5744
|
async anchorSourcePins(anchor) {
|
|
3775
5745
|
if (!existsSync(this.store.repositoryPath(anchor.repositoryId))) throw unavailableCheckout();
|
|
@@ -3779,7 +5749,10 @@ var LocalReviewData = class {
|
|
|
3779
5749
|
* `snapshot` is a stored version. */
|
|
3780
5750
|
async unavailableAnchors(snapshot) {
|
|
3781
5751
|
const missing = /* @__PURE__ */ new Set();
|
|
3782
|
-
|
|
5752
|
+
const repositoryIds = pinnedRepositoryIds(snapshot.document);
|
|
5753
|
+
const boardRepository = boardRepositoryId(snapshot);
|
|
5754
|
+
if (boardRepository) repositoryIds.add(boardRepository);
|
|
5755
|
+
for (const repositoryId of repositoryIds) try {
|
|
3783
5756
|
if (!existsSync(this.store.repositoryPath(repositoryId))) missing.add(repositoryId);
|
|
3784
5757
|
} catch (error) {
|
|
3785
5758
|
if (!(error instanceof ReviewInputError)) throw error;
|
|
@@ -3788,7 +5761,10 @@ var LocalReviewData = class {
|
|
|
3788
5761
|
if (!missing.size) return [];
|
|
3789
5762
|
return this.storedReferences.get(snapshot).filter((ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId)).map((ref) => ref.id);
|
|
3790
5763
|
}
|
|
3791
|
-
storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, {
|
|
5764
|
+
storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, {
|
|
5765
|
+
tolerant: true,
|
|
5766
|
+
repositoryId: boardRepositoryId(snapshot)
|
|
5767
|
+
}), { capacity: 512 });
|
|
3792
5768
|
trackedFiles = /* @__PURE__ */ new Map();
|
|
3793
5769
|
repositories = /* @__PURE__ */ new Map();
|
|
3794
5770
|
readers = /* @__PURE__ */ new Map();
|
|
@@ -4010,7 +5986,10 @@ var LocalReviewData = class {
|
|
|
4010
5986
|
staleSources: []
|
|
4011
5987
|
};
|
|
4012
5988
|
delete projected.sourceUnavailable;
|
|
4013
|
-
for (const reference of sourceReferences(snapshot.document, {
|
|
5989
|
+
for (const reference of sourceReferences(snapshot.document, {
|
|
5990
|
+
tolerant: true,
|
|
5991
|
+
repositoryId: boardRepositoryId(snapshot)
|
|
5992
|
+
})) try {
|
|
4014
5993
|
await this.quote(anchorPins(reference.source, pins), reference.source);
|
|
4015
5994
|
} catch (error) {
|
|
4016
5995
|
if (!(error instanceof ReviewInputError)) throw error;
|
|
@@ -4022,6 +6001,7 @@ var LocalReviewData = class {
|
|
|
4022
6001
|
* for a document without default pins. */
|
|
4023
6002
|
async sourcePins(snapshot) {
|
|
4024
6003
|
if (snapshot.target?.kind === "worktree") return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
|
|
6004
|
+
if (snapshot.target?.kind === "project" && !existsSync(this.store.repositoryPath(snapshot.target.repositoryId))) throw unavailableCheckout();
|
|
4025
6005
|
if (!snapshot.pins) return void 0;
|
|
4026
6006
|
if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId))) throw unavailableCheckout();
|
|
4027
6007
|
return snapshot.pins;
|
|
@@ -4031,14 +6011,13 @@ var LocalReviewData = class {
|
|
|
4031
6011
|
const vcs = await this.vcs(pins.repositoryId);
|
|
4032
6012
|
if (!vcs || vcs.kind !== "git") return void 0;
|
|
4033
6013
|
try {
|
|
4034
|
-
const { stdout } = await
|
|
6014
|
+
const { stdout } = await runVcs("git", [
|
|
4035
6015
|
"-C",
|
|
4036
6016
|
vcs.rootPath,
|
|
4037
6017
|
"rev-parse",
|
|
4038
6018
|
"--symbolic-full-name",
|
|
4039
6019
|
"--verify",
|
|
4040
|
-
|
|
4041
|
-
!headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : headRef
|
|
6020
|
+
!headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : { rev: headRef }
|
|
4042
6021
|
]);
|
|
4043
6022
|
const ref = stdout.trim();
|
|
4044
6023
|
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) return void 0;
|
|
@@ -4051,6 +6030,7 @@ var LocalReviewData = class {
|
|
|
4051
6030
|
/** `pinned` refreshes a stored target: a worktree base ref resolves to its
|
|
4052
6031
|
* current fork point, and keeps the pinned one if the ref is gone. */
|
|
4053
6032
|
async resolveTarget(target, pinned) {
|
|
6033
|
+
if (target.kind === "project") throw new ReviewInputError("A project target has no commits.");
|
|
4054
6034
|
const vcs = await this.vcs(target.repositoryId);
|
|
4055
6035
|
if (!vcs) throw unavailableCheckout();
|
|
4056
6036
|
if (target.kind === "commits") {
|
|
@@ -4115,6 +6095,86 @@ var LocalReviewData = class {
|
|
|
4115
6095
|
commit: fork.commit
|
|
4116
6096
|
};
|
|
4117
6097
|
}
|
|
6098
|
+
/** Each PR's state, by URL, as last read. */
|
|
6099
|
+
pullRequestStates = /* @__PURE__ */ new Map();
|
|
6100
|
+
pullRequestStateListeners = /* @__PURE__ */ new Set();
|
|
6101
|
+
pullRequestStateTimer;
|
|
6102
|
+
pullRequestStateQueue = [];
|
|
6103
|
+
pullRequestStateReads = 0;
|
|
6104
|
+
/** While GitHub rate-limits, no state is read. */
|
|
6105
|
+
pullRequestStatesPausedUntil = 0;
|
|
6106
|
+
/** Calls back when a PR's state changes. While anyone listens, every PR
|
|
6107
|
+
* still shown is read again once its state is a few minutes old. */
|
|
6108
|
+
subscribePullRequestStates(listener) {
|
|
6109
|
+
this.pullRequestStateListeners.add(listener);
|
|
6110
|
+
this.pullRequestStateTimer ??= setInterval(() => {
|
|
6111
|
+
for (const [url, entry] of this.pullRequestStates) if (Date.now() - entry.seenAt > 2 * PULL_REQUEST_STATE_TTL_MS) this.pullRequestStates.delete(url);
|
|
6112
|
+
else this.pullRequestState(url, { seen: false });
|
|
6113
|
+
this.readPullRequestStates();
|
|
6114
|
+
}, PULL_REQUEST_STATE_TTL_MS / 5);
|
|
6115
|
+
this.pullRequestStateTimer.unref?.();
|
|
6116
|
+
return () => {
|
|
6117
|
+
this.pullRequestStateListeners.delete(listener);
|
|
6118
|
+
if (!this.pullRequestStateListeners.size) {
|
|
6119
|
+
clearInterval(this.pullRequestStateTimer);
|
|
6120
|
+
this.pullRequestStateTimer = void 0;
|
|
6121
|
+
}
|
|
6122
|
+
};
|
|
6123
|
+
}
|
|
6124
|
+
/** A PR's state as last read from GitHub; a read older than a few minutes
|
|
6125
|
+
* queues another in the background. Undefined until the first read lands,
|
|
6126
|
+
* and a failed read keeps the last state. A merged or closed PR is not
|
|
6127
|
+
* read again. */
|
|
6128
|
+
pullRequestState(url, { seen = true } = {}) {
|
|
6129
|
+
if (!url || !this.options.pullRequestStates) return void 0;
|
|
6130
|
+
const entry = this.pullRequestStates.get(url) ?? {
|
|
6131
|
+
readAt: 0,
|
|
6132
|
+
seenAt: 0,
|
|
6133
|
+
reading: false
|
|
6134
|
+
};
|
|
6135
|
+
this.pullRequestStates.set(url, entry);
|
|
6136
|
+
if (seen) entry.seenAt = Date.now();
|
|
6137
|
+
if (!entry.reading && entry.state !== "merged" && entry.state !== "closed" && Date.now() - entry.readAt > PULL_REQUEST_STATE_TTL_MS) {
|
|
6138
|
+
entry.reading = true;
|
|
6139
|
+
this.pullRequestStateQueue.push(url);
|
|
6140
|
+
this.readPullRequestStates();
|
|
6141
|
+
}
|
|
6142
|
+
return entry.state;
|
|
6143
|
+
}
|
|
6144
|
+
/** Reads queued PR states a few at a time. */
|
|
6145
|
+
readPullRequestStates() {
|
|
6146
|
+
while (this.pullRequestStateReads < PULL_REQUEST_STATE_READS && this.pullRequestStateQueue.length && Date.now() >= this.pullRequestStatesPausedUntil) {
|
|
6147
|
+
const url = this.pullRequestStateQueue.shift();
|
|
6148
|
+
const entry = this.pullRequestStates.get(url);
|
|
6149
|
+
if (!entry) continue;
|
|
6150
|
+
this.pullRequestStateReads++;
|
|
6151
|
+
Promise.resolve().then(() => {
|
|
6152
|
+
const { provider, address } = resolvePullRequest(url);
|
|
6153
|
+
return provider.readPullRequest(address, this.options.pullRequests ?? defaultPullRequestDeps);
|
|
6154
|
+
}).then((pullRequest) => {
|
|
6155
|
+
if ((!pullRequest.state || pullRequest.state === entry.state) && pullRequest.baseRefName === entry.baseBranch && pullRequest.headBranch === entry.headBranch) return;
|
|
6156
|
+
entry.state = pullRequest.state ?? entry.state;
|
|
6157
|
+
entry.baseBranch = pullRequest.baseRefName;
|
|
6158
|
+
entry.headBranch = pullRequest.headBranch;
|
|
6159
|
+
for (const listener of this.pullRequestStateListeners) listener();
|
|
6160
|
+
}).catch((error) => {
|
|
6161
|
+
if (error instanceof GitHubRefusedRead) this.pullRequestStatesPausedUntil = error.retryAt;
|
|
6162
|
+
}).finally(() => {
|
|
6163
|
+
entry.readAt = Date.now();
|
|
6164
|
+
entry.reading = false;
|
|
6165
|
+
this.pullRequestStateReads--;
|
|
6166
|
+
this.readPullRequestStates();
|
|
6167
|
+
});
|
|
6168
|
+
}
|
|
6169
|
+
}
|
|
6170
|
+
/** The branch a PR merges into, as last read with its state. */
|
|
6171
|
+
pullRequestBaseBranch(url) {
|
|
6172
|
+
return this.pullRequestStates.get(url)?.baseBranch;
|
|
6173
|
+
}
|
|
6174
|
+
/** A PR's own branch, as last read with its state. */
|
|
6175
|
+
pullRequestHeadBranch(url) {
|
|
6176
|
+
return this.pullRequestStates.get(url)?.headBranch;
|
|
6177
|
+
}
|
|
4118
6178
|
/** The PR's current comparison, fetched into a registered checkout of its repository. */
|
|
4119
6179
|
async resolvePullRequest(url, repository) {
|
|
4120
6180
|
const deps = this.options.pullRequests ?? defaultPullRequestDeps;
|
|
@@ -4155,9 +6215,8 @@ var LocalReviewData = class {
|
|
|
4155
6215
|
}
|
|
4156
6216
|
/** A registered checkout with a remote for host/owner/repo, and that remote. */
|
|
4157
6217
|
async pullRequestCheckout(provider, address, repository, deps) {
|
|
4158
|
-
const
|
|
4159
|
-
|
|
4160
|
-
if (repository.id && candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
|
|
6218
|
+
const candidates = this.store.repositories().filter((entry) => entry.id === repository.id);
|
|
6219
|
+
if (candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
|
|
4161
6220
|
for (const { id } of candidates) {
|
|
4162
6221
|
if (!existsSync(this.store.repositoryPath(id))) continue;
|
|
4163
6222
|
const vcs = await this.vcs(id);
|
|
@@ -4174,7 +6233,7 @@ var LocalReviewData = class {
|
|
|
4174
6233
|
}
|
|
4175
6234
|
const { host, slug } = address;
|
|
4176
6235
|
const name = host === "github.com" ? slug : `${host}/${slug}`;
|
|
4177
|
-
throw new ReviewInputError(
|
|
6236
|
+
throw new ReviewInputError(`That checkout has no remote for ${name}. Name a checkout of ${name} as the target's repositoryPath.`, 404);
|
|
4178
6237
|
}
|
|
4179
6238
|
async resolvePins(repositoryId, base, head) {
|
|
4180
6239
|
const vcs = await this.vcs(repositoryId);
|
|
@@ -4206,6 +6265,45 @@ var LocalReviewData = class {
|
|
|
4206
6265
|
head
|
|
4207
6266
|
};
|
|
4208
6267
|
}
|
|
6268
|
+
/** What a commit is, for a quote read at it: its subject, author and
|
|
6269
|
+
* date, and how far behind the default branch's tip it is when both are
|
|
6270
|
+
* known. */
|
|
6271
|
+
async commitInfo(repositoryId, commit) {
|
|
6272
|
+
const vcs = await this.vcs(repositoryId);
|
|
6273
|
+
if (!vcs) throw unavailableCheckout();
|
|
6274
|
+
const git = async (...args) => (await runVcs("git", [
|
|
6275
|
+
"-C",
|
|
6276
|
+
vcs.rootPath,
|
|
6277
|
+
...args
|
|
6278
|
+
])).stdout.trim();
|
|
6279
|
+
let line;
|
|
6280
|
+
try {
|
|
6281
|
+
line = await git("log", "-1", "--format=%H%x00%s%x00%an%x00%aI", { rev: commit });
|
|
6282
|
+
} catch {
|
|
6283
|
+
throw new ReviewInputError("That commit is not in the repository.", 404);
|
|
6284
|
+
}
|
|
6285
|
+
const [full = commit, subject = "", author = "", authoredAt = ""] = line.split("\0");
|
|
6286
|
+
const branch = await vcs.defaultBranch().catch(() => null);
|
|
6287
|
+
const behind = branch ? await git("rev-list", "--count", { rev: `${full}..${branch.commit}` }).then((count) => ({
|
|
6288
|
+
count: Number(count),
|
|
6289
|
+
branch: branch.ref.replace(/^origin\//, "")
|
|
6290
|
+
})).catch(() => void 0) : void 0;
|
|
6291
|
+
return {
|
|
6292
|
+
commit: full,
|
|
6293
|
+
subject,
|
|
6294
|
+
author,
|
|
6295
|
+
authoredAt,
|
|
6296
|
+
...behind && { behind }
|
|
6297
|
+
};
|
|
6298
|
+
}
|
|
6299
|
+
/** The full commit a commit ID or its prefix names in the repository,
|
|
6300
|
+
* or undefined when it names none. */
|
|
6301
|
+
async resolveCommit(repositoryId, commit) {
|
|
6302
|
+
const vcs = await this.vcs(repositoryId);
|
|
6303
|
+
if (!vcs) throw unavailableCheckout();
|
|
6304
|
+
const resolved = await vcs.resolveRevision(commit);
|
|
6305
|
+
return resolved?.commit.startsWith(commit.toLowerCase()) ? resolved.commit : void 0;
|
|
6306
|
+
}
|
|
4209
6307
|
async validatePins(pins) {
|
|
4210
6308
|
if (pins.worktreeRevision) {
|
|
4211
6309
|
if (!await this.vcs(pins.repositoryId)) throw unavailableCheckout();
|
|
@@ -4568,6 +6666,7 @@ async function unwatch(entry) {
|
|
|
4568
6666
|
/** Linux fails to unwatch a directory that is already gone. */
|
|
4569
6667
|
const release = (subscription) => subscription.unsubscribe().catch(() => {});
|
|
4570
6668
|
function openLocalReviewStore(databasePath, options = {}) {
|
|
6669
|
+
const { checkoutOwners, ...dataOptions } = options;
|
|
4571
6670
|
const store = new ReviewStore(databasePath, {
|
|
4572
6671
|
projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
|
|
4573
6672
|
resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
|
|
@@ -4577,14 +6676,16 @@ function openLocalReviewStore(databasePath, options = {}) {
|
|
|
4577
6676
|
sourcePins: (snapshot) => data.sourcePins(snapshot),
|
|
4578
6677
|
unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
|
|
4579
6678
|
validatePins: (pins) => data.validatePins(pins),
|
|
6679
|
+
resolveCommit: (repositoryId, commit) => data.resolveCommit(repositoryId, commit),
|
|
4580
6680
|
filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
|
|
4581
6681
|
validateSource: (pins, source, options) => data.validateSource(pins, source, options),
|
|
4582
|
-
validateResource: (pins, block) => data.validateResource(pins, block)
|
|
4583
|
-
|
|
6682
|
+
validateResource: (pins, block) => data.validateResource(pins, block),
|
|
6683
|
+
pullRequestBaseBranch: (url) => data.pullRequestBaseBranch(url)
|
|
6684
|
+
}, checkoutOwners);
|
|
4584
6685
|
let data;
|
|
4585
6686
|
try {
|
|
4586
6687
|
data = new LocalReviewData(store, {
|
|
4587
|
-
...
|
|
6688
|
+
...dataOptions,
|
|
4588
6689
|
workspaceDatabase: `${databasePath}.workspaces`
|
|
4589
6690
|
});
|
|
4590
6691
|
} catch (error) {
|
|
@@ -4617,4 +6718,4 @@ async function readWorkspace(file) {
|
|
|
4617
6718
|
}
|
|
4618
6719
|
}
|
|
4619
6720
|
//#endregion
|
|
4620
|
-
export {
|
|
6721
|
+
export { projectOfCheckout as a, activityBeginSchema as c, pullRequestProvider as d, textualRows as f, CheckoutGroups as g, diffrMissingError as h, inspectSnapshot as i, activityEndSchema as l, diffrExecutable as m, uploadSchema as n, groupCheckoutsAhead as o, invalidateStructuralComparisons as p, commandSchema as r, initializeReviewStoreSchema as s, openLocalReviewStore as t, activityUpdateSchema as u };
|