@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

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.
Files changed (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -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 = anchor?.repositoryId ?? snapshot.pins?.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 promisify(execFile)("git", [
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; 409 when the document has none. */
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 = await this.sourcePins(snapshot);
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
- for (const repositoryId of pinnedRepositoryIds(snapshot.document))
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, { tolerant: true }),
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 run = promisify(execFile);
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?: string; preferred?: string },
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?: string; preferred?: string },
1396
+ repository: { id: string },
1206
1397
  deps: PullRequestDeps,
1207
1398
  ) {
1208
- const registered = this.store.repositories();
1209
-
1210
- const candidates = repository.id
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 (repository.id && candidates.length === 0)
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
- repository.id
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 store: ReviewStore = new ReviewStore(databasePath, {
1932
- projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
1933
- resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
1934
- resolvePullRequest: (url, repository) =>
1935
- data.resolvePullRequest(url, repository),
1936
- pullRequestTitle: (url) => data.pullRequestTitle(url),
1937
- headBranch: (pins, headRef) => data.headBranch(pins, headRef),
1938
- sourcePins: (snapshot) => data.sourcePins(snapshot),
1939
- unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
1940
- validatePins: (pins) => data.validatePins(pins),
1941
- filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
1942
- validateSource: (pins, source, options) =>
1943
- data.validateSource(pins, source, options),
1944
- validateResource: (pins, block) => data.validateResource(pins, block),
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
- ...options,
2215
+ ...dataOptions,
1952
2216
  workspaceDatabase: `${databasePath}.workspaces`,
1953
2217
  });
1954
2218
  } catch (error) {