@dev.fast/whiteboard 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,4491 @@
|
|
|
1
|
+
import { _ as readFileAtCommit, b as resolveRevision, c as diffTrees, f as gitCommonDir, g as parseGitRemote, h as listTrackedFilesAtCommit, i as diff, l as diffWorkingTree, m as listCommitRange, n as createBlobBatchReader, o as diffFileSummariesTrees, p as jjRevisionIsConflicted, r as detectLocalVcs, s as diffFileSummariesWorkingTree, t as LocalVcsToolsMissingError, u as git, v as resolveRepoContext, y as resolveRepoContextSync } from "./src-CmdiBL20.js";
|
|
2
|
+
import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
+
import { _ as SCRATCHPAD_REVIEW_ID, b as structuralChangeCounts, g as ReviewSelectedDiffSchema, m as ReviewApiSelectionSourceSchema, y as decodeStructuralDiffEvent } from "./src-DnwdaQ2r.js";
|
|
4
|
+
import { $ as unionIntervals, A as applyLensEdit, C as resourceReferences, D as summarizeEdit, E as sourceReferences, F as checkSourcePath, I as fileLineRangeSchema, K as sourceAnchors, L as requireVisibleSource, N as lensSelections, O as checkReferences, P as SourceRangeError, Q as subtractIntervals, R as sliceSourceRange, S as resourceReference, X as intersectIntervals, Y as emptyCoverage, a as defineSoftwareMap, b as pinsSchema, c as traceSchema, d as applyEdit, et as updateCoverage, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, j as lensEditSchema, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, p as documentSchema, q as coverageSchema, r as migrateStoredLenses, t as liftFileLenses, u as anchorPins, v as isUnit, w as reviewTargetSchema } from "./stored-document-migration-DHeMpLMb.js";
|
|
5
|
+
import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
|
|
6
|
+
import { D as processIsAlive, O as withFileLock, P as writePrivateJsonAtomic } from "./s3-config-CAxhO9u_.js";
|
|
7
|
+
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
8
|
+
import { s as safeStorageSegment } from "./review-home-paths-6zZH-1c9.js";
|
|
9
|
+
import { t as findReviewPackageRoot } from "./package-paths-B6-zxvIO.js";
|
|
10
|
+
import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, watch } from "node:fs";
|
|
15
|
+
import * as fs$1 from "node:fs/promises";
|
|
16
|
+
import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
17
|
+
import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import { execFile, spawn } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { createInterface } from "node:readline";
|
|
23
|
+
//#region ../review-protocol/src/source-alignment.ts
|
|
24
|
+
/** The 0-based, half-open line span a region touches. An end at column 0 does not touch its end line. */
|
|
25
|
+
function alignmentRegionLines(region) {
|
|
26
|
+
return {
|
|
27
|
+
start: region.start.line,
|
|
28
|
+
end: region.end.column === 0 ? region.end.line : region.end.line + 1
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function alignmentLeaves(regions) {
|
|
32
|
+
const leaves = [];
|
|
33
|
+
const walk = (region) => {
|
|
34
|
+
if (region.kind === "leaf") leaves.push(region);
|
|
35
|
+
else for (const child of region.children) walk(child);
|
|
36
|
+
};
|
|
37
|
+
for (const region of regions ?? []) walk(region);
|
|
38
|
+
return leaves;
|
|
39
|
+
}
|
|
40
|
+
/** Monaco keeps a final empty line after a newline; the wire need not mention it. */
|
|
41
|
+
function monacoLineCount(source) {
|
|
42
|
+
return source ? source.text.split("\n").length : 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Zips the two sides' leaves by `alignment_id` into the full row table. Paired leaves
|
|
46
|
+
* yield rows line for line, in the same order on both sides; an unpaired leaf
|
|
47
|
+
* yields one-sided rows. Rows past the last leaf pair the trailing empty
|
|
48
|
+
* lines Monaco keeps.
|
|
49
|
+
*/
|
|
50
|
+
function structuralRows(diff) {
|
|
51
|
+
const lhsLeaves = alignmentLeaves(diff.lhs?.regions);
|
|
52
|
+
const rhsLeaves = alignmentLeaves(diff.rhs?.regions);
|
|
53
|
+
const leftCount = monacoLineCount(diff.lhs);
|
|
54
|
+
const rightCount = monacoLineCount(diff.rhs);
|
|
55
|
+
const rows = [];
|
|
56
|
+
let left = 0, right = 0;
|
|
57
|
+
const push = (l, r) => {
|
|
58
|
+
rows.push([l, r]);
|
|
59
|
+
if (l !== null) left = l + 1;
|
|
60
|
+
if (r !== null) right = r + 1;
|
|
61
|
+
};
|
|
62
|
+
const oneSided = (leaf, side) => {
|
|
63
|
+
const lines = alignmentRegionLines(leaf);
|
|
64
|
+
for (let line = lines.start; line < lines.end; line++) push(side === 0 ? line : null, side === 1 ? line : null);
|
|
65
|
+
};
|
|
66
|
+
const rhsIndex = new Map(rhsLeaves.map((leaf, index) => [leaf.alignment_id, index]));
|
|
67
|
+
let cursor = 0;
|
|
68
|
+
for (const leaf of lhsLeaves) {
|
|
69
|
+
const partner = rhsIndex.get(leaf.alignment_id);
|
|
70
|
+
if (partner === void 0) {
|
|
71
|
+
oneSided(leaf, 0);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
while (cursor < partner) oneSided(rhsLeaves[cursor++], 1);
|
|
75
|
+
const a = alignmentRegionLines(leaf);
|
|
76
|
+
const b = alignmentRegionLines(rhsLeaves[partner]);
|
|
77
|
+
for (let offset = 0; offset < a.end - a.start; offset++) push(a.start + offset, b.start + offset);
|
|
78
|
+
cursor = partner + 1;
|
|
79
|
+
}
|
|
80
|
+
while (cursor < rhsLeaves.length) oneSided(rhsLeaves[cursor++], 1);
|
|
81
|
+
while (left < leftCount || right < rightCount) rows.push([left < leftCount ? left++ : null, right < rightCount ? right++ : null]);
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/review-checkout-paths.ts
|
|
86
|
+
/**
|
|
87
|
+
* Root for Review-owned checkouts. A Review UUID identifies one subtree.
|
|
88
|
+
*/
|
|
89
|
+
function reviewManagedCheckoutsDir(gitCommonDir) {
|
|
90
|
+
return path.join(gitCommonDir, "dev-fast", "reviews");
|
|
91
|
+
}
|
|
92
|
+
/** Return the checkout subtree owned by one Review UUID. */
|
|
93
|
+
function reviewManagedCheckoutRoot(gitCommonDir, reviewUuid) {
|
|
94
|
+
return path.join(reviewManagedCheckoutsDir(gitCommonDir), safeStorageSegment(reviewUuid));
|
|
95
|
+
}
|
|
96
|
+
/** A detached checkout owned by one Review revision and source role. */
|
|
97
|
+
function reviewManagedCheckoutDir(gitCommonDir, reviewUuid, role, commit) {
|
|
98
|
+
return path.join(reviewManagedCheckoutRoot(gitCommonDir, reviewUuid), role, safeStorageSegment(commit));
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/review-prepare.ts
|
|
102
|
+
const PREPARE_LOCK_RETRY_MS = 250;
|
|
103
|
+
const PREPARE_LOCK_STALE_MS = 18e5;
|
|
104
|
+
const PREPARE_LOCK_TIMEOUT_MS = 1e3;
|
|
105
|
+
const PREPARE_LOCK_UNOWNED_GRACE_MS = 1e3;
|
|
106
|
+
const PREPARE_OUTPUT_TAIL_LINES = 20;
|
|
107
|
+
/** The marker file that records a completed prepare, beside the worktree. */
|
|
108
|
+
function reviewPrepareMarkerPath(checkoutPath) {
|
|
109
|
+
return `${checkoutPath}.prepared`;
|
|
110
|
+
}
|
|
111
|
+
/** The failure log written beside the worktree when a prepare command fails. */
|
|
112
|
+
function reviewPrepareLogPath(checkoutPath) {
|
|
113
|
+
return `${checkoutPath}.prepare-log`;
|
|
114
|
+
}
|
|
115
|
+
function reviewPrepareCommandsHash(commands) {
|
|
116
|
+
return crypto.createHash("sha256").update(JSON.stringify(commands)).digest("hex").slice(0, 16);
|
|
117
|
+
}
|
|
118
|
+
async function removeReviewPrepareArtifacts(checkoutPath) {
|
|
119
|
+
await rm(reviewPrepareMarkerPath(checkoutPath), { force: true });
|
|
120
|
+
await rm(reviewPrepareLogPath(checkoutPath), { force: true });
|
|
121
|
+
}
|
|
122
|
+
async function prepareReviewPinnedCheckout(input) {
|
|
123
|
+
if (input.commands.length === 0) return { prepared: false };
|
|
124
|
+
const markerPath = reviewPrepareMarkerPath(input.checkoutPath);
|
|
125
|
+
const expectedHash = reviewPrepareCommandsHash(input.commands);
|
|
126
|
+
if (await markerMatches(markerPath, expectedHash)) return { prepared: true };
|
|
127
|
+
const outcome = await withFileLock(`${input.checkoutPath}.prepare-lock`, {
|
|
128
|
+
retryMs: PREPARE_LOCK_RETRY_MS,
|
|
129
|
+
staleMs: PREPARE_LOCK_STALE_MS,
|
|
130
|
+
heartbeatMs: 5e3,
|
|
131
|
+
timeoutMs: PREPARE_LOCK_TIMEOUT_MS,
|
|
132
|
+
unownedGraceMs: PREPARE_LOCK_UNOWNED_GRACE_MS
|
|
133
|
+
}, async () => {
|
|
134
|
+
if (await markerMatches(markerPath, expectedHash)) return { prepared: true };
|
|
135
|
+
await rm(markerPath, { force: true });
|
|
136
|
+
const runCommand = input.runCommand ?? (async (command, cwd) => {
|
|
137
|
+
return {
|
|
138
|
+
exitCode: 0,
|
|
139
|
+
output: await runPrepareCommand(command, cwd, input.progress ?? (() => {}), input.signal ?? new AbortController().signal, input.timeoutMs)
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
for (const command of input.commands) {
|
|
143
|
+
const result = await runCommand(command, input.checkoutPath).catch((cause) => ({
|
|
144
|
+
exitCode: null,
|
|
145
|
+
output: cause instanceof Error ? cause.message : String(cause)
|
|
146
|
+
}));
|
|
147
|
+
if (result.exitCode !== 0) {
|
|
148
|
+
const exit = result.exitCode === null ? "no exit code" : `exit ${result.exitCode}`;
|
|
149
|
+
const logNote = await writePrepareFailureLog(input.checkoutPath, result.output);
|
|
150
|
+
input.warning?.(formatPrepareFailure({
|
|
151
|
+
commit: input.commit,
|
|
152
|
+
command,
|
|
153
|
+
exit,
|
|
154
|
+
output: result.output,
|
|
155
|
+
logNote
|
|
156
|
+
}));
|
|
157
|
+
return { prepared: false };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await rm(reviewPrepareLogPath(input.checkoutPath), { force: true });
|
|
161
|
+
await writeFile(markerPath, JSON.stringify({
|
|
162
|
+
commandsHash: expectedHash,
|
|
163
|
+
preparedAt: Date.now()
|
|
164
|
+
}), "utf8");
|
|
165
|
+
return { prepared: true };
|
|
166
|
+
});
|
|
167
|
+
if (!outcome.acquired) {
|
|
168
|
+
input.warning?.(`devfast.prepare skipped for commit ${input.commit.slice(0, 12)}: another process holds the prepare lock. Language features use the unprepared pinned checkout.`);
|
|
169
|
+
return { prepared: false };
|
|
170
|
+
}
|
|
171
|
+
return outcome.result;
|
|
172
|
+
}
|
|
173
|
+
async function writePrepareFailureLog(checkoutPath, output) {
|
|
174
|
+
const trimmed = output?.trimEnd();
|
|
175
|
+
if (!trimmed) return "";
|
|
176
|
+
const logPath = reviewPrepareLogPath(checkoutPath);
|
|
177
|
+
return writeFile(logPath, `${trimmed}\n`, "utf8").then(() => ` Full output: ${logPath}.`).catch(() => "");
|
|
178
|
+
}
|
|
179
|
+
function formatPrepareFailure(input) {
|
|
180
|
+
const base = `devfast.prepare failed for commit ${input.commit.slice(0, 12)} (${input.exit}): ${input.command}. Language features use the unprepared pinned checkout.`;
|
|
181
|
+
const output = input.output?.trimEnd();
|
|
182
|
+
if (!output) return base;
|
|
183
|
+
const lines = output.split("\n");
|
|
184
|
+
const tail = lines.slice(-20).join("\n");
|
|
185
|
+
return `${base} Last output:\n${lines.length > PREPARE_OUTPUT_TAIL_LINES ? "…\n" : ""}${tail}${input.logNote}`;
|
|
186
|
+
}
|
|
187
|
+
async function markerMatches(markerPath, expectedHash) {
|
|
188
|
+
return readFile(markerPath, "utf8").then((contents) => jsonObject(parseJsonText(contents))?.commandsHash === expectedHash).catch(() => false);
|
|
189
|
+
}
|
|
190
|
+
function runPrepareCommand(command, cwd, progress, signal, timeoutMs = 9e5) {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
let output = "";
|
|
194
|
+
const child = spawn(command, {
|
|
195
|
+
cwd,
|
|
196
|
+
shell: true,
|
|
197
|
+
detached: process.platform !== "win32",
|
|
198
|
+
stdio: [
|
|
199
|
+
"ignore",
|
|
200
|
+
"pipe",
|
|
201
|
+
"pipe"
|
|
202
|
+
]
|
|
203
|
+
});
|
|
204
|
+
let interrupted;
|
|
205
|
+
let force;
|
|
206
|
+
const kill = (signal) => {
|
|
207
|
+
try {
|
|
208
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
209
|
+
else child.kill(signal);
|
|
210
|
+
} catch {}
|
|
211
|
+
};
|
|
212
|
+
const stop = (reason) => {
|
|
213
|
+
interrupted = reason;
|
|
214
|
+
kill("SIGTERM");
|
|
215
|
+
force ??= setTimeout(() => kill("SIGKILL"), 2e3);
|
|
216
|
+
force.unref();
|
|
217
|
+
};
|
|
218
|
+
const abort = () => stop(/* @__PURE__ */ new Error("Project command interrupted by shutdown."));
|
|
219
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
220
|
+
const timeout = setTimeout(() => stop(/* @__PURE__ */ new Error("Project preparation timed out.")), timeoutMs);
|
|
221
|
+
timeout.unref();
|
|
222
|
+
const append = (chunk) => {
|
|
223
|
+
output = (output + chunk.toString()).slice(-64e3);
|
|
224
|
+
progress(output);
|
|
225
|
+
};
|
|
226
|
+
child.stdout.on("data", append);
|
|
227
|
+
child.stderr.on("data", append);
|
|
228
|
+
child.on("error", reject);
|
|
229
|
+
child.on("close", (code) => {
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
clearTimeout(force);
|
|
232
|
+
signal.removeEventListener("abort", abort);
|
|
233
|
+
if (interrupted) reject(/* @__PURE__ */ new Error(`${interrupted.message}\n${output}`));
|
|
234
|
+
else if (code === 0) resolve(output);
|
|
235
|
+
else reject(/* @__PURE__ */ new Error(`Command exited with ${code}.\n${output}`));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/review-head-checkout.ts
|
|
241
|
+
async function ensureReviewPinnedCheckout(input) {
|
|
242
|
+
const target = await resolveReviewPinnedCheckout({
|
|
243
|
+
...input,
|
|
244
|
+
role: input.role ?? "head"
|
|
245
|
+
});
|
|
246
|
+
if (!target) return null;
|
|
247
|
+
if (!(await withFileLock(`${target.checkoutPath}.prepare-lock`, {
|
|
248
|
+
retryMs: 20,
|
|
249
|
+
timeoutMs: 3e4,
|
|
250
|
+
staleMs: 12e4,
|
|
251
|
+
heartbeatMs: 5e3,
|
|
252
|
+
unownedGraceMs: 1e3
|
|
253
|
+
}, () => materializeReviewPinnedCheckout({
|
|
254
|
+
rootPath: input.rootPath,
|
|
255
|
+
...target
|
|
256
|
+
}))).acquired) throw new Error(`Pinned checkout ${target.commit} is busy; retry the operation.`);
|
|
257
|
+
return target.checkoutPath;
|
|
258
|
+
}
|
|
259
|
+
async function resolveReviewPinnedCheckout(input) {
|
|
260
|
+
const commit = await resolveReviewPinnedCommit(input.rootPath, input.ref);
|
|
261
|
+
if (!commit) return null;
|
|
262
|
+
const commonDir = await gitCommonDir(input.rootPath);
|
|
263
|
+
if (!commonDir) return null;
|
|
264
|
+
return {
|
|
265
|
+
checkoutPath: reviewManagedCheckoutDir(commonDir, input.reviewUuid, input.role ?? "head", commit),
|
|
266
|
+
commit
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function materializeReviewPinnedCheckout(input) {
|
|
270
|
+
const { checkoutPath } = input;
|
|
271
|
+
if (await isWorktreeAt(input.rootPath, checkoutPath, input.commit)) return;
|
|
272
|
+
if (await jjRevisionIsConflicted(input.rootPath, input.commit)) throw new Error(`Whiteboard cannot pin conflicted revision ${input.commit.slice(0, 12)}: the jj change has unresolved conflicts. Resolve them (jj resolve), then re-pin the review.`);
|
|
273
|
+
await git(input.rootPath, [
|
|
274
|
+
"worktree",
|
|
275
|
+
"remove",
|
|
276
|
+
"--force",
|
|
277
|
+
checkoutPath
|
|
278
|
+
], { allowFailure: true });
|
|
279
|
+
await git(input.rootPath, ["worktree", "prune"], { allowFailure: true });
|
|
280
|
+
rmSync(checkoutPath, {
|
|
281
|
+
recursive: true,
|
|
282
|
+
force: true
|
|
283
|
+
});
|
|
284
|
+
await removeReviewPrepareArtifacts(checkoutPath);
|
|
285
|
+
await mkdir(path.dirname(checkoutPath), { recursive: true });
|
|
286
|
+
await git(input.rootPath, [
|
|
287
|
+
"worktree",
|
|
288
|
+
"add",
|
|
289
|
+
"--detach",
|
|
290
|
+
checkoutPath,
|
|
291
|
+
input.commit
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Remove every checkout one Review owns in a repository. Registrations go
|
|
296
|
+
* first, so an interrupted removal cannot leave a gutted tree that git still
|
|
297
|
+
* lists and a later ensure would reuse; an unregistered leftover is replaced
|
|
298
|
+
* by the next ensure. Each checkout is removed on its own, so one that cannot
|
|
299
|
+
* be deleted leaves the others removed; the error names every failure.
|
|
300
|
+
*/
|
|
301
|
+
async function removeReviewManagedCheckouts(commonDir, reviewUuid) {
|
|
302
|
+
const root = reviewManagedCheckoutRoot(commonDir, reviewUuid);
|
|
303
|
+
if (!isInsideDirectory(root, reviewManagedCheckoutsDir(commonDir))) throw new Error(`Refusing to remove non-managed checkouts at ${root}.`);
|
|
304
|
+
const roots = [root, path.join(realPath(path.dirname(root)), path.basename(root))];
|
|
305
|
+
const errors = [];
|
|
306
|
+
const kept = /* @__PURE__ */ new Set();
|
|
307
|
+
const attempt = (work) => work.then(() => true, (error) => {
|
|
308
|
+
errors.push(errorMessage(error));
|
|
309
|
+
return false;
|
|
310
|
+
});
|
|
311
|
+
for (const worktree of await listRegisteredWorktrees(commonDir)) if (roots.some((dir) => isInsideDirectory(worktree.worktreePath, dir)) && !await attempt(git(commonDir, [
|
|
312
|
+
"worktree",
|
|
313
|
+
"remove",
|
|
314
|
+
"--force",
|
|
315
|
+
worktree.worktreePath
|
|
316
|
+
]))) kept.add(realPath(worktree.worktreePath));
|
|
317
|
+
const entries = async (dir) => (await lstat(dir).catch(() => null))?.isDirectory() ? await readdir(dir, { withFileTypes: true }) : [];
|
|
318
|
+
for (const entry of await entries(root)) {
|
|
319
|
+
const dir = path.join(root, entry.name);
|
|
320
|
+
const checkouts = entry.isDirectory() && (entry.name === "base" || entry.name === "head") ? (await entries(dir)).map((child) => path.join(dir, child.name)) : [dir];
|
|
321
|
+
for (const checkout of checkouts) if (!kept.has(path.join(realPath(path.dirname(checkout)), path.basename(checkout)))) await attempt(rm(checkout, {
|
|
322
|
+
recursive: true,
|
|
323
|
+
force: true
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
if (!errors.length) await rm(root, {
|
|
327
|
+
recursive: true,
|
|
328
|
+
force: true
|
|
329
|
+
});
|
|
330
|
+
await git(commonDir, ["worktree", "prune"], { allowFailure: true });
|
|
331
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
332
|
+
}
|
|
333
|
+
async function resolveReviewPinnedCommit(rootPath, ref) {
|
|
334
|
+
const resolved = await resolveRevision(rootPath, ref).catch(() => null);
|
|
335
|
+
if (resolved) return resolved.commit;
|
|
336
|
+
const fromGitDir = await git(rootPath, [
|
|
337
|
+
"rev-parse",
|
|
338
|
+
"--verify",
|
|
339
|
+
`${ref}^{commit}`
|
|
340
|
+
], { allowFailure: true }).catch(() => null);
|
|
341
|
+
if (!fromGitDir?.ok) return null;
|
|
342
|
+
return fromGitDir.stdout.trim() || null;
|
|
343
|
+
}
|
|
344
|
+
async function isWorktreeAt(rootPath, checkoutPath, commit) {
|
|
345
|
+
if (!existsSync(path.join(checkoutPath, ".git"))) return false;
|
|
346
|
+
const list = await git(rootPath, [
|
|
347
|
+
"worktree",
|
|
348
|
+
"list",
|
|
349
|
+
"--porcelain"
|
|
350
|
+
], { allowFailure: true }).catch(() => null);
|
|
351
|
+
if (!list?.ok) return false;
|
|
352
|
+
return parseWorktreeList(list.stdout).some((entry) => path.resolve(entry.worktreePath) === path.resolve(checkoutPath) && entry.headCommit === commit);
|
|
353
|
+
}
|
|
354
|
+
function parseWorktreeList(output) {
|
|
355
|
+
const entries = [];
|
|
356
|
+
for (const block of output.split(/\n\n+/)) {
|
|
357
|
+
let worktreePath = null;
|
|
358
|
+
let headCommit = null;
|
|
359
|
+
for (const line of block.split("\n")) if (line.startsWith("worktree ")) worktreePath = line.slice(9);
|
|
360
|
+
else if (line.startsWith("HEAD ")) headCommit = line.slice(5);
|
|
361
|
+
if (worktreePath) entries.push({
|
|
362
|
+
worktreePath,
|
|
363
|
+
headCommit
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return entries;
|
|
367
|
+
}
|
|
368
|
+
async function listRegisteredWorktrees(rootPath) {
|
|
369
|
+
const listed = await git(rootPath, [
|
|
370
|
+
"worktree",
|
|
371
|
+
"list",
|
|
372
|
+
"--porcelain"
|
|
373
|
+
], { allowFailure: true }).catch(() => null);
|
|
374
|
+
return listed?.ok ? parseWorktreeList(listed.stdout) : [];
|
|
375
|
+
}
|
|
376
|
+
function realPath(filePath) {
|
|
377
|
+
return existsSync(filePath) ? realpathSync(filePath) : path.resolve(filePath);
|
|
378
|
+
}
|
|
379
|
+
function isInsideDirectory(filePath, directory) {
|
|
380
|
+
const relative = path.relative(directory, filePath);
|
|
381
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/server/diffr-languages.ts
|
|
385
|
+
const exec$1 = promisify(execFile);
|
|
386
|
+
const versionSchema = z.object({ version: z.string() });
|
|
387
|
+
const stampSchema = z.object({
|
|
388
|
+
version: z.string(),
|
|
389
|
+
full: z.boolean()
|
|
390
|
+
});
|
|
391
|
+
const pending = /* @__PURE__ */ new Map();
|
|
392
|
+
function configuration(root) {
|
|
393
|
+
const installer = path.join(root, "bin/diffr-package/bin/fetch.mjs");
|
|
394
|
+
const { version } = versionSchema.parse(JSON.parse(readFileSync(path.join(root, "bin/diffr-package/package.json"), "utf8")));
|
|
395
|
+
const directory = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "diffr", version, `${process.platform}-${process.arch}`, "full");
|
|
396
|
+
return {
|
|
397
|
+
installer,
|
|
398
|
+
version,
|
|
399
|
+
directory,
|
|
400
|
+
binary: path.join(directory, process.platform === "win32" ? "diffr.exe" : "diffr")
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function installedFullDiffr(root) {
|
|
404
|
+
try {
|
|
405
|
+
const { binary, directory, version } = configuration(root);
|
|
406
|
+
const stat = lstatSync(binary);
|
|
407
|
+
const stamp = stampSchema.parse(JSON.parse(readFileSync(path.join(directory, "diffr.stamp.json"), "utf8")));
|
|
408
|
+
if (stat.isFile() && (process.platform === "win32" || stat.mode & 73) && stamp.full && stamp.version === version) return binary;
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
function installFullDiffr(root = findReviewPackageRoot(import.meta.url)) {
|
|
412
|
+
if (process.env.REVIEW_DIFFR_BINARY) return Promise.reject(/* @__PURE__ */ new Error("An explicitly selected diffr executable is in use. Install its full edition separately."));
|
|
413
|
+
const { installer, directory } = configuration(root);
|
|
414
|
+
const current = pending.get(directory);
|
|
415
|
+
if (current) return current;
|
|
416
|
+
const task = exec$1(process.execPath, [
|
|
417
|
+
installer,
|
|
418
|
+
"--full",
|
|
419
|
+
"--required",
|
|
420
|
+
"--into",
|
|
421
|
+
directory
|
|
422
|
+
], {
|
|
423
|
+
env: {
|
|
424
|
+
...process.env,
|
|
425
|
+
ELECTRON_RUN_AS_NODE: "1"
|
|
426
|
+
},
|
|
427
|
+
timeout: 18e4
|
|
428
|
+
}).then(() => {}).finally(() => pending.delete(directory));
|
|
429
|
+
pending.set(directory, task);
|
|
430
|
+
return task;
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/server/structural-diff.ts
|
|
434
|
+
function diffrExecutable(packageRoot = findReviewPackageRoot(import.meta.url)) {
|
|
435
|
+
if (process.env.REVIEW_DIFFR_BINARY) return process.env.REVIEW_DIFFR_BINARY;
|
|
436
|
+
const full = installedFullDiffr(packageRoot);
|
|
437
|
+
if (full) return full;
|
|
438
|
+
const bundled = path.join(packageRoot, "bin", process.platform === "win32" ? "diffr.exe" : "diffr");
|
|
439
|
+
return existsSync(bundled) ? bundled : "diffr";
|
|
440
|
+
}
|
|
441
|
+
function diffrMissingError() {
|
|
442
|
+
return /* @__PURE__ */ new Error(`Cannot find diffr at ${diffrExecutable()}. Whiteboard Desktop bundles it at bin/diffr under its runtime; in a checkout, run \`pnpm --filter @dev.fast/whiteboard ensure:diffr\` or install diffr on PATH, or set REVIEW_DIFFR_BINARY to its executable.`);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Stream validated diffr records. File errors and aborted completion are data;
|
|
446
|
+
* launch failures, malformed/truncated streams and unexpected exits throw.
|
|
447
|
+
* Closing the iterator (including a consumer's break) terminates the child.
|
|
448
|
+
*/
|
|
449
|
+
async function* structuralDiff(input) {
|
|
450
|
+
input.signal.throwIfAborted();
|
|
451
|
+
const comparison = input.comparison;
|
|
452
|
+
const args = [
|
|
453
|
+
"--repo",
|
|
454
|
+
input.repositoryPath,
|
|
455
|
+
"--format",
|
|
456
|
+
"ndjson",
|
|
457
|
+
"--stream-annotations"
|
|
458
|
+
];
|
|
459
|
+
switch (comparison.kind) {
|
|
460
|
+
case "worktree":
|
|
461
|
+
args.push(comparison.base);
|
|
462
|
+
break;
|
|
463
|
+
case "trees":
|
|
464
|
+
args.push(comparison.base, comparison.head);
|
|
465
|
+
break;
|
|
466
|
+
case "merge-base": args.push(`${comparison.base}...${comparison.head}`);
|
|
467
|
+
}
|
|
468
|
+
args.push("--", ...input.paths ?? []);
|
|
469
|
+
const idleAbort = new AbortController();
|
|
470
|
+
const idle = setTimeout(() => idleAbort.abort(), 12e4);
|
|
471
|
+
const signal = AbortSignal.any([idleAbort.signal, input.signal]);
|
|
472
|
+
console.info(`[Review] structural diff: ${diffrExecutable()} ${args.join(" ")}`);
|
|
473
|
+
const child = spawn(diffrExecutable(), args, {
|
|
474
|
+
cwd: input.repositoryPath,
|
|
475
|
+
stdio: [
|
|
476
|
+
"ignore",
|
|
477
|
+
"pipe",
|
|
478
|
+
"pipe"
|
|
479
|
+
],
|
|
480
|
+
signal
|
|
481
|
+
});
|
|
482
|
+
let stderr = "";
|
|
483
|
+
child.stderr.setEncoding("utf8");
|
|
484
|
+
child.stderr.on("data", (chunk) => {
|
|
485
|
+
stderr = (stderr + chunk).slice(-16384);
|
|
486
|
+
});
|
|
487
|
+
const exited = new Promise((resolve, reject) => {
|
|
488
|
+
child.once("error", (error) => {
|
|
489
|
+
reject(error.code === "ENOENT" ? diffrMissingError() : error);
|
|
490
|
+
});
|
|
491
|
+
child.once("close", (code) => resolve(code));
|
|
492
|
+
});
|
|
493
|
+
const exitError = (code) => /* @__PURE__ */ new Error(`diffr exited with ${code}: ${stderr}`);
|
|
494
|
+
exited.catch(() => {});
|
|
495
|
+
let started = false;
|
|
496
|
+
let completed = false;
|
|
497
|
+
let aborted;
|
|
498
|
+
let failed = 0;
|
|
499
|
+
let annotationFailed = false;
|
|
500
|
+
const lines = createInterface({
|
|
501
|
+
input: child.stdout,
|
|
502
|
+
crlfDelay: Infinity
|
|
503
|
+
});
|
|
504
|
+
try {
|
|
505
|
+
for await (const line of lines) {
|
|
506
|
+
idle.refresh();
|
|
507
|
+
if (Buffer.byteLength(line) > 67108864) throw new Error("Structural diff record exceeded 64 MiB.");
|
|
508
|
+
if (!line.trim()) continue;
|
|
509
|
+
const event = decodeStructuralDiffEvent(line);
|
|
510
|
+
if (completed) throw new Error("diffr emitted data after completion.");
|
|
511
|
+
if (!started) {
|
|
512
|
+
if (event.type !== "start" || event.version !== 4) throw new Error("Unsupported diffr stream protocol.");
|
|
513
|
+
started = true;
|
|
514
|
+
} else if (event.type === "complete") {
|
|
515
|
+
completed = true;
|
|
516
|
+
failed = event.failed;
|
|
517
|
+
aborted = event.aborted;
|
|
518
|
+
} else if (event.type === "annotations") annotationFailed ||= event.error !== void 0;
|
|
519
|
+
else if (event.type !== "file") throw new Error(`Unexpected diffr event: ${event.type}`);
|
|
520
|
+
yield event;
|
|
521
|
+
}
|
|
522
|
+
if (!completed) {
|
|
523
|
+
const code = await exited;
|
|
524
|
+
if (code !== 0) throw exitError(code);
|
|
525
|
+
throw new Error("diffr stream ended before completion.");
|
|
526
|
+
}
|
|
527
|
+
const code = await exited;
|
|
528
|
+
if (code !== 0 && !(code === 2 && (failed > 0 || annotationFailed || aborted !== void 0))) throw exitError(code);
|
|
529
|
+
} finally {
|
|
530
|
+
clearTimeout(idle);
|
|
531
|
+
lines.close();
|
|
532
|
+
if (child.exitCode === null) child.kill();
|
|
533
|
+
await exited.catch(() => {});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/server/structural-comparisons.ts
|
|
538
|
+
/** Replays a pinned comparison to concurrent rendering and coverage consumers. */
|
|
539
|
+
var StructuralComparisons = class {
|
|
540
|
+
entries = /* @__PURE__ */ new Map();
|
|
541
|
+
async *stream(input) {
|
|
542
|
+
input.signal.throwIfAborted();
|
|
543
|
+
const key = JSON.stringify([
|
|
544
|
+
configGeneration,
|
|
545
|
+
input.repositoryPath,
|
|
546
|
+
input.comparison,
|
|
547
|
+
input.paths
|
|
548
|
+
]);
|
|
549
|
+
let entry = this.entries.get(key);
|
|
550
|
+
if (entry) this.entries.delete(key);
|
|
551
|
+
else entry = new Comparison(input);
|
|
552
|
+
this.entries.set(key, entry);
|
|
553
|
+
entry.readers++;
|
|
554
|
+
try {
|
|
555
|
+
yield* entry.read(input.signal);
|
|
556
|
+
} finally {
|
|
557
|
+
entry.readers--;
|
|
558
|
+
if (!entry.done && !entry.initialReady && !entry.readers || entry.error || entry.failed) {
|
|
559
|
+
entry.abort.abort();
|
|
560
|
+
this.entries.delete(key);
|
|
561
|
+
}
|
|
562
|
+
const idle = [...this.entries].filter(([, value]) => (value.done || value.initialReady) && !value.readers);
|
|
563
|
+
for (const [oldKey, old] of idle.slice(0, -10)) {
|
|
564
|
+
old.abort.abort();
|
|
565
|
+
this.entries.delete(oldKey);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
close() {
|
|
570
|
+
for (const entry of this.entries.values()) entry.abort.abort();
|
|
571
|
+
this.entries.clear();
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
let configGeneration = 0;
|
|
575
|
+
function invalidateStructuralComparisons() {
|
|
576
|
+
configGeneration++;
|
|
577
|
+
}
|
|
578
|
+
var Comparison = class {
|
|
579
|
+
abort = new AbortController();
|
|
580
|
+
events = [];
|
|
581
|
+
readers = 0;
|
|
582
|
+
done = false;
|
|
583
|
+
error;
|
|
584
|
+
failed = false;
|
|
585
|
+
initialReady = false;
|
|
586
|
+
remaining;
|
|
587
|
+
listeners = /* @__PURE__ */ new Set();
|
|
588
|
+
constructor(input) {
|
|
589
|
+
this.run(input);
|
|
590
|
+
}
|
|
591
|
+
async run(input) {
|
|
592
|
+
try {
|
|
593
|
+
for await (const event of structuralDiff({
|
|
594
|
+
...input,
|
|
595
|
+
signal: this.abort.signal
|
|
596
|
+
})) {
|
|
597
|
+
if (event.type === "start") {
|
|
598
|
+
this.remaining = new Set(event.files.map((entry) => (entry.file.rhs ?? entry.file.lhs).path));
|
|
599
|
+
this.initialReady = this.remaining.size === 0;
|
|
600
|
+
} else if (event.type === "file") {
|
|
601
|
+
const path = (event.file.rhs ?? event.file.lhs).path;
|
|
602
|
+
if (!this.remaining?.delete(path)) throw new Error(`Unexpected structural result: ${path}`);
|
|
603
|
+
this.initialReady = this.remaining.size === 0;
|
|
604
|
+
}
|
|
605
|
+
if (event.type === "complete") this.failed = event.failed > 0 || !!event.aborted;
|
|
606
|
+
this.events.push(event);
|
|
607
|
+
for (const wake of this.listeners) wake();
|
|
608
|
+
}
|
|
609
|
+
} catch (error) {
|
|
610
|
+
this.error = error;
|
|
611
|
+
} finally {
|
|
612
|
+
this.done = true;
|
|
613
|
+
for (const wake of this.listeners) wake();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async *read(signal) {
|
|
617
|
+
let index = 0;
|
|
618
|
+
while (true) {
|
|
619
|
+
signal.throwIfAborted();
|
|
620
|
+
if (index < this.events.length) {
|
|
621
|
+
yield this.events[index++];
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (this.done) {
|
|
625
|
+
if (this.error) throw this.error;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
await new Promise((resolve) => {
|
|
629
|
+
const wake = () => {
|
|
630
|
+
this.listeners.delete(wake);
|
|
631
|
+
signal.removeEventListener("abort", wake);
|
|
632
|
+
resolve();
|
|
633
|
+
};
|
|
634
|
+
this.listeners.add(wake);
|
|
635
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/unified-diff.ts
|
|
642
|
+
const HUNK_HEADER = /^@@ -(?<oldStart>\d+)(?:,(?<oldLines>\d+))? \+(?<newStart>\d+)(?:,(?<newLines>\d+))? @@/;
|
|
643
|
+
function parseUnifiedPatch(file, patch) {
|
|
644
|
+
if (!patch) return [];
|
|
645
|
+
const hunks = [];
|
|
646
|
+
let current = null;
|
|
647
|
+
let oldLine = 0;
|
|
648
|
+
let newLine = 0;
|
|
649
|
+
for (const row of patch.split(/\r?\n/)) {
|
|
650
|
+
const header = HUNK_HEADER.exec(row);
|
|
651
|
+
if (header?.groups) {
|
|
652
|
+
current = {
|
|
653
|
+
file,
|
|
654
|
+
oldStart: Number(header.groups.oldStart),
|
|
655
|
+
oldLines: Number(header.groups.oldLines ?? "1"),
|
|
656
|
+
newStart: Number(header.groups.newStart),
|
|
657
|
+
newLines: Number(header.groups.newLines ?? "1"),
|
|
658
|
+
lines: []
|
|
659
|
+
};
|
|
660
|
+
oldLine = current.oldStart;
|
|
661
|
+
newLine = current.newStart;
|
|
662
|
+
hunks.push(current);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!current || row.startsWith("\")) continue;
|
|
666
|
+
const marker = row[0];
|
|
667
|
+
const text = row.slice(1);
|
|
668
|
+
let line = null;
|
|
669
|
+
if (marker === " ") {
|
|
670
|
+
line = {
|
|
671
|
+
kind: "context",
|
|
672
|
+
oldLine,
|
|
673
|
+
newLine,
|
|
674
|
+
text
|
|
675
|
+
};
|
|
676
|
+
oldLine += 1;
|
|
677
|
+
newLine += 1;
|
|
678
|
+
} else if (marker === "+") {
|
|
679
|
+
line = {
|
|
680
|
+
kind: "add",
|
|
681
|
+
oldLine: null,
|
|
682
|
+
newLine,
|
|
683
|
+
text
|
|
684
|
+
};
|
|
685
|
+
newLine += 1;
|
|
686
|
+
} else if (marker === "-") {
|
|
687
|
+
line = {
|
|
688
|
+
kind: "remove",
|
|
689
|
+
oldLine,
|
|
690
|
+
newLine: null,
|
|
691
|
+
text
|
|
692
|
+
};
|
|
693
|
+
oldLine += 1;
|
|
694
|
+
}
|
|
695
|
+
if (line) current.lines.push(line);
|
|
696
|
+
}
|
|
697
|
+
return hunks;
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/software-map-diff-counts.ts
|
|
701
|
+
async function resolveSoftwareMapDiffCounts(input) {
|
|
702
|
+
const baseRef = input.baseRef?.trim();
|
|
703
|
+
if (!baseRef) return {
|
|
704
|
+
countsByElementPath: {},
|
|
705
|
+
unmappedByElementPath: {}
|
|
706
|
+
};
|
|
707
|
+
const headRef = input.headRef?.trim() || void 0;
|
|
708
|
+
const sourceRootPath = input.sourceRootPath;
|
|
709
|
+
const diff$1 = input.patch ?? await diff({
|
|
710
|
+
rootPath: sourceRootPath,
|
|
711
|
+
kind: input.sourceVcsKind,
|
|
712
|
+
baseRef,
|
|
713
|
+
headRef,
|
|
714
|
+
contextLines: 0
|
|
715
|
+
}).catch(() => "");
|
|
716
|
+
if (!diff$1.trim()) return {
|
|
717
|
+
baseRef,
|
|
718
|
+
headRef,
|
|
719
|
+
countsByElementPath: {},
|
|
720
|
+
unmappedByElementPath: {}
|
|
721
|
+
};
|
|
722
|
+
const countsByFile = parseGitUnifiedDiffLineCounts(diff$1, input.side);
|
|
723
|
+
if (countsByFile.size === 0) return {
|
|
724
|
+
baseRef,
|
|
725
|
+
headRef,
|
|
726
|
+
countsByElementPath: {},
|
|
727
|
+
unmappedByElementPath: {}
|
|
728
|
+
};
|
|
729
|
+
const mapped = await mapDiffLineCountsToSoftwareMapElements({
|
|
730
|
+
codeElements: input.codeElements,
|
|
731
|
+
countsByFile
|
|
732
|
+
});
|
|
733
|
+
const unmappedByElementPath = mapDiffLineCountsToCoverageClaims({
|
|
734
|
+
coverageClaims: input.coverageClaims ?? [],
|
|
735
|
+
countsByFile
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
baseRef,
|
|
739
|
+
headRef,
|
|
740
|
+
countsByElementPath: mapped.countsByElementPath,
|
|
741
|
+
unmappedByElementPath
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function parseGitUnifiedDiffLineCounts(diff, side = "head") {
|
|
745
|
+
const countsByFile = /* @__PURE__ */ new Map();
|
|
746
|
+
for (const fileDiff of splitGitDiff(diff)) {
|
|
747
|
+
const file = side === "base" ? fileDiff.oldFile ?? fileDiff.newFile : fileDiff.newFile ?? fileDiff.oldFile;
|
|
748
|
+
if (!file) continue;
|
|
749
|
+
for (const hunk of parseUnifiedPatch(file, fileDiff.patch)) {
|
|
750
|
+
let currentNewLine = Math.max(1, hunk.newStart);
|
|
751
|
+
let currentOldLine = Math.max(1, hunk.oldStart);
|
|
752
|
+
let replacedOldLine = null;
|
|
753
|
+
for (const line of hunk.lines) {
|
|
754
|
+
if (line.kind === "add" && line.newLine !== null) {
|
|
755
|
+
addLineCount(countsByFile, file, side === "base" ? replacedOldLine ?? currentOldLine : line.newLine, "additions", line);
|
|
756
|
+
currentNewLine = line.newLine + 1;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (line.kind === "remove") {
|
|
760
|
+
const oldLine = line.oldLine ?? currentOldLine;
|
|
761
|
+
addLineCount(countsByFile, file, side === "base" ? oldLine : currentNewLine, "deletions", line);
|
|
762
|
+
replacedOldLine ??= oldLine;
|
|
763
|
+
currentOldLine = oldLine + 1;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
replacedOldLine = null;
|
|
767
|
+
if (line.newLine !== null) currentNewLine = line.newLine + 1;
|
|
768
|
+
if (line.oldLine !== null) currentOldLine = line.oldLine + 1;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return countsByFile;
|
|
773
|
+
}
|
|
774
|
+
function mapDiffLineCountsToSoftwareMapElements(input) {
|
|
775
|
+
const countsByElementPath = {};
|
|
776
|
+
for (const element of input.codeElements) {
|
|
777
|
+
const total = {
|
|
778
|
+
additions: 0,
|
|
779
|
+
deletions: 0
|
|
780
|
+
};
|
|
781
|
+
const countedLines = /* @__PURE__ */ new Set();
|
|
782
|
+
for (const range of element.sourceRanges ?? []) {
|
|
783
|
+
const fileCounts = input.countsByFile.get(range.file);
|
|
784
|
+
if (!fileCounts) continue;
|
|
785
|
+
for (const [line, counts] of fileCounts) {
|
|
786
|
+
if (line < range.fromLine || line > range.toLine) continue;
|
|
787
|
+
const lineKey = `${range.file}:${line}`;
|
|
788
|
+
if (countedLines.has(lineKey)) continue;
|
|
789
|
+
countedLines.add(lineKey);
|
|
790
|
+
total.additions += element.changeStatus === "removed" ? 0 : counts.additions;
|
|
791
|
+
total.deletions += element.changeStatus === "added" ? 0 : counts.deletions;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (total.additions > 0 || total.deletions > 0) countsByElementPath[element.path] = total;
|
|
795
|
+
}
|
|
796
|
+
return { countsByElementPath };
|
|
797
|
+
}
|
|
798
|
+
function mapDiffLineCountsToCoverageClaims(input) {
|
|
799
|
+
const result = {};
|
|
800
|
+
const claims = input.coverageClaims.filter((claim) => (claim.files?.length ?? 0) > 0 || (claim.globs?.length ?? 0) > 0).sort(compareCoverageClaims);
|
|
801
|
+
for (const [file, fileCounts] of input.countsByFile) for (const [line, counts] of fileCounts) for (const claim of claims) {
|
|
802
|
+
if (!softwareMapCoverageClaimMatchesLine(claim, file, line)) continue;
|
|
803
|
+
const summary = result[claim.path] ??= {
|
|
804
|
+
additions: 0,
|
|
805
|
+
deletions: 0,
|
|
806
|
+
files: []
|
|
807
|
+
};
|
|
808
|
+
summary.additions += counts.additions;
|
|
809
|
+
summary.deletions += counts.deletions;
|
|
810
|
+
addUnmappedFileLine(summary, file, line, counts.rows);
|
|
811
|
+
}
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
function addLineCount(countsByFile, file, line, kind, diffLine) {
|
|
815
|
+
const fileCounts = countsByFile.get(file) ?? /* @__PURE__ */ new Map();
|
|
816
|
+
const current = fileCounts.get(line) ?? {
|
|
817
|
+
additions: 0,
|
|
818
|
+
deletions: 0,
|
|
819
|
+
rows: []
|
|
820
|
+
};
|
|
821
|
+
current[kind] += 1;
|
|
822
|
+
current.rows.push(toUnmappedDiffLine(diffLine));
|
|
823
|
+
fileCounts.set(line, current);
|
|
824
|
+
countsByFile.set(file, fileCounts);
|
|
825
|
+
}
|
|
826
|
+
function addUnmappedFileLine(summary, file, line, rows) {
|
|
827
|
+
let fileSummary = summary.files.find((candidate) => candidate.file === file);
|
|
828
|
+
if (!fileSummary) {
|
|
829
|
+
fileSummary = {
|
|
830
|
+
file,
|
|
831
|
+
additions: 0,
|
|
832
|
+
deletions: 0,
|
|
833
|
+
hunks: []
|
|
834
|
+
};
|
|
835
|
+
summary.files.push(fileSummary);
|
|
836
|
+
}
|
|
837
|
+
for (const row of rows) {
|
|
838
|
+
if (row.kind === "add") fileSummary.additions += 1;
|
|
839
|
+
if (row.kind === "remove") fileSummary.deletions += 1;
|
|
840
|
+
}
|
|
841
|
+
const lastHunk = fileSummary.hunks.at(-1);
|
|
842
|
+
if (lastHunk && line <= lastHunk.startLine + lastHunk.lines.length + 1) lastHunk.lines.push(...rows);
|
|
843
|
+
else fileSummary.hunks.push({
|
|
844
|
+
startLine: line,
|
|
845
|
+
lines: [...rows]
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
function toUnmappedDiffLine(line) {
|
|
849
|
+
return {
|
|
850
|
+
kind: line.kind === "add" ? "add" : "remove",
|
|
851
|
+
oldLine: line.oldLine,
|
|
852
|
+
newLine: line.newLine,
|
|
853
|
+
text: line.text
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function compareCoverageClaims(left, right) {
|
|
857
|
+
return coverageSpecificity(right) - coverageSpecificity(left);
|
|
858
|
+
}
|
|
859
|
+
function coverageSpecificity(claim) {
|
|
860
|
+
const pathDepth = claim.path.split(".").length * 1e3;
|
|
861
|
+
const exactFiles = (claim.files?.length ?? 0) * 20;
|
|
862
|
+
const globs = claim.globs?.reduce((total, glob) => total + glob.length, 0) ?? 0;
|
|
863
|
+
return pathDepth + exactFiles + globs;
|
|
864
|
+
}
|
|
865
|
+
function softwareMapCoverageClaimMatchesLine(claim, file, line) {
|
|
866
|
+
for (const claimedFile of claim.files ?? []) {
|
|
867
|
+
if (claimedFile.path !== file) continue;
|
|
868
|
+
if (softwareMapLineInRanges(line, claimedFile.ranges)) return true;
|
|
869
|
+
}
|
|
870
|
+
return (claim.globs ?? []).some((glob) => softwareMapGlobMatches(glob, file));
|
|
871
|
+
}
|
|
872
|
+
function softwareMapLineInRanges(line, ranges = []) {
|
|
873
|
+
if (ranges.length === 0) return true;
|
|
874
|
+
return ranges.some((range) => line >= range.fromLine && line <= range.toLine);
|
|
875
|
+
}
|
|
876
|
+
const softwareMapGlobRegexes = /* @__PURE__ */ new Map();
|
|
877
|
+
function softwareMapGlobMatches(pattern, value) {
|
|
878
|
+
let regex = softwareMapGlobRegexes.get(pattern);
|
|
879
|
+
if (!regex) {
|
|
880
|
+
regex = new RegExp(`^${globToRegex(pattern)}$`);
|
|
881
|
+
softwareMapGlobRegexes.set(pattern, regex);
|
|
882
|
+
}
|
|
883
|
+
return regex.test(value);
|
|
884
|
+
}
|
|
885
|
+
function globToRegex(pattern) {
|
|
886
|
+
let result = "";
|
|
887
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
888
|
+
const char = pattern[index];
|
|
889
|
+
const next = pattern[index + 1];
|
|
890
|
+
if (char === "*" && next === "*") {
|
|
891
|
+
result += ".*";
|
|
892
|
+
index += 1;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (char === "*") {
|
|
896
|
+
result += "[^/]*";
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (char === "?") {
|
|
900
|
+
result += "[^/]";
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
result += escapeRegex(char);
|
|
904
|
+
}
|
|
905
|
+
return result;
|
|
906
|
+
}
|
|
907
|
+
function escapeRegex(char) {
|
|
908
|
+
return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
|
|
909
|
+
}
|
|
910
|
+
function splitGitDiff(diff) {
|
|
911
|
+
const result = [];
|
|
912
|
+
let current = null;
|
|
913
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
914
|
+
if (line.startsWith("diff --git ")) {
|
|
915
|
+
if (current) result.push({
|
|
916
|
+
oldFile: current.oldFile,
|
|
917
|
+
newFile: current.newFile,
|
|
918
|
+
patch: current.lines.join("\n")
|
|
919
|
+
});
|
|
920
|
+
current = {
|
|
921
|
+
oldFile: null,
|
|
922
|
+
newFile: null,
|
|
923
|
+
lines: []
|
|
924
|
+
};
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (!current) continue;
|
|
928
|
+
const oldFile = parseGitFileLine(line, "--- ");
|
|
929
|
+
const newFile = parseGitFileLine(line, "+++ ");
|
|
930
|
+
if (oldFile !== void 0) {
|
|
931
|
+
current.oldFile = oldFile;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (newFile !== void 0) {
|
|
935
|
+
current.newFile = newFile;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
current.lines.push(line);
|
|
939
|
+
}
|
|
940
|
+
if (current) result.push({
|
|
941
|
+
oldFile: current.oldFile,
|
|
942
|
+
newFile: current.newFile,
|
|
943
|
+
patch: current.lines.join("\n")
|
|
944
|
+
});
|
|
945
|
+
return result;
|
|
946
|
+
}
|
|
947
|
+
function parseGitFileLine(line, prefix) {
|
|
948
|
+
if (!line.startsWith(prefix)) return void 0;
|
|
949
|
+
const raw = unquoteGitPath(line.slice(prefix.length).trim());
|
|
950
|
+
if (raw === "/dev/null") return null;
|
|
951
|
+
if (raw.startsWith("a/") || raw.startsWith("b/")) return raw.slice(2);
|
|
952
|
+
return raw;
|
|
953
|
+
}
|
|
954
|
+
function unquoteGitPath(value) {
|
|
955
|
+
if (!value.startsWith(`"`)) return value;
|
|
956
|
+
const unquoted = value.slice(1, value.endsWith(`"`) ? -1 : void 0);
|
|
957
|
+
try {
|
|
958
|
+
return jsonString(parseJsonText(value)) ?? unquoted;
|
|
959
|
+
} catch {
|
|
960
|
+
return unquoted;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/review-api/checkout-fs.ts
|
|
965
|
+
/**
|
|
966
|
+
* File system calls for paths inside a user's checkout. Electron's `fs`
|
|
967
|
+
* treats every `*.asar` path as an archive, so a checkout that merely
|
|
968
|
+
* contains one fails with "Invalid package" or shows it as a directory.
|
|
969
|
+
* Electron's unpatched `original-fs` reads it as the plain file it is.
|
|
970
|
+
*/
|
|
971
|
+
const checkoutFs = process.versions.electron ? createRequire(import.meta.url)("original-fs").promises : fs$1;
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/review-api/lens-alignment.ts
|
|
974
|
+
/** Project the already-computed patch's runs, including unchanged gaps. */
|
|
975
|
+
function textualRows(path, patch, baseCount, headCount) {
|
|
976
|
+
const rows = [];
|
|
977
|
+
let base = 0, head = 0;
|
|
978
|
+
const append = (baseEnd, headEnd) => {
|
|
979
|
+
while (base < baseEnd || head < headEnd) rows.push([base < baseEnd ? base++ : null, head < headEnd ? head++ : null]);
|
|
980
|
+
};
|
|
981
|
+
for (const hunk of parseUnifiedPatch(path, patch)) {
|
|
982
|
+
append(hunk.oldLines ? hunk.oldStart - 1 : hunk.oldStart, hunk.newLines ? hunk.newStart - 1 : hunk.newStart);
|
|
983
|
+
let removed = 0, added = 0;
|
|
984
|
+
const flush = () => {
|
|
985
|
+
append(base + removed, head + added);
|
|
986
|
+
removed = added = 0;
|
|
987
|
+
};
|
|
988
|
+
for (const line of hunk.lines) if (line.kind === "context") {
|
|
989
|
+
flush();
|
|
990
|
+
append(base + 1, head + 1);
|
|
991
|
+
} else if (line.kind === "add") added++;
|
|
992
|
+
else removed++;
|
|
993
|
+
flush();
|
|
994
|
+
}
|
|
995
|
+
append(baseCount, headCount);
|
|
996
|
+
return rows;
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/review-api/comparison-coverage.ts
|
|
1000
|
+
const hash = (parts) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
1001
|
+
/** Immutable comparison facts shared by catalog totals and review progress. */
|
|
1002
|
+
async function comparisonCoverage(data, reviewId, pins, mode, signal, publish) {
|
|
1003
|
+
const fileSources = /* @__PURE__ */ new Map();
|
|
1004
|
+
const alignments = /* @__PURE__ */ new Map();
|
|
1005
|
+
const files = [];
|
|
1006
|
+
if (mode === "textual") for (const file of await data.changes(pins)) {
|
|
1007
|
+
signal.throwIfAborted();
|
|
1008
|
+
const patch = await data.changes(pins, file.path);
|
|
1009
|
+
const changed = emptyCoverage();
|
|
1010
|
+
for (const hunk of parseUnifiedPatch(file.path, patch)) for (const line of hunk.lines) {
|
|
1011
|
+
if (line.kind === "add") changed.head.push([line.newLine - 1, line.newLine]);
|
|
1012
|
+
if (line.kind === "remove") changed.base.push([line.oldLine - 1, line.oldLine]);
|
|
1013
|
+
}
|
|
1014
|
+
changed.base = unionIntervals(changed.base);
|
|
1015
|
+
changed.head = unionIntervals(changed.head);
|
|
1016
|
+
const [base, head] = await Promise.all([file.status === "added" ? null : data.file(pins, "base", file.previousPath ?? file.path).then((value) => value.text).catch(() => null), file.status === "deleted" ? null : data.file(pins, "head", file.path).then((value) => value.text).catch(() => null)]);
|
|
1017
|
+
alignments.set(file.path, textualRows(file.path, patch, base === null ? 0 : base.split("\n").length, head === null ? 0 : head.split("\n").length));
|
|
1018
|
+
const readable = (file.status === "added" || base !== null) && (file.status === "deleted" || head !== null);
|
|
1019
|
+
const fingerprint = hash([
|
|
1020
|
+
pins.repositoryId,
|
|
1021
|
+
base,
|
|
1022
|
+
head,
|
|
1023
|
+
...readable ? [] : [
|
|
1024
|
+
pins.base,
|
|
1025
|
+
pins.head,
|
|
1026
|
+
patch
|
|
1027
|
+
]
|
|
1028
|
+
]);
|
|
1029
|
+
fileSources.set(file.path, [...base !== null ? [{
|
|
1030
|
+
side: "base",
|
|
1031
|
+
file: file.previousPath ?? file.path,
|
|
1032
|
+
fromLine: 1,
|
|
1033
|
+
toLine: base.split("\n").length
|
|
1034
|
+
}] : [], ...head !== null ? [{
|
|
1035
|
+
side: "head",
|
|
1036
|
+
file: file.path,
|
|
1037
|
+
fromLine: 1,
|
|
1038
|
+
toLine: head.split("\n").length
|
|
1039
|
+
}] : []]);
|
|
1040
|
+
files.push({
|
|
1041
|
+
path: file.path,
|
|
1042
|
+
previousPath: file.previousPath,
|
|
1043
|
+
fingerprint,
|
|
1044
|
+
changed,
|
|
1045
|
+
viewed: emptyCoverage()
|
|
1046
|
+
});
|
|
1047
|
+
publish?.({
|
|
1048
|
+
files: [...files],
|
|
1049
|
+
fileSources: new Map(fileSources),
|
|
1050
|
+
alignments: new Map(alignments)
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
const remaining = /* @__PURE__ */ new Set();
|
|
1055
|
+
for await (const event of data.structuralChanges({
|
|
1056
|
+
reviewId,
|
|
1057
|
+
pins,
|
|
1058
|
+
signal
|
|
1059
|
+
})) {
|
|
1060
|
+
if (event.type === "start") {
|
|
1061
|
+
for (const entry of event.files) remaining.add((entry.file.rhs ?? entry.file.lhs).path);
|
|
1062
|
+
if (!remaining.size) break;
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (event.type === "complete" && (event.failed || event.aborted)) throw new Error(event.aborted?.message ?? "Structural coverage is incomplete.");
|
|
1066
|
+
if (event.type !== "file") continue;
|
|
1067
|
+
const path = (event.file.rhs ?? event.file.lhs).path;
|
|
1068
|
+
if (!remaining.delete(path)) throw new Error(`Unexpected structural result: ${path}`);
|
|
1069
|
+
if (event.error) {
|
|
1070
|
+
if (event.error.code !== "unsupported_file_type") throw new Error(`Cannot count ${path}: ${event.error.message}`);
|
|
1071
|
+
if (!remaining.size) break;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const diff = event.diff;
|
|
1075
|
+
const previousPath = event.file.lhs?.path !== path ? event.file.lhs?.path : void 0;
|
|
1076
|
+
const base = diff.type === "text" ? diff.lhs?.text ?? null : null;
|
|
1077
|
+
const head = diff.type === "text" ? diff.rhs?.text ?? null : null;
|
|
1078
|
+
const fingerprint = hash([
|
|
1079
|
+
pins.repositoryId,
|
|
1080
|
+
base,
|
|
1081
|
+
head,
|
|
1082
|
+
...diff.type === "binary" ? [event.file.lhs?.oid ?? null, event.file.rhs?.oid ?? null] : []
|
|
1083
|
+
]);
|
|
1084
|
+
const sources = [];
|
|
1085
|
+
if (base !== null) sources.push({
|
|
1086
|
+
side: "base",
|
|
1087
|
+
file: event.file.lhs.path,
|
|
1088
|
+
fromLine: 1,
|
|
1089
|
+
toLine: base.split("\n").length
|
|
1090
|
+
});
|
|
1091
|
+
if (head !== null) sources.push({
|
|
1092
|
+
side: "head",
|
|
1093
|
+
file: path,
|
|
1094
|
+
fromLine: 1,
|
|
1095
|
+
toLine: head.split("\n").length
|
|
1096
|
+
});
|
|
1097
|
+
fileSources.set(path, sources);
|
|
1098
|
+
if (diff.type === "text") alignments.set(path, structuralRows(diff));
|
|
1099
|
+
files.push({
|
|
1100
|
+
path,
|
|
1101
|
+
previousPath,
|
|
1102
|
+
fingerprint,
|
|
1103
|
+
changed: diff.type === "text" ? diff.structural_changes : emptyCoverage(),
|
|
1104
|
+
folded: foldedChanges(event.visibility, diff),
|
|
1105
|
+
viewed: emptyCoverage()
|
|
1106
|
+
});
|
|
1107
|
+
publish?.({
|
|
1108
|
+
files: [...files],
|
|
1109
|
+
fileSources: new Map(fileSources),
|
|
1110
|
+
alignments: new Map(alignments)
|
|
1111
|
+
});
|
|
1112
|
+
if (!remaining.size) break;
|
|
1113
|
+
}
|
|
1114
|
+
if (remaining.size) throw new Error("Structural coverage is incomplete.");
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
files,
|
|
1118
|
+
fileSources,
|
|
1119
|
+
alignments
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The changed lines diffr folds by default. A file it hides (lockfiles,
|
|
1124
|
+
* generated, vendored and test files, per its plugins) folds all of them.
|
|
1125
|
+
* Otherwise they are the changed lines under regions that start collapsed
|
|
1126
|
+
* and in no visible leaf: `structural_changes` less what diffr counts in
|
|
1127
|
+
* `stats.visible`, recomputed the way diffr's `change_coverage` does so that
|
|
1128
|
+
* the lines, not just the counts, are known. A paired leaf contributes its
|
|
1129
|
+
* changed spans' lines; an unpaired leaf, all of its lines.
|
|
1130
|
+
*/
|
|
1131
|
+
function foldedChanges(visibility, diff) {
|
|
1132
|
+
if (diff.type !== "text") return emptyCoverage();
|
|
1133
|
+
const all = diff.structural_changes;
|
|
1134
|
+
if (visibility?.collapsed) return {
|
|
1135
|
+
base: unionIntervals(all.base),
|
|
1136
|
+
head: unionIntervals(all.head)
|
|
1137
|
+
};
|
|
1138
|
+
const side = (changed, source, other) => {
|
|
1139
|
+
const paired = /* @__PURE__ */ new Set();
|
|
1140
|
+
const pair = (region) => {
|
|
1141
|
+
if (region.kind === "leaf") paired.add(region.alignment_id);
|
|
1142
|
+
else region.children.forEach(pair);
|
|
1143
|
+
};
|
|
1144
|
+
other?.regions?.forEach(pair);
|
|
1145
|
+
const hidden = [], visible = [];
|
|
1146
|
+
const collect = (region, folded) => {
|
|
1147
|
+
folded ||= region.visibility?.collapsed === true;
|
|
1148
|
+
if (region.kind === "fold") {
|
|
1149
|
+
for (const child of region.children) collect(child, folded);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const lines = folded ? hidden : visible;
|
|
1153
|
+
if (paired.has(region.alignment_id)) for (const span of region.changed ?? []) lines.push([span.line, span.line + 1]);
|
|
1154
|
+
else lines.push([region.start.line, region.end.column === 0 ? region.end.line : region.end.line + 1]);
|
|
1155
|
+
};
|
|
1156
|
+
for (const region of source?.regions ?? []) collect(region, false);
|
|
1157
|
+
return subtractIntervals(intersectIntervals(changed, hidden), visible);
|
|
1158
|
+
};
|
|
1159
|
+
return {
|
|
1160
|
+
base: side(all.base, diff.lhs, diff.rhs),
|
|
1161
|
+
head: side(all.head, diff.rhs, diff.lhs)
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/review-api/image-decode.ts
|
|
1166
|
+
/** The one decoder for images entering the store, whether uploaded by a client
|
|
1167
|
+
* or read from a legacy review: bounded, single frame, re-encoded as PNG. */
|
|
1168
|
+
async function decodeImage(bytes) {
|
|
1169
|
+
const { default: sharp } = await import("sharp");
|
|
1170
|
+
try {
|
|
1171
|
+
const decoder = sharp(Buffer.from(bytes), {
|
|
1172
|
+
limitInputPixels: 2e7,
|
|
1173
|
+
failOn: "warning"
|
|
1174
|
+
});
|
|
1175
|
+
const metadata = await decoder.metadata();
|
|
1176
|
+
if (![
|
|
1177
|
+
"png",
|
|
1178
|
+
"jpeg",
|
|
1179
|
+
"webp"
|
|
1180
|
+
].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1) throw new Error("Unsupported image");
|
|
1181
|
+
return await decoder.png().toBuffer();
|
|
1182
|
+
} catch {
|
|
1183
|
+
throw new ReviewInputError("Provide a valid single PNG, JPEG, or WebP image (at most 20 megapixels).");
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/review-api/map-input.ts
|
|
1188
|
+
const label = z.string().trim().min(1);
|
|
1189
|
+
const nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "Must not be empty");
|
|
1190
|
+
const optionalNonEmptyStringSchema = nonEmptyStringSchema.optional();
|
|
1191
|
+
const softwareDataStoreForeignKeyRefSchema = z.union([nonEmptyStringSchema, z.strictObject({
|
|
1192
|
+
store: optionalNonEmptyStringSchema,
|
|
1193
|
+
table: nonEmptyStringSchema,
|
|
1194
|
+
field: nonEmptyStringSchema,
|
|
1195
|
+
label: optionalNonEmptyStringSchema,
|
|
1196
|
+
cardinality: z.enum(["one-to-one", "many-to-one"]).optional(),
|
|
1197
|
+
onDelete: optionalNonEmptyStringSchema,
|
|
1198
|
+
onUpdate: optionalNonEmptyStringSchema
|
|
1199
|
+
})]);
|
|
1200
|
+
const softwareDataStoreFieldSchema = z.lazy(() => z.record(nonEmptyStringSchema, z.union([z.strictObject({
|
|
1201
|
+
type: nonEmptyStringSchema,
|
|
1202
|
+
example: z.unknown().optional(),
|
|
1203
|
+
pk: z.boolean().optional(),
|
|
1204
|
+
fk: softwareDataStoreForeignKeyRefSchema.optional(),
|
|
1205
|
+
schema: softwareDataStoreFieldSchema.optional()
|
|
1206
|
+
}), softwareDataStoreFieldSchema])));
|
|
1207
|
+
const softwareDataStoreCollectionInputSchema = z.strictObject({
|
|
1208
|
+
label: optionalNonEmptyStringSchema,
|
|
1209
|
+
key: optionalNonEmptyStringSchema,
|
|
1210
|
+
schema: softwareDataStoreFieldSchema
|
|
1211
|
+
});
|
|
1212
|
+
const endpoint = label.describe("Existing element or data-store schema path. Resolution order: relative to the containing element, relative to its parent, then full path. \".\" means the containing element. At model root use full paths. Cross-level endpoints are allowed.");
|
|
1213
|
+
const range = z.strictObject({
|
|
1214
|
+
fromLine: z.number().int().positive(),
|
|
1215
|
+
toLine: z.number().int().positive()
|
|
1216
|
+
});
|
|
1217
|
+
const relationship = z.union([z.strictObject({
|
|
1218
|
+
kind: z.literal("call"),
|
|
1219
|
+
from: endpoint,
|
|
1220
|
+
to: endpoint,
|
|
1221
|
+
label: z.string().optional(),
|
|
1222
|
+
description: z.string().optional(),
|
|
1223
|
+
nthCallSite: z.number().int().nonnegative().optional()
|
|
1224
|
+
}), z.strictObject({
|
|
1225
|
+
kind: z.literal("semantic"),
|
|
1226
|
+
from: endpoint,
|
|
1227
|
+
to: endpoint,
|
|
1228
|
+
label: z.string().optional(),
|
|
1229
|
+
description: z.string().optional(),
|
|
1230
|
+
semanticKind: z.string().optional(),
|
|
1231
|
+
sourceRanges: z.array(range).optional()
|
|
1232
|
+
})]);
|
|
1233
|
+
const base = z.strictObject({
|
|
1234
|
+
id: label.optional().describe("Local ID, unique among siblings; defaults to the object key (or array index). Give array entries explicit IDs for stable paths. Nesting builds the full path: api under app becomes app.api. Do not repeat the parent path in the ID."),
|
|
1235
|
+
label: label.optional(),
|
|
1236
|
+
description: z.string().optional(),
|
|
1237
|
+
changeStatus: z.enum([
|
|
1238
|
+
"added",
|
|
1239
|
+
"removed",
|
|
1240
|
+
"modified",
|
|
1241
|
+
"unchanged"
|
|
1242
|
+
]).optional(),
|
|
1243
|
+
relationships: z.array(relationship).optional().describe("Relationships scoped to this element. Endpoints may use relative names or full paths; they need not be direct children.")
|
|
1244
|
+
});
|
|
1245
|
+
const coverage = z.strictObject({
|
|
1246
|
+
files: z.array(z.union([label, z.strictObject({
|
|
1247
|
+
path: label,
|
|
1248
|
+
ranges: z.array(range).optional()
|
|
1249
|
+
})])).optional(),
|
|
1250
|
+
globs: z.array(label).optional()
|
|
1251
|
+
});
|
|
1252
|
+
const collection = (schema) => z.union([z.record(label, schema), z.array(schema)]);
|
|
1253
|
+
const code = base.extend({ sourceRanges: z.array(range.extend({ file: label })).optional() });
|
|
1254
|
+
const component = base.extend({
|
|
1255
|
+
coverage: coverage.optional(),
|
|
1256
|
+
codeElements: collection(code).optional()
|
|
1257
|
+
});
|
|
1258
|
+
const container = base.extend({
|
|
1259
|
+
coverage: coverage.optional(),
|
|
1260
|
+
components: collection(component).optional()
|
|
1261
|
+
});
|
|
1262
|
+
const store = container.extend({
|
|
1263
|
+
kind: z.enum([
|
|
1264
|
+
"database",
|
|
1265
|
+
"objectStore",
|
|
1266
|
+
"bucket",
|
|
1267
|
+
"artifactStore",
|
|
1268
|
+
"fileStore"
|
|
1269
|
+
]).optional(),
|
|
1270
|
+
tables: z.record(label, softwareDataStoreCollectionInputSchema).optional(),
|
|
1271
|
+
documents: z.record(label, softwareDataStoreCollectionInputSchema).optional()
|
|
1272
|
+
});
|
|
1273
|
+
const system = base.extend({
|
|
1274
|
+
coverage: coverage.optional(),
|
|
1275
|
+
external: z.boolean().optional(),
|
|
1276
|
+
containers: collection(container).optional(),
|
|
1277
|
+
dataStores: collection(store).optional()
|
|
1278
|
+
});
|
|
1279
|
+
const mapInputSchema = z.strictObject({
|
|
1280
|
+
people: collection(base).optional(),
|
|
1281
|
+
systems: collection(system).optional(),
|
|
1282
|
+
relationships: z.array(relationship).optional().describe("Model-level relationships, including people and systems. Use full endpoint paths; external systems are systems with external: true.")
|
|
1283
|
+
});
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/review-api/pull-request.ts
|
|
1286
|
+
const GH_TIMEOUT_MS = 2e4;
|
|
1287
|
+
const API_TIMEOUT_MS = 15e3;
|
|
1288
|
+
const FETCH_TIMEOUT_MS = 12e4;
|
|
1289
|
+
const LOCAL_TIMEOUT_MS = 3e4;
|
|
1290
|
+
const defaultPullRequestDeps = {
|
|
1291
|
+
run: (file, args, options) => new Promise((resolve, reject) => {
|
|
1292
|
+
execFile(file, args, {
|
|
1293
|
+
cwd: options.cwd,
|
|
1294
|
+
timeout: options.timeoutMs,
|
|
1295
|
+
maxBuffer: 16777216,
|
|
1296
|
+
env: {
|
|
1297
|
+
...process.env,
|
|
1298
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1299
|
+
GH_PROMPT_DISABLED: "1",
|
|
1300
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes"
|
|
1301
|
+
}
|
|
1302
|
+
}, (error, stdout, stderr) => {
|
|
1303
|
+
if (!error) return resolve(stdout);
|
|
1304
|
+
const reason = error.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(stderr).trim() || error.message;
|
|
1305
|
+
reject(/* @__PURE__ */ new Error(`${file} ${args[0]}: ${reason}`));
|
|
1306
|
+
});
|
|
1307
|
+
}),
|
|
1308
|
+
fetch: (input, init) => fetch(input, init)
|
|
1309
|
+
};
|
|
1310
|
+
/** Host, owner/repo and number from a canonical PR URL (validated by the command schema). */
|
|
1311
|
+
function pullRequestAddress(url) {
|
|
1312
|
+
const { hostname, pathname } = new URL(url);
|
|
1313
|
+
const [, owner, repo, , number] = pathname.split("/");
|
|
1314
|
+
return {
|
|
1315
|
+
host: hostname,
|
|
1316
|
+
slug: `${owner}/${repo}`,
|
|
1317
|
+
number: Number(number)
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
const metadataSchema = z.object({
|
|
1321
|
+
number: z.number().int().positive(),
|
|
1322
|
+
title: z.string(),
|
|
1323
|
+
baseRefName: z.string().min(1),
|
|
1324
|
+
baseRefOid: z.string().optional()
|
|
1325
|
+
});
|
|
1326
|
+
/** gh first (it carries the user's auth), then, for github.com only, GitHub's
|
|
1327
|
+
* public REST API: Enterprise hosts require gh. */
|
|
1328
|
+
async function readPullRequest(url, deps) {
|
|
1329
|
+
const { host, slug, number } = pullRequestAddress(url);
|
|
1330
|
+
let ghFailure;
|
|
1331
|
+
try {
|
|
1332
|
+
const stdout = await deps.run("gh", [
|
|
1333
|
+
"pr",
|
|
1334
|
+
"view",
|
|
1335
|
+
String(number),
|
|
1336
|
+
"--repo",
|
|
1337
|
+
`${host}/${slug}`,
|
|
1338
|
+
"--json",
|
|
1339
|
+
"number,title,baseRefName,baseRefOid"
|
|
1340
|
+
], { timeoutMs: GH_TIMEOUT_MS });
|
|
1341
|
+
return {
|
|
1342
|
+
host,
|
|
1343
|
+
slug,
|
|
1344
|
+
...metadataSchema.parse(JSON.parse(stdout))
|
|
1345
|
+
};
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
ghFailure = firstLine(errorMessage(error));
|
|
1348
|
+
}
|
|
1349
|
+
if (host !== "github.com") throw new ReviewInputError(`Could not read ${url}: gh failed (${ghFailure}). Run \`gh auth login --hostname ${host}\` with an account that can read the repository, then retry.`, 409);
|
|
1350
|
+
let response;
|
|
1351
|
+
try {
|
|
1352
|
+
response = await deps.fetch(`https://api.github.com/repos/${slug}/pulls/${number}`, {
|
|
1353
|
+
headers: {
|
|
1354
|
+
Accept: "application/vnd.github+json",
|
|
1355
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1356
|
+
},
|
|
1357
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
|
1358
|
+
});
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
throw unreadable(url, ghFailure, `the GitHub API is unreachable (${firstLine(errorMessage(error))})`);
|
|
1361
|
+
}
|
|
1362
|
+
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);
|
|
1363
|
+
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");
|
|
1364
|
+
if (!response.ok) throw unreadable(url, ghFailure, `the GitHub API returned HTTP ${response.status}`);
|
|
1365
|
+
const parsed = z.object({
|
|
1366
|
+
number: z.number(),
|
|
1367
|
+
title: z.string(),
|
|
1368
|
+
base: z.object({
|
|
1369
|
+
ref: z.string().min(1),
|
|
1370
|
+
sha: z.string()
|
|
1371
|
+
})
|
|
1372
|
+
}).safeParse(await response.json().catch(() => void 0));
|
|
1373
|
+
if (!parsed.success) throw unreadable(url, ghFailure, "the GitHub API response was malformed");
|
|
1374
|
+
return {
|
|
1375
|
+
host,
|
|
1376
|
+
slug,
|
|
1377
|
+
number: parsed.data.number,
|
|
1378
|
+
title: parsed.data.title,
|
|
1379
|
+
baseRefName: parsed.data.base.ref,
|
|
1380
|
+
baseRefOid: parsed.data.base.sha
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/** Remotes by the host and owner/repo their configured URL names. The
|
|
1384
|
+
* configured URL, not the insteadOf rewrite: a mirror still names its repo. */
|
|
1385
|
+
async function githubRemotes(gitDir, deps) {
|
|
1386
|
+
return (await deps.run("git", [
|
|
1387
|
+
"--git-dir",
|
|
1388
|
+
gitDir,
|
|
1389
|
+
"config",
|
|
1390
|
+
"--get-regexp",
|
|
1391
|
+
String.raw`^remote\..*\.url$`
|
|
1392
|
+
], { timeoutMs: LOCAL_TIMEOUT_MS }).catch(() => "")).split("\n").flatMap((line) => {
|
|
1393
|
+
const match = /^remote\.(.+)\.url\s+(\S+)/.exec(line.trim());
|
|
1394
|
+
const remote = match && parseGitRemote(match[2]);
|
|
1395
|
+
return match && remote ? [{
|
|
1396
|
+
name: match[1],
|
|
1397
|
+
host: remote.host,
|
|
1398
|
+
slug: remote.slug
|
|
1399
|
+
}] : [];
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
/** Refs Review owns for one PR; the user's branches and bookmarks never move. */
|
|
1403
|
+
function pullRequestRefs(pr) {
|
|
1404
|
+
const prefix = `refs/review/github/${`${pr.host === "github.com" ? "" : `${pr.host}/`}${pr.slug}`.toLowerCase()}/pull/${pr.number}`;
|
|
1405
|
+
return {
|
|
1406
|
+
head: `${prefix}/head`,
|
|
1407
|
+
base: `${prefix}/base`,
|
|
1408
|
+
frozenBase: `${prefix}/frozen-base`
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Fetch the PR head (refs/pull/N/head, so fork PRs work) and its base branch
|
|
1413
|
+
* into Review's own namespace, and return the comparison GitHub shows: the
|
|
1414
|
+
* head, and the merge base of the head with the base branch.
|
|
1415
|
+
*
|
|
1416
|
+
* A PR merged with a merge commit is absorbed: merge-base(base branch, head)
|
|
1417
|
+
* is the head itself and the diff is empty. GitHub freezes the base commit
|
|
1418
|
+
* (baseRefOid) at the PR's last update, so its merge base with the head is
|
|
1419
|
+
* the fork point GitHub diffs against, and it survives base branches that are
|
|
1420
|
+
* force-rebuilt or deleted.
|
|
1421
|
+
*/
|
|
1422
|
+
async function fetchPullRequest(input, deps) {
|
|
1423
|
+
const { pullRequest: pr } = input;
|
|
1424
|
+
const refs = pullRequestRefs(pr);
|
|
1425
|
+
const git = (args, timeoutMs = LOCAL_TIMEOUT_MS) => deps.run("git", [
|
|
1426
|
+
"--git-dir",
|
|
1427
|
+
input.gitDir,
|
|
1428
|
+
...args
|
|
1429
|
+
], { timeoutMs }).then((stdout) => stdout.trim());
|
|
1430
|
+
const fetchRefs = (...refspecs) => git([
|
|
1431
|
+
"fetch",
|
|
1432
|
+
"--no-tags",
|
|
1433
|
+
"--no-write-fetch-head",
|
|
1434
|
+
"--quiet",
|
|
1435
|
+
input.remote,
|
|
1436
|
+
...refspecs
|
|
1437
|
+
], FETCH_TIMEOUT_MS);
|
|
1438
|
+
const commit = (ref) => git([
|
|
1439
|
+
"rev-parse",
|
|
1440
|
+
"--verify",
|
|
1441
|
+
"--quiet",
|
|
1442
|
+
`${ref}^{commit}`
|
|
1443
|
+
]).catch(() => void 0);
|
|
1444
|
+
const mergeBase = (left, right) => git([
|
|
1445
|
+
"merge-base",
|
|
1446
|
+
left,
|
|
1447
|
+
right
|
|
1448
|
+
]).catch(() => void 0);
|
|
1449
|
+
const frozenBase = async () => {
|
|
1450
|
+
if (!pr.baseRefOid) return void 0;
|
|
1451
|
+
if (!await commit(pr.baseRefOid)) await fetchRefs(`+${pr.baseRefOid}:${refs.frozenBase}`).catch(() => void 0);
|
|
1452
|
+
return commit(pr.baseRefOid);
|
|
1453
|
+
};
|
|
1454
|
+
let baseTip;
|
|
1455
|
+
try {
|
|
1456
|
+
await fetchRefs(`+refs/pull/${pr.number}/head:${refs.head}`, `+refs/heads/${pr.baseRefName}:${refs.base}`);
|
|
1457
|
+
baseTip = await commit(refs.base);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
try {
|
|
1460
|
+
await fetchRefs(`+refs/pull/${pr.number}/head:${refs.head}`);
|
|
1461
|
+
} catch {
|
|
1462
|
+
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);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const head = await commit(refs.head);
|
|
1466
|
+
baseTip ??= await frozenBase();
|
|
1467
|
+
if (!head || !baseTip) throw new ReviewInputError(`Fetched PR #${pr.number} but could not resolve its ${head ? `base branch ${pr.baseRefName}` : "head"}.`, 409);
|
|
1468
|
+
let base = await mergeBase(baseTip, head);
|
|
1469
|
+
if (!base || base === head) {
|
|
1470
|
+
const frozen = await frozenBase();
|
|
1471
|
+
if (frozen && frozen !== baseTip) base = await mergeBase(frozen, head) ?? base;
|
|
1472
|
+
}
|
|
1473
|
+
if (!base) throw new ReviewInputError(`PR #${pr.number}'s head shares no history with ${pr.baseRefName}.`, 409);
|
|
1474
|
+
if (input.kind === "jj") await indexForJj(input.rootPath, [base, head], git, deps);
|
|
1475
|
+
return {
|
|
1476
|
+
head,
|
|
1477
|
+
base
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* jj resolves only commits in its index, and `jj git import` indexes only
|
|
1482
|
+
* branches, remote branches and tags. Import the commits through a
|
|
1483
|
+
* transient tag, then drop it: the commits stay indexed (hidden, resolvable
|
|
1484
|
+
* by commit id) and no bookmark or tag remains.
|
|
1485
|
+
*/
|
|
1486
|
+
async function indexForJj(rootPath, commits, git, deps) {
|
|
1487
|
+
const tags = commits.map(() => `refs/tags/review-index-${randomUUID()}`);
|
|
1488
|
+
const jjImport = () => deps.run("jj", [
|
|
1489
|
+
"-R",
|
|
1490
|
+
rootPath,
|
|
1491
|
+
"git",
|
|
1492
|
+
"import",
|
|
1493
|
+
"--ignore-working-copy",
|
|
1494
|
+
"--quiet"
|
|
1495
|
+
], {
|
|
1496
|
+
cwd: rootPath,
|
|
1497
|
+
timeoutMs: LOCAL_TIMEOUT_MS
|
|
1498
|
+
});
|
|
1499
|
+
try {
|
|
1500
|
+
for (const [index, tag] of tags.entries()) await git([
|
|
1501
|
+
"update-ref",
|
|
1502
|
+
tag,
|
|
1503
|
+
commits[index]
|
|
1504
|
+
]);
|
|
1505
|
+
await jjImport();
|
|
1506
|
+
} finally {
|
|
1507
|
+
for (const tag of tags) await git([
|
|
1508
|
+
"update-ref",
|
|
1509
|
+
"-d",
|
|
1510
|
+
tag
|
|
1511
|
+
]).catch(() => void 0);
|
|
1512
|
+
await jjImport().catch(() => void 0);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
function unreadable(url, ghFailure, fallback) {
|
|
1516
|
+
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);
|
|
1517
|
+
}
|
|
1518
|
+
function firstLine(text) {
|
|
1519
|
+
return text.split("\n")[0].slice(0, 300);
|
|
1520
|
+
}
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/review-api/activity.ts
|
|
1523
|
+
const focusSchema = z.strictObject({
|
|
1524
|
+
description: z.string().trim().min(1).max(160),
|
|
1525
|
+
targetId: z.string().min(1).optional()
|
|
1526
|
+
});
|
|
1527
|
+
const activityBeginSchema = z.strictObject({ focus: focusSchema.nullable().optional() });
|
|
1528
|
+
const activityUpdateSchema = z.strictObject({
|
|
1529
|
+
activityId: z.string().min(1),
|
|
1530
|
+
focus: focusSchema.nullable().optional()
|
|
1531
|
+
});
|
|
1532
|
+
const activityEndSchema = z.strictObject({ activityId: z.string().min(1) });
|
|
1533
|
+
const activitySchema = z.discriminatedUnion("action", [
|
|
1534
|
+
activityBeginSchema.extend({ action: z.literal("begin") }),
|
|
1535
|
+
activityUpdateSchema.extend({ action: z.literal("update") }),
|
|
1536
|
+
activityEndSchema.extend({ action: z.literal("end") })
|
|
1537
|
+
]);
|
|
1538
|
+
const ACTIVITY_TTL_MS = 18e4;
|
|
1539
|
+
var ReviewActivity = class {
|
|
1540
|
+
db;
|
|
1541
|
+
assertReview;
|
|
1542
|
+
timers = /* @__PURE__ */ new Map();
|
|
1543
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1544
|
+
working = /* @__PURE__ */ new Set();
|
|
1545
|
+
workingListeners = /* @__PURE__ */ new Set();
|
|
1546
|
+
constructor(db, assertReview) {
|
|
1547
|
+
this.db = db;
|
|
1548
|
+
this.assertReview = assertReview;
|
|
1549
|
+
db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
|
|
1550
|
+
activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
|
|
1551
|
+
started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
|
|
1552
|
+
DROP TABLE IF EXISTS authoring_sessions;`);
|
|
1553
|
+
for (const row of db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) {
|
|
1554
|
+
this.working.add(String(row.review_id));
|
|
1555
|
+
this.scheduleExpiry(String(row.review_id));
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
subscribe(listener) {
|
|
1559
|
+
this.listeners.add(listener);
|
|
1560
|
+
return () => {
|
|
1561
|
+
this.listeners.delete(listener);
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
/** Fires only when a review starts or stops being authored, never on
|
|
1565
|
+
* renewals or focus changes. */
|
|
1566
|
+
subscribeWorking(listener) {
|
|
1567
|
+
this.workingListeners.add(listener);
|
|
1568
|
+
return () => {
|
|
1569
|
+
this.workingListeners.delete(listener);
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
isWorking(reviewId) {
|
|
1573
|
+
return this.working.has(reviewId);
|
|
1574
|
+
}
|
|
1575
|
+
changed(reviewId) {
|
|
1576
|
+
const working = this.db.prepare("SELECT 1 FROM authoring_presences WHERE review_id=? AND expires_at>? LIMIT 1").get(reviewId, Date.now()) !== void 0;
|
|
1577
|
+
if (working !== this.working.has(reviewId)) {
|
|
1578
|
+
if (working) this.working.add(reviewId);
|
|
1579
|
+
else this.working.delete(reviewId);
|
|
1580
|
+
for (const notify of this.workingListeners) notify();
|
|
1581
|
+
}
|
|
1582
|
+
for (const notify of this.listeners) notify(reviewId);
|
|
1583
|
+
}
|
|
1584
|
+
live(reviewId) {
|
|
1585
|
+
return this.db.prepare("SELECT activity_id,slot,expires_at,focus,surface FROM authoring_presences WHERE review_id=? AND expires_at>? ORDER BY rowid").all(reviewId, Date.now());
|
|
1586
|
+
}
|
|
1587
|
+
read(reviewId) {
|
|
1588
|
+
const live = this.live(reviewId);
|
|
1589
|
+
const snapshot = {
|
|
1590
|
+
workingCount: live.length,
|
|
1591
|
+
expiresAt: live.length ? Math.max(...live.map((row) => Number(row.expires_at))) : null
|
|
1592
|
+
};
|
|
1593
|
+
if (live.length) snapshot.activities = live.map((row) => {
|
|
1594
|
+
const presence = {
|
|
1595
|
+
activityId: String(row.activity_id),
|
|
1596
|
+
slot: Number(row.slot)
|
|
1597
|
+
};
|
|
1598
|
+
if (row.focus) presence.focus = focusSchema.parse(JSON.parse(String(row.focus)));
|
|
1599
|
+
if (row.surface === "document" || row.surface === "lenses") presence.surface = row.surface;
|
|
1600
|
+
return presence;
|
|
1601
|
+
});
|
|
1602
|
+
return snapshot;
|
|
1603
|
+
}
|
|
1604
|
+
/** Inside the caller's write transaction: the presence a write belongs to,
|
|
1605
|
+
* renewed and moved to the write's surface. The named one if it is live;
|
|
1606
|
+
* otherwise, with none named, the review's only presence. A write is never
|
|
1607
|
+
* refused for this. Call `extended` once the transaction commits. */
|
|
1608
|
+
attribute(reviewId, surface, activityId) {
|
|
1609
|
+
const live = this.live(reviewId);
|
|
1610
|
+
const owner = activityId === void 0 ? live.length === 1 ? String(live[0].activity_id) : void 0 : live.some((row) => row.activity_id === activityId) ? activityId : void 0;
|
|
1611
|
+
if (owner === void 0) return void 0;
|
|
1612
|
+
this.db.prepare("UPDATE authoring_presences SET expires_at=?,surface=? WHERE activity_id=?").run(Date.now() + ACTIVITY_TTL_MS, surface, owner);
|
|
1613
|
+
return owner;
|
|
1614
|
+
}
|
|
1615
|
+
/** Move the expiry timer after a committed `attribute`. */
|
|
1616
|
+
extended(reviewId) {
|
|
1617
|
+
this.scheduleExpiry(reviewId);
|
|
1618
|
+
this.changed(reviewId);
|
|
1619
|
+
}
|
|
1620
|
+
update(reviewId, value) {
|
|
1621
|
+
const input = activitySchema.parse(value);
|
|
1622
|
+
const now = Date.now();
|
|
1623
|
+
let activityId;
|
|
1624
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1625
|
+
try {
|
|
1626
|
+
this.assertReview?.(reviewId);
|
|
1627
|
+
if (input.action === "begin") {
|
|
1628
|
+
activityId = randomUUID();
|
|
1629
|
+
const taken = new Set(this.live(reviewId).map((row) => Number(row.slot)));
|
|
1630
|
+
let slot = 0;
|
|
1631
|
+
while (taken.has(slot)) slot += 1;
|
|
1632
|
+
this.db.prepare("INSERT INTO authoring_presences(activity_id,review_id,slot,started_at,expires_at,focus) VALUES(?,?,?,?,?,?)").run(activityId, reviewId, slot, now, now + ACTIVITY_TTL_MS, input.focus ? JSON.stringify(input.focus) : null);
|
|
1633
|
+
} else {
|
|
1634
|
+
activityId = input.activityId;
|
|
1635
|
+
if (input.action === "end") this.db.prepare("DELETE FROM authoring_presences WHERE activity_id=? AND review_id=?").run(activityId, reviewId);
|
|
1636
|
+
else if (this.db.prepare(`UPDATE authoring_presences SET expires_at=?${input.focus === void 0 ? "" : ",focus=?"}
|
|
1637
|
+
WHERE activity_id=? AND review_id=? AND expires_at>?`).run(...[
|
|
1638
|
+
now + 18e4,
|
|
1639
|
+
...input.focus === void 0 ? [] : [input.focus === null ? null : JSON.stringify(input.focus)],
|
|
1640
|
+
activityId,
|
|
1641
|
+
reviewId,
|
|
1642
|
+
now
|
|
1643
|
+
]).changes === 0) throw new ReviewInputError("This activity ended or expired. Begin a new one.", 409);
|
|
1644
|
+
}
|
|
1645
|
+
this.db.exec("COMMIT");
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
this.db.exec("ROLLBACK");
|
|
1648
|
+
throw error;
|
|
1649
|
+
}
|
|
1650
|
+
this.scheduleExpiry(reviewId);
|
|
1651
|
+
this.changed(reviewId);
|
|
1652
|
+
const result = this.read(reviewId);
|
|
1653
|
+
if (input.action === "begin") result.activityId = activityId;
|
|
1654
|
+
return result;
|
|
1655
|
+
}
|
|
1656
|
+
/** One timer per review, for its soonest-expiring presence. */
|
|
1657
|
+
scheduleExpiry(reviewId) {
|
|
1658
|
+
clearTimeout(this.timers.get(reviewId));
|
|
1659
|
+
this.timers.delete(reviewId);
|
|
1660
|
+
const next = this.db.prepare("SELECT MIN(expires_at) AS expires_at FROM authoring_presences WHERE review_id=? AND expires_at>?").get(reviewId, Date.now());
|
|
1661
|
+
if (next?.expires_at === null || next?.expires_at === void 0) return;
|
|
1662
|
+
const timer = setTimeout(() => {
|
|
1663
|
+
this.scheduleExpiry(reviewId);
|
|
1664
|
+
this.changed(reviewId);
|
|
1665
|
+
}, Math.max(1, Number(next.expires_at) - Date.now()));
|
|
1666
|
+
timer.unref?.();
|
|
1667
|
+
this.timers.set(reviewId, timer);
|
|
1668
|
+
}
|
|
1669
|
+
/** Called when another database connection commits session changes. */
|
|
1670
|
+
refresh() {
|
|
1671
|
+
const ids = new Set(this.timers.keys());
|
|
1672
|
+
for (const row of this.db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) ids.add(String(row.review_id));
|
|
1673
|
+
for (const id of ids) {
|
|
1674
|
+
this.scheduleExpiry(id);
|
|
1675
|
+
this.changed(id);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
/** The store deletes the session atomically with its review before notifying. */
|
|
1679
|
+
deleted(reviewId) {
|
|
1680
|
+
clearTimeout(this.timers.get(reviewId));
|
|
1681
|
+
this.timers.delete(reviewId);
|
|
1682
|
+
this.changed(reviewId);
|
|
1683
|
+
}
|
|
1684
|
+
close() {
|
|
1685
|
+
this.listeners.clear();
|
|
1686
|
+
this.workingListeners.clear();
|
|
1687
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
1688
|
+
this.timers.clear();
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/** A semantic selection, independent of comment/thread creation. */
|
|
1692
|
+
const AgentSelectionSchema = z.strictObject({
|
|
1693
|
+
target: z.discriminatedUnion("kind", [z.strictObject({
|
|
1694
|
+
kind: z.literal("text"),
|
|
1695
|
+
quote: z.string(),
|
|
1696
|
+
/** Where the quote is: characters `start` to `end` of the text of the
|
|
1697
|
+
* review block `blockId`, which was `text` then. The block keeps its
|
|
1698
|
+
* id across versions; comparing its text then and now finds the quote
|
|
1699
|
+
* again, or tells that it changed. */
|
|
1700
|
+
anchor: z.strictObject({
|
|
1701
|
+
blockId: z.string().min(1).max(200),
|
|
1702
|
+
start: z.number().int().nonnegative(),
|
|
1703
|
+
end: z.number().int().positive(),
|
|
1704
|
+
text: z.string().max(2e4)
|
|
1705
|
+
}).refine((anchor) => anchor.start < anchor.end, "an empty anchor").optional()
|
|
1706
|
+
}), z.strictObject({
|
|
1707
|
+
kind: z.literal("code"),
|
|
1708
|
+
path: z.string(),
|
|
1709
|
+
side: z.enum(["base", "head"]),
|
|
1710
|
+
startLine: z.number().int().positive(),
|
|
1711
|
+
endLine: z.number().int().positive()
|
|
1712
|
+
})]),
|
|
1713
|
+
title: z.string().max(500),
|
|
1714
|
+
detail: z.string().max(24e3).optional(),
|
|
1715
|
+
revision: z.string().max(200).optional(),
|
|
1716
|
+
selectedDiff: ReviewSelectedDiffSchema.optional(),
|
|
1717
|
+
apiSource: ReviewApiSelectionSourceSchema.optional()
|
|
1718
|
+
});
|
|
1719
|
+
function selectionMarkdown(selection, sourceExcerpt = "", diffPaths) {
|
|
1720
|
+
const target = selection.target;
|
|
1721
|
+
if (selection.selectedDiff) return selectedDiffMarkdown(selection.selectedDiff, diffPaths);
|
|
1722
|
+
if (target.kind === "text") return target.quote.split(/\r?\n/).map((line) => `> ${line}`).join("\n");
|
|
1723
|
+
return [
|
|
1724
|
+
`## ${selection.title}`,
|
|
1725
|
+
selection.detail ?? "",
|
|
1726
|
+
sourceExcerpt
|
|
1727
|
+
].filter(Boolean).join("\n\n");
|
|
1728
|
+
}
|
|
1729
|
+
function selectedDiffMarkdown(diff, paths) {
|
|
1730
|
+
const oldCount = diff.rows.filter((row) => row.kind !== "added").length;
|
|
1731
|
+
const newCount = diff.rows.filter((row) => row.kind !== "deleted").length;
|
|
1732
|
+
const lines = diff.rows.map((row) => `${row.kind === "added" ? "+" : row.kind === "deleted" ? "-" : " "}${row.text}`);
|
|
1733
|
+
const fence = "`".repeat(Math.max(3, ...lines.flatMap((line) => [...line.matchAll(/`+/g)].map((match) => match[0].length + 1))));
|
|
1734
|
+
return [
|
|
1735
|
+
`Base: ${paths?.base ?? "a/"}`,
|
|
1736
|
+
`Head: ${paths?.head ?? "b/"}`,
|
|
1737
|
+
`Range: -${diff.oldStart},${oldCount} +${diff.newStart},${newCount}`,
|
|
1738
|
+
"",
|
|
1739
|
+
`${fence}diff`,
|
|
1740
|
+
...lines,
|
|
1741
|
+
fence
|
|
1742
|
+
].join("\n");
|
|
1743
|
+
}
|
|
1744
|
+
//#endregion
|
|
1745
|
+
//#region src/ask/thread-state.ts
|
|
1746
|
+
const askAgentIds = [
|
|
1747
|
+
"claude",
|
|
1748
|
+
"codex",
|
|
1749
|
+
"cursor",
|
|
1750
|
+
"opencode",
|
|
1751
|
+
"pi"
|
|
1752
|
+
];
|
|
1753
|
+
const permissionOptionSchema = z.object({
|
|
1754
|
+
optionId: z.string(),
|
|
1755
|
+
name: z.string(),
|
|
1756
|
+
kind: z.enum([
|
|
1757
|
+
"allow_once",
|
|
1758
|
+
"allow_always",
|
|
1759
|
+
"reject_once",
|
|
1760
|
+
"reject_always"
|
|
1761
|
+
])
|
|
1762
|
+
});
|
|
1763
|
+
/** What a question carries besides its text, as the thread shows it. An
|
|
1764
|
+
* image shows by name: its bytes go to the agent, not into the thread. */
|
|
1765
|
+
const askAttachmentSchema = z.discriminatedUnion("kind", [z.object({
|
|
1766
|
+
kind: z.literal("file"),
|
|
1767
|
+
path: z.string()
|
|
1768
|
+
}), z.object({
|
|
1769
|
+
kind: z.literal("image"),
|
|
1770
|
+
name: z.string()
|
|
1771
|
+
})]);
|
|
1772
|
+
const askEntrySchema = z.discriminatedUnion("kind", [
|
|
1773
|
+
z.object({
|
|
1774
|
+
kind: z.literal("user"),
|
|
1775
|
+
id: z.string(),
|
|
1776
|
+
text: z.string(),
|
|
1777
|
+
/** When it was asked, in epoch milliseconds; unknown for a replayed one. */
|
|
1778
|
+
at: z.number().optional(),
|
|
1779
|
+
attachments: z.array(askAttachmentSchema).optional()
|
|
1780
|
+
}),
|
|
1781
|
+
z.object({
|
|
1782
|
+
kind: z.literal("agent"),
|
|
1783
|
+
id: z.string(),
|
|
1784
|
+
text: z.string()
|
|
1785
|
+
}),
|
|
1786
|
+
z.object({
|
|
1787
|
+
kind: z.literal("tool"),
|
|
1788
|
+
id: z.string(),
|
|
1789
|
+
title: z.string(),
|
|
1790
|
+
toolKind: z.string(),
|
|
1791
|
+
status: z.enum([
|
|
1792
|
+
"pending",
|
|
1793
|
+
"in_progress",
|
|
1794
|
+
"completed",
|
|
1795
|
+
"failed"
|
|
1796
|
+
]),
|
|
1797
|
+
/** The command it ran, or the file it opened. */
|
|
1798
|
+
input: z.string().optional(),
|
|
1799
|
+
/** The agent's own note on why, when it gives one. */
|
|
1800
|
+
summary: z.string().optional(),
|
|
1801
|
+
/** What came back, shortened. */
|
|
1802
|
+
output: z.string().optional()
|
|
1803
|
+
}),
|
|
1804
|
+
z.object({
|
|
1805
|
+
kind: z.literal("permission"),
|
|
1806
|
+
id: z.string(),
|
|
1807
|
+
title: z.string(),
|
|
1808
|
+
toolKind: z.string(),
|
|
1809
|
+
/** The command it would run, or the file it would open, when the title
|
|
1810
|
+
* does not say. */
|
|
1811
|
+
input: z.string().optional(),
|
|
1812
|
+
options: z.array(permissionOptionSchema),
|
|
1813
|
+
/** The chosen option, `cancelled`, or absent while the user decides. */
|
|
1814
|
+
outcome: z.string().optional(),
|
|
1815
|
+
/** Refused by Whiteboard's read-only policy, not by the user. */
|
|
1816
|
+
automatic: z.boolean().optional()
|
|
1817
|
+
}),
|
|
1818
|
+
z.object({
|
|
1819
|
+
kind: z.literal("notice"),
|
|
1820
|
+
id: z.string(),
|
|
1821
|
+
severity: z.string(),
|
|
1822
|
+
title: z.string(),
|
|
1823
|
+
description: z.string().optional()
|
|
1824
|
+
})
|
|
1825
|
+
]);
|
|
1826
|
+
/** One of the agent's settings: its choices, and the one in use. */
|
|
1827
|
+
const askSelectSchema = z.object({
|
|
1828
|
+
current: z.string(),
|
|
1829
|
+
options: z.array(z.object({
|
|
1830
|
+
value: z.string(),
|
|
1831
|
+
name: z.string(),
|
|
1832
|
+
description: z.string().optional()
|
|
1833
|
+
}))
|
|
1834
|
+
});
|
|
1835
|
+
/** The settings a reviewer can pick: the model, and how hard it thinks. */
|
|
1836
|
+
const askChoiceKinds = ["model", "effort"];
|
|
1837
|
+
/** What the agent offers of each, when it offers it. */
|
|
1838
|
+
const askChoicesSchema = z.object({
|
|
1839
|
+
model: askSelectSchema.optional(),
|
|
1840
|
+
effort: askSelectSchema.optional()
|
|
1841
|
+
});
|
|
1842
|
+
/** The values picked, before the agent says what it offers. */
|
|
1843
|
+
const askPicksSchema = z.strictObject({
|
|
1844
|
+
model: z.string().min(1).max(200).optional(),
|
|
1845
|
+
effort: z.string().min(1).max(200).optional()
|
|
1846
|
+
});
|
|
1847
|
+
/** A slash command the agent takes, its skills among them. */
|
|
1848
|
+
const askCommandSchema = z.object({
|
|
1849
|
+
name: z.string(),
|
|
1850
|
+
description: z.string(),
|
|
1851
|
+
/** What it takes after its name, when it takes anything. */
|
|
1852
|
+
hint: z.string().optional()
|
|
1853
|
+
});
|
|
1854
|
+
/** What a question to the agent may carry besides text and file links. */
|
|
1855
|
+
const askAcceptsSchema = z.object({ image: z.boolean() });
|
|
1856
|
+
/** What an agent offers before anything is asked of it, as it last said. */
|
|
1857
|
+
const askOfferSchema = z.object({
|
|
1858
|
+
choices: askChoicesSchema,
|
|
1859
|
+
commands: z.array(askCommandSchema).optional(),
|
|
1860
|
+
accepts: askAcceptsSchema.optional()
|
|
1861
|
+
});
|
|
1862
|
+
/** A question as the reviewer asks it: its text, the checkout files it
|
|
1863
|
+
* mentions, and any images. */
|
|
1864
|
+
const askQuestionSchema = z.strictObject({
|
|
1865
|
+
text: z.string().trim().min(1).max(8e3),
|
|
1866
|
+
/** Paths relative to the checkout's root. */
|
|
1867
|
+
mentions: z.array(z.string().min(1).max(400)).max(20).optional(),
|
|
1868
|
+
images: z.array(z.strictObject({
|
|
1869
|
+
name: z.string().min(1).max(200),
|
|
1870
|
+
mimeType: z.enum([
|
|
1871
|
+
"image/png",
|
|
1872
|
+
"image/jpeg",
|
|
1873
|
+
"image/gif",
|
|
1874
|
+
"image/webp"
|
|
1875
|
+
]),
|
|
1876
|
+
data: z.string().min(1).max(7e6)
|
|
1877
|
+
})).max(4).optional()
|
|
1878
|
+
});
|
|
1879
|
+
/** How full the agent's context window is, and what the session cost. */
|
|
1880
|
+
const askUsageSchema = z.object({
|
|
1881
|
+
used: z.number(),
|
|
1882
|
+
size: z.number(),
|
|
1883
|
+
cost: z.object({
|
|
1884
|
+
amount: z.number(),
|
|
1885
|
+
currency: z.string()
|
|
1886
|
+
}).optional()
|
|
1887
|
+
});
|
|
1888
|
+
const statusSchema = z.enum([
|
|
1889
|
+
"starting",
|
|
1890
|
+
"running",
|
|
1891
|
+
"waiting",
|
|
1892
|
+
"idle",
|
|
1893
|
+
"failed"
|
|
1894
|
+
]);
|
|
1895
|
+
/** Everything the Ask panel renders. A watcher gets it whole once, then
|
|
1896
|
+
* follows it through `AskChange`s. */
|
|
1897
|
+
const askThreadStateSchema = z.object({
|
|
1898
|
+
id: z.string(),
|
|
1899
|
+
agent: z.enum(askAgentIds),
|
|
1900
|
+
agentName: z.string(),
|
|
1901
|
+
status: statusSchema,
|
|
1902
|
+
error: z.string().optional(),
|
|
1903
|
+
/** The agent's login lapsed: the command that signs in again. */
|
|
1904
|
+
signIn: z.string().optional(),
|
|
1905
|
+
/** The mode that keeps the agent from changing files was accepted. */
|
|
1906
|
+
readOnly: z.boolean(),
|
|
1907
|
+
/** The agent edits and runs commands without asking. */
|
|
1908
|
+
bypass: z.boolean(),
|
|
1909
|
+
head: z.string(),
|
|
1910
|
+
/** The checkout the agent works in. */
|
|
1911
|
+
cwd: z.string(),
|
|
1912
|
+
/** Absent until the agent says, or when it offers no choice. */
|
|
1913
|
+
choices: askChoicesSchema.optional(),
|
|
1914
|
+
/** Absent until the agent says, or when it takes none. */
|
|
1915
|
+
commands: z.array(askCommandSchema).optional(),
|
|
1916
|
+
accepts: askAcceptsSchema.optional(),
|
|
1917
|
+
usage: askUsageSchema.optional(),
|
|
1918
|
+
/** What the agent calls the conversation. */
|
|
1919
|
+
title: z.string().optional(),
|
|
1920
|
+
selection: z.object({
|
|
1921
|
+
title: z.string(),
|
|
1922
|
+
quote: z.string().optional()
|
|
1923
|
+
}),
|
|
1924
|
+
entries: z.array(askEntrySchema)
|
|
1925
|
+
});
|
|
1926
|
+
/** One change to a thread. Answer text arrives as `append`, so a streamed
|
|
1927
|
+
* token costs its own length, not the thread's. */
|
|
1928
|
+
const askChangeSchema = z.discriminatedUnion("type", [
|
|
1929
|
+
z.object({
|
|
1930
|
+
type: z.literal("set"),
|
|
1931
|
+
status: statusSchema.optional(),
|
|
1932
|
+
readOnly: z.boolean().optional(),
|
|
1933
|
+
bypass: z.boolean().optional(),
|
|
1934
|
+
choices: askChoicesSchema.optional(),
|
|
1935
|
+
commands: z.array(askCommandSchema).optional(),
|
|
1936
|
+
accepts: askAcceptsSchema.optional(),
|
|
1937
|
+
usage: askUsageSchema.optional(),
|
|
1938
|
+
title: z.string().optional(),
|
|
1939
|
+
/** `null` clears the error; absent leaves it. */
|
|
1940
|
+
error: z.string().nullable().optional(),
|
|
1941
|
+
/** `null` clears it; absent leaves it. */
|
|
1942
|
+
signIn: z.string().nullable().optional()
|
|
1943
|
+
}),
|
|
1944
|
+
z.object({
|
|
1945
|
+
type: z.literal("add"),
|
|
1946
|
+
entry: askEntrySchema
|
|
1947
|
+
}),
|
|
1948
|
+
z.object({
|
|
1949
|
+
type: z.literal("entry"),
|
|
1950
|
+
entry: askEntrySchema
|
|
1951
|
+
}),
|
|
1952
|
+
z.object({
|
|
1953
|
+
type: z.literal("append"),
|
|
1954
|
+
id: z.string(),
|
|
1955
|
+
text: z.string()
|
|
1956
|
+
}),
|
|
1957
|
+
z.object({
|
|
1958
|
+
type: z.literal("remove"),
|
|
1959
|
+
ids: z.array(z.string())
|
|
1960
|
+
})
|
|
1961
|
+
]);
|
|
1962
|
+
z.union([z.object({
|
|
1963
|
+
seq: z.number().int().nonnegative(),
|
|
1964
|
+
snapshot: askThreadStateSchema
|
|
1965
|
+
}), z.object({
|
|
1966
|
+
seq: z.number().int().positive(),
|
|
1967
|
+
change: askChangeSchema
|
|
1968
|
+
})]);
|
|
1969
|
+
/** The server and the panel both advance a thread with this, so they agree. */
|
|
1970
|
+
function applyAskChange(state, change) {
|
|
1971
|
+
switch (change.type) {
|
|
1972
|
+
case "set": {
|
|
1973
|
+
const { error: _error, signIn: _signIn, ...rest } = state;
|
|
1974
|
+
const next = {
|
|
1975
|
+
...rest,
|
|
1976
|
+
status: change.status ?? state.status,
|
|
1977
|
+
readOnly: change.readOnly ?? state.readOnly,
|
|
1978
|
+
bypass: change.bypass ?? state.bypass
|
|
1979
|
+
};
|
|
1980
|
+
const choices = change.choices ?? state.choices;
|
|
1981
|
+
if (choices) next.choices = choices;
|
|
1982
|
+
const commands = change.commands ?? state.commands;
|
|
1983
|
+
if (commands) next.commands = commands;
|
|
1984
|
+
const accepts = change.accepts ?? state.accepts;
|
|
1985
|
+
if (accepts) next.accepts = accepts;
|
|
1986
|
+
const usage = change.usage ?? state.usage;
|
|
1987
|
+
if (usage) next.usage = usage;
|
|
1988
|
+
const title = change.title ?? state.title;
|
|
1989
|
+
if (title) next.title = title;
|
|
1990
|
+
const error = change.error === void 0 ? state.error : change.error;
|
|
1991
|
+
if (error) next.error = error;
|
|
1992
|
+
const signIn = change.signIn === void 0 ? state.signIn : change.signIn;
|
|
1993
|
+
if (signIn) next.signIn = signIn;
|
|
1994
|
+
return next;
|
|
1995
|
+
}
|
|
1996
|
+
case "add": return {
|
|
1997
|
+
...state,
|
|
1998
|
+
entries: [...state.entries, change.entry]
|
|
1999
|
+
};
|
|
2000
|
+
case "entry": return {
|
|
2001
|
+
...state,
|
|
2002
|
+
entries: state.entries.map((entry) => entry.id === change.entry.id && entry.kind === change.entry.kind ? change.entry : entry)
|
|
2003
|
+
};
|
|
2004
|
+
case "remove": {
|
|
2005
|
+
const ids = new Set(change.ids);
|
|
2006
|
+
return {
|
|
2007
|
+
...state,
|
|
2008
|
+
entries: state.entries.filter((entry) => !ids.has(entry.id))
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
case "append": return {
|
|
2012
|
+
...state,
|
|
2013
|
+
entries: state.entries.map((entry) => entry.kind === "agent" && entry.id === change.id ? {
|
|
2014
|
+
...entry,
|
|
2015
|
+
text: entry.text + change.text
|
|
2016
|
+
} : entry)
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
//#endregion
|
|
2021
|
+
//#region src/review-api/ask-history.ts
|
|
2022
|
+
/** A saved Ask conversation: its history entry, and how to reopen it. */
|
|
2023
|
+
const askRecordSchema = z.object({
|
|
2024
|
+
id: z.string(),
|
|
2025
|
+
agent: z.enum(askAgentIds),
|
|
2026
|
+
/** What the agent calls it, else the first question. */
|
|
2027
|
+
title: z.string(),
|
|
2028
|
+
/** The first saved user question, independent of the agent's title. */
|
|
2029
|
+
question: z.string().optional(),
|
|
2030
|
+
selection: AgentSelectionSchema,
|
|
2031
|
+
/** The commit the agent read. */
|
|
2032
|
+
head: z.string(),
|
|
2033
|
+
createdAt: z.string(),
|
|
2034
|
+
updatedAt: z.string()
|
|
2035
|
+
}).extend({
|
|
2036
|
+
reviewId: z.string(),
|
|
2037
|
+
/** The ACP session the agent keeps the transcript under. */
|
|
2038
|
+
sessionId: z.string(),
|
|
2039
|
+
/** The review version asked about; its pins recreate the checkout. */
|
|
2040
|
+
version: z.number().int(),
|
|
2041
|
+
cwd: z.string(),
|
|
2042
|
+
/** What the panel showed last; absent until a turn ends or it closes. */
|
|
2043
|
+
entries: z.array(askEntrySchema).optional(),
|
|
2044
|
+
/** The agent edits and runs commands without asking. */
|
|
2045
|
+
bypass: z.boolean().optional()
|
|
2046
|
+
});
|
|
2047
|
+
const rowSchema = z.object({
|
|
2048
|
+
id: z.string(),
|
|
2049
|
+
review_id: z.string(),
|
|
2050
|
+
agent: z.string(),
|
|
2051
|
+
session_id: z.string(),
|
|
2052
|
+
version: z.number(),
|
|
2053
|
+
head: z.string(),
|
|
2054
|
+
cwd: z.string(),
|
|
2055
|
+
selection: z.string(),
|
|
2056
|
+
title: z.string(),
|
|
2057
|
+
created_at: z.string(),
|
|
2058
|
+
updated_at: z.string(),
|
|
2059
|
+
entries: z.string().nullable(),
|
|
2060
|
+
bypass: z.number()
|
|
2061
|
+
}).transform((row) => askRecordSchema.parse({
|
|
2062
|
+
id: row.id,
|
|
2063
|
+
reviewId: row.review_id,
|
|
2064
|
+
agent: row.agent,
|
|
2065
|
+
sessionId: row.session_id,
|
|
2066
|
+
version: row.version,
|
|
2067
|
+
head: row.head,
|
|
2068
|
+
cwd: row.cwd,
|
|
2069
|
+
selection: JSON.parse(row.selection),
|
|
2070
|
+
title: row.title,
|
|
2071
|
+
createdAt: row.created_at,
|
|
2072
|
+
updatedAt: row.updated_at,
|
|
2073
|
+
entries: row.entries === null ? void 0 : JSON.parse(row.entries),
|
|
2074
|
+
bypass: row.bypass === 1
|
|
2075
|
+
}));
|
|
2076
|
+
/** Saved Ask conversations, per review. Shared reviews are not in
|
|
2077
|
+
* `reviews`, so there is no foreign key; deleting a review deletes its rows. */
|
|
2078
|
+
var AskHistory = class {
|
|
2079
|
+
db;
|
|
2080
|
+
constructor(db) {
|
|
2081
|
+
this.db = db;
|
|
2082
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
|
|
2083
|
+
id TEXT PRIMARY KEY,
|
|
2084
|
+
review_id TEXT NOT NULL,
|
|
2085
|
+
agent TEXT NOT NULL,
|
|
2086
|
+
session_id TEXT NOT NULL,
|
|
2087
|
+
version INTEGER NOT NULL,
|
|
2088
|
+
head TEXT NOT NULL,
|
|
2089
|
+
cwd TEXT NOT NULL,
|
|
2090
|
+
selection TEXT NOT NULL,
|
|
2091
|
+
title TEXT NOT NULL,
|
|
2092
|
+
created_at TEXT NOT NULL,
|
|
2093
|
+
updated_at TEXT NOT NULL,
|
|
2094
|
+
entries TEXT,
|
|
2095
|
+
bypass INTEGER NOT NULL DEFAULT 0,
|
|
2096
|
+
UNIQUE(agent, session_id));
|
|
2097
|
+
CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
|
|
2098
|
+
if (!db.prepare("PRAGMA table_info(ask_conversations)").all().some((column) => String(column.name) === "bypass")) db.exec("ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0");
|
|
2099
|
+
db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
|
|
2100
|
+
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))");
|
|
2101
|
+
}
|
|
2102
|
+
save(record) {
|
|
2103
|
+
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)
|
|
2104
|
+
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.bypass ? 1 : 0);
|
|
2105
|
+
}
|
|
2106
|
+
/** Whether reopening the conversation bypasses permissions. */
|
|
2107
|
+
setBypass(id, bypass) {
|
|
2108
|
+
this.db.prepare("UPDATE ask_conversations SET bypass=? WHERE id=?").run(bypass ? 1 : 0, id);
|
|
2109
|
+
}
|
|
2110
|
+
/** Points a conversation at a new session, when its agent could not
|
|
2111
|
+
* reopen the one it had. */
|
|
2112
|
+
updateSession(id, sessionId) {
|
|
2113
|
+
this.db.prepare("UPDATE ask_conversations SET session_id=? WHERE id=?").run(sessionId, id);
|
|
2114
|
+
}
|
|
2115
|
+
/** Keeps what the panel shows, so a reopen need not wait on the agent. */
|
|
2116
|
+
saveEntries(id, entries) {
|
|
2117
|
+
this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
|
|
2118
|
+
}
|
|
2119
|
+
/** The agent's name for the conversation, which the list shows. */
|
|
2120
|
+
rename(id, title) {
|
|
2121
|
+
this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
|
|
2122
|
+
}
|
|
2123
|
+
saveOffer(agent, offer) {
|
|
2124
|
+
this.db.prepare("INSERT OR REPLACE INTO ask_agent_offers(agent, offer) VALUES(?, ?)").run(agent, JSON.stringify(offer));
|
|
2125
|
+
this.saveModelOffer(agent, offer);
|
|
2126
|
+
}
|
|
2127
|
+
/** Keeps what the agent offers with the offer's model, without making
|
|
2128
|
+
* it what the agent offered last. */
|
|
2129
|
+
saveModelOffer(agent, offer) {
|
|
2130
|
+
const model = offer.choices.model?.current;
|
|
2131
|
+
if (!model) return;
|
|
2132
|
+
this.db.prepare("INSERT OR REPLACE INTO ask_agent_model_offers(agent, model, offer) VALUES(?, ?, ?)").run(agent, model, JSON.stringify(offer));
|
|
2133
|
+
}
|
|
2134
|
+
/** What the agent offered last, or last with the model given. */
|
|
2135
|
+
offer(agent, model) {
|
|
2136
|
+
const row = z.object({ offer: z.string() }).safeParse(model === void 0 ? this.db.prepare("SELECT offer FROM ask_agent_offers WHERE agent=?").get(agent) : this.db.prepare("SELECT offer FROM ask_agent_model_offers WHERE agent=? AND model=?").get(agent, model)).data;
|
|
2137
|
+
return row && askOfferSchema.safeParse(JSON.parse(row.offer)).data;
|
|
2138
|
+
}
|
|
2139
|
+
touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2140
|
+
this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
|
|
2141
|
+
}
|
|
2142
|
+
/** Newest first. */
|
|
2143
|
+
list(reviewId) {
|
|
2144
|
+
return this.db.prepare("SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC").all(reviewId).map((row) => {
|
|
2145
|
+
const { reviewId: _reviewId, sessionId: _sessionId, version: _version, cwd: _cwd, entries, bypass: _bypass, ...entry } = rowSchema.parse(row);
|
|
2146
|
+
const question = entries?.find((entry) => entry.kind === "user")?.text;
|
|
2147
|
+
if (question?.trim()) entry.question = question;
|
|
2148
|
+
return entry;
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
get(id) {
|
|
2152
|
+
const row = this.db.prepare("SELECT * FROM ask_conversations WHERE id=?").get(id);
|
|
2153
|
+
return row ? rowSchema.parse(row) : void 0;
|
|
2154
|
+
}
|
|
2155
|
+
delete(id) {
|
|
2156
|
+
this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
//#endregion
|
|
2160
|
+
//#region src/review-api/origin.ts
|
|
2161
|
+
const pullRequestUrl = z.string().regex(/^https:\/\/[a-z0-9.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9]\d*$/, "Use a canonical GitHub PR URL: https://github.com/owner/repository/pull/123, or the same path on a GitHub Enterprise host.").refine((url) => Number.isSafeInteger(Number(url.split("/").at(-1))), "PR number is too large.");
|
|
2162
|
+
/** One key per PR: GitHub owner and repository names are case-insensitive,
|
|
2163
|
+
* and the URL pattern admits only ASCII, so lowercasing is canonical. */
|
|
2164
|
+
function pullRequestKey(url) {
|
|
2165
|
+
return url.toLowerCase();
|
|
2166
|
+
}
|
|
2167
|
+
/** Omission preserves identity; null detaches it without changing import metadata. */
|
|
2168
|
+
function setPullRequest(snapshot, url) {
|
|
2169
|
+
if (url === void 0) return;
|
|
2170
|
+
if (url === null) {
|
|
2171
|
+
if (snapshot.origin) {
|
|
2172
|
+
delete snapshot.origin.pullRequestUrl;
|
|
2173
|
+
delete snapshot.origin.pullRequestNumber;
|
|
2174
|
+
}
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
snapshot.origin = {
|
|
2178
|
+
...snapshot.origin,
|
|
2179
|
+
pullRequestUrl: url,
|
|
2180
|
+
pullRequestNumber: Number(url.split("/").at(-1))
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
//#endregion
|
|
2184
|
+
//#region src/review-api/store-schema.ts
|
|
2185
|
+
function initializeReviewStoreSchema(db) {
|
|
2186
|
+
db.exec(`
|
|
2187
|
+
PRAGMA journal_mode=WAL;
|
|
2188
|
+
PRAGMA foreign_keys=ON;
|
|
2189
|
+
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL);
|
|
2190
|
+
CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
|
|
2191
|
+
PRIMARY KEY(review_id,version));
|
|
2192
|
+
DROP TABLE IF EXISTS receipts;
|
|
2193
|
+
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, dismissed_at TEXT);
|
|
2194
|
+
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
|
|
2195
|
+
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
|
|
2196
|
+
kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
|
|
2197
|
+
CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
|
|
2198
|
+
PRIMARY KEY(review_id,file));
|
|
2199
|
+
DROP TABLE IF EXISTS review_viewed;
|
|
2200
|
+
CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
|
|
2201
|
+
CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
|
|
2202
|
+
DROP TABLE IF EXISTS authoring_drafts;
|
|
2203
|
+
`);
|
|
2204
|
+
}
|
|
2205
|
+
//#endregion
|
|
2206
|
+
//#region src/review-api/store.ts
|
|
2207
|
+
const reviewId = z.string().min(1);
|
|
2208
|
+
/** There is one scratchpad. Its id is fixed so a skill can name it. */
|
|
2209
|
+
const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
|
|
2210
|
+
const DIAGRAM_TYPES = /* @__PURE__ */ new Set([
|
|
2211
|
+
"sequence",
|
|
2212
|
+
"flow_diagram",
|
|
2213
|
+
"call_stack_diff",
|
|
2214
|
+
"database_lens",
|
|
2215
|
+
"software_map"
|
|
2216
|
+
]);
|
|
2217
|
+
const SCRATCHPAD_TITLE = "Scratchpad";
|
|
2218
|
+
const activityId = z.string().min(1).optional().describe("The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.");
|
|
2219
|
+
const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
|
|
2220
|
+
z.strictObject({
|
|
2221
|
+
type: z.literal("delete"),
|
|
2222
|
+
reviewId
|
|
2223
|
+
}),
|
|
2224
|
+
z.strictObject({
|
|
2225
|
+
type: z.literal("attention"),
|
|
2226
|
+
reviewId,
|
|
2227
|
+
action: z.enum([
|
|
2228
|
+
"view",
|
|
2229
|
+
"dismiss",
|
|
2230
|
+
"restore"
|
|
2231
|
+
])
|
|
2232
|
+
}),
|
|
2233
|
+
z.strictObject({
|
|
2234
|
+
type: z.literal("create"),
|
|
2235
|
+
/** Required unless pullRequestUrl alone names the source; then the PR title. */
|
|
2236
|
+
title: z.string().trim().min(1).optional(),
|
|
2237
|
+
target: reviewTargetSchema.optional(),
|
|
2238
|
+
pullRequestUrl: pullRequestUrl.optional(),
|
|
2239
|
+
/** With pullRequestUrl and no target: the checkout to fetch the PR into. */
|
|
2240
|
+
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."),
|
|
2241
|
+
/** Return the existing review for pullRequestUrl instead of creating one. */
|
|
2242
|
+
reuseExisting: z.boolean().optional().describe("Default true: return the existing review for pullRequestUrl. false creates a separate review."),
|
|
2243
|
+
/** The one scratchpad: no target, no pins; every reference names its own. */
|
|
2244
|
+
kind: z.literal("scratchpad").optional()
|
|
2245
|
+
}),
|
|
2246
|
+
z.strictObject({
|
|
2247
|
+
type: z.literal("set_target"),
|
|
2248
|
+
reviewId,
|
|
2249
|
+
target: reviewTargetSchema,
|
|
2250
|
+
pullRequestUrl: pullRequestUrl.nullable().optional()
|
|
2251
|
+
}),
|
|
2252
|
+
z.strictObject({
|
|
2253
|
+
type: z.literal("edit"),
|
|
2254
|
+
reviewId,
|
|
2255
|
+
edit: editSchema,
|
|
2256
|
+
activityId
|
|
2257
|
+
}),
|
|
2258
|
+
z.strictObject({
|
|
2259
|
+
type: z.literal("lens_edit"),
|
|
2260
|
+
reviewId,
|
|
2261
|
+
edit: lensEditSchema,
|
|
2262
|
+
activityId
|
|
2263
|
+
}),
|
|
2264
|
+
z.strictObject({
|
|
2265
|
+
type: z.literal("rename"),
|
|
2266
|
+
reviewId,
|
|
2267
|
+
title: z.string().trim().min(1)
|
|
2268
|
+
}),
|
|
2269
|
+
z.strictObject({
|
|
2270
|
+
type: z.literal("restore"),
|
|
2271
|
+
reviewId,
|
|
2272
|
+
version: z.number().int().nonnegative()
|
|
2273
|
+
})
|
|
2274
|
+
]) });
|
|
2275
|
+
/** One instance owned by the desktop server. All writers go through execute().
|
|
2276
|
+
* The queue includes async validation; SQLite transactions contain only writes.
|
|
2277
|
+
* This prototype uses a new, explicitly supplied database, never an existing profile.
|
|
2278
|
+
*/
|
|
2279
|
+
var ReviewStore = class {
|
|
2280
|
+
providers;
|
|
2281
|
+
activity;
|
|
2282
|
+
askHistory;
|
|
2283
|
+
db;
|
|
2284
|
+
pending = Promise.resolve();
|
|
2285
|
+
closing = false;
|
|
2286
|
+
liveSources = /* @__PURE__ */ new Map();
|
|
2287
|
+
refreshTimer;
|
|
2288
|
+
refreshSubscribers = 0;
|
|
2289
|
+
watchWorktrees() {
|
|
2290
|
+
this.refreshSubscribers++;
|
|
2291
|
+
this.refreshTimer ??= setInterval(() => {
|
|
2292
|
+
this.refreshWorktrees();
|
|
2293
|
+
}, 1e3);
|
|
2294
|
+
this.refreshTimer.unref();
|
|
2295
|
+
return () => {
|
|
2296
|
+
if (--this.refreshSubscribers === 0) {
|
|
2297
|
+
clearInterval(this.refreshTimer);
|
|
2298
|
+
this.refreshTimer = void 0;
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
async projectLiveSource(snapshot, current = snapshot) {
|
|
2303
|
+
if (snapshot.target?.kind !== "worktree") {
|
|
2304
|
+
await this.providers.sourcePins?.(snapshot);
|
|
2305
|
+
const unavailable = await this.providers.unavailableAnchors?.(snapshot) ?? [];
|
|
2306
|
+
const stale = [.../* @__PURE__ */ new Set([...snapshot.staleSources ?? [], ...unavailable])];
|
|
2307
|
+
const staleChanged = JSON.stringify(stale) !== JSON.stringify(current.staleSources ?? []);
|
|
2308
|
+
if (!current.sourceUnavailable && !staleChanged) return current;
|
|
2309
|
+
const projected = {
|
|
2310
|
+
...current,
|
|
2311
|
+
sourceUnavailable: void 0
|
|
2312
|
+
};
|
|
2313
|
+
if (stale.length) projected.staleSources = stale;
|
|
2314
|
+
else delete projected.staleSources;
|
|
2315
|
+
return projected;
|
|
2316
|
+
}
|
|
2317
|
+
const { pins } = await this.providers.resolveTarget(snapshot.target, current.pins);
|
|
2318
|
+
if (JSON.stringify(current.pins) === JSON.stringify(pins) && !current.sourceUnavailable) return current;
|
|
2319
|
+
return this.providers.projectSource ? this.providers.projectSource(snapshot, pins) : {
|
|
2320
|
+
...snapshot,
|
|
2321
|
+
pins,
|
|
2322
|
+
sourceUnavailable: void 0
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
refreshPending;
|
|
2326
|
+
/** Refresh source state without writing authored document versions. Serialized with edits. */
|
|
2327
|
+
refreshWorktrees() {
|
|
2328
|
+
if (this.closing || !this.providers.resolveTarget) return Promise.resolve();
|
|
2329
|
+
if (this.refreshPending) return this.refreshPending;
|
|
2330
|
+
const run = this.pending.then(async () => {
|
|
2331
|
+
for (const summary of this.list()) {
|
|
2332
|
+
const snapshot = this.read(summary.reviewId, summary.version);
|
|
2333
|
+
try {
|
|
2334
|
+
const live = this.liveSources.get(summary.reviewId);
|
|
2335
|
+
const last = live?.version === snapshot.version ? live : snapshot;
|
|
2336
|
+
const previous = last.pins;
|
|
2337
|
+
const projected = await this.projectLiveSource(snapshot, last);
|
|
2338
|
+
if (projected === last) continue;
|
|
2339
|
+
this.liveSources.set(snapshot.reviewId, projected);
|
|
2340
|
+
if (JSON.stringify(previous) !== JSON.stringify(projected.pins) || JSON.stringify(last.staleSources ?? []) !== JSON.stringify(projected.staleSources ?? []) || last.sourceUnavailable) this.notify({
|
|
2341
|
+
reviewId: snapshot.reviewId,
|
|
2342
|
+
version: snapshot.version
|
|
2343
|
+
});
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
if (error instanceof ReviewInputError && error.status === 404) {
|
|
2346
|
+
const last = this.read(snapshot.reviewId);
|
|
2347
|
+
if (!last.sourceUnavailable) {
|
|
2348
|
+
this.liveSources.set(snapshot.reviewId, {
|
|
2349
|
+
...last,
|
|
2350
|
+
sourceUnavailable: true
|
|
2351
|
+
});
|
|
2352
|
+
this.notify({
|
|
2353
|
+
reviewId: snapshot.reviewId,
|
|
2354
|
+
version: snapshot.version
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
});
|
|
2361
|
+
this.pending = run.catch(() => {});
|
|
2362
|
+
this.refreshPending = run.finally(() => {
|
|
2363
|
+
this.refreshPending = void 0;
|
|
2364
|
+
});
|
|
2365
|
+
return this.refreshPending;
|
|
2366
|
+
}
|
|
2367
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2368
|
+
catalogListeners = /* @__PURE__ */ new Set();
|
|
2369
|
+
externalChanges;
|
|
2370
|
+
observedDataVersion;
|
|
2371
|
+
observedVersions = /* @__PURE__ */ new Map();
|
|
2372
|
+
subscribeCatalog(listener) {
|
|
2373
|
+
this.catalogListeners.add(listener);
|
|
2374
|
+
return () => {
|
|
2375
|
+
this.catalogListeners.delete(listener);
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
/** For a host whose listing changed without the store: a preference flip. */
|
|
2379
|
+
invalidateCatalog() {
|
|
2380
|
+
for (const listener of this.catalogListeners) try {
|
|
2381
|
+
listener();
|
|
2382
|
+
} catch {}
|
|
2383
|
+
}
|
|
2384
|
+
subscribe(listener) {
|
|
2385
|
+
this.listeners.add(listener);
|
|
2386
|
+
return () => {
|
|
2387
|
+
this.listeners.delete(listener);
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
constructor(databasePath, providers) {
|
|
2391
|
+
this.providers = providers;
|
|
2392
|
+
this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
2393
|
+
initializeReviewStoreSchema(this.db);
|
|
2394
|
+
this.activity = new ReviewActivity(this.db, (id) => this.assertExists(id));
|
|
2395
|
+
this.askHistory = new AskHistory(this.db);
|
|
2396
|
+
this.observedDataVersion = this.dataVersion();
|
|
2397
|
+
this.observedVersions = this.currentVersions();
|
|
2398
|
+
this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
|
|
2399
|
+
this.externalChanges.unref();
|
|
2400
|
+
}
|
|
2401
|
+
dataVersion() {
|
|
2402
|
+
return Number(this.db.prepare("PRAGMA data_version").get().data_version);
|
|
2403
|
+
}
|
|
2404
|
+
currentVersions() {
|
|
2405
|
+
return new Map(this.db.prepare("SELECT id,version FROM reviews").all().map((row) => [String(row.id), Number(row.version)]));
|
|
2406
|
+
}
|
|
2407
|
+
refreshExternalChanges() {
|
|
2408
|
+
const version = this.dataVersion();
|
|
2409
|
+
if (version === this.observedDataVersion) return;
|
|
2410
|
+
this.observedDataVersion = version;
|
|
2411
|
+
const current = this.currentVersions();
|
|
2412
|
+
const previous = this.observedVersions;
|
|
2413
|
+
this.observedVersions = current;
|
|
2414
|
+
for (const [reviewId, savedVersion] of current) if (previous.get(reviewId) !== savedVersion) this.notify({
|
|
2415
|
+
reviewId,
|
|
2416
|
+
version: savedVersion
|
|
2417
|
+
});
|
|
2418
|
+
for (const [reviewId, savedVersion] of previous) if (!current.has(reviewId)) {
|
|
2419
|
+
this.activity.deleted(reviewId);
|
|
2420
|
+
this.notify({
|
|
2421
|
+
reviewId,
|
|
2422
|
+
version: savedVersion,
|
|
2423
|
+
deleted: true
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
this.activity.refresh();
|
|
2427
|
+
for (const listener of this.catalogListeners) try {
|
|
2428
|
+
listener();
|
|
2429
|
+
} catch {}
|
|
2430
|
+
}
|
|
2431
|
+
/** Reader progress never creates a document version or authoring event. */
|
|
2432
|
+
viewedCoverage(reviewId) {
|
|
2433
|
+
this.assertExists(reviewId);
|
|
2434
|
+
return new Map(this.db.prepare("SELECT file,fingerprint,coverage FROM review_coverage WHERE review_id=?").all(reviewId).map((row) => [String(row.file), {
|
|
2435
|
+
fingerprint: String(row.fingerprint),
|
|
2436
|
+
coverage: coverageSchema.parse(JSON.parse(String(row.coverage)))
|
|
2437
|
+
}]));
|
|
2438
|
+
}
|
|
2439
|
+
updateViewedCoverage(reviewId, files, viewed) {
|
|
2440
|
+
this.assertExists(reviewId);
|
|
2441
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2442
|
+
try {
|
|
2443
|
+
const current = this.viewedCoverage(reviewId);
|
|
2444
|
+
for (const file of files) {
|
|
2445
|
+
const previous = current.get(file.path);
|
|
2446
|
+
const coverage = updateCoverage(previous?.fingerprint === file.fingerprint ? previous.coverage : emptyCoverage(), file.scope, viewed);
|
|
2447
|
+
this.db.prepare("INSERT OR REPLACE INTO review_coverage(review_id,file,fingerprint,coverage) VALUES(?,?,?,?)").run(reviewId, file.path, file.fingerprint, JSON.stringify(coverage));
|
|
2448
|
+
}
|
|
2449
|
+
this.db.exec("COMMIT");
|
|
2450
|
+
} catch (error) {
|
|
2451
|
+
this.db.exec("ROLLBACK");
|
|
2452
|
+
throw error;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
repositoryGroups = /* @__PURE__ */ new Map();
|
|
2456
|
+
repositoryGroup(root) {
|
|
2457
|
+
if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
|
|
2458
|
+
const context = resolveRepoContextSync(root);
|
|
2459
|
+
if (!context) return void 0;
|
|
2460
|
+
const group = context.githubSlug ? {
|
|
2461
|
+
key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
|
|
2462
|
+
label: context.githubSlug
|
|
2463
|
+
} : {
|
|
2464
|
+
key: `git:${context.commonDir}`,
|
|
2465
|
+
label: path.basename(path.dirname(context.commonDir))
|
|
2466
|
+
};
|
|
2467
|
+
this.repositoryGroups.set(root, group);
|
|
2468
|
+
return group;
|
|
2469
|
+
}
|
|
2470
|
+
registerRepository(root) {
|
|
2471
|
+
this.db.prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)").run(randomUUID(), root, root.split(/[\\/]/).at(-1));
|
|
2472
|
+
const row = this.db.prepare("SELECT id,name FROM repositories WHERE path=?").get(root);
|
|
2473
|
+
return {
|
|
2474
|
+
id: String(row.id),
|
|
2475
|
+
name: String(row.name)
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
unregisterRepository(id) {
|
|
2479
|
+
this.db.prepare(`DELETE FROM repositories WHERE id=?
|
|
2480
|
+
AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
2481
|
+
AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`).run(id, `"repositoryId":${JSON.stringify(id)}`, id);
|
|
2482
|
+
}
|
|
2483
|
+
/** Registered checkouts, oldest registration first. */
|
|
2484
|
+
repositories() {
|
|
2485
|
+
return this.db.prepare("SELECT id,path FROM repositories ORDER BY rowid").all().map((row) => ({
|
|
2486
|
+
id: String(row.id),
|
|
2487
|
+
path: String(row.path)
|
|
2488
|
+
}));
|
|
2489
|
+
}
|
|
2490
|
+
repositoryPath(id) {
|
|
2491
|
+
const row = this.db.prepare("SELECT path FROM repositories WHERE id=?").get(id);
|
|
2492
|
+
if (!row) throw new ReviewInputError("Repository is not registered.", 404);
|
|
2493
|
+
return String(row.path);
|
|
2494
|
+
}
|
|
2495
|
+
putResource(id, repositoryId, kind, mimeType, data) {
|
|
2496
|
+
this.db.prepare("INSERT OR IGNORE INTO resources(id,repository_id,kind,mime_type,data) VALUES(?,?,?,?,?)").run(id, repositoryId, kind, mimeType, data);
|
|
2497
|
+
const saved = this.resource(id);
|
|
2498
|
+
if (saved.repositoryId !== repositoryId || saved.kind !== kind || saved.mimeType !== mimeType || !Buffer.from(saved.data).equals(data)) throw new ReviewInputError("Resource ID was already used for different content.", 409);
|
|
2499
|
+
return {
|
|
2500
|
+
id,
|
|
2501
|
+
kind,
|
|
2502
|
+
mimeType
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
resource(id) {
|
|
2506
|
+
const row = this.db.prepare("SELECT * FROM resources WHERE id=?").get(id);
|
|
2507
|
+
if (!row) throw new ReviewInputError("Resource not found.", 404);
|
|
2508
|
+
return {
|
|
2509
|
+
id,
|
|
2510
|
+
repositoryId: String(row.repository_id),
|
|
2511
|
+
kind: String(row.kind),
|
|
2512
|
+
mimeType: String(row.mime_type),
|
|
2513
|
+
data: row.data
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
async close() {
|
|
2517
|
+
this.closing = true;
|
|
2518
|
+
clearInterval(this.externalChanges);
|
|
2519
|
+
clearInterval(this.refreshTimer);
|
|
2520
|
+
await this.pending;
|
|
2521
|
+
this.listeners.clear();
|
|
2522
|
+
this.catalogListeners.clear();
|
|
2523
|
+
this.activity.close();
|
|
2524
|
+
this.db.close();
|
|
2525
|
+
}
|
|
2526
|
+
/** Stable across restarts; the first host on a new store chooses it. */
|
|
2527
|
+
serverId() {
|
|
2528
|
+
const read = () => this.db.prepare("SELECT id FROM server_identity").get()?.id;
|
|
2529
|
+
let id = read();
|
|
2530
|
+
if (id === void 0) {
|
|
2531
|
+
this.db.prepare("INSERT OR IGNORE INTO server_identity(one,id) VALUES(1,?)").run(randomUUID());
|
|
2532
|
+
id = read();
|
|
2533
|
+
}
|
|
2534
|
+
return String(id);
|
|
2535
|
+
}
|
|
2536
|
+
resetServerId() {
|
|
2537
|
+
const id = randomUUID();
|
|
2538
|
+
this.db.prepare("INSERT OR REPLACE INTO server_identity(one,id) VALUES(1,?)").run(id);
|
|
2539
|
+
return id;
|
|
2540
|
+
}
|
|
2541
|
+
/** The 404 check alone, without loading a snapshot. */
|
|
2542
|
+
assertExists(id) {
|
|
2543
|
+
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);
|
|
2544
|
+
}
|
|
2545
|
+
read(id, version) {
|
|
2546
|
+
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);
|
|
2547
|
+
if (!row) {
|
|
2548
|
+
this.assertExists(id);
|
|
2549
|
+
throw new ReviewInputError("Review version not found.", 404);
|
|
2550
|
+
}
|
|
2551
|
+
const snapshot = JSON.parse(String(row.snapshot));
|
|
2552
|
+
const { document, lenses } = liftFileLenses(migrateStoredDocument(snapshot.document));
|
|
2553
|
+
snapshot.document = document;
|
|
2554
|
+
if (snapshot.lenses) snapshot.lenses = migrateStoredLenses(snapshot.lenses);
|
|
2555
|
+
if (lenses.length) snapshot.lenses = [...snapshot.lenses ?? [], ...lenses];
|
|
2556
|
+
if (snapshot.pins) snapshot.target ??= {
|
|
2557
|
+
kind: "commits",
|
|
2558
|
+
repositoryId: snapshot.pins.repositoryId,
|
|
2559
|
+
base: snapshot.pins.base,
|
|
2560
|
+
head: snapshot.pins.head
|
|
2561
|
+
};
|
|
2562
|
+
const live = version === void 0 ? this.liveSources.get(id) : void 0;
|
|
2563
|
+
if (live?.version === snapshot.version) return structuredClone(live);
|
|
2564
|
+
return snapshot;
|
|
2565
|
+
}
|
|
2566
|
+
setDiffStats(pins, stats, mode = "structural") {
|
|
2567
|
+
if (this.closing) return;
|
|
2568
|
+
this.db.prepare("INSERT OR REPLACE INTO comparison_stats(identity, stats) VALUES (?, ?)").run(JSON.stringify([pins, mode]), JSON.stringify(stats));
|
|
2569
|
+
for (const listener of this.catalogListeners) try {
|
|
2570
|
+
listener();
|
|
2571
|
+
} catch {}
|
|
2572
|
+
}
|
|
2573
|
+
/** Managed records are discoverable even if their preparation stamp was lost. */
|
|
2574
|
+
tutorialIds() {
|
|
2575
|
+
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));
|
|
2576
|
+
}
|
|
2577
|
+
list(mode = "structural") {
|
|
2578
|
+
return this.summaries(mode);
|
|
2579
|
+
}
|
|
2580
|
+
/** One review's catalog entry, as review_list shows it. */
|
|
2581
|
+
summary(id) {
|
|
2582
|
+
return this.summaries("structural", id)[0];
|
|
2583
|
+
}
|
|
2584
|
+
summaries(mode, id) {
|
|
2585
|
+
const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
2586
|
+
(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,
|
|
2587
|
+
review_attention.viewed_at, review_attention.dismissed_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
2588
|
+
FROM reviews
|
|
2589
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2590
|
+
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
2591
|
+
LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
|
|
2592
|
+
WHERE COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
|
|
2593
|
+
${id === void 0 ? "" : "AND reviews.id=?"}
|
|
2594
|
+
ORDER BY reviews.rowid`).all(...id === void 0 ? [] : [id]).map((row) => {
|
|
2595
|
+
const summary = JSON.parse(String(row.summary));
|
|
2596
|
+
if (summary.pins) summary.target ??= {
|
|
2597
|
+
kind: "commits",
|
|
2598
|
+
repositoryId: summary.pins.repositoryId,
|
|
2599
|
+
base: summary.pins.base,
|
|
2600
|
+
head: summary.pins.head
|
|
2601
|
+
};
|
|
2602
|
+
const live = this.liveSources.get(summary.reviewId);
|
|
2603
|
+
if (live?.version === summary.version && summary.target?.kind === "worktree") summary.pins = live.pins;
|
|
2604
|
+
const listed = {
|
|
2605
|
+
...summary,
|
|
2606
|
+
firstCreatedAt: row.first_created_at ? String(row.first_created_at) : void 0,
|
|
2607
|
+
repositoryPath: row.repository_path ? String(row.repository_path) : void 0,
|
|
2608
|
+
repositoryGroup: row.repository_path ? this.repositoryGroup(String(row.repository_path)) : void 0,
|
|
2609
|
+
repositoryName: row.repository_name ? String(row.repository_name) : summary.pins?.repositoryId ?? "",
|
|
2610
|
+
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
2611
|
+
dismissedAt: row.dismissed_at ? String(row.dismissed_at) : null,
|
|
2612
|
+
working: this.activity.isWorking(summary.reviewId)
|
|
2613
|
+
};
|
|
2614
|
+
if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
|
|
2615
|
+
return listed;
|
|
2616
|
+
});
|
|
2617
|
+
return this.withDiffStats(reviews, mode);
|
|
2618
|
+
}
|
|
2619
|
+
/** Local and imported summaries use the same persisted, mode-specific counts. */
|
|
2620
|
+
withDiffStats(reviews, mode = "structural") {
|
|
2621
|
+
const stats = new Map(this.db.prepare("SELECT identity, stats FROM comparison_stats").all().map((row) => [String(row.identity), JSON.parse(String(row.stats))]));
|
|
2622
|
+
return reviews.map((review) => ({
|
|
2623
|
+
...review,
|
|
2624
|
+
diffStats: review.pins ? stats.get(JSON.stringify([review.pins, mode])) ?? null : null
|
|
2625
|
+
}));
|
|
2626
|
+
}
|
|
2627
|
+
/** What the pad holds, for its Home card: blocks, and the diagrams among them. */
|
|
2628
|
+
scratchpadContents() {
|
|
2629
|
+
const blocks = elements(this.read(SCRATCHPAD_ID).document).filter((element) => !isUnit(element));
|
|
2630
|
+
return {
|
|
2631
|
+
blocks: blocks.length,
|
|
2632
|
+
diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
/** The one scratchpad, made on first use. A host that loses the race to
|
|
2636
|
+
* another on the same home finds it made. */
|
|
2637
|
+
async ensureScratchpad() {
|
|
2638
|
+
if (this.has(SCRATCHPAD_ID)) return;
|
|
2639
|
+
try {
|
|
2640
|
+
await this.execute({ operation: {
|
|
2641
|
+
type: "create",
|
|
2642
|
+
title: SCRATCHPAD_TITLE,
|
|
2643
|
+
kind: "scratchpad"
|
|
2644
|
+
} });
|
|
2645
|
+
} catch (error) {
|
|
2646
|
+
if (!this.has(SCRATCHPAD_ID)) throw error;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
history(id) {
|
|
2650
|
+
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) => ({
|
|
2651
|
+
version: Number(row.version),
|
|
2652
|
+
title: String(row.title),
|
|
2653
|
+
createdAt: String(row.created_at)
|
|
2654
|
+
}));
|
|
2655
|
+
}
|
|
2656
|
+
inspect(id, targetId, version) {
|
|
2657
|
+
return inspectSnapshot(this.read(id, version), targetId);
|
|
2658
|
+
}
|
|
2659
|
+
/** The host can seed a managed document; transport callers only supply a command. */
|
|
2660
|
+
execute(input, initial) {
|
|
2661
|
+
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
|
|
2662
|
+
const command = commandSchema.parse(input);
|
|
2663
|
+
if (initial && command.operation.type !== "create") throw new ReviewInputError("Initial content requires a create command.");
|
|
2664
|
+
const pullRequest = this.startPullRequest(command);
|
|
2665
|
+
pullRequest?.catch(() => {});
|
|
2666
|
+
const run = this.pending.then(async () => {
|
|
2667
|
+
const op = command.operation;
|
|
2668
|
+
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);
|
|
2669
|
+
if (op.type === "create" && op.kind === "scratchpad") {
|
|
2670
|
+
if (op.target) throw new ReviewInputError("A scratchpad has no target of its own.");
|
|
2671
|
+
if (this.has(SCRATCHPAD_ID)) throw new ReviewInputError("The scratchpad already exists.", 409);
|
|
2672
|
+
} else if (op.type === "create") {
|
|
2673
|
+
if (!op.target && !op.pullRequestUrl) throw new ReviewInputError("Supply a target or a pullRequestUrl.");
|
|
2674
|
+
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.");
|
|
2675
|
+
if (!op.title && op.target) throw new ReviewInputError("Supply a title.");
|
|
2676
|
+
}
|
|
2677
|
+
const requestedTarget = op.type === "create" || op.type === "set_target" ? op.target : void 0;
|
|
2678
|
+
const fromPullRequest = await pullRequest;
|
|
2679
|
+
const resolvedTarget = requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
|
|
2680
|
+
if (requestedTarget && !resolvedTarget) throw new ReviewInputError("Review targets are unavailable.");
|
|
2681
|
+
if (op.type === "create" && op.pullRequestUrl && op.reuseExisting !== false) {
|
|
2682
|
+
const [found, ...others] = this.reviewsForPullRequest(op.pullRequestUrl);
|
|
2683
|
+
if (found) return this.existingReview(found, others, resolvedTarget.pins);
|
|
2684
|
+
}
|
|
2685
|
+
if (op.type === "delete") {
|
|
2686
|
+
const result = {
|
|
2687
|
+
reviewId: op.reviewId,
|
|
2688
|
+
version: this.read(op.reviewId).version,
|
|
2689
|
+
deleted: true
|
|
2690
|
+
};
|
|
2691
|
+
this.commitCommand(result, () => {
|
|
2692
|
+
for (const table of [
|
|
2693
|
+
"ask_conversations",
|
|
2694
|
+
"authoring_presences",
|
|
2695
|
+
"review_coverage",
|
|
2696
|
+
"review_attention",
|
|
2697
|
+
"versions"
|
|
2698
|
+
]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(op.reviewId);
|
|
2699
|
+
this.db.prepare("DELETE FROM reviews WHERE id=?").run(op.reviewId);
|
|
2700
|
+
}, () => this.assertMutation(op.reviewId, result.version));
|
|
2701
|
+
return result;
|
|
2702
|
+
}
|
|
2703
|
+
if (op.type === "attention") {
|
|
2704
|
+
const result = {
|
|
2705
|
+
reviewId: op.reviewId,
|
|
2706
|
+
version: this.read(op.reviewId).version,
|
|
2707
|
+
attention: true
|
|
2708
|
+
};
|
|
2709
|
+
this.commitCommand(result, () => {
|
|
2710
|
+
this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(op.reviewId);
|
|
2711
|
+
if (op.action === "view") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
|
|
2712
|
+
else this.db.prepare("UPDATE review_attention SET dismissed_at=? WHERE review_id=?").run(op.action === "dismiss" ? (/* @__PURE__ */ new Date()).toISOString() : null, op.reviewId);
|
|
2713
|
+
});
|
|
2714
|
+
return result;
|
|
2715
|
+
}
|
|
2716
|
+
const id = op.type !== "create" ? op.reviewId : op.kind === "scratchpad" ? SCRATCHPAD_ID : randomUUID();
|
|
2717
|
+
const previous = op.type === "create" ? void 0 : this.read(id);
|
|
2718
|
+
let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title) : structuredClone(previous);
|
|
2719
|
+
delete snapshot.sourceUnavailable;
|
|
2720
|
+
delete snapshot.lastEdit;
|
|
2721
|
+
let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
|
|
2722
|
+
let applied;
|
|
2723
|
+
let lensTarget;
|
|
2724
|
+
if ((op.type === "create" || op.type === "set_target") && this.providers.headBranch) {
|
|
2725
|
+
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
2726
|
+
if (pins) {
|
|
2727
|
+
const headRef = requestedTarget?.kind === "commits" ? requestedTarget.head : void 0;
|
|
2728
|
+
const branch = await this.providers.headBranch(pins, headRef);
|
|
2729
|
+
snapshot.origin = {
|
|
2730
|
+
...snapshot.origin,
|
|
2731
|
+
branch
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
switch (op.type) {
|
|
2736
|
+
case "create":
|
|
2737
|
+
if (initial) {
|
|
2738
|
+
snapshot.document = documentSchema.parse(initial.document);
|
|
2739
|
+
snapshot.origin = {
|
|
2740
|
+
...snapshot.origin,
|
|
2741
|
+
...structuredClone(initial.origin)
|
|
2742
|
+
};
|
|
2743
|
+
for (const block of snapshot.document) assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
|
|
2744
|
+
}
|
|
2745
|
+
setPullRequest(snapshot, op.pullRequestUrl);
|
|
2746
|
+
break;
|
|
2747
|
+
case "rename":
|
|
2748
|
+
snapshot.title = op.title;
|
|
2749
|
+
break;
|
|
2750
|
+
case "set_target":
|
|
2751
|
+
setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.pins?.repositoryId !== resolvedTarget.pins.repositoryId ? null : void 0));
|
|
2752
|
+
snapshot.staleSources = [];
|
|
2753
|
+
snapshot.target = resolvedTarget.target;
|
|
2754
|
+
snapshot.pins = resolvedTarget.pins;
|
|
2755
|
+
break;
|
|
2756
|
+
case "restore":
|
|
2757
|
+
snapshot = this.read(id, op.version);
|
|
2758
|
+
delete snapshot.lastEdit;
|
|
2759
|
+
break;
|
|
2760
|
+
case "lens_edit": {
|
|
2761
|
+
if (snapshot.kind === "scratchpad") throw new ReviewInputError("The scratchpad has no changes of its own to lens.", 409);
|
|
2762
|
+
const lenses = snapshot.lenses ?? [];
|
|
2763
|
+
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
2764
|
+
if (lenses.length) snapshot.lenses = lenses;
|
|
2765
|
+
else delete snapshot.lenses;
|
|
2766
|
+
lensTarget = {
|
|
2767
|
+
targetId: lens.id,
|
|
2768
|
+
type: "lens"
|
|
2769
|
+
};
|
|
2770
|
+
snapshot.lastEdit = {
|
|
2771
|
+
type: op.edit.type,
|
|
2772
|
+
targetId: lens.id,
|
|
2773
|
+
blockId: lens.id,
|
|
2774
|
+
kind: "lens",
|
|
2775
|
+
...op.edit.type === "update" && { fields: Object.keys(op.edit).filter((key) => key !== "type" && key !== "targetId") }
|
|
2776
|
+
};
|
|
2777
|
+
break;
|
|
2778
|
+
}
|
|
2779
|
+
case "edit":
|
|
2780
|
+
applied = applyEdit(snapshot.document, op.edit, (prefix) => `${prefix}-${++nextId}`, { placement: snapshot.kind === "scratchpad" ? "first" : "last" });
|
|
2781
|
+
snapshot.lastEdit = summarizeEdit(op.edit, applied, previous.document, snapshot.document);
|
|
2782
|
+
if (snapshot.staleSources?.length) {
|
|
2783
|
+
const oldSources = new Map(sourceReferences(previous.document).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
2784
|
+
const newSources = new Map(sourceReferences(snapshot.document).map((item) => [item.id, JSON.stringify(item.source)]));
|
|
2785
|
+
snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (snapshot.target?.kind === "worktree" && op.type !== "restore" && !resolvedTarget) snapshot = await this.projectLiveSource(snapshot);
|
|
2789
|
+
checkReferences(snapshot.document);
|
|
2790
|
+
if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
|
|
2791
|
+
const warnings = await this.validateExternal(snapshot, previous, op.type === "set_target");
|
|
2792
|
+
snapshot.version = previous ? previous.version + 1 : 0;
|
|
2793
|
+
snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2794
|
+
const result = {
|
|
2795
|
+
...op.type === "create" && { created: true },
|
|
2796
|
+
reviewId: id,
|
|
2797
|
+
version: snapshot.version,
|
|
2798
|
+
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
2799
|
+
...applied && { type: applied.type },
|
|
2800
|
+
...lensTarget && { type: lensTarget.type },
|
|
2801
|
+
...applied?.children && { children: applied.children }
|
|
2802
|
+
};
|
|
2803
|
+
if (snapshot.staleSources?.length) warnings.push("Some authored source ranges changed. Update their references before presenting this review.");
|
|
2804
|
+
if (warnings.length) result.warnings = warnings;
|
|
2805
|
+
this.commitCommand(result, () => {
|
|
2806
|
+
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);
|
|
2807
|
+
this.db.prepare("INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot));
|
|
2808
|
+
}, previous ? () => this.assertMutation(id, previous.version) : void 0, op.type === "edit" || op.type === "lens_edit" ? {
|
|
2809
|
+
surface: op.type === "edit" ? "document" : "lenses",
|
|
2810
|
+
activityId: op.activityId,
|
|
2811
|
+
owned: (owner) => {
|
|
2812
|
+
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
2813
|
+
}
|
|
2814
|
+
} : void 0);
|
|
2815
|
+
return result;
|
|
2816
|
+
});
|
|
2817
|
+
this.pending = run.catch(() => {});
|
|
2818
|
+
return run;
|
|
2819
|
+
}
|
|
2820
|
+
/** Resolve a target-less PR create before it queues, when it will need it. */
|
|
2821
|
+
startPullRequest(command) {
|
|
2822
|
+
const op = command.operation;
|
|
2823
|
+
if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl) return void 0;
|
|
2824
|
+
if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
|
|
2825
|
+
const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
|
|
2826
|
+
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
2827
|
+
id: op.repositoryId,
|
|
2828
|
+
preferred: existing && this.read(existing).pins?.repositoryId
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
/** Reviews whose PR is this one, newest version first. Summaries only. */
|
|
2832
|
+
reviewsForPullRequest(url) {
|
|
2833
|
+
return this.db.prepare(`SELECT reviews.id FROM reviews
|
|
2834
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2835
|
+
WHERE lower(json_extract(versions.snapshot,'$.origin.pullRequestUrl'))=?
|
|
2836
|
+
AND COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
|
|
2837
|
+
ORDER BY json_extract(versions.snapshot,'$.createdAt') DESC, reviews.rowid DESC`).all(pullRequestKey(url)).map((row) => String(row.id));
|
|
2838
|
+
}
|
|
2839
|
+
/** The answer to a create that found its PR's review. Its target stays:
|
|
2840
|
+
* moving it would silently point existing links at different code. */
|
|
2841
|
+
existingReview(reviewId, others, requested) {
|
|
2842
|
+
const snapshot = this.read(reviewId);
|
|
2843
|
+
const headMoved = snapshot.pins?.repositoryId !== requested.repositoryId || snapshot.pins?.head !== requested.head;
|
|
2844
|
+
const working = this.activity.read(reviewId).activities ?? [];
|
|
2845
|
+
return {
|
|
2846
|
+
created: false,
|
|
2847
|
+
note: [
|
|
2848
|
+
"Returned the existing review for this PR instead of creating one; the requested title and target were not applied. Update it in place (read it with session_get first), or pass reuseExisting:false to create a separate review.",
|
|
2849
|
+
headMoved && "The PR head moved since this review's target was set, and the target was NOT changed: call review_set_target to move it, then repair the source references it reports.",
|
|
2850
|
+
working.length > 0 && `Another agent is working on it${working[0].focus ? `: ${working[0].focus.description}` : ""}.`,
|
|
2851
|
+
others.length > 0 && "Older reviews also name this PR; see otherReviewIds."
|
|
2852
|
+
].filter(Boolean).join(" "),
|
|
2853
|
+
reviewId,
|
|
2854
|
+
version: snapshot.version,
|
|
2855
|
+
target: snapshot.target,
|
|
2856
|
+
headMoved,
|
|
2857
|
+
...working.length > 0 && { working: true },
|
|
2858
|
+
...others.length > 0 && { otherReviewIds: others }
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
commitCommand(result, apply, guard, attribution) {
|
|
2862
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2863
|
+
let owner;
|
|
2864
|
+
try {
|
|
2865
|
+
guard?.();
|
|
2866
|
+
if (attribution) {
|
|
2867
|
+
owner = this.activity.attribute(result.reviewId, attribution.surface, attribution.activityId);
|
|
2868
|
+
if (owner) attribution.owned(owner);
|
|
2869
|
+
else if (attribution.activityId) result.warnings = [...result.warnings ?? [], "Your activity has ended or expired, so readers no longer see you working. The edit was saved; call session_activity_begin and pass the new activityId."];
|
|
2870
|
+
}
|
|
2871
|
+
apply();
|
|
2872
|
+
this.db.exec("COMMIT");
|
|
2873
|
+
} catch (error) {
|
|
2874
|
+
this.db.exec("ROLLBACK");
|
|
2875
|
+
throw error;
|
|
2876
|
+
}
|
|
2877
|
+
if (result.deleted) this.activity.deleted(result.reviewId);
|
|
2878
|
+
else if (owner) this.activity.extended(result.reviewId);
|
|
2879
|
+
this.notify(result);
|
|
2880
|
+
}
|
|
2881
|
+
assertMutation(reviewId, version) {
|
|
2882
|
+
const current = this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId);
|
|
2883
|
+
if ((current ? Number(current.version) : void 0) !== version) throw new ReviewInputError("Review changed during validation. Reread it and retry the edit.", 409);
|
|
2884
|
+
}
|
|
2885
|
+
notify(result) {
|
|
2886
|
+
if (result.deleted) this.observedVersions.delete(result.reviewId);
|
|
2887
|
+
else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
|
|
2888
|
+
if (!result.attention) for (const listener of this.listeners) try {
|
|
2889
|
+
listener(result);
|
|
2890
|
+
} catch {}
|
|
2891
|
+
for (const listener of this.catalogListeners) try {
|
|
2892
|
+
listener();
|
|
2893
|
+
} catch {}
|
|
2894
|
+
}
|
|
2895
|
+
/** Dismissed reviews, as Home lists them; only a restore clears it, not a view. */
|
|
2896
|
+
dismissedIds() {
|
|
2897
|
+
return this.db.prepare("SELECT review_id FROM review_attention WHERE dismissed_at IS NOT NULL").all().map((row) => String(row.review_id));
|
|
2898
|
+
}
|
|
2899
|
+
has(reviewId) {
|
|
2900
|
+
return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
|
|
2901
|
+
}
|
|
2902
|
+
async validateExternal(snapshot, previous, repin = false) {
|
|
2903
|
+
const warnings = [];
|
|
2904
|
+
const references = (document, tolerant = false, lenses = []) => {
|
|
2905
|
+
const sources = /* @__PURE__ */ new Map();
|
|
2906
|
+
const resources = /* @__PURE__ */ new Map();
|
|
2907
|
+
const add = (source, peek) => {
|
|
2908
|
+
const key = JSON.stringify(source);
|
|
2909
|
+
const kept = sources.get(key);
|
|
2910
|
+
sources.set(key, {
|
|
2911
|
+
source,
|
|
2912
|
+
peek: peek || (kept?.peek ?? false)
|
|
2913
|
+
});
|
|
2914
|
+
};
|
|
2915
|
+
for (const { source, peek } of sourceReferences(document, { tolerant })) add(source, peek === true);
|
|
2916
|
+
for (const { source } of lensSelections(lenses)) for (const anchor of sourceAnchors(source)) add(anchor, false);
|
|
2917
|
+
for (const block of resourceReferences(document)) resources.set(JSON.stringify(block), block);
|
|
2918
|
+
return {
|
|
2919
|
+
sources,
|
|
2920
|
+
resources
|
|
2921
|
+
};
|
|
2922
|
+
};
|
|
2923
|
+
const current = references(snapshot.document, repin, snapshot.lenses);
|
|
2924
|
+
const pinsChanged = previous && JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
|
|
2925
|
+
const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
|
|
2926
|
+
const changed = repin && pinsChanged && previous.pins && snapshot.pins ? await this.providers.filesChangedBetween?.(previous.pins, snapshot.pins).catch(() => void 0) : void 0;
|
|
2927
|
+
const retained = references(previous?.document ?? [], true, previous?.lenses);
|
|
2928
|
+
const checks = [];
|
|
2929
|
+
const retainedPins = new Set(explicitPins([...retained.sources.values()]).map((pins) => JSON.stringify(pins)));
|
|
2930
|
+
for (const pins of explicitPins([...current.sources.values()])) if (!retainedPins.has(JSON.stringify(pins))) checks.push(this.providers.validatePins(pins));
|
|
2931
|
+
for (const [key, { source, peek }] of current.sources) {
|
|
2932
|
+
const kept = retained.sources.get(key);
|
|
2933
|
+
if (pinsChanged && !source.pins || !kept || peek && !kept.peek) checks.push(this.providers.validateSource(anchorPins(source, snapshot.pins), source, { peek }).then(() => {
|
|
2934
|
+
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.`);
|
|
2935
|
+
}, (error) => {
|
|
2936
|
+
if (!repin && !(worktreeMoved && kept) || !(error instanceof ReviewInputError)) throw error;
|
|
2937
|
+
warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`);
|
|
2938
|
+
}));
|
|
2939
|
+
}
|
|
2940
|
+
for (const [key, block] of current.resources) if (pinsChanged || !retained.resources.has(key)) checks.push(this.providers.validateResource(snapshot.pins, block).catch((error) => {
|
|
2941
|
+
if (!repin && !(worktreeMoved && retained.resources.has(key)) || !(error instanceof ReviewInputError)) throw error;
|
|
2942
|
+
warnings.push(`${block.id} (${block.type}): ${error.message}`);
|
|
2943
|
+
}));
|
|
2944
|
+
await Promise.all(checks);
|
|
2945
|
+
return warnings.sort();
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
/** Without a resolver, a commits target that names both commits pins them as
|
|
2949
|
+
* given; validatePins still checks they exist. */
|
|
2950
|
+
function namedCommits(target) {
|
|
2951
|
+
if (target.kind !== "commits" || target.base === void 0) return void 0;
|
|
2952
|
+
const { repositoryId, base, head } = target;
|
|
2953
|
+
return {
|
|
2954
|
+
target,
|
|
2955
|
+
pins: {
|
|
2956
|
+
repositoryId,
|
|
2957
|
+
base,
|
|
2958
|
+
head
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
/** A new document's first version, before its initial content. Field order
|
|
2963
|
+
* is kept so stored JSON reads as it always has. */
|
|
2964
|
+
function createdSnapshot(id, op, resolved, defaultTitle) {
|
|
2965
|
+
const title = op.title ?? defaultTitle;
|
|
2966
|
+
if (!title) throw new ReviewInputError("Supply a title.");
|
|
2967
|
+
if (!resolved) return {
|
|
2968
|
+
reviewId: id,
|
|
2969
|
+
version: 0,
|
|
2970
|
+
title,
|
|
2971
|
+
kind: op.kind,
|
|
2972
|
+
document: [],
|
|
2973
|
+
createdAt: ""
|
|
2974
|
+
};
|
|
2975
|
+
return {
|
|
2976
|
+
reviewId: id,
|
|
2977
|
+
version: 0,
|
|
2978
|
+
title,
|
|
2979
|
+
pins: resolved.pins,
|
|
2980
|
+
target: resolved.target,
|
|
2981
|
+
document: [],
|
|
2982
|
+
createdAt: ""
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
function inspectSnapshot(snapshot, targetId) {
|
|
2986
|
+
if (targetId !== void 0) {
|
|
2987
|
+
const target = elements(snapshot.document).find((element) => element.id === targetId);
|
|
2988
|
+
if (!target) throw new ReviewInputError("Target not found in this version.", 404);
|
|
2989
|
+
return target;
|
|
2990
|
+
}
|
|
2991
|
+
return elements(snapshot.document).map((element) => ({
|
|
2992
|
+
id: element.id,
|
|
2993
|
+
type: element.type,
|
|
2994
|
+
label: "title" in element ? element.title : "label" in element ? element.label : element.type === "markdown" ? element.markdown.slice(0, 120) : void 0
|
|
2995
|
+
}));
|
|
2996
|
+
}
|
|
2997
|
+
//#endregion
|
|
2998
|
+
//#region src/review-api/workspaces.ts
|
|
2999
|
+
const OWNED_ELSEWHERE = "Another Desktop owns this review's language workspaces. Quit that Desktop, then retry.";
|
|
3000
|
+
/** Local lifecycle only: source and authored history never depend on preparation.
|
|
3001
|
+
* Status/queue/process lifecycle follows #334, retaining the legacy prepare config
|
|
3002
|
+
* and per-review managed checkout layout instead of a new settings system.
|
|
3003
|
+
*/
|
|
3004
|
+
var ReviewWorkspaces = class {
|
|
3005
|
+
store;
|
|
3006
|
+
db;
|
|
3007
|
+
requests = /* @__PURE__ */ new Map();
|
|
3008
|
+
jobs = /* @__PURE__ */ new Map();
|
|
3009
|
+
stop;
|
|
3010
|
+
cleanup = Promise.resolve();
|
|
3011
|
+
closed = false;
|
|
3012
|
+
ownerId = randomUUID();
|
|
3013
|
+
constructor(databasePath, store) {
|
|
3014
|
+
this.store = store;
|
|
3015
|
+
this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
|
|
3016
|
+
this.db.exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS pinned_environments(id TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
3017
|
+
this.db.exec("DROP TABLE IF EXISTS workspace_owner; CREATE TABLE IF NOT EXISTS workspace_leases(review_id TEXT PRIMARY KEY,owner TEXT NOT NULL,pid INTEGER NOT NULL)");
|
|
3018
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3019
|
+
try {
|
|
3020
|
+
for (const environment of this.all()) {
|
|
3021
|
+
if (this.leasedElsewhere(environment.reviewId)) continue;
|
|
3022
|
+
if (environment.state === "preparing") environment.state = "pending";
|
|
3023
|
+
environment.generation = randomUUID();
|
|
3024
|
+
this.save(environment);
|
|
3025
|
+
}
|
|
3026
|
+
this.db.exec("COMMIT");
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
this.db.exec("ROLLBACK");
|
|
3029
|
+
this.db.close();
|
|
3030
|
+
throw error;
|
|
3031
|
+
}
|
|
3032
|
+
this.stop = store.subscribeCatalog(() => {
|
|
3033
|
+
this.collect();
|
|
3034
|
+
this.releaseDismissed();
|
|
3035
|
+
});
|
|
3036
|
+
this.collect();
|
|
3037
|
+
this.releaseDismissed();
|
|
3038
|
+
}
|
|
3039
|
+
dismissed;
|
|
3040
|
+
releasing = /* @__PURE__ */ new Map();
|
|
3041
|
+
/**
|
|
3042
|
+
* Frees a newly dismissed review's checkouts to save disk. The review itself
|
|
3043
|
+
* stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
|
|
3044
|
+
* dismissed review are kept until the next startup or dismissal, since
|
|
3045
|
+
* the Desktop's canvas tabs and source windows may still show them.
|
|
3046
|
+
*/
|
|
3047
|
+
releaseDismissed() {
|
|
3048
|
+
const dismissed = new Set(this.store.dismissedIds());
|
|
3049
|
+
const released = [...dismissed].filter((id) => !this.dismissed?.has(id));
|
|
3050
|
+
this.dismissed = dismissed;
|
|
3051
|
+
if (!released.length) return;
|
|
3052
|
+
const repositories = this.repositoryDirs();
|
|
3053
|
+
repositories.catch(() => void 0);
|
|
3054
|
+
for (const reviewId of released) this.release(reviewId, repositories);
|
|
3055
|
+
}
|
|
3056
|
+
async repositoryDirs() {
|
|
3057
|
+
const dirs = new Set(this.all().map((item) => item.repository));
|
|
3058
|
+
for (const { path: root } of this.store.repositories()) {
|
|
3059
|
+
const common = await gitCommonDir(root).catch(() => null);
|
|
3060
|
+
if (common) dirs.add(common);
|
|
3061
|
+
}
|
|
3062
|
+
return [...dirs].filter(Boolean);
|
|
3063
|
+
}
|
|
3064
|
+
/** Resolves once no release of the review is pending, so an ensure cannot race it. */
|
|
3065
|
+
released(reviewId) {
|
|
3066
|
+
return this.releasing.get(reviewId) ?? Promise.resolve();
|
|
3067
|
+
}
|
|
3068
|
+
release(reviewId, repositories = this.repositoryDirs()) {
|
|
3069
|
+
const requests = [...this.requests.values()];
|
|
3070
|
+
const done = this.cleanup.then(async () => {
|
|
3071
|
+
await Promise.allSettled(requests);
|
|
3072
|
+
await this.releaseCheckouts(reviewId, await repositories);
|
|
3073
|
+
}).catch((error) => {
|
|
3074
|
+
console.error(`Could not free the pinned checkouts of dismissed review ${reviewId}:`, error);
|
|
3075
|
+
}).finally(() => {
|
|
3076
|
+
if (this.releasing.get(reviewId) === done) this.releasing.delete(reviewId);
|
|
3077
|
+
});
|
|
3078
|
+
this.releasing.set(reviewId, done);
|
|
3079
|
+
this.cleanup = done;
|
|
3080
|
+
}
|
|
3081
|
+
async releaseCheckouts(reviewId, repositories) {
|
|
3082
|
+
if (!this.store.dismissedIds().includes(reviewId)) return;
|
|
3083
|
+
const environments = this.all().filter((item) => item.reviewId === reviewId);
|
|
3084
|
+
const checkouts = repositories.filter((repository) => existsSync(reviewManagedCheckoutRoot(repository, reviewId)));
|
|
3085
|
+
if (!environments.length && !checkouts.length || !this.claim(reviewId)) return;
|
|
3086
|
+
for (const environment of environments) {
|
|
3087
|
+
const job = this.jobs.get(environment.id);
|
|
3088
|
+
job?.abort.abort();
|
|
3089
|
+
await job?.done;
|
|
3090
|
+
}
|
|
3091
|
+
const errors = [];
|
|
3092
|
+
for (const repository of checkouts) await removeReviewManagedCheckouts(repository, reviewId).catch((error) => errors.push(errorMessage(error)));
|
|
3093
|
+
for (const environment of environments) if (errors.length && environment.rootPath && existsSync(environment.rootPath)) {
|
|
3094
|
+
environment.state = "cleanup-failed";
|
|
3095
|
+
environment.log = errors.join("\n");
|
|
3096
|
+
this.save(environment);
|
|
3097
|
+
} else this.db.prepare("DELETE FROM pinned_environments WHERE id=?").run(environment.id);
|
|
3098
|
+
this.db.prepare("DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)").run(reviewId, this.ownerId, reviewId);
|
|
3099
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
3100
|
+
}
|
|
3101
|
+
external;
|
|
3102
|
+
stopExternal;
|
|
3103
|
+
attachExternalReviews(source) {
|
|
3104
|
+
if (this.external === source) return;
|
|
3105
|
+
this.stopExternal?.();
|
|
3106
|
+
this.external = source;
|
|
3107
|
+
this.stopExternal = source.subscribe(() => this.collect());
|
|
3108
|
+
this.collect();
|
|
3109
|
+
}
|
|
3110
|
+
hasReview(id) {
|
|
3111
|
+
if (id.startsWith("shared-") && !this.external) return true;
|
|
3112
|
+
return this.store.has(id) || Boolean(this.external?.has(id));
|
|
3113
|
+
}
|
|
3114
|
+
assertReview(id) {
|
|
3115
|
+
if (!this.hasReview(id)) this.store.assertExists(id);
|
|
3116
|
+
}
|
|
3117
|
+
async remove(reviewId) {
|
|
3118
|
+
if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
|
|
3119
|
+
await Promise.all(this.requests.values());
|
|
3120
|
+
this.collect(void 0, reviewId);
|
|
3121
|
+
await this.cleanup;
|
|
3122
|
+
if (this.all().some((item) => item.reviewId === reviewId)) throw new ReviewInputError("Could not remove the managed workspace. Retry deletion.", 409);
|
|
3123
|
+
}
|
|
3124
|
+
lease(reviewId) {
|
|
3125
|
+
return this.db.prepare("SELECT owner,pid FROM workspace_leases WHERE review_id=?").get(reviewId);
|
|
3126
|
+
}
|
|
3127
|
+
leasedElsewhere(reviewId) {
|
|
3128
|
+
const lease = this.lease(reviewId);
|
|
3129
|
+
return Boolean(lease && lease.owner !== this.ownerId && processIsAlive(Number(lease.pid)));
|
|
3130
|
+
}
|
|
3131
|
+
/** Takes the review's lease unless another live Desktop holds it. */
|
|
3132
|
+
claim(reviewId) {
|
|
3133
|
+
if (this.lease(reviewId)?.owner === this.ownerId) return true;
|
|
3134
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3135
|
+
try {
|
|
3136
|
+
const free = !this.leasedElsewhere(reviewId);
|
|
3137
|
+
if (free) this.db.prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?)").run(reviewId, this.ownerId, process.pid);
|
|
3138
|
+
this.db.exec("COMMIT");
|
|
3139
|
+
return free;
|
|
3140
|
+
} catch (error) {
|
|
3141
|
+
this.db.exec("ROLLBACK");
|
|
3142
|
+
throw error;
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
all() {
|
|
3146
|
+
return this.db.prepare("SELECT value FROM pinned_environments").all().map((row) => JSON.parse(String(row.value)));
|
|
3147
|
+
}
|
|
3148
|
+
get(id) {
|
|
3149
|
+
const row = this.db.prepare("SELECT value FROM pinned_environments WHERE id=?").get(id);
|
|
3150
|
+
return row ? JSON.parse(String(row.value)) : void 0;
|
|
3151
|
+
}
|
|
3152
|
+
save(environment) {
|
|
3153
|
+
this.db.prepare("INSERT INTO pinned_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value").run(environment.id, JSON.stringify(environment));
|
|
3154
|
+
}
|
|
3155
|
+
status(environment) {
|
|
3156
|
+
const { id, commit, rootPath, generation, state, log } = environment;
|
|
3157
|
+
const status = {
|
|
3158
|
+
id,
|
|
3159
|
+
commit,
|
|
3160
|
+
rootPath,
|
|
3161
|
+
generation,
|
|
3162
|
+
state,
|
|
3163
|
+
log
|
|
3164
|
+
};
|
|
3165
|
+
if (state === "failed" && !rootPath) status.issue = log;
|
|
3166
|
+
return status;
|
|
3167
|
+
}
|
|
3168
|
+
list(reviewId) {
|
|
3169
|
+
return this.all().filter((item) => item.reviewId === reviewId).map((item) => this.status(item));
|
|
3170
|
+
}
|
|
3171
|
+
failures() {
|
|
3172
|
+
return this.all().filter((item) => item.state === "cleanup-failed").map((item) => this.status(item));
|
|
3173
|
+
}
|
|
3174
|
+
async retryCleanup(id) {
|
|
3175
|
+
const environment = this.get(id);
|
|
3176
|
+
if (!environment || environment.state !== "cleanup-failed") throw new ReviewInputError("Cleanup failure not found.", 404);
|
|
3177
|
+
if (this.store.has(environment.reviewId)) this.release(environment.reviewId);
|
|
3178
|
+
else this.collect(id);
|
|
3179
|
+
await this.cleanup;
|
|
3180
|
+
}
|
|
3181
|
+
async open(reviewId, pins) {
|
|
3182
|
+
await this.source(reviewId, pins, "head");
|
|
3183
|
+
if (pins.base !== pins.head) await this.source(reviewId, pins, "base");
|
|
3184
|
+
}
|
|
3185
|
+
source(reviewId, pins, side, retryFailed = false) {
|
|
3186
|
+
if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("Language environments are closed."));
|
|
3187
|
+
this.assertReview(reviewId);
|
|
3188
|
+
const id = createHash("sha256").update(JSON.stringify([
|
|
3189
|
+
reviewId,
|
|
3190
|
+
pins.repositoryId,
|
|
3191
|
+
pins[side]
|
|
3192
|
+
])).digest("hex");
|
|
3193
|
+
const current = this.requests.get(id);
|
|
3194
|
+
if (current) return current;
|
|
3195
|
+
if (!this.claim(reviewId)) {
|
|
3196
|
+
const environment = this.get(id);
|
|
3197
|
+
return Promise.resolve(environment ? this.status(environment) : {
|
|
3198
|
+
id,
|
|
3199
|
+
commit: pins[side],
|
|
3200
|
+
rootPath: null,
|
|
3201
|
+
generation: "",
|
|
3202
|
+
state: "pending",
|
|
3203
|
+
log: OWNED_ELSEWHERE
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
const request = this.acquire(id, reviewId, pins, side, retryFailed).finally(() => this.requests.delete(id));
|
|
3207
|
+
this.requests.set(id, request);
|
|
3208
|
+
return request;
|
|
3209
|
+
}
|
|
3210
|
+
async acquire(id, reviewId, pins, side, retryFailed) {
|
|
3211
|
+
await this.released(reviewId);
|
|
3212
|
+
let environment = this.get(id);
|
|
3213
|
+
if (this.jobs.has(id)) return this.status(environment);
|
|
3214
|
+
environment ??= {
|
|
3215
|
+
id,
|
|
3216
|
+
reviewId,
|
|
3217
|
+
repositoryId: pins.repositoryId,
|
|
3218
|
+
repository: "",
|
|
3219
|
+
commit: pins[side],
|
|
3220
|
+
rootPath: null,
|
|
3221
|
+
generation: randomUUID(),
|
|
3222
|
+
commandsHash: "",
|
|
3223
|
+
role: pins.base === pins.head ? "head" : side,
|
|
3224
|
+
state: "pending",
|
|
3225
|
+
log: ""
|
|
3226
|
+
};
|
|
3227
|
+
this.save(environment);
|
|
3228
|
+
try {
|
|
3229
|
+
const root = this.store.repositoryPath(pins.repositoryId);
|
|
3230
|
+
const repository = environment.repository || await gitCommonDir(root).catch(() => null);
|
|
3231
|
+
if (!repository) throw new Error("Repository Git directory is unavailable. Restore the registered checkout and retry preparation.");
|
|
3232
|
+
environment.repository = repository;
|
|
3233
|
+
const result = await git(repository, [
|
|
3234
|
+
"config",
|
|
3235
|
+
"--null",
|
|
3236
|
+
"--get-all",
|
|
3237
|
+
"devfast.prepare"
|
|
3238
|
+
], { allowFailure: true });
|
|
3239
|
+
const commands = result.stdout.trim() ? result.stdout.split("\0").filter((command) => command.trim()) : [];
|
|
3240
|
+
const commandsHash = reviewPrepareCommandsHash(commands);
|
|
3241
|
+
const existed = environment.rootPath && existsSync(path.join(environment.rootPath, ".git"));
|
|
3242
|
+
const checkout = await ensureReviewPinnedCheckout({
|
|
3243
|
+
rootPath: existsSync(root) ? root : repository,
|
|
3244
|
+
reviewUuid: reviewId,
|
|
3245
|
+
ref: pins[side],
|
|
3246
|
+
role: environment.role
|
|
3247
|
+
});
|
|
3248
|
+
if (!checkout) throw new Error("Pinned checkout is unavailable.");
|
|
3249
|
+
if (!this.hasReview(reviewId)) {
|
|
3250
|
+
environment.rootPath = checkout;
|
|
3251
|
+
this.save(environment);
|
|
3252
|
+
this.collect();
|
|
3253
|
+
return this.status(environment);
|
|
3254
|
+
}
|
|
3255
|
+
const changed = !existed || environment.rootPath !== checkout || environment.commandsHash !== commandsHash;
|
|
3256
|
+
environment.rootPath = checkout;
|
|
3257
|
+
environment.commandsHash = commandsHash;
|
|
3258
|
+
if (!commands.length) {
|
|
3259
|
+
environment.state = "unconfigured";
|
|
3260
|
+
environment.log = "Configure dependencies with git config devfast.prepare '<command>'.";
|
|
3261
|
+
} else if (await markerMatches(reviewPrepareMarkerPath(checkout), commandsHash)) {
|
|
3262
|
+
environment.state = "ready";
|
|
3263
|
+
environment.log = "";
|
|
3264
|
+
} else if (environment.state !== "failed" || changed || retryFailed) {
|
|
3265
|
+
environment.state = "preparing";
|
|
3266
|
+
environment.generation = randomUUID();
|
|
3267
|
+
environment.log = "Preparing pinned checkout…";
|
|
3268
|
+
this.save(environment);
|
|
3269
|
+
this.prepare(environment, commands);
|
|
3270
|
+
return this.status(environment);
|
|
3271
|
+
}
|
|
3272
|
+
if (changed) environment.generation = randomUUID();
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
environment.state = "failed";
|
|
3275
|
+
environment.rootPath = null;
|
|
3276
|
+
environment.log = errorMessage(error);
|
|
3277
|
+
}
|
|
3278
|
+
this.save(environment);
|
|
3279
|
+
return this.status(environment);
|
|
3280
|
+
}
|
|
3281
|
+
prepare(environment, commands) {
|
|
3282
|
+
const abort = new AbortController();
|
|
3283
|
+
const done = prepareReviewPinnedCheckout({
|
|
3284
|
+
checkoutPath: environment.rootPath,
|
|
3285
|
+
commit: environment.commit,
|
|
3286
|
+
commands,
|
|
3287
|
+
signal: abort.signal,
|
|
3288
|
+
progress: (log) => {
|
|
3289
|
+
environment.log = log;
|
|
3290
|
+
this.save(environment);
|
|
3291
|
+
},
|
|
3292
|
+
warning: (log) => {
|
|
3293
|
+
environment.log = log;
|
|
3294
|
+
}
|
|
3295
|
+
}).then(async (result) => {
|
|
3296
|
+
environment.state = result.prepared ? "ready" : "failed";
|
|
3297
|
+
if (!result.prepared) environment.log = await readFile(reviewPrepareLogPath(environment.rootPath), "utf8").catch(() => environment.log);
|
|
3298
|
+
}).catch((error) => {
|
|
3299
|
+
environment.state = "failed";
|
|
3300
|
+
environment.log = errorMessage(error);
|
|
3301
|
+
}).finally(() => {
|
|
3302
|
+
environment.generation = randomUUID();
|
|
3303
|
+
this.save(environment);
|
|
3304
|
+
this.jobs.delete(environment.id);
|
|
3305
|
+
});
|
|
3306
|
+
this.jobs.set(environment.id, {
|
|
3307
|
+
done,
|
|
3308
|
+
abort
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
async retry(reviewId, id) {
|
|
3312
|
+
const environment = this.get(id);
|
|
3313
|
+
if (!environment || environment.reviewId !== reviewId) throw new ReviewInputError("Language environment not found.", 404);
|
|
3314
|
+
if (this.jobs.has(id)) return this.status(environment);
|
|
3315
|
+
if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
|
|
3316
|
+
if (environment.state === "cleanup-failed") {
|
|
3317
|
+
this.collect();
|
|
3318
|
+
return this.status(environment);
|
|
3319
|
+
}
|
|
3320
|
+
this.assertReview(reviewId);
|
|
3321
|
+
if (environment.rootPath) await rm(reviewPrepareMarkerPath(environment.rootPath), { force: true });
|
|
3322
|
+
environment.state = "pending";
|
|
3323
|
+
environment.generation = randomUUID();
|
|
3324
|
+
this.save(environment);
|
|
3325
|
+
return this.source(reviewId, {
|
|
3326
|
+
repositoryId: environment.repositoryId,
|
|
3327
|
+
base: environment.commit,
|
|
3328
|
+
head: environment.commit
|
|
3329
|
+
}, "head");
|
|
3330
|
+
}
|
|
3331
|
+
collect(retryId, removedReviewId) {
|
|
3332
|
+
const deleted = this.all().filter((environment) => (environment.reviewId === removedReviewId || !this.hasReview(environment.reviewId)) && (environment.state !== "cleanup-failed" || environment.id === retryId || environment.reviewId === removedReviewId) && this.claim(environment.reviewId));
|
|
3333
|
+
this.cleanup = this.cleanup.then(async () => {
|
|
3334
|
+
for (const environment of deleted) {
|
|
3335
|
+
const job = this.jobs.get(environment.id);
|
|
3336
|
+
job?.abort.abort();
|
|
3337
|
+
await job?.done;
|
|
3338
|
+
try {
|
|
3339
|
+
if (environment.rootPath && existsSync(environment.rootPath)) {
|
|
3340
|
+
const managed = reviewManagedCheckoutRoot(environment.repository, environment.reviewId);
|
|
3341
|
+
const relative = path.relative(managed, environment.rootPath);
|
|
3342
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Refusing to remove a non-managed checkout.");
|
|
3343
|
+
await git(environment.repository, [
|
|
3344
|
+
"worktree",
|
|
3345
|
+
"remove",
|
|
3346
|
+
"--force",
|
|
3347
|
+
environment.rootPath
|
|
3348
|
+
]);
|
|
3349
|
+
}
|
|
3350
|
+
if (environment.rootPath) await removeReviewPrepareArtifacts(environment.rootPath);
|
|
3351
|
+
this.db.prepare("DELETE FROM pinned_environments WHERE id=?").run(environment.id);
|
|
3352
|
+
this.db.prepare("DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)").run(environment.reviewId, this.ownerId, environment.reviewId);
|
|
3353
|
+
} catch (error) {
|
|
3354
|
+
environment.state = "cleanup-failed";
|
|
3355
|
+
environment.log = errorMessage(error);
|
|
3356
|
+
this.save(environment);
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
3361
|
+
async idle() {
|
|
3362
|
+
await Promise.all(this.requests.values());
|
|
3363
|
+
await Promise.all([...this.jobs.values()].map((job) => job.done));
|
|
3364
|
+
await this.cleanup;
|
|
3365
|
+
}
|
|
3366
|
+
closing;
|
|
3367
|
+
close() {
|
|
3368
|
+
return this.closing ??= this.closeAll();
|
|
3369
|
+
}
|
|
3370
|
+
async closeAll() {
|
|
3371
|
+
this.closed = true;
|
|
3372
|
+
this.stop();
|
|
3373
|
+
this.stopExternal?.();
|
|
3374
|
+
await Promise.all(this.requests.values());
|
|
3375
|
+
for (const job of this.jobs.values()) job.abort.abort();
|
|
3376
|
+
await this.idle();
|
|
3377
|
+
this.db.prepare("DELETE FROM workspace_leases WHERE owner=?").run(this.ownerId);
|
|
3378
|
+
this.db.close();
|
|
3379
|
+
}
|
|
3380
|
+
};
|
|
3381
|
+
//#endregion
|
|
3382
|
+
//#region src/review-api/worktree-source.ts
|
|
3383
|
+
const { lstat: lstat$1, readFile: readFile$1, readlink, realpath: realpath$1 } = checkoutFs;
|
|
3384
|
+
const exec = promisify(execFile);
|
|
3385
|
+
const EMPTY_SOURCE = "empty-worktree-baseline";
|
|
3386
|
+
/** Never follow a working-copy link outside the selected checkout. */
|
|
3387
|
+
async function localSourcePath(root, file) {
|
|
3388
|
+
const canonicalRoot = await realpath$1(root);
|
|
3389
|
+
const candidate = await realpath$1(resolve(canonicalRoot, file));
|
|
3390
|
+
const child = relative(canonicalRoot, candidate);
|
|
3391
|
+
if (!child || isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`)) throw new ReviewInputError("Source symlink leaves the selected worktree.");
|
|
3392
|
+
if (!(await lstat$1(candidate)).isFile()) throw new ReviewInputError("Source is not a regular file.");
|
|
3393
|
+
return candidate;
|
|
3394
|
+
}
|
|
3395
|
+
/** List the live checkout's tracked files. jj tracks new files itself; Git's untracked files are left out. */
|
|
3396
|
+
async function workingFiles(vcs) {
|
|
3397
|
+
const gitDirectory = vcs.kind === "jj" ? await gitCommonDir(vcs.rootPath) : void 0;
|
|
3398
|
+
if (vcs.kind === "jj" && !gitDirectory) throw new ReviewInputError("Working source requires a Git-backed jj repository.");
|
|
3399
|
+
const { stdout } = await exec("git", [
|
|
3400
|
+
"-C",
|
|
3401
|
+
vcs.rootPath,
|
|
3402
|
+
...gitDirectory ? [
|
|
3403
|
+
"--git-dir",
|
|
3404
|
+
gitDirectory,
|
|
3405
|
+
"--work-tree",
|
|
3406
|
+
vcs.rootPath
|
|
3407
|
+
] : [],
|
|
3408
|
+
"ls-files",
|
|
3409
|
+
"-z",
|
|
3410
|
+
...vcs.kind === "git" ? ["--cached"] : [
|
|
3411
|
+
"--others",
|
|
3412
|
+
"--exclude-standard",
|
|
3413
|
+
"--exclude=.jj/"
|
|
3414
|
+
]
|
|
3415
|
+
], { maxBuffer: 33554432 });
|
|
3416
|
+
const paths = [.../* @__PURE__ */ new Set([...stdout.split("\0").filter(Boolean), ...vcs.kind === "jj" ? await vcs.listTrackedFiles() : []])].sort();
|
|
3417
|
+
return (await Promise.all(paths.map(async (file) => {
|
|
3418
|
+
try {
|
|
3419
|
+
await lstat$1(resolve(vcs.rootPath, file));
|
|
3420
|
+
return file;
|
|
3421
|
+
} catch (error) {
|
|
3422
|
+
if (isMissingFileError(error)) return void 0;
|
|
3423
|
+
throw error;
|
|
3424
|
+
}
|
|
3425
|
+
}))).filter((file) => file !== void 0);
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* Count Git's untracked, non-ignored files, which working tree reviews leave
|
|
3429
|
+
* out. Undefined for jj, which tracks new files itself, or when the listing
|
|
3430
|
+
* is too large to read.
|
|
3431
|
+
*/
|
|
3432
|
+
async function untrackedFileCount(vcs) {
|
|
3433
|
+
if (vcs.kind !== "git") return void 0;
|
|
3434
|
+
try {
|
|
3435
|
+
const { stdout } = await exec("git", [
|
|
3436
|
+
"-C",
|
|
3437
|
+
vcs.rootPath,
|
|
3438
|
+
"ls-files",
|
|
3439
|
+
"-z",
|
|
3440
|
+
"--others",
|
|
3441
|
+
"--exclude-standard",
|
|
3442
|
+
"--exclude=.jj/"
|
|
3443
|
+
], { maxBuffer: 33554432 });
|
|
3444
|
+
return stdout.split("\0").filter(Boolean).length;
|
|
3445
|
+
} catch {
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
/** A refresh token, not stored source: all reads still use the checkout. */
|
|
3450
|
+
async function inspectWorktree(repositoryId, vcs) {
|
|
3451
|
+
const commit = (await vcs.resolveRevision(vcs.kind === "jj" ? "@" : "HEAD"))?.commit ?? "empty-worktree-baseline";
|
|
3452
|
+
const hash = createHash("sha256").update(`${repositoryId}\0${commit}`);
|
|
3453
|
+
for (const file of await workingFiles(vcs)) try {
|
|
3454
|
+
const info = await lstat$1(resolve(vcs.rootPath, file), { bigint: true });
|
|
3455
|
+
hash.update(`\0${file}\0${info.ino}:${info.size}:${info.mode}:${info.mtimeNs}:${info.ctimeNs}`);
|
|
3456
|
+
} catch (error) {
|
|
3457
|
+
if (!isMissingFileError(error)) throw error;
|
|
3458
|
+
}
|
|
3459
|
+
return {
|
|
3460
|
+
revision: hash.digest("hex"),
|
|
3461
|
+
commit
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
async function readWorkingFile(root, file) {
|
|
3465
|
+
try {
|
|
3466
|
+
const candidate = resolve(root, file);
|
|
3467
|
+
const parent = relative(await realpath$1(root), await realpath$1(dirname(candidate)));
|
|
3468
|
+
if (isAbsolute(parent) || parent === ".." || parent.startsWith(`..${sep}`)) throw new ReviewInputError("Source symlink leaves the selected worktree.");
|
|
3469
|
+
if ((await lstat$1(candidate)).isSymbolicLink()) return readlink(candidate);
|
|
3470
|
+
return await readFile$1(await localSourcePath(root, file), "utf8");
|
|
3471
|
+
} catch (error) {
|
|
3472
|
+
if (isMissingFileError(error)) return null;
|
|
3473
|
+
throw error;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
//#endregion
|
|
3477
|
+
//#region src/review-api/local-data.ts
|
|
3478
|
+
const uploadSchema = z.discriminatedUnion("kind", [
|
|
3479
|
+
z.strictObject({
|
|
3480
|
+
id: z.uuid(),
|
|
3481
|
+
repositoryId: z.string(),
|
|
3482
|
+
kind: z.literal("image"),
|
|
3483
|
+
base64: z.string()
|
|
3484
|
+
}),
|
|
3485
|
+
z.strictObject({
|
|
3486
|
+
id: z.uuid(),
|
|
3487
|
+
repositoryId: z.string(),
|
|
3488
|
+
kind: z.literal("trace"),
|
|
3489
|
+
trace: traceSchema
|
|
3490
|
+
}),
|
|
3491
|
+
z.strictObject({
|
|
3492
|
+
id: z.uuid(),
|
|
3493
|
+
repositoryId: z.string(),
|
|
3494
|
+
kind: z.literal("map"),
|
|
3495
|
+
pins: pinsSchema,
|
|
3496
|
+
side: z.enum(["base", "head"]),
|
|
3497
|
+
model: mapInputSchema
|
|
3498
|
+
})
|
|
3499
|
+
]);
|
|
3500
|
+
const unavailableCheckout = () => new ReviewInputError("The selected local checkout is unavailable.", 404);
|
|
3501
|
+
/** File reads cannot name the root or a directory; tree reads can. */
|
|
3502
|
+
function checkRelativePath(file) {
|
|
3503
|
+
inputError(() => checkSourcePath(file));
|
|
3504
|
+
if (file === "" || file.endsWith("/")) throw new ReviewInputError("Source file must be a repository-relative path.");
|
|
3505
|
+
}
|
|
3506
|
+
function sliceRange(file, source) {
|
|
3507
|
+
return {
|
|
3508
|
+
...source,
|
|
3509
|
+
commit: file.commit,
|
|
3510
|
+
text: inputError(() => sliceSourceRange(file.text, source))
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
/** Local source/resource boundary, including Desktop-only local language context. */
|
|
3514
|
+
var LocalReviewData = class {
|
|
3515
|
+
store;
|
|
3516
|
+
options;
|
|
3517
|
+
workspaceManager;
|
|
3518
|
+
get workspaces() {
|
|
3519
|
+
if (!this.workspaceManager) throw new ReviewInputError("Open this review in Desktop to prepare language workspaces.", 409);
|
|
3520
|
+
return this.workspaceManager;
|
|
3521
|
+
}
|
|
3522
|
+
currentEnvironmentIssues(snapshot) {
|
|
3523
|
+
if (snapshot.target?.kind !== "commits" || !snapshot.pins) return [];
|
|
3524
|
+
const pins = snapshot.pins;
|
|
3525
|
+
return this.workspaces.list(snapshot.reviewId).flatMap((environment) => {
|
|
3526
|
+
const side = environment.commit === pins.head ? "head" : environment.commit === pins.base ? "base" : void 0;
|
|
3527
|
+
return side && environment.issue ? [{
|
|
3528
|
+
side,
|
|
3529
|
+
message: environment.issue
|
|
3530
|
+
}] : [];
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
3533
|
+
async environmentIssues(snapshot, retryFailed = false) {
|
|
3534
|
+
const issues = [];
|
|
3535
|
+
if (!snapshot.pins) return issues;
|
|
3536
|
+
const sides = snapshot.target?.kind === "commits" && snapshot.pins.base !== snapshot.pins.head ? ["head", "base"] : ["head"];
|
|
3537
|
+
for (const side of sides) {
|
|
3538
|
+
const context = await this.languageEnvironment(snapshot, side, void 0, retryFailed);
|
|
3539
|
+
if (context.issue) issues.push({
|
|
3540
|
+
side,
|
|
3541
|
+
message: context.issue
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
return issues;
|
|
3545
|
+
}
|
|
3546
|
+
untrackedCounts = /* @__PURE__ */ new Map();
|
|
3547
|
+
/**
|
|
3548
|
+
* Untracked files a live worktree review leaves out; undefined otherwise.
|
|
3549
|
+
* Counted once per worktree revision, never by the revision poll, so a
|
|
3550
|
+
* checkout with many untracked files pays for the walk only when read.
|
|
3551
|
+
*/
|
|
3552
|
+
untrackedFiles(pins) {
|
|
3553
|
+
if (!pins.worktreeRevision) return Promise.resolve(void 0);
|
|
3554
|
+
const key = `${pins.repositoryId}\0${pins.worktreeRevision}`;
|
|
3555
|
+
let count = this.untrackedCounts.get(key);
|
|
3556
|
+
if (!count) {
|
|
3557
|
+
count = this.vcs(pins.repositoryId).then((vcs) => vcs ? untrackedFileCount(vcs) : void 0);
|
|
3558
|
+
this.untrackedCounts.set(key, count);
|
|
3559
|
+
for (const cached of this.untrackedCounts.keys()) {
|
|
3560
|
+
if (this.untrackedCounts.size <= 32) break;
|
|
3561
|
+
this.untrackedCounts.delete(cached);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
return count;
|
|
3565
|
+
}
|
|
3566
|
+
/** Local checkout context for live worktree targets only. */
|
|
3567
|
+
/** Desktop language services borrow the registered checkout, never create one. */
|
|
3568
|
+
async liveFile(repositoryId, file, text) {
|
|
3569
|
+
try {
|
|
3570
|
+
const rootPath = await realpath(this.store.repositoryPath(repositoryId));
|
|
3571
|
+
const localPath = await localSourcePath(rootPath, file);
|
|
3572
|
+
if (await checkoutFs.readFile(localPath, "utf8") === text) return {
|
|
3573
|
+
localPath,
|
|
3574
|
+
localRoot: rootPath
|
|
3575
|
+
};
|
|
3576
|
+
} catch {}
|
|
3577
|
+
}
|
|
3578
|
+
/** Source bytes and the language workspace have independent lifetimes. */
|
|
3579
|
+
async languageEnvironment(snapshot, side, commit, retryFailed = false, anchor) {
|
|
3580
|
+
if (!anchor && snapshot.target?.kind === "commits" && snapshot.pins) {
|
|
3581
|
+
const pins = await this.comparison(snapshot.pins, commit);
|
|
3582
|
+
const environment = await this.workspaces.source(snapshot.reviewId, pins, side, retryFailed);
|
|
3583
|
+
return {
|
|
3584
|
+
rootPath: environment.state === "preparing" || environment.state === "pending" ? null : environment.rootPath,
|
|
3585
|
+
identity: environment.generation,
|
|
3586
|
+
issue: environment.issue
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
if (commit && !anchor) await this.comparison(await this.documentPins(snapshot), commit);
|
|
3590
|
+
const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
|
|
3591
|
+
if (!repositoryId) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
3592
|
+
const unavailable = {
|
|
3593
|
+
rootPath: null,
|
|
3594
|
+
identity: `${repositoryId}:unavailable`,
|
|
3595
|
+
issue: "Could not access the registered language checkout. Check the repository path and permissions, then retry."
|
|
3596
|
+
};
|
|
3597
|
+
let rootPath;
|
|
3598
|
+
try {
|
|
3599
|
+
rootPath = await realpath(this.store.repositoryPath(repositoryId));
|
|
3600
|
+
} catch (error) {
|
|
3601
|
+
if (error instanceof ReviewInputError && error.status !== 404) throw error;
|
|
3602
|
+
return unavailable;
|
|
3603
|
+
}
|
|
3604
|
+
if (!rootPath || !await this.vcs(repositoryId)) return unavailable;
|
|
3605
|
+
const info = await stat(rootPath, { bigint: true }).catch(() => null);
|
|
3606
|
+
return info ? {
|
|
3607
|
+
rootPath,
|
|
3608
|
+
identity: `${repositoryId}:${rootPath}:${info.dev}:${info.ino}:${info.birthtimeNs}`
|
|
3609
|
+
} : unavailable;
|
|
3610
|
+
}
|
|
3611
|
+
/** Resolve source coordinates after selecting a local or imported snapshot.
|
|
3612
|
+
* `anchor` reads at a reference's own pins instead of the document's;
|
|
3613
|
+
* `commit` narrows the document comparison to one of its commits. */
|
|
3614
|
+
async resolveSource(snapshot, commit, anchor) {
|
|
3615
|
+
return {
|
|
3616
|
+
snapshot,
|
|
3617
|
+
pins: anchor ? await this.anchorSourcePins(anchor) : await this.comparison(await this.documentPins(snapshot), commit)
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
/** Where an Ask agent reads source: the Review's own head checkout, or the
|
|
3621
|
+
* registered checkout for a live worktree target, as the navigator does. */
|
|
3622
|
+
async agentCheckout(snapshot) {
|
|
3623
|
+
const { pins } = await this.resolveSource(snapshot);
|
|
3624
|
+
const rootPath = pins.worktreeRevision ? await realpath(this.store.repositoryPath(pins.repositoryId)) : (await this.workspaces.source(snapshot.reviewId, pins, "head")).rootPath;
|
|
3625
|
+
if (!rootPath) throw new ReviewInputError("The head checkout for this review is not ready yet.", 409);
|
|
3626
|
+
return {
|
|
3627
|
+
rootPath,
|
|
3628
|
+
head: pins.head,
|
|
3629
|
+
live: !!pins.worktreeRevision
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
/** Resolve a native workspace without replacing the selected source with today's HEAD. */
|
|
3633
|
+
async navigatorWorkspace(snapshot, source = {}) {
|
|
3634
|
+
const { pins } = await this.resolveSource(snapshot, source.commit, source.anchor);
|
|
3635
|
+
const repository = this.store.repositoryPath(pins.repositoryId);
|
|
3636
|
+
const side = source.side ?? "head";
|
|
3637
|
+
const live = !!pins.worktreeRevision && (side === "head" || source.empty && pins[side] === "empty-worktree-baseline");
|
|
3638
|
+
const checkoutSide = pins[side] === "empty-worktree-baseline" && source.empty ? "head" : side;
|
|
3639
|
+
const ref = pins[checkoutSide];
|
|
3640
|
+
if (source.file) checkRelativePath(source.file);
|
|
3641
|
+
const rootPath = live ? await realpath(repository) : (await this.workspaces.source(snapshot.reviewId, pins, checkoutSide)).rootPath;
|
|
3642
|
+
const context = await resolveRepoContext(repository);
|
|
3643
|
+
if (!rootPath || !context) throw new ReviewInputError("Could not open the selected source checkout.", 409);
|
|
3644
|
+
if (!live) {
|
|
3645
|
+
const { stdout } = await promisify(execFile)("git", [
|
|
3646
|
+
"-C",
|
|
3647
|
+
rootPath,
|
|
3648
|
+
"status",
|
|
3649
|
+
"--porcelain",
|
|
3650
|
+
"--untracked-files=no"
|
|
3651
|
+
]);
|
|
3652
|
+
if (stdout.trim()) throw new ReviewInputError("The pinned checkout has local changes, possibly from devfast.prepare. Restore those files before browsing this pinned revision.", 409);
|
|
3653
|
+
}
|
|
3654
|
+
const name = context.githubSlug?.split("/").at(-1) ?? path.basename(path.dirname(context.commonDir));
|
|
3655
|
+
const workspaceDirectory = path.join(reviewManagedCheckoutRoot(context.commonDir, snapshot.reviewId), "navigator", "workspaces", live ? "worktree" : ref);
|
|
3656
|
+
const workspacePath = path.join(workspaceDirectory, `${name}.code-workspace`);
|
|
3657
|
+
const current = await readWorkspace(workspacePath);
|
|
3658
|
+
if (current !== null) {
|
|
3659
|
+
const previous = current ?? await readWorkspace(path.join(workspaceDirectory, `${path.basename(repository)}.code-workspace`));
|
|
3660
|
+
const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
3661
|
+
const workspace = previous ?? {
|
|
3662
|
+
folders: [],
|
|
3663
|
+
settings: { "files.readonlyInclude": { "**/*": true } }
|
|
3664
|
+
};
|
|
3665
|
+
const next = {
|
|
3666
|
+
...workspace,
|
|
3667
|
+
folders: [{
|
|
3668
|
+
path: rootPath,
|
|
3669
|
+
name
|
|
3670
|
+
}, ...workspace.folders.slice(1)],
|
|
3671
|
+
settings: {
|
|
3672
|
+
...workspace.settings,
|
|
3673
|
+
"window.title": title
|
|
3674
|
+
}
|
|
3675
|
+
};
|
|
3676
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) await writePrivateJsonAtomic(workspacePath, next);
|
|
3677
|
+
}
|
|
3678
|
+
let filePath;
|
|
3679
|
+
if (source.file) {
|
|
3680
|
+
if (source.empty) {
|
|
3681
|
+
filePath = path.join(path.dirname(workspacePath), "empty", path.basename(source.file));
|
|
3682
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
3683
|
+
await writeFile(filePath, "", { mode: 384 });
|
|
3684
|
+
} else try {
|
|
3685
|
+
filePath = await localSourcePath(rootPath, source.file);
|
|
3686
|
+
} catch (error) {
|
|
3687
|
+
if (isMissingFileError(error)) throw new ReviewInputError("File is unavailable at the selected revision.", 404);
|
|
3688
|
+
throw error;
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
return {
|
|
3692
|
+
workspacePath,
|
|
3693
|
+
filePath
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
/** A document read that needs default pins; 409 when the document has none. */
|
|
3697
|
+
async documentPins(snapshot) {
|
|
3698
|
+
const pins = await this.sourcePins(snapshot);
|
|
3699
|
+
if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
3700
|
+
return pins;
|
|
3701
|
+
}
|
|
3702
|
+
/** Pins a reference names itself, checked against the registered checkout. */
|
|
3703
|
+
async anchorSourcePins(anchor) {
|
|
3704
|
+
if (!existsSync(this.store.repositoryPath(anchor.repositoryId))) throw unavailableCheckout();
|
|
3705
|
+
return anchorPins({ pins: anchor }, void 0);
|
|
3706
|
+
}
|
|
3707
|
+
/** References whose own repository is unregistered or gone from disk. */
|
|
3708
|
+
async unavailableAnchors(snapshot) {
|
|
3709
|
+
const missing = /* @__PURE__ */ new Set();
|
|
3710
|
+
for (const pins of explicitPins(sourceReferences(snapshot.document, { tolerant: true }))) try {
|
|
3711
|
+
await this.anchorSourcePins(pins);
|
|
3712
|
+
} catch (error) {
|
|
3713
|
+
if (!(error instanceof ReviewInputError)) throw error;
|
|
3714
|
+
missing.add(pins.repositoryId);
|
|
3715
|
+
}
|
|
3716
|
+
if (!missing.size) return [];
|
|
3717
|
+
return sourceReferences(snapshot.document, { tolerant: true }).filter((ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId)).map((ref) => ref.id);
|
|
3718
|
+
}
|
|
3719
|
+
trackedFiles = /* @__PURE__ */ new Map();
|
|
3720
|
+
repositories = /* @__PURE__ */ new Map();
|
|
3721
|
+
readers = /* @__PURE__ */ new Map();
|
|
3722
|
+
commitRanges = /* @__PURE__ */ new Map();
|
|
3723
|
+
constructor(store, options = {}) {
|
|
3724
|
+
this.store = store;
|
|
3725
|
+
this.options = options;
|
|
3726
|
+
if (options.manageWorkspaces !== false) this.workspaceManager = new ReviewWorkspaces(options.workspaceDatabase ?? ":memory:", store);
|
|
3727
|
+
}
|
|
3728
|
+
async *structuralChanges({ reviewId, pins, signal, file }) {
|
|
3729
|
+
if (file !== void 0) checkRelativePath(file);
|
|
3730
|
+
if (pins.worktreeRevision) {
|
|
3731
|
+
const { rootPath, baseRef } = await this.worktreeInput(pins);
|
|
3732
|
+
yield* this.structuralComparisons.stream({
|
|
3733
|
+
repositoryPath: rootPath,
|
|
3734
|
+
comparison: {
|
|
3735
|
+
kind: "worktree",
|
|
3736
|
+
base: baseRef ?? "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
3737
|
+
revision: pins.worktreeRevision
|
|
3738
|
+
},
|
|
3739
|
+
paths: file === void 0 ? void 0 : [file],
|
|
3740
|
+
signal
|
|
3741
|
+
});
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
await this.workspaceManager?.released(reviewId);
|
|
3745
|
+
const rootPath = await ensureReviewPinnedCheckout({
|
|
3746
|
+
rootPath: this.store.repositoryPath(pins.repositoryId),
|
|
3747
|
+
ref: pins.head,
|
|
3748
|
+
reviewUuid: reviewId
|
|
3749
|
+
});
|
|
3750
|
+
if (!rootPath) throw new ReviewInputError("Cannot prepare the pinned repository for structural diffing.");
|
|
3751
|
+
yield* this.structuralComparisons.stream({
|
|
3752
|
+
repositoryPath: rootPath,
|
|
3753
|
+
comparison: {
|
|
3754
|
+
kind: "trees",
|
|
3755
|
+
base: pins.base,
|
|
3756
|
+
head: pins.head
|
|
3757
|
+
},
|
|
3758
|
+
paths: file === void 0 ? void 0 : [file],
|
|
3759
|
+
signal
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
structuralComparisons = new StructuralComparisons();
|
|
3763
|
+
closed = false;
|
|
3764
|
+
worktrees = /* @__PURE__ */ new Map();
|
|
3765
|
+
forgetWorktree(repositoryId) {
|
|
3766
|
+
const entry = this.worktrees.get(repositoryId);
|
|
3767
|
+
if (entry) for (const watcher of entry.watchers) watcher.close();
|
|
3768
|
+
this.worktrees.delete(repositoryId);
|
|
3769
|
+
}
|
|
3770
|
+
async worktreeState(repositoryId, vcs) {
|
|
3771
|
+
let entry = this.worktrees.get(repositoryId);
|
|
3772
|
+
if (!entry) {
|
|
3773
|
+
entry = {
|
|
3774
|
+
epoch: 0,
|
|
3775
|
+
inspectedEpoch: -1,
|
|
3776
|
+
watchers: [],
|
|
3777
|
+
healthy: true,
|
|
3778
|
+
forks: /* @__PURE__ */ new Map()
|
|
3779
|
+
};
|
|
3780
|
+
this.worktrees.set(repositoryId, entry);
|
|
3781
|
+
const state = entry;
|
|
3782
|
+
const roots = /* @__PURE__ */ new Set([vcs.rootPath]);
|
|
3783
|
+
const common = await gitCommonDir(vcs.rootPath);
|
|
3784
|
+
if (common) roots.add(common);
|
|
3785
|
+
for (const root of roots) try {
|
|
3786
|
+
const watcher = (this.options.watch ?? watch)(root, { recursive: true }, () => {
|
|
3787
|
+
state.epoch++;
|
|
3788
|
+
});
|
|
3789
|
+
watcher.on("error", () => {
|
|
3790
|
+
state.epoch++;
|
|
3791
|
+
state.inspectedEpoch = -1;
|
|
3792
|
+
state.healthy = false;
|
|
3793
|
+
watcher.close();
|
|
3794
|
+
});
|
|
3795
|
+
watcher.unref();
|
|
3796
|
+
state.watchers.push(watcher);
|
|
3797
|
+
} catch {
|
|
3798
|
+
state.healthy = false;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
if (entry.inspection && entry.inspectedEpoch === entry.epoch && entry.healthy && entry.watchers.length) return {
|
|
3802
|
+
...entry.inspection,
|
|
3803
|
+
forks: entry.forks
|
|
3804
|
+
};
|
|
3805
|
+
const epoch = entry.epoch;
|
|
3806
|
+
const inspected = await inspectWorktree(repositoryId, vcs);
|
|
3807
|
+
entry.inspection = inspected;
|
|
3808
|
+
entry.inspectedEpoch = epoch;
|
|
3809
|
+
entry.forks = /* @__PURE__ */ new Map();
|
|
3810
|
+
return {
|
|
3811
|
+
...inspected,
|
|
3812
|
+
forks: entry.forks
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
async close() {
|
|
3816
|
+
this.coverageAbort.abort();
|
|
3817
|
+
clearTimeout(this.coverageNotification);
|
|
3818
|
+
this.coverageListeners.clear();
|
|
3819
|
+
this.coverageCache.clear();
|
|
3820
|
+
this.structuralComparisons.close();
|
|
3821
|
+
this.closed = true;
|
|
3822
|
+
await this.workspaceManager?.close();
|
|
3823
|
+
for (const entry of this.worktrees.values()) for (const watcher of entry.watchers) watcher.close();
|
|
3824
|
+
this.worktrees.clear();
|
|
3825
|
+
const readers = [...this.readers.values()];
|
|
3826
|
+
this.readers.clear();
|
|
3827
|
+
await Promise.all(readers.map((reader) => reader.close()));
|
|
3828
|
+
}
|
|
3829
|
+
/** Detected once; dropped when the root vanishes or detection found nothing. */
|
|
3830
|
+
vcs(repositoryId) {
|
|
3831
|
+
const cached = this.repositories.get(repositoryId);
|
|
3832
|
+
if (cached && (!cached.vcs || existsSync(cached.vcs.rootPath))) return cached.detection;
|
|
3833
|
+
this.closeReader(repositoryId);
|
|
3834
|
+
this.forgetWorktree(repositoryId);
|
|
3835
|
+
const rootPath = this.store.repositoryPath(repositoryId);
|
|
3836
|
+
const forget = () => {
|
|
3837
|
+
this.repositories.delete(repositoryId);
|
|
3838
|
+
this.closeReader(repositoryId);
|
|
3839
|
+
};
|
|
3840
|
+
const entry = { detection: detectLocalVcs(rootPath).then((vcs) => {
|
|
3841
|
+
if (vcs) entry.vcs = vcs;
|
|
3842
|
+
else forget();
|
|
3843
|
+
return vcs;
|
|
3844
|
+
}, (cause) => {
|
|
3845
|
+
forget();
|
|
3846
|
+
throw cause;
|
|
3847
|
+
}) };
|
|
3848
|
+
this.repositories.set(repositoryId, entry);
|
|
3849
|
+
return entry.detection;
|
|
3850
|
+
}
|
|
3851
|
+
/** None once closed: a read suspended across close() gets its own process. */
|
|
3852
|
+
reader(repositoryId, vcs) {
|
|
3853
|
+
if (this.closed) return void 0;
|
|
3854
|
+
const existing = this.readers.get(repositoryId);
|
|
3855
|
+
if (existing) return existing;
|
|
3856
|
+
const reader = createBlobBatchReader({
|
|
3857
|
+
rootPath: vcs.rootPath,
|
|
3858
|
+
kind: vcs.kind,
|
|
3859
|
+
idleTimeoutMs: this.options.blobReaderIdleTimeoutMs
|
|
3860
|
+
});
|
|
3861
|
+
this.readers.set(repositoryId, reader);
|
|
3862
|
+
return reader;
|
|
3863
|
+
}
|
|
3864
|
+
closeReader(repositoryId) {
|
|
3865
|
+
const reader = this.readers.get(repositoryId);
|
|
3866
|
+
if (!reader) return;
|
|
3867
|
+
this.readers.delete(repositoryId);
|
|
3868
|
+
return reader.close();
|
|
3869
|
+
}
|
|
3870
|
+
async forgetRepository(repositoryId) {
|
|
3871
|
+
await this.closeReader(repositoryId);
|
|
3872
|
+
this.forgetWorktree(repositoryId);
|
|
3873
|
+
this.repositories.delete(repositoryId);
|
|
3874
|
+
for (const key of this.trackedFiles.keys()) if (key.startsWith(repositoryId + "\0")) this.trackedFiles.delete(key);
|
|
3875
|
+
for (const key of this.commitRanges.keys()) if (key.startsWith(repositoryId + ":")) this.commitRanges.delete(key);
|
|
3876
|
+
}
|
|
3877
|
+
async vcsTarget(repositoryId) {
|
|
3878
|
+
const vcs = await this.vcs(repositoryId);
|
|
3879
|
+
if (vcs) return {
|
|
3880
|
+
rootPath: vcs.rootPath,
|
|
3881
|
+
kind: vcs.kind
|
|
3882
|
+
};
|
|
3883
|
+
const rootPath = this.store.repositoryPath(repositoryId);
|
|
3884
|
+
if (!existsSync(rootPath)) throw unavailableCheckout();
|
|
3885
|
+
return { rootPath };
|
|
3886
|
+
}
|
|
3887
|
+
async register(root) {
|
|
3888
|
+
const resolved = await realpath(root).catch(() => {
|
|
3889
|
+
throw new ReviewInputError("Repository path does not exist or is not readable.");
|
|
3890
|
+
});
|
|
3891
|
+
const vcs = await detectLocalVcs(resolved).catch((cause) => {
|
|
3892
|
+
if (cause instanceof LocalVcsToolsMissingError) throw new ReviewInputError(cause.message);
|
|
3893
|
+
throw cause;
|
|
3894
|
+
});
|
|
3895
|
+
if (!vcs) throw new ReviewInputError("Choose a Git or jj repository.");
|
|
3896
|
+
const repository = this.store.registerRepository(await realpath(vcs.rootPath));
|
|
3897
|
+
await this.closeReader(repository.id);
|
|
3898
|
+
this.repositories.delete(repository.id);
|
|
3899
|
+
return repository;
|
|
3900
|
+
}
|
|
3901
|
+
async projectSource(snapshot, pins) {
|
|
3902
|
+
const projected = {
|
|
3903
|
+
...snapshot,
|
|
3904
|
+
pins,
|
|
3905
|
+
staleSources: []
|
|
3906
|
+
};
|
|
3907
|
+
delete projected.sourceUnavailable;
|
|
3908
|
+
for (const reference of sourceReferences(snapshot.document, { tolerant: true })) try {
|
|
3909
|
+
await this.quote(anchorPins(reference.source, pins), reference.source);
|
|
3910
|
+
} catch (error) {
|
|
3911
|
+
if (!(error instanceof ReviewInputError)) throw error;
|
|
3912
|
+
projected.staleSources.push(reference.id);
|
|
3913
|
+
}
|
|
3914
|
+
return projected;
|
|
3915
|
+
}
|
|
3916
|
+
/** Throws 404 when the checkout behind the snapshot is gone. Undefined
|
|
3917
|
+
* for a document without default pins. */
|
|
3918
|
+
async sourcePins(snapshot) {
|
|
3919
|
+
if (snapshot.target?.kind === "worktree") return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
|
|
3920
|
+
if (!snapshot.pins) return void 0;
|
|
3921
|
+
if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId))) throw unavailableCheckout();
|
|
3922
|
+
return snapshot.pins;
|
|
3923
|
+
}
|
|
3924
|
+
/** Capture a label only when its ref still resolves to these exact pins. */
|
|
3925
|
+
async headBranch(pins, headRef) {
|
|
3926
|
+
const vcs = await this.vcs(pins.repositoryId);
|
|
3927
|
+
if (!vcs || vcs.kind !== "git") return void 0;
|
|
3928
|
+
try {
|
|
3929
|
+
const { stdout } = await promisify(execFile)("git", [
|
|
3930
|
+
"-C",
|
|
3931
|
+
vcs.rootPath,
|
|
3932
|
+
"rev-parse",
|
|
3933
|
+
"--symbolic-full-name",
|
|
3934
|
+
"--verify",
|
|
3935
|
+
"--end-of-options",
|
|
3936
|
+
!headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : headRef
|
|
3937
|
+
]);
|
|
3938
|
+
const ref = stdout.trim();
|
|
3939
|
+
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) return void 0;
|
|
3940
|
+
if ((await vcs.resolveRevision(ref))?.commit !== pins.head) return void 0;
|
|
3941
|
+
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
|
3942
|
+
} catch {
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
/** `pinned` refreshes a stored target: a worktree base ref resolves to its
|
|
3947
|
+
* current fork point, and keeps the pinned one if the ref is gone. */
|
|
3948
|
+
async resolveTarget(target, pinned) {
|
|
3949
|
+
const vcs = await this.vcs(target.repositoryId);
|
|
3950
|
+
if (!vcs) throw unavailableCheckout();
|
|
3951
|
+
if (target.kind === "commits") {
|
|
3952
|
+
const head = await vcs.resolveRevision(target.head);
|
|
3953
|
+
if (!head) throw new ReviewInputError("Head revision does not exist.");
|
|
3954
|
+
const base = target.base === void 0 ? head : await vcs.resolveRevision(target.base);
|
|
3955
|
+
if (!base) throw new ReviewInputError("Base revision does not exist.");
|
|
3956
|
+
const resolved = {
|
|
3957
|
+
...target,
|
|
3958
|
+
head: head.commit
|
|
3959
|
+
};
|
|
3960
|
+
if (target.base !== void 0) resolved.base = base.commit;
|
|
3961
|
+
return {
|
|
3962
|
+
target: resolved,
|
|
3963
|
+
pins: {
|
|
3964
|
+
repositoryId: target.repositoryId,
|
|
3965
|
+
base: base.commit,
|
|
3966
|
+
head: head.commit
|
|
3967
|
+
}
|
|
3968
|
+
};
|
|
3969
|
+
}
|
|
3970
|
+
const { revision, commit, forks } = await this.worktreeState(target.repositoryId, vcs);
|
|
3971
|
+
const resolved = { ...target };
|
|
3972
|
+
const fork = () => {
|
|
3973
|
+
const key = `${target.base ?? ""}\0${commit}`;
|
|
3974
|
+
let found = forks.get(key);
|
|
3975
|
+
if (!found) {
|
|
3976
|
+
found = this.worktreeBase(vcs, target.base, commit);
|
|
3977
|
+
forks.set(key, found);
|
|
3978
|
+
found.catch((error) => {
|
|
3979
|
+
if (!(error instanceof ReviewInputError)) forks.delete(key);
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
return found;
|
|
3983
|
+
};
|
|
3984
|
+
const base = pinned && target.base === void 0 ? pinned.base === "empty-worktree-baseline" ? EMPTY_SOURCE : commit : commit === "empty-worktree-baseline" && target.base === void 0 ? EMPTY_SOURCE : await fork().then((found) => {
|
|
3985
|
+
resolved.base = found.ref;
|
|
3986
|
+
return found.commit;
|
|
3987
|
+
}, (error) => {
|
|
3988
|
+
if (pinned && error instanceof ReviewInputError) return pinned.base;
|
|
3989
|
+
throw error;
|
|
3990
|
+
});
|
|
3991
|
+
return {
|
|
3992
|
+
target: resolved,
|
|
3993
|
+
pins: {
|
|
3994
|
+
repositoryId: target.repositoryId,
|
|
3995
|
+
base,
|
|
3996
|
+
head: commit,
|
|
3997
|
+
worktreeRevision: revision
|
|
3998
|
+
}
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
/** The fork point from `ref`, by default the repository's default branch. */
|
|
4002
|
+
async worktreeBase(vcs, ref, head) {
|
|
4003
|
+
const branch = ref ?? (await vcs.defaultBranch())?.ref;
|
|
4004
|
+
if (!branch) throw new ReviewInputError("No default branch found (tried origin/HEAD, origin/main, origin/master, main and master). Supply target.base.");
|
|
4005
|
+
if (!await vcs.resolveRevision(branch)) throw new ReviewInputError("Base revision does not exist.");
|
|
4006
|
+
const fork = head === "empty-worktree-baseline" ? null : await vcs.mergeBase(branch, head);
|
|
4007
|
+
if (!fork) throw new ReviewInputError(`${branch} shares no history with the checkout's HEAD.`);
|
|
4008
|
+
return {
|
|
4009
|
+
ref: branch,
|
|
4010
|
+
commit: fork.commit
|
|
4011
|
+
};
|
|
4012
|
+
}
|
|
4013
|
+
/** The PR's current comparison, fetched into a registered checkout of its repository. */
|
|
4014
|
+
async resolvePullRequest(url, repository) {
|
|
4015
|
+
const deps = this.options.pullRequests ?? defaultPullRequestDeps;
|
|
4016
|
+
const { host, slug, number } = pullRequestAddress(url);
|
|
4017
|
+
const checkout = await this.pullRequestCheckout(host, slug, repository, deps);
|
|
4018
|
+
const pullRequest = await readPullRequest(url, deps);
|
|
4019
|
+
const { head, base } = await fetchPullRequest({
|
|
4020
|
+
...checkout,
|
|
4021
|
+
pullRequest
|
|
4022
|
+
}, deps);
|
|
4023
|
+
const repositoryId = checkout.repositoryId;
|
|
4024
|
+
return {
|
|
4025
|
+
target: {
|
|
4026
|
+
kind: "commits",
|
|
4027
|
+
repositoryId,
|
|
4028
|
+
head,
|
|
4029
|
+
base
|
|
4030
|
+
},
|
|
4031
|
+
pins: {
|
|
4032
|
+
repositoryId,
|
|
4033
|
+
base,
|
|
4034
|
+
head
|
|
4035
|
+
},
|
|
4036
|
+
title: pullRequest.title.trim() || `PR #${number}`
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
/** A registered checkout with a remote for host/owner/repo, and that remote. */
|
|
4040
|
+
async pullRequestCheckout(host, slug, repository, deps) {
|
|
4041
|
+
const registered = this.store.repositories();
|
|
4042
|
+
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)];
|
|
4043
|
+
if (repository.id && candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
|
|
4044
|
+
for (const { id } of candidates) {
|
|
4045
|
+
if (!existsSync(this.store.repositoryPath(id))) continue;
|
|
4046
|
+
const vcs = await this.vcs(id);
|
|
4047
|
+
const gitDir = vcs && await gitCommonDir(vcs.rootPath);
|
|
4048
|
+
if (!vcs || !gitDir) continue;
|
|
4049
|
+
const remote = (await githubRemotes(gitDir, deps)).find((entry) => entry.host === host && entry.slug.toLowerCase() === slug.toLowerCase());
|
|
4050
|
+
if (remote) return {
|
|
4051
|
+
repositoryId: id,
|
|
4052
|
+
rootPath: vcs.rootPath,
|
|
4053
|
+
gitDir,
|
|
4054
|
+
kind: vcs.kind,
|
|
4055
|
+
remote: remote.name
|
|
4056
|
+
};
|
|
4057
|
+
}
|
|
4058
|
+
const name = host === "github.com" ? slug : `${host}/${slug}`;
|
|
4059
|
+
throw new ReviewInputError(repository.id ? `That checkout has no GitHub remote for ${name}. Add one, or omit repositoryId.` : `No registered checkout has a GitHub remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`, 404);
|
|
4060
|
+
}
|
|
4061
|
+
async resolvePins(repositoryId, base, head) {
|
|
4062
|
+
const vcs = await this.vcs(repositoryId);
|
|
4063
|
+
const [left, right] = vcs ? await Promise.all([vcs.resolveRevision(base), vcs.resolveRevision(head)]) : [null, null];
|
|
4064
|
+
if (!left || !right) throw new ReviewInputError(`Base or head revision does not exist in the local checkout (${!left ? `base: ${base}` : `head: ${head}`}). Fetch the requested commits before authoring; in CI, configure checkout depth to include both revisions.`);
|
|
4065
|
+
return {
|
|
4066
|
+
repositoryId,
|
|
4067
|
+
base: left.commit,
|
|
4068
|
+
head: right.commit
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
/** Files each side's commit changed from one set of pins to another, under
|
|
4072
|
+
* both names for a rename. A worktree has no commit to diff from. */
|
|
4073
|
+
async filesChangedBetween(from, to) {
|
|
4074
|
+
if (from.repositoryId !== to.repositoryId || from.worktreeRevision || to.worktreeRevision) return void 0;
|
|
4075
|
+
const target = await this.vcsTarget(to.repositoryId);
|
|
4076
|
+
const changed = async (side) => {
|
|
4077
|
+
if (from[side] === to[side]) return /* @__PURE__ */ new Set();
|
|
4078
|
+
const files = await diffFileSummariesTrees({
|
|
4079
|
+
...target,
|
|
4080
|
+
baseRef: from[side],
|
|
4081
|
+
headRef: to[side]
|
|
4082
|
+
});
|
|
4083
|
+
return new Set(files.flatMap((file) => file.previousPath ? [file.previousPath, file.path] : [file.path]));
|
|
4084
|
+
};
|
|
4085
|
+
const [base, head] = await Promise.all([changed("base"), changed("head")]);
|
|
4086
|
+
return {
|
|
4087
|
+
base,
|
|
4088
|
+
head
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
async validatePins(pins) {
|
|
4092
|
+
if (pins.worktreeRevision) {
|
|
4093
|
+
if (!await this.vcs(pins.repositoryId)) throw unavailableCheckout();
|
|
4094
|
+
return;
|
|
4095
|
+
}
|
|
4096
|
+
const resolved = await this.resolvePins(pins.repositoryId, pins.base, pins.head);
|
|
4097
|
+
if (resolved.base !== pins.base || resolved.head !== pins.head) throw new ReviewInputError("Use resolved commit IDs, not moving branch names.");
|
|
4098
|
+
}
|
|
4099
|
+
async file(pins, side, file, allowBinary = false) {
|
|
4100
|
+
checkRelativePath(file);
|
|
4101
|
+
const commit = pins[side];
|
|
4102
|
+
const vcs = await this.vcs(pins.repositoryId);
|
|
4103
|
+
const text = pins.worktreeRevision && side === "head" ? vcs ? await readWorkingFile(vcs.rootPath, file) : null : commit === "empty-worktree-baseline" ? null : vcs ? await readFileAtCommit({
|
|
4104
|
+
rootPath: vcs.rootPath,
|
|
4105
|
+
kind: vcs.kind,
|
|
4106
|
+
commit,
|
|
4107
|
+
relativePath: file,
|
|
4108
|
+
reader: this.reader(pins.repositoryId, vcs)
|
|
4109
|
+
}) : null;
|
|
4110
|
+
if (text === null) throw new ReviewInputError("File is unavailable at the pinned commit.", 404);
|
|
4111
|
+
if (!allowBinary && text.includes("\0")) throw new ReviewInputError("Binary files cannot be used as code references.");
|
|
4112
|
+
return {
|
|
4113
|
+
file,
|
|
4114
|
+
side,
|
|
4115
|
+
commit,
|
|
4116
|
+
text
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
async tree(pins, side, directory) {
|
|
4120
|
+
inputError(() => checkSourcePath(directory));
|
|
4121
|
+
const prefix = directory ? directory.replace(/\/$/, "") + "/" : "";
|
|
4122
|
+
const entries = /* @__PURE__ */ new Map();
|
|
4123
|
+
const vcs = pins.worktreeRevision ? await this.vcs(pins.repositoryId) : void 0;
|
|
4124
|
+
if (pins.worktreeRevision && !vcs) throw unavailableCheckout();
|
|
4125
|
+
const files = vcs && side === "head" ? await workingFiles(vcs) : pins[side] === "empty-worktree-baseline" ? [] : await this.trackedFilesAt(pins.repositoryId, pins[side]);
|
|
4126
|
+
for (const file of files) {
|
|
4127
|
+
if (!file.startsWith(prefix)) continue;
|
|
4128
|
+
const relative = file.slice(prefix.length);
|
|
4129
|
+
const name = relative.split("/", 1)[0];
|
|
4130
|
+
entries.set(name, {
|
|
4131
|
+
path: prefix + name,
|
|
4132
|
+
kind: relative.includes("/") ? "directory" : "file"
|
|
4133
|
+
});
|
|
4134
|
+
}
|
|
4135
|
+
if (directory && entries.size === 0) throw new ReviewInputError("Directory is unavailable at the pinned commit.", 404);
|
|
4136
|
+
return [...entries.values()];
|
|
4137
|
+
}
|
|
4138
|
+
trackedFilesAt(repositoryId, ref) {
|
|
4139
|
+
const key = `${repositoryId}\0${ref}`;
|
|
4140
|
+
let files = this.trackedFiles.get(key);
|
|
4141
|
+
if (!files) {
|
|
4142
|
+
files = this.vcs(repositoryId).then((vcs) => {
|
|
4143
|
+
if (!vcs) {
|
|
4144
|
+
this.trackedFiles.delete(key);
|
|
4145
|
+
return [];
|
|
4146
|
+
}
|
|
4147
|
+
return listTrackedFilesAtCommit({
|
|
4148
|
+
rootPath: vcs.rootPath,
|
|
4149
|
+
kind: vcs.kind,
|
|
4150
|
+
commit: ref
|
|
4151
|
+
});
|
|
4152
|
+
});
|
|
4153
|
+
this.trackedFiles.set(key, files);
|
|
4154
|
+
files.catch(() => this.trackedFiles.delete(key));
|
|
4155
|
+
}
|
|
4156
|
+
return files;
|
|
4157
|
+
}
|
|
4158
|
+
async validateSources(pins, sources) {
|
|
4159
|
+
const files = /* @__PURE__ */ new Map();
|
|
4160
|
+
for (const input of sources) {
|
|
4161
|
+
const source = fileLineRangeSchema.parse(input);
|
|
4162
|
+
const key = JSON.stringify([
|
|
4163
|
+
pins[source.side],
|
|
4164
|
+
pins.worktreeRevision ? source.side : null,
|
|
4165
|
+
source.file
|
|
4166
|
+
]);
|
|
4167
|
+
const ranges = files.get(key);
|
|
4168
|
+
if (ranges) ranges.push(source);
|
|
4169
|
+
else files.set(key, [source]);
|
|
4170
|
+
}
|
|
4171
|
+
for (const ranges of files.values()) {
|
|
4172
|
+
const first = ranges[0];
|
|
4173
|
+
const file = await this.file(pins, first.side, first.file);
|
|
4174
|
+
for (const source of ranges) sliceRange(file, source);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
async quote(pins, source) {
|
|
4178
|
+
source = fileLineRangeSchema.parse(source);
|
|
4179
|
+
return sliceRange(await this.file(pins, source.side, source.file), source);
|
|
4180
|
+
}
|
|
4181
|
+
/** Every source reference must exist at the pins; only code peeks must also
|
|
4182
|
+
* show something. */
|
|
4183
|
+
async validateSource(pins, source, options) {
|
|
4184
|
+
const quote = await this.quote(pins, source);
|
|
4185
|
+
if (options.peek) inputError(() => requireVisibleSource(quote.text, source));
|
|
4186
|
+
}
|
|
4187
|
+
coverageCache = /* @__PURE__ */ new Map();
|
|
4188
|
+
coverageAbort = new AbortController();
|
|
4189
|
+
coverageListeners = /* @__PURE__ */ new Set();
|
|
4190
|
+
coverageRevision = 0;
|
|
4191
|
+
subscribeCoverage(listener) {
|
|
4192
|
+
this.coverageListeners.add(listener);
|
|
4193
|
+
return () => {
|
|
4194
|
+
this.coverageListeners.delete(listener);
|
|
4195
|
+
};
|
|
4196
|
+
}
|
|
4197
|
+
/** Start shared coverage work without occupying an HTTP request until it completes. */
|
|
4198
|
+
coveragePending(reviewId, pins, mode) {
|
|
4199
|
+
const key = JSON.stringify([pins, mode]);
|
|
4200
|
+
const existing = this.coverageCache.get(key);
|
|
4201
|
+
if (existing?.state === "error") {
|
|
4202
|
+
this.coverageCache.delete(key);
|
|
4203
|
+
throw existing.error;
|
|
4204
|
+
}
|
|
4205
|
+
this.coverage(reviewId, pins, mode).catch(() => {});
|
|
4206
|
+
return this.coverageCache.get(key).state === "pending";
|
|
4207
|
+
}
|
|
4208
|
+
coverageSnapshot(reviewId, pins, mode) {
|
|
4209
|
+
return {
|
|
4210
|
+
pending: this.coveragePending(reviewId, pins, mode),
|
|
4211
|
+
comparison: this.coverageCache.get(JSON.stringify([pins, mode])).partial ?? {
|
|
4212
|
+
files: [],
|
|
4213
|
+
fileSources: /* @__PURE__ */ new Map(),
|
|
4214
|
+
alignments: /* @__PURE__ */ new Map()
|
|
4215
|
+
}
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
coverageNotification;
|
|
4219
|
+
notifyCoverage() {
|
|
4220
|
+
if (this.closed || this.coverageNotification) return;
|
|
4221
|
+
this.coverageNotification = setTimeout(() => {
|
|
4222
|
+
this.coverageNotification = void 0;
|
|
4223
|
+
this.coverageRevision++;
|
|
4224
|
+
for (const listener of this.coverageListeners) listener();
|
|
4225
|
+
}, 50);
|
|
4226
|
+
}
|
|
4227
|
+
coverage(reviewId, pins, mode) {
|
|
4228
|
+
const key = JSON.stringify([pins, mode]);
|
|
4229
|
+
let entry = this.coverageCache.get(key);
|
|
4230
|
+
if (!entry || entry.state === "error") {
|
|
4231
|
+
const promise = comparisonCoverage(this, reviewId, pins, mode, this.coverageAbort.signal, (partial) => {
|
|
4232
|
+
const current = this.coverageCache.get(key);
|
|
4233
|
+
if (current) current.partial = partial;
|
|
4234
|
+
this.notifyCoverage();
|
|
4235
|
+
});
|
|
4236
|
+
entry = {
|
|
4237
|
+
promise,
|
|
4238
|
+
state: "pending"
|
|
4239
|
+
};
|
|
4240
|
+
this.coverageCache.set(key, entry);
|
|
4241
|
+
const current = entry;
|
|
4242
|
+
const settled = (state, error) => {
|
|
4243
|
+
current.state = state;
|
|
4244
|
+
current.error = error;
|
|
4245
|
+
this.notifyCoverage();
|
|
4246
|
+
for (const [cachedKey, cached] of this.coverageCache) {
|
|
4247
|
+
if (this.coverageCache.size <= 32) break;
|
|
4248
|
+
if (cached.state !== "pending" && cachedKey !== key) this.coverageCache.delete(cachedKey);
|
|
4249
|
+
}
|
|
4250
|
+
};
|
|
4251
|
+
promise.then((value) => {
|
|
4252
|
+
current.partial = value;
|
|
4253
|
+
if (!this.closed) this.store.setDiffStats(pins, {
|
|
4254
|
+
fileCount: value.files.length,
|
|
4255
|
+
additions: value.files.reduce((sum, file) => sum + structuralChangeCounts(file.changed).added, 0),
|
|
4256
|
+
deletions: value.files.reduce((sum, file) => sum + structuralChangeCounts(file.changed).removed, 0)
|
|
4257
|
+
}, mode);
|
|
4258
|
+
settled("ready");
|
|
4259
|
+
}, (error) => settled("error", error));
|
|
4260
|
+
}
|
|
4261
|
+
return entry.promise;
|
|
4262
|
+
}
|
|
4263
|
+
async changes(pins, file) {
|
|
4264
|
+
if (file === void 0) return this.summaries(pins);
|
|
4265
|
+
checkRelativePath(file);
|
|
4266
|
+
return this.rawPatch(pins, { paths: [file] });
|
|
4267
|
+
}
|
|
4268
|
+
async summaries(pins) {
|
|
4269
|
+
if (pins.worktreeRevision) return diffFileSummariesWorkingTree(await this.worktreeInput(pins));
|
|
4270
|
+
return diffFileSummariesTrees({
|
|
4271
|
+
...await this.vcsTarget(pins.repositoryId),
|
|
4272
|
+
baseRef: pins.base,
|
|
4273
|
+
headRef: pins.head
|
|
4274
|
+
});
|
|
4275
|
+
}
|
|
4276
|
+
/** Raw Git patch text for exact filenames, or every change. */
|
|
4277
|
+
async rawPatch(pins, options) {
|
|
4278
|
+
if (pins.worktreeRevision) return diffWorkingTree({
|
|
4279
|
+
...await this.worktreeInput(pins),
|
|
4280
|
+
...options
|
|
4281
|
+
});
|
|
4282
|
+
return diffTrees({
|
|
4283
|
+
...await this.vcsTarget(pins.repositoryId),
|
|
4284
|
+
baseRef: pins.base,
|
|
4285
|
+
headRef: pins.head,
|
|
4286
|
+
...options,
|
|
4287
|
+
literalPaths: options.paths !== void 0
|
|
4288
|
+
});
|
|
4289
|
+
}
|
|
4290
|
+
async worktreeInput(pins) {
|
|
4291
|
+
const vcs = await this.vcs(pins.repositoryId);
|
|
4292
|
+
if (!vcs) throw unavailableCheckout();
|
|
4293
|
+
return {
|
|
4294
|
+
rootPath: vcs.rootPath,
|
|
4295
|
+
kind: vcs.kind,
|
|
4296
|
+
baseRef: pins.base === "empty-worktree-baseline" ? void 0 : pins.base,
|
|
4297
|
+
headRef: pins.head === "empty-worktree-baseline" ? void 0 : pins.head
|
|
4298
|
+
};
|
|
4299
|
+
}
|
|
4300
|
+
commits(pins) {
|
|
4301
|
+
if (pins.base === pins.head || pins.base === "empty-worktree-baseline" || pins.head === "empty-worktree-baseline") return Promise.resolve([]);
|
|
4302
|
+
const key = `${pins.repositoryId}:${pins.base}:${pins.head}`;
|
|
4303
|
+
const cached = this.commitRanges.get(key);
|
|
4304
|
+
if (cached) return cached;
|
|
4305
|
+
const pending = this.readCommitRange(pins).catch((cause) => {
|
|
4306
|
+
this.commitRanges.delete(key);
|
|
4307
|
+
throw cause;
|
|
4308
|
+
});
|
|
4309
|
+
this.commitRanges.set(key, pending);
|
|
4310
|
+
return pending;
|
|
4311
|
+
}
|
|
4312
|
+
async readCommitRange(pins) {
|
|
4313
|
+
return listCommitRange({
|
|
4314
|
+
...await this.vcsTarget(pins.repositoryId),
|
|
4315
|
+
baseRef: pins.base,
|
|
4316
|
+
headRef: pins.head
|
|
4317
|
+
});
|
|
4318
|
+
}
|
|
4319
|
+
async comparison(pins, commit) {
|
|
4320
|
+
if (!commit) return pins;
|
|
4321
|
+
const selected = (await this.commits(pins)).find((item) => item.commit === commit);
|
|
4322
|
+
if (!selected) throw new ReviewInputError("The selected commit is not part of this review version.", 404);
|
|
4323
|
+
return {
|
|
4324
|
+
repositoryId: pins.repositoryId,
|
|
4325
|
+
base: selected.parentCommit,
|
|
4326
|
+
head: selected.commit
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
async map(documentPins, resourceId) {
|
|
4330
|
+
await this.validateResource(documentPins, {
|
|
4331
|
+
type: "software_map",
|
|
4332
|
+
mapVersionId: resourceId
|
|
4333
|
+
});
|
|
4334
|
+
const resource = this.store.resource(resourceId);
|
|
4335
|
+
const saved = JSON.parse(Buffer.from(resource.data).toString());
|
|
4336
|
+
const pins = documentPins ?? {
|
|
4337
|
+
repositoryId: resource.repositoryId,
|
|
4338
|
+
base: saved.commit,
|
|
4339
|
+
head: saved.commit
|
|
4340
|
+
};
|
|
4341
|
+
const target = await this.vcsTarget(pins.repositoryId);
|
|
4342
|
+
const resolved = await resolveSoftwareMapDiffCounts({
|
|
4343
|
+
patch: pins.worktreeRevision ? await diffWorkingTree({
|
|
4344
|
+
...target,
|
|
4345
|
+
kind: target.kind ?? "git",
|
|
4346
|
+
baseRef: pins.base === "empty-worktree-baseline" ? void 0 : pins.base,
|
|
4347
|
+
headRef: pins.head === "empty-worktree-baseline" ? void 0 : pins.head
|
|
4348
|
+
}) : void 0,
|
|
4349
|
+
sourceRootPath: target.rootPath,
|
|
4350
|
+
sourceVcsKind: target.kind,
|
|
4351
|
+
baseRef: pins.base,
|
|
4352
|
+
headRef: pins.head,
|
|
4353
|
+
side: saved.side,
|
|
4354
|
+
codeElements: saved.elements.filter((element) => element.type === "codeElement"),
|
|
4355
|
+
coverageClaims: saved.elements.flatMap((element) => element.coverage ? [{
|
|
4356
|
+
path: element.path,
|
|
4357
|
+
...element.coverage
|
|
4358
|
+
}] : [])
|
|
4359
|
+
});
|
|
4360
|
+
return {
|
|
4361
|
+
...saved,
|
|
4362
|
+
countsByElementPath: resolved.countsByElementPath,
|
|
4363
|
+
unmappedByElementPath: resolved.unmappedByElementPath
|
|
4364
|
+
};
|
|
4365
|
+
}
|
|
4366
|
+
async upload(value) {
|
|
4367
|
+
const input = uploadSchema.parse(value);
|
|
4368
|
+
this.store.repositoryPath(input.repositoryId);
|
|
4369
|
+
let mimeType = "application/json", data;
|
|
4370
|
+
switch (input.kind) {
|
|
4371
|
+
case "image":
|
|
4372
|
+
data = await decodeImage(Buffer.from(input.base64, "base64"));
|
|
4373
|
+
mimeType = "image/png";
|
|
4374
|
+
break;
|
|
4375
|
+
case "trace":
|
|
4376
|
+
if (new Set(input.trace.events.map((event) => event.id)).size !== input.trace.events.length) throw new ReviewInputError("Trace event IDs must be unique.");
|
|
4377
|
+
data = Buffer.from(JSON.stringify({
|
|
4378
|
+
...input.trace,
|
|
4379
|
+
provenance: "client_supplied"
|
|
4380
|
+
}));
|
|
4381
|
+
break;
|
|
4382
|
+
case "map": {
|
|
4383
|
+
if (input.pins.repositoryId !== input.repositoryId) throw new ReviewInputError("Map belongs to a different repository.");
|
|
4384
|
+
await this.validatePins(input.pins);
|
|
4385
|
+
let model;
|
|
4386
|
+
try {
|
|
4387
|
+
model = defineSoftwareMap(input.model);
|
|
4388
|
+
} catch (error) {
|
|
4389
|
+
if (error instanceof SoftwareModelValidationError) throw new ReviewInputError(error.message);
|
|
4390
|
+
throw error;
|
|
4391
|
+
}
|
|
4392
|
+
const files = /* @__PURE__ */ new Map();
|
|
4393
|
+
await Promise.all(model.elements.flatMap((element) => (element.sourceRanges ?? []).map(async (source) => {
|
|
4394
|
+
const range = fileLineRangeSchema.parse({
|
|
4395
|
+
...source,
|
|
4396
|
+
side: input.side
|
|
4397
|
+
});
|
|
4398
|
+
let read = files.get(range.file);
|
|
4399
|
+
if (!read) files.set(range.file, read = this.file(input.pins, range.side, range.file));
|
|
4400
|
+
sliceRange(await read, range);
|
|
4401
|
+
})));
|
|
4402
|
+
data = Buffer.from(JSON.stringify({
|
|
4403
|
+
commit: input.pins[input.side],
|
|
4404
|
+
side: input.side,
|
|
4405
|
+
elements: model.elements,
|
|
4406
|
+
relationships: model.relationships
|
|
4407
|
+
}));
|
|
4408
|
+
break;
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
return this.store.putResource(input.id, input.repositoryId, input.kind, mimeType, data);
|
|
4412
|
+
}
|
|
4413
|
+
/** A resource must belong to the document's repository when it has one;
|
|
4414
|
+
* a document without pins may use any registered repository's resources. */
|
|
4415
|
+
async validateResource(pins, block) {
|
|
4416
|
+
const reference = resourceReference(block);
|
|
4417
|
+
if (!reference) return;
|
|
4418
|
+
const { id, kind } = reference;
|
|
4419
|
+
const resource = this.store.resource(id);
|
|
4420
|
+
if (pins && resource.repositoryId !== pins.repositoryId || resource.kind !== kind) throw new ReviewInputError("Resource belongs to a different repository or component type.");
|
|
4421
|
+
if (!pins) this.store.repositoryPath(resource.repositoryId);
|
|
4422
|
+
if (block.type === "trace_quote") {
|
|
4423
|
+
const event = traceSchema.parse(JSON.parse(Buffer.from(resource.data).toString())).events.find((event) => event.id === block.eventId);
|
|
4424
|
+
if (!event || !textIncludesQuote(event.text, block.text)) throw new ReviewInputError("Quote does not match the retained trace event.");
|
|
4425
|
+
}
|
|
4426
|
+
if (block.type === "software_map") {
|
|
4427
|
+
const map = JSON.parse(Buffer.from(resource.data).toString());
|
|
4428
|
+
if (pins && map.commit !== pins[map.side]) throw new ReviewInputError("Map does not match this review's source pins.");
|
|
4429
|
+
if (block.focusElementId && !map.elements.some((element) => element.id === block.focusElementId || element.path === block.focusElementId)) throw new ReviewInputError("Map focus element does not exist.");
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
};
|
|
4433
|
+
/** The pure source checks throw their own error; API clients see it as input. */
|
|
4434
|
+
function inputError(run) {
|
|
4435
|
+
try {
|
|
4436
|
+
return run();
|
|
4437
|
+
} catch (error) {
|
|
4438
|
+
if (error instanceof SourceRangeError) throw new ReviewInputError(error.message);
|
|
4439
|
+
throw error;
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
function openLocalReviewStore(databasePath, options = {}) {
|
|
4443
|
+
const store = new ReviewStore(databasePath, {
|
|
4444
|
+
projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
|
|
4445
|
+
resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
|
|
4446
|
+
resolvePullRequest: (url, repository) => data.resolvePullRequest(url, repository),
|
|
4447
|
+
headBranch: (pins, headRef) => data.headBranch(pins, headRef),
|
|
4448
|
+
sourcePins: (snapshot) => data.sourcePins(snapshot),
|
|
4449
|
+
unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
|
|
4450
|
+
validatePins: (pins) => data.validatePins(pins),
|
|
4451
|
+
filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
|
|
4452
|
+
validateSource: (pins, source, options) => data.validateSource(pins, source, options),
|
|
4453
|
+
validateResource: (pins, block) => data.validateResource(pins, block)
|
|
4454
|
+
});
|
|
4455
|
+
let data;
|
|
4456
|
+
try {
|
|
4457
|
+
data = new LocalReviewData(store, {
|
|
4458
|
+
...options,
|
|
4459
|
+
workspaceDatabase: `${databasePath}.workspaces`
|
|
4460
|
+
});
|
|
4461
|
+
} catch (error) {
|
|
4462
|
+
store.close();
|
|
4463
|
+
throw error;
|
|
4464
|
+
}
|
|
4465
|
+
return {
|
|
4466
|
+
store,
|
|
4467
|
+
data
|
|
4468
|
+
};
|
|
4469
|
+
}
|
|
4470
|
+
/** The parts of a VS Code workspace file the navigator owns; everything else
|
|
4471
|
+
* VS Code or the user adds is kept as is. */
|
|
4472
|
+
const codeWorkspaceSchema = z.looseObject({
|
|
4473
|
+
folders: z.array(z.json()).default([]),
|
|
4474
|
+
settings: z.record(z.string(), z.json()).default({})
|
|
4475
|
+
});
|
|
4476
|
+
/** A workspace file's contents, undefined when it does not exist, or null when
|
|
4477
|
+
* it is not plain JSON (VS Code accepts comments), which is left untouched. */
|
|
4478
|
+
async function readWorkspace(file) {
|
|
4479
|
+
const text = await readFile(file, "utf8").catch((error) => {
|
|
4480
|
+
if (isMissingFileError(error)) return void 0;
|
|
4481
|
+
throw error;
|
|
4482
|
+
});
|
|
4483
|
+
if (text === void 0) return void 0;
|
|
4484
|
+
try {
|
|
4485
|
+
return codeWorkspaceSchema.safeParse(JSON.parse(text)).data ?? null;
|
|
4486
|
+
} catch {
|
|
4487
|
+
return null;
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
//#endregion
|
|
4491
|
+
export { invalidateStructuralComparisons as _, inspectSnapshot as a, installFullDiffr as b, askAgentIds as c, askQuestionSchema as d, AgentSelectionSchema as f, activityUpdateSchema as g, activityEndSchema as h, commandSchema as i, askChoiceKinds as l, activityBeginSchema as m, uploadSchema as n, initializeReviewStoreSchema as o, selectionMarkdown as p, SCRATCHPAD_ID as r, applyAskChange as s, openLocalReviewStore as t, askPicksSchema as u, diffrExecutable as v, diffrMissingError as y };
|