@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.
- package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
- package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
- package/dist/agent-cli-DKpWo-4F.js +2 -0
- package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
- package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
- package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
- package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
- package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
- package/dist/cli.js +2 -2
- package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
- package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
- package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
- package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
- package/dist/lens-selection-CyJapcsl.js +214 -0
- package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
- package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
- package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
- package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
- package/dist/profile-DthRnZ7x.js +2 -0
- package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
- package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
- package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
- package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
- package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
- package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
- package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
- package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
- package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
- package/dist/runtime-Bj2tD33R.js +27 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
- package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
- package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
- package/dist/server/desktop-host.js +29 -76
- package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
- package/dist/sharing/index.d.ts +486 -70
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
- package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
- package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
- package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
- package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
- package/instructions/authoring.md +3 -2
- package/package.json +4 -4
- package/src/agent-connect.ts +8 -2
- package/src/ask/agents.ts +51 -9
- package/src/ask/checkout-files.ts +2 -5
- package/src/ask/thread-state.ts +4 -0
- package/src/ask/thread.ts +16 -0
- package/src/ask/threads.ts +24 -3
- package/src/cli-runner.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/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 +137 -51
- package/src/review-api/authoring-tools.ts +45 -30
- 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 +560 -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 +281 -34
- package/src/review-api/foreign-references.ts +328 -0
- package/src/review-api/github-provider.ts +51 -13
- package/src/review-api/http.ts +702 -123
- 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 +3 -11
- 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 +357 -0
- package/src/review-api/store-schema.ts +38 -29
- package/src/review-api/store.ts +2144 -480
- package/src/review-api/workspaces.ts +52 -13
- 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 -31
- package/src/server/headless-host.ts +0 -2
- package/src/server/hono-http.ts +2 -1
- package/src/server/legacy-tutorial-cleanup.ts +2 -3
- 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 +128 -20
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +110 -19
- package/src/ui-telemetry-events.ts +25 -9
- package/dist/agent-cli-DeCrUC2s.js +0 -2
- package/dist/profile-Cz7L781D.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/review-api/http.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type JsonObject,
|
|
3
|
+
type JsonValue,
|
|
4
|
+
isJsonObject,
|
|
5
|
+
isStringValue,
|
|
6
|
+
} from "@dev.fast/json";
|
|
7
|
+
import { defaultBranch, resolveRepoContextSync } from "@dev.fast/local-vcs";
|
|
2
8
|
import {
|
|
3
9
|
REVIEW_CLIENT_HEADER,
|
|
4
10
|
REVIEW_CLIENT_REMOTE,
|
|
11
|
+
type ReviewApiSummary,
|
|
12
|
+
type ReviewBoardPlace,
|
|
13
|
+
type ReviewProjectThreads,
|
|
5
14
|
type ReviewRemoteLanguageEnvironment,
|
|
6
15
|
type ReviewRemoteNavigatorAnswer,
|
|
7
16
|
type ReviewStreamLine,
|
|
8
17
|
type ReviewStructuralDiffEvent,
|
|
18
|
+
type ReviewThreadBoard,
|
|
19
|
+
boardIconKind,
|
|
20
|
+
boardTitle,
|
|
9
21
|
} from "@dev.fast/review-protocol";
|
|
10
22
|
import { errorMessage } from "@dev.fast/trace-core";
|
|
11
23
|
import {
|
|
@@ -51,12 +63,13 @@ import { anchorQuotes } from "./anchor-quotes.js";
|
|
|
51
63
|
import type { AskRecord } from "./ask-history.js";
|
|
52
64
|
import { authoringTools } from "./authoring-tools.js";
|
|
53
65
|
import { documentText } from "./document-text.js";
|
|
54
|
-
import { ReviewInputError } from "./document.js";
|
|
55
66
|
import {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
ReviewInputError,
|
|
68
|
+
type ReviewTarget,
|
|
69
|
+
boardLinks,
|
|
70
|
+
boardRepositoryId,
|
|
71
|
+
} from "./document.js";
|
|
72
|
+
import { instructionsQuerySchema, renderInstructions } from "./instructions.js";
|
|
60
73
|
import type { LocalReviewData } from "./local-data.js";
|
|
61
74
|
import { registerOnboardingRoutes } from "./onboarding.js";
|
|
62
75
|
import {
|
|
@@ -65,6 +78,8 @@ import {
|
|
|
65
78
|
readQuerySchemas,
|
|
66
79
|
} from "./read-schemas.js";
|
|
67
80
|
import {
|
|
81
|
+
REVIEW_PERSON_HEADER,
|
|
82
|
+
REVIEW_THREAD_HEADER,
|
|
68
83
|
type ReviewRequestVia,
|
|
69
84
|
reviewRequestOrigin,
|
|
70
85
|
} from "./request-origin.js";
|
|
@@ -77,8 +92,9 @@ import {
|
|
|
77
92
|
uncategorizedReport,
|
|
78
93
|
} from "./review-progress.js";
|
|
79
94
|
import {
|
|
95
|
+
type Author,
|
|
96
|
+
type PlacedBoard,
|
|
80
97
|
type ReviewStore,
|
|
81
|
-
SCRATCHPAD_ID,
|
|
82
98
|
type Snapshot,
|
|
83
99
|
commandSchema,
|
|
84
100
|
inspectSnapshot,
|
|
@@ -142,13 +158,8 @@ const askDecisionSchema = z.strictObject({
|
|
|
142
158
|
export interface AuthoringCapabilities {
|
|
143
159
|
desktopAvailable: boolean;
|
|
144
160
|
softwareMapEnabled: boolean;
|
|
145
|
-
/** Off, the host neither makes nor lists the scratchpad, and refuses its id. */
|
|
146
|
-
scratchpadEnabled: boolean;
|
|
147
161
|
}
|
|
148
162
|
|
|
149
|
-
const SCRATCHPAD_DISABLED =
|
|
150
|
-
"The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
|
|
151
|
-
|
|
152
163
|
/**
|
|
153
164
|
* What the host reports about reviews, for telemetry. `onReviewCreated` fires
|
|
154
165
|
* again for a replayed create command; consumers dedupe by review id.
|
|
@@ -156,7 +167,6 @@ const SCRATCHPAD_DISABLED =
|
|
|
156
167
|
export interface ReviewApiHooks {
|
|
157
168
|
onReviewCreated?: (event: {
|
|
158
169
|
reviewId: string;
|
|
159
|
-
kind: "review" | "scratchpad";
|
|
160
170
|
blocks: number;
|
|
161
171
|
via: ReviewRequestVia;
|
|
162
172
|
agentKind?: ReviewSessionAgent;
|
|
@@ -164,6 +174,14 @@ export interface ReviewApiHooks {
|
|
|
164
174
|
sharing?: SharingHostEvents;
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
/** A person's save carries the whole board, which edits one at a time can
|
|
178
|
+
* grow past the size of a single command. */
|
|
179
|
+
const PERSON_EDIT_MAX_BYTES = 16 * 1024 * 1024;
|
|
180
|
+
|
|
181
|
+
/** A request the gateway forwarded from another machine. */
|
|
182
|
+
const remoteCaller = (context: Context) =>
|
|
183
|
+
context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
|
|
184
|
+
|
|
167
185
|
/** Both hosts mount this behind their token authentication. */
|
|
168
186
|
export function createReviewApi(
|
|
169
187
|
store: ReviewStore,
|
|
@@ -174,14 +192,11 @@ export function createReviewApi(
|
|
|
174
192
|
}) => Promise<{ softwareMapEnabled: boolean }>,
|
|
175
193
|
shared?: SharedReviewStore,
|
|
176
194
|
capabilities: () =>
|
|
177
|
-
|
|
|
178
|
-
| Promise<
|
|
195
|
+
| AuthoringCapabilities
|
|
196
|
+
| Promise<AuthoringCapabilities> = () => ({
|
|
179
197
|
desktopAvailable: Boolean(open),
|
|
180
198
|
softwareMapEnabled: false,
|
|
181
199
|
}),
|
|
182
|
-
// Synchronous because the catalog is read inside watch callbacks. The host
|
|
183
|
-
// keeps it current from its preferences file.
|
|
184
|
-
scratchpadEnabled: () => boolean = () => false,
|
|
185
200
|
// Read per request: capture can change from outside this server.
|
|
186
201
|
traceEnabled: () => Promise<boolean> = async () => false,
|
|
187
202
|
/** Which server this is, for whiteboard_status. */
|
|
@@ -252,23 +267,7 @@ export function createReviewApi(
|
|
|
252
267
|
operation: z.object({ reviewId: z.string().optional() }),
|
|
253
268
|
});
|
|
254
269
|
|
|
255
|
-
// The host that can show the scratchpad keeps it: Desktop, while the
|
|
256
|
-
// preference is on. Headless servers never make one, and a pad made earlier
|
|
257
|
-
// stays in the store while it is off.
|
|
258
|
-
const ensureScratchpad = async (id?: string) => {
|
|
259
|
-
if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID))
|
|
260
|
-
await store.ensureScratchpad();
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const refuseDisabledScratchpad = (id?: string) => {
|
|
264
|
-
if (id === SCRATCHPAD_ID && !scratchpadEnabled())
|
|
265
|
-
throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
270
|
const sharedGuard: MiddlewareHandler = async (context, next) => {
|
|
269
|
-
refuseDisabledScratchpad(context.req.param("id"));
|
|
270
|
-
await ensureScratchpad(context.req.param("id"));
|
|
271
|
-
|
|
272
271
|
const id = context.req.param("id");
|
|
273
272
|
|
|
274
273
|
if (!id || !isShared(id)) return next();
|
|
@@ -300,22 +299,68 @@ export function createReviewApi(
|
|
|
300
299
|
return snapshot;
|
|
301
300
|
};
|
|
302
301
|
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
/** Adds a PR board's state, as this host last read it from GitHub. */
|
|
303
|
+
const withPullRequestState = <
|
|
304
|
+
Board extends {
|
|
305
|
+
pullRequestUrl?: string;
|
|
306
|
+
origin?: { pullRequestUrl?: string };
|
|
307
|
+
},
|
|
308
|
+
>(
|
|
309
|
+
board: Board,
|
|
310
|
+
) => {
|
|
311
|
+
const state = data?.pullRequestState(
|
|
312
|
+
board.origin?.pullRequestUrl ?? board.pullRequestUrl,
|
|
313
|
+
);
|
|
305
314
|
|
|
306
|
-
return
|
|
307
|
-
...(scratchpadEnabled()
|
|
308
|
-
? local
|
|
309
|
-
: local.filter((summary) => summary.kind !== "scratchpad")),
|
|
310
|
-
...(shared?.list() ?? []),
|
|
311
|
-
];
|
|
315
|
+
return state ? { ...board, pullRequestState: state } : board;
|
|
312
316
|
};
|
|
313
317
|
|
|
314
|
-
|
|
315
|
-
|
|
318
|
+
const catalog = () => [
|
|
319
|
+
...store.list().map(withPullRequestState),
|
|
320
|
+
...(shared?.list() ?? []),
|
|
321
|
+
];
|
|
316
322
|
|
|
317
|
-
|
|
318
|
-
|
|
323
|
+
/** Where a board sits among its project's, and its PR's facts, for the
|
|
324
|
+
* board's header. */
|
|
325
|
+
const placeOf = (id: string): ReviewBoardPlace => {
|
|
326
|
+
if (isShared(id) || !store.has(id)) return { siblings: [] };
|
|
327
|
+
|
|
328
|
+
const { board, parent, siblings, children } = store.place(id);
|
|
329
|
+
const url = board.origin?.pullRequestUrl;
|
|
330
|
+
const state = data?.pullRequestState(url);
|
|
331
|
+
const baseBranch = url && data?.pullRequestBaseBranch(url);
|
|
332
|
+
const headBranch = url && data?.pullRequestHeadBranch(url);
|
|
333
|
+
|
|
334
|
+
const place = ({ reviewId, title, target }: PlacedBoard) => ({
|
|
335
|
+
reviewId,
|
|
336
|
+
title,
|
|
337
|
+
icon: boardIconKind(target),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const listed = (child: PlacedBoard) => {
|
|
341
|
+
const childState = data?.pullRequestState(child.origin?.pullRequestUrl);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
...place(child),
|
|
345
|
+
...(child.origin?.pullRequestNumber && {
|
|
346
|
+
pullRequestNumber: child.origin.pullRequestNumber,
|
|
347
|
+
}),
|
|
348
|
+
...(childState && { pullRequestState: childState }),
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
...(state && { pullRequestState: state }),
|
|
354
|
+
...(baseBranch && { baseBranch }),
|
|
355
|
+
...(headBranch && { headBranch }),
|
|
356
|
+
board: place(board),
|
|
357
|
+
...(parent && { parent: place(parent) }),
|
|
358
|
+
siblings: siblings.map(listed),
|
|
359
|
+
...(children?.length && { children: children.map(listed) }),
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
app.get("/", (context) => context.json(catalog()));
|
|
319
364
|
|
|
320
365
|
app.get("/feedback-eligibility", (context) =>
|
|
321
366
|
context.json({ eligible: store.feedbackEligible() }),
|
|
@@ -324,20 +369,13 @@ export function createReviewApi(
|
|
|
324
369
|
// Server-owned state only: asking the Desktop canvas would let a stalled
|
|
325
370
|
// renderer block tool listing and the first instructions call.
|
|
326
371
|
const instructionContext = async () => ({
|
|
327
|
-
desktopAvailable: Boolean(open),
|
|
328
|
-
scratchpadEnabled: scratchpadEnabled(),
|
|
329
372
|
traceEnabled: await traceEnabled(),
|
|
330
373
|
});
|
|
331
374
|
|
|
332
375
|
app.get("/authoring", async (context) => {
|
|
333
376
|
const instructions = await instructionContext();
|
|
334
377
|
|
|
335
|
-
return context.json(
|
|
336
|
-
authoringTools(
|
|
337
|
-
scratchpadAvailable(instructions),
|
|
338
|
-
instructions.traceEnabled,
|
|
339
|
-
),
|
|
340
|
-
);
|
|
378
|
+
return context.json(authoringTools(instructions.traceEnabled));
|
|
341
379
|
});
|
|
342
380
|
app.get("/instructions", async (context) => {
|
|
343
381
|
const { topic } = instructionsQuerySchema.parse(context.req.query());
|
|
@@ -468,10 +506,7 @@ export function createReviewApi(
|
|
|
468
506
|
);
|
|
469
507
|
|
|
470
508
|
app.get("/capabilities", async (context) =>
|
|
471
|
-
context.json(
|
|
472
|
-
...(await capabilities()),
|
|
473
|
-
scratchpadEnabled: scratchpadEnabled(),
|
|
474
|
-
}),
|
|
509
|
+
context.json(await capabilities()),
|
|
475
510
|
);
|
|
476
511
|
|
|
477
512
|
app.get("/:id/activity", (context) => {
|
|
@@ -503,6 +538,27 @@ export function createReviewApi(
|
|
|
503
538
|
);
|
|
504
539
|
},
|
|
505
540
|
);
|
|
541
|
+
// The person's canvas, never an agent tool: a save of the whole board,
|
|
542
|
+
// the block in focus, and discarding a board left untouched.
|
|
543
|
+
app.post("/:id/person-edit", async (context) =>
|
|
544
|
+
context.json(
|
|
545
|
+
await store.personEdit(
|
|
546
|
+
context.req.param("id"),
|
|
547
|
+
await readBoundedRequestJson(context.req.raw, PERSON_EDIT_MAX_BYTES),
|
|
548
|
+
),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
app.post("/:id/focus", async (context) =>
|
|
552
|
+
context.json(
|
|
553
|
+
store.focus(
|
|
554
|
+
context.req.param("id"),
|
|
555
|
+
await readBoundedRequestJson(context.req.raw),
|
|
556
|
+
),
|
|
557
|
+
),
|
|
558
|
+
);
|
|
559
|
+
app.post("/:id/discard", async (context) =>
|
|
560
|
+
context.json(await store.discard(context.req.param("id"))),
|
|
561
|
+
);
|
|
506
562
|
app.get("/watch", (context) => {
|
|
507
563
|
let input: unknown;
|
|
508
564
|
|
|
@@ -530,6 +586,36 @@ export function createReviewApi(
|
|
|
530
586
|
let dirtyList = lists;
|
|
531
587
|
const dirtyReviews = new Set(reviewIds);
|
|
532
588
|
let sentList: string | undefined;
|
|
589
|
+
// Bumped when a board a review links to changes, so its links reload.
|
|
590
|
+
const linksRevision = new Map<string, number>();
|
|
591
|
+
|
|
592
|
+
// The PR states its links show: only a change to one of them sends the
|
|
593
|
+
// review again. GitHub answers for every PR on launch, a few at a time.
|
|
594
|
+
const linksKey = (id: string) => {
|
|
595
|
+
if (!data || isShared(id) || !store.has(id)) return "";
|
|
596
|
+
const { outgoing, backlinks } = store.links(id);
|
|
597
|
+
|
|
598
|
+
return JSON.stringify(
|
|
599
|
+
[...outgoing, ...backlinks].map(
|
|
600
|
+
(link) =>
|
|
601
|
+
link.pullRequestUrl &&
|
|
602
|
+
data.pullRequestState(link.pullRequestUrl, { seen: false }),
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const linksKeys = new Map(
|
|
608
|
+
[...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)] as const),
|
|
609
|
+
);
|
|
610
|
+
|
|
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));
|
|
615
|
+
|
|
616
|
+
const placeKeys = new Map(
|
|
617
|
+
[...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)] as const),
|
|
618
|
+
);
|
|
533
619
|
|
|
534
620
|
const readReviewLine = (reviewId: string): ReviewStreamLine<Snapshot> => {
|
|
535
621
|
try {
|
|
@@ -540,6 +626,8 @@ export function createReviewApi(
|
|
|
540
626
|
...readReview(reviewId),
|
|
541
627
|
activity: store.activity.read(reviewId),
|
|
542
628
|
coverageRevision: data?.coverageRevision ?? 0,
|
|
629
|
+
linksRevision: linksRevision.get(reviewId) ?? 0,
|
|
630
|
+
placeRevision: placeRevision.get(reviewId) ?? 0,
|
|
543
631
|
},
|
|
544
632
|
};
|
|
545
633
|
} catch (error) {
|
|
@@ -584,6 +672,22 @@ export function createReviewApi(
|
|
|
584
672
|
return true;
|
|
585
673
|
};
|
|
586
674
|
|
|
675
|
+
/** Bumps the place of each board whose place changed. */
|
|
676
|
+
const refreshPlaces = () => {
|
|
677
|
+
let marked = false;
|
|
678
|
+
|
|
679
|
+
for (const [id, key] of placeKeys) {
|
|
680
|
+
const next = placeKey(id);
|
|
681
|
+
|
|
682
|
+
if (next === key) continue;
|
|
683
|
+
placeKeys.set(id, next);
|
|
684
|
+
placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
|
|
685
|
+
marked = markReview(id) || marked;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return marked;
|
|
689
|
+
};
|
|
690
|
+
|
|
587
691
|
const markLists = () => {
|
|
588
692
|
dirtyList ||= lists;
|
|
589
693
|
|
|
@@ -598,7 +702,14 @@ export function createReviewApi(
|
|
|
598
702
|
notify();
|
|
599
703
|
}) ?? (() => {}),
|
|
600
704
|
store.subscribe((result) => {
|
|
601
|
-
|
|
705
|
+
let marked = markReview(result.reviewId);
|
|
706
|
+
|
|
707
|
+
for (const from of store.linkingBoards(result.reviewId)) {
|
|
708
|
+
linksRevision.set(from, (linksRevision.get(from) ?? 0) + 1);
|
|
709
|
+
marked = markReview(from) || marked;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (marked) notify();
|
|
602
713
|
}),
|
|
603
714
|
store.activity.subscribe((id) => {
|
|
604
715
|
if (markReview(id)) notify();
|
|
@@ -606,11 +717,31 @@ export function createReviewApi(
|
|
|
606
717
|
store.activity.subscribeWorking(() => {
|
|
607
718
|
if (markLists()) notify();
|
|
608
719
|
}),
|
|
720
|
+
// A PR's state shows on its board in the list, on links to it, and
|
|
721
|
+
// with its branches in its board's header.
|
|
722
|
+
data?.subscribePullRequestStates(() => {
|
|
723
|
+
let marked = markLists();
|
|
724
|
+
|
|
725
|
+
marked = refreshPlaces() || marked;
|
|
726
|
+
|
|
727
|
+
for (const reviewId of reviewIds) {
|
|
728
|
+
const next = linksKey(reviewId);
|
|
729
|
+
|
|
730
|
+
if (next === linksKeys.get(reviewId)) continue;
|
|
731
|
+
linksKeys.set(reviewId, next);
|
|
732
|
+
linksRevision.set(reviewId, (linksRevision.get(reviewId) ?? 0) + 1);
|
|
733
|
+
marked = markReview(reviewId) || marked;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (marked) notify();
|
|
737
|
+
}) ?? (() => {}),
|
|
609
738
|
shared?.subscribe(() => {
|
|
610
739
|
if (markLists()) notify();
|
|
611
740
|
}) ?? (() => {}),
|
|
612
741
|
store.subscribeCatalog(() => {
|
|
613
|
-
|
|
742
|
+
const marked = refreshPlaces();
|
|
743
|
+
|
|
744
|
+
if (markLists() || marked) notify();
|
|
614
745
|
}),
|
|
615
746
|
];
|
|
616
747
|
|
|
@@ -684,21 +815,15 @@ export function createReviewApi(
|
|
|
684
815
|
});
|
|
685
816
|
|
|
686
817
|
// Traces are stored beside the review's own repository.
|
|
687
|
-
const tracePins = (id: string, version?: number) => {
|
|
688
|
-
const
|
|
818
|
+
const tracePins = async (id: string, version?: number) => {
|
|
819
|
+
const snapshot = readReview(id, version);
|
|
689
820
|
|
|
690
|
-
|
|
691
|
-
throw new ReviewInputError(
|
|
692
|
-
"This document has no source pins of its own.",
|
|
693
|
-
409,
|
|
694
|
-
);
|
|
695
|
-
|
|
696
|
-
return pins;
|
|
821
|
+
return snapshot.pins ?? (await data.documentPins(snapshot));
|
|
697
822
|
};
|
|
698
823
|
|
|
699
824
|
app.get("/:id/agent-traces", async (context) => {
|
|
700
825
|
const query = traceQuery.parse(context.req.query());
|
|
701
|
-
const pins = tracePins(context.req.param("id"), query.version);
|
|
826
|
+
const pins = await tracePins(context.req.param("id"), query.version);
|
|
702
827
|
|
|
703
828
|
return context.json(
|
|
704
829
|
await listPinnedTraces(
|
|
@@ -710,7 +835,7 @@ export function createReviewApi(
|
|
|
710
835
|
});
|
|
711
836
|
app.get("/:id/agent-traces/:sessionId", async (context) => {
|
|
712
837
|
const query = traceQuery.parse(context.req.query());
|
|
713
|
-
const pins = tracePins(context.req.param("id"), query.version);
|
|
838
|
+
const pins = await tracePins(context.req.param("id"), query.version);
|
|
714
839
|
|
|
715
840
|
const result = await readStoredTrace(
|
|
716
841
|
store.repositoryPath(pins.repositoryId),
|
|
@@ -789,13 +914,27 @@ export function createReviewApi(
|
|
|
789
914
|
|
|
790
915
|
return context.json(await data!.register(input.path));
|
|
791
916
|
});
|
|
792
|
-
app.post("/resources", async (context) =>
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
917
|
+
app.post("/resources", async (context) => {
|
|
918
|
+
const body = await readBoundedRequestJson(
|
|
919
|
+
context.req.raw,
|
|
920
|
+
8 * 1024 * 1024,
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// Agents send a checkout path; swap it for a repository id.
|
|
924
|
+
const named = z
|
|
925
|
+
.looseObject({ repositoryPath: z.string().min(1) })
|
|
926
|
+
.safeParse(body);
|
|
927
|
+
|
|
928
|
+
if (!named.success) return context.json(await data!.upload(body));
|
|
929
|
+
const { repositoryPath, ...upload } = named.data;
|
|
930
|
+
|
|
931
|
+
return context.json(
|
|
932
|
+
await data!.upload({
|
|
933
|
+
...upload,
|
|
934
|
+
repositoryId: (await data!.register(repositoryPath)).id,
|
|
935
|
+
}),
|
|
936
|
+
);
|
|
937
|
+
});
|
|
799
938
|
app.get("/:id/resources/:resourceId", async (context) => {
|
|
800
939
|
const id = context.req.param("id");
|
|
801
940
|
const snapshot = readReview(id);
|
|
@@ -996,6 +1135,20 @@ export function createReviewApi(
|
|
|
996
1135
|
|
|
997
1136
|
return context.json(await data.changes(pins));
|
|
998
1137
|
});
|
|
1138
|
+
// A commit a quote is read at, in the board's repository.
|
|
1139
|
+
app.get("/:id/commit/:commit", async (context) => {
|
|
1140
|
+
const repositoryId = boardRepositoryId(
|
|
1141
|
+
readReview(context.req.param("id")),
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
if (!repositoryId)
|
|
1145
|
+
throw new ReviewInputError("This board has no repository.", 404);
|
|
1146
|
+
|
|
1147
|
+
return context.json(
|
|
1148
|
+
await data.commitInfo(repositoryId, context.req.param("commit")),
|
|
1149
|
+
);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
999
1152
|
app.get("/:id/commits", async (context) => {
|
|
1000
1153
|
const input = readQuerySchemas.commits.parse(context.req.query());
|
|
1001
1154
|
|
|
@@ -1085,7 +1238,7 @@ export function createReviewApi(
|
|
|
1085
1238
|
);
|
|
1086
1239
|
|
|
1087
1240
|
return [
|
|
1088
|
-
`Whiteboard: ${snapshot.title}`,
|
|
1241
|
+
`Whiteboard: ${boardTitle(snapshot.title)}`,
|
|
1089
1242
|
`Session ID: ${snapshot.reviewId}`,
|
|
1090
1243
|
`Version: ${snapshot.version}`,
|
|
1091
1244
|
...(selection.apiSource?.commit
|
|
@@ -1093,7 +1246,6 @@ export function createReviewApi(
|
|
|
1093
1246
|
: []),
|
|
1094
1247
|
...(selection.apiSource?.pins
|
|
1095
1248
|
? [
|
|
1096
|
-
`Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
|
|
1097
1249
|
...(selection.apiSource.pins.base
|
|
1098
1250
|
? [`Selected base: ${selection.apiSource.pins.base}`]
|
|
1099
1251
|
: []),
|
|
@@ -1102,7 +1254,6 @@ export function createReviewApi(
|
|
|
1102
1254
|
: []),
|
|
1103
1255
|
...(snapshot.pins
|
|
1104
1256
|
? [
|
|
1105
|
-
`Repository ID: ${snapshot.pins.repositoryId}`,
|
|
1106
1257
|
`Session base: ${snapshot.pins.base}`,
|
|
1107
1258
|
`Session head: ${snapshot.pins.head}`,
|
|
1108
1259
|
]
|
|
@@ -1116,10 +1267,15 @@ export function createReviewApi(
|
|
|
1116
1267
|
}
|
|
1117
1268
|
|
|
1118
1269
|
if (ask && data) {
|
|
1270
|
+
const projectOf = (reviewId: string) =>
|
|
1271
|
+
isShared(reviewId) || !store.has(reviewId)
|
|
1272
|
+
? undefined
|
|
1273
|
+
: store.projectOf(reviewId);
|
|
1274
|
+
|
|
1119
1275
|
const readThread = (reviewId: string, threadId: string) => {
|
|
1120
1276
|
const thread = ask.threads.get(threadId);
|
|
1121
1277
|
|
|
1122
|
-
if (!thread
|
|
1278
|
+
if (!thread)
|
|
1123
1279
|
throw new ReviewInputError("This conversation has ended.", 404);
|
|
1124
1280
|
|
|
1125
1281
|
return thread;
|
|
@@ -1198,7 +1354,7 @@ export function createReviewApi(
|
|
|
1198
1354
|
const { reviewId } = snapshot;
|
|
1199
1355
|
|
|
1200
1356
|
return [
|
|
1201
|
-
`A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
|
|
1357
|
+
`A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
|
|
1202
1358
|
checkout.live
|
|
1203
1359
|
? "Your working directory is the repository the review describes."
|
|
1204
1360
|
: `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
|
|
@@ -1326,6 +1482,9 @@ export function createReviewApi(
|
|
|
1326
1482
|
|
|
1327
1483
|
assertWritable(reviewId);
|
|
1328
1484
|
|
|
1485
|
+
// Asked on screen: seen from the start.
|
|
1486
|
+
store.askHistory.markViewed(id, createdAt);
|
|
1487
|
+
|
|
1329
1488
|
const thread = ask.threads.open({
|
|
1330
1489
|
id,
|
|
1331
1490
|
reviewId,
|
|
@@ -1334,7 +1493,10 @@ export function createReviewApi(
|
|
|
1334
1493
|
// replaces a session the agent could not reopen.
|
|
1335
1494
|
...historyHooks({
|
|
1336
1495
|
id,
|
|
1337
|
-
|
|
1496
|
+
anchor:
|
|
1497
|
+
target.kind === "text" && target.anchor
|
|
1498
|
+
? { board: reviewId, block: target.anchor.blockId }
|
|
1499
|
+
: { board: reviewId },
|
|
1338
1500
|
agent: input.agent,
|
|
1339
1501
|
version: snapshot.version,
|
|
1340
1502
|
head: checkout.head,
|
|
@@ -1360,12 +1522,109 @@ export function createReviewApi(
|
|
|
1360
1522
|
return context.json({ threadId: thread.id });
|
|
1361
1523
|
});
|
|
1362
1524
|
|
|
1525
|
+
/** Every project's threads for the sidebar: who runs them, where they
|
|
1526
|
+
* started and live, and which boards they changed. */
|
|
1527
|
+
const projectThreads = (reviewId: string): ReviewProjectThreads => {
|
|
1528
|
+
const entries = store.askHistory.listAll();
|
|
1529
|
+
|
|
1530
|
+
const ids = entries.map((entry) => entry.id);
|
|
1531
|
+
const edited = store.boardsChangedByThreads(ids);
|
|
1532
|
+
const viewed = store.askHistory.viewedAt(ids);
|
|
1533
|
+
|
|
1534
|
+
const boards = new Map<string, ReviewThreadBoard>();
|
|
1535
|
+
|
|
1536
|
+
const board = (id: string) => {
|
|
1537
|
+
let found = boards.get(id);
|
|
1538
|
+
|
|
1539
|
+
if (!found) {
|
|
1540
|
+
found = store.has(id)
|
|
1541
|
+
? store.boardFace(id)
|
|
1542
|
+
: { reviewId: id, icon: boardIconKind(undefined) };
|
|
1543
|
+
boards.set(id, found);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
return found;
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
const project = projectOf(reviewId);
|
|
1550
|
+
|
|
1551
|
+
return {
|
|
1552
|
+
board: reviewId,
|
|
1553
|
+
...(project && { project }),
|
|
1554
|
+
threads: entries.map((entry) => {
|
|
1555
|
+
const anchor = entry.anchor ?? { board: reviewId };
|
|
1556
|
+
const status = ask.threads.get(entry.id)?.status;
|
|
1557
|
+
const viewedAt = viewed.get(entry.id);
|
|
1558
|
+
|
|
1559
|
+
// A turn finished since the reviewer last saw the thread.
|
|
1560
|
+
const unread =
|
|
1561
|
+
status !== "starting" &&
|
|
1562
|
+
status !== "running" &&
|
|
1563
|
+
status !== "waiting" &&
|
|
1564
|
+
viewedAt !== undefined &&
|
|
1565
|
+
entry.updatedAt > viewedAt;
|
|
1566
|
+
|
|
1567
|
+
return {
|
|
1568
|
+
id: entry.id,
|
|
1569
|
+
...(entry.projectId && { projectId: entry.projectId }),
|
|
1570
|
+
...(store.has(anchor.board) && {
|
|
1571
|
+
place: store.boardPlace(anchor.board),
|
|
1572
|
+
}),
|
|
1573
|
+
agent: entry.agent,
|
|
1574
|
+
agentName: askAgents[entry.agent].name,
|
|
1575
|
+
opening: entry.question ?? entry.title,
|
|
1576
|
+
pinned:
|
|
1577
|
+
entry.selection.target.kind === "text" &&
|
|
1578
|
+
entry.selection.target.anchor !== undefined,
|
|
1579
|
+
...(status && { status }),
|
|
1580
|
+
...(unread && { unread }),
|
|
1581
|
+
updatedAt: entry.updatedAt,
|
|
1582
|
+
anchor: {
|
|
1583
|
+
board: board(anchor.board),
|
|
1584
|
+
...(anchor.block && { block: anchor.block }),
|
|
1585
|
+
},
|
|
1586
|
+
edited: edited.get(entry.id) ?? [],
|
|
1587
|
+
};
|
|
1588
|
+
}),
|
|
1589
|
+
};
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1363
1592
|
app.get("/:id/ask/threads", (context) => {
|
|
1364
1593
|
const reviewId = context.req.param("id");
|
|
1365
1594
|
|
|
1366
1595
|
readReview(reviewId);
|
|
1367
1596
|
|
|
1368
|
-
|
|
1597
|
+
if (context.req.query("watch") !== undefined) {
|
|
1598
|
+
// Sent again only when it reads differently.
|
|
1599
|
+
let sent = "";
|
|
1600
|
+
|
|
1601
|
+
return watch(
|
|
1602
|
+
() => {
|
|
1603
|
+
const line = JSON.stringify(projectThreads(reviewId));
|
|
1604
|
+
|
|
1605
|
+
if (line === sent) return [];
|
|
1606
|
+
sent = line;
|
|
1607
|
+
|
|
1608
|
+
return [line];
|
|
1609
|
+
},
|
|
1610
|
+
(notify) => {
|
|
1611
|
+
const stops = [
|
|
1612
|
+
store.askHistory.subscribe(notify),
|
|
1613
|
+
ask.threads.subscribeStatus(notify),
|
|
1614
|
+
// An edit, title or target of any board a thread may show; a
|
|
1615
|
+
// deleted board may have been a thread's anchor.
|
|
1616
|
+
store.subscribe(() => notify()),
|
|
1617
|
+
];
|
|
1618
|
+
|
|
1619
|
+
return () => stops.forEach((stop) => stop());
|
|
1620
|
+
},
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return context.json({
|
|
1625
|
+
threads: store.askHistory.list(reviewId, projectOf(reviewId)),
|
|
1626
|
+
board: reviewId,
|
|
1627
|
+
});
|
|
1369
1628
|
});
|
|
1370
1629
|
|
|
1371
1630
|
// The threads of every open Ask in a review, over one connection. Before
|
|
@@ -1384,20 +1643,32 @@ export function createReviewApi(
|
|
|
1384
1643
|
threads.map((threadId) => {
|
|
1385
1644
|
const thread = ask.threads.get(threadId);
|
|
1386
1645
|
|
|
1387
|
-
return [
|
|
1388
|
-
threadId,
|
|
1389
|
-
thread?.reviewId === reviewId ? thread : undefined,
|
|
1390
|
-
];
|
|
1646
|
+
return [threadId, thread];
|
|
1391
1647
|
}),
|
|
1392
1648
|
),
|
|
1393
1649
|
);
|
|
1394
1650
|
});
|
|
1395
1651
|
|
|
1652
|
+
// The boards a thread changed: titles from any project, so only for a
|
|
1653
|
+
// local caller.
|
|
1654
|
+
app.get("/:id/ask/:threadId/boards", (context) => {
|
|
1655
|
+
const threadId = context.req.param("threadId");
|
|
1656
|
+
const record = store.askHistory.get(threadId);
|
|
1657
|
+
|
|
1658
|
+
const anchor =
|
|
1659
|
+
record?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
|
|
1660
|
+
|
|
1661
|
+
if (remoteCaller(context) || !anchor)
|
|
1662
|
+
throw new ReviewInputError("This conversation was not found.", 404);
|
|
1663
|
+
|
|
1664
|
+
return context.json({ boards: store.boardsChangedBy(threadId) });
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1396
1667
|
// A saved conversation, without starting its agent.
|
|
1397
1668
|
app.get("/:id/ask/:threadId", (context) => {
|
|
1398
1669
|
const record = store.askHistory.get(context.req.param("threadId"));
|
|
1399
1670
|
|
|
1400
|
-
if (record
|
|
1671
|
+
if (!record)
|
|
1401
1672
|
throw new ReviewInputError("This conversation was not found.", 404);
|
|
1402
1673
|
const target = record.selection.target;
|
|
1403
1674
|
|
|
@@ -1429,17 +1700,19 @@ export function createReviewApi(
|
|
|
1429
1700
|
const threadId = context.req.param("threadId");
|
|
1430
1701
|
const live = ask.threads.get(threadId);
|
|
1431
1702
|
|
|
1432
|
-
if (live
|
|
1703
|
+
if (live) return context.json({ threadId });
|
|
1433
1704
|
const record = store.askHistory.get(threadId);
|
|
1434
1705
|
|
|
1435
1706
|
const { picks } = askOpenSchema.parse(
|
|
1436
1707
|
await readBoundedRequestJson(context.req.raw, undefined, {}),
|
|
1437
1708
|
);
|
|
1438
1709
|
|
|
1439
|
-
if (record
|
|
1710
|
+
if (!record)
|
|
1440
1711
|
throw new ReviewInputError("This conversation was not found.", 404);
|
|
1441
1712
|
|
|
1442
|
-
|
|
1713
|
+
// The thread runs in the checkout of the board it started on.
|
|
1714
|
+
const anchor = record.anchor.board;
|
|
1715
|
+
const snapshot = readReview(anchor, record.version);
|
|
1443
1716
|
const checkout = await data.agentCheckout(snapshot);
|
|
1444
1717
|
const target = record.selection.target;
|
|
1445
1718
|
|
|
@@ -1455,7 +1728,7 @@ export function createReviewApi(
|
|
|
1455
1728
|
assertWritable(reviewId);
|
|
1456
1729
|
ask.threads.open({
|
|
1457
1730
|
id: record.id,
|
|
1458
|
-
reviewId,
|
|
1731
|
+
reviewId: anchor,
|
|
1459
1732
|
agent: record.agent,
|
|
1460
1733
|
cwd: checkout.rootPath,
|
|
1461
1734
|
head: checkout.head,
|
|
@@ -1647,6 +1920,16 @@ export function createReviewApi(
|
|
|
1647
1920
|
},
|
|
1648
1921
|
);
|
|
1649
1922
|
|
|
1923
|
+
app.post("/:id/ask/:threadId/viewed", (context) => {
|
|
1924
|
+
const threadId = context.req.param("threadId");
|
|
1925
|
+
|
|
1926
|
+
if (!ask.threads.get(threadId) && !store.askHistory.get(threadId))
|
|
1927
|
+
throw new ReviewInputError("This conversation was not found.", 404);
|
|
1928
|
+
store.askHistory.markViewed(threadId);
|
|
1929
|
+
|
|
1930
|
+
return context.json({ ok: true });
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1650
1933
|
// Closing an Ask ends its agent; the conversation stays saved. A tab
|
|
1651
1934
|
// switched away from or a reload leaves it running, to come back to.
|
|
1652
1935
|
app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
|
|
@@ -1664,17 +1947,29 @@ export function createReviewApi(
|
|
|
1664
1947
|
const reviewId = context.req.param("id");
|
|
1665
1948
|
const threadId = context.req.param("threadId");
|
|
1666
1949
|
|
|
1667
|
-
|
|
1950
|
+
const record = store.askHistory.get(threadId);
|
|
1951
|
+
|
|
1952
|
+
if (!record)
|
|
1668
1953
|
throw new ReviewInputError("This conversation was not found.", 404);
|
|
1669
1954
|
|
|
1670
|
-
if (ask.threads.get(threadId)
|
|
1671
|
-
ask.threads.close(threadId);
|
|
1955
|
+
if (ask.threads.get(threadId)) ask.threads.close(threadId);
|
|
1672
1956
|
store.askHistory.delete(threadId);
|
|
1673
1957
|
|
|
1674
1958
|
return context.json({ ok: true });
|
|
1675
1959
|
});
|
|
1676
1960
|
}
|
|
1677
1961
|
|
|
1962
|
+
app.get("/:id/place", (context) => {
|
|
1963
|
+
const id = context.req.param("id");
|
|
1964
|
+
|
|
1965
|
+
if (!isShared(id)) store.assertExists(id);
|
|
1966
|
+
|
|
1967
|
+
return context.json(placeOf(id));
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
// The board's branch stack: its GitHub stack, else the reviews chained
|
|
1971
|
+
// through the bases they name, bottom first. Apart from its place among
|
|
1972
|
+
// boards.
|
|
1678
1973
|
app.get("/:id/stack", async (context) => {
|
|
1679
1974
|
const query = readQuerySchemas.get.parse(context.req.query());
|
|
1680
1975
|
const id = context.req.param("id");
|
|
@@ -1682,14 +1977,94 @@ export function createReviewApi(
|
|
|
1682
1977
|
|
|
1683
1978
|
if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
|
|
1684
1979
|
|
|
1980
|
+
const repositoryPath = store
|
|
1981
|
+
.repositories()
|
|
1982
|
+
.find(
|
|
1983
|
+
(repository) => repository.id === snapshot.pins!.repositoryId,
|
|
1984
|
+
)?.path;
|
|
1985
|
+
|
|
1685
1986
|
const layers = await resolveReviewStackLayers(snapshot, store.list(), {
|
|
1686
|
-
repositoryPath
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1987
|
+
repositoryPath,
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
// The branch the stack sits on: the repository's default branch.
|
|
1991
|
+
const trunk =
|
|
1992
|
+
layers.length > 1 && repositoryPath
|
|
1993
|
+
? await defaultBranch(repositoryPath).catch(() => null)
|
|
1994
|
+
: null;
|
|
1995
|
+
|
|
1996
|
+
return context.json({
|
|
1997
|
+
layers: layers.map((layer) => {
|
|
1998
|
+
const state = data?.pullRequestState(layer.pullRequestUrl ?? undefined);
|
|
1999
|
+
|
|
2000
|
+
return state ? { ...layer, pullRequestState: state } : layer;
|
|
2001
|
+
}),
|
|
2002
|
+
...(trunk && { base: trunk.ref.replace(/^origin\//, "") }),
|
|
1690
2003
|
});
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
// The board's project and its whiteboards, for the board's sidebar.
|
|
2007
|
+
app.get("/:id/project", (context) => {
|
|
2008
|
+
const id = context.req.param("id");
|
|
2009
|
+
|
|
2010
|
+
if (isShared(id)) return context.json({ project: null, boards: [] });
|
|
2011
|
+
|
|
2012
|
+
const project = store.summary(id)?.project ?? null;
|
|
2013
|
+
|
|
2014
|
+
return context.json({
|
|
2015
|
+
project,
|
|
2016
|
+
boards: project
|
|
2017
|
+
? store
|
|
2018
|
+
.list()
|
|
2019
|
+
.filter(
|
|
2020
|
+
(board) => board.project?.id === project.id && !board.trashedAt,
|
|
2021
|
+
)
|
|
2022
|
+
.map(withPullRequestState)
|
|
2023
|
+
: [],
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
app.get("/:id/links", (context) => {
|
|
2027
|
+
const id = context.req.param("id");
|
|
2028
|
+
|
|
2029
|
+
// A shared board's links name boards on the sender's machine: shown,
|
|
2030
|
+
// but not followed.
|
|
2031
|
+
if (isShared(id))
|
|
2032
|
+
return context.json({
|
|
2033
|
+
outgoing: boardLinks(id, readReview(id).document).map(
|
|
2034
|
+
({ from, to }) => ({ from, to, missing: true, elsewhere: true }),
|
|
2035
|
+
),
|
|
2036
|
+
backlinks: [],
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
const { version } = readQuerySchemas.get
|
|
2040
|
+
.pick({ version: true })
|
|
2041
|
+
.parse({ version: context.req.query("version") });
|
|
2042
|
+
|
|
2043
|
+
const { outgoing, backlinks } = store.links(id, version);
|
|
2044
|
+
|
|
2045
|
+
return context.json({
|
|
2046
|
+
outgoing: outgoing.map(withPullRequestState),
|
|
2047
|
+
backlinks: backlinks.map(withPullRequestState),
|
|
2048
|
+
});
|
|
2049
|
+
});
|
|
2050
|
+
// A link's hover card, read when it opens.
|
|
2051
|
+
app.get("/:id/links/peek", (context) => {
|
|
2052
|
+
const id = context.req.param("id");
|
|
1691
2053
|
|
|
1692
|
-
return context.json({
|
|
2054
|
+
if (isShared(id)) return context.json({});
|
|
2055
|
+
|
|
2056
|
+
const { version } = readQuerySchemas.get
|
|
2057
|
+
.pick({ version: true })
|
|
2058
|
+
.parse({ version: context.req.query("version") });
|
|
2059
|
+
|
|
2060
|
+
const board = context.req.query("board");
|
|
2061
|
+
const block = context.req.query("block");
|
|
2062
|
+
|
|
2063
|
+
if (!board) throw new ReviewInputError("Name the linked board.", 400);
|
|
2064
|
+
|
|
2065
|
+
return context.json(
|
|
2066
|
+
store.linkPeek(id, { board, ...(block && { block }) }, version),
|
|
2067
|
+
);
|
|
1693
2068
|
});
|
|
1694
2069
|
|
|
1695
2070
|
app.get("/:id/history", (context) => {
|
|
@@ -1703,9 +2078,44 @@ export function createReviewApi(
|
|
|
1703
2078
|
version: snapshot.version,
|
|
1704
2079
|
title: snapshot.title,
|
|
1705
2080
|
createdAt: snapshot.createdAt,
|
|
2081
|
+
author: "agent",
|
|
1706
2082
|
},
|
|
1707
2083
|
]);
|
|
1708
2084
|
});
|
|
2085
|
+
|
|
2086
|
+
/** A board's relations as session_list reports them: only those it has. */
|
|
2087
|
+
const listedRelations = ({
|
|
2088
|
+
parentId,
|
|
2089
|
+
children,
|
|
2090
|
+
dependsOn,
|
|
2091
|
+
}: ReturnType<typeof store.relations>) => {
|
|
2092
|
+
const listed: Partial<ReturnType<typeof store.relations>> = {};
|
|
2093
|
+
|
|
2094
|
+
if (parentId) listed.parentId = parentId;
|
|
2095
|
+
|
|
2096
|
+
if (children.length) listed.children = children;
|
|
2097
|
+
|
|
2098
|
+
if (dependsOn.length) listed.dependsOn = dependsOn;
|
|
2099
|
+
|
|
2100
|
+
return listed;
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
/** A board's relations, each board named by its title. */
|
|
2104
|
+
const relatedBoards = (id: string) => {
|
|
2105
|
+
const { parentId, children, dependsOn } = store.relations(id);
|
|
2106
|
+
|
|
2107
|
+
const named = (board: string) => ({
|
|
2108
|
+
id: board,
|
|
2109
|
+
title: store.read(board).title,
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
return {
|
|
2113
|
+
...(parentId && { parent: named(parentId) }),
|
|
2114
|
+
children: children.map(named),
|
|
2115
|
+
dependsOn: dependsOn.map(named),
|
|
2116
|
+
};
|
|
2117
|
+
};
|
|
2118
|
+
|
|
1709
2119
|
app.get("/:id/inspect", (context) => {
|
|
1710
2120
|
const query = inspectQuerySchema.parse(context.req.query());
|
|
1711
2121
|
const id = context.req.param("id");
|
|
@@ -1713,12 +2123,19 @@ export function createReviewApi(
|
|
|
1713
2123
|
|
|
1714
2124
|
return context.json(
|
|
1715
2125
|
query.format === "text"
|
|
1716
|
-
? documentText(
|
|
2126
|
+
? documentText(
|
|
2127
|
+
snapshot,
|
|
2128
|
+
query.targetId,
|
|
2129
|
+
Boolean(query.full),
|
|
2130
|
+
isShared(id) ? undefined : relatedBoards(id),
|
|
2131
|
+
)
|
|
1717
2132
|
: query.targetId !== undefined
|
|
1718
2133
|
? inspectSnapshot(snapshot, query.targetId)
|
|
1719
|
-
:
|
|
1720
|
-
|
|
1721
|
-
|
|
2134
|
+
: {
|
|
2135
|
+
...(query.full ? snapshot : inspectSnapshot(snapshot)),
|
|
2136
|
+
// Its place among the project's boards, as session_list has it.
|
|
2137
|
+
...(!isShared(id) && listedRelations(store.relations(id))),
|
|
2138
|
+
},
|
|
1722
2139
|
);
|
|
1723
2140
|
});
|
|
1724
2141
|
app.get("/:id", async (context) => {
|
|
@@ -1778,27 +2195,32 @@ export function createReviewApi(
|
|
|
1778
2195
|
await readBoundedRequestJson(context.req.raw),
|
|
1779
2196
|
);
|
|
1780
2197
|
|
|
1781
|
-
|
|
1782
|
-
|
|
2198
|
+
// Without local data (a bare store), a path is recorded as it is.
|
|
2199
|
+
const register = async (path: string) =>
|
|
2200
|
+
data ? data.register(path) : store.registerRepository(path);
|
|
1783
2201
|
|
|
1784
|
-
|
|
1785
|
-
|
|
2202
|
+
const request = withBoardRepository(
|
|
2203
|
+
await locateRepositories(
|
|
2204
|
+
await pullRequestTargets(
|
|
2205
|
+
body,
|
|
2206
|
+
register,
|
|
2207
|
+
(url, repositoryId) => {
|
|
2208
|
+
if (!data)
|
|
2209
|
+
throw new ReviewInputError("Repositories are unavailable.");
|
|
2210
|
+
|
|
2211
|
+
return data.resolvePullRequest(url, { id: repositoryId });
|
|
2212
|
+
},
|
|
2213
|
+
(id) => store.read(id),
|
|
2214
|
+
),
|
|
2215
|
+
register,
|
|
2216
|
+
),
|
|
2217
|
+
(id) => (store.has(id) ? store.read(id) : undefined),
|
|
2218
|
+
);
|
|
1786
2219
|
|
|
1787
2220
|
const input = commandSchema.parse(request);
|
|
1788
2221
|
|
|
1789
2222
|
const command = sharedCommandSchema.safeParse(input);
|
|
1790
2223
|
|
|
1791
|
-
if (command.success) {
|
|
1792
|
-
refuseDisabledScratchpad(command.data.operation.reviewId);
|
|
1793
|
-
await ensureScratchpad(command.data.operation.reviewId);
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
if (
|
|
1797
|
-
input.operation.type === "create" &&
|
|
1798
|
-
input.operation.kind === "scratchpad"
|
|
1799
|
-
)
|
|
1800
|
-
refuseDisabledScratchpad(SCRATCHPAD_ID);
|
|
1801
|
-
|
|
1802
2224
|
if (
|
|
1803
2225
|
command.success &&
|
|
1804
2226
|
command.data.operation.reviewId?.startsWith("shared-")
|
|
@@ -1847,7 +2269,23 @@ export function createReviewApi(
|
|
|
1847
2269
|
throw new ReviewInputError("Shared reviews are read-only.", 409);
|
|
1848
2270
|
}
|
|
1849
2271
|
|
|
1850
|
-
|
|
2272
|
+
// Who is writing, trusted only from local callers: a person from
|
|
2273
|
+
// Desktop or the canvas, or an agent, perhaps a thread's. The store
|
|
2274
|
+
// keeps each author's rules.
|
|
2275
|
+
const threadId = remoteCaller(context)
|
|
2276
|
+
? undefined
|
|
2277
|
+
: context.req.header(REVIEW_THREAD_HEADER)?.trim() || undefined;
|
|
2278
|
+
|
|
2279
|
+
const author: Author =
|
|
2280
|
+
!remoteCaller(context) &&
|
|
2281
|
+
context.req.header(REVIEW_PERSON_HEADER) !== undefined
|
|
2282
|
+
? { kind: "person" }
|
|
2283
|
+
: {
|
|
2284
|
+
kind: "agent",
|
|
2285
|
+
...(threadId && { threadId }),
|
|
2286
|
+
};
|
|
2287
|
+
|
|
2288
|
+
const result = await store.execute(input, undefined, author);
|
|
1851
2289
|
|
|
1852
2290
|
if (
|
|
1853
2291
|
result.deleted ||
|
|
@@ -1893,7 +2331,6 @@ export function createReviewApi(
|
|
|
1893
2331
|
if (result.created !== false)
|
|
1894
2332
|
hooks.onReviewCreated?.({
|
|
1895
2333
|
reviewId: result.reviewId,
|
|
1896
|
-
kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
|
|
1897
2334
|
blocks: store.read(result.reviewId).document.length,
|
|
1898
2335
|
...reviewRequestOrigin(context.req.raw.headers),
|
|
1899
2336
|
});
|
|
@@ -1979,6 +2416,148 @@ async function locateRepositories(
|
|
|
1979
2416
|
};
|
|
1980
2417
|
}
|
|
1981
2418
|
|
|
2419
|
+
/** Turns an agent's pullRequest target into what the store takes: a create
|
|
2420
|
+
* from pullRequestUrl, or for set_target the PR's resolved commits. */
|
|
2421
|
+
async function pullRequestTargets(
|
|
2422
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
|
|
2423
|
+
body: unknown,
|
|
2424
|
+
register: (path: string) => Promise<{ id: string }>,
|
|
2425
|
+
resolve: (
|
|
2426
|
+
url: string,
|
|
2427
|
+
repositoryId: string,
|
|
2428
|
+
) => Promise<{ target: ReviewTarget }>,
|
|
2429
|
+
board: (id: string) => void,
|
|
2430
|
+
) {
|
|
2431
|
+
const target = z.strictObject({
|
|
2432
|
+
kind: z.literal("pullRequest"),
|
|
2433
|
+
repositoryPath: z.string().min(1),
|
|
2434
|
+
pullRequest: z.union([z.string().min(1), z.number().int().positive()]),
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
// A set_target is checked whole before anything is registered or fetched.
|
|
2438
|
+
const located = z
|
|
2439
|
+
.looseObject({
|
|
2440
|
+
operation: z.discriminatedUnion("type", [
|
|
2441
|
+
z.looseObject({ type: z.literal("create"), target }),
|
|
2442
|
+
z.strictObject({
|
|
2443
|
+
type: z.literal("set_target"),
|
|
2444
|
+
reviewId: z.string().min(1),
|
|
2445
|
+
target,
|
|
2446
|
+
}),
|
|
2447
|
+
]),
|
|
2448
|
+
})
|
|
2449
|
+
.safeParse(body);
|
|
2450
|
+
|
|
2451
|
+
if (!located.success) return body;
|
|
2452
|
+
const { target: pullRequest, ...operation } = located.data.operation;
|
|
2453
|
+
|
|
2454
|
+
if (operation.type === "set_target") board(operation.reviewId);
|
|
2455
|
+
const { id: repositoryId } = await register(pullRequest.repositoryPath);
|
|
2456
|
+
|
|
2457
|
+
const url = pullRequestAt(
|
|
2458
|
+
pullRequest.repositoryPath,
|
|
2459
|
+
pullRequest.pullRequest,
|
|
2460
|
+
);
|
|
2461
|
+
|
|
2462
|
+
if (operation.type === "create")
|
|
2463
|
+
return {
|
|
2464
|
+
...located.data,
|
|
2465
|
+
operation: { ...operation, pullRequestUrl: url, repositoryId },
|
|
2466
|
+
};
|
|
2467
|
+
|
|
2468
|
+
return {
|
|
2469
|
+
...located.data,
|
|
2470
|
+
operation: {
|
|
2471
|
+
...operation,
|
|
2472
|
+
target: (await resolve(url, repositoryId)).target,
|
|
2473
|
+
pullRequestUrl: url,
|
|
2474
|
+
},
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** A PR's URL: as given, or its number on the checkout's GitHub repository. */
|
|
2479
|
+
function pullRequestAt(repositoryPath: string, pullRequest: string | number) {
|
|
2480
|
+
if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest))
|
|
2481
|
+
return pullRequest;
|
|
2482
|
+
|
|
2483
|
+
const slug = resolveRepoContextSync(repositoryPath)?.githubSlug;
|
|
2484
|
+
|
|
2485
|
+
if (!slug)
|
|
2486
|
+
throw new ReviewInputError(
|
|
2487
|
+
"This checkout has no GitHub remote to find the PR number in; pass the PR's URL.",
|
|
2488
|
+
);
|
|
2489
|
+
|
|
2490
|
+
return `https://github.com/${slug}/pull/${pullRequest}`;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/** A pins object as an edit spells it: commits, with no other fields. A
|
|
2494
|
+
* diagram may key its own data `pins`, and that is left alone. */
|
|
2495
|
+
/** Pins as an agent writes them: the board's repository is the host's to
|
|
2496
|
+
* add. */
|
|
2497
|
+
const agentPinsSchema = z.strictObject({
|
|
2498
|
+
head: z.string().min(1),
|
|
2499
|
+
base: z.string().min(1).optional(),
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
/** An object an agent meant as pins: one naming a head, base or repository.
|
|
2503
|
+
* Content may have a field called `pins` that is nothing of the kind. */
|
|
2504
|
+
const meantAsPins = (value: JsonValue): value is JsonObject =>
|
|
2505
|
+
isJsonObject(value) &&
|
|
2506
|
+
["head", "base", "repositoryId"].some((key) => key in value);
|
|
2507
|
+
|
|
2508
|
+
/** Sets every pins object in an edit to the board's own repository. */
|
|
2509
|
+
function withBoardRepository(
|
|
2510
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
|
|
2511
|
+
body: unknown,
|
|
2512
|
+
board: (id: string) => Snapshot | undefined,
|
|
2513
|
+
) {
|
|
2514
|
+
const located = z
|
|
2515
|
+
.looseObject({
|
|
2516
|
+
operation: z.looseObject({
|
|
2517
|
+
type: z.enum(["edit", "lens_edit"]),
|
|
2518
|
+
reviewId: z.string(),
|
|
2519
|
+
edit: z.json(),
|
|
2520
|
+
}),
|
|
2521
|
+
})
|
|
2522
|
+
.safeParse(body);
|
|
2523
|
+
|
|
2524
|
+
if (!located.success) return body;
|
|
2525
|
+
const snapshot = board(located.data.operation.reviewId);
|
|
2526
|
+
|
|
2527
|
+
const repositoryId = snapshot ? boardRepositoryId(snapshot) : undefined;
|
|
2528
|
+
|
|
2529
|
+
const edit = JSON.parse(
|
|
2530
|
+
JSON.stringify(located.data.operation.edit),
|
|
2531
|
+
(key, value: JsonValue) => {
|
|
2532
|
+
if (key !== "pins" || !meantAsPins(value)) return value;
|
|
2533
|
+
|
|
2534
|
+
// Checked as the agent wrote them, so a mistake names head or base,
|
|
2535
|
+
// never the repository the host adds.
|
|
2536
|
+
const { repositoryId: _ignored, ...written } = value;
|
|
2537
|
+
const pins = agentPinsSchema.safeParse(written);
|
|
2538
|
+
|
|
2539
|
+
if (!pins.success)
|
|
2540
|
+
throw new ReviewInputError(
|
|
2541
|
+
`Write pins as {head, base?}, each a commit: ${pins.error.issues
|
|
2542
|
+
.map((issue) => `pins.${issue.path.join(".")}: ${issue.message}`)
|
|
2543
|
+
.join("; ")}.`,
|
|
2544
|
+
);
|
|
2545
|
+
|
|
2546
|
+
if (!repositoryId)
|
|
2547
|
+
throw new ReviewInputError(
|
|
2548
|
+
"This whiteboard has no repository for its pins to name commits of.",
|
|
2549
|
+
);
|
|
2550
|
+
|
|
2551
|
+
return { ...pins.data, repositoryId };
|
|
2552
|
+
},
|
|
2553
|
+
);
|
|
2554
|
+
|
|
2555
|
+
return {
|
|
2556
|
+
...located.data,
|
|
2557
|
+
operation: { ...located.data.operation, edit },
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
|
|
1982
2561
|
function watch(
|
|
1983
2562
|
lines: () => string[],
|
|
1984
2563
|
subscribe: (notify: () => void) => () => void,
|