@dev.fast/whiteboard 0.3.4-preview.20261019.127 → 0.3.4
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-B5-HYi1D.js → account-alias-CprZojXd.js} +8 -13
- package/dist/agent-cli-C6SPSII0.js +2 -0
- package/dist/{agent-cli-CohHf6Ua.js → agent-cli-CThN7Q59.js} +10 -15
- package/dist/{agent-client-Dtc-Kn_w.js → agent-client-BOOi7EUR.js} +3 -13
- package/dist/{authoring-tools-B7KU04f7.js → authoring-tools-BNwuMnz6.js} +27 -34
- package/dist/{background-server-C03RKxKS.js → background-server-CkvxA-6O.js} +5 -5
- package/dist/{background-server-CB9C4uoc.js → background-server-DLSDogkI.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-CJaEb7zC.js → cli-R4yfa5DT.js} +1 -1
- package/dist/{cli-runner-DGDIqh3V.js → cli-runner-Cuc9FSzu.js} +26 -26
- package/dist/cli.js +3 -4
- package/dist/{client-lKVF_Elo.js → client-BN_FDxFr.js} +47 -253
- package/dist/{fuzzy-match-BmVJJm9I.js → fuzzy-match-BO9bK37N.js} +4 -6
- package/dist/{headless-host-DRtjf9WA.js → headless-host-CVBOQyRS.js} +1 -1
- package/dist/{headless-host-XhL1X4vX.js → headless-host-Cog6SpS9.js} +140 -513
- package/dist/{local-data-Cpl4ENbJ.js → local-data-D_9tvdsp.js} +323 -2419
- package/dist/{mcp-ovmiUE_x.js → mcp-D1hziANa.js} +9 -5
- package/dist/{package-paths-BWxO-UEL.js → package-paths-C--__jEv.js} +1 -1
- package/dist/profile-CYPmvBoA.js +2 -0
- package/dist/{profile-BadSkCkI.js → profile-DokFw782.js} +8 -20
- package/dist/{remote-attach-Dd-YobM6.js → remote-attach-CEJ-kvDY.js} +4 -4
- package/dist/{remote-extensions-CNXwqK0D.js → remote-extensions-Blcjr9Ze.js} +3 -3
- package/dist/{remote-extensions-VP-kfq0C.js → remote-extensions-GN8Gf_qG.js} +1 -1
- package/dist/{remote-language-server-BXk87KCr.js → remote-language-server-BTKBQ7x4.js} +5 -5
- package/dist/{remote-uninstall-DuOzsvaD.js → remote-uninstall-DW7Gdryh.js} +6 -6
- package/dist/{request-origin-B9qCPiVB.js → request-origin-Zcn_CjX4.js} +12 -45
- package/dist/{review-app-launcher-C7el2Uoy.js → review-app-launcher-CkwLclzR.js} +2 -2
- package/dist/{review-home-paths-D8AoCdd2.js → review-home-paths-j2BRAaNv.js} +17 -19
- package/dist/{review-telemetry-BsoRMC2w.js → review-telemetry-BLNwYnLV.js} +5 -5
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-CsG2EWED.js → s3-DtJFWC0G.js} +1 -1
- package/dist/{s3-config-CjcLyFV5.js → s3-config-BTO12tCC.js} +2 -4
- package/dist/{s3-config-zz4GWh2T.js → s3-config-CSy3fx8H.js} +1 -1
- package/dist/server/desktop-host.js +76 -29
- package/dist/{server-discovery-CKjw3-6h.js → server-discovery-Bey8IqXH.js} +3 -3
- package/dist/sharing/index.d.ts +70 -484
- package/dist/sharing/index.js +1 -1
- package/dist/{src-WPaQn75m.js → src-BMPuxWv4.js} +73 -208
- package/dist/{src-BiWNbcF_.js → src-DsM3jO8G.js} +12 -89
- package/dist/{stored-document-migration-dFbewRTG.js → stored-document-migration-CChxtFAy.js} +269 -270
- package/dist/{thread-state-Do5LX10I.js → thread-state-5pYr6_zM.js} +2 -7
- package/dist/{tool-failure-DvI-gys2.js → tool-failure-C9MpbXJJ.js} +3 -10
- package/instructions/authoring.md +2 -3
- package/instructions/scratchpad.md +23 -0
- package/package.json +4 -4
- package/src/ask/agents.ts +1 -13
- package/src/ask/checkout-files.ts +5 -2
- package/src/ask/thread-state.ts +0 -4
- package/src/ask/thread.ts +0 -16
- package/src/ask/threads.ts +3 -24
- package/src/cli-install.ts +2 -13
- package/src/cli-runner.ts +1 -4
- package/src/cli.ts +1 -4
- package/src/diff-selection-migration.ts +34 -48
- package/src/lens-selection.ts +2 -38
- package/src/review-api/README.md +13 -52
- package/src/review-api/activity.ts +4 -1
- package/src/review-api/agent-cli.ts +1 -9
- package/src/review-api/anchor-quotes.ts +7 -14
- package/src/review-api/ask-history.ts +51 -137
- package/src/review-api/authoring-tools.ts +30 -45
- package/src/review-api/blocks/definition.ts +1 -1
- package/src/review-api/blocks/markdown.ts +2 -2
- package/src/review-api/document-headings.ts +27 -58
- package/src/review-api/document-text.ts +2 -31
- package/src/review-api/document.ts +34 -281
- package/src/review-api/github-provider.ts +13 -51
- package/src/review-api/http.ts +123 -702
- package/src/review-api/instructions.ts +19 -0
- package/src/review-api/local-data.ts +46 -310
- package/src/review-api/mcp.ts +11 -3
- package/src/review-api/onboarding.ts +1 -1
- package/src/review-api/profile.ts +6 -37
- package/src/review-api/public-tools.ts +2 -24
- package/src/review-api/pull-request.ts +49 -82
- package/src/review-api/request-origin.ts +0 -10
- package/src/review-api/review-progress.ts +27 -61
- package/src/review-api/store-schema.ts +29 -38
- package/src/review-api/store.ts +478 -2142
- package/src/review-api/worktree-events.ts +5 -3
- package/src/review-api/worktree-source.ts +7 -3
- package/src/review-api/worktree-structural.integration.ts +0 -72
- package/src/review-preferences.ts +88 -0
- package/src/review-stack.ts +23 -15
- package/src/runtime.ts +30 -4
- package/src/server/desktop-host.ts +1 -6
- package/src/server/desktop-server.ts +31 -1
- package/src/server/headless-host.ts +2 -0
- package/src/server/hono-http.ts +1 -2
- package/src/server/legacy-tutorial-cleanup.ts +3 -2
- package/src/server/review-gateway-list.ts +5 -3
- 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 +5 -1
- package/src/server/structural-comparisons.ts +0 -47
- package/src/sharing/export.ts +7 -36
- package/src/sharing/host.ts +14 -34
- package/src/sharing/import.ts +24 -156
- package/src/sharing/repository.ts +20 -128
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +19 -110
- package/src/ui-telemetry-events.ts +11 -33
- package/dist/agent-cli-_YyMIMRY.js +0 -2
- package/dist/lens-selection-CyJapcsl.js +0 -214
- package/dist/profile-DJvkBJNQ.js +0 -2
- package/dist/runtime-Bj2tD33R.js +0 -27
- package/src/heading-anchors.ts +0 -42
- package/src/review-api/board-order.ts +0 -144
- package/src/review-api/checkout-groups.ts +0 -560
- package/src/review-api/foreign-references.ts +0 -328
- package/src/review-api/link-preview.ts +0 -113
- package/src/review-api/scratchpad-plan-upgrade.ts +0 -250
- package/src/review-api/store-migrations.ts +0 -357
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { c as jsonString,
|
|
3
|
-
import {
|
|
4
|
-
import { s as safeStorageSegment } from "./review-home-paths-
|
|
5
|
-
import { b as
|
|
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-dFbewRTG.js";
|
|
1
|
+
import { _ as parseGitRemote, a as diff, b as resolveRepoContextSync, c as diffFileSummariesWorkingTree, d as git, g as listTrackedFilesAtCommit, h as listCommitRange, i as detectLocalVcs, l as diffTrees, m as jjRevisionIsConflicted, n as createBlobBatchReader, p as gitCommonDir, s as diffFileSummariesTrees, t as LocalVcsToolsMissingError, u as diffWorkingTree, v as readFileAtCommit, x as resolveRevision, y as resolveRepoContext } from "./src-BMPuxWv4.js";
|
|
2
|
+
import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
+
import { N as decodeStructuralDiffEvent, P as structuralChangeCounts, j as SCRATCHPAD_REVIEW_ID } from "./src-DsM3jO8G.js";
|
|
4
|
+
import { s as safeStorageSegment } from "./review-home-paths-j2BRAaNv.js";
|
|
5
|
+
import { $ as intersectIntervals, A as documentAdvice, B as requireVisibleSource, C as resourceReference, D as sourceReferences, I as lensSelections, L as SourceRangeError, N as applyLensEdit, O as summarizeEdit, P as lensEditSchema, Q as emptyCoverage, R as checkSourcePath, T as reviewTargetSchema, V as sliceSourceRange, X as coverageSchema, Y as sourceAnchors, a as defineSoftwareMap, b as pinnedRepositoryIds, c as traceSchema, d as applyEdit, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, k as checkReferences, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, nt as unionIntervals, p as documentSchema, r as migrateStoredLenses, rt as updateCoverage, t as liftFileLenses, tt as subtractIntervals, u as anchorPins, v as isUnit, w as resourceReferences, x as pinsSchema, z as fileLineRangeSchema } from "./stored-document-migration-CChxtFAy.js";
|
|
7
6
|
import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
|
|
8
|
-
import { A as processStartIdentity, L as writePrivateJsonAtomic, O as pidReused, j as withFileLock, k as processIsAlive } from "./s3-config-
|
|
7
|
+
import { A as processStartIdentity, L as writePrivateJsonAtomic, O as pidReused, j as withFileLock, k as processIsAlive } from "./s3-config-BTO12tCC.js";
|
|
9
8
|
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
10
9
|
import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
|
|
11
|
-
import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-
|
|
10
|
+
import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-5pYr6_zM.js";
|
|
12
11
|
import { createRequire } from "node:module";
|
|
13
12
|
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
14
13
|
import { z } from "zod";
|
|
15
|
-
import { existsSync,
|
|
14
|
+
import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
16
15
|
import * as fs$1 from "node:fs/promises";
|
|
17
16
|
import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
18
17
|
import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
18
|
import { execFile, spawn } from "node:child_process";
|
|
21
|
-
import {
|
|
19
|
+
import { promisify } from "node:util";
|
|
22
20
|
import { DatabaseSync } from "node:sqlite";
|
|
23
21
|
import { createInterface } from "node:readline";
|
|
24
22
|
//#region ../../diffr/diffr-ts/dist/binary.js
|
|
@@ -95,329 +93,6 @@ function structuralRows(diff) {
|
|
|
95
93
|
return rows;
|
|
96
94
|
}
|
|
97
95
|
//#endregion
|
|
98
|
-
//#region src/review-api/checkout-groups.ts
|
|
99
|
-
/** A command's output, or undefined if it fails; the runner's rules (no
|
|
100
|
-
* lazy fetch, no prompt, a timeout) apply. */
|
|
101
|
-
function quietly(tool, args, options = {}) {
|
|
102
|
-
try {
|
|
103
|
-
return runVcsSync(tool, args, {
|
|
104
|
-
cwd: options.cwd,
|
|
105
|
-
input: options.input,
|
|
106
|
-
encoding: "utf8",
|
|
107
|
-
stdio: [
|
|
108
|
-
options.input === void 0 ? "ignore" : "pipe",
|
|
109
|
-
"pipe",
|
|
110
|
-
"ignore"
|
|
111
|
-
],
|
|
112
|
-
maxBuffer: 67108864
|
|
113
|
-
});
|
|
114
|
-
} catch {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
/** A path with its parent's symlinks resolved, for a path that may be gone. */
|
|
119
|
-
function canonicalPath(file) {
|
|
120
|
-
try {
|
|
121
|
-
return path.join(realpathSync(path.dirname(file)), path.basename(file));
|
|
122
|
-
} catch {
|
|
123
|
-
return path.resolve(file);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
/** The git directory a checkout's worktrees share, read from its `.git`
|
|
127
|
-
* without running git; undefined when that can't be read so. */
|
|
128
|
-
function commonDirOf(root) {
|
|
129
|
-
const dotGit = path.join(root, ".git");
|
|
130
|
-
try {
|
|
131
|
-
if (statSync(dotGit).isDirectory()) return realpathSync(dotGit);
|
|
132
|
-
const gitdir = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
|
|
133
|
-
if (!gitdir) return void 0;
|
|
134
|
-
const own = path.resolve(root, gitdir.trim());
|
|
135
|
-
try {
|
|
136
|
-
return realpathSync(path.resolve(own, readFileSync(path.join(own, "commondir"), "utf8").trim()));
|
|
137
|
-
} catch {
|
|
138
|
-
return realpathSync(own);
|
|
139
|
-
}
|
|
140
|
-
} catch {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
/** The origin remote's URL from a git config file; undefined when the file
|
|
145
|
-
* can't settle it (a URL rewrite, an include), so git must be asked. */
|
|
146
|
-
function originUrlOf(commonDir) {
|
|
147
|
-
let config;
|
|
148
|
-
try {
|
|
149
|
-
config = readFileSync(path.join(commonDir, "config"), "utf8");
|
|
150
|
-
} catch {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
if (/insteadof|^\s*\[include/im.test(config)) return void 0;
|
|
154
|
-
const section = /^\s*\[remote\s+"origin"\]([^[]*)/im.exec(config)?.[1];
|
|
155
|
-
return section ? /^\s*url\s*=\s*(.+)$/im.exec(section)?.[1]?.trim() ?? null : null;
|
|
156
|
-
}
|
|
157
|
-
/** A checkout's repository group; undefined if it isn't a readable git
|
|
158
|
-
* checkout. Reads `.git` and its config directly where it can, so many
|
|
159
|
-
* checkouts group in a moment; git answers the rest. */
|
|
160
|
-
function repositoryGroupAt(root) {
|
|
161
|
-
if (!existsSync(root)) return void 0;
|
|
162
|
-
const commonDir = commonDirOf(root);
|
|
163
|
-
const origin = commonDir ? originUrlOf(commonDir) : void 0;
|
|
164
|
-
const context = commonDir && origin !== void 0 ? {
|
|
165
|
-
commonDir,
|
|
166
|
-
githubSlug: origin ? parseGitRemoteSlug(origin) : null
|
|
167
|
-
} : resolveRepoContextSync(root);
|
|
168
|
-
if (!context) return void 0;
|
|
169
|
-
return context.githubSlug ? {
|
|
170
|
-
key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
|
|
171
|
-
label: context.githubSlug
|
|
172
|
-
} : {
|
|
173
|
-
key: `git:${context.commonDir}`,
|
|
174
|
-
label: path.basename(path.dirname(context.commonDir))
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
/** The probe that reads `.git` directly, running git only where it must. */
|
|
178
|
-
const gitCheckoutProbe = {
|
|
179
|
-
groupAt: repositoryGroupAt,
|
|
180
|
-
worktreesOf(root) {
|
|
181
|
-
const commonDir = commonDirOf(root) ?? gitCommonDirSync(root) ?? void 0;
|
|
182
|
-
if (!commonDir) return void 0;
|
|
183
|
-
let entries = [];
|
|
184
|
-
try {
|
|
185
|
-
entries = readdirSync(path.join(commonDir, "worktrees"));
|
|
186
|
-
} catch {}
|
|
187
|
-
return {
|
|
188
|
-
commonDir,
|
|
189
|
-
worktrees: entries.flatMap((entry) => {
|
|
190
|
-
try {
|
|
191
|
-
const gitdir = readFileSync(path.join(commonDir, "worktrees", entry, "gitdir"), "utf8").trim();
|
|
192
|
-
return [path.dirname(gitdir)];
|
|
193
|
-
} catch {
|
|
194
|
-
return [];
|
|
195
|
-
}
|
|
196
|
-
})
|
|
197
|
-
};
|
|
198
|
-
},
|
|
199
|
-
commitsIn: (commonDir, commits) => commitsListed(quietly("git", [
|
|
200
|
-
"--git-dir",
|
|
201
|
-
commonDir,
|
|
202
|
-
"cat-file",
|
|
203
|
-
"--batch-check"
|
|
204
|
-
], { input: commits.join("\n") + "\n" })),
|
|
205
|
-
findCommits: async (commonDir, commits) => commitsListed(await runVcs("git", [
|
|
206
|
-
"--git-dir",
|
|
207
|
-
commonDir,
|
|
208
|
-
"cat-file",
|
|
209
|
-
"--batch-check"
|
|
210
|
-
], {
|
|
211
|
-
input: commits.join("\n") + "\n",
|
|
212
|
-
maxBuffer: 67108864
|
|
213
|
-
}).then(({ stdout }) => stdout, () => void 0))
|
|
214
|
-
};
|
|
215
|
-
/** How many repositories are asked about commits at once. */
|
|
216
|
-
const PARALLEL_GIT = 8;
|
|
217
|
-
/**
|
|
218
|
-
* How a store's checkouts group into projects, worked out in one pass and
|
|
219
|
-
* kept for the life of the process. `prepare` asks each checkout still on
|
|
220
|
-
* disk for its group and its worktrees, once each, before an upgrade takes
|
|
221
|
-
* the write lock; projects are then assigned from the maps it builds,
|
|
222
|
-
* without touching git. A deleted checkout joins the checkout that lists it
|
|
223
|
-
* as a worktree, else one whose repository has the commits its boards
|
|
224
|
-
* pinned (a pruned worktree's history lives on in its repository),
|
|
225
|
-
* preferring the one whose folder held it; unmatched, it gets its own
|
|
226
|
-
* project.
|
|
227
|
-
*/
|
|
228
|
-
var CheckoutGroups = class {
|
|
229
|
-
db;
|
|
230
|
-
probe;
|
|
231
|
-
groups = /* @__PURE__ */ new Map();
|
|
232
|
-
owners;
|
|
233
|
-
constructor(db, probe = gitCheckoutProbe, prepared) {
|
|
234
|
-
this.db = db;
|
|
235
|
-
this.probe = probe;
|
|
236
|
-
this.owners = prepared;
|
|
237
|
-
}
|
|
238
|
-
/** A checkout's group, asked once per path. */
|
|
239
|
-
group(root) {
|
|
240
|
-
if (!this.groups.has(root)) {
|
|
241
|
-
let group;
|
|
242
|
-
if (existsSync(root)) try {
|
|
243
|
-
group = this.probe.groupAt(root);
|
|
244
|
-
} catch {
|
|
245
|
-
group = void 0;
|
|
246
|
-
}
|
|
247
|
-
this.groups.set(root, group);
|
|
248
|
-
}
|
|
249
|
-
return this.groups.get(root);
|
|
250
|
-
}
|
|
251
|
-
/** A file as a commit has it, or undefined if this repository can't say
|
|
252
|
-
* without fetching. */
|
|
253
|
-
fileAt(root, commit, file) {
|
|
254
|
-
return quietly("git", [
|
|
255
|
-
"-C",
|
|
256
|
-
root,
|
|
257
|
-
"show",
|
|
258
|
-
{ rev: `${commit}:${file}` }
|
|
259
|
-
]);
|
|
260
|
-
}
|
|
261
|
-
/** Works everything out now, ahead of a migration that needs it. */
|
|
262
|
-
prepare() {
|
|
263
|
-
if (this.owners) return;
|
|
264
|
-
const finding = this.findOwners();
|
|
265
|
-
let step = finding.next();
|
|
266
|
-
while (!step.done) step = finding.next(new Map([...step.value].map(([commonDir, commits]) => [commonDir, this.commitsIn(commonDir, commits)])));
|
|
267
|
-
this.owners = step.value;
|
|
268
|
-
}
|
|
269
|
-
/** As `prepare`, asking several repositories at once: before a store
|
|
270
|
-
* opens, while nothing waits on its write lock yet. */
|
|
271
|
-
async prepareConcurrently() {
|
|
272
|
-
if (this.owners) return;
|
|
273
|
-
const finding = this.findOwners();
|
|
274
|
-
let step = finding.next();
|
|
275
|
-
while (!step.done) {
|
|
276
|
-
const questions = [...step.value];
|
|
277
|
-
const answers = /* @__PURE__ */ new Map();
|
|
278
|
-
const ask = async () => {
|
|
279
|
-
for (let next = questions.shift(); next; next = questions.shift()) {
|
|
280
|
-
const [commonDir, commits] = next;
|
|
281
|
-
answers.set(commonDir, await this.findCommits(commonDir, commits));
|
|
282
|
-
}
|
|
283
|
-
};
|
|
284
|
-
await Promise.all(Array.from({ length: PARALLEL_GIT }, ask));
|
|
285
|
-
step = finding.next(answers);
|
|
286
|
-
}
|
|
287
|
-
this.owners = step.value;
|
|
288
|
-
}
|
|
289
|
-
/** The grouping `prepare` worked out, to hand to another connection's. */
|
|
290
|
-
get prepared() {
|
|
291
|
-
return this.owners;
|
|
292
|
-
}
|
|
293
|
-
ownerOf(repositoryId) {
|
|
294
|
-
this.prepare();
|
|
295
|
-
return this.owners.get(repositoryId);
|
|
296
|
-
}
|
|
297
|
-
repositories() {
|
|
298
|
-
return this.db.prepare("SELECT id, path FROM repositories").all();
|
|
299
|
-
}
|
|
300
|
-
commitsIn(commonDir, commits) {
|
|
301
|
-
try {
|
|
302
|
-
return this.probe.commitsIn(commonDir, commits);
|
|
303
|
-
} catch {
|
|
304
|
-
return /* @__PURE__ */ new Set();
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
async findCommits(commonDir, commits) {
|
|
308
|
-
try {
|
|
309
|
-
return await (this.probe.findCommits?.(commonDir, commits) ?? this.probe.commitsIn(commonDir, commits));
|
|
310
|
-
} catch {
|
|
311
|
-
return /* @__PURE__ */ new Set();
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
/** Works out each deleted checkout's owner, yielding the questions for
|
|
315
|
-
* git it needs answered: once about the commits boards pin now, and
|
|
316
|
-
* again about older ones only for a checkout those leave unsettled. */
|
|
317
|
-
*findOwners() {
|
|
318
|
-
const owners = /* @__PURE__ */ new Map();
|
|
319
|
-
const existing = [];
|
|
320
|
-
const deleted = [];
|
|
321
|
-
for (const { id, path: root } of this.repositories()) (existsSync(String(root)) ? existing : deleted).push({
|
|
322
|
-
id: String(id),
|
|
323
|
-
root: String(root)
|
|
324
|
-
});
|
|
325
|
-
const listed = /* @__PURE__ */ new Map();
|
|
326
|
-
const repositories = /* @__PURE__ */ new Map();
|
|
327
|
-
const repositoryOf = /* @__PURE__ */ new Map();
|
|
328
|
-
for (const checkout of existing) {
|
|
329
|
-
this.group(checkout.root);
|
|
330
|
-
if (!deleted.length) continue;
|
|
331
|
-
let found;
|
|
332
|
-
try {
|
|
333
|
-
found = this.probe.worktreesOf(checkout.root);
|
|
334
|
-
} catch {
|
|
335
|
-
found = void 0;
|
|
336
|
-
}
|
|
337
|
-
if (!found) continue;
|
|
338
|
-
repositoryOf.set(checkout.id, found.commonDir);
|
|
339
|
-
if (repositories.has(found.commonDir)) continue;
|
|
340
|
-
repositories.set(found.commonDir, checkout.id);
|
|
341
|
-
for (const worktree of found.worktrees) listed.set(canonicalPath(worktree), checkout.id);
|
|
342
|
-
}
|
|
343
|
-
for (const gone of deleted) {
|
|
344
|
-
const owner = listed.get(canonicalPath(gone.root));
|
|
345
|
-
if (owner) owners.set(gone.id, owner);
|
|
346
|
-
}
|
|
347
|
-
const folders = existing.map((checkout) => ({
|
|
348
|
-
id: checkout.id,
|
|
349
|
-
folder: canonicalPath(checkout.root) + path.sep
|
|
350
|
-
}));
|
|
351
|
-
const containers = (gone) => {
|
|
352
|
-
const at = canonicalPath(gone.root);
|
|
353
|
-
return folders.flatMap(({ id, folder }) => at.startsWith(folder) && repositoryOf.has(id) ? [{
|
|
354
|
-
id,
|
|
355
|
-
commonDir: repositoryOf.get(id)
|
|
356
|
-
}] : []);
|
|
357
|
-
};
|
|
358
|
-
const unmatched = deleted.filter(({ id }) => !owners.has(id));
|
|
359
|
-
const now = this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "current");
|
|
360
|
-
const historyFirst = unmatched.some(({ id }) => !now.has(id));
|
|
361
|
-
const pinned = historyFirst ? this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "all") : now;
|
|
362
|
-
const having = /* @__PURE__ */ new Map();
|
|
363
|
-
if (pinned.size) {
|
|
364
|
-
const answers = yield questionsFor(repositories, pinned);
|
|
365
|
-
for (const [gone, theirs] of pinned) having.set(gone, repositoriesWith(repositories, answers, theirs));
|
|
366
|
-
}
|
|
367
|
-
const unsettled = historyFirst ? [] : unmatched.filter(({ id, root }) => {
|
|
368
|
-
const first = containers({ root })[0]?.commonDir ?? repositories.keys().next().value;
|
|
369
|
-
return !having.get(id)?.includes(first ?? "");
|
|
370
|
-
});
|
|
371
|
-
if (unsettled.length) {
|
|
372
|
-
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))]));
|
|
373
|
-
const answers = yield questionsFor(repositories, older);
|
|
374
|
-
for (const [gone, theirs] of older) {
|
|
375
|
-
const more = repositoriesWith(repositories, answers, theirs);
|
|
376
|
-
const known = having.get(gone) ?? [];
|
|
377
|
-
having.set(gone, [...repositories.keys()].filter((commonDir) => known.includes(commonDir) || more.includes(commonDir)));
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
for (const gone of unmatched) {
|
|
381
|
-
const withCommits = having.get(gone.id);
|
|
382
|
-
if (!withCommits?.length) continue;
|
|
383
|
-
const container = containers(gone).find(({ commonDir }) => withCommits.includes(commonDir));
|
|
384
|
-
owners.set(gone.id, container?.id ?? repositories.get(withCommits[0]));
|
|
385
|
-
}
|
|
386
|
-
return owners;
|
|
387
|
-
}
|
|
388
|
-
/** The commits each of these checkouts' boards pin: in their current
|
|
389
|
-
* versions, or in every version. */
|
|
390
|
-
pinnedCommits(checkouts, versions) {
|
|
391
|
-
const pinned = /* @__PURE__ */ new Map();
|
|
392
|
-
if (!checkouts.size) return pinned;
|
|
393
|
-
for (const row of this.db.prepare(`SELECT json_extract(snapshot,'$.pins.repositoryId') AS repository,
|
|
394
|
-
json_extract(snapshot,'$.pins.head') AS head,
|
|
395
|
-
json_extract(snapshot,'$.pins.base') AS base
|
|
396
|
-
FROM ${versions === "current" ? "reviews CROSS JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version" : "versions"}
|
|
397
|
-
WHERE json_valid(snapshot) AND json_type(snapshot,'$.pins')='object'`).all()) {
|
|
398
|
-
const repository = String(row.repository);
|
|
399
|
-
if (!checkouts.has(repository)) continue;
|
|
400
|
-
const commits = pinned.get(repository) ?? [];
|
|
401
|
-
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);
|
|
402
|
-
if (commits.length) pinned.set(repository, commits);
|
|
403
|
-
}
|
|
404
|
-
return pinned;
|
|
405
|
-
}
|
|
406
|
-
};
|
|
407
|
-
/** Every repository asked about every commit pinned. */
|
|
408
|
-
function questionsFor(repositories, pinned) {
|
|
409
|
-
const commits = [...new Set([...pinned.values()].flat())];
|
|
410
|
-
return new Map(commits.length ? [...repositories.keys()].map((commonDir) => [commonDir, commits]) : []);
|
|
411
|
-
}
|
|
412
|
-
/** The repositories, in order, that have any of these commits. */
|
|
413
|
-
function repositoriesWith(repositories, answers, commits) {
|
|
414
|
-
return [...repositories.keys()].filter((commonDir) => commits.some((commit) => answers.get(commonDir)?.has(commit)));
|
|
415
|
-
}
|
|
416
|
-
/** The commits `git cat-file --batch-check` found. */
|
|
417
|
-
function commitsListed(output) {
|
|
418
|
-
return new Set((output ?? "").split("\n").filter((line) => / commit /.test(line)).map((line) => line.split(" ")[0]));
|
|
419
|
-
}
|
|
420
|
-
//#endregion
|
|
421
96
|
//#region src/review-checkout-paths.ts
|
|
422
97
|
/**
|
|
423
98
|
* Root for Review-owned checkouts. A Review UUID identifies one subtree.
|
|
@@ -825,7 +500,6 @@ var StructuralComparisons = class {
|
|
|
825
500
|
input.signal.throwIfAborted();
|
|
826
501
|
const key = JSON.stringify([
|
|
827
502
|
configGeneration,
|
|
828
|
-
await classificationStamp(input.repositoryPath),
|
|
829
503
|
input.repositoryPath,
|
|
830
504
|
input.comparison,
|
|
831
505
|
input.paths
|
|
@@ -856,30 +530,6 @@ var StructuralComparisons = class {
|
|
|
856
530
|
}
|
|
857
531
|
};
|
|
858
532
|
let configGeneration = 0;
|
|
859
|
-
const classificationFiles = /* @__PURE__ */ new Map();
|
|
860
|
-
/** Git attribute and diffr config files that diffr's classifier reads. */
|
|
861
|
-
function classificationInputs(root) {
|
|
862
|
-
let files = classificationFiles.get(root);
|
|
863
|
-
if (!files) {
|
|
864
|
-
const config = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
|
|
865
|
-
const diffrDirectory = process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
|
|
866
|
-
files = Promise.all([gitCommonDir(root), git(root, [
|
|
867
|
-
"config",
|
|
868
|
-
"--path",
|
|
869
|
-
"core.attributesFile"
|
|
870
|
-
], { allowFailure: true })]).then(([common, user]) => [
|
|
871
|
-
...common ? [path.join(common, "info", "attributes")] : [],
|
|
872
|
-
path.join(root, ".gitattributes"),
|
|
873
|
-
user.stdout.trim() || path.join(config, "git", "attributes"),
|
|
874
|
-
path.join(diffrDirectory, "config.toml")
|
|
875
|
-
]);
|
|
876
|
-
classificationFiles.set(root, files);
|
|
877
|
-
}
|
|
878
|
-
return files;
|
|
879
|
-
}
|
|
880
|
-
async function classificationStamp(root) {
|
|
881
|
-
return Promise.all((await classificationInputs(root)).map((file) => stat(file).then(({ mtimeMs, size }) => [mtimeMs, size], () => null)));
|
|
882
|
-
}
|
|
883
533
|
function invalidateStructuralComparisons() {
|
|
884
534
|
configGeneration++;
|
|
885
535
|
}
|
|
@@ -1576,31 +1226,26 @@ const mapInputSchema = z.strictObject({
|
|
|
1576
1226
|
});
|
|
1577
1227
|
//#endregion
|
|
1578
1228
|
//#region src/review-api/pull-request.ts
|
|
1579
|
-
const execFileAsync = promisify(execFile);
|
|
1580
1229
|
const FETCH_TIMEOUT_MS = 12e4;
|
|
1581
1230
|
const LOCAL_TIMEOUT_MS = 3e4;
|
|
1582
1231
|
const defaultPullRequestDeps = {
|
|
1583
|
-
run:
|
|
1584
|
-
|
|
1232
|
+
run: (file, args, options) => new Promise((resolve, reject) => {
|
|
1233
|
+
execFile(file, args, {
|
|
1585
1234
|
cwd: options.cwd,
|
|
1586
1235
|
timeout: options.timeoutMs,
|
|
1587
1236
|
maxBuffer: 16777216,
|
|
1588
1237
|
env: {
|
|
1589
1238
|
...process.env,
|
|
1239
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1240
|
+
GH_PROMPT_DISABLED: "1",
|
|
1590
1241
|
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes"
|
|
1591
1242
|
}
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
} catch (error) {
|
|
1599
|
-
const failure = error;
|
|
1600
|
-
const reason = failure.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(failure.stderr ?? "").trim() || errorMessage(error);
|
|
1601
|
-
throw new Error(`${file} ${String(args[0])}: ${reason}`);
|
|
1602
|
-
}
|
|
1603
|
-
},
|
|
1243
|
+
}, (error, stdout, stderr) => {
|
|
1244
|
+
if (!error) return resolve(stdout);
|
|
1245
|
+
const reason = error.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(stderr).trim() || error.message;
|
|
1246
|
+
reject(/* @__PURE__ */ new Error(`${file} ${args[0]}: ${reason}`));
|
|
1247
|
+
});
|
|
1248
|
+
}),
|
|
1604
1249
|
fetch: (input, init) => fetch(input, init)
|
|
1605
1250
|
};
|
|
1606
1251
|
/** Remotes by their configured URL, not the insteadOf rewrite: a mirror
|
|
@@ -1653,45 +1298,32 @@ async function fetchPullRequest(input, deps) {
|
|
|
1653
1298
|
"--no-tags",
|
|
1654
1299
|
"--no-write-fetch-head",
|
|
1655
1300
|
"--quiet",
|
|
1656
|
-
|
|
1301
|
+
input.remote,
|
|
1657
1302
|
...refspecs
|
|
1658
1303
|
], FETCH_TIMEOUT_MS);
|
|
1659
1304
|
const commit = (ref) => git([
|
|
1660
1305
|
"rev-parse",
|
|
1661
1306
|
"--verify",
|
|
1662
1307
|
"--quiet",
|
|
1663
|
-
|
|
1308
|
+
`${ref}^{commit}`
|
|
1664
1309
|
]).catch(() => void 0);
|
|
1665
1310
|
const mergeBase = (left, right) => git([
|
|
1666
1311
|
"merge-base",
|
|
1667
|
-
|
|
1668
|
-
|
|
1312
|
+
left,
|
|
1313
|
+
right
|
|
1669
1314
|
]).catch(() => void 0);
|
|
1670
1315
|
const frozenBase = async () => {
|
|
1671
1316
|
if (!pr.baseRefOid) return void 0;
|
|
1672
|
-
if (!await commit(pr.baseRefOid)) await fetchRefs({
|
|
1673
|
-
from: pr.baseRefOid,
|
|
1674
|
-
to: refs.frozenBase,
|
|
1675
|
-
force: true
|
|
1676
|
-
} }).catch(() => void 0);
|
|
1317
|
+
if (!await commit(pr.baseRefOid)) await fetchRefs(`+${pr.baseRefOid}:${refs.frozenBase}`).catch(() => void 0);
|
|
1677
1318
|
return commit(pr.baseRefOid);
|
|
1678
1319
|
};
|
|
1679
1320
|
let baseTip;
|
|
1680
|
-
const pullHead = { refspec: {
|
|
1681
|
-
from: pr.headRef,
|
|
1682
|
-
to: refs.head,
|
|
1683
|
-
force: true
|
|
1684
|
-
} };
|
|
1685
1321
|
try {
|
|
1686
|
-
await fetchRefs(
|
|
1687
|
-
from: `refs/heads/${pr.baseRefName}`,
|
|
1688
|
-
to: refs.base,
|
|
1689
|
-
force: true
|
|
1690
|
-
} });
|
|
1322
|
+
await fetchRefs(`+${pr.headRef}:${refs.head}`, `+refs/heads/${pr.baseRefName}:${refs.base}`);
|
|
1691
1323
|
baseTip = await commit(refs.base);
|
|
1692
1324
|
} catch (error) {
|
|
1693
1325
|
try {
|
|
1694
|
-
await fetchRefs(
|
|
1326
|
+
await fetchRefs(`+${pr.headRef}:${refs.head}`);
|
|
1695
1327
|
} catch {
|
|
1696
1328
|
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);
|
|
1697
1329
|
}
|
|
@@ -1733,8 +1365,8 @@ async function indexForJj(rootPath, commits, git, deps) {
|
|
|
1733
1365
|
try {
|
|
1734
1366
|
for (const [index, tag] of tags.entries()) await git([
|
|
1735
1367
|
"update-ref",
|
|
1736
|
-
|
|
1737
|
-
|
|
1368
|
+
tag,
|
|
1369
|
+
commits[index]
|
|
1738
1370
|
]);
|
|
1739
1371
|
await jjImport();
|
|
1740
1372
|
} finally {
|
|
@@ -1746,15 +1378,6 @@ async function indexForJj(rootPath, commits, git, deps) {
|
|
|
1746
1378
|
await jjImport().catch(() => void 0);
|
|
1747
1379
|
}
|
|
1748
1380
|
}
|
|
1749
|
-
/** GitHub refused a read, as it does past a rate limit; it takes reads
|
|
1750
|
-
* again at `retryAt`. */
|
|
1751
|
-
var GitHubRefusedRead = class extends ReviewInputError {
|
|
1752
|
-
retryAt;
|
|
1753
|
-
constructor(message, retryAt) {
|
|
1754
|
-
super(message, 409);
|
|
1755
|
-
this.retryAt = retryAt;
|
|
1756
|
-
}
|
|
1757
|
-
};
|
|
1758
1381
|
function firstLine(text) {
|
|
1759
1382
|
return text.split("\n")[0].slice(0, 300);
|
|
1760
1383
|
}
|
|
@@ -1871,20 +1494,8 @@ const metadataSchema = z.object({
|
|
|
1871
1494
|
baseRefName: z.string().min(1),
|
|
1872
1495
|
baseRefOid: z.string().optional(),
|
|
1873
1496
|
headRefName: z.string().min(1),
|
|
1874
|
-
isCrossRepository: z.boolean()
|
|
1875
|
-
state: z.enum([
|
|
1876
|
-
"OPEN",
|
|
1877
|
-
"CLOSED",
|
|
1878
|
-
"MERGED"
|
|
1879
|
-
]).optional(),
|
|
1880
|
-
isDraft: z.boolean().optional()
|
|
1497
|
+
isCrossRepository: z.boolean()
|
|
1881
1498
|
});
|
|
1882
|
-
/** gh's state and draft flag as one state. */
|
|
1883
|
-
function ghState(state, isDraft) {
|
|
1884
|
-
if (state === "MERGED") return "merged";
|
|
1885
|
-
if (state === "CLOSED") return "closed";
|
|
1886
|
-
if (state === "OPEN") return isDraft ? "draft" : "open";
|
|
1887
|
-
}
|
|
1888
1499
|
function unreadable(url, ghFailure, fallback) {
|
|
1889
1500
|
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);
|
|
1890
1501
|
}
|
|
@@ -1912,21 +1523,19 @@ const providers = [{
|
|
|
1912
1523
|
const stdout = await deps.run("gh", [
|
|
1913
1524
|
"pr",
|
|
1914
1525
|
"view",
|
|
1915
|
-
|
|
1526
|
+
String(number),
|
|
1916
1527
|
"--repo",
|
|
1917
|
-
|
|
1528
|
+
`${host}/${slug}`,
|
|
1918
1529
|
"--json",
|
|
1919
|
-
"number,title,baseRefName,baseRefOid,headRefName,isCrossRepository
|
|
1530
|
+
"number,title,baseRefName,baseRefOid,headRefName,isCrossRepository"
|
|
1920
1531
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
1921
|
-
const { headRefName, isCrossRepository,
|
|
1922
|
-
const pullRequestState = ghState(state, isDraft);
|
|
1532
|
+
const { headRefName, isCrossRepository, ...metadata } = metadataSchema.parse(JSON.parse(stdout));
|
|
1923
1533
|
return {
|
|
1924
1534
|
host,
|
|
1925
1535
|
slug,
|
|
1926
1536
|
headRef,
|
|
1927
1537
|
...metadata,
|
|
1928
|
-
headBranch: isCrossRepository ? void 0 : headRefName
|
|
1929
|
-
...pullRequestState && { state: pullRequestState }
|
|
1538
|
+
headBranch: isCrossRepository ? void 0 : headRefName
|
|
1930
1539
|
};
|
|
1931
1540
|
} catch (error) {
|
|
1932
1541
|
ghFailure = firstLine(errorMessage(error));
|
|
@@ -1945,10 +1554,7 @@ const providers = [{
|
|
|
1945
1554
|
throw unreadable(url, ghFailure, `the GitHub API is unreachable (${firstLine(errorMessage(error))})`);
|
|
1946
1555
|
}
|
|
1947
1556
|
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);
|
|
1948
|
-
if (response.status === 403 || response.status === 429)
|
|
1949
|
-
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
1950
|
-
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);
|
|
1951
|
-
}
|
|
1557
|
+
if (response.status === 403 || response.status === 429) throw 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");
|
|
1952
1558
|
if (!response.ok) throw unreadable(url, ghFailure, `the GitHub API returned HTTP ${response.status}`);
|
|
1953
1559
|
const parsed = z.object({
|
|
1954
1560
|
number: z.number(),
|
|
@@ -1961,10 +1567,7 @@ const providers = [{
|
|
|
1961
1567
|
head: z.object({
|
|
1962
1568
|
ref: z.string().min(1),
|
|
1963
1569
|
repo: z.object({ id: z.number() }).nullable()
|
|
1964
|
-
})
|
|
1965
|
-
state: z.enum(["open", "closed"]).optional(),
|
|
1966
|
-
draft: z.boolean().optional(),
|
|
1967
|
-
merged_at: z.string().nullable().optional()
|
|
1570
|
+
})
|
|
1968
1571
|
}).safeParse(await response.json().catch(() => void 0));
|
|
1969
1572
|
if (!parsed.success) throw unreadable(url, ghFailure, "the GitHub API response was malformed");
|
|
1970
1573
|
return {
|
|
@@ -1975,8 +1578,7 @@ const providers = [{
|
|
|
1975
1578
|
title: parsed.data.title,
|
|
1976
1579
|
baseRefName: parsed.data.base.ref,
|
|
1977
1580
|
baseRefOid: parsed.data.base.sha,
|
|
1978
|
-
headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0
|
|
1979
|
-
...parsed.data.state && { state: parsed.data.merged_at ? "merged" : parsed.data.state === "closed" ? "closed" : parsed.data.draft ? "draft" : "open" }
|
|
1581
|
+
headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0
|
|
1980
1582
|
};
|
|
1981
1583
|
},
|
|
1982
1584
|
matchesRemote(remoteUrl, address) {
|
|
@@ -2028,7 +1630,8 @@ var ReviewActivity = class {
|
|
|
2028
1630
|
this.assertReview = assertReview;
|
|
2029
1631
|
db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
|
|
2030
1632
|
activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
|
|
2031
|
-
started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT)
|
|
1633
|
+
started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
|
|
1634
|
+
DROP TABLE IF EXISTS authoring_sessions;`);
|
|
2032
1635
|
for (const row of db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) {
|
|
2033
1636
|
this.working.add(String(row.review_id));
|
|
2034
1637
|
this.scheduleExpiry(String(row.review_id));
|
|
@@ -2171,14 +1774,7 @@ var ReviewActivity = class {
|
|
|
2171
1774
|
//#region src/review-api/ask-history.ts
|
|
2172
1775
|
/** A saved Ask conversation: its history entry, and how to reopen it. */
|
|
2173
1776
|
const askRecordSchema = askHistoryEntrySchema.extend({
|
|
2174
|
-
|
|
2175
|
-
anchor: z.object({
|
|
2176
|
-
board: z.string(),
|
|
2177
|
-
block: z.string().optional()
|
|
2178
|
-
}),
|
|
2179
|
-
/** The project of the board it started on, read from that board, or its
|
|
2180
|
-
* tombstone once deleted; absent for a shared board. */
|
|
2181
|
-
projectId: z.string().optional(),
|
|
1777
|
+
reviewId: z.string(),
|
|
2182
1778
|
/** The ACP session the agent keeps the transcript under. */
|
|
2183
1779
|
sessionId: z.string(),
|
|
2184
1780
|
/** The review version asked about; its pins recreate the checkout. */
|
|
@@ -2191,9 +1787,7 @@ const askRecordSchema = askHistoryEntrySchema.extend({
|
|
|
2191
1787
|
});
|
|
2192
1788
|
const rowSchema = z.object({
|
|
2193
1789
|
id: z.string(),
|
|
2194
|
-
|
|
2195
|
-
anchor_block: z.string().nullable(),
|
|
2196
|
-
project_id: z.string().nullable(),
|
|
1790
|
+
review_id: z.string(),
|
|
2197
1791
|
agent: z.string(),
|
|
2198
1792
|
session_id: z.string(),
|
|
2199
1793
|
version: z.number(),
|
|
@@ -2208,11 +1802,7 @@ const rowSchema = z.object({
|
|
|
2208
1802
|
permissions: z.string().nullable()
|
|
2209
1803
|
}).transform((row) => askRecordSchema.parse({
|
|
2210
1804
|
id: row.id,
|
|
2211
|
-
|
|
2212
|
-
board: row.anchor_board,
|
|
2213
|
-
block: row.anchor_block
|
|
2214
|
-
},
|
|
2215
|
-
projectId: row.project_id ?? void 0,
|
|
1805
|
+
reviewId: row.review_id,
|
|
2216
1806
|
agent: row.agent,
|
|
2217
1807
|
sessionId: row.session_id,
|
|
2218
1808
|
version: row.version,
|
|
@@ -2225,32 +1815,19 @@ const rowSchema = z.object({
|
|
|
2225
1815
|
entries: row.entries === null ? void 0 : JSON.parse(row.entries),
|
|
2226
1816
|
permissions: row.permissions ?? (row.bypass === 1 ? "bypass" : "read-only")
|
|
2227
1817
|
}));
|
|
2228
|
-
/**
|
|
2229
|
-
*
|
|
2230
|
-
* disagree with its board's. */
|
|
2231
|
-
const THREAD_PROJECT_SQL = `COALESCE(
|
|
2232
|
-
(SELECT project_id FROM reviews WHERE reviews.id=ask_conversations.anchor_board),
|
|
2233
|
-
(SELECT project_id FROM deleted_boards WHERE deleted_boards.id=ask_conversations.anchor_board))`;
|
|
2234
|
-
/** Every column of a thread, with its project. */
|
|
2235
|
-
const THREAD_COLUMNS = `ask_conversations.*, ${THREAD_PROJECT_SQL} AS project_id`;
|
|
2236
|
-
/** Saved Ask conversations by project, each anchored on the board it started
|
|
2237
|
-
* on. No foreign key on the anchor: shared reviews are not in `reviews`.
|
|
2238
|
-
* So deleting a board removes its threads by hand (removeReview). */
|
|
1818
|
+
/** Saved Ask conversations, per review. Shared reviews are not in
|
|
1819
|
+
* `reviews`, so there is no foreign key; deleting a review deletes its rows. */
|
|
2239
1820
|
var AskHistory = class {
|
|
2240
1821
|
db;
|
|
2241
|
-
writable;
|
|
2242
|
-
listeners = /* @__PURE__ */ new Set();
|
|
2243
1822
|
externalWriteGuard;
|
|
2244
1823
|
setExternalWriteGuard(guard) {
|
|
2245
1824
|
this.externalWriteGuard = guard;
|
|
2246
1825
|
}
|
|
2247
|
-
constructor(db
|
|
1826
|
+
constructor(db) {
|
|
2248
1827
|
this.db = db;
|
|
2249
|
-
this.writable = writable;
|
|
2250
1828
|
db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
|
|
2251
1829
|
id TEXT PRIMARY KEY,
|
|
2252
|
-
|
|
2253
|
-
anchor_block TEXT,
|
|
1830
|
+
review_id TEXT NOT NULL,
|
|
2254
1831
|
agent TEXT NOT NULL,
|
|
2255
1832
|
session_id TEXT NOT NULL,
|
|
2256
1833
|
version INTEGER NOT NULL,
|
|
@@ -2263,39 +1840,30 @@ var AskHistory = class {
|
|
|
2263
1840
|
entries TEXT,
|
|
2264
1841
|
bypass INTEGER NOT NULL DEFAULT 0,
|
|
2265
1842
|
permissions TEXT,
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
db.
|
|
1843
|
+
UNIQUE(agent, session_id));
|
|
1844
|
+
CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
|
|
1845
|
+
const columns = new Set(db.prepare("PRAGMA table_info(ask_conversations)").all().map((column) => String(column.name)));
|
|
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");
|
|
2269
1848
|
db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
|
|
2270
1849
|
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))");
|
|
2271
1850
|
}
|
|
2272
|
-
/** Calls back when a conversation is saved, renamed, used or deleted. */
|
|
2273
|
-
subscribe(listener) {
|
|
2274
|
-
this.listeners.add(listener);
|
|
2275
|
-
return () => {
|
|
2276
|
-
this.listeners.delete(listener);
|
|
2277
|
-
};
|
|
2278
|
-
}
|
|
2279
|
-
changed() {
|
|
2280
|
-
for (const listener of this.listeners) listener();
|
|
2281
|
-
}
|
|
2282
1851
|
assertWritable(reviewId) {
|
|
2283
1852
|
if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
|
|
2284
|
-
|
|
1853
|
+
const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(reviewId);
|
|
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);
|
|
2285
1858
|
}
|
|
2286
1859
|
assertThreadWritable(id) {
|
|
2287
1860
|
const record = this.get(id);
|
|
2288
|
-
if (record) this.assertWritable(record.
|
|
1861
|
+
if (record) this.assertWritable(record.reviewId);
|
|
2289
1862
|
}
|
|
2290
1863
|
save(record) {
|
|
2291
|
-
this.assertWritable(record.
|
|
2292
|
-
this.db.prepare(`INSERT INTO ask_conversations(id,
|
|
2293
|
-
VALUES(
|
|
2294
|
-
ON CONFLICT(id) DO UPDATE SET anchor_board=excluded.anchor_board, anchor_block=excluded.anchor_block,
|
|
2295
|
-
agent=excluded.agent, session_id=excluded.session_id, version=excluded.version, head=excluded.head,
|
|
2296
|
-
cwd=excluded.cwd, selection=excluded.selection, title=excluded.title, created_at=excluded.created_at,
|
|
2297
|
-
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);
|
|
2298
|
-
this.changed();
|
|
1864
|
+
this.assertWritable(record.reviewId);
|
|
1865
|
+
this.db.prepare(`INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
|
|
1866
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(record.id, record.reviewId, 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);
|
|
2299
1867
|
}
|
|
2300
1868
|
/** What the agent may do without asking when the conversation reopens. */
|
|
2301
1869
|
setPermissions(id, permissions) {
|
|
@@ -2312,13 +1880,11 @@ var AskHistory = class {
|
|
|
2312
1880
|
saveEntries(id, entries) {
|
|
2313
1881
|
this.assertThreadWritable(id);
|
|
2314
1882
|
this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
|
|
2315
|
-
this.changed();
|
|
2316
1883
|
}
|
|
2317
1884
|
/** The agent's name for the conversation, which the list shows. */
|
|
2318
1885
|
rename(id, title) {
|
|
2319
1886
|
this.assertThreadWritable(id);
|
|
2320
1887
|
this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
|
|
2321
|
-
this.changed();
|
|
2322
1888
|
}
|
|
2323
1889
|
saveOffer(agent, offer) {
|
|
2324
1890
|
this.db.prepare("INSERT OR REPLACE INTO ask_agent_offers(agent, offer) VALUES(?, ?)").run(agent, JSON.stringify(offer));
|
|
@@ -2339,202 +1905,29 @@ var AskHistory = class {
|
|
|
2339
1905
|
touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2340
1906
|
this.assertThreadWritable(id);
|
|
2341
1907
|
this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
listAll() {
|
|
2351
|
-
return this.entries("1");
|
|
2352
|
-
}
|
|
2353
|
-
entries(where, ...params) {
|
|
2354
|
-
return this.db.prepare(`SELECT id, anchor_board, anchor_block, ${THREAD_PROJECT_SQL} AS project_id, agent, session_id, version, head, cwd, selection, title,
|
|
2355
|
-
created_at, updated_at, NULL AS entries, bypass, permissions,
|
|
2356
|
-
(SELECT json_extract(value,'$.text') FROM json_each(entries)
|
|
2357
|
-
WHERE json_extract(value,'$.kind')='user' LIMIT 1) AS question
|
|
2358
|
-
FROM ask_conversations WHERE ${where} ORDER BY updated_at DESC`).all(...params).map((row) => {
|
|
2359
|
-
const { anchor, sessionId: _sessionId, version: _version, cwd: _cwd, entries: _entries, permissions: _permissions, ...entry } = rowSchema.parse(row);
|
|
2360
|
-
const question = z.string().nullable().parse(row.question) ?? "";
|
|
2361
|
-
if (question.trim()) entry.question = question;
|
|
2362
|
-
return {
|
|
2363
|
-
...entry,
|
|
2364
|
-
anchor
|
|
2365
|
-
};
|
|
1908
|
+
}
|
|
1909
|
+
/** Newest first. */
|
|
1910
|
+
list(reviewId) {
|
|
1911
|
+
return this.db.prepare("SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC").all(reviewId).map((row) => {
|
|
1912
|
+
const { reviewId: _reviewId, sessionId: _sessionId, version: _version, cwd: _cwd, entries, permissions: _permissions, ...entry } = rowSchema.parse(row);
|
|
1913
|
+
const question = entries?.find((entry) => entry.kind === "user")?.text;
|
|
1914
|
+
if (question?.trim()) entry.question = question;
|
|
1915
|
+
return entry;
|
|
2366
1916
|
});
|
|
2367
1917
|
}
|
|
2368
1918
|
get(id) {
|
|
2369
|
-
const row = this.db.prepare(
|
|
1919
|
+
const row = this.db.prepare("SELECT * FROM ask_conversations WHERE id=?").get(id);
|
|
2370
1920
|
return row ? rowSchema.parse(row) : void 0;
|
|
2371
1921
|
}
|
|
2372
|
-
/** The reviewer saw the thread, on screen with the app focused. Only
|
|
2373
|
-
* when it was seen changes: not its order, nor when it was used. */
|
|
2374
|
-
markViewed(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2375
|
-
this.db.prepare("UPDATE ask_conversations SET viewed_at=? WHERE id=?").run(at, id);
|
|
2376
|
-
this.changed();
|
|
2377
|
-
}
|
|
2378
|
-
/** When each of these threads was last viewed, where it ever was.
|
|
2379
|
-
* Threads from before views were kept have none, and none is unread. */
|
|
2380
|
-
viewedAt(ids) {
|
|
2381
|
-
const select = this.db.prepare("SELECT viewed_at FROM ask_conversations WHERE id=? AND viewed_at IS NOT NULL");
|
|
2382
|
-
return new Map(ids.flatMap((id) => {
|
|
2383
|
-
const row = select.get(id);
|
|
2384
|
-
return row ? [[id, String(row.viewed_at)]] : [];
|
|
2385
|
-
}));
|
|
2386
|
-
}
|
|
2387
|
-
/** Forgets the threads that started on a board gone for good. */
|
|
2388
1922
|
removeReview(reviewId) {
|
|
2389
|
-
this.db.prepare("DELETE FROM ask_conversations WHERE
|
|
2390
|
-
this.changed();
|
|
1923
|
+
this.db.prepare("DELETE FROM ask_conversations WHERE review_id=?").run(reviewId);
|
|
2391
1924
|
}
|
|
2392
1925
|
delete(id) {
|
|
2393
1926
|
this.assertThreadWritable(id);
|
|
2394
1927
|
this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
|
|
2395
|
-
this.changed();
|
|
2396
1928
|
}
|
|
2397
1929
|
};
|
|
2398
1930
|
//#endregion
|
|
2399
|
-
//#region src/review-api/board-order.ts
|
|
2400
|
-
/**
|
|
2401
|
-
* Siblings in reading order. A comes after B when A reaches B through
|
|
2402
|
-
* depends_on, directly or through boards outside the siblings. Between two
|
|
2403
|
-
* siblings with no such chain either way, commits decide: A comes after B
|
|
2404
|
-
* when A's base is, or descends from, B's head, or A's PR merges into B's
|
|
2405
|
-
* branch. Remaining ties go newest made first, then by id, so an edit
|
|
2406
|
-
* never reorders them.
|
|
2407
|
-
*/
|
|
2408
|
-
function orderSiblings(siblings, dependsOn, isAncestor) {
|
|
2409
|
-
const reach = new Map(siblings.map((board) => [board.id, reachable(board.id, dependsOn)]));
|
|
2410
|
-
const after = (a, b) => {
|
|
2411
|
-
if (reach.get(a.id).has(b.id)) return true;
|
|
2412
|
-
if (reach.get(b.id).has(a.id)) return false;
|
|
2413
|
-
return buildsOn(a, b, isAncestor) && !buildsOn(b, a, isAncestor);
|
|
2414
|
-
};
|
|
2415
|
-
const before = new Map(siblings.map((a) => [a.id, new Set(siblings.flatMap((b) => b !== a && after(a, b) ? [b.id] : []))]));
|
|
2416
|
-
const newestFirst = [...siblings].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || a.id.localeCompare(b.id));
|
|
2417
|
-
const placed = /* @__PURE__ */ new Set();
|
|
2418
|
-
const order = [];
|
|
2419
|
-
while (order.length < siblings.length) {
|
|
2420
|
-
const remaining = newestFirst.filter((board) => !placed.has(board.id));
|
|
2421
|
-
const next = remaining.find((board) => [...before.get(board.id)].every((id) => placed.has(id))) ?? remaining[0];
|
|
2422
|
-
placed.add(next.id);
|
|
2423
|
-
order.push(next.id);
|
|
2424
|
-
}
|
|
2425
|
-
return order;
|
|
2426
|
-
}
|
|
2427
|
-
function reachable(id, dependsOn) {
|
|
2428
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2429
|
-
const stack = [...dependsOn(id)];
|
|
2430
|
-
while (stack.length) {
|
|
2431
|
-
const next = stack.pop();
|
|
2432
|
-
if (seen.has(next)) continue;
|
|
2433
|
-
seen.add(next);
|
|
2434
|
-
stack.push(...dependsOn(next));
|
|
2435
|
-
}
|
|
2436
|
-
return seen;
|
|
2437
|
-
}
|
|
2438
|
-
/** Whether a's change sits on top of b's. */
|
|
2439
|
-
function buildsOn(a, b, isAncestor) {
|
|
2440
|
-
if (a.repositoryId !== b.repositoryId) return false;
|
|
2441
|
-
if (a.baseBranch && a.baseBranch === b.headBranch) return true;
|
|
2442
|
-
if (!a.base || !b.head) return false;
|
|
2443
|
-
if (a.base === b.head) return true;
|
|
2444
|
-
return a.base !== a.head && isAncestor(a.repositoryId, b.head, a.base);
|
|
2445
|
-
}
|
|
2446
|
-
/**
|
|
2447
|
-
* The path that adding `from → to` would close, from `from` back to itself,
|
|
2448
|
-
* or undefined when it closes none.
|
|
2449
|
-
*/
|
|
2450
|
-
function dependencyCycle(from, to, dependsOn) {
|
|
2451
|
-
if (from === to) return [from, from];
|
|
2452
|
-
const previous = /* @__PURE__ */ new Map();
|
|
2453
|
-
const queue = [to];
|
|
2454
|
-
const seen = /* @__PURE__ */ new Set([to]);
|
|
2455
|
-
while (queue.length) {
|
|
2456
|
-
const current = queue.shift();
|
|
2457
|
-
if (current === from) {
|
|
2458
|
-
const path = [from];
|
|
2459
|
-
for (let at = from; at !== to; at = previous.get(at)) path.unshift(previous.get(at));
|
|
2460
|
-
return [from, ...path];
|
|
2461
|
-
}
|
|
2462
|
-
for (const next of dependsOn(current)) if (!seen.has(next)) {
|
|
2463
|
-
seen.add(next);
|
|
2464
|
-
previous.set(next, current);
|
|
2465
|
-
queue.push(next);
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
//#endregion
|
|
2470
|
-
//#region src/review-api/link-preview.ts
|
|
2471
|
-
/** About what the card shows before it fades; more is cut, not sent. */
|
|
2472
|
-
const PROSE_CHARACTERS = 700;
|
|
2473
|
-
const CODE_LINES = 6;
|
|
2474
|
-
function childBlocks(block) {
|
|
2475
|
-
return "children" in block ? block.children : [];
|
|
2476
|
-
}
|
|
2477
|
-
/** Paragraphs of markdown, without headings. */
|
|
2478
|
-
function paragraphs(markdown) {
|
|
2479
|
-
return markdown.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter((paragraph) => paragraph && !paragraph.startsWith("#"));
|
|
2480
|
-
}
|
|
2481
|
-
/** The prose from the first markdown block on, in reading order, until the
|
|
2482
|
-
* card has enough. Diagrams and code before it are skipped. */
|
|
2483
|
-
function openingProse(blocks) {
|
|
2484
|
-
const prose = [];
|
|
2485
|
-
let length = 0;
|
|
2486
|
-
const walk = (list) => {
|
|
2487
|
-
for (const block of list) if (block.type === "markdown") for (const paragraph of paragraphs(block.markdown)) {
|
|
2488
|
-
prose.push(paragraph);
|
|
2489
|
-
length += paragraph.length;
|
|
2490
|
-
if (length >= PROSE_CHARACTERS) return true;
|
|
2491
|
-
}
|
|
2492
|
-
else if (walk(childBlocks(block))) return true;
|
|
2493
|
-
return false;
|
|
2494
|
-
};
|
|
2495
|
-
walk(blocks);
|
|
2496
|
-
return prose;
|
|
2497
|
-
}
|
|
2498
|
-
/** The block and the title of the section it is in. */
|
|
2499
|
-
function find(blocks, id, section) {
|
|
2500
|
-
for (const block of blocks) {
|
|
2501
|
-
if (block.id === id) return {
|
|
2502
|
-
block,
|
|
2503
|
-
section
|
|
2504
|
-
};
|
|
2505
|
-
const found = find(childBlocks(block), id, block.type === "section" ? block.title : section);
|
|
2506
|
-
if (found) return found;
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
2509
|
-
/** The opening prose of a board, or one block of it. */
|
|
2510
|
-
function boardLinkPreview(document, blockId) {
|
|
2511
|
-
if (blockId === void 0) return { prose: openingProse(document) };
|
|
2512
|
-
const found = find(document, blockId);
|
|
2513
|
-
if (!found) return {};
|
|
2514
|
-
const { block } = found;
|
|
2515
|
-
const section = block.type === "section" ? block.title : found.section;
|
|
2516
|
-
if (block.type === "code") return {
|
|
2517
|
-
...section && { section },
|
|
2518
|
-
code: {
|
|
2519
|
-
language: block.language,
|
|
2520
|
-
lines: block.text.split("\n").slice(0, CODE_LINES)
|
|
2521
|
-
}
|
|
2522
|
-
};
|
|
2523
|
-
const prose = openingProse([block]);
|
|
2524
|
-
if (prose.length) return {
|
|
2525
|
-
...section && { section },
|
|
2526
|
-
prose
|
|
2527
|
-
};
|
|
2528
|
-
const title = "title" in block ? block.title : void 0;
|
|
2529
|
-
return {
|
|
2530
|
-
...section && { section },
|
|
2531
|
-
figure: {
|
|
2532
|
-
type: block.type,
|
|
2533
|
-
title: title ?? ""
|
|
2534
|
-
}
|
|
2535
|
-
};
|
|
2536
|
-
}
|
|
2537
|
-
//#endregion
|
|
2538
1931
|
//#region src/review-api/origin.ts
|
|
2539
1932
|
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.");
|
|
2540
1933
|
/** One key per PR: GitHub owner and repository names are case-insensitive,
|
|
@@ -2561,496 +1954,36 @@ function setPullRequest(snapshot, url, title) {
|
|
|
2561
1954
|
};
|
|
2562
1955
|
}
|
|
2563
1956
|
//#endregion
|
|
2564
|
-
//#region src/review-api/foreign-references.ts
|
|
2565
|
-
const LANGUAGES = /* @__PURE__ */ new Map([
|
|
2566
|
-
[".ts", "typescript"],
|
|
2567
|
-
[".tsx", "tsx"],
|
|
2568
|
-
[".js", "javascript"],
|
|
2569
|
-
[".jsx", "jsx"],
|
|
2570
|
-
[".mjs", "javascript"],
|
|
2571
|
-
[".rs", "rust"],
|
|
2572
|
-
[".go", "go"],
|
|
2573
|
-
[".py", "python"],
|
|
2574
|
-
[".rb", "ruby"],
|
|
2575
|
-
[".java", "java"],
|
|
2576
|
-
[".kt", "kotlin"],
|
|
2577
|
-
[".swift", "swift"],
|
|
2578
|
-
[".c", "c"],
|
|
2579
|
-
[".h", "c"],
|
|
2580
|
-
[".cc", "cpp"],
|
|
2581
|
-
[".cpp", "cpp"],
|
|
2582
|
-
[".css", "css"],
|
|
2583
|
-
[".html", "html"],
|
|
2584
|
-
[".json", "json"],
|
|
2585
|
-
[".md", "markdown"],
|
|
2586
|
-
[".sh", "bash"],
|
|
2587
|
-
[".sql", "sql"],
|
|
2588
|
-
[".toml", "toml"],
|
|
2589
|
-
[".yaml", "yaml"],
|
|
2590
|
-
[".yml", "yaml"]
|
|
2591
|
-
]);
|
|
2592
|
-
/** A source link in Markdown, in either spelling. */
|
|
2593
|
-
const SOURCE_LINK = /^(?:whiteboard:source\/|review-source:)/i;
|
|
2594
|
-
/**
|
|
2595
|
-
* Migrates the old global scratchpad, which quoted several repositories.
|
|
2596
|
-
* A board may only quote its own, so each quote of another repository is
|
|
2597
|
-
* replaced:
|
|
2598
|
-
* - a code peek becomes a `code` block of those lines;
|
|
2599
|
-
* - a source link becomes its text, then what it pointed at in code;
|
|
2600
|
-
* - a diagram attachment leaves the diagram and its lines follow it as
|
|
2601
|
-
* a `code` block;
|
|
2602
|
-
* - a step keeps its label and takes the lines as its code;
|
|
2603
|
-
* - a call stack or database lens becomes a line saying what it was,
|
|
2604
|
-
* then a `code` block for each range it quoted, in any repository; the
|
|
2605
|
-
* first takes its id. A lens that quotes only its own repository is
|
|
2606
|
-
* kept.
|
|
2607
|
-
* New blocks take ids from `allocate`.
|
|
2608
|
-
*/
|
|
2609
|
-
function snapshotForeignReferences(document, own, read, repositoryName, allocate) {
|
|
2610
|
-
const foreign = (pins) => isJsonObject(pins) && isStringValue(pins.repositoryId) && pins.repositoryId !== own ? pins : void 0;
|
|
2611
|
-
const caption = (pins, anchor) => {
|
|
2612
|
-
const selection = parseAnchor(anchor);
|
|
2613
|
-
const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
|
|
2614
|
-
const range = selection ? `${selection.file}:${selection.start.line}-${selection.end.line}` : anchor;
|
|
2615
|
-
return `${repositoryName(pins.repositoryId)} · ${range} @ ${commit.slice(0, 7)}`;
|
|
2616
|
-
};
|
|
2617
|
-
const snapshot = (id, pins, anchor) => {
|
|
2618
|
-
const selection = parseAnchor(anchor);
|
|
2619
|
-
const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
|
|
2620
|
-
const file = selection ? read(pins.repositoryId, commit, selection.file) : void 0;
|
|
2621
|
-
const label = caption(pins, anchor);
|
|
2622
|
-
const text = file !== void 0 && selection ? file.split("\n").slice(selection.start.line - 1, selection.end.line).join("\n") : `${label}\n(source unavailable)`;
|
|
2623
|
-
return {
|
|
2624
|
-
type: "code",
|
|
2625
|
-
...isStringValue(id) && { id },
|
|
2626
|
-
language: selection ? LANGUAGES.get(path.extname(selection.file).toLowerCase()) ?? "text" : "text",
|
|
2627
|
-
text,
|
|
2628
|
-
caption: label
|
|
2629
|
-
};
|
|
2630
|
-
};
|
|
2631
|
-
const pinsIn = (pins) => {
|
|
2632
|
-
return isJsonObject(pins) && isStringValue(pins.repositoryId) && isStringValue(pins.head) ? {
|
|
2633
|
-
repositoryId: pins.repositoryId,
|
|
2634
|
-
head: pins.head,
|
|
2635
|
-
...isStringValue(pins.base) && { base: pins.base }
|
|
2636
|
-
} : void 0;
|
|
2637
|
-
};
|
|
2638
|
-
const pinsOf = (value) => pinsIn(foreign(value));
|
|
2639
|
-
const peeks = (blocks) => blocks.map((block) => {
|
|
2640
|
-
if (!isJsonObject(block)) return block;
|
|
2641
|
-
if (Array.isArray(block.children)) return {
|
|
2642
|
-
...block,
|
|
2643
|
-
children: peeks(block.children)
|
|
2644
|
-
};
|
|
2645
|
-
const pins = pinsOf(block.pins);
|
|
2646
|
-
if (block.type === "code_peek" && pins && isStringValue(block.source)) return snapshot(block.id, pins, block.source);
|
|
2647
|
-
return block;
|
|
2648
|
-
});
|
|
2649
|
-
const rest = (blocks) => blocks.flatMap((block) => {
|
|
2650
|
-
if (!isJsonObject(block)) return block;
|
|
2651
|
-
if (Array.isArray(block.children)) return {
|
|
2652
|
-
...block,
|
|
2653
|
-
children: rest(block.children)
|
|
2654
|
-
};
|
|
2655
|
-
const blockPins = pinsOf(block.pins);
|
|
2656
|
-
if (block.type === "markdown" && blockPins && isStringValue(block.markdown)) {
|
|
2657
|
-
const { pins: _pins, ...kept } = block;
|
|
2658
|
-
return {
|
|
2659
|
-
...kept,
|
|
2660
|
-
markdown: unlinkSourceLinks(block.markdown, blockPins)
|
|
2661
|
-
};
|
|
2662
|
-
}
|
|
2663
|
-
if (block.type === "sequence" && Array.isArray(block.steps)) return {
|
|
2664
|
-
...block,
|
|
2665
|
-
steps: block.steps.map((step) => {
|
|
2666
|
-
if (!isJsonObject(step) || !isStringValue(step.source)) return step;
|
|
2667
|
-
const pins = pinsOf(step.pins ?? block.pins);
|
|
2668
|
-
if (!pins) return step;
|
|
2669
|
-
const { source, pins: _pins, ...kept } = step;
|
|
2670
|
-
const quoted = snapshot(void 0, pins, source);
|
|
2671
|
-
return isJsonObject(quoted) ? {
|
|
2672
|
-
...kept,
|
|
2673
|
-
code: {
|
|
2674
|
-
language: quoted.language,
|
|
2675
|
-
text: quoted.text
|
|
2676
|
-
}
|
|
2677
|
-
} : kept;
|
|
2678
|
-
})
|
|
2679
|
-
};
|
|
2680
|
-
if (block.type === "flow_diagram" && Array.isArray(block.nodes)) {
|
|
2681
|
-
const moved = [];
|
|
2682
|
-
return [{
|
|
2683
|
-
...block,
|
|
2684
|
-
nodes: block.nodes.map((node) => isJsonObject(node) && Array.isArray(node.attachments) ? {
|
|
2685
|
-
...node,
|
|
2686
|
-
attachments: node.attachments.filter((attachment) => {
|
|
2687
|
-
if (!isJsonObject(attachment) || !pinsOf(attachment.pins ?? block.pins)) return true;
|
|
2688
|
-
for (const [pins, anchor] of quotes(attachment, pinsIn, block.pins)) moved.push(snapshot(allocate("block"), pins, anchor));
|
|
2689
|
-
return false;
|
|
2690
|
-
})
|
|
2691
|
-
} : node)
|
|
2692
|
-
}, ...moved];
|
|
2693
|
-
}
|
|
2694
|
-
if (block.type === "call_stack_diff" || block.type === "database_lens") {
|
|
2695
|
-
const foreignQuote = quotes(block, pinsOf)[0];
|
|
2696
|
-
if (!foreignQuote) return block;
|
|
2697
|
-
const what = block.type === "call_stack_diff" ? "Call stack" : "Database lens";
|
|
2698
|
-
const title = isStringValue(block.title) ? ` “${block.title}”` : "";
|
|
2699
|
-
return [{
|
|
2700
|
-
type: "markdown",
|
|
2701
|
-
id: allocate("block"),
|
|
2702
|
-
markdown: `${what}${title} (from ${repositoryName(foreignQuote[0].repositoryId)}), kept as snapshots below.`
|
|
2703
|
-
}, ...quotes(block, pinsIn).map(([pins, anchor], index) => snapshot(index === 0 ? block.id : allocate("block"), pins, anchor))];
|
|
2704
|
-
}
|
|
2705
|
-
return block;
|
|
2706
|
-
});
|
|
2707
|
-
/** The ranges a block's frames or operations quote at the pins `read`
|
|
2708
|
-
* accepts. */
|
|
2709
|
-
function quotes(block, read, inherited) {
|
|
2710
|
-
if (Array.isArray(block)) return block.flatMap((item) => quotes(item, read, inherited));
|
|
2711
|
-
if (!isJsonObject(block)) return [];
|
|
2712
|
-
const scope = block.pins ?? inherited;
|
|
2713
|
-
const pins = read(scope);
|
|
2714
|
-
return [...pins ? [
|
|
2715
|
-
block.source,
|
|
2716
|
-
block.callSite,
|
|
2717
|
-
...Array.isArray(block.contextSources) ? block.contextSources : [],
|
|
2718
|
-
...Array.isArray(block.sources) ? block.sources : []
|
|
2719
|
-
].flatMap((anchor) => isStringValue(anchor) ? [[pins, anchor]] : []) : [], ...Object.entries(block).filter(([key]) => key !== "pins").flatMap(([, value]) => Array.isArray(value) || isJsonObject(value) ? quotes(value, read, scope) : [])];
|
|
2720
|
-
}
|
|
2721
|
-
function unlinkSourceLinks(markdown, pins) {
|
|
2722
|
-
let result = markdown;
|
|
2723
|
-
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();
|
|
2724
|
-
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);
|
|
2725
|
-
return result;
|
|
2726
|
-
}
|
|
2727
|
-
return rest(peeks(document));
|
|
2728
|
-
}
|
|
2729
|
-
//#endregion
|
|
2730
1957
|
//#region src/review-api/store-schema.ts
|
|
2731
|
-
/** A board version's repository in SQL over `versions.snapshot`, as
|
|
2732
|
-
* `boardRepositoryId` reads it: its target's, else its pins'. */
|
|
2733
|
-
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";
|
|
2734
|
-
const columnsOf = (db, table) => new Set(db.prepare("SELECT name FROM pragma_table_info(?)").all(table).map(({ name }) => String(name)));
|
|
2735
1958
|
function initializeReviewStoreSchema(db) {
|
|
2736
1959
|
db.exec(`
|
|
2737
1960
|
PRAGMA journal_mode=WAL;
|
|
2738
1961
|
PRAGMA foreign_keys=ON;
|
|
2739
|
-
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL
|
|
2740
|
-
project_id TEXT NOT NULL REFERENCES projects(id), parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL);
|
|
1962
|
+
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL);
|
|
2741
1963
|
CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
|
|
2742
|
-
thread_id TEXT, author_kind TEXT,
|
|
2743
1964
|
PRIMARY KEY(review_id,version));
|
|
1965
|
+
DROP TABLE IF EXISTS receipts;
|
|
2744
1966
|
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
|
|
2745
|
-
CREATE TABLE IF NOT EXISTS
|
|
2746
|
-
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
2747
|
-
project_id TEXT REFERENCES projects(id));
|
|
1967
|
+
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
|
|
2748
1968
|
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
|
|
2749
1969
|
kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
|
|
2750
1970
|
CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
|
|
2751
1971
|
PRIMARY KEY(review_id,file));
|
|
1972
|
+
DROP TABLE IF EXISTS review_viewed;
|
|
2752
1973
|
CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
|
|
2753
1974
|
CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
|
|
2754
|
-
|
|
2755
|
-
depends_on TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, PRIMARY KEY(review_id,depends_on));
|
|
2756
|
-
CREATE INDEX IF NOT EXISTS board_dependencies_depends_on ON board_dependencies(depends_on);
|
|
2757
|
-
-- A link goes with the board it is on. Where it points stays plain
|
|
2758
|
-
-- text, no foreign key: a link to a deleted board stays, and reads
|
|
2759
|
-
-- "Board deleted" from the deleted_boards tombstone.
|
|
2760
|
-
CREATE TABLE IF NOT EXISTS board_links(from_review TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, from_block TEXT NOT NULL,
|
|
2761
|
-
to_review TEXT NOT NULL, to_block TEXT);
|
|
2762
|
-
CREATE INDEX IF NOT EXISTS board_links_from ON board_links(from_review);
|
|
2763
|
-
CREATE INDEX IF NOT EXISTS board_links_to ON board_links(to_review);
|
|
2764
|
-
CREATE TABLE IF NOT EXISTS deleted_boards(id TEXT PRIMARY KEY, title TEXT NOT NULL, deleted_at TEXT NOT NULL,
|
|
2765
|
-
project_id TEXT);
|
|
1975
|
+
DROP TABLE IF EXISTS authoring_drafts;
|
|
2766
1976
|
`);
|
|
2767
|
-
|
|
2768
|
-
//#endregion
|
|
2769
|
-
//#region src/review-api/scratchpad-plan-upgrade.ts
|
|
2770
|
-
const OLD_SCRATCHPAD_ID = "scratchpad";
|
|
2771
|
-
const hasScratchpadVersions = ({ db }) => Boolean(db.prepare("SELECT 1 FROM versions WHERE review_id=? LIMIT 1").get(OLD_SCRATCHPAD_ID));
|
|
2772
|
-
/** A pad version's snapshot with its document in today's form (old pads
|
|
2773
|
-
* held anchors as objects); undefined for one that won't parse. */
|
|
2774
|
-
function parsePadVersion(text) {
|
|
1977
|
+
db.exec("BEGIN IMMEDIATE");
|
|
2775
1978
|
try {
|
|
2776
|
-
|
|
2777
|
-
const document = migrateStoredDocument(snapshot.document);
|
|
2778
|
-
snapshot.document = Array.isArray(document) ? document : [];
|
|
2779
|
-
return snapshot;
|
|
2780
|
-
} catch {
|
|
2781
|
-
return;
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
/** Each pad version as parsed, by version, for one open. The run, inside
|
|
2785
|
-
* the write lock, reads the rows again: it reuses a parse only of text that
|
|
2786
|
-
* hasn't changed since. */
|
|
2787
|
-
const parsedPads = /* @__PURE__ */ new WeakMap();
|
|
2788
|
-
function padVersion(context, version, text) {
|
|
2789
|
-
let parsed = parsedPads.get(context);
|
|
2790
|
-
if (!parsed) parsedPads.set(context, parsed = /* @__PURE__ */ new Map());
|
|
2791
|
-
const known = parsed.get(version);
|
|
2792
|
-
if (known?.text === text) return known.snapshot;
|
|
2793
|
-
const snapshot = parsePadVersion(text);
|
|
2794
|
-
parsed.set(version, {
|
|
2795
|
-
text,
|
|
2796
|
-
snapshot
|
|
2797
|
-
});
|
|
2798
|
-
return snapshot;
|
|
2799
|
-
}
|
|
2800
|
-
/** The pad's versions, oldest first, each parsed once per open. */
|
|
2801
|
-
const scratchpadVersions = (context) => context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version").all(OLD_SCRATCHPAD_ID).map((row) => ({
|
|
2802
|
-
version: Number(row.version),
|
|
2803
|
-
snapshot: padVersion(context, Number(row.version), String(row.snapshot))
|
|
2804
|
-
}));
|
|
2805
|
-
/** The pad's newest version that parses, reading no further back. */
|
|
2806
|
-
function latestScratchpadDocument(context) {
|
|
2807
|
-
for (const row of context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version DESC").iterate(OLD_SCRATCHPAD_ID)) {
|
|
2808
|
-
const snapshot = padVersion(context, Number(row.version), String(row.snapshot));
|
|
2809
|
-
if (snapshot) return snapshot.document;
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
/** The checkout the pad quotes most, else the one opened last. Only a
|
|
2813
|
-
* checkout that still exists: a plan opens on it. */
|
|
2814
|
-
function scratchpadRepository(context) {
|
|
2815
|
-
const exists = (repositoryId) => {
|
|
2816
|
-
try {
|
|
2817
|
-
return existsSync(context.repositoryPath(repositoryId));
|
|
2818
|
-
} catch {
|
|
2819
|
-
return false;
|
|
2820
|
-
}
|
|
2821
|
-
};
|
|
2822
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2823
|
-
const latest = latestScratchpadDocument(context);
|
|
2824
|
-
for (const { source } of sourceReferences(latest ?? [], { tolerant: true })) {
|
|
2825
|
-
const repositoryId = source.pins?.repositoryId;
|
|
2826
|
-
if (repositoryId && exists(repositoryId)) counts.set(repositoryId, (counts.get(repositoryId) ?? 0) + 1);
|
|
2827
|
-
}
|
|
2828
|
-
const [quoted] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
2829
|
-
if (quoted) return quoted;
|
|
2830
|
-
return [...context.db.prepare(`SELECT ${BOARD_REPOSITORY_ID_SQL} AS repository
|
|
2831
|
-
FROM review_attention
|
|
2832
|
-
JOIN reviews ON reviews.id=review_attention.review_id
|
|
2833
|
-
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2834
|
-
WHERE review_attention.viewed_at IS NOT NULL AND repository IS NOT NULL
|
|
2835
|
-
ORDER BY review_attention.viewed_at DESC`).all().map(({ repository }) => String(repository)), ...context.repositories().map(({ id }) => id)].find(exists);
|
|
2836
|
-
}
|
|
2837
|
-
const snapshots = (context, own, document, allocate) => snapshotForeignReferences(document, own, context.fileAtCommit, context.repositoryName, allocate);
|
|
2838
|
-
/** Reads the files the pad quotes, which the context keeps, before the
|
|
2839
|
-
* write lock other hosts on this home wait on. */
|
|
2840
|
-
function readScratchpadQuotes(context) {
|
|
2841
|
-
if (!hasScratchpadVersions(context)) return;
|
|
2842
|
-
const quoting = scratchpadRepository(context);
|
|
2843
|
-
if (quoting) {
|
|
2844
|
-
for (const { snapshot } of scratchpadVersions(context)) if (snapshot) snapshots(context, quoting, snapshot.document, (prefix) => prefix);
|
|
2845
|
-
}
|
|
2846
|
-
}
|
|
2847
|
-
function planFromScratchpad(context) {
|
|
2848
|
-
const { db } = context;
|
|
2849
|
-
if (!hasScratchpadVersions(context)) return;
|
|
2850
|
-
const own = scratchpadRepository(context);
|
|
2851
|
-
const id = randomUUID();
|
|
2852
|
-
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);
|
|
2853
|
-
let nextId = Number(db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id);
|
|
2854
|
-
const old = scratchpadVersions(context);
|
|
2855
|
-
parsedPads.delete(context);
|
|
2856
|
-
db.prepare("UPDATE versions SET review_id=? WHERE review_id=?").run(id, OLD_SCRATCHPAD_ID);
|
|
2857
|
-
for (const { version, snapshot } of old) {
|
|
2858
|
-
if (!snapshot) continue;
|
|
2859
|
-
delete snapshot.kind;
|
|
2860
|
-
snapshot.reviewId = id;
|
|
2861
|
-
snapshot.title = "Scratchpad";
|
|
2862
|
-
if (own) {
|
|
2863
|
-
snapshot.target = {
|
|
2864
|
-
kind: "project",
|
|
2865
|
-
repositoryId: own
|
|
2866
|
-
};
|
|
2867
|
-
snapshot.document = snapshots(context, own, snapshot.document, (prefix) => `${prefix}-${++nextId}`);
|
|
2868
|
-
}
|
|
2869
|
-
context.rewriteVersion(id, version, snapshot);
|
|
2870
|
-
}
|
|
2871
|
-
db.prepare("UPDATE reviews SET next_id=? WHERE id=?").run(nextId, id);
|
|
2872
|
-
for (const [table, ...names] of [
|
|
2873
|
-
[
|
|
2874
|
-
"ask_conversations",
|
|
2875
|
-
"review_id",
|
|
2876
|
-
"anchor_board"
|
|
2877
|
-
],
|
|
2878
|
-
["authoring_presences", "review_id"],
|
|
2879
|
-
["review_coverage", "review_id"],
|
|
2880
|
-
["review_attention", "review_id"]
|
|
2881
|
-
]) {
|
|
2882
|
-
const columns = columnsOf(db, table);
|
|
2883
|
-
const column = names.find((name) => columns.has(name));
|
|
2884
|
-
if (column) db.prepare(`UPDATE ${table} SET ${column}=? WHERE ${column}=?`).run(id, OLD_SCRATCHPAD_ID);
|
|
2885
|
-
}
|
|
2886
|
-
db.prepare("DELETE FROM reviews WHERE id=?").run(OLD_SCRATCHPAD_ID);
|
|
2887
|
-
}
|
|
2888
|
-
//#endregion
|
|
2889
|
-
//#region src/review-api/store-migrations.ts
|
|
2890
|
-
/** Keep versions.author_kind to its values, as a CHECK would. */
|
|
2891
|
-
const VERSION_AUTHOR_TRIGGERS = `
|
|
2892
|
-
CREATE TRIGGER IF NOT EXISTS versions_author_kind_insert BEFORE INSERT ON versions
|
|
2893
|
-
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
2894
|
-
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;
|
|
2895
|
-
CREATE TRIGGER IF NOT EXISTS versions_author_kind_update BEFORE UPDATE OF author_kind ON versions
|
|
2896
|
-
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
2897
|
-
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;`;
|
|
2898
|
-
const STORE_UPGRADES = [
|
|
2899
|
-
{
|
|
2900
|
-
version: 1,
|
|
2901
|
-
name: "retired-tables",
|
|
2902
|
-
run: ({ db }) => db.exec(`DROP TABLE IF EXISTS receipts;
|
|
2903
|
-
DROP TABLE IF EXISTS review_viewed;
|
|
2904
|
-
DROP TABLE IF EXISTS authoring_drafts;
|
|
2905
|
-
DROP TABLE IF EXISTS authoring_sessions;`)
|
|
2906
|
-
},
|
|
2907
|
-
{
|
|
2908
|
-
version: 2,
|
|
2909
|
-
name: "archive-to-trash",
|
|
2910
|
-
run: ({ db }) => {
|
|
2911
|
-
if (!columnsOf(db, "review_attention").has("dismissed_at")) return;
|
|
1979
|
+
if (db.prepare("PRAGMA table_info(review_attention)").all().some((column) => column.name === "dismissed_at")) {
|
|
2912
1980
|
db.exec("ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at");
|
|
2913
1981
|
db.prepare("UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL").run((/* @__PURE__ */ new Date()).toISOString());
|
|
2914
1982
|
}
|
|
2915
|
-
|
|
2916
|
-
{
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
groupsCheckouts: true,
|
|
2920
|
-
foreignKeysOff: true,
|
|
2921
|
-
run: ({ db, repositories, projectOfRepository, firstRepositoryProject }) => {
|
|
2922
|
-
const parents = () => db.exec("CREATE INDEX IF NOT EXISTS reviews_parent ON reviews(parent_id)");
|
|
2923
|
-
if (columnsOf(db, "reviews").has("project_id")) return parents();
|
|
2924
|
-
if (!columnsOf(db, "repositories").has("project_id")) db.exec("ALTER TABLE repositories ADD COLUMN project_id TEXT REFERENCES projects(id)");
|
|
2925
|
-
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,
|
|
2926
|
-
review_attention.viewed_at
|
|
2927
|
-
FROM reviews LEFT JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2928
|
-
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
2929
|
-
ORDER BY reviews.rowid`).all();
|
|
2930
|
-
const registered = new Set(repositories().map(({ id }) => id));
|
|
2931
|
-
const projects = /* @__PURE__ */ new Map();
|
|
2932
|
-
for (const { id, repository_id: repository } of boards) if (repository && registered.has(String(repository))) projects.set(String(id), projectOfRepository(String(repository)));
|
|
2933
|
-
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)));
|
|
2934
|
-
let fallback = opened && projects.get(String(opened.id));
|
|
2935
|
-
const brokenReferences = () => db.prepare("PRAGMA foreign_key_check").all().filter(({ parent }) => parent === "reviews").length;
|
|
2936
|
-
const broken = brokenReferences();
|
|
2937
|
-
db.exec(`CREATE TABLE reviews_with_projects(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
|
|
2938
|
-
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
2939
|
-
parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL)`);
|
|
2940
|
-
const insert = db.prepare("INSERT INTO reviews_with_projects(id,version,next_id,project_id) SELECT id,version,next_id,? FROM reviews WHERE id=?");
|
|
2941
|
-
for (const { id } of boards) insert.run(projects.get(String(id)) ?? (fallback ??= firstRepositoryProject()), String(id));
|
|
2942
|
-
db.exec(`DROP TABLE reviews;
|
|
2943
|
-
ALTER TABLE reviews_with_projects RENAME TO reviews;`);
|
|
2944
|
-
parents();
|
|
2945
|
-
if (brokenReferences() > broken) throw new Error("Giving boards projects broke a reference.");
|
|
2946
|
-
}
|
|
2947
|
-
},
|
|
2948
|
-
{
|
|
2949
|
-
version: 4,
|
|
2950
|
-
name: "scratchpad-plan",
|
|
2951
|
-
beforeLock: readScratchpadQuotes,
|
|
2952
|
-
run: planFromScratchpad
|
|
2953
|
-
},
|
|
2954
|
-
{
|
|
2955
|
-
version: 5,
|
|
2956
|
-
name: "ask-threads-by-project",
|
|
2957
|
-
run: ({ db }) => {
|
|
2958
|
-
db.exec("CREATE INDEX IF NOT EXISTS reviews_project ON reviews(project_id)");
|
|
2959
|
-
const columns = columnsOf(db, "ask_conversations");
|
|
2960
|
-
if (columns.size === 0) return;
|
|
2961
|
-
if (columns.has("review_id")) db.exec("ALTER TABLE ask_conversations RENAME COLUMN review_id TO anchor_board");
|
|
2962
|
-
if (!columns.has("anchor_block")) db.exec("ALTER TABLE ask_conversations ADD COLUMN anchor_block TEXT");
|
|
2963
|
-
}
|
|
2964
|
-
},
|
|
2965
|
-
{
|
|
2966
|
-
version: 6,
|
|
2967
|
-
name: "version-threads",
|
|
2968
|
-
run: ({ db }) => {
|
|
2969
|
-
if (!columnsOf(db, "versions").has("thread_id")) db.exec("ALTER TABLE versions ADD COLUMN thread_id TEXT");
|
|
2970
|
-
db.exec("CREATE INDEX IF NOT EXISTS versions_thread_id ON versions(thread_id)");
|
|
2971
|
-
}
|
|
2972
|
-
},
|
|
2973
|
-
{
|
|
2974
|
-
version: 7,
|
|
2975
|
-
name: "viewed-on-upgrade",
|
|
2976
|
-
run: ({ db }) => db.exec(`INSERT OR IGNORE INTO review_attention(review_id) SELECT id FROM reviews;
|
|
2977
|
-
UPDATE review_attention SET viewed_at=COALESCE(NULLIF((
|
|
2978
|
-
SELECT MAX(COALESCE(review_attention.viewed_at, ''), COALESCE(CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.createdAt') END, ''))
|
|
2979
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2980
|
-
WHERE reviews.id=review_attention.review_id), ''), review_attention.viewed_at)
|
|
2981
|
-
WHERE review_id IN (SELECT id FROM reviews);`)
|
|
2982
|
-
},
|
|
2983
|
-
{
|
|
2984
|
-
version: 8,
|
|
2985
|
-
name: "ask-thread-viewed",
|
|
2986
|
-
run: ({ db }) => {
|
|
2987
|
-
const columns = columnsOf(db, "ask_conversations");
|
|
2988
|
-
if (columns.size > 0 && !columns.has("viewed_at")) db.exec("ALTER TABLE ask_conversations ADD COLUMN viewed_at TEXT");
|
|
2989
|
-
}
|
|
2990
|
-
},
|
|
2991
|
-
{
|
|
2992
|
-
version: 9,
|
|
2993
|
-
name: "version-authors",
|
|
2994
|
-
run: ({ db }) => {
|
|
2995
|
-
if (!columnsOf(db, "versions").has("author_kind")) db.exec("ALTER TABLE versions ADD COLUMN author_kind TEXT");
|
|
2996
|
-
db.exec(VERSION_AUTHOR_TRIGGERS);
|
|
2997
|
-
}
|
|
2998
|
-
},
|
|
2999
|
-
{
|
|
3000
|
-
version: 10,
|
|
3001
|
-
name: "ask-permissions",
|
|
3002
|
-
run: ({ db }) => {
|
|
3003
|
-
const columns = columnsOf(db, "ask_conversations");
|
|
3004
|
-
if (columns.size > 0 && !columns.has("permissions")) db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
|
|
3005
|
-
}
|
|
3006
|
-
}
|
|
3007
|
-
];
|
|
3008
|
-
STORE_UPGRADES.at(-1).version;
|
|
3009
|
-
const storeVersion = (db) => Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
3010
|
-
/** Runs every step the store hasn't, each in its own transaction with the
|
|
3011
|
-
* version it reaches, so a failed step rolls back alone and stops the store
|
|
3012
|
-
* opening, naming the step; the ones before it stay done. A store at a
|
|
3013
|
-
* later version than this build knows is left as it is. */
|
|
3014
|
-
function upgradeStore(context) {
|
|
3015
|
-
const { db } = context;
|
|
3016
|
-
for (const step of STORE_UPGRADES) {
|
|
3017
|
-
if (storeVersion(db) >= step.version) continue;
|
|
3018
|
-
if (step.groupsCheckouts) context.prepareCheckouts();
|
|
3019
|
-
step.beforeLock?.(context);
|
|
3020
|
-
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=OFF");
|
|
3021
|
-
db.exec("BEGIN IMMEDIATE");
|
|
3022
|
-
try {
|
|
3023
|
-
if (storeVersion(db) < step.version) {
|
|
3024
|
-
step.run(context);
|
|
3025
|
-
db.exec(`PRAGMA user_version=${step.version}`);
|
|
3026
|
-
}
|
|
3027
|
-
db.exec("COMMIT");
|
|
3028
|
-
} catch (error) {
|
|
3029
|
-
if (db.isTransaction) db.exec("ROLLBACK");
|
|
3030
|
-
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 });
|
|
3031
|
-
} finally {
|
|
3032
|
-
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=ON");
|
|
3033
|
-
}
|
|
3034
|
-
}
|
|
3035
|
-
}
|
|
3036
|
-
/** How the store's checkouts group, worked out before it opens, asking
|
|
3037
|
-
* several repositories at once, when an upgrade still to run needs it;
|
|
3038
|
-
* undefined otherwise, or when the store can't be read so, and the step
|
|
3039
|
-
* works it out itself. */
|
|
3040
|
-
async function groupCheckoutsAhead(databasePath) {
|
|
3041
|
-
if (!existsSync(databasePath)) return void 0;
|
|
3042
|
-
let db;
|
|
3043
|
-
try {
|
|
3044
|
-
db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
3045
|
-
const version = storeVersion(db);
|
|
3046
|
-
if (!STORE_UPGRADES.some((step) => step.groupsCheckouts && step.version > version) || !columnsOf(db, "repositories").size) return void 0;
|
|
3047
|
-
const groups = new CheckoutGroups(db);
|
|
3048
|
-
await groups.prepareConcurrently();
|
|
3049
|
-
return groups.prepared;
|
|
3050
|
-
} catch {
|
|
3051
|
-
return;
|
|
3052
|
-
} finally {
|
|
3053
|
-
db?.close();
|
|
1983
|
+
db.exec("COMMIT");
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
db.exec("ROLLBACK");
|
|
1986
|
+
throw error;
|
|
3054
1987
|
}
|
|
3055
1988
|
}
|
|
3056
1989
|
//#endregion
|
|
@@ -3058,11 +1991,17 @@ async function groupCheckoutsAhead(databasePath) {
|
|
|
3058
1991
|
const reviewId = z.string().min(1);
|
|
3059
1992
|
/** The key older hosts read and write. */
|
|
3060
1993
|
const statsKey = (pins) => JSON.stringify([pins, "structural"]);
|
|
3061
|
-
/**
|
|
3062
|
-
const
|
|
1994
|
+
/** There is one scratchpad. Its id is fixed so a skill can name it. */
|
|
1995
|
+
const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
|
|
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";
|
|
3063
2004
|
const activityId = z.string().min(1).optional().describe("The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.");
|
|
3064
|
-
const parentId = reviewId.describe("The reviewId of a top-level review in the same project to nest this one under.");
|
|
3065
|
-
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.");
|
|
3066
2005
|
const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
3067
2006
|
z.strictObject({
|
|
3068
2007
|
type: z.literal("delete"),
|
|
@@ -3080,28 +2019,16 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
|
3080
2019
|
}),
|
|
3081
2020
|
z.strictObject({
|
|
3082
2021
|
type: z.literal("create"),
|
|
3083
|
-
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
3084
|
-
|
|
3085
|
-
* "Untitled" until they name it. */
|
|
3086
|
-
title: z.string().trim().optional(),
|
|
2022
|
+
/** Required unless pullRequestUrl alone names the source; then the PR title. */
|
|
2023
|
+
title: z.string().trim().min(1).optional(),
|
|
3087
2024
|
target: reviewTargetSchema.optional(),
|
|
3088
2025
|
pullRequestUrl: pullRequestUrl.optional(),
|
|
3089
2026
|
/** With pullRequestUrl and no target: the checkout to fetch the PR into. */
|
|
3090
|
-
repositoryId: z.string().min(1).optional().describe("Only with pullRequestUrl and no target: the registered 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. Default: the existing review's, else the first registered checkout with a remote for the PR's repository."),
|
|
3091
2028
|
/** Return the existing review for pullRequestUrl instead of creating one. */
|
|
3092
2029
|
reuseExisting: z.boolean().optional().describe("Default true: return the existing review for pullRequestUrl. false creates a separate review."),
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
}),
|
|
3096
|
-
z.strictObject({
|
|
3097
|
-
type: z.literal("set_parent"),
|
|
3098
|
-
reviewId,
|
|
3099
|
-
parentId: parentId.nullable()
|
|
3100
|
-
}),
|
|
3101
|
-
z.strictObject({
|
|
3102
|
-
type: z.literal("set_dependencies"),
|
|
3103
|
-
reviewId,
|
|
3104
|
-
dependsOn
|
|
2030
|
+
/** The one scratchpad: no target, no pins; every reference names its own. */
|
|
2031
|
+
kind: z.literal("scratchpad").optional()
|
|
3105
2032
|
}),
|
|
3106
2033
|
z.strictObject({
|
|
3107
2034
|
type: z.literal("set_target"),
|
|
@@ -3132,60 +2059,12 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
|
3132
2059
|
version: z.number().int().nonnegative()
|
|
3133
2060
|
})
|
|
3134
2061
|
]) });
|
|
3135
|
-
/** A person's save of a whole board from the canvas. A block they added
|
|
3136
|
-
* has no id and may carry a clientKey, which the response maps to its id. */
|
|
3137
|
-
const personEditSchema = z.strictObject({
|
|
3138
|
-
baseVersion: z.number().int().nonnegative(),
|
|
3139
|
-
/** Empty until the person names the board; it reads "Untitled". */
|
|
3140
|
-
title: z.string().trim(),
|
|
3141
|
-
document: z.array(z.json())
|
|
3142
|
-
});
|
|
3143
|
-
/** The block a person's canvas has focus in; null clears it. */
|
|
3144
|
-
const focusLeaseSchema = z.strictObject({
|
|
3145
|
-
clientId: z.string().min(1),
|
|
3146
|
-
blockId: z.string().min(1).nullable(),
|
|
3147
|
-
/** false when the canvas closes the board. */
|
|
3148
|
-
open: z.boolean().optional()
|
|
3149
|
-
});
|
|
3150
|
-
/** A focus lease lasts this long unless renewed. The canvas renews it every
|
|
3151
|
-
* few seconds, so a closed or crashed app releases its block on its own. */
|
|
3152
|
-
const FOCUS_LEASE_TTL_MS = 15e3;
|
|
3153
|
-
/** How often a discard looks again for a canvas letting go of the board. */
|
|
3154
|
-
const DISCARD_POLL_MS = 100;
|
|
3155
|
-
/**
|
|
3156
|
-
* The project of a registered checkout, made on first use. Checkouts of one
|
|
3157
|
-
* repository (worktrees, or clones of one GitHub repository) share it. A
|
|
3158
|
-
* deleted checkout joins the project of the existing one it belonged to
|
|
3159
|
-
* (see `CheckoutGroups.ownerOf`), else gets its own.
|
|
3160
|
-
*/
|
|
3161
|
-
function projectOfCheckout(db, repositoryId, groups = new CheckoutGroups(db)) {
|
|
3162
|
-
const { path: root, name, project_id: projectOf } = db.prepare("SELECT path, name, project_id FROM repositories WHERE id=?").get(repositoryId);
|
|
3163
|
-
if (projectOf) return String(projectOf);
|
|
3164
|
-
const join = (projectId) => {
|
|
3165
|
-
db.prepare("UPDATE repositories SET project_id=? WHERE id=?").run(projectId, repositoryId);
|
|
3166
|
-
return projectId;
|
|
3167
|
-
};
|
|
3168
|
-
if (!existsSync(String(root))) {
|
|
3169
|
-
const owner = groups.ownerOf(repositoryId);
|
|
3170
|
-
if (owner) return join(projectOfCheckout(db, owner, groups));
|
|
3171
|
-
}
|
|
3172
|
-
const group = groups.group(String(root));
|
|
3173
|
-
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);
|
|
3174
|
-
if (sibling) return join(String(sibling.project_id));
|
|
3175
|
-
const projectId = randomUUID();
|
|
3176
|
-
db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, group?.label ?? String(name));
|
|
3177
|
-
return join(projectId);
|
|
3178
|
-
}
|
|
3179
|
-
/** The Ask thread a version names, on its edit or on itself; stored in
|
|
3180
|
-
* `versions.thread_id` so a thread's boards are one index away. */
|
|
3181
|
-
const versionThread = (snapshot) => snapshot.lastEdit?.threadId ?? snapshot.threadId ?? null;
|
|
3182
2062
|
/** One instance owned by the desktop server. All writers go through execute().
|
|
3183
2063
|
* The queue includes async validation; SQLite transactions contain only writes.
|
|
3184
2064
|
* This prototype uses a new, explicitly supplied database, never an existing profile.
|
|
3185
2065
|
*/
|
|
3186
2066
|
var ReviewStore = class {
|
|
3187
2067
|
providers;
|
|
3188
|
-
checkoutOwners;
|
|
3189
2068
|
activity;
|
|
3190
2069
|
askHistory;
|
|
3191
2070
|
db;
|
|
@@ -3333,117 +2212,32 @@ var ReviewStore = class {
|
|
|
3333
2212
|
this.catalogListeners.delete(listener);
|
|
3334
2213
|
};
|
|
3335
2214
|
}
|
|
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
|
+
}
|
|
3336
2221
|
subscribe(listener) {
|
|
3337
2222
|
this.listeners.add(listener);
|
|
3338
2223
|
return () => {
|
|
3339
2224
|
this.listeners.delete(listener);
|
|
3340
2225
|
};
|
|
3341
2226
|
}
|
|
3342
|
-
constructor(databasePath, providers
|
|
2227
|
+
constructor(databasePath, providers) {
|
|
3343
2228
|
this.providers = providers;
|
|
3344
|
-
this.checkoutOwners = checkoutOwners;
|
|
3345
2229
|
this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
3346
2230
|
initializeReviewStoreSchema(this.db);
|
|
3347
|
-
const files = /* @__PURE__ */ new Map();
|
|
3348
|
-
upgradeStore({
|
|
3349
|
-
db: this.db,
|
|
3350
|
-
prepareCheckouts: () => this.checkouts.prepare(),
|
|
3351
|
-
repositories: () => this.repositories(),
|
|
3352
|
-
repositoryPath: (repositoryId) => this.repositoryPath(repositoryId),
|
|
3353
|
-
repositoryName: (repositoryId) => this.repositoryName(repositoryId),
|
|
3354
|
-
fileAtCommit: (repositoryId, commit, file) => {
|
|
3355
|
-
const key = JSON.stringify([
|
|
3356
|
-
repositoryId,
|
|
3357
|
-
commit,
|
|
3358
|
-
file
|
|
3359
|
-
]);
|
|
3360
|
-
if (!files.has(key)) files.set(key, this.fileAtCommit(repositoryId, commit, file));
|
|
3361
|
-
return files.get(key);
|
|
3362
|
-
},
|
|
3363
|
-
projectOfRepository: (repositoryId) => this.projectOfRepository(repositoryId),
|
|
3364
|
-
fallbackProject: () => this.fallbackProject(),
|
|
3365
|
-
rewriteVersion: (reviewId, version, snapshot) => this.rewriteVersion(reviewId, version, snapshot),
|
|
3366
|
-
firstRepositoryProject: () => this.firstRepositoryProject()
|
|
3367
|
-
});
|
|
3368
2231
|
this.activity = new ReviewActivity(this.db, (id) => this.assertWritable(id));
|
|
3369
|
-
this.askHistory = new AskHistory(this.db
|
|
2232
|
+
this.askHistory = new AskHistory(this.db);
|
|
3370
2233
|
this.purgeExpired();
|
|
3371
2234
|
this.purgeTimer = setInterval(() => this.purgeExpired(), 6e4);
|
|
3372
2235
|
this.purgeTimer.unref();
|
|
3373
2236
|
this.observedDataVersion = this.dataVersion();
|
|
3374
2237
|
this.observedVersions = this.currentVersions();
|
|
3375
|
-
this.observedDeletions = this.currentDeletions();
|
|
3376
|
-
this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
|
|
3377
|
-
this.externalChanges.unref();
|
|
3378
|
-
}
|
|
3379
|
-
/** Saves a version over what it was; the current one re-indexes its
|
|
3380
|
-
* board's links, as every written version does. */
|
|
3381
|
-
rewriteVersion(reviewId, version, snapshot) {
|
|
3382
|
-
this.db.prepare("UPDATE versions SET snapshot=? WHERE review_id=? AND version=?").run(JSON.stringify(snapshot), reviewId, version);
|
|
3383
|
-
if (version !== this.currentVersion(reviewId)) return;
|
|
3384
|
-
try {
|
|
3385
|
-
this.indexBoardLinks(reviewId, this.read(reviewId).document);
|
|
3386
|
-
} catch {}
|
|
3387
|
-
}
|
|
3388
|
-
/** The project of a registered checkout, made on first use. Checkouts of
|
|
3389
|
-
* one repository (worktrees, or clones of one GitHub repository) share it. */
|
|
3390
|
-
projectOfRepository(repositoryId) {
|
|
3391
|
-
const row = this.db.prepare("SELECT project_id FROM repositories WHERE id=?").get(repositoryId);
|
|
3392
|
-
if (!row) return this.fallbackProject();
|
|
3393
|
-
if (row.project_id) return String(row.project_id);
|
|
3394
|
-
return projectOfCheckout(this.db, repositoryId, this.checkouts);
|
|
3395
|
-
}
|
|
3396
|
-
/** For a board with no repository: the project of the board opened last,
|
|
3397
|
-
* else of the first registered checkout, else one with no repository. */
|
|
3398
|
-
fallbackProject() {
|
|
3399
|
-
const recent = this.db.prepare(`SELECT reviews.project_id FROM reviews
|
|
3400
|
-
JOIN review_attention ON review_attention.review_id=reviews.id
|
|
3401
|
-
WHERE review_attention.viewed_at IS NOT NULL
|
|
3402
|
-
ORDER BY review_attention.viewed_at DESC LIMIT 1`).get();
|
|
3403
|
-
return recent ? String(recent.project_id) : this.firstRepositoryProject();
|
|
3404
|
-
}
|
|
3405
|
-
firstRepositoryProject() {
|
|
3406
|
-
const [first] = this.repositories();
|
|
3407
|
-
if (first) return this.projectOfRepository(first.id);
|
|
3408
|
-
const empty = this.db.prepare("SELECT id FROM projects WHERE id NOT IN (SELECT project_id FROM repositories WHERE project_id IS NOT NULL) LIMIT 1").get();
|
|
3409
|
-
if (empty) return String(empty.id);
|
|
3410
|
-
const projectId = randomUUID();
|
|
3411
|
-
this.db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, "Whiteboards");
|
|
3412
|
-
return projectId;
|
|
3413
|
-
}
|
|
3414
|
-
/** A checkout's project, as the catalog lists it; undefined once the
|
|
3415
|
-
* checkout is unregistered. */
|
|
3416
|
-
checkoutProject(repositoryId) {
|
|
3417
|
-
if (!this.repositories().some(({ id }) => id === repositoryId)) return void 0;
|
|
3418
|
-
const id = this.projectOfRepository(repositoryId);
|
|
3419
|
-
const { name } = this.db.prepare("SELECT name FROM projects WHERE id=?").get(id);
|
|
3420
|
-
return {
|
|
3421
|
-
id,
|
|
3422
|
-
name: String(name)
|
|
3423
|
-
};
|
|
3424
|
-
}
|
|
3425
|
-
/** Undefined when the file can't be read. */
|
|
3426
|
-
fileAtCommit(repositoryId, commit, file) {
|
|
3427
|
-
try {
|
|
3428
|
-
return this.checkouts.fileAt(this.repositoryPath(repositoryId), commit, file);
|
|
3429
|
-
} catch {
|
|
3430
|
-
return;
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
/** A checkout's name as its project labels it, else its folder. */
|
|
3434
|
-
repositoryName(repositoryId) {
|
|
3435
|
-
try {
|
|
3436
|
-
const root = this.repositoryPath(repositoryId);
|
|
3437
|
-
return this.repositoryGroup(root)?.label.split("/").at(-1) ?? path.basename(root);
|
|
3438
|
-
} catch {
|
|
3439
|
-
return "another repository";
|
|
3440
|
-
}
|
|
3441
|
-
}
|
|
3442
|
-
/** A board's project. */
|
|
3443
|
-
projectOf(id) {
|
|
3444
|
-
const row = this.db.prepare("SELECT project_id FROM reviews WHERE id=?").get(id);
|
|
3445
|
-
if (!row) this.assertExists(id);
|
|
3446
|
-
return String(row.project_id);
|
|
2238
|
+
this.observedDeletions = this.currentDeletions();
|
|
2239
|
+
this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
|
|
2240
|
+
this.externalChanges.unref();
|
|
3447
2241
|
}
|
|
3448
2242
|
dataVersion() {
|
|
3449
2243
|
return Number(this.db.prepare("PRAGMA data_version").get().data_version);
|
|
@@ -3512,56 +2306,34 @@ var ReviewStore = class {
|
|
|
3512
2306
|
throw error;
|
|
3513
2307
|
}
|
|
3514
2308
|
}
|
|
3515
|
-
|
|
3516
|
-
/** How the store's checkouts group into projects, kept for the open. */
|
|
3517
|
-
get checkouts() {
|
|
3518
|
-
return this.checkoutGroups ??= new CheckoutGroups(this.db, void 0, this.checkoutOwners);
|
|
3519
|
-
}
|
|
2309
|
+
repositoryGroups = /* @__PURE__ */ new Map();
|
|
3520
2310
|
repositoryGroup(root) {
|
|
3521
|
-
return this.
|
|
2311
|
+
if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
|
|
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;
|
|
3522
2324
|
}
|
|
3523
2325
|
registerRepository(root) {
|
|
3524
2326
|
this.db.prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)").run(randomUUID(), root, root.split(/[\\/]/).at(-1));
|
|
3525
2327
|
const row = this.db.prepare("SELECT id,name FROM repositories WHERE path=?").get(root);
|
|
3526
|
-
this.projectOfRepository(String(row.id));
|
|
3527
2328
|
return {
|
|
3528
2329
|
id: String(row.id),
|
|
3529
2330
|
name: String(row.name)
|
|
3530
2331
|
};
|
|
3531
2332
|
}
|
|
3532
|
-
/** Forgets a checkout no board uses, after every write queued before it,
|
|
3533
|
-
* so a board being made with it keeps it. */
|
|
3534
2333
|
unregisterRepository(id) {
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
if (!this.db.prepare(`SELECT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
3539
|
-
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);
|
|
3540
|
-
this.db.exec("COMMIT");
|
|
3541
|
-
} catch (error) {
|
|
3542
|
-
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
3543
|
-
throw error;
|
|
3544
|
-
}
|
|
3545
|
-
});
|
|
3546
|
-
this.pending = run.catch(() => {});
|
|
3547
|
-
return run;
|
|
3548
|
-
}
|
|
3549
|
-
/** Checkouts whose remote is cloneUrl, most recently used first. */
|
|
3550
|
-
checkoutsOf(cloneUrl) {
|
|
3551
|
-
const key = `remote:${cloneUrl.toLowerCase()}`;
|
|
3552
|
-
const used = (repositoryId) => String(this.db.prepare(`SELECT MAX(json_extract(versions.snapshot,'$.createdAt')) AS at
|
|
3553
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
3554
|
-
WHERE json_extract(versions.snapshot,'$.target.repositoryId')=?`).get(repositoryId)?.at ?? "");
|
|
3555
|
-
return this.repositories().filter((repository) => {
|
|
3556
|
-
try {
|
|
3557
|
-
return this.repositoryGroup(repository.path)?.key === key;
|
|
3558
|
-
} catch {
|
|
3559
|
-
return false;
|
|
3560
|
-
}
|
|
3561
|
-
}).map((repository) => ({
|
|
3562
|
-
id: repository.id,
|
|
3563
|
-
at: used(repository.id)
|
|
3564
|
-
})).sort((left, right) => right.at.localeCompare(left.at)).map((repository) => repository.id);
|
|
2334
|
+
this.db.prepare(`DELETE FROM repositories WHERE id=?
|
|
2335
|
+
AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
2336
|
+
AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`).run(id, `"repositoryId":${JSON.stringify(id)}`, id);
|
|
3565
2337
|
}
|
|
3566
2338
|
/** Registered checkouts, oldest registration first. */
|
|
3567
2339
|
repositories() {
|
|
@@ -3626,14 +2398,8 @@ var ReviewStore = class {
|
|
|
3626
2398
|
assertExists(id) {
|
|
3627
2399
|
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);
|
|
3628
2400
|
}
|
|
3629
|
-
/** A board that exists and isn't in Trash: the one rule every write
|
|
3630
|
-
* about a board keeps. */
|
|
3631
2401
|
assertWritable(id) {
|
|
3632
2402
|
this.assertExists(id);
|
|
3633
|
-
this.assertOutOfTrash(id);
|
|
3634
|
-
}
|
|
3635
|
-
/** Refuses a board in Trash, which is read-only until it is restored. */
|
|
3636
|
-
assertOutOfTrash(id) {
|
|
3637
2403
|
const trashedAt = this.trashTime(id);
|
|
3638
2404
|
if (trashedAt) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", {
|
|
3639
2405
|
month: "short",
|
|
@@ -3644,28 +2410,40 @@ var ReviewStore = class {
|
|
|
3644
2410
|
const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(id);
|
|
3645
2411
|
return row?.deleted_at ? String(row.deleted_at) : null;
|
|
3646
2412
|
}
|
|
3647
|
-
/** Removes a board for good, from Delete… or Trash's purge. Links into
|
|
3648
|
-
* it stay, and show it as deleted. */
|
|
3649
2413
|
removePermanently(id) {
|
|
3650
|
-
const { title } = this.read(id);
|
|
3651
2414
|
for (const table of [
|
|
2415
|
+
"ask_conversations",
|
|
3652
2416
|
"authoring_presences",
|
|
3653
2417
|
"review_coverage",
|
|
3654
2418
|
"review_attention",
|
|
3655
2419
|
"versions"
|
|
3656
2420
|
]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
|
|
3657
|
-
this.askHistory.removeReview(id);
|
|
3658
|
-
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);
|
|
3659
2421
|
this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
|
|
3660
2422
|
this.liveSources.delete(id);
|
|
3661
2423
|
}
|
|
3662
|
-
/** Deletes the boards 30 days in Trash, each as Delete… would. */
|
|
3663
2424
|
purgeExpired() {
|
|
3664
|
-
const cutoff = (/* @__PURE__ */ new Date(Date.now() -
|
|
3665
|
-
const expired =
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
2425
|
+
const cutoff = (/* @__PURE__ */ new Date(Date.now() - 2592e6)).toISOString();
|
|
2426
|
+
const expired = [];
|
|
2427
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2428
|
+
try {
|
|
2429
|
+
for (const row of this.db.prepare("SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?").all(cutoff)) {
|
|
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
2447
|
}
|
|
3670
2448
|
read(id, version) {
|
|
3671
2449
|
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);
|
|
@@ -3674,7 +2452,7 @@ var ReviewStore = class {
|
|
|
3674
2452
|
throw new ReviewInputError("Review version not found.", 404);
|
|
3675
2453
|
}
|
|
3676
2454
|
const snapshot = JSON.parse(String(row.snapshot));
|
|
3677
|
-
const { document, lenses } =
|
|
2455
|
+
const { document, lenses } = liftFileLenses(migrateStoredDocument(snapshot.document));
|
|
3678
2456
|
snapshot.document = document;
|
|
3679
2457
|
if (snapshot.lenses) snapshot.lenses = migrateStoredLenses(snapshot.lenses);
|
|
3680
2458
|
if (lenses.length) snapshot.lenses = [...snapshot.lenses ?? [], ...lenses];
|
|
@@ -3699,21 +2477,9 @@ var ReviewStore = class {
|
|
|
3699
2477
|
listener();
|
|
3700
2478
|
} catch {}
|
|
3701
2479
|
}
|
|
3702
|
-
/**
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
const tutorials = 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));
|
|
3706
|
-
for (const reviewId of tutorials) {
|
|
3707
|
-
if (!this.trashTime(reviewId)) await this.execute({ operation: {
|
|
3708
|
-
type: "lifecycle",
|
|
3709
|
-
reviewId,
|
|
3710
|
-
state: "trash"
|
|
3711
|
-
} });
|
|
3712
|
-
await this.execute({ operation: {
|
|
3713
|
-
type: "delete",
|
|
3714
|
-
reviewId
|
|
3715
|
-
} });
|
|
3716
|
-
}
|
|
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));
|
|
3717
2483
|
}
|
|
3718
2484
|
list() {
|
|
3719
2485
|
return this.summaries();
|
|
@@ -3725,13 +2491,11 @@ var ReviewStore = class {
|
|
|
3725
2491
|
summaries(id) {
|
|
3726
2492
|
const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
3727
2493
|
(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,
|
|
3728
|
-
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
3729
|
-
projects.id AS project_id, projects.name AS project_name
|
|
2494
|
+
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
3730
2495
|
FROM reviews
|
|
3731
|
-
JOIN projects ON projects.id=reviews.project_id
|
|
3732
2496
|
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
3733
2497
|
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
3734
|
-
LEFT JOIN repositories ON repositories.id
|
|
2498
|
+
LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
|
|
3735
2499
|
${id === void 0 ? "" : "WHERE reviews.id=?"}
|
|
3736
2500
|
ORDER BY reviews.rowid`).all(...id === void 0 ? [] : [id]).map((row) => {
|
|
3737
2501
|
const summary = JSON.parse(String(row.summary));
|
|
@@ -3743,30 +2507,19 @@ var ReviewStore = class {
|
|
|
3743
2507
|
};
|
|
3744
2508
|
const live = this.liveSources.get(summary.reviewId);
|
|
3745
2509
|
if (live?.version === summary.version && summary.target?.kind === "worktree") summary.pins = live.pins;
|
|
3746
|
-
|
|
2510
|
+
const listed = {
|
|
3747
2511
|
...summary,
|
|
3748
2512
|
firstCreatedAt: row.first_created_at ? String(row.first_created_at) : void 0,
|
|
3749
2513
|
repositoryPath: row.repository_path ? String(row.repository_path) : void 0,
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
name: String(row.project_name)
|
|
3753
|
-
},
|
|
3754
|
-
repositoryName: row.repository_name ? String(row.repository_name) : "(unknown repository)",
|
|
2514
|
+
repositoryGroup: row.repository_path ? this.repositoryGroup(String(row.repository_path)) : void 0,
|
|
2515
|
+
repositoryName: row.repository_name ? String(row.repository_name) : summary.pins?.repositoryId ?? "",
|
|
3755
2516
|
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
3756
2517
|
trashedAt: row.deleted_at ? String(row.deleted_at) : null,
|
|
3757
|
-
changed: !row.viewed_at || summary.createdAt > String(row.viewed_at),
|
|
3758
2518
|
working: this.activity.isWorking(summary.reviewId)
|
|
3759
2519
|
};
|
|
2520
|
+
if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
|
|
2521
|
+
return listed;
|
|
3760
2522
|
});
|
|
3761
|
-
const graph = this.relationGraph();
|
|
3762
|
-
for (const listed of reviews) {
|
|
3763
|
-
const parentId = graph.parents.get(listed.reviewId);
|
|
3764
|
-
const children = graph.children.get(listed.reviewId);
|
|
3765
|
-
const dependsOn = graph.dependsOn.get(listed.reviewId);
|
|
3766
|
-
if (parentId) listed.parentId = parentId;
|
|
3767
|
-
if (children) listed.children = this.orderChildren(children, graph);
|
|
3768
|
-
if (dependsOn) listed.dependsOn = dependsOn;
|
|
3769
|
-
}
|
|
3770
2523
|
return this.withDiffStats(reviews);
|
|
3771
2524
|
}
|
|
3772
2525
|
/** Local and imported summaries use the same persisted counts. */
|
|
@@ -3777,6 +2530,28 @@ var ReviewStore = class {
|
|
|
3777
2530
|
diffStats: review.pins ? stats.get(statsKey(review.pins)) ?? null : null
|
|
3778
2531
|
}));
|
|
3779
2532
|
}
|
|
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
|
+
}
|
|
3780
2555
|
/** Two saved board/day pairs in the last fourteen days. */
|
|
3781
2556
|
feedbackEligible(now = Date.now()) {
|
|
3782
2557
|
const row = this.db.prepare(`SELECT count(*) AS pairs FROM (
|
|
@@ -3791,69 +2566,81 @@ var ReviewStore = class {
|
|
|
3791
2566
|
return Number(row?.pairs) >= 2;
|
|
3792
2567
|
}
|
|
3793
2568
|
history(id) {
|
|
3794
|
-
return this.db.prepare(
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
FROM versions WHERE review_id=? ORDER BY version`).all(id).map((row) => {
|
|
3800
|
-
const entry = {
|
|
3801
|
-
version: Number(row.version),
|
|
3802
|
-
title: String(row.title ?? ""),
|
|
3803
|
-
createdAt: String(row.created_at ?? "")
|
|
3804
|
-
};
|
|
3805
|
-
if (row.author_kind !== null) entry.author = row.author_kind === "person" ? "you" : "agent";
|
|
3806
|
-
if (row.author_kind !== "person" && row.thread_id !== null) entry.threadId = String(row.thread_id);
|
|
3807
|
-
return entry;
|
|
3808
|
-
});
|
|
2569
|
+
return this.db.prepare("SELECT version,json_extract(snapshot,'$.title') AS title,json_extract(snapshot,'$.createdAt') AS created_at FROM versions WHERE review_id=? ORDER BY version").all(id).map((row) => ({
|
|
2570
|
+
version: Number(row.version),
|
|
2571
|
+
title: String(row.title),
|
|
2572
|
+
createdAt: String(row.created_at)
|
|
2573
|
+
}));
|
|
3809
2574
|
}
|
|
3810
2575
|
inspect(id, targetId, version) {
|
|
3811
2576
|
return inspectSnapshot(this.read(id, version), targetId);
|
|
3812
2577
|
}
|
|
3813
2578
|
/** The host can seed a document; transport callers only supply a command. */
|
|
3814
|
-
execute(input, initial
|
|
2579
|
+
execute(input, initial) {
|
|
3815
2580
|
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
|
|
3816
2581
|
const command = commandSchema.parse(input);
|
|
3817
2582
|
if (initial && command.operation.type !== "create") throw new ReviewInputError("Initial content requires a create command.");
|
|
3818
2583
|
const pullRequest = this.startPullRequest(command);
|
|
3819
2584
|
pullRequest?.catch(() => {});
|
|
3820
2585
|
const pullRequestTitle = pullRequest ? pullRequest.then(({ title }) => title || void 0, () => void 0) : this.startPullRequestTitle(command);
|
|
3821
|
-
|
|
2586
|
+
const run = this.pending.then(async () => {
|
|
3822
2587
|
const op = command.operation;
|
|
3823
2588
|
this.purgeExpired();
|
|
3824
|
-
if (op.type
|
|
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") {
|
|
3825
2595
|
if (!op.target && !op.pullRequestUrl) throw new ReviewInputError("Supply a target or a pullRequestUrl.");
|
|
3826
2596
|
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.");
|
|
3827
|
-
if (!op.title && op.target
|
|
2597
|
+
if (!op.title && op.target) throw new ReviewInputError("Supply a title.");
|
|
3828
2598
|
}
|
|
3829
2599
|
const requestedTarget = op.type === "create" || op.type === "set_target" ? op.target : void 0;
|
|
3830
2600
|
const fromPullRequest = await pullRequest;
|
|
3831
|
-
const resolvedTarget = requestedTarget
|
|
2601
|
+
const resolvedTarget = requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
|
|
3832
2602
|
if (requestedTarget && !resolvedTarget) throw new ReviewInputError("Review targets are unavailable.");
|
|
3833
2603
|
if (op.type === "create" && op.pullRequestUrl && op.reuseExisting !== false) {
|
|
3834
2604
|
const [found, ...others] = this.reviewsForPullRequest(op.pullRequestUrl);
|
|
3835
|
-
if (found)
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
2605
|
+
if (found) return this.existingReview(found, others, resolvedTarget.pins);
|
|
2606
|
+
}
|
|
2607
|
+
if (op.type === "delete") {
|
|
2608
|
+
this.assertExists(op.reviewId);
|
|
2609
|
+
if (!this.trashTime(op.reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
|
|
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;
|
|
3840
2634
|
}
|
|
3841
|
-
|
|
3842
|
-
if (op.type === "set_dependencies") return this.commitBoardFacts(op.reviewId, author, { dependsOn: op.dependsOn });
|
|
3843
|
-
if (op.type === "lifecycle") return this.commitBoardFacts(op.reviewId, author, { state: op.state });
|
|
3844
|
-
if (op.type === "attention") return this.commitBoardFacts(op.reviewId, author, { viewed: true });
|
|
3845
|
-
if (op.type === "delete") return this.deleteBoard(this.read(op.reviewId), author, { fromTrash: true });
|
|
3846
|
-
const id = op.type === "create" ? randomUUID() : op.reviewId;
|
|
2635
|
+
const id = op.type !== "create" ? op.reviewId : op.kind === "scratchpad" ? SCRATCHPAD_ID : randomUUID();
|
|
3847
2636
|
const previous = op.type === "create" ? void 0 : this.read(id);
|
|
3848
|
-
let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`)
|
|
2637
|
+
let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`)) : structuredClone(previous);
|
|
3849
2638
|
delete snapshot.trashedAt;
|
|
3850
2639
|
delete snapshot.sourceUnavailable;
|
|
3851
2640
|
delete snapshot.lastEdit;
|
|
3852
2641
|
let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
|
|
3853
2642
|
let applied;
|
|
3854
2643
|
let lensTarget;
|
|
3855
|
-
/** The head a worktree board read at, when it becomes a plan. */
|
|
3856
|
-
let unpinnedWorktree;
|
|
3857
2644
|
if ((op.type === "create" || op.type === "set_target") && this.providers.headBranch) {
|
|
3858
2645
|
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
3859
2646
|
if (pins) {
|
|
@@ -3870,18 +2657,14 @@ var ReviewStore = class {
|
|
|
3870
2657
|
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
3871
2658
|
baseBranch: fromPullRequest.baseBranch
|
|
3872
2659
|
};
|
|
3873
|
-
if (requestedTarget
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
...snapshot.origin,
|
|
3878
|
-
baseBranch: named && !base?.startsWith(named.toLowerCase()) ? named : void 0
|
|
3879
|
-
};
|
|
3880
|
-
}
|
|
2660
|
+
if (requestedTarget) snapshot.origin = {
|
|
2661
|
+
...snapshot.origin,
|
|
2662
|
+
baseBranch: requestedTarget.base === resolvedTarget?.pins.base ? void 0 : requestedTarget.base
|
|
2663
|
+
};
|
|
3881
2664
|
switch (op.type) {
|
|
3882
2665
|
case "create":
|
|
3883
2666
|
if (initial) {
|
|
3884
|
-
snapshot.document =
|
|
2667
|
+
snapshot.document = documentSchema.parse(initial.document);
|
|
3885
2668
|
snapshot.origin = {
|
|
3886
2669
|
...snapshot.origin,
|
|
3887
2670
|
...structuredClone(initial.origin)
|
|
@@ -3894,22 +2677,17 @@ var ReviewStore = class {
|
|
|
3894
2677
|
snapshot.title = op.title;
|
|
3895
2678
|
break;
|
|
3896
2679
|
case "set_target":
|
|
3897
|
-
setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.
|
|
2680
|
+
setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.pins?.repositoryId !== resolvedTarget.pins.repositoryId ? null : void 0), await pullRequestTitle);
|
|
3898
2681
|
snapshot.staleSources = [];
|
|
3899
|
-
if (snapshot.pins && !resolvedTarget.pins) {
|
|
3900
|
-
const pinned = pinDocumentReferences(snapshot.document, snapshot.pins);
|
|
3901
|
-
if (snapshot.target?.kind === "worktree" && JSON.stringify(pinned) !== JSON.stringify(snapshot.document)) unpinnedWorktree = snapshot.pins.head;
|
|
3902
|
-
snapshot.document = pinned;
|
|
3903
|
-
}
|
|
3904
2682
|
snapshot.target = resolvedTarget.target;
|
|
3905
|
-
|
|
3906
|
-
else delete snapshot.pins;
|
|
2683
|
+
snapshot.pins = resolvedTarget.pins;
|
|
3907
2684
|
break;
|
|
3908
2685
|
case "restore":
|
|
3909
2686
|
snapshot = this.read(id, op.version);
|
|
3910
2687
|
delete snapshot.lastEdit;
|
|
3911
2688
|
break;
|
|
3912
2689
|
case "lens_edit": {
|
|
2690
|
+
if (snapshot.kind === "scratchpad") throw new ReviewInputError("The scratchpad has no changes of its own to lens.", 409);
|
|
3913
2691
|
const lenses = snapshot.lenses ?? [];
|
|
3914
2692
|
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
3915
2693
|
if (lenses.length) snapshot.lenses = lenses;
|
|
@@ -3927,678 +2705,65 @@ var ReviewStore = class {
|
|
|
3927
2705
|
};
|
|
3928
2706
|
break;
|
|
3929
2707
|
}
|
|
3930
|
-
case "edit":
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
return this.commitVersion({
|
|
3939
|
-
snapshot,
|
|
3940
|
-
previous,
|
|
3941
|
-
nextId,
|
|
3942
|
-
author,
|
|
3943
|
-
warnings,
|
|
3944
|
-
restore: op.type === "restore",
|
|
3945
|
-
repin: op.type === "set_target",
|
|
3946
|
-
projectLive: op.type !== "restore" && !resolvedTarget,
|
|
3947
|
-
result: {
|
|
3948
|
-
...op.type === "create" && { created: true },
|
|
3949
|
-
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
3950
|
-
...applied && { type: applied.type },
|
|
3951
|
-
...lensTarget && { type: lensTarget.type },
|
|
3952
|
-
...applied?.children && { children: applied.children }
|
|
3953
|
-
},
|
|
3954
|
-
...op.type === "create" && { relations: {
|
|
3955
|
-
parentId: op.parentId,
|
|
3956
|
-
dependsOn: op.dependsOn
|
|
3957
|
-
} },
|
|
3958
|
-
...(op.type === "edit" || op.type === "lens_edit") && { courier: {
|
|
3959
|
-
surface: op.type === "edit" ? "document" : "lenses",
|
|
3960
|
-
activityId: op.activityId
|
|
3961
|
-
} }
|
|
3962
|
-
});
|
|
3963
|
-
});
|
|
3964
|
-
}
|
|
3965
|
-
/**
|
|
3966
|
-
* Commits a board's next version: the one place a version is written.
|
|
3967
|
-
* Whoever writes it, it keeps the same rules:
|
|
3968
|
-
* - an agent may not change a block a person holds;
|
|
3969
|
-
* - the version names its author: a thread's names the thread, a
|
|
3970
|
-
* person's is theirs and never inherits a thread;
|
|
3971
|
-
* - only a change of repository moves a board to another project, and an
|
|
3972
|
-
* existing board never takes the project of the board opened last;
|
|
3973
|
-
* - only links the change adds are checked;
|
|
3974
|
-
* - the version, its link index, the board's project and its relations
|
|
3975
|
-
* are written together, and only if the board is still at the version
|
|
3976
|
-
* read and the rules still hold.
|
|
3977
|
-
* Runs inside the write queue.
|
|
3978
|
-
*/
|
|
3979
|
-
async commitVersion(change) {
|
|
3980
|
-
const { previous, author } = change;
|
|
3981
|
-
const id = change.snapshot.reviewId;
|
|
3982
|
-
let snapshot = change.snapshot;
|
|
3983
|
-
delete snapshot.threadId;
|
|
3984
|
-
delete snapshot.personEdit;
|
|
3985
|
-
if (snapshot.lastEdit) delete snapshot.lastEdit.threadId;
|
|
3986
|
-
if (previous && !change.restore) await this.normalizeDocument(snapshot, previous);
|
|
3987
|
-
if (change.unchangedSavesNothing && previous && snapshot.title === previous.title && isDeepStrictEqual(snapshot.document, previous.document)) return {
|
|
3988
|
-
reviewId: id,
|
|
3989
|
-
version: previous.version
|
|
3990
|
-
};
|
|
3991
|
-
const touched = previous ? changedBlocks(previous.document, snapshot.document) : [];
|
|
3992
|
-
const holds = () => {
|
|
3993
|
-
if (author.kind !== "person") this.assertUnleased(id, touched);
|
|
3994
|
-
};
|
|
3995
|
-
holds();
|
|
3996
|
-
if (author.kind === "person") snapshot.personEdit = {
|
|
3997
|
-
changed: touched,
|
|
3998
|
-
...change.personIds && { ids: change.personIds }
|
|
3999
|
-
};
|
|
4000
|
-
else if (author.threadId) {
|
|
4001
|
-
if (snapshot.lastEdit) snapshot.lastEdit.threadId = author.threadId;
|
|
4002
|
-
else snapshot.threadId = author.threadId;
|
|
4003
|
-
}
|
|
4004
|
-
let warnings;
|
|
4005
|
-
({snapshot, warnings} = await this.prepareVersion(snapshot, previous, {
|
|
4006
|
-
restore: change.restore,
|
|
4007
|
-
repin: change.repin,
|
|
4008
|
-
projectLive: change.projectLive,
|
|
4009
|
-
person: author.kind === "person"
|
|
4010
|
-
}));
|
|
4011
|
-
warnings = [...change.warnings ?? [], ...warnings];
|
|
4012
|
-
const result = {
|
|
4013
|
-
...change.result,
|
|
4014
|
-
reviewId: id,
|
|
4015
|
-
version: snapshot.version
|
|
4016
|
-
};
|
|
4017
|
-
if (warnings.length) result.warnings = warnings;
|
|
4018
|
-
const projectId = this.projectFor(snapshot, previous);
|
|
4019
|
-
const previousProject = previous ? this.projectOf(id) : void 0;
|
|
4020
|
-
this.commitCommand(result, () => {
|
|
4021
|
-
this.saveVersion(snapshot, change.nextId, projectId, change.author);
|
|
4022
|
-
if (change.relations?.parentId) this.setParent(id, change.relations.parentId);
|
|
4023
|
-
if (change.relations?.dependsOn) this.setDependencies(id, change.relations.dependsOn);
|
|
4024
|
-
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."];
|
|
4025
|
-
}, previous ? () => {
|
|
4026
|
-
this.assertWritable(id);
|
|
4027
|
-
if (change.baseVersion !== void 0) assertBaseVersion(change.baseVersion, this.currentVersion(id));
|
|
4028
|
-
else this.assertMutation(id, previous.version);
|
|
4029
|
-
holds();
|
|
4030
|
-
} : void 0, change.courier && {
|
|
4031
|
-
...change.courier,
|
|
4032
|
-
owned: (owner) => {
|
|
4033
|
-
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
2708
|
+
case "edit":
|
|
2709
|
+
applied = applyEdit(snapshot.document, op.edit, (prefix) => `${prefix}-${++nextId}`, { placement: snapshot.kind === "scratchpad" ? "first" : "last" });
|
|
2710
|
+
snapshot.lastEdit = summarizeEdit(op.edit, applied, previous.document, snapshot.document);
|
|
2711
|
+
if (snapshot.staleSources?.length) {
|
|
2712
|
+
const oldSources = new Map(sourceReferences(previous.document).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
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
|
+
}
|
|
4034
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;
|
|
2745
|
+
}
|
|
2746
|
+
} : void 0);
|
|
2747
|
+
return result;
|
|
4035
2748
|
});
|
|
4036
|
-
return result;
|
|
4037
|
-
}
|
|
4038
|
-
/** The project a version puts its board in: a new board's repository's,
|
|
4039
|
-
* or, for a board that has one, its own unless the version moves it to
|
|
4040
|
-
* another registered repository. */
|
|
4041
|
-
projectFor(snapshot, previous) {
|
|
4042
|
-
const repository = snapshot.target?.repositoryId;
|
|
4043
|
-
if (previous && (!repository || repository === previous.target?.repositoryId || !this.db.prepare("SELECT 1 FROM repositories WHERE id=?").get(repository))) return this.projectOf(snapshot.reviewId);
|
|
4044
|
-
return repository ? this.projectOfRepository(repository) : this.fallbackProject();
|
|
4045
|
-
}
|
|
4046
|
-
currentVersion(reviewId) {
|
|
4047
|
-
return Number(this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId).version);
|
|
4048
|
-
}
|
|
4049
|
-
/** Runs one write after every write queued before it. */
|
|
4050
|
-
enqueue(work) {
|
|
4051
|
-
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
|
|
4052
|
-
const run = this.pending.then(work);
|
|
4053
2749
|
this.pending = run.catch(() => {});
|
|
4054
2750
|
return run;
|
|
4055
2751
|
}
|
|
4056
|
-
/** Respells a document's links and resolves its commit links, as every
|
|
4057
|
-
* save does. A changed reference is no longer stale. */
|
|
4058
|
-
async normalizeDocument(snapshot, previous) {
|
|
4059
|
-
snapshot.document = await this.resolveCommitLinks(respellDocumentLinks(snapshot.document), snapshot, previous.document);
|
|
4060
|
-
if (snapshot.staleSources?.length) {
|
|
4061
|
-
const oldSources = new Map(sourceReferences(previous.document, { repositoryId: boardRepositoryId(previous) }).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
4062
|
-
const newSources = new Map(sourceReferences(snapshot.document, { repositoryId: boardRepositoryId(snapshot) }).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
4063
|
-
snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
|
|
4064
|
-
}
|
|
4065
|
-
}
|
|
4066
|
-
/** Checks a candidate version and numbers it. A worktree board's version
|
|
4067
|
-
* may come back with its live source projected. */
|
|
4068
|
-
async prepareVersion(candidate, previous, options) {
|
|
4069
|
-
const snapshot = candidate.target?.kind === "worktree" && options.projectLive ? await this.projectLiveSource(candidate) : candidate;
|
|
4070
|
-
checkReferences(snapshot.document);
|
|
4071
|
-
if (!options.restore) this.checkBoardLinks(snapshot, previous, options.person);
|
|
4072
|
-
if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
|
|
4073
|
-
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
4074
|
-
const warnings = [...documentAdvice(snapshot.document).filter((advice) => !advised.has(advice)), ...await this.validateExternal(snapshot, previous, options.repin)];
|
|
4075
|
-
snapshot.version = previous ? previous.version + 1 : 0;
|
|
4076
|
-
snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4077
|
-
return {
|
|
4078
|
-
snapshot,
|
|
4079
|
-
warnings
|
|
4080
|
-
};
|
|
4081
|
-
}
|
|
4082
|
-
/** Writes a version; call inside commitCommand. */
|
|
4083
|
-
saveVersion(snapshot, nextId, projectId, author) {
|
|
4084
|
-
const id = snapshot.reviewId;
|
|
4085
|
-
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);
|
|
4086
|
-
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);
|
|
4087
|
-
this.indexBoardLinks(id, snapshot.document);
|
|
4088
|
-
}
|
|
4089
|
-
/** Deletes a board and its history, if it is still at the version read
|
|
4090
|
-
* and passes `guard`: Delete… from Trash, Trash's purge and a discarded
|
|
4091
|
-
* empty board all end here. An agent can't delete a board while a person
|
|
4092
|
-
* holds a block in it. */
|
|
4093
|
-
deleteBoard({ reviewId, version }, author, { guard, fromTrash = false } = {}) {
|
|
4094
|
-
const result = {
|
|
4095
|
-
reviewId,
|
|
4096
|
-
version,
|
|
4097
|
-
deleted: true
|
|
4098
|
-
};
|
|
4099
|
-
const rules = () => {
|
|
4100
|
-
guard?.();
|
|
4101
|
-
if (fromTrash && !this.trashTime(reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
|
|
4102
|
-
this.assertMutation(reviewId, version);
|
|
4103
|
-
if (author.kind !== "person") this.assertUnheld(reviewId);
|
|
4104
|
-
};
|
|
4105
|
-
rules();
|
|
4106
|
-
this.commitCommand(result, () => this.removePermanently(reviewId), rules);
|
|
4107
|
-
this.leases.delete(reviewId);
|
|
4108
|
-
return result;
|
|
4109
|
-
}
|
|
4110
|
-
/** Writes what a board is outside its versions: its parent or what it
|
|
4111
|
-
* depends on, Trash or back, or that it was viewed. No version is saved,
|
|
4112
|
-
* but the rules are a version's: a board in Trash takes nothing but a
|
|
4113
|
-
* restore, and an agent can't put a board a person is in to Trash. */
|
|
4114
|
-
commitBoardFacts(reviewId, author, fact) {
|
|
4115
|
-
const lifecycle = "state" in fact;
|
|
4116
|
-
const rules = () => {
|
|
4117
|
-
this.assertExists(reviewId);
|
|
4118
|
-
if (!lifecycle) this.assertOutOfTrash(reviewId);
|
|
4119
|
-
else if (fact.state === "trash" && author.kind !== "person") this.assertUnheld(reviewId);
|
|
4120
|
-
};
|
|
4121
|
-
rules();
|
|
4122
|
-
const result = {
|
|
4123
|
-
reviewId,
|
|
4124
|
-
version: this.read(reviewId).version
|
|
4125
|
-
};
|
|
4126
|
-
if (lifecycle || "viewed" in fact) result.attention = true;
|
|
4127
|
-
if (lifecycle) result.lifecycle = true;
|
|
4128
|
-
this.commitCommand(result, () => {
|
|
4129
|
-
if ("parentId" in fact) this.setParent(reviewId, fact.parentId);
|
|
4130
|
-
else if ("dependsOn" in fact) this.setDependencies(reviewId, fact.dependsOn);
|
|
4131
|
-
else {
|
|
4132
|
-
this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(reviewId);
|
|
4133
|
-
if ("viewed" in fact) this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), reviewId);
|
|
4134
|
-
else {
|
|
4135
|
-
const timestamp = fact.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
4136
|
-
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);
|
|
4137
|
-
}
|
|
4138
|
-
}
|
|
4139
|
-
if ("parentId" in fact || "dependsOn" in fact) result.relations = this.relations(reviewId);
|
|
4140
|
-
}, rules);
|
|
4141
|
-
return result;
|
|
4142
|
-
}
|
|
4143
|
-
/** Deletes a board the person made and left as it was: with no title
|
|
4144
|
-
* (it reads "Untitled"), no blocks and no child boards. A title typed as
|
|
4145
|
-
* "Untitled" is a title. A board still open on a canvas, or
|
|
4146
|
-
* one an agent is working on, is kept. A closing canvas saves, then lets
|
|
4147
|
-
* go of the board, so the discard waits for that first. */
|
|
4148
|
-
async discard(reviewId) {
|
|
4149
|
-
const deadline = Date.now() + FOCUS_LEASE_TTL_MS;
|
|
4150
|
-
while (this.leased(reviewId) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
|
|
4151
|
-
return this.enqueue(async () => {
|
|
4152
|
-
if (!this.has(reviewId)) return { deleted: false };
|
|
4153
|
-
const snapshot = this.read(reviewId);
|
|
4154
|
-
if (this.leased(reviewId) || this.activity.isWorking(reviewId) || snapshot.title !== "" || snapshot.document.length || this.relations(reviewId).children.length) return { deleted: false };
|
|
4155
|
-
this.deleteBoard(snapshot, { kind: "person" });
|
|
4156
|
-
return { deleted: true };
|
|
4157
|
-
});
|
|
4158
|
-
}
|
|
4159
|
-
/** Each board's canvas leases by client: the board is open there, and
|
|
4160
|
-
* `blockId` is the block the person is in, if any. In memory: a lease
|
|
4161
|
-
* outlives neither its renewals nor the host. */
|
|
4162
|
-
leases = /* @__PURE__ */ new Map();
|
|
4163
|
-
/** Whether a canvas still holds the board open. */
|
|
4164
|
-
leased(reviewId) {
|
|
4165
|
-
const now = Date.now();
|
|
4166
|
-
return [...this.leases.get(reviewId)?.values() ?? []].some((lease) => lease.expiresAt > now);
|
|
4167
|
-
}
|
|
4168
|
-
/** Records the block a person's canvas has focus in (null for none), or
|
|
4169
|
-
* with `open: false` that the canvas closed the board. */
|
|
4170
|
-
focus(reviewId, input) {
|
|
4171
|
-
const { clientId, blockId, open } = focusLeaseSchema.parse(input);
|
|
4172
|
-
this.assertExists(reviewId);
|
|
4173
|
-
const leases = this.leases.get(reviewId) ?? /* @__PURE__ */ new Map();
|
|
4174
|
-
if (open === false) leases.delete(clientId);
|
|
4175
|
-
else leases.set(clientId, {
|
|
4176
|
-
blockId,
|
|
4177
|
-
expiresAt: Date.now() + FOCUS_LEASE_TTL_MS
|
|
4178
|
-
});
|
|
4179
|
-
if (leases.size) this.leases.set(reviewId, leases);
|
|
4180
|
-
else this.leases.delete(reviewId);
|
|
4181
|
-
return { ok: true };
|
|
4182
|
-
}
|
|
4183
|
-
/** Refuses an agent's change to a board while a person holds any block
|
|
4184
|
-
* in it. */
|
|
4185
|
-
assertUnheld(reviewId) {
|
|
4186
|
-
const now = Date.now();
|
|
4187
|
-
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);
|
|
4188
|
-
}
|
|
4189
|
-
/** Refuses an agent's edit to a block a person is editing. */
|
|
4190
|
-
assertUnleased(reviewId, changed) {
|
|
4191
|
-
const leases = this.leases.get(reviewId);
|
|
4192
|
-
if (!leases || !changed.length) return;
|
|
4193
|
-
const now = Date.now();
|
|
4194
|
-
for (const [clientId, lease] of leases) if (lease.expiresAt <= now) leases.delete(clientId);
|
|
4195
|
-
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);
|
|
4196
|
-
}
|
|
4197
|
-
/** Saves a person's whole board as their own version, against the version
|
|
4198
|
-
* they started from. Blocks without ids get fresh ones. */
|
|
4199
|
-
personEdit(reviewId, input) {
|
|
4200
|
-
const body = personEditSchema.parse(input);
|
|
4201
|
-
return this.enqueue(async () => {
|
|
4202
|
-
const previous = this.read(reviewId);
|
|
4203
|
-
assertBaseVersion(body.baseVersion, previous.version);
|
|
4204
|
-
const { document, nextId, ids } = personDocument(body.document, Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(reviewId).next_id));
|
|
4205
|
-
const snapshot = structuredClone(previous);
|
|
4206
|
-
delete snapshot.sourceUnavailable;
|
|
4207
|
-
delete snapshot.lastEdit;
|
|
4208
|
-
snapshot.title = body.title;
|
|
4209
|
-
snapshot.document = document;
|
|
4210
|
-
const { version } = await this.commitVersion({
|
|
4211
|
-
snapshot,
|
|
4212
|
-
previous,
|
|
4213
|
-
nextId,
|
|
4214
|
-
author: { kind: "person" },
|
|
4215
|
-
projectLive: true,
|
|
4216
|
-
personIds: ids,
|
|
4217
|
-
baseVersion: body.baseVersion,
|
|
4218
|
-
unchangedSavesNothing: true
|
|
4219
|
-
});
|
|
4220
|
-
return {
|
|
4221
|
-
reviewId,
|
|
4222
|
-
version,
|
|
4223
|
-
ids
|
|
4224
|
-
};
|
|
4225
|
-
});
|
|
4226
|
-
}
|
|
4227
|
-
/** Where a board sits: its parent, and the parent's children in order,
|
|
4228
|
-
* each with what its header shows. */
|
|
4229
|
-
place(id) {
|
|
4230
|
-
const graph = this.relationGraph(true);
|
|
4231
|
-
const parentId = graph.parents.get(id);
|
|
4232
|
-
const placed = (reviewId) => {
|
|
4233
|
-
const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title,
|
|
4234
|
-
json_extract(versions.snapshot,'$.target') AS target,
|
|
4235
|
-
json_extract(versions.snapshot,'$.origin') AS origin
|
|
4236
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4237
|
-
WHERE reviews.id=?`).get(reviewId);
|
|
4238
|
-
return {
|
|
4239
|
-
reviewId,
|
|
4240
|
-
title: String(row.title),
|
|
4241
|
-
...row.target && { target: JSON.parse(String(row.target)) },
|
|
4242
|
-
...row.origin && { origin: JSON.parse(String(row.origin)) }
|
|
4243
|
-
};
|
|
4244
|
-
};
|
|
4245
|
-
return {
|
|
4246
|
-
board: placed(id),
|
|
4247
|
-
...parentId && { parent: placed(parentId) },
|
|
4248
|
-
siblings: parentId ? this.orderChildren(this.listedSiblings(graph.children.get(parentId), id), graph).map(placed) : [],
|
|
4249
|
-
...!parentId && { children: this.orderChildren(this.listedSiblings(graph.children.get(id) ?? [], id), graph).map(placed) }
|
|
4250
|
-
};
|
|
4251
|
-
}
|
|
4252
|
-
/** The siblings Home and the sidebar list, which a board's place counts
|
|
4253
|
-
* among: all but those in Trash, and the board itself. */
|
|
4254
|
-
listedSiblings(ids, self) {
|
|
4255
|
-
const trashed = new Set(this.trashedIds());
|
|
4256
|
-
return ids.filter((id) => id === self || !trashed.has(id));
|
|
4257
|
-
}
|
|
4258
|
-
/** A board's parent, its children in sibling order, and what it depends on. */
|
|
4259
|
-
relations(id) {
|
|
4260
|
-
const graph = this.relationGraph();
|
|
4261
|
-
return {
|
|
4262
|
-
parentId: graph.parents.get(id) ?? null,
|
|
4263
|
-
children: this.orderChildren(graph.children.get(id) ?? [], graph),
|
|
4264
|
-
dependsOn: graph.dependsOn.get(id) ?? []
|
|
4265
|
-
};
|
|
4266
|
-
}
|
|
4267
|
-
/** Parents, children and dependencies. A board in Trash is out of them
|
|
4268
|
-
* until it is restored, its rows kept; only its own place sees it. */
|
|
4269
|
-
relationGraph(withTrash = false) {
|
|
4270
|
-
const parents = /* @__PURE__ */ new Map();
|
|
4271
|
-
const children = /* @__PURE__ */ new Map();
|
|
4272
|
-
const dependsOn = /* @__PURE__ */ new Map();
|
|
4273
|
-
const trashed = new Set(withTrash ? [] : this.trashedIds());
|
|
4274
|
-
for (const row of this.db.prepare("SELECT id, parent_id FROM reviews WHERE parent_id IS NOT NULL ORDER BY rowid").all()) {
|
|
4275
|
-
const board = String(row.id);
|
|
4276
|
-
const parent = String(row.parent_id);
|
|
4277
|
-
if (trashed.has(board) || trashed.has(parent)) continue;
|
|
4278
|
-
parents.set(board, parent);
|
|
4279
|
-
children.set(parent, [...children.get(parent) ?? [], board]);
|
|
4280
|
-
}
|
|
4281
|
-
for (const row of this.db.prepare("SELECT review_id, depends_on FROM board_dependencies ORDER BY rowid").all()) {
|
|
4282
|
-
const board = String(row.review_id);
|
|
4283
|
-
const dependency = String(row.depends_on);
|
|
4284
|
-
if (trashed.has(board) || trashed.has(dependency)) continue;
|
|
4285
|
-
dependsOn.set(board, [...dependsOn.get(board) ?? [], dependency]);
|
|
4286
|
-
}
|
|
4287
|
-
return {
|
|
4288
|
-
parents,
|
|
4289
|
-
children,
|
|
4290
|
-
dependsOn
|
|
4291
|
-
};
|
|
4292
|
-
}
|
|
4293
|
-
/** Drops a board's parent, children and dependencies in other projects,
|
|
4294
|
-
* after it moved to `projectId`. Whether it dropped any. */
|
|
4295
|
-
dropRelationsOutside(id, projectId) {
|
|
4296
|
-
const outside = "SELECT id FROM reviews WHERE project_id!=?";
|
|
4297
|
-
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;
|
|
4298
|
-
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;
|
|
4299
|
-
return Number(parents) + Number(dependencies) > 0;
|
|
4300
|
-
}
|
|
4301
|
-
orderChildren(children, graph) {
|
|
4302
|
-
if (children.length < 2) return [...children];
|
|
4303
|
-
return orderSiblings(this.siblingFacts(children), (id) => graph.dependsOn.get(id) ?? [], (repositoryId, ancestor, of) => this.isAncestor(repositoryId, ancestor, of));
|
|
4304
|
-
}
|
|
4305
|
-
siblingFacts(ids) {
|
|
4306
|
-
return this.db.prepare(`SELECT reviews.id,
|
|
4307
|
-
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first
|
|
4308
|
-
WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS created_at,
|
|
4309
|
-
json_extract(versions.snapshot,'$.pins') AS pins,
|
|
4310
|
-
json_extract(versions.snapshot,'$.origin.branch') AS branch,
|
|
4311
|
-
json_extract(versions.snapshot,'$.origin.pullRequestUrl') AS url
|
|
4312
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4313
|
-
WHERE reviews.id IN (${ids.map(() => "?").join(",")})`).all(...ids).map((row) => {
|
|
4314
|
-
const id = String(row.id);
|
|
4315
|
-
const saved = row.pins ? JSON.parse(String(row.pins)) : void 0;
|
|
4316
|
-
const pins = this.liveSources.get(id)?.pins ?? saved;
|
|
4317
|
-
const url = row.url ? String(row.url) : void 0;
|
|
4318
|
-
const baseBranch = url && this.providers.pullRequestBaseBranch?.(url);
|
|
4319
|
-
return {
|
|
4320
|
-
id,
|
|
4321
|
-
createdAt: String(row.created_at),
|
|
4322
|
-
...pins && {
|
|
4323
|
-
repositoryId: pins.repositoryId,
|
|
4324
|
-
base: pins.base,
|
|
4325
|
-
head: pins.head
|
|
4326
|
-
},
|
|
4327
|
-
...url && row.branch && { headBranch: String(row.branch) },
|
|
4328
|
-
...baseBranch && { baseBranch }
|
|
4329
|
-
};
|
|
4330
|
-
});
|
|
4331
|
-
}
|
|
4332
|
-
/** Commits never change, so a definite answer is kept. */
|
|
4333
|
-
ancestry = /* @__PURE__ */ new Map();
|
|
4334
|
-
/** When git was last asked, for questions with no answer yet. */
|
|
4335
|
-
ancestryAsked = /* @__PURE__ */ new Map();
|
|
4336
|
-
/** Whether `ancestor` is an ancestor of `of`, as far as is known. git is
|
|
4337
|
-
* asked in the background, never on the server thread: until it answers,
|
|
4338
|
-
* or when it fails, the answer is no, and a yes re-sorts the catalog. */
|
|
4339
|
-
isAncestor(repositoryId, ancestor, of) {
|
|
4340
|
-
const key = `${repositoryId}:${ancestor}:${of}`;
|
|
4341
|
-
const known = this.ancestry.get(key);
|
|
4342
|
-
if (known !== void 0) return known;
|
|
4343
|
-
const asked = this.ancestryAsked.get(key);
|
|
4344
|
-
if (asked !== void 0 && Date.now() - asked < 6e4) return false;
|
|
4345
|
-
this.ancestryAsked.set(key, Date.now());
|
|
4346
|
-
let root;
|
|
4347
|
-
try {
|
|
4348
|
-
root = this.repositoryPath(repositoryId);
|
|
4349
|
-
} catch {
|
|
4350
|
-
return false;
|
|
4351
|
-
}
|
|
4352
|
-
this.ancestryQueue.push(() => runVcs("git", [
|
|
4353
|
-
"-C",
|
|
4354
|
-
root,
|
|
4355
|
-
"merge-base",
|
|
4356
|
-
"--is-ancestor",
|
|
4357
|
-
{ rev: ancestor },
|
|
4358
|
-
{ rev: of }
|
|
4359
|
-
], { timeout: 1e4 }).then(() => this.knowAncestry(key, true), (error) => {
|
|
4360
|
-
if (error.code === 1) this.knowAncestry(key, false);
|
|
4361
|
-
}));
|
|
4362
|
-
this.askNextAncestry();
|
|
4363
|
-
return false;
|
|
4364
|
-
}
|
|
4365
|
-
ancestryQueue = [];
|
|
4366
|
-
ancestryRunning = 0;
|
|
4367
|
-
askNextAncestry() {
|
|
4368
|
-
while (this.ancestryRunning < ANCESTRY_CONCURRENCY && this.ancestryQueue.length) {
|
|
4369
|
-
this.ancestryRunning++;
|
|
4370
|
-
this.ancestryQueue.shift()().finally(() => {
|
|
4371
|
-
this.ancestryRunning--;
|
|
4372
|
-
this.askNextAncestry();
|
|
4373
|
-
});
|
|
4374
|
-
}
|
|
4375
|
-
}
|
|
4376
|
-
knowAncestry(key, ancestor) {
|
|
4377
|
-
this.ancestry.set(key, ancestor);
|
|
4378
|
-
this.ancestryAsked.delete(key);
|
|
4379
|
-
if (ancestor && !this.closing) this.catalogChanged();
|
|
4380
|
-
}
|
|
4381
|
-
catalogChanged() {
|
|
4382
|
-
for (const listener of this.catalogListeners) try {
|
|
4383
|
-
listener();
|
|
4384
|
-
} catch {}
|
|
4385
|
-
}
|
|
4386
|
-
boardName(id) {
|
|
4387
|
-
return `"${boardTitle(this.read(id).title)}" (${id})`;
|
|
4388
|
-
}
|
|
4389
|
-
/** The other board exists and is in the same project. */
|
|
4390
|
-
checkRelated(id, other) {
|
|
4391
|
-
if (!this.has(other)) throw new ReviewInputError(`Whiteboard ${other} does not exist. Name a whiteboard by its sessionId from session_list.`);
|
|
4392
|
-
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.`);
|
|
4393
|
-
}
|
|
4394
|
-
/** One parent, one level: a parent is top-level and a child has no
|
|
4395
|
-
* children. Null makes the board top-level. */
|
|
4396
|
-
setParent(id, parentId) {
|
|
4397
|
-
if (parentId !== null) {
|
|
4398
|
-
if (parentId === id) throw new ReviewInputError("A whiteboard can't be its own parent.");
|
|
4399
|
-
this.checkRelated(id, parentId);
|
|
4400
|
-
const { parents, children } = this.relationGraph();
|
|
4401
|
-
const grandparent = parents.get(parentId);
|
|
4402
|
-
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.`);
|
|
4403
|
-
const own = children.get(id) ?? [];
|
|
4404
|
-
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.`);
|
|
4405
|
-
}
|
|
4406
|
-
this.db.prepare("UPDATE reviews SET parent_id=? WHERE id=?").run(parentId, id);
|
|
4407
|
-
}
|
|
4408
|
-
setDependencies(id, dependsOn) {
|
|
4409
|
-
const unique = [...new Set(dependsOn)];
|
|
4410
|
-
const graph = this.relationGraph();
|
|
4411
|
-
for (const dependency of unique) {
|
|
4412
|
-
if (dependency === id) throw new ReviewInputError("A whiteboard can't depend on itself.");
|
|
4413
|
-
const cycle = dependencyCycle(id, dependency, (board) => graph.dependsOn.get(board) ?? []);
|
|
4414
|
-
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.`);
|
|
4415
|
-
this.checkRelated(id, dependency);
|
|
4416
|
-
}
|
|
4417
|
-
this.db.prepare("DELETE FROM board_dependencies WHERE review_id=?").run(id);
|
|
4418
|
-
const insert = this.db.prepare("INSERT INTO board_dependencies(review_id,depends_on) VALUES(?,?)");
|
|
4419
|
-
for (const dependency of unique) insert.run(id, dependency);
|
|
4420
|
-
}
|
|
4421
|
-
/** New links must point at a board and block that exist. Saved links are
|
|
4422
|
-
* not rechecked, since their target may have been deleted. */
|
|
4423
|
-
checkBoardLinks(snapshot, previous, person = false) {
|
|
4424
|
-
const key = ({ to }) => `${to.board}#${to.block ?? ""}`;
|
|
4425
|
-
const kept = new Set(boardLinks(snapshot.reviewId, previous?.document ?? []).map(key));
|
|
4426
|
-
for (const link of boardLinks(snapshot.reviewId, snapshot.document)) {
|
|
4427
|
-
if (kept.has(key(link))) continue;
|
|
4428
|
-
const { from, to } = link;
|
|
4429
|
-
const document = to.board === snapshot.reviewId ? snapshot.document : this.has(to.board) ? this.read(to.board).document : void 0;
|
|
4430
|
-
if (!document && person && this.wasDeleted(to.board)) continue;
|
|
4431
|
-
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.`);
|
|
4432
|
-
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.`);
|
|
4433
|
-
}
|
|
4434
|
-
}
|
|
4435
|
-
wasDeleted(id) {
|
|
4436
|
-
return this.db.prepare("SELECT 1 FROM deleted_boards WHERE id=?").get(id) !== void 0;
|
|
4437
|
-
}
|
|
4438
|
-
/** The boards that link to a board or its blocks. */
|
|
4439
|
-
linkingBoards(id) {
|
|
4440
|
-
return this.db.prepare("SELECT DISTINCT from_review FROM board_links WHERE to_review=?").all(id).map((row) => String(row.from_review));
|
|
4441
|
-
}
|
|
4442
|
-
/** Rebuilds the board_links rows for one board. */
|
|
4443
|
-
indexBoardLinks(id, document) {
|
|
4444
|
-
this.db.prepare("DELETE FROM board_links WHERE from_review=?").run(id);
|
|
4445
|
-
const insert = this.db.prepare("INSERT INTO board_links(from_review,from_block,to_review,to_block) VALUES(?,?,?,?)");
|
|
4446
|
-
for (const { from, to } of boardLinks(id, document)) insert.run(from.board, from.block, to.board, to.block ?? null);
|
|
4447
|
-
}
|
|
4448
|
-
/** Link previews by board, version and block; a version never changes. */
|
|
4449
|
-
linkPreviews = /* @__PURE__ */ new Map();
|
|
4450
|
-
linkPreview(target, block) {
|
|
4451
|
-
const key = `${target.reviewId}@${target.version}#${block ?? ""}`;
|
|
4452
|
-
let preview = this.linkPreviews.get(key);
|
|
4453
|
-
if (!preview) {
|
|
4454
|
-
preview = boardLinkPreview(target.document, block);
|
|
4455
|
-
if (this.linkPreviews.size >= 500) this.linkPreviews.delete(this.linkPreviews.keys().next().value);
|
|
4456
|
-
this.linkPreviews.set(key, preview);
|
|
4457
|
-
}
|
|
4458
|
-
return preview;
|
|
4459
|
-
}
|
|
4460
|
-
/** What a link's hover card shows of the board or block it links to,
|
|
4461
|
-
* read when the card opens rather than with every link. Only for a link
|
|
4462
|
-
* the version has. */
|
|
4463
|
-
linkPeek(id, to, version) {
|
|
4464
|
-
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);
|
|
4465
|
-
return this.linkPreview(this.read(to.board), to.block);
|
|
4466
|
-
}
|
|
4467
|
-
/** "owner / repo" for a board in a GitHub repository, else its folder. */
|
|
4468
|
-
repositoryLabel(board) {
|
|
4469
|
-
const repositoryId = board.target?.repositoryId;
|
|
4470
|
-
if (!repositoryId) return void 0;
|
|
4471
|
-
try {
|
|
4472
|
-
return this.repositoryGroup(this.repositoryPath(repositoryId))?.label.split("/").join(" / ");
|
|
4473
|
-
} catch {
|
|
4474
|
-
return;
|
|
4475
|
-
}
|
|
4476
|
-
}
|
|
4477
|
-
/** A version's outgoing links and the board's current backlinks. Links to a
|
|
4478
|
-
* deleted board or block are marked missing. */
|
|
4479
|
-
links(id, version) {
|
|
4480
|
-
const titles = /* @__PURE__ */ new Map();
|
|
4481
|
-
const board = (reviewId) => {
|
|
4482
|
-
if (!titles.has(reviewId)) titles.set(reviewId, this.has(reviewId) ? this.read(reviewId) : void 0);
|
|
4483
|
-
return titles.get(reviewId);
|
|
4484
|
-
};
|
|
4485
|
-
return {
|
|
4486
|
-
outgoing: boardLinks(id, this.read(id, version).document).map(({ from, to }) => {
|
|
4487
|
-
const target = board(to.board);
|
|
4488
|
-
const missing = !target || to.block !== void 0 && !elements(target.document).some((element) => element.id === to.block);
|
|
4489
|
-
const deleted = target ? void 0 : this.db.prepare("SELECT title,deleted_at FROM deleted_boards WHERE id=?").get(to.board);
|
|
4490
|
-
return {
|
|
4491
|
-
from,
|
|
4492
|
-
to,
|
|
4493
|
-
...target && {
|
|
4494
|
-
title: target.title,
|
|
4495
|
-
icon: boardIconKind(target.target),
|
|
4496
|
-
...this.repositoryLabel(target) && { repository: this.repositoryLabel(target) },
|
|
4497
|
-
...target.origin?.pullRequestNumber && {
|
|
4498
|
-
pullRequestNumber: target.origin.pullRequestNumber,
|
|
4499
|
-
pullRequestUrl: target.origin.pullRequestUrl
|
|
4500
|
-
},
|
|
4501
|
-
updatedAt: target.createdAt
|
|
4502
|
-
},
|
|
4503
|
-
missing,
|
|
4504
|
-
...deleted && {
|
|
4505
|
-
title: String(deleted.title),
|
|
4506
|
-
deletedAt: String(deleted.deleted_at)
|
|
4507
|
-
}
|
|
4508
|
-
};
|
|
4509
|
-
}),
|
|
4510
|
-
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) => {
|
|
4511
|
-
const from = board(String(row.from_review));
|
|
4512
|
-
return {
|
|
4513
|
-
from: {
|
|
4514
|
-
board: String(row.from_review),
|
|
4515
|
-
block: String(row.from_block)
|
|
4516
|
-
},
|
|
4517
|
-
to: row.to_block === null ? { board: id } : {
|
|
4518
|
-
board: id,
|
|
4519
|
-
block: String(row.to_block)
|
|
4520
|
-
},
|
|
4521
|
-
title: from?.title ?? "",
|
|
4522
|
-
...from && { icon: boardIconKind(from.target) },
|
|
4523
|
-
...from?.origin?.pullRequestNumber && {
|
|
4524
|
-
pullRequestNumber: from.origin.pullRequestNumber,
|
|
4525
|
-
pullRequestUrl: from.origin.pullRequestUrl
|
|
4526
|
-
}
|
|
4527
|
-
};
|
|
4528
|
-
})
|
|
4529
|
-
};
|
|
4530
|
-
}
|
|
4531
|
-
/** Boards edited by an Ask thread, most recent first. */
|
|
4532
|
-
boardsChangedBy(threadId) {
|
|
4533
|
-
return this.db.prepare(`SELECT edits.review_id, MAX(edits.version) AS last
|
|
4534
|
-
FROM versions edits
|
|
4535
|
-
JOIN reviews ON reviews.id=edits.review_id
|
|
4536
|
-
WHERE edits.thread_id=?
|
|
4537
|
-
GROUP BY edits.review_id ORDER BY MAX(json_extract(edits.snapshot,'$.createdAt')) DESC`).all(threadId).map((row) => {
|
|
4538
|
-
const board = this.read(String(row.review_id));
|
|
4539
|
-
const blockId = this.read(board.reviewId, Number(row.last)).lastEdit?.blockId;
|
|
4540
|
-
return {
|
|
4541
|
-
reviewId: board.reviewId,
|
|
4542
|
-
title: board.title,
|
|
4543
|
-
icon: boardIconKind(board.target),
|
|
4544
|
-
...blockId && elements(board.document).some((element) => element.id === blockId) && { blockId }
|
|
4545
|
-
};
|
|
4546
|
-
});
|
|
4547
|
-
}
|
|
4548
|
-
/** The boards each thread changed, newest first, for many threads in one
|
|
4549
|
-
* query. */
|
|
4550
|
-
/** A board's title and icon, read without its document. */
|
|
4551
|
-
/** Where a board lives, as Recent names it: its checkout's folder, else
|
|
4552
|
-
* its project's name. */
|
|
4553
|
-
boardPlace(reviewId) {
|
|
4554
|
-
const row = this.db.prepare(`SELECT repositories.path, projects.name FROM reviews
|
|
4555
|
-
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4556
|
-
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
4557
|
-
LEFT JOIN projects ON projects.id=reviews.project_id
|
|
4558
|
-
WHERE reviews.id=?`).get(reviewId);
|
|
4559
|
-
return row?.path && path.basename(String(row.path)) || (row?.name ? String(row.name) : "");
|
|
4560
|
-
}
|
|
4561
|
-
boardFace(reviewId) {
|
|
4562
|
-
const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title, json_extract(versions.snapshot,'$.target') AS target
|
|
4563
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
4564
|
-
WHERE reviews.id=?`).get(reviewId);
|
|
4565
|
-
return {
|
|
4566
|
-
reviewId,
|
|
4567
|
-
title: String(row.title),
|
|
4568
|
-
icon: boardIconKind(row.target ? JSON.parse(String(row.target)) : void 0)
|
|
4569
|
-
};
|
|
4570
|
-
}
|
|
4571
|
-
boardsChangedByThreads(threadIds) {
|
|
4572
|
-
const changed = /* @__PURE__ */ new Map();
|
|
4573
|
-
if (!threadIds.length) return changed;
|
|
4574
|
-
const rows = this.db.prepare(`SELECT thread_id AS thread, review_id
|
|
4575
|
-
FROM versions
|
|
4576
|
-
WHERE thread_id IN (${threadIds.map(() => "?").join(",")})
|
|
4577
|
-
GROUP BY thread, review_id ORDER BY MAX(rowid) DESC`).all(...threadIds);
|
|
4578
|
-
const boards = /* @__PURE__ */ new Map();
|
|
4579
|
-
for (const row of rows) {
|
|
4580
|
-
const reviewId = String(row.review_id);
|
|
4581
|
-
if (!boards.has(reviewId)) boards.set(reviewId, this.has(reviewId) ? this.boardFace(reviewId) : void 0);
|
|
4582
|
-
const board = boards.get(reviewId);
|
|
4583
|
-
if (!board) continue;
|
|
4584
|
-
const thread = String(row.thread);
|
|
4585
|
-
changed.set(thread, [...changed.get(thread) ?? [], board]);
|
|
4586
|
-
}
|
|
4587
|
-
return changed;
|
|
4588
|
-
}
|
|
4589
2752
|
/** Resolve a target-less PR create before it queues, when it will need it. */
|
|
4590
2753
|
startPullRequest(command) {
|
|
4591
2754
|
const op = command.operation;
|
|
4592
|
-
if (op.type !== "create" || op.target || !op.pullRequestUrl) return void 0;
|
|
2755
|
+
if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl) return void 0;
|
|
4593
2756
|
if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
|
|
4594
|
-
if (!op.repositoryId) return Promise.reject(new ReviewInputError("Name the checkout to fetch the PR into: target {kind:\"pullRequest\", repositoryPath, pullRequest}."));
|
|
4595
2757
|
const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
|
|
4596
2758
|
if (existing) try {
|
|
4597
2759
|
this.assertReusableReview(existing);
|
|
4598
2760
|
} catch (error) {
|
|
4599
2761
|
return Promise.reject(error);
|
|
4600
2762
|
}
|
|
4601
|
-
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
2763
|
+
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
2764
|
+
id: op.repositoryId,
|
|
2765
|
+
preferred: existing && this.read(existing).pins?.repositoryId
|
|
2766
|
+
});
|
|
4602
2767
|
}
|
|
4603
2768
|
startPullRequestTitle(command) {
|
|
4604
2769
|
const op = command.operation;
|
|
@@ -4620,7 +2785,7 @@ var ReviewStore = class {
|
|
|
4620
2785
|
existingReview(reviewId, others, requested) {
|
|
4621
2786
|
const snapshot = this.read(reviewId);
|
|
4622
2787
|
this.assertReusableReview(reviewId);
|
|
4623
|
-
const headMoved = snapshot.pins?.head !== requested.head;
|
|
2788
|
+
const headMoved = snapshot.pins?.repositoryId !== requested.repositoryId || snapshot.pins?.head !== requested.head;
|
|
4624
2789
|
const working = this.activity.read(reviewId).activities ?? [];
|
|
4625
2790
|
return {
|
|
4626
2791
|
created: false,
|
|
@@ -4665,7 +2830,7 @@ var ReviewStore = class {
|
|
|
4665
2830
|
notify(result) {
|
|
4666
2831
|
if (result.deleted) this.observedVersions.delete(result.reviewId);
|
|
4667
2832
|
else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
|
|
4668
|
-
if (
|
|
2833
|
+
if (!result.attention || result.lifecycle) for (const listener of this.listeners) try {
|
|
4669
2834
|
listener(result);
|
|
4670
2835
|
} catch {}
|
|
4671
2836
|
for (const listener of this.catalogListeners) try {
|
|
@@ -4679,26 +2844,9 @@ var ReviewStore = class {
|
|
|
4679
2844
|
has(reviewId) {
|
|
4680
2845
|
return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
|
|
4681
2846
|
}
|
|
4682
|
-
/** Saves each new commit link's commit in full, resolved in the board's
|
|
4683
|
-
* repository; one it does not have rejects the edit, as pins do. A link the
|
|
4684
|
-
* board already had stays as it was saved, even if its commit is gone. */
|
|
4685
|
-
async resolveCommitLinks(document, board, previous) {
|
|
4686
|
-
const saved = new Set(previous ? commitLinkCommits(previous) : []);
|
|
4687
|
-
const commits = commitLinkCommits(document).filter((commit) => !saved.has(commit));
|
|
4688
|
-
const repositoryId = boardRepositoryId(board);
|
|
4689
|
-
if (!commits.length || !repositoryId || !this.providers.resolveCommit) return document;
|
|
4690
|
-
const resolved = /* @__PURE__ */ new Map();
|
|
4691
|
-
await Promise.all(commits.map(async (commit) => {
|
|
4692
|
-
const full = await this.providers.resolveCommit(repositoryId, commit);
|
|
4693
|
-
if (!full) throw new ReviewInputError(`Commit ${commit} is not in the board's repository. Fetch it first, or link a commit the repository has.`);
|
|
4694
|
-
resolved.set(commit, full);
|
|
4695
|
-
}));
|
|
4696
|
-
return respellCommitLinks(document, resolved);
|
|
4697
|
-
}
|
|
4698
2847
|
async validateExternal(snapshot, previous, repin = false) {
|
|
4699
2848
|
const warnings = [];
|
|
4700
|
-
const references = (
|
|
4701
|
-
const { document, lenses = [] } = board;
|
|
2849
|
+
const references = (document, tolerant = false, lenses = []) => {
|
|
4702
2850
|
const sources = /* @__PURE__ */ new Map();
|
|
4703
2851
|
const resources = /* @__PURE__ */ new Map();
|
|
4704
2852
|
const add = (source, peek) => {
|
|
@@ -4709,10 +2857,7 @@ var ReviewStore = class {
|
|
|
4709
2857
|
peek: peek || (kept?.peek ?? false)
|
|
4710
2858
|
});
|
|
4711
2859
|
};
|
|
4712
|
-
for (const { source, peek } of sourceReferences(document, {
|
|
4713
|
-
tolerant,
|
|
4714
|
-
repositoryId: boardRepositoryId(board)
|
|
4715
|
-
})) add(source, peek === true);
|
|
2860
|
+
for (const { source, peek } of sourceReferences(document, { tolerant })) add(source, peek === true);
|
|
4716
2861
|
for (const { source } of lensSelections(lenses)) for (const anchor of sourceAnchors(source)) add(anchor, false);
|
|
4717
2862
|
for (const block of resourceReferences(document)) resources.set(JSON.stringify(block), block);
|
|
4718
2863
|
return {
|
|
@@ -4720,18 +2865,18 @@ var ReviewStore = class {
|
|
|
4720
2865
|
resources
|
|
4721
2866
|
};
|
|
4722
2867
|
};
|
|
4723
|
-
const current = references(snapshot, repin);
|
|
2868
|
+
const current = references(snapshot.document, repin, snapshot.lenses);
|
|
4724
2869
|
const pinsChanged = previous && JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
|
|
4725
2870
|
const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
|
|
4726
2871
|
const changed = repin && pinsChanged && previous.pins && snapshot.pins ? await this.providers.filesChangedBetween?.(previous.pins, snapshot.pins).catch(() => void 0) : void 0;
|
|
4727
|
-
const retained = references(previous ??
|
|
2872
|
+
const retained = references(previous?.document ?? [], true, previous?.lenses);
|
|
4728
2873
|
const checks = [];
|
|
4729
2874
|
const retainedPins = new Set(explicitPins([...retained.sources.values()]).map((pins) => JSON.stringify(pins)));
|
|
4730
2875
|
for (const pins of explicitPins([...current.sources.values()])) if (!retainedPins.has(JSON.stringify(pins))) checks.push(this.providers.validatePins(pins));
|
|
4731
2876
|
for (const [key, { source, peek }] of current.sources) {
|
|
4732
2877
|
const kept = retained.sources.get(key);
|
|
4733
2878
|
if (pinsChanged && !source.pins || !kept || peek && !kept.peek) checks.push(this.providers.validateSource(anchorPins(source, snapshot.pins), source, { peek }).then(() => {
|
|
4734
|
-
if (repin &&
|
|
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.`);
|
|
4735
2880
|
}, (error) => {
|
|
4736
2881
|
if (!repin && !(worktreeMoved && kept) || !(error instanceof ReviewInputError)) throw error;
|
|
4737
2882
|
warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`);
|
|
@@ -4745,106 +2890,6 @@ var ReviewStore = class {
|
|
|
4745
2890
|
return warnings.sort();
|
|
4746
2891
|
}
|
|
4747
2892
|
};
|
|
4748
|
-
/** The highest number in a document's `prefix-N` ids, units included. */
|
|
4749
|
-
function highestIdNumber(document) {
|
|
4750
|
-
let highest = 0;
|
|
4751
|
-
for (const [, number] of JSON.stringify(document).matchAll(/"id":"[^"]*?-(\d+)"/g)) highest = Math.max(highest, Number(number));
|
|
4752
|
-
return highest;
|
|
4753
|
-
}
|
|
4754
|
-
/** The title of a board a person made and has not named. */
|
|
4755
|
-
/** A person's save must start from the board's current version. */
|
|
4756
|
-
function assertBaseVersion(base, current) {
|
|
4757
|
-
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);
|
|
4758
|
-
}
|
|
4759
|
-
/**
|
|
4760
|
-
* A person's whole document as the store saves it: each new block's
|
|
4761
|
-
* clientKey taken, the blocks parsed, a flow's units adopted and every block
|
|
4762
|
-
* without an id given the next one after `nextId`. Every save then respells
|
|
4763
|
-
* its links (respellDocumentLinks) and resolves commit links it has not
|
|
4764
|
-
* saved before. Mutates `input`.
|
|
4765
|
-
*/
|
|
4766
|
-
function personDocument(input, nextId) {
|
|
4767
|
-
const keys = takeClientKeys(input);
|
|
4768
|
-
const document = documentSchema.parse(input);
|
|
4769
|
-
const allocate = (prefix) => `${prefix}-${++nextId}`;
|
|
4770
|
-
adoptFlowUnits(document, allocate);
|
|
4771
|
-
for (const block of document) assignMissingIds(block, allocate);
|
|
4772
|
-
assertUniqueIds(document);
|
|
4773
|
-
return {
|
|
4774
|
-
document,
|
|
4775
|
-
nextId: Math.max(nextId, highestIdNumber(document)),
|
|
4776
|
-
ids: Object.fromEntries([...keys.byPath].map(([path, key]) => [key, blockAt(document, path).id]))
|
|
4777
|
-
};
|
|
4778
|
-
}
|
|
4779
|
-
const clientKeySchema = z.string().min(1).optional();
|
|
4780
|
-
/** Removes each block's clientKey from a person's raw document, keeping it
|
|
4781
|
-
* by the block's path when the block has no id. Counts the bare blocks. */
|
|
4782
|
-
function takeClientKeys(document) {
|
|
4783
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
4784
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4785
|
-
let bare = 0;
|
|
4786
|
-
const visit = (list, prefix) => list.forEach((block, index) => {
|
|
4787
|
-
if (!isJsonObject(block)) return;
|
|
4788
|
-
const path = `${prefix}${index}`;
|
|
4789
|
-
const clientKey = clientKeySchema.parse(block.clientKey);
|
|
4790
|
-
delete block.clientKey;
|
|
4791
|
-
if (block.id === void 0) {
|
|
4792
|
-
bare++;
|
|
4793
|
-
if (clientKey !== void 0) {
|
|
4794
|
-
if (seen.has(clientKey)) throw new ReviewInputError(`clientKey ${clientKey} names more than one block. Give each new block its own clientKey.`);
|
|
4795
|
-
seen.add(clientKey);
|
|
4796
|
-
byPath.set(path, clientKey);
|
|
4797
|
-
}
|
|
4798
|
-
}
|
|
4799
|
-
if (Array.isArray(block.children)) visit(block.children, `${path}/`);
|
|
4800
|
-
});
|
|
4801
|
-
visit(document, "");
|
|
4802
|
-
return {
|
|
4803
|
-
byPath,
|
|
4804
|
-
bare
|
|
4805
|
-
};
|
|
4806
|
-
}
|
|
4807
|
-
/** The block at a path takeClientKeys recorded. */
|
|
4808
|
-
function blockAt(document, path) {
|
|
4809
|
-
const [first, ...rest] = path.split("/").map(Number);
|
|
4810
|
-
let block = document[first];
|
|
4811
|
-
for (const index of rest) if ("children" in block) block = block.children[index];
|
|
4812
|
-
return block;
|
|
4813
|
-
}
|
|
4814
|
-
/** Ids name one component each across the whole document. */
|
|
4815
|
-
function assertUniqueIds(document) {
|
|
4816
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4817
|
-
for (const { id } of elements(document)) {
|
|
4818
|
-
if (id === void 0) continue;
|
|
4819
|
-
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.`);
|
|
4820
|
-
seen.add(id);
|
|
4821
|
-
}
|
|
4822
|
-
}
|
|
4823
|
-
/** Each block's own content by id, nested blocks included. A section or
|
|
4824
|
-
* callout is kept without its children, so a change to a child is only the
|
|
4825
|
-
* child's. */
|
|
4826
|
-
function ownBlocks(document, into = /* @__PURE__ */ new Map()) {
|
|
4827
|
-
for (const block of document) if ("children" in block) {
|
|
4828
|
-
const { children, ...own } = block;
|
|
4829
|
-
if (block.id !== void 0) into.set(block.id, own);
|
|
4830
|
-
ownBlocks(children, into);
|
|
4831
|
-
} else if (block.id !== void 0) into.set(block.id, block);
|
|
4832
|
-
return into;
|
|
4833
|
-
}
|
|
4834
|
-
/** The blocks added, removed or changed from one document to the next. A
|
|
4835
|
-
* block moved with its content unchanged did not change. */
|
|
4836
|
-
function changedBlocks(before, after) {
|
|
4837
|
-
const old = ownBlocks(before);
|
|
4838
|
-
const now = ownBlocks(after);
|
|
4839
|
-
return [.../* @__PURE__ */ new Set([...old.keys(), ...now.keys()])].filter((id) => !isDeepStrictEqual(old.get(id), now.get(id)));
|
|
4840
|
-
}
|
|
4841
|
-
/** Whether a reference's own pins read the commit the board's previous
|
|
4842
|
-
* pins did. */
|
|
4843
|
-
function readsWhereItDid(source, previous) {
|
|
4844
|
-
if (!source.pins || !previous) return false;
|
|
4845
|
-
const commit = source.side === "base" ? source.pins.base ?? source.pins.head : source.pins.head;
|
|
4846
|
-
return source.pins.repositoryId === previous.repositoryId && (commit === previous.head || commit === previous.base);
|
|
4847
|
-
}
|
|
4848
2893
|
/** Without a resolver, a commits target that names both commits pins them as
|
|
4849
2894
|
* given; validatePins still checks they exist. */
|
|
4850
2895
|
function namedCommits(target) {
|
|
@@ -4861,19 +2906,26 @@ function namedCommits(target) {
|
|
|
4861
2906
|
}
|
|
4862
2907
|
/** A new document's first version, before its initial content. Field order
|
|
4863
2908
|
* is kept so stored JSON reads as it always has. */
|
|
4864
|
-
function createdSnapshot(id, op, resolved, defaultTitle
|
|
4865
|
-
const title = op.title
|
|
4866
|
-
if (title
|
|
4867
|
-
|
|
2909
|
+
function createdSnapshot(id, op, resolved, defaultTitle) {
|
|
2910
|
+
const title = op.title ?? defaultTitle;
|
|
2911
|
+
if (!title) throw new ReviewInputError("Supply a title.");
|
|
2912
|
+
if (!resolved) return {
|
|
2913
|
+
reviewId: id,
|
|
2914
|
+
version: 0,
|
|
2915
|
+
title,
|
|
2916
|
+
kind: op.kind,
|
|
2917
|
+
document: [],
|
|
2918
|
+
createdAt: ""
|
|
2919
|
+
};
|
|
2920
|
+
return {
|
|
4868
2921
|
reviewId: id,
|
|
4869
2922
|
version: 0,
|
|
4870
2923
|
title,
|
|
2924
|
+
pins: resolved.pins,
|
|
4871
2925
|
target: resolved.target,
|
|
4872
2926
|
document: [],
|
|
4873
2927
|
createdAt: ""
|
|
4874
2928
|
};
|
|
4875
|
-
if (resolved.pins) snapshot.pins = resolved.pins;
|
|
4876
|
-
return snapshot;
|
|
4877
2929
|
}
|
|
4878
2930
|
function inspectSnapshot(snapshot, targetId) {
|
|
4879
2931
|
if (targetId !== void 0) {
|
|
@@ -4887,8 +2939,6 @@ function inspectSnapshot(snapshot, targetId) {
|
|
|
4887
2939
|
label: "title" in element ? element.title : "label" in element ? element.label : element.type === "markdown" ? element.markdown.slice(0, 120) : void 0
|
|
4888
2940
|
}));
|
|
4889
2941
|
}
|
|
4890
|
-
/** How many ancestry questions git answers at once. */
|
|
4891
|
-
const ANCESTRY_CONCURRENCY = 8;
|
|
4892
2942
|
//#endregion
|
|
4893
2943
|
//#region src/review-api/version-cache.ts
|
|
4894
2944
|
/** A stored version never changes, so work derived from it runs once. Keeps
|
|
@@ -5321,6 +3371,7 @@ var ReviewWorkspaces = class {
|
|
|
5321
3371
|
};
|
|
5322
3372
|
//#endregion
|
|
5323
3373
|
//#region src/review-api/worktree-events.ts
|
|
3374
|
+
const exec$1 = promisify(execFile);
|
|
5324
3375
|
const normalize = (name) => name.replaceAll("\\", "/").normalize("NFC");
|
|
5325
3376
|
const parent = (file) => file.slice(0, Math.max(0, file.lastIndexOf("/")));
|
|
5326
3377
|
/** Directories that hold tracked files, the root as "". */
|
|
@@ -5343,7 +3394,7 @@ function atTrackedEdge(name, tracked) {
|
|
|
5343
3394
|
/** Wholly ignored directories, so watchers can skip them. */
|
|
5344
3395
|
async function ignoredDirectories(rootPath, gitDirectory) {
|
|
5345
3396
|
try {
|
|
5346
|
-
const { stdout } = await
|
|
3397
|
+
const { stdout } = await exec$1("git", [
|
|
5347
3398
|
"-C",
|
|
5348
3399
|
rootPath,
|
|
5349
3400
|
...gitDirectory ? [
|
|
@@ -5382,7 +3433,7 @@ function gitEventMatters(name, prefix) {
|
|
|
5382
3433
|
/** Where this checkout's git directory sits in the common one: "" for the main checkout. */
|
|
5383
3434
|
async function gitDirectoryPrefix(rootPath, common) {
|
|
5384
3435
|
try {
|
|
5385
|
-
const { stdout } = await
|
|
3436
|
+
const { stdout } = await exec$1("git", [
|
|
5386
3437
|
"-C",
|
|
5387
3438
|
rootPath,
|
|
5388
3439
|
"rev-parse",
|
|
@@ -5398,6 +3449,7 @@ async function gitDirectoryPrefix(rootPath, common) {
|
|
|
5398
3449
|
//#endregion
|
|
5399
3450
|
//#region src/review-api/worktree-source.ts
|
|
5400
3451
|
const { lstat: lstat$1, readFile: readFile$1, readlink, realpath: realpath$1 } = checkoutFs;
|
|
3452
|
+
const exec = promisify(execFile);
|
|
5401
3453
|
const EMPTY_SOURCE = "empty-worktree-baseline";
|
|
5402
3454
|
/** Never follow a working-copy link outside the selected checkout. */
|
|
5403
3455
|
async function localSourcePath(root, file) {
|
|
@@ -5412,7 +3464,7 @@ async function localSourcePath(root, file) {
|
|
|
5412
3464
|
async function workingFiles(vcs) {
|
|
5413
3465
|
const gitDirectory = vcs.kind === "jj" ? await gitCommonDir(vcs.rootPath) : void 0;
|
|
5414
3466
|
if (vcs.kind === "jj" && !gitDirectory) throw new ReviewInputError("Working source requires a Git-backed jj repository.");
|
|
5415
|
-
const { stdout } = await
|
|
3467
|
+
const { stdout } = await exec("git", [
|
|
5416
3468
|
"-C",
|
|
5417
3469
|
vcs.rootPath,
|
|
5418
3470
|
...gitDirectory ? [
|
|
@@ -5448,7 +3500,7 @@ async function workingFiles(vcs) {
|
|
|
5448
3500
|
async function untrackedFileCount(vcs) {
|
|
5449
3501
|
if (vcs.kind !== "git") return void 0;
|
|
5450
3502
|
try {
|
|
5451
|
-
const { stdout } = await
|
|
3503
|
+
const { stdout } = await exec("git", [
|
|
5452
3504
|
"-C",
|
|
5453
3505
|
vcs.rootPath,
|
|
5454
3506
|
"ls-files",
|
|
@@ -5528,10 +3580,6 @@ function sliceRange(file, source) {
|
|
|
5528
3580
|
text: inputError(() => sliceSourceRange(file.text, source))
|
|
5529
3581
|
};
|
|
5530
3582
|
}
|
|
5531
|
-
/** How long a PR's state is shown before it is read again. */
|
|
5532
|
-
const PULL_REQUEST_STATE_TTL_MS = 3e5;
|
|
5533
|
-
/** How many PR states are read from GitHub at once. */
|
|
5534
|
-
const PULL_REQUEST_STATE_READS = 2;
|
|
5535
3583
|
/** Local source/resource boundary, including Desktop-only local language context. */
|
|
5536
3584
|
var LocalReviewData = class {
|
|
5537
3585
|
store;
|
|
@@ -5609,7 +3657,7 @@ var LocalReviewData = class {
|
|
|
5609
3657
|
};
|
|
5610
3658
|
}
|
|
5611
3659
|
if (commit && !anchor) await this.comparison(await this.documentPins(snapshot), commit);
|
|
5612
|
-
const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId
|
|
3660
|
+
const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
|
|
5613
3661
|
if (!repositoryId) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
5614
3662
|
const unavailable = {
|
|
5615
3663
|
rootPath: null,
|
|
@@ -5664,7 +3712,7 @@ var LocalReviewData = class {
|
|
|
5664
3712
|
const context = await resolveRepoContext(repository);
|
|
5665
3713
|
if (!rootPath || !context) throw new ReviewInputError("Could not open the selected source checkout.", 409);
|
|
5666
3714
|
if (!live) {
|
|
5667
|
-
const { stdout } = await
|
|
3715
|
+
const { stdout } = await promisify(execFile)("git", [
|
|
5668
3716
|
"-C",
|
|
5669
3717
|
rootPath,
|
|
5670
3718
|
"status",
|
|
@@ -5679,7 +3727,7 @@ var LocalReviewData = class {
|
|
|
5679
3727
|
const current = await readWorkspace(workspacePath);
|
|
5680
3728
|
if (current !== null) {
|
|
5681
3729
|
const previous = current ?? await readWorkspace(path.join(workspaceDirectory, `${path.basename(repository)}.code-workspace`));
|
|
5682
|
-
const title = `${
|
|
3730
|
+
const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
5683
3731
|
const workspace = previous ?? {
|
|
5684
3732
|
folders: [],
|
|
5685
3733
|
settings: { "files.readonlyInclude": { "**/*": true } }
|
|
@@ -5716,25 +3764,12 @@ var LocalReviewData = class {
|
|
|
5716
3764
|
rootPath
|
|
5717
3765
|
};
|
|
5718
3766
|
}
|
|
5719
|
-
/** A document read that needs default pins
|
|
5720
|
-
* repository's live checkout with base at head; 409 when it has neither. */
|
|
3767
|
+
/** A document read that needs default pins; 409 when the document has none. */
|
|
5721
3768
|
async documentPins(snapshot) {
|
|
5722
|
-
const pins = await this.sourcePins(snapshot)
|
|
3769
|
+
const pins = await this.sourcePins(snapshot);
|
|
5723
3770
|
if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
5724
3771
|
return pins;
|
|
5725
3772
|
}
|
|
5726
|
-
/** A checkout's current state, base at head: what a plan reads. */
|
|
5727
|
-
async livePins(repositoryId) {
|
|
5728
|
-
const vcs = await this.vcs(repositoryId);
|
|
5729
|
-
if (!vcs) throw unavailableCheckout();
|
|
5730
|
-
const { revision, commit } = await this.worktreeState(repositoryId, vcs);
|
|
5731
|
-
return {
|
|
5732
|
-
repositoryId,
|
|
5733
|
-
base: commit,
|
|
5734
|
-
head: commit,
|
|
5735
|
-
worktreeRevision: revision
|
|
5736
|
-
};
|
|
5737
|
-
}
|
|
5738
3773
|
/** Pins a reference names itself, checked against the registered checkout. */
|
|
5739
3774
|
async anchorSourcePins(anchor) {
|
|
5740
3775
|
if (!existsSync(this.store.repositoryPath(anchor.repositoryId))) throw unavailableCheckout();
|
|
@@ -5744,10 +3779,7 @@ var LocalReviewData = class {
|
|
|
5744
3779
|
* `snapshot` is a stored version. */
|
|
5745
3780
|
async unavailableAnchors(snapshot) {
|
|
5746
3781
|
const missing = /* @__PURE__ */ new Set();
|
|
5747
|
-
const
|
|
5748
|
-
const boardRepository = boardRepositoryId(snapshot);
|
|
5749
|
-
if (boardRepository) repositoryIds.add(boardRepository);
|
|
5750
|
-
for (const repositoryId of repositoryIds) try {
|
|
3782
|
+
for (const repositoryId of pinnedRepositoryIds(snapshot.document)) try {
|
|
5751
3783
|
if (!existsSync(this.store.repositoryPath(repositoryId))) missing.add(repositoryId);
|
|
5752
3784
|
} catch (error) {
|
|
5753
3785
|
if (!(error instanceof ReviewInputError)) throw error;
|
|
@@ -5756,10 +3788,7 @@ var LocalReviewData = class {
|
|
|
5756
3788
|
if (!missing.size) return [];
|
|
5757
3789
|
return this.storedReferences.get(snapshot).filter((ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId)).map((ref) => ref.id);
|
|
5758
3790
|
}
|
|
5759
|
-
storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, {
|
|
5760
|
-
tolerant: true,
|
|
5761
|
-
repositoryId: boardRepositoryId(snapshot)
|
|
5762
|
-
}), { capacity: 512 });
|
|
3791
|
+
storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, { tolerant: true }), { capacity: 512 });
|
|
5763
3792
|
trackedFiles = /* @__PURE__ */ new Map();
|
|
5764
3793
|
repositories = /* @__PURE__ */ new Map();
|
|
5765
3794
|
readers = /* @__PURE__ */ new Map();
|
|
@@ -5981,10 +4010,7 @@ var LocalReviewData = class {
|
|
|
5981
4010
|
staleSources: []
|
|
5982
4011
|
};
|
|
5983
4012
|
delete projected.sourceUnavailable;
|
|
5984
|
-
for (const reference of sourceReferences(snapshot.document, {
|
|
5985
|
-
tolerant: true,
|
|
5986
|
-
repositoryId: boardRepositoryId(snapshot)
|
|
5987
|
-
})) try {
|
|
4013
|
+
for (const reference of sourceReferences(snapshot.document, { tolerant: true })) try {
|
|
5988
4014
|
await this.quote(anchorPins(reference.source, pins), reference.source);
|
|
5989
4015
|
} catch (error) {
|
|
5990
4016
|
if (!(error instanceof ReviewInputError)) throw error;
|
|
@@ -5996,7 +4022,6 @@ var LocalReviewData = class {
|
|
|
5996
4022
|
* for a document without default pins. */
|
|
5997
4023
|
async sourcePins(snapshot) {
|
|
5998
4024
|
if (snapshot.target?.kind === "worktree") return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
|
|
5999
|
-
if (snapshot.target?.kind === "project" && !existsSync(this.store.repositoryPath(snapshot.target.repositoryId))) throw unavailableCheckout();
|
|
6000
4025
|
if (!snapshot.pins) return void 0;
|
|
6001
4026
|
if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId))) throw unavailableCheckout();
|
|
6002
4027
|
return snapshot.pins;
|
|
@@ -6006,13 +4031,14 @@ var LocalReviewData = class {
|
|
|
6006
4031
|
const vcs = await this.vcs(pins.repositoryId);
|
|
6007
4032
|
if (!vcs || vcs.kind !== "git") return void 0;
|
|
6008
4033
|
try {
|
|
6009
|
-
const { stdout } = await
|
|
4034
|
+
const { stdout } = await promisify(execFile)("git", [
|
|
6010
4035
|
"-C",
|
|
6011
4036
|
vcs.rootPath,
|
|
6012
4037
|
"rev-parse",
|
|
6013
4038
|
"--symbolic-full-name",
|
|
6014
4039
|
"--verify",
|
|
6015
|
-
|
|
4040
|
+
"--end-of-options",
|
|
4041
|
+
!headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : headRef
|
|
6016
4042
|
]);
|
|
6017
4043
|
const ref = stdout.trim();
|
|
6018
4044
|
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) return void 0;
|
|
@@ -6025,7 +4051,6 @@ var LocalReviewData = class {
|
|
|
6025
4051
|
/** `pinned` refreshes a stored target: a worktree base ref resolves to its
|
|
6026
4052
|
* current fork point, and keeps the pinned one if the ref is gone. */
|
|
6027
4053
|
async resolveTarget(target, pinned) {
|
|
6028
|
-
if (target.kind === "project") throw new ReviewInputError("A project target has no commits.");
|
|
6029
4054
|
const vcs = await this.vcs(target.repositoryId);
|
|
6030
4055
|
if (!vcs) throw unavailableCheckout();
|
|
6031
4056
|
if (target.kind === "commits") {
|
|
@@ -6090,86 +4115,6 @@ var LocalReviewData = class {
|
|
|
6090
4115
|
commit: fork.commit
|
|
6091
4116
|
};
|
|
6092
4117
|
}
|
|
6093
|
-
/** Each PR's state, by URL, as last read. */
|
|
6094
|
-
pullRequestStates = /* @__PURE__ */ new Map();
|
|
6095
|
-
pullRequestStateListeners = /* @__PURE__ */ new Set();
|
|
6096
|
-
pullRequestStateTimer;
|
|
6097
|
-
pullRequestStateQueue = [];
|
|
6098
|
-
pullRequestStateReads = 0;
|
|
6099
|
-
/** While GitHub rate-limits, no state is read. */
|
|
6100
|
-
pullRequestStatesPausedUntil = 0;
|
|
6101
|
-
/** Calls back when a PR's state changes. While anyone listens, every PR
|
|
6102
|
-
* still shown is read again once its state is a few minutes old. */
|
|
6103
|
-
subscribePullRequestStates(listener) {
|
|
6104
|
-
this.pullRequestStateListeners.add(listener);
|
|
6105
|
-
this.pullRequestStateTimer ??= setInterval(() => {
|
|
6106
|
-
for (const [url, entry] of this.pullRequestStates) if (Date.now() - entry.seenAt > 2 * PULL_REQUEST_STATE_TTL_MS) this.pullRequestStates.delete(url);
|
|
6107
|
-
else this.pullRequestState(url, { seen: false });
|
|
6108
|
-
this.readPullRequestStates();
|
|
6109
|
-
}, PULL_REQUEST_STATE_TTL_MS / 5);
|
|
6110
|
-
this.pullRequestStateTimer.unref?.();
|
|
6111
|
-
return () => {
|
|
6112
|
-
this.pullRequestStateListeners.delete(listener);
|
|
6113
|
-
if (!this.pullRequestStateListeners.size) {
|
|
6114
|
-
clearInterval(this.pullRequestStateTimer);
|
|
6115
|
-
this.pullRequestStateTimer = void 0;
|
|
6116
|
-
}
|
|
6117
|
-
};
|
|
6118
|
-
}
|
|
6119
|
-
/** A PR's state as last read from GitHub; a read older than a few minutes
|
|
6120
|
-
* queues another in the background. Undefined until the first read lands,
|
|
6121
|
-
* and a failed read keeps the last state. A merged or closed PR is not
|
|
6122
|
-
* read again. */
|
|
6123
|
-
pullRequestState(url, { seen = true } = {}) {
|
|
6124
|
-
if (!url || !this.options.pullRequestStates) return void 0;
|
|
6125
|
-
const entry = this.pullRequestStates.get(url) ?? {
|
|
6126
|
-
readAt: 0,
|
|
6127
|
-
seenAt: 0,
|
|
6128
|
-
reading: false
|
|
6129
|
-
};
|
|
6130
|
-
this.pullRequestStates.set(url, entry);
|
|
6131
|
-
if (seen) entry.seenAt = Date.now();
|
|
6132
|
-
if (!entry.reading && entry.state !== "merged" && entry.state !== "closed" && Date.now() - entry.readAt > PULL_REQUEST_STATE_TTL_MS) {
|
|
6133
|
-
entry.reading = true;
|
|
6134
|
-
this.pullRequestStateQueue.push(url);
|
|
6135
|
-
this.readPullRequestStates();
|
|
6136
|
-
}
|
|
6137
|
-
return entry.state;
|
|
6138
|
-
}
|
|
6139
|
-
/** Reads queued PR states a few at a time. */
|
|
6140
|
-
readPullRequestStates() {
|
|
6141
|
-
while (this.pullRequestStateReads < PULL_REQUEST_STATE_READS && this.pullRequestStateQueue.length && Date.now() >= this.pullRequestStatesPausedUntil) {
|
|
6142
|
-
const url = this.pullRequestStateQueue.shift();
|
|
6143
|
-
const entry = this.pullRequestStates.get(url);
|
|
6144
|
-
if (!entry) continue;
|
|
6145
|
-
this.pullRequestStateReads++;
|
|
6146
|
-
Promise.resolve().then(() => {
|
|
6147
|
-
const { provider, address } = resolvePullRequest(url);
|
|
6148
|
-
return provider.readPullRequest(address, this.options.pullRequests ?? defaultPullRequestDeps);
|
|
6149
|
-
}).then((pullRequest) => {
|
|
6150
|
-
if ((!pullRequest.state || pullRequest.state === entry.state) && pullRequest.baseRefName === entry.baseBranch && pullRequest.headBranch === entry.headBranch) return;
|
|
6151
|
-
entry.state = pullRequest.state ?? entry.state;
|
|
6152
|
-
entry.baseBranch = pullRequest.baseRefName;
|
|
6153
|
-
entry.headBranch = pullRequest.headBranch;
|
|
6154
|
-
for (const listener of this.pullRequestStateListeners) listener();
|
|
6155
|
-
}).catch((error) => {
|
|
6156
|
-
if (error instanceof GitHubRefusedRead) this.pullRequestStatesPausedUntil = error.retryAt;
|
|
6157
|
-
}).finally(() => {
|
|
6158
|
-
entry.readAt = Date.now();
|
|
6159
|
-
entry.reading = false;
|
|
6160
|
-
this.pullRequestStateReads--;
|
|
6161
|
-
this.readPullRequestStates();
|
|
6162
|
-
});
|
|
6163
|
-
}
|
|
6164
|
-
}
|
|
6165
|
-
/** The branch a PR merges into, as last read with its state. */
|
|
6166
|
-
pullRequestBaseBranch(url) {
|
|
6167
|
-
return this.pullRequestStates.get(url)?.baseBranch;
|
|
6168
|
-
}
|
|
6169
|
-
/** A PR's own branch, as last read with its state. */
|
|
6170
|
-
pullRequestHeadBranch(url) {
|
|
6171
|
-
return this.pullRequestStates.get(url)?.headBranch;
|
|
6172
|
-
}
|
|
6173
4118
|
/** The PR's current comparison, fetched into a registered checkout of its repository. */
|
|
6174
4119
|
async resolvePullRequest(url, repository) {
|
|
6175
4120
|
const deps = this.options.pullRequests ?? defaultPullRequestDeps;
|
|
@@ -6210,8 +4155,9 @@ var LocalReviewData = class {
|
|
|
6210
4155
|
}
|
|
6211
4156
|
/** A registered checkout with a remote for host/owner/repo, and that remote. */
|
|
6212
4157
|
async pullRequestCheckout(provider, address, repository, deps) {
|
|
6213
|
-
const
|
|
6214
|
-
|
|
4158
|
+
const registered = this.store.repositories();
|
|
4159
|
+
const candidates = repository.id ? registered.filter((entry) => entry.id === repository.id) : [...registered.filter((entry) => entry.id === repository.preferred), ...registered.filter((entry) => entry.id !== repository.preferred)];
|
|
4160
|
+
if (repository.id && candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
|
|
6215
4161
|
for (const { id } of candidates) {
|
|
6216
4162
|
if (!existsSync(this.store.repositoryPath(id))) continue;
|
|
6217
4163
|
const vcs = await this.vcs(id);
|
|
@@ -6228,7 +4174,7 @@ var LocalReviewData = class {
|
|
|
6228
4174
|
}
|
|
6229
4175
|
const { host, slug } = address;
|
|
6230
4176
|
const name = host === "github.com" ? slug : `${host}/${slug}`;
|
|
6231
|
-
throw new ReviewInputError(`That checkout has no remote for ${name}.
|
|
4177
|
+
throw new ReviewInputError(repository.id ? `That checkout has no remote for ${name}. Add one, or omit repositoryId.` : `No registered checkout has a remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`, 404);
|
|
6232
4178
|
}
|
|
6233
4179
|
async resolvePins(repositoryId, base, head) {
|
|
6234
4180
|
const vcs = await this.vcs(repositoryId);
|
|
@@ -6260,45 +4206,6 @@ var LocalReviewData = class {
|
|
|
6260
4206
|
head
|
|
6261
4207
|
};
|
|
6262
4208
|
}
|
|
6263
|
-
/** What a commit is, for a quote read at it: its subject, author and
|
|
6264
|
-
* date, and how far behind the default branch's tip it is when both are
|
|
6265
|
-
* known. */
|
|
6266
|
-
async commitInfo(repositoryId, commit) {
|
|
6267
|
-
const vcs = await this.vcs(repositoryId);
|
|
6268
|
-
if (!vcs) throw unavailableCheckout();
|
|
6269
|
-
const git = async (...args) => (await runVcs("git", [
|
|
6270
|
-
"-C",
|
|
6271
|
-
vcs.rootPath,
|
|
6272
|
-
...args
|
|
6273
|
-
])).stdout.trim();
|
|
6274
|
-
let line;
|
|
6275
|
-
try {
|
|
6276
|
-
line = await git("log", "-1", "--format=%H%x00%s%x00%an%x00%aI", { rev: commit });
|
|
6277
|
-
} catch {
|
|
6278
|
-
throw new ReviewInputError("That commit is not in the repository.", 404);
|
|
6279
|
-
}
|
|
6280
|
-
const [full = commit, subject = "", author = "", authoredAt = ""] = line.split("\0");
|
|
6281
|
-
const branch = await vcs.defaultBranch().catch(() => null);
|
|
6282
|
-
const behind = branch ? await git("rev-list", "--count", { rev: `${full}..${branch.commit}` }).then((count) => ({
|
|
6283
|
-
count: Number(count),
|
|
6284
|
-
branch: branch.ref.replace(/^origin\//, "")
|
|
6285
|
-
})).catch(() => void 0) : void 0;
|
|
6286
|
-
return {
|
|
6287
|
-
commit: full,
|
|
6288
|
-
subject,
|
|
6289
|
-
author,
|
|
6290
|
-
authoredAt,
|
|
6291
|
-
...behind && { behind }
|
|
6292
|
-
};
|
|
6293
|
-
}
|
|
6294
|
-
/** The full commit a commit ID or its prefix names in the repository,
|
|
6295
|
-
* or undefined when it names none. */
|
|
6296
|
-
async resolveCommit(repositoryId, commit) {
|
|
6297
|
-
const vcs = await this.vcs(repositoryId);
|
|
6298
|
-
if (!vcs) throw unavailableCheckout();
|
|
6299
|
-
const resolved = await vcs.resolveRevision(commit);
|
|
6300
|
-
return resolved?.commit.startsWith(commit.toLowerCase()) ? resolved.commit : void 0;
|
|
6301
|
-
}
|
|
6302
4209
|
async validatePins(pins) {
|
|
6303
4210
|
if (pins.worktreeRevision) {
|
|
6304
4211
|
if (!await this.vcs(pins.repositoryId)) throw unavailableCheckout();
|
|
@@ -6661,7 +4568,6 @@ async function unwatch(entry) {
|
|
|
6661
4568
|
/** Linux fails to unwatch a directory that is already gone. */
|
|
6662
4569
|
const release = (subscription) => subscription.unsubscribe().catch(() => {});
|
|
6663
4570
|
function openLocalReviewStore(databasePath, options = {}) {
|
|
6664
|
-
const { checkoutOwners, ...dataOptions } = options;
|
|
6665
4571
|
const store = new ReviewStore(databasePath, {
|
|
6666
4572
|
projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
|
|
6667
4573
|
resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
|
|
@@ -6671,16 +4577,14 @@ function openLocalReviewStore(databasePath, options = {}) {
|
|
|
6671
4577
|
sourcePins: (snapshot) => data.sourcePins(snapshot),
|
|
6672
4578
|
unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
|
|
6673
4579
|
validatePins: (pins) => data.validatePins(pins),
|
|
6674
|
-
resolveCommit: (repositoryId, commit) => data.resolveCommit(repositoryId, commit),
|
|
6675
4580
|
filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
|
|
6676
4581
|
validateSource: (pins, source, options) => data.validateSource(pins, source, options),
|
|
6677
|
-
validateResource: (pins, block) => data.validateResource(pins, block)
|
|
6678
|
-
|
|
6679
|
-
}, checkoutOwners);
|
|
4582
|
+
validateResource: (pins, block) => data.validateResource(pins, block)
|
|
4583
|
+
});
|
|
6680
4584
|
let data;
|
|
6681
4585
|
try {
|
|
6682
4586
|
data = new LocalReviewData(store, {
|
|
6683
|
-
...
|
|
4587
|
+
...options,
|
|
6684
4588
|
workspaceDatabase: `${databasePath}.workspaces`
|
|
6685
4589
|
});
|
|
6686
4590
|
} catch (error) {
|
|
@@ -6713,4 +4617,4 @@ async function readWorkspace(file) {
|
|
|
6713
4617
|
}
|
|
6714
4618
|
}
|
|
6715
4619
|
//#endregion
|
|
6716
|
-
export {
|
|
4620
|
+
export { inspectSnapshot as a, activityEndSchema as c, invalidateStructuralComparisons as d, diffrExecutable as f, commandSchema as i, activityUpdateSchema as l, uploadSchema as n, initializeReviewStoreSchema as o, diffrMissingError as p, SCRATCHPAD_ID as r, activityBeginSchema as s, openLocalReviewStore as t, pullRequestProvider as u };
|