@dev.fast/whiteboard 0.3.5-preview.20261022.134 → 0.3.5-preview.20261023.136

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.
@@ -0,0 +1,97 @@
1
+ import { z } from "zod";
2
+
3
+ export type RunGitHubApi = (host: string, endpoint: string) => Promise<string>;
4
+
5
+ const repository = z.object({ full_name: z.string() });
6
+
7
+ const pullRequest = z.object({
8
+ number: z.number().int().positive(),
9
+ title: z.string(),
10
+ draft: z.boolean().optional(),
11
+ head: z.object({ ref: z.string(), repo: repository.nullable() }),
12
+ base: z.object({ ref: z.string(), repo: repository }),
13
+ });
14
+
15
+ export interface GitHubPullRequest {
16
+ number: number;
17
+ url: string;
18
+ title: string;
19
+ headBranch: string;
20
+ baseBranch: string;
21
+ draft: boolean;
22
+ }
23
+
24
+ // Repository facts are independent of which PRs have whiteboards. Share a
25
+ // short-lived, coalesced read between source resolution and stack discovery.
26
+ const caches = new WeakMap<
27
+ RunGitHubApi,
28
+ Map<
29
+ string,
30
+ {
31
+ expires: number;
32
+ read: Promise<GitHubPullRequest[]>;
33
+ }
34
+ >
35
+ >();
36
+
37
+ export function readGitHubPullRequests(
38
+ host: string,
39
+ slug: string,
40
+ run: RunGitHubApi,
41
+ ): Promise<GitHubPullRequest[]> {
42
+ let cache = caches.get(run);
43
+
44
+ if (!cache) caches.set(run, (cache = new Map()));
45
+ const key = `${host}/${slug}`.toLowerCase();
46
+ const previous = cache.get(key);
47
+
48
+ if (previous && previous.expires > Date.now()) return previous.read;
49
+
50
+ const read = (async () => {
51
+ const result: GitHubPullRequest[] = [];
52
+
53
+ for (let page = 1; page <= 10; page++) {
54
+ const records = z
55
+ .array(pullRequest)
56
+ .parse(
57
+ JSON.parse(
58
+ await run(
59
+ host,
60
+ `repos/${slug}/pulls?state=open&per_page=100&page=${page}`,
61
+ ),
62
+ ),
63
+ );
64
+
65
+ for (const pr of records) {
66
+ // A fork's identically named branch is not this repository's branch.
67
+ if (
68
+ pr.head.repo?.full_name.toLowerCase() !== slug.toLowerCase() ||
69
+ pr.base.repo.full_name.toLowerCase() !== slug.toLowerCase()
70
+ )
71
+ continue;
72
+ result.push({
73
+ number: pr.number,
74
+ url: `https://${host}/${slug}/pull/${pr.number}`,
75
+ title: pr.title,
76
+ headBranch: pr.head.ref,
77
+ baseBranch: pr.base.ref,
78
+ draft: pr.draft ?? false,
79
+ });
80
+ }
81
+
82
+ if (records.length < 100) return result;
83
+ }
84
+
85
+ throw new Error(
86
+ "PR discovery exceeded 1000 open pull requests; refusing a partial stack.",
87
+ );
88
+ })();
89
+
90
+ cache.set(key, { expires: Date.now() + 60_000, read });
91
+ // Failures are optional context, but must not poison the next read.
92
+ void read.catch(() => {
93
+ if (cache.get(key)?.read === read) cache.delete(key);
94
+ });
95
+
96
+ return read;
97
+ }
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import {
2
4
  type JsonObject,
3
5
  type JsonValue,
@@ -608,10 +610,27 @@ export function createReviewApi(
608
610
  [...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)] as const),
609
611
  );
610
612
 
611
- // Bumped when a review's parent or siblings change, so its header
612
- // reloads them.
613
- const placeRevision = new Map<string, number>();
614
- const placeKey = (id: string) => JSON.stringify(placeOf(id));
613
+ // A digest of a review's place and its project's branches, so a later
614
+ // stream's matches unless its header or stack moved.
615
+ const placeKey = (id: string) => {
616
+ const project = !isShared(id) && store.summary(id)?.project;
617
+
618
+ const branches = project
619
+ ? store
620
+ .listProject(project.id)
621
+ .filter(({ origin }) => origin?.branch)
622
+ .map(({ reviewId, origin, trashedAt }) => [
623
+ reviewId,
624
+ origin,
625
+ trashedAt,
626
+ ])
627
+ : [];
628
+
629
+ return createHash("sha1")
630
+ .update(JSON.stringify([placeOf(id), branches]))
631
+ .digest()
632
+ .readUInt32BE(0);
633
+ };
615
634
 
616
635
  const placeKeys = new Map(
617
636
  [...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)] as const),
@@ -627,7 +646,7 @@ export function createReviewApi(
627
646
  activity: store.activity.read(reviewId),
628
647
  coverageRevision: data?.coverageRevision ?? 0,
629
648
  linksRevision: linksRevision.get(reviewId) ?? 0,
630
- placeRevision: placeRevision.get(reviewId) ?? 0,
649
+ placeRevision: placeKeys.get(reviewId)!,
631
650
  },
632
651
  };
633
652
  } catch (error) {
@@ -681,7 +700,6 @@ export function createReviewApi(
681
700
 
682
701
  if (next === key) continue;
683
702
  placeKeys.set(id, next);
684
- placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
685
703
  marked = markReview(id) || marked;
686
704
  }
687
705
 
@@ -1996,10 +2014,12 @@ export function createReviewApi(
1996
2014
  app.get("/:id/stack", async (context) => {
1997
2015
  const query = readQuerySchemas.get.parse(context.req.query());
1998
2016
  const id = context.req.param("id");
1999
- const snapshot = readReview(id, query.version);
2017
+ let snapshot = readReview(id, query.version);
2000
2018
 
2001
2019
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
2002
2020
 
2021
+ if (data) snapshot = await data.discoverPullRequest(snapshot);
2022
+
2003
2023
  const repositoryPath = store
2004
2024
  .repositories()
2005
2025
  .find(
@@ -2163,7 +2183,15 @@ export function createReviewApi(
2163
2183
  app.get("/:id", async (context) => {
2164
2184
  const query = readQuerySchemas.get.parse(context.req.query());
2165
2185
 
2166
- const snapshot = { ...readReview(context.req.param("id"), query.version) };
2186
+ let snapshot = { ...readReview(context.req.param("id"), query.version) };
2187
+
2188
+ if (
2189
+ data &&
2190
+ query.full &&
2191
+ query.version === undefined &&
2192
+ !isShared(snapshot.reviewId)
2193
+ )
2194
+ snapshot = await data.discoverPullRequest(snapshot);
2167
2195
 
2168
2196
  if (data && query.full) {
2169
2197
  try {
@@ -2223,17 +2251,7 @@ export function createReviewApi(
2223
2251
 
2224
2252
  const request = withBoardRepository(
2225
2253
  await locateRepositories(
2226
- await pullRequestTargets(
2227
- body,
2228
- register,
2229
- (url, repositoryId) => {
2230
- if (!data)
2231
- throw new ReviewInputError("Repositories are unavailable.");
2232
-
2233
- return data.resolvePullRequest(url, { id: repositoryId });
2234
- },
2235
- (id) => store.read(id),
2236
- ),
2254
+ await pullRequestTargets(body, register, (id) => store.read(id)),
2237
2255
  register,
2238
2256
  ),
2239
2257
  (id) => (store.has(id) ? store.read(id) : undefined),
@@ -2433,10 +2451,6 @@ async function pullRequestTargets(
2433
2451
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2434
2452
  body: unknown,
2435
2453
  register: (path: string) => Promise<{ id: string }>,
2436
- resolve: (
2437
- url: string,
2438
- repositoryId: string,
2439
- ) => Promise<{ target: ReviewTarget }>,
2440
2454
  board: (id: string) => void,
2441
2455
  ) {
2442
2456
  const target = z.strictObject({
@@ -2480,7 +2494,7 @@ async function pullRequestTargets(
2480
2494
  ...located.data,
2481
2495
  operation: {
2482
2496
  ...operation,
2483
- target: (await resolve(url, repositoryId)).target,
2497
+ repositoryId,
2484
2498
  pullRequestUrl: url,
2485
2499
  },
2486
2500
  };
@@ -20,6 +20,7 @@ import {
20
20
  gitCommonDir,
21
21
  listCommitRange,
22
22
  listTrackedFilesAtCommit,
23
+ parseGitRemote,
23
24
  readFileAtCommit,
24
25
  resolveRepoContext,
25
26
  runVcs,
@@ -39,6 +40,10 @@ import type {
39
40
  } from "@parcel/watcher";
40
41
  import { textIncludesQuote } from "@review/evidence.js";
41
42
  import { isMissingFileError } from "@review/fs-utils.js";
43
+ import {
44
+ type GitHubPullRequest,
45
+ readGitHubPullRequests,
46
+ } from "@review/github-pr-graph.js";
42
47
  import { reviewManagedCheckoutRoot } from "@review/review-checkout-paths.js";
43
48
  import { ensureReviewPinnedCheckout } from "@review/review-head-checkout.js";
44
49
  import { StructuralComparisons } from "@review/server/structural-comparisons.js";
@@ -79,6 +84,7 @@ import {
79
84
  } from "./document.js";
80
85
  import { decodeImage } from "./image-decode.js";
81
86
  import { mapInputSchema } from "./map-input.js";
87
+ import { pullRequestKey, sourceBranchName } from "./origin.js";
82
88
  import {
83
89
  type PullRequestAddress,
84
90
  type PullRequestProvider,
@@ -1052,22 +1058,101 @@ export class LocalReviewData {
1052
1058
 
1053
1059
  return snapshot.pins;
1054
1060
  }
1061
+ /** Optional forge context; never changes the comparison's pins. */
1062
+ private readonly readGitHubApi = (host: string, endpoint: string) =>
1063
+ (this.options.pullRequests ?? defaultPullRequestDeps).run(
1064
+ "gh",
1065
+ ["api", "--hostname", { value: host }, { value: endpoint }],
1066
+ { timeoutMs: 10_000 },
1067
+ );
1068
+
1069
+ async findPullRequest(
1070
+ pins: Pins,
1071
+ branch: string,
1072
+ ): Promise<GitHubPullRequest | undefined> {
1073
+ const context = await resolveRepoContext(
1074
+ this.store.repositoryPath(pins.repositoryId),
1075
+ );
1076
+
1077
+ if (!context?.githubSlug || !context.originUrl) return undefined;
1078
+ const remote = parseGitRemote(context.originUrl);
1079
+
1080
+ if (!remote) return undefined;
1081
+ const name = sourceBranchName(branch);
1082
+
1083
+ const matches = (
1084
+ await readGitHubPullRequests(
1085
+ remote.host,
1086
+ context.githubSlug,
1087
+ this.readGitHubApi,
1088
+ )
1089
+ ).filter((pr) => pr.headBranch === name);
1090
+
1091
+ return matches.length === 1 ? matches[0] : undefined;
1092
+ }
1093
+
1094
+ /** Enrich current display metadata without repinning or rewriting history. */
1095
+ async discoverPullRequest(snapshot: Snapshot): Promise<Snapshot> {
1096
+ const { origin, pins } = snapshot;
1097
+
1098
+ if (!pins || !origin?.branch) return snapshot;
1099
+
1100
+ const found = await this.findPullRequest(pins, origin.branch).catch(
1101
+ () => undefined,
1102
+ );
1103
+
1104
+ if (!found) return snapshot;
1105
+
1106
+ // A manually selected PR must not be replaced by a branch-name match.
1107
+ if (
1108
+ origin.pullRequestUrl &&
1109
+ pullRequestKey(origin.pullRequestUrl) !== pullRequestKey(found.url)
1110
+ )
1111
+ return snapshot;
1112
+
1113
+ return {
1114
+ ...snapshot,
1115
+ origin: {
1116
+ ...origin,
1117
+ pullRequestUrl: found.url,
1118
+ pullRequestNumber: found.number,
1119
+ pullRequestTitle: found.title,
1120
+ pullRequestBaseBranch: found.baseBranch,
1121
+ },
1122
+ };
1123
+ }
1124
+
1055
1125
  /** Capture a label only when its ref still resolves to these exact pins. */
1056
1126
  async headBranch(pins: Pins, headRef?: string): Promise<string | undefined> {
1127
+ const context = await resolveRepoContext(
1128
+ this.store.repositoryPath(pins.repositoryId),
1129
+ );
1130
+
1131
+ if (!context) return undefined;
1132
+
1057
1133
  const vcs = await this.vcs(pins.repositoryId);
1058
1134
 
1059
- if (!vcs || vcs.kind !== "git") return undefined;
1135
+ const symbolicRef =
1136
+ headRef &&
1137
+ headRef !== "HEAD" &&
1138
+ headRef !== "@" &&
1139
+ !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef)
1140
+ ? headRef
1141
+ : undefined;
1142
+
1143
+ let location: VcsArg[];
1144
+
1145
+ if (symbolicRef) location = ["--git-dir", context.commonDir];
1146
+ else if (vcs?.kind === "git") location = ["-C", vcs.rootPath];
1147
+ else return undefined;
1060
1148
 
1061
1149
  try {
1062
1150
  const { stdout } = await runVcs("git", [
1063
- "-C",
1064
- vcs.rootPath,
1151
+ ...location,
1065
1152
  "rev-parse",
1066
1153
  "--symbolic-full-name",
1067
1154
  "--verify",
1068
- !headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef)
1069
- ? "HEAD"
1070
- : { rev: headRef },
1155
+ symbolicRef ? { rev: symbolicRef } : "HEAD",
1071
1156
  ]);
1072
1157
 
1073
1158
  const ref = stdout.trim();
@@ -1075,13 +1160,18 @@ export class LocalReviewData {
1075
1160
  if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/"))
1076
1161
  return undefined;
1077
1162
 
1078
- const resolved = await vcs.resolveRevision(ref);
1163
+ const { stdout: commit } = await runVcs("git", [
1164
+ ...location,
1165
+ "rev-parse",
1166
+ { rev: ref },
1167
+ ]);
1079
1168
 
1080
- if (resolved?.commit !== pins.head) return undefined;
1169
+ if (commit.trim() !== pins.head) return undefined;
1081
1170
 
1082
- return ref.replace(/^refs\/(heads|remotes)\//, "");
1171
+ return ref
1172
+ .replace(/^refs\/remotes\/origin\//, "")
1173
+ .replace(/^refs\/(heads|remotes)\//, "");
1083
1174
  } catch {
1084
- // Optional provenance must not block authoring in a detached checkout.
1085
1175
  return undefined;
1086
1176
  }
1087
1177
  }
@@ -2215,6 +2305,7 @@ export function openLocalReviewStore(
2215
2305
  resolvePullRequest: (url, repository) =>
2216
2306
  data.resolvePullRequest(url, repository),
2217
2307
  pullRequestTitle: (url) => data.pullRequestTitle(url),
2308
+ findPullRequest: (pins, branch) => data.findPullRequest(pins, branch),
2218
2309
  headBranch: (pins, headRef) => data.headBranch(pins, headRef),
2219
2310
  sourcePins: (snapshot) => data.sourcePins(snapshot),
2220
2311
  unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
@@ -16,11 +16,18 @@ export function pullRequestKey(url: string): string {
16
16
  return url.toLowerCase();
17
17
  }
18
18
 
19
- /** Omission preserves identity; null detaches it without changing import metadata. */
19
+ /** Older snapshots kept origin/ on remote branch labels. */
20
+ export const sourceBranchName = (branch: string | undefined) =>
21
+ branch?.replace(/^origin\//, "");
22
+
23
+ /** Omission preserves identity; null removes the PR without changing import
24
+ * metadata. A new url keeps the title and PR base when the caller omits them
25
+ * for the same PR. */
20
26
  export function setPullRequest(
21
27
  snapshot: Pick<Snapshot, "origin">,
22
28
  url: string | null | undefined,
23
29
  title?: string,
30
+ baseBranch?: string,
24
31
  ) {
25
32
  if (url === undefined) return;
26
33
 
@@ -29,19 +36,20 @@ export function setPullRequest(
29
36
  delete snapshot.origin.pullRequestUrl;
30
37
  delete snapshot.origin.pullRequestNumber;
31
38
  delete snapshot.origin.pullRequestTitle;
39
+ delete snapshot.origin.pullRequestBaseBranch;
32
40
  }
33
41
 
34
42
  return;
35
43
  }
36
44
 
45
+ const same = snapshot.origin?.pullRequestUrl === url;
37
46
  snapshot.origin = {
38
47
  ...snapshot.origin,
39
48
  pullRequestUrl: url,
40
49
  pullRequestNumber: Number(url.split("/").at(-1)),
41
50
  pullRequestTitle:
42
- title ??
43
- (snapshot.origin?.pullRequestUrl === url
44
- ? snapshot.origin.pullRequestTitle
45
- : undefined),
51
+ title ?? (same ? snapshot.origin?.pullRequestTitle : undefined),
52
+ pullRequestBaseBranch:
53
+ baseBranch ?? (same ? snapshot.origin?.pullRequestBaseBranch : undefined),
46
54
  };
47
55
  }