@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261021.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{account-alias-CprZojXd.js → account-alias-CisiYUZT.js} +13 -8
- package/dist/agent-cli-BglNRDZN.js +2 -0
- package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
- package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
- package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
- package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
- package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
- package/dist/build-info.json +1 -1
- package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
- package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
- package/dist/cli.js +4 -3
- package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
- package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
- package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
- package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
- package/dist/lens-selection-BxPk53c8.js +214 -0
- package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
- package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
- package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
- package/dist/profile-Dbyidrcr.js +2 -0
- package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
- package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
- package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
- package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
- package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
- package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
- package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
- package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
- package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
- package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
- package/dist/runtime-CF4JXdb8.js +27 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
- package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
- package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
- package/dist/server/desktop-host.js +28 -104
- package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
- package/dist/sharing/index.d.ts +484 -74
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
- package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
- package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
- package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
- package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
- package/instructions/authoring.md +20 -5
- package/package.json +4 -4
- package/src/agent-selection.ts +6 -0
- package/src/ask/agents.ts +13 -1
- package/src/ask/checkout-files.ts +2 -5
- package/src/ask/thread-state.ts +4 -0
- package/src/ask/thread.ts +61 -31
- package/src/ask/threads.ts +24 -3
- package/src/cli-install.ts +13 -2
- package/src/cli-runner.ts +4 -1
- package/src/cli.ts +4 -1
- package/src/diff-selection-migration.ts +48 -34
- package/src/heading-anchors.ts +42 -0
- package/src/lens-selection.ts +38 -2
- package/src/markdown.ts +42 -0
- package/src/posthog-capture-client.ts +1 -1
- package/src/review-api/README.md +52 -13
- package/src/review-api/activity.ts +1 -4
- package/src/review-api/agent-cli.ts +9 -1
- package/src/review-api/anchor-quotes.ts +14 -7
- package/src/review-api/ask-history.ts +118 -51
- package/src/review-api/authoring-tools.ts +45 -30
- package/src/review-api/azure-devops-provider.ts +2 -20
- package/src/review-api/azure-remote.ts +21 -0
- package/src/review-api/blocks/definition.ts +1 -1
- package/src/review-api/blocks/markdown.ts +2 -2
- package/src/review-api/board-order.ts +144 -0
- package/src/review-api/checkout-groups.ts +586 -0
- package/src/review-api/document-headings.ts +58 -27
- package/src/review-api/document-text.ts +31 -2
- package/src/review-api/document.ts +362 -67
- package/src/review-api/foreign-references.ts +328 -0
- package/src/review-api/github-provider.ts +51 -13
- package/src/review-api/http.ts +734 -135
- package/src/review-api/instructions.ts +0 -19
- package/src/review-api/link-preview.ts +113 -0
- package/src/review-api/local-data.ts +310 -46
- package/src/review-api/mcp.ts +7 -13
- package/src/review-api/onboarding.ts +1 -1
- package/src/review-api/profile.ts +37 -6
- package/src/review-api/public-tools.ts +24 -2
- package/src/review-api/pull-request.ts +82 -49
- package/src/review-api/request-origin.ts +10 -0
- package/src/review-api/review-progress.ts +61 -27
- package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
- package/src/review-api/store-migrations.ts +355 -0
- package/src/review-api/store-schema.ts +38 -29
- package/src/review-api/store.ts +2176 -495
- package/src/review-api/worktree-events.ts +3 -5
- package/src/review-api/worktree-source.ts +3 -7
- package/src/review-api/worktree-structural.integration.ts +72 -0
- package/src/review-stack.ts +15 -23
- package/src/runtime.ts +4 -30
- package/src/server/desktop-host.ts +6 -1
- package/src/server/desktop-server.ts +1 -36
- package/src/server/headless-host.ts +0 -2
- package/src/server/hono-http.ts +2 -1
- package/src/server/review-gateway-list.ts +3 -5
- package/src/server/review-gateway-memory.ts +1 -1
- package/src/server/review-lifecycle-telemetry.ts +2 -2
- package/src/server/review-server-core.ts +1 -5
- package/src/server/structural-comparisons.ts +47 -0
- package/src/sharing/export.ts +36 -7
- package/src/sharing/host.ts +34 -14
- package/src/sharing/import.ts +156 -24
- package/src/sharing/repository.ts +143 -24
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +110 -19
- package/src/ui-telemetry-events.ts +44 -18
- package/dist/agent-cli-C6SPSII0.js +0 -2
- package/dist/profile-CYPmvBoA.js +0 -2
- package/dist/runtime-CKiWhGrf.js +0 -52
- package/instructions/scratchpad.md +0 -23
- package/src/review-preferences.ts +0 -88
- package/src/server/legacy-tutorial-cleanup.ts +0 -38
|
@@ -9,7 +9,6 @@ import { COMPONENT_REFERENCE } from "./component-reference.js";
|
|
|
9
9
|
export const INSTRUCTION_TOPICS = [
|
|
10
10
|
"authoring",
|
|
11
11
|
"file-lenses",
|
|
12
|
-
"scratchpad",
|
|
13
12
|
"trace-archaeology",
|
|
14
13
|
] as const;
|
|
15
14
|
|
|
@@ -20,15 +19,9 @@ export const instructionsQuerySchema = z.strictObject({
|
|
|
20
19
|
});
|
|
21
20
|
|
|
22
21
|
export interface InstructionContext {
|
|
23
|
-
desktopAvailable: boolean;
|
|
24
|
-
scratchpadEnabled: boolean;
|
|
25
22
|
traceEnabled: boolean;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
export function scratchpadAvailable(context: InstructionContext): boolean {
|
|
29
|
-
return context.desktopAvailable && context.scratchpadEnabled;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
25
|
const cache = new Map<string, Promise<string>>();
|
|
33
26
|
|
|
34
27
|
function read(root: string, name: string): Promise<string> {
|
|
@@ -51,24 +44,12 @@ export async function renderInstructions(
|
|
|
51
44
|
context: InstructionContext,
|
|
52
45
|
root = findReviewPackageRoot(import.meta.url),
|
|
53
46
|
): Promise<string> {
|
|
54
|
-
if (topic === "scratchpad" && !scratchpadAvailable(context)) {
|
|
55
|
-
return "The Whiteboard scratchpad is turned off or Whiteboard Desktop is not running. Answer in chat; the scratchpad can be turned on in Whiteboard Desktop Settings.";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
47
|
if (topic === "trace-archaeology" && !context.traceEnabled)
|
|
59
48
|
return "Trace capture is off on this machine, so no agent traces are available. It can be turned on in Whiteboard Desktop Settings under Experimental Features.";
|
|
60
49
|
|
|
61
|
-
if (topic === "scratchpad")
|
|
62
|
-
return `${await read(root, topic)}\n\n${COMPONENT_REFERENCE}`;
|
|
63
|
-
|
|
64
50
|
if (topic !== "authoring") return read(root, topic);
|
|
65
51
|
|
|
66
52
|
const more = [
|
|
67
|
-
...(scratchpadAvailable(context)
|
|
68
|
-
? [
|
|
69
|
-
'- Explaining code visually outside a review: `session_get_instructions({topic:"scratchpad"})`',
|
|
70
|
-
]
|
|
71
|
-
: []),
|
|
72
53
|
...(context.traceEnabled
|
|
73
54
|
? [
|
|
74
55
|
'- Why code exists / past agent sessions: `session_get_instructions({topic:"trace-archaeology"})`',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Block } from "./blocks/index.js";
|
|
2
|
+
|
|
3
|
+
/** What a board link's hover card shows of the board or block it links to. */
|
|
4
|
+
export interface BoardLinkPreview {
|
|
5
|
+
/** A block link: the title of the section the block is in. */
|
|
6
|
+
section?: string;
|
|
7
|
+
/** Markdown paragraphs, opening the board or block. */
|
|
8
|
+
prose?: string[];
|
|
9
|
+
/** A block link to code: its language and first lines. */
|
|
10
|
+
code?: { language: string; lines: string[] };
|
|
11
|
+
/** A block link to a diagram or other figure: its kind and title. */
|
|
12
|
+
figure?: { type: string; title: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** About what the card shows before it fades; more is cut, not sent. */
|
|
16
|
+
const PROSE_CHARACTERS = 700;
|
|
17
|
+
|
|
18
|
+
const CODE_LINES = 6;
|
|
19
|
+
|
|
20
|
+
function childBlocks(block: Block): Block[] {
|
|
21
|
+
return "children" in block ? block.children : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Paragraphs of markdown, without headings. */
|
|
25
|
+
function paragraphs(markdown: string): string[] {
|
|
26
|
+
return markdown
|
|
27
|
+
.split(/\n\s*\n/)
|
|
28
|
+
.map((paragraph) => paragraph.trim())
|
|
29
|
+
.filter((paragraph) => paragraph && !paragraph.startsWith("#"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The prose from the first markdown block on, in reading order, until the
|
|
33
|
+
* card has enough. Diagrams and code before it are skipped. */
|
|
34
|
+
function openingProse(blocks: Block[]): string[] {
|
|
35
|
+
const prose: string[] = [];
|
|
36
|
+
let length = 0;
|
|
37
|
+
|
|
38
|
+
const walk = (list: Block[]): boolean => {
|
|
39
|
+
for (const block of list) {
|
|
40
|
+
if (block.type === "markdown")
|
|
41
|
+
for (const paragraph of paragraphs(block.markdown)) {
|
|
42
|
+
prose.push(paragraph);
|
|
43
|
+
length += paragraph.length;
|
|
44
|
+
|
|
45
|
+
if (length >= PROSE_CHARACTERS) return true;
|
|
46
|
+
}
|
|
47
|
+
else if (walk(childBlocks(block))) return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
walk(blocks);
|
|
54
|
+
|
|
55
|
+
return prose;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The block and the title of the section it is in. */
|
|
59
|
+
function find(
|
|
60
|
+
blocks: Block[],
|
|
61
|
+
id: string,
|
|
62
|
+
section?: string,
|
|
63
|
+
): { block: Block; section?: string } | undefined {
|
|
64
|
+
for (const block of blocks) {
|
|
65
|
+
if (block.id === id) return { block, section };
|
|
66
|
+
|
|
67
|
+
const found = find(
|
|
68
|
+
childBlocks(block),
|
|
69
|
+
id,
|
|
70
|
+
block.type === "section" ? block.title : section,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (found) return found;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The opening prose of a board, or one block of it. */
|
|
80
|
+
export function boardLinkPreview(
|
|
81
|
+
document: Block[],
|
|
82
|
+
blockId?: string,
|
|
83
|
+
): BoardLinkPreview {
|
|
84
|
+
if (blockId === undefined) return { prose: openingProse(document) };
|
|
85
|
+
|
|
86
|
+
const found = find(document, blockId);
|
|
87
|
+
|
|
88
|
+
if (!found) return {};
|
|
89
|
+
|
|
90
|
+
const { block } = found;
|
|
91
|
+
// A section link is about the section itself.
|
|
92
|
+
const section = block.type === "section" ? block.title : found.section;
|
|
93
|
+
|
|
94
|
+
if (block.type === "code")
|
|
95
|
+
return {
|
|
96
|
+
...(section && { section }),
|
|
97
|
+
code: {
|
|
98
|
+
language: block.language,
|
|
99
|
+
lines: block.text.split("\n").slice(0, CODE_LINES),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const prose = openingProse([block]);
|
|
104
|
+
|
|
105
|
+
if (prose.length) return { ...(section && { section }), prose };
|
|
106
|
+
|
|
107
|
+
const title = "title" in block ? block.title : undefined;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...(section && { section }),
|
|
111
|
+
figure: { type: block.type, title: title ?? "" },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { existsSync, statSync } from "node:fs";
|
|
3
2
|
import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
4
|
|
|
7
5
|
import {
|
|
8
6
|
type BlobBatchReader,
|
|
@@ -11,6 +9,7 @@ import {
|
|
|
11
9
|
type LocalVcsDiffFileSummary,
|
|
12
10
|
type LocalVcsKind,
|
|
13
11
|
LocalVcsToolsMissingError,
|
|
12
|
+
type VcsArg,
|
|
14
13
|
createBlobBatchReader,
|
|
15
14
|
detectLocalVcs,
|
|
16
15
|
diffFileSummariesTrees,
|
|
@@ -22,9 +21,11 @@ import {
|
|
|
22
21
|
listTrackedFilesAtCommit,
|
|
23
22
|
readFileAtCommit,
|
|
24
23
|
resolveRepoContext,
|
|
24
|
+
runVcs,
|
|
25
25
|
} from "@dev.fast/local-vcs";
|
|
26
|
-
import { structuralChangeCounts } from "@dev.fast/review-protocol";
|
|
26
|
+
import { boardTitle, structuralChangeCounts } from "@dev.fast/review-protocol";
|
|
27
27
|
import type {
|
|
28
|
+
PullRequestState,
|
|
28
29
|
ReviewLanguageEnvironment,
|
|
29
30
|
ReviewSourceEntry,
|
|
30
31
|
StructuralDiffEvent,
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
type ReviewTarget,
|
|
69
70
|
type SourcePins,
|
|
70
71
|
anchorPins,
|
|
72
|
+
boardRepositoryId,
|
|
71
73
|
elements,
|
|
72
74
|
fileLineRangeSchema,
|
|
73
75
|
pinnedRepositoryIds,
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
resolvePullRequest,
|
|
83
85
|
} from "./provider.js";
|
|
84
86
|
import {
|
|
87
|
+
GitHubRefusedRead,
|
|
85
88
|
type PullRequestDeps,
|
|
86
89
|
defaultPullRequestDeps,
|
|
87
90
|
fetchPullRequest,
|
|
@@ -165,7 +168,14 @@ interface RepositoryVcs {
|
|
|
165
168
|
vcs?: LocalVcs;
|
|
166
169
|
}
|
|
167
170
|
|
|
171
|
+
/** How long a PR's state is shown before it is read again. */
|
|
172
|
+
const PULL_REQUEST_STATE_TTL_MS = 5 * 60_000;
|
|
173
|
+
|
|
174
|
+
/** How many PR states are read from GitHub at once. */
|
|
175
|
+
const PULL_REQUEST_STATE_READS = 2;
|
|
176
|
+
|
|
168
177
|
/** Local source/resource boundary, including Desktop-only local language context. */
|
|
178
|
+
|
|
169
179
|
export class LocalReviewData {
|
|
170
180
|
private readonly workspaceManager?: ReviewWorkspaces;
|
|
171
181
|
|
|
@@ -301,7 +311,12 @@ export class LocalReviewData {
|
|
|
301
311
|
if (commit && !anchor)
|
|
302
312
|
await this.comparison(await this.documentPins(snapshot), commit);
|
|
303
313
|
|
|
304
|
-
const repositoryId =
|
|
314
|
+
const repositoryId =
|
|
315
|
+
anchor?.repositoryId ??
|
|
316
|
+
snapshot.pins?.repositoryId ??
|
|
317
|
+
(snapshot.target?.kind === "project"
|
|
318
|
+
? snapshot.target.repositoryId
|
|
319
|
+
: undefined);
|
|
305
320
|
|
|
306
321
|
if (!repositoryId)
|
|
307
322
|
throw new ReviewInputError(
|
|
@@ -419,7 +434,7 @@ export class LocalReviewData {
|
|
|
419
434
|
);
|
|
420
435
|
|
|
421
436
|
if (!live) {
|
|
422
|
-
const { stdout } = await
|
|
437
|
+
const { stdout } = await runVcs("git", [
|
|
423
438
|
"-C",
|
|
424
439
|
rootPath,
|
|
425
440
|
"status",
|
|
@@ -472,7 +487,7 @@ export class LocalReviewData {
|
|
|
472
487
|
),
|
|
473
488
|
));
|
|
474
489
|
|
|
475
|
-
const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
490
|
+
const title = `${boardTitle(snapshot.title)} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
|
|
476
491
|
|
|
477
492
|
const workspace = previous ?? {
|
|
478
493
|
folders: [],
|
|
@@ -519,9 +534,14 @@ export class LocalReviewData {
|
|
|
519
534
|
return { workspacePath, filePath, rootPath };
|
|
520
535
|
}
|
|
521
536
|
|
|
522
|
-
/** A document read that needs default pins
|
|
537
|
+
/** A document read that needs default pins: its own, or for a plan its
|
|
538
|
+
* repository's live checkout with base at head; 409 when it has neither. */
|
|
523
539
|
async documentPins(snapshot: Snapshot): Promise<Pins> {
|
|
524
|
-
const pins =
|
|
540
|
+
const pins =
|
|
541
|
+
(await this.sourcePins(snapshot)) ??
|
|
542
|
+
(snapshot.target?.kind === "project"
|
|
543
|
+
? await this.livePins(snapshot.target.repositoryId)
|
|
544
|
+
: undefined);
|
|
525
545
|
|
|
526
546
|
if (!pins)
|
|
527
547
|
throw new ReviewInputError(
|
|
@@ -532,6 +552,21 @@ export class LocalReviewData {
|
|
|
532
552
|
return pins;
|
|
533
553
|
}
|
|
534
554
|
|
|
555
|
+
/** A checkout's current state, base at head: what a plan reads. */
|
|
556
|
+
private async livePins(repositoryId: string): Promise<Pins> {
|
|
557
|
+
const vcs = await this.vcs(repositoryId);
|
|
558
|
+
|
|
559
|
+
if (!vcs) throw unavailableCheckout();
|
|
560
|
+
const { revision, commit } = await this.worktreeState(repositoryId, vcs);
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
repositoryId,
|
|
564
|
+
base: commit,
|
|
565
|
+
head: commit,
|
|
566
|
+
worktreeRevision: revision,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
535
570
|
/** Pins a reference names itself, checked against the registered checkout. */
|
|
536
571
|
async anchorSourcePins(anchor: SourcePins): Promise<Pins> {
|
|
537
572
|
if (!existsSync(this.store.repositoryPath(anchor.repositoryId)))
|
|
@@ -545,8 +580,14 @@ export class LocalReviewData {
|
|
|
545
580
|
async unavailableAnchors(snapshot: Snapshot): Promise<string[]> {
|
|
546
581
|
const missing = new Set<string>();
|
|
547
582
|
|
|
548
|
-
// Only Markdown needs parsing, so check the repositories first.
|
|
549
|
-
|
|
583
|
+
// Only Markdown needs parsing, so check the repositories first. Its
|
|
584
|
+
// commit links read from the board's repository.
|
|
585
|
+
const repositoryIds = pinnedRepositoryIds(snapshot.document);
|
|
586
|
+
const boardRepository = boardRepositoryId(snapshot);
|
|
587
|
+
|
|
588
|
+
if (boardRepository) repositoryIds.add(boardRepository);
|
|
589
|
+
|
|
590
|
+
for (const repositoryId of repositoryIds)
|
|
550
591
|
try {
|
|
551
592
|
if (!existsSync(this.store.repositoryPath(repositoryId)))
|
|
552
593
|
missing.add(repositoryId);
|
|
@@ -567,7 +608,10 @@ export class LocalReviewData {
|
|
|
567
608
|
|
|
568
609
|
private readonly storedReferences = new VersionCache(
|
|
569
610
|
(snapshot: Snapshot) =>
|
|
570
|
-
sourceReferences(snapshot.document, {
|
|
611
|
+
sourceReferences(snapshot.document, {
|
|
612
|
+
tolerant: true,
|
|
613
|
+
repositoryId: boardRepositoryId(snapshot),
|
|
614
|
+
}),
|
|
571
615
|
{ capacity: 512 },
|
|
572
616
|
);
|
|
573
617
|
|
|
@@ -592,6 +636,8 @@ export class LocalReviewData {
|
|
|
592
636
|
subscribe?: typeof subscribe;
|
|
593
637
|
/** gh, git and GitHub API access for pull request targets. */
|
|
594
638
|
pullRequests?: PullRequestDeps;
|
|
639
|
+
/** Read PR states from GitHub for the catalog. */
|
|
640
|
+
pullRequestStates?: boolean;
|
|
595
641
|
} = {},
|
|
596
642
|
) {
|
|
597
643
|
if (options.manageWorkspaces !== false)
|
|
@@ -973,6 +1019,7 @@ export class LocalReviewData {
|
|
|
973
1019
|
// that no longer exist; the author decides how to update changed source.
|
|
974
1020
|
for (const reference of sourceReferences(snapshot.document, {
|
|
975
1021
|
tolerant: true,
|
|
1022
|
+
repositoryId: boardRepositoryId(snapshot),
|
|
976
1023
|
})) {
|
|
977
1024
|
try {
|
|
978
1025
|
await this.quote(anchorPins(reference.source, pins), reference.source);
|
|
@@ -990,6 +1037,13 @@ export class LocalReviewData {
|
|
|
990
1037
|
if (snapshot.target?.kind === "worktree")
|
|
991
1038
|
return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
|
|
992
1039
|
|
|
1040
|
+
// A plan has no pins of its own, but its checkout must exist.
|
|
1041
|
+
if (
|
|
1042
|
+
snapshot.target?.kind === "project" &&
|
|
1043
|
+
!existsSync(this.store.repositoryPath(snapshot.target.repositoryId))
|
|
1044
|
+
)
|
|
1045
|
+
throw unavailableCheckout();
|
|
1046
|
+
|
|
993
1047
|
if (!snapshot.pins) return undefined;
|
|
994
1048
|
|
|
995
1049
|
if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId)))
|
|
@@ -1004,18 +1058,15 @@ export class LocalReviewData {
|
|
|
1004
1058
|
if (!vcs || vcs.kind !== "git") return undefined;
|
|
1005
1059
|
|
|
1006
1060
|
try {
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
const { stdout } = await run("git", [
|
|
1061
|
+
const { stdout } = await runVcs("git", [
|
|
1010
1062
|
"-C",
|
|
1011
1063
|
vcs.rootPath,
|
|
1012
1064
|
"rev-parse",
|
|
1013
1065
|
"--symbolic-full-name",
|
|
1014
1066
|
"--verify",
|
|
1015
|
-
"--end-of-options",
|
|
1016
1067
|
!headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef)
|
|
1017
1068
|
? "HEAD"
|
|
1018
|
-
: headRef,
|
|
1069
|
+
: { rev: headRef },
|
|
1019
1070
|
]);
|
|
1020
1071
|
|
|
1021
1072
|
const ref = stdout.trim();
|
|
@@ -1040,6 +1091,9 @@ export class LocalReviewData {
|
|
|
1040
1091
|
target: ReviewTarget,
|
|
1041
1092
|
pinned?: Pins,
|
|
1042
1093
|
): Promise<{ target: ReviewTarget; pins: Pins }> {
|
|
1094
|
+
if (target.kind === "project")
|
|
1095
|
+
throw new ReviewInputError("A project target has no commits.");
|
|
1096
|
+
|
|
1043
1097
|
const vcs = await this.vcs(target.repositoryId);
|
|
1044
1098
|
|
|
1045
1099
|
if (!vcs) throw unavailableCheckout();
|
|
@@ -1151,10 +1205,147 @@ export class LocalReviewData {
|
|
|
1151
1205
|
|
|
1152
1206
|
return { ref: branch, commit: fork.commit };
|
|
1153
1207
|
}
|
|
1208
|
+
/** Each PR's state, by URL, as last read. */
|
|
1209
|
+
private readonly pullRequestStates = new Map<
|
|
1210
|
+
string,
|
|
1211
|
+
{
|
|
1212
|
+
state?: PullRequestState;
|
|
1213
|
+
/** The branch it merges into, and its own. */
|
|
1214
|
+
baseBranch?: string;
|
|
1215
|
+
headBranch?: string;
|
|
1216
|
+
readAt: number;
|
|
1217
|
+
/** When a board last showed it; a deleted board's PR is forgotten. */
|
|
1218
|
+
seenAt: number;
|
|
1219
|
+
reading: boolean;
|
|
1220
|
+
}
|
|
1221
|
+
>();
|
|
1222
|
+
|
|
1223
|
+
private readonly pullRequestStateListeners = new Set<() => void>();
|
|
1224
|
+
private pullRequestStateTimer: ReturnType<typeof setInterval> | undefined;
|
|
1225
|
+
private readonly pullRequestStateQueue: string[] = [];
|
|
1226
|
+
private pullRequestStateReads = 0;
|
|
1227
|
+
|
|
1228
|
+
/** While GitHub rate-limits, no state is read. */
|
|
1229
|
+
private pullRequestStatesPausedUntil = 0;
|
|
1230
|
+
|
|
1231
|
+
/** Calls back when a PR's state changes. While anyone listens, every PR
|
|
1232
|
+
* still shown is read again once its state is a few minutes old. */
|
|
1233
|
+
subscribePullRequestStates(listener: () => void): () => void {
|
|
1234
|
+
this.pullRequestStateListeners.add(listener);
|
|
1235
|
+
this.pullRequestStateTimer ??= setInterval(() => {
|
|
1236
|
+
for (const [url, entry] of this.pullRequestStates)
|
|
1237
|
+
if (Date.now() - entry.seenAt > 2 * PULL_REQUEST_STATE_TTL_MS)
|
|
1238
|
+
this.pullRequestStates.delete(url);
|
|
1239
|
+
else this.pullRequestState(url, { seen: false });
|
|
1240
|
+
this.readPullRequestStates();
|
|
1241
|
+
}, PULL_REQUEST_STATE_TTL_MS / 5);
|
|
1242
|
+
this.pullRequestStateTimer.unref?.();
|
|
1243
|
+
|
|
1244
|
+
return () => {
|
|
1245
|
+
this.pullRequestStateListeners.delete(listener);
|
|
1246
|
+
|
|
1247
|
+
if (!this.pullRequestStateListeners.size) {
|
|
1248
|
+
clearInterval(this.pullRequestStateTimer);
|
|
1249
|
+
this.pullRequestStateTimer = undefined;
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** A PR's state as last read from GitHub; a read older than a few minutes
|
|
1255
|
+
* queues another in the background. Undefined until the first read lands,
|
|
1256
|
+
* and a failed read keeps the last state. A merged or closed PR is not
|
|
1257
|
+
* read again. */
|
|
1258
|
+
pullRequestState(
|
|
1259
|
+
url: string | undefined,
|
|
1260
|
+
{ seen = true }: { seen?: boolean } = {},
|
|
1261
|
+
): PullRequestState | undefined {
|
|
1262
|
+
if (!url || !this.options.pullRequestStates) return undefined;
|
|
1263
|
+
|
|
1264
|
+
const entry = this.pullRequestStates.get(url) ?? {
|
|
1265
|
+
readAt: 0,
|
|
1266
|
+
seenAt: 0,
|
|
1267
|
+
reading: false,
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
this.pullRequestStates.set(url, entry);
|
|
1271
|
+
|
|
1272
|
+
if (seen) entry.seenAt = Date.now();
|
|
1273
|
+
|
|
1274
|
+
if (
|
|
1275
|
+
!entry.reading &&
|
|
1276
|
+
entry.state !== "merged" &&
|
|
1277
|
+
entry.state !== "closed" &&
|
|
1278
|
+
Date.now() - entry.readAt > PULL_REQUEST_STATE_TTL_MS
|
|
1279
|
+
) {
|
|
1280
|
+
entry.reading = true;
|
|
1281
|
+
this.pullRequestStateQueue.push(url);
|
|
1282
|
+
this.readPullRequestStates();
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return entry.state;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Reads queued PR states a few at a time. */
|
|
1289
|
+
private readPullRequestStates() {
|
|
1290
|
+
while (
|
|
1291
|
+
this.pullRequestStateReads < PULL_REQUEST_STATE_READS &&
|
|
1292
|
+
this.pullRequestStateQueue.length &&
|
|
1293
|
+
Date.now() >= this.pullRequestStatesPausedUntil
|
|
1294
|
+
) {
|
|
1295
|
+
const url = this.pullRequestStateQueue.shift()!;
|
|
1296
|
+
const entry = this.pullRequestStates.get(url);
|
|
1297
|
+
|
|
1298
|
+
if (!entry) continue;
|
|
1299
|
+
this.pullRequestStateReads++;
|
|
1300
|
+
void Promise.resolve()
|
|
1301
|
+
.then(() => {
|
|
1302
|
+
const { provider, address } = resolvePullRequest(url);
|
|
1303
|
+
|
|
1304
|
+
return provider.readPullRequest(
|
|
1305
|
+
address,
|
|
1306
|
+
this.options.pullRequests ?? defaultPullRequestDeps,
|
|
1307
|
+
);
|
|
1308
|
+
})
|
|
1309
|
+
.then((pullRequest) => {
|
|
1310
|
+
if (
|
|
1311
|
+
(!pullRequest.state || pullRequest.state === entry.state) &&
|
|
1312
|
+
pullRequest.baseRefName === entry.baseBranch &&
|
|
1313
|
+
pullRequest.headBranch === entry.headBranch
|
|
1314
|
+
)
|
|
1315
|
+
return;
|
|
1316
|
+
entry.state = pullRequest.state ?? entry.state;
|
|
1317
|
+
entry.baseBranch = pullRequest.baseRefName;
|
|
1318
|
+
entry.headBranch = pullRequest.headBranch;
|
|
1319
|
+
|
|
1320
|
+
for (const listener of this.pullRequestStateListeners) listener();
|
|
1321
|
+
})
|
|
1322
|
+
.catch((error) => {
|
|
1323
|
+
if (error instanceof GitHubRefusedRead)
|
|
1324
|
+
this.pullRequestStatesPausedUntil = error.retryAt;
|
|
1325
|
+
})
|
|
1326
|
+
.finally(() => {
|
|
1327
|
+
entry.readAt = Date.now();
|
|
1328
|
+
entry.reading = false;
|
|
1329
|
+
this.pullRequestStateReads--;
|
|
1330
|
+
this.readPullRequestStates();
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** The branch a PR merges into, as last read with its state. */
|
|
1336
|
+
pullRequestBaseBranch(url: string): string | undefined {
|
|
1337
|
+
return this.pullRequestStates.get(url)?.baseBranch;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** A PR's own branch, as last read with its state. */
|
|
1341
|
+
pullRequestHeadBranch(url: string): string | undefined {
|
|
1342
|
+
return this.pullRequestStates.get(url)?.headBranch;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1154
1345
|
/** The PR's current comparison, fetched into a registered checkout of its repository. */
|
|
1155
1346
|
async resolvePullRequest(
|
|
1156
1347
|
url: string,
|
|
1157
|
-
repository: { id
|
|
1348
|
+
repository: { id: string },
|
|
1158
1349
|
): Promise<ResolvedPullRequest> {
|
|
1159
1350
|
const deps = this.options.pullRequests ?? defaultPullRequestDeps;
|
|
1160
1351
|
const { provider, address } = resolvePullRequest(url);
|
|
@@ -1202,19 +1393,14 @@ export class LocalReviewData {
|
|
|
1202
1393
|
private async pullRequestCheckout(
|
|
1203
1394
|
provider: PullRequestProvider,
|
|
1204
1395
|
address: PullRequestAddress,
|
|
1205
|
-
repository: { id
|
|
1396
|
+
repository: { id: string },
|
|
1206
1397
|
deps: PullRequestDeps,
|
|
1207
1398
|
) {
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
? registered.filter((entry) => entry.id === repository.id)
|
|
1212
|
-
: [
|
|
1213
|
-
...registered.filter((entry) => entry.id === repository.preferred),
|
|
1214
|
-
...registered.filter((entry) => entry.id !== repository.preferred),
|
|
1215
|
-
];
|
|
1399
|
+
const candidates = this.store
|
|
1400
|
+
.repositories()
|
|
1401
|
+
.filter((entry) => entry.id === repository.id);
|
|
1216
1402
|
|
|
1217
|
-
if (
|
|
1403
|
+
if (candidates.length === 0)
|
|
1218
1404
|
throw new ReviewInputError("Repository is not registered.", 404);
|
|
1219
1405
|
|
|
1220
1406
|
for (const { id } of candidates) {
|
|
@@ -1242,9 +1428,7 @@ export class LocalReviewData {
|
|
|
1242
1428
|
const name = host === "github.com" ? slug : `${host}/${slug}`;
|
|
1243
1429
|
|
|
1244
1430
|
throw new ReviewInputError(
|
|
1245
|
-
|
|
1246
|
-
? `That checkout has no remote for ${name}. Add one, or omit repositoryId.`
|
|
1247
|
-
: `No registered checkout has a remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`,
|
|
1431
|
+
`That checkout has no remote for ${name}. Name a checkout of ${name} as the target's repositoryPath.`,
|
|
1248
1432
|
404,
|
|
1249
1433
|
);
|
|
1250
1434
|
}
|
|
@@ -1301,6 +1485,74 @@ export class LocalReviewData {
|
|
|
1301
1485
|
|
|
1302
1486
|
return { base, head };
|
|
1303
1487
|
}
|
|
1488
|
+
/** What a commit is, for a quote read at it: its subject, author and
|
|
1489
|
+
* date, and how far behind the default branch's tip it is when both are
|
|
1490
|
+
* known. */
|
|
1491
|
+
async commitInfo(
|
|
1492
|
+
repositoryId: string,
|
|
1493
|
+
commit: string,
|
|
1494
|
+
): Promise<{
|
|
1495
|
+
commit: string;
|
|
1496
|
+
subject: string;
|
|
1497
|
+
author: string;
|
|
1498
|
+
authoredAt: string;
|
|
1499
|
+
behind?: { count: number; branch: string };
|
|
1500
|
+
}> {
|
|
1501
|
+
const vcs = await this.vcs(repositoryId);
|
|
1502
|
+
|
|
1503
|
+
if (!vcs) throw unavailableCheckout();
|
|
1504
|
+
|
|
1505
|
+
const git = async (...args: VcsArg[]) =>
|
|
1506
|
+
(await runVcs("git", ["-C", vcs.rootPath, ...args])).stdout.trim();
|
|
1507
|
+
|
|
1508
|
+
let line: string;
|
|
1509
|
+
|
|
1510
|
+
try {
|
|
1511
|
+
line = await git("log", "-1", "--format=%H%x00%s%x00%an%x00%aI", {
|
|
1512
|
+
rev: commit,
|
|
1513
|
+
});
|
|
1514
|
+
} catch {
|
|
1515
|
+
throw new ReviewInputError("That commit is not in the repository.", 404);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const [full = commit, subject = "", author = "", authoredAt = ""] =
|
|
1519
|
+
line.split("\0");
|
|
1520
|
+
|
|
1521
|
+
const branch = await vcs.defaultBranch().catch(() => null);
|
|
1522
|
+
|
|
1523
|
+
const behind = branch
|
|
1524
|
+
? await git("rev-list", "--count", { rev: `${full}..${branch.commit}` })
|
|
1525
|
+
.then((count) => ({
|
|
1526
|
+
count: Number(count),
|
|
1527
|
+
branch: branch.ref.replace(/^origin\//, ""),
|
|
1528
|
+
}))
|
|
1529
|
+
.catch(() => undefined)
|
|
1530
|
+
: undefined;
|
|
1531
|
+
|
|
1532
|
+
return {
|
|
1533
|
+
commit: full,
|
|
1534
|
+
subject,
|
|
1535
|
+
author,
|
|
1536
|
+
authoredAt,
|
|
1537
|
+
...(behind && { behind }),
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
/** The full commit a commit ID or its prefix names in the repository,
|
|
1541
|
+
* or undefined when it names none. */
|
|
1542
|
+
async resolveCommit(
|
|
1543
|
+
repositoryId: string,
|
|
1544
|
+
commit: string,
|
|
1545
|
+
): Promise<string | undefined> {
|
|
1546
|
+
const vcs = await this.vcs(repositoryId);
|
|
1547
|
+
|
|
1548
|
+
if (!vcs) throw unavailableCheckout();
|
|
1549
|
+
const resolved = await vcs.resolveRevision(commit);
|
|
1550
|
+
|
|
1551
|
+
// A branch or tag spelled in hex is not the commit it seems.
|
|
1552
|
+
return resolved?.commit.startsWith(commit.toLowerCase())
|
|
1553
|
+
? resolved.commit
|
|
1554
|
+
: undefined;
|
|
1555
|
+
}
|
|
1304
1556
|
async validatePins(pins: Pins) {
|
|
1305
1557
|
if (pins.worktreeRevision) {
|
|
1306
1558
|
if (!(await this.vcs(pins.repositoryId))) throw unavailableCheckout();
|
|
@@ -1926,29 +2178,41 @@ export function openLocalReviewStore(
|
|
|
1926
2178
|
manageWorkspaces?: boolean;
|
|
1927
2179
|
subscribe?: typeof subscribe;
|
|
1928
2180
|
pullRequests?: PullRequestDeps;
|
|
2181
|
+
pullRequestStates?: boolean;
|
|
2182
|
+
/** From `groupCheckoutsAhead`. */
|
|
2183
|
+
checkoutOwners?: ReadonlyMap<string, string>;
|
|
1929
2184
|
} = {},
|
|
1930
2185
|
) {
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
data.
|
|
1944
|
-
|
|
1945
|
-
|
|
2186
|
+
const { checkoutOwners, ...dataOptions } = options;
|
|
2187
|
+
|
|
2188
|
+
const store: ReviewStore = new ReviewStore(
|
|
2189
|
+
databasePath,
|
|
2190
|
+
{
|
|
2191
|
+
projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
|
|
2192
|
+
resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
|
|
2193
|
+
resolvePullRequest: (url, repository) =>
|
|
2194
|
+
data.resolvePullRequest(url, repository),
|
|
2195
|
+
pullRequestTitle: (url) => data.pullRequestTitle(url),
|
|
2196
|
+
headBranch: (pins, headRef) => data.headBranch(pins, headRef),
|
|
2197
|
+
sourcePins: (snapshot) => data.sourcePins(snapshot),
|
|
2198
|
+
unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
|
|
2199
|
+
validatePins: (pins) => data.validatePins(pins),
|
|
2200
|
+
resolveCommit: (repositoryId, commit) =>
|
|
2201
|
+
data.resolveCommit(repositoryId, commit),
|
|
2202
|
+
filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
|
|
2203
|
+
validateSource: (pins, source, options) =>
|
|
2204
|
+
data.validateSource(pins, source, options),
|
|
2205
|
+
validateResource: (pins, block) => data.validateResource(pins, block),
|
|
2206
|
+
pullRequestBaseBranch: (url) => data.pullRequestBaseBranch(url),
|
|
2207
|
+
},
|
|
2208
|
+
checkoutOwners,
|
|
2209
|
+
);
|
|
1946
2210
|
|
|
1947
2211
|
let data: LocalReviewData;
|
|
1948
2212
|
|
|
1949
2213
|
try {
|
|
1950
2214
|
data = new LocalReviewData(store, {
|
|
1951
|
-
...
|
|
2215
|
+
...dataOptions,
|
|
1952
2216
|
workspaceDatabase: `${databasePath}.workspaces`,
|
|
1953
2217
|
});
|
|
1954
2218
|
} catch (error) {
|