prreviewbuddy 0.64.2 → 0.70.2

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/server.js CHANGED
@@ -1,7 +1,9 @@
1
- import { $ as storedScopes, $n as loadWorkspace, A as reanalyseReview, An as endedAt, Ar as issuesOutstanding, At as renderMarkdown, Br as isCommitReview, Bt as createWorktree, C as track, Cn as remoteHost, Cr as queueAddMany, Ct as unreadCount$1, D as endRun, Dn as followedBranch, Dr as doneVerb, Dt as authorPreview, E as completeRun, En as checkReview, Er as readQueue, Et as UNESTABLISHED_MOTIVATION, F as record, Fr as describeAuthorship, Ft as asString, G as describeRuntimes, Gn as groupByLineage, Gr as readAgentPreference, Gt as requestJobStop, H as mapRuntimes, Hn as isClaimed, Ht as isWorktreeHeld, In as isLiveJob, Ir as WITHHELD_REASON, It as extractJsonText, J as loadArchitecture, Jn as kissDecisionRefusal, Jr as STORE_ROOT, Jt as activateQueued, K as ARCHITECTURE_DIR, Kn as indexKeptIds, Kr as writeAgentPreference, Ln as phasesFor, Lr as kissOutcome, Lt as objectWithAnyOf, Mn as isTerminal, Mr as reviewerDispositions, Mt as CODE_COPY_CLASS, Nn as loadJob, Nr as processDiscussion, Nt as escapeHtml$1, O as reportRun, On as reviewedShaOf, Or as isIssueOutstanding, Ot as hasAuthorContent, P as readEvents, Pn as saveJob, Pr as recommendationsOf, Pt as renderInlineText, Q as storeArchitecture, Qn as lineagePosition, Qr as agentFor, Qt as readIndexToken, R as recordTelemetryUploadConsent, Rn as progressSteps, Rt as ReviewBeingDeletedError, S as isDriving, Sn as forgeFor, Sr as queueAdd, St as toneOf, T as beginRun, Tn as checkCodeFreshness, Tr as queueStamp, Tt as EXPLAIN_SIMPLY_PROMPT, U as recordRuntimeBestEffort, Un as decideKissRecommendation, Ur as resolveModel, Ut as readMarker, V as waitingHistory, Vr as git, Vt as holdWorktree, W as RUNTIME_WORDS, Wn as followedRefName, Wr as clearAgentPreference, Wt as removeWorktree, X as readArchitecture, Xn as latestKissRun, Y as partFiles, Yn as kissDecisionsOf, Yr as AGENT_IDS, Yt as compactQueue, Z as repositoryKey, Zt as settleQueue, _ as liveUpdateFor, _n as resolveWorkspacePort, _r as lifecycleItemLabel, _t as markRead, a as wasBlocked, an as spawnServer, ar as recentWorkspaces, at as isArchitectureId, bn as claimRequestUrl, br as parseLifecycle, bt as readNotification, c as RefreshUnavailableError, ct as branchOf, d as mergeUpdateInto, dr as workspaceStamps, dt as createPrState, ei as AgentCancelledError, en as clearServerRecordOwnedBy, et as trackedFiles, f as updateReview, fn as feedbackUrl, fr as LIFECYCLES, ft as createPrUpdate, g as UpdateAlreadyRunningError, gn as parseWorkspacePort, gr as lifecycleChip, gt as markAllRead, h as askCheckout, hn as MIN_WORKSPACE_PORT, hr as isLifecycle, ht as listNotifications, i as liveJobsFor, in as serverEntryExists, ir as previousKissResult, it as ancestorsOf, j as runJob, jn as fail, jr as questionsOutstanding, jt as CODE_BLOCK_CLASS, k as stopRun, kn as allJobs, kr as isQuestionOutstanding, kt as GITHUB_MARKDOWN, l as cancelUpdate, ln as writeServerRecord, lr as touchWorkspace, lt as subjectOf, m as isUnchanged, mn as MAX_WORKSPACE_PORT, mr as activityLabel, mt as headline, n as deleteReview, nn as readServerRecord, nt as QUOTE_LIMITS, o as discardJob, on as stopChild, or as reviewedCommit, ot as writeJsonAtomically, p as refreshPrContext, pn as DEFAULT_WORKSPACE_PORT, pr as activityAttributes, pt as eventName, q as checkArchitecture, qn as indexReviewGroups, qt as relativeTime$1, r as lineageIds, rr as presentedKissRunOf, rt as REPOSITORY_SCOPE, s as FETCH_TIMEOUT_MS, st as announceJob, t as deleteLineage, ti as AgentUnavailableError, tr as mutateWorkspace, tt as ownersOf, u as cancellableHere, un as BUILD_VERSION, ur as workspaceRevision, ut as createArchitectureNotification, vn as statedWorkspacePort, vr as lifecyclePill, vt as markReadForReview, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as queueRemove, wt as withUsageRecorded, x as startKissJob, xr as sectionOf, xt as removeNotification, y as agentEnvOf, yn as writePortPreference, yr as offersLifecycle, yt as pruneNotifications, z as telemetryUploadConsent, zn as purposeOf, zt as MANAGED_ROOT } from "./delete_review-l4p_p6oh.js";
1
+ import { $ as MAX_WORKSPACE_PORT, $n as isClaimed, $r as resolveAgent, $t as markReadForReview, A as relativeTime$1, An as isWorktreeHeld, Ar as parseLifecycle, At as partFiles, B as readIndexToken, Bn as reviewedShaOf, Br as issuesOutstanding, Bt as isArchitectureId, C as track, Cn as originRemote, Cr as activityAttributes, Ct as mapRuntimes, D as endRun, Dn as MANAGED_ROOT, Dr as lifecycleItemLabel, Dt as ARCHITECTURE_DIR, E as completeRun, En as ReviewBeingDeletedError, Er as lifecycleChip, Et as describeRuntimes, F as runBatch, Fr as queueStamp, Ft as trackedFiles, Gn as loadJob, Gr as describeAuthorship, Gt as createArchitectureNotification, H as clearServerRecordOwnedBy, Hn as endedAt, Hr as reviewerDispositions, Ht as announceJob, I as activateQueued, In as findExistingReview, Ir as readQueue, It as ownersOf, J as stopChild, Jn as isLiveJob, Jr as resolveTarget, Jt as eventName, K as serverEntryExists, Kn as saveJob, Kr as WITHHELD_REASON, Kt as createPrState, L as compactQueue, Ln as checkCodeFreshness, Lr as doneVerb, Lt as QUOTE_LIMITS, M as extractPullRequestLinks, Mn as removeWorktree, Mr as queueAdd, Mt as repositoryKey, N as parsePullRequestLink, Nn as requestJobStop, Nr as queueAddMany, Nt as storeArchitecture, O as reportRun, On as createWorktree, Or as lifecyclePill, Ot as checkArchitecture, P as unsupportedReason, Pr as queueRemove, Pt as storedScopes, Q as DEFAULT_WORKSPACE_PORT, Qr as git, Qt as markRead, Rn as checkReview, Rr as isIssueOutstanding, Rt as REPOSITORY_SCOPE, S as isDriving, Sn as remoteHost, Sr as LIFECYCLES, St as waitingHistory, T as beginRun, Tr as isLifecycle, Tt as RUNTIME_WORDS, Un as fail, Ur as processDiscussion, Ut as branchOf, Vn as allJobs, Vr as questionsOutstanding, Vt as writeJsonAtomically, W as readServerRecord, Wn as isTerminal, Wr as recommendationsOf, Wt as subjectOf, Xn as progressSteps, Xr as isCommitReview, Xt as listNotifications, Yn as phasesFor, Yr as DetachedHeadError, Yt as headline, Z as writeServerRecord, Zn as purposeOf, Zr as resolveBase, Zt as markAllRead, _ as liveUpdateFor, _n as extractJsonText, _t as telemetryUploadConsent, a as wasBlocked, ai as STORE_ROOT, an as withUsageRecorded, ar as kissDecisionRefusal, at as failureOf$1, bn as forgeResolver, br as workspaceRevision, c as RefreshUnavailableError, ci as agentFor$1, cn as authorPreview, cr as lineagePosition, ct as reanalyseReview, d as mergeUpdateInto, di as AgentUnavailableError, dn as renderMarkdown, dr as mutateWorkspace, ei as resolveModel, en as pruneNotifications, er as decideKissRecommendation, et as MIN_WORKSPACE_PORT, f as updateReview, fn as CODE_BLOCK_CLASS, ft as readEvents, g as UpdateAlreadyRunningError, gn as asString, gr as reviewedCommit, gt as recordTelemetryUploadConsent, h as askCheckout, hn as renderInlineText, hr as recentWorkspaces, i as liveJobsFor, in as unreadCount$1, ir as indexReviewGroups, it as writePortPreference, j as batchIdentity, jn as readMarker, jr as sectionOf, jt as readArchitecture, k as stopRun, kn as holdWorktree, kr as offersLifecycle, kt as loadArchitecture, l as cancelUpdate, li as detectAgents, ln as hasAuthorContent, lr as loadWorkspace, lt as runJob, m as isUnchanged, mn as escapeHtml$1, mr as previousKissResult, n as deleteReview, ni as readAgentPreference, nn as removeNotification, nr as groupByLineage, nt as resolveWorkspacePort, o as discardJob, oi as AGENT_IDS, on as EXPLAIN_SIMPLY_PROMPT, or as kissDecisionsOf, ot as prepareReview, p as refreshPrContext, pn as CODE_COPY_CLASS, pr as presentedKissRunOf, pt as record, q as spawnServer, qr as kissOutcome, qt as createPrUpdate, r as lineageIds, ri as writeAgentPreference, rn as toneOf, rr as indexKeptIds, rt as statedWorkspacePort, s as FETCH_TIMEOUT_MS, si as agentById, sn as UNESTABLISHED_MOTIVATION, sr as latestKissRun, st as settleReview, t as deleteLineage, ti as clearAgentPreference, tn as readNotification, tr as followedRefName, tt as parseWorkspacePort, u as cancellableHere, ui as AgentCancelledError, un as GITHUB_MARKDOWN, vn as objectWithAnyOf, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as activityLabel, wt as recordRuntimeBestEffort, x as startKissJob, xn as forgeFor, xr as workspaceStamps, xt as feedbackUrl, y as agentEnvOf, yn as claimRequestUrl, yr as touchWorkspace, yt as BUILD_VERSION, z as settleQueue, zn as followedBranch, zr as isQuestionOutstanding, zt as ancestorsOf } from "./delete_review-CRPRIRKg.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
5
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
6
+ import { homedir } from "node:os";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import { createServer } from "node:http";
7
9
  //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
@@ -7241,7 +7243,7 @@ function startArchitectureRun(workspace, scope, deps = {}) {
7241
7243
  const now = deps.now ?? Date.now;
7242
7244
  const startedAt = now();
7243
7245
  const env = agentEnvOf(workspace);
7244
- const agent = agentFor(env);
7246
+ const agent = agentFor$1(env);
7245
7247
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
7246
7248
  const record = {
7247
7249
  version: 1,
@@ -10372,6 +10374,656 @@ function reviewsRevision(live = analysisStates()) {
10372
10374
  return hash.digest("base64url");
10373
10375
  }
10374
10376
  //#endregion
10377
+ //#region ../../packages/review-harness/src/workspace/working_folders.ts
10378
+ /**
10379
+ * The local Git working folders PR Review Buddy can find right now.
10380
+ *
10381
+ * One question and nothing more: which folders on this machine could a review run in. The answer is
10382
+ * derived each time from what is already known, the folders past reviews ran in and the other
10383
+ * worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
10384
+ * up because `git worktree list` names it, not because anything remembered it.
10385
+ *
10386
+ * A folder is a folder. A linked worktree is its own entry with its own path and is never folded
10387
+ * into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
10388
+ * where the branch is checked out.
10389
+ *
10390
+ * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
10391
+ */
10392
+ function canonical(path) {
10393
+ try {
10394
+ return realpathSync(path);
10395
+ } catch {
10396
+ return path;
10397
+ }
10398
+ }
10399
+ /** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
10400
+ async function worktreesOf(repoPath) {
10401
+ let output;
10402
+ try {
10403
+ output = await git(repoPath, [
10404
+ "worktree",
10405
+ "list",
10406
+ "--porcelain"
10407
+ ]);
10408
+ } catch {
10409
+ return [];
10410
+ }
10411
+ const entries = [];
10412
+ for (const block of output.split(/\n\s*\n/)) {
10413
+ const lines = block.split("\n");
10414
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
10415
+ if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
10416
+ const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
10417
+ entries.push({
10418
+ path,
10419
+ branch: ref ? ref.replace(/^refs\/heads\//, "") : null
10420
+ });
10421
+ }
10422
+ return entries;
10423
+ }
10424
+ async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
10425
+ const lastReviewed = /* @__PURE__ */ new Map();
10426
+ for (const summary of summaries) {
10427
+ if (!summary.repoPath) continue;
10428
+ const key = canonical(summary.repoPath);
10429
+ lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
10430
+ }
10431
+ const found = /* @__PURE__ */ new Map();
10432
+ for (const seed of lastReviewed.keys()) {
10433
+ if (found.has(seed) || !existsSync(seed)) continue;
10434
+ (await worktreesOf(seed)).forEach((entry, index) => {
10435
+ const key = canonical(entry.path);
10436
+ if (!found.has(key) && existsSync(key)) found.set(key, {
10437
+ entry: {
10438
+ ...entry,
10439
+ path: key
10440
+ },
10441
+ main: index === 0
10442
+ });
10443
+ });
10444
+ }
10445
+ return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
10446
+ path: entry.path,
10447
+ remote: await originRemote(entry.path),
10448
+ branch: entry.branch,
10449
+ main,
10450
+ lastReviewedAt: lastReviewed.get(entry.path) ?? null
10451
+ })))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
10452
+ }
10453
+ /** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
10454
+ function isGitHubRepository(remote, owner, repo) {
10455
+ return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
10456
+ }
10457
+ /**
10458
+ * The folders a pull request could be reviewed in, the suggested one first.
10459
+ *
10460
+ * Every match is returned, because which one is right is the reviewer's call: an agent's worktree
10461
+ * and the main clone hold the same objects and differ in what is checked out. The order only
10462
+ * decides the default. A folder that already has a review of this pull request comes first, so
10463
+ * reviewing it again finds that review rather than starting a second one filed under another
10464
+ * folder; then the main worktree; then whichever was reviewed in most recently.
10465
+ */
10466
+ function candidatesFor(folders, link, summaries) {
10467
+ const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
10468
+ const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
10469
+ const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
10470
+ return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
10471
+ }
10472
+ /** The review of this pull request already filed under one of these folders, if there is one. */
10473
+ function reviewedIn(folders, link, summaries) {
10474
+ const paths = new Set(folders.map((folder) => folder.path));
10475
+ return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
10476
+ }
10477
+ //#endregion
10478
+ //#region ../../packages/review-harness/src/workspace/choose_folder.ts
10479
+ /**
10480
+ * Ask the person at this machine to pick a folder, with the operating system's own picker.
10481
+ *
10482
+ * The workspace is a page served by a local daemon, and a page cannot learn a folder's path from
10483
+ * the browser's directory picker, so the daemon asks instead. The operation is "choose a working
10484
+ * folder"; how a platform does it stays in this file, so neither the routes nor the page know.
10485
+ *
10486
+ * Only macOS has an implementation, through `osascript`. Elsewhere there is no chooser, and the
10487
+ * Add review panel falls back to the path field and the Recent list, which is all v1 needs.
10488
+ */
10489
+ /** Long enough for somebody to browse; short enough that a forgotten dialog does not hold a request for ever. */
10490
+ var CHOOSER_TIMEOUT_MS = 3e5;
10491
+ function folderChooser(platform = process.platform) {
10492
+ if (platform !== "darwin") return null;
10493
+ return () => new Promise((resolve, reject) => {
10494
+ execFile("osascript", ["-e", "POSIX path of (choose folder with prompt \"Choose the working folder to review\")"], { timeout: CHOOSER_TIMEOUT_MS }, (error, stdout, stderr) => {
10495
+ if (error) return /-128/.test(String(stderr)) ? resolve(null) : reject(/* @__PURE__ */ new Error("The folder picker could not be opened."));
10496
+ resolve(stdout.trim().replace(/\/$/, "") || null);
10497
+ });
10498
+ });
10499
+ }
10500
+ function createSlots(capacity) {
10501
+ let free = capacity;
10502
+ const waiting = [];
10503
+ const releaser = () => {
10504
+ let given = false;
10505
+ return () => {
10506
+ if (given) return;
10507
+ given = true;
10508
+ const next = waiting.shift();
10509
+ if (next) next(releaser());
10510
+ else free += 1;
10511
+ };
10512
+ };
10513
+ return {
10514
+ acquire: () => {
10515
+ if (free > 0) {
10516
+ free -= 1;
10517
+ return Promise.resolve(releaser());
10518
+ }
10519
+ return new Promise((resolve) => waiting.push(resolve));
10520
+ },
10521
+ tryAcquire: () => {
10522
+ if (free === 0 || waiting.length > 0) return null;
10523
+ free -= 1;
10524
+ return releaser();
10525
+ }
10526
+ };
10527
+ }
10528
+ /** How many pull requests one submission may name. See `MAX_PULL_REQUESTS_PER_BATCH`. */
10529
+ var MAX_BATCH = 20;
10530
+ /** How long a finished batch stays answerable, for a panel that is still open on it. */
10531
+ var FINISHED_BATCH_TTL_MS = 6e5;
10532
+ function realAddReviewDeps() {
10533
+ return {
10534
+ resolveAgent: (flag) => resolveAgent(flag ? { flag } : {}),
10535
+ agents: async () => (await detectAgents()).map((agent) => ({
10536
+ id: agent.id,
10537
+ displayName: agent.displayName,
10538
+ installed: agent.state === "installed"
10539
+ })),
10540
+ folders: () => discoverWorkingFolders(),
10541
+ summaries: () => recentWorkspaces(Number.MAX_SAFE_INTEGER),
10542
+ remoteOf: originRemote,
10543
+ resolvePullRequest: (folder, url) => resolveTarget({
10544
+ repoPath: folder,
10545
+ prUrl: url,
10546
+ resolveRequest: forgeResolver
10547
+ }),
10548
+ resolveBranch: (folder, base) => resolveTarget({
10549
+ repoPath: folder,
10550
+ ...base ? { base } : {}
10551
+ }),
10552
+ defaultBase: async (root) => {
10553
+ try {
10554
+ const base = await resolveBase(root, void 0);
10555
+ return (base.followRef ?? base.display).replace(/^refs\/(?:heads|remotes)\//, "");
10556
+ } catch {
10557
+ return null;
10558
+ }
10559
+ },
10560
+ findExisting: findExistingReview,
10561
+ prepare: prepareReview,
10562
+ settle: (workspaceId) => settleReview(workspaceId, "standard"),
10563
+ track,
10564
+ chooseFolder: folderChooser(),
10565
+ concurrency: 2,
10566
+ slots: createSlots(2),
10567
+ resumeOf: (reviewId) => {
10568
+ const failure = failureOf$1(reviewId);
10569
+ return failure ? {
10570
+ agent: agentNameOf(failure.agentId),
10571
+ ...failure.requestedModel ? { model: failure.requestedModel } : {}
10572
+ } : null;
10573
+ },
10574
+ agentName: agentNameOf,
10575
+ home: homedir()
10576
+ };
10577
+ }
10578
+ function agentNameOf(agentId) {
10579
+ try {
10580
+ return agentById(agentId).displayName;
10581
+ } catch {
10582
+ return agentId;
10583
+ }
10584
+ }
10585
+ var refuse = (status, error) => ({
10586
+ status,
10587
+ body: { error }
10588
+ });
10589
+ /** A path as a person reads it, with their home folder as `~`. */
10590
+ function displayPath(path, home) {
10591
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
10592
+ }
10593
+ /** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
10594
+ function expandPath(raw, home) {
10595
+ const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
10596
+ if (!trimmed) return null;
10597
+ const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
10598
+ return isAbsolute(expanded) ? expanded : null;
10599
+ }
10600
+ function message(error) {
10601
+ return error instanceof Error ? error.message : String(error);
10602
+ }
10603
+ /** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
10604
+ function unresolvedAgent(resolution) {
10605
+ switch (resolution.kind) {
10606
+ case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
10607
+ case "unknown-id": return `"${resolution.id}" is not an agent PR Review Buddy knows. Choose another under Review options, or change the default in Settings.`;
10608
+ case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
10609
+ }
10610
+ }
10611
+ /**
10612
+ * The agent a submission runs under: the one named under Review options, or else the same answer
10613
+ * the CLI reaches with no flag (the configured default, or the only one installed). Never the
10614
+ * harness's own fallback: an id is always passed down, so `startJob` never has to guess.
10615
+ */
10616
+ async function agentFor(flag, deps) {
10617
+ if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.");
10618
+ const resolution = await deps.resolveAgent(flag || void 0);
10619
+ if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
10620
+ if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.");
10621
+ return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution));
10622
+ }
10623
+ function modelOf(value) {
10624
+ if (value === void 0 || value === null || value === "") return void 0;
10625
+ if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.");
10626
+ return value.trim() || void 0;
10627
+ }
10628
+ /**
10629
+ * The tail of each target's queue of creations, by batch identity. In-process, like the fetch
10630
+ * queue in `target.ts`.
10631
+ *
10632
+ * `prepareReview` looks for an existing review and then creates one, and nothing between the two
10633
+ * stops a second start of the same target, from another tab or another paste, from looking in the
10634
+ * same gap and creating a second review of it. One at a time per target closes that gap for every
10635
+ * start this server makes; the second one finds the first one's review.
10636
+ */
10637
+ var creations = /* @__PURE__ */ new Map();
10638
+ function oneCreationAtATime(target, work) {
10639
+ const key = batchIdentity(target);
10640
+ const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
10641
+ const tail = mine.catch(() => {});
10642
+ creations.set(key, tail);
10643
+ tail.then(() => {
10644
+ if (creations.get(key) === tail) creations.delete(key);
10645
+ });
10646
+ return mine;
10647
+ }
10648
+ var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
10649
+ async function addReviewOptions(deps) {
10650
+ const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
10651
+ return {
10652
+ status: 200,
10653
+ body: {
10654
+ agents,
10655
+ agent: resolution.kind === "resolved" ? {
10656
+ kind: "resolved",
10657
+ id: resolution.agent.id,
10658
+ displayName: resolution.agent.displayName
10659
+ } : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
10660
+ kind: "refused",
10661
+ error: unresolvedAgent(resolution)
10662
+ },
10663
+ canChooseFolder: deps.chooseFolder !== null
10664
+ }
10665
+ };
10666
+ }
10667
+ function reviewedRow(reviewId, deps) {
10668
+ const kept = deps.resumeOf(reviewId);
10669
+ return {
10670
+ reviewId,
10671
+ ...kept ? { kept } : {}
10672
+ };
10673
+ }
10674
+ function folderOption(folder, home) {
10675
+ return {
10676
+ path: folder.path,
10677
+ display: displayPath(folder.path, home),
10678
+ branch: folder.branch
10679
+ };
10680
+ }
10681
+ /**
10682
+ * What a paste would start, without starting anything: rows, the folders each could run in, and
10683
+ * which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
10684
+ * run on every pause in typing.
10685
+ *
10686
+ * Every link gets a row, however many there are. Past the batch limit the panel refuses to start
10687
+ * rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
10688
+ * got twenty reviews would not find out which one was missing.
10689
+ */
10690
+ async function previewPullRequests(text, deps) {
10691
+ if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
10692
+ const links = extractPullRequestLinks(text);
10693
+ const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
10694
+ return {
10695
+ status: 200,
10696
+ body: {
10697
+ rows: links.map((link) => {
10698
+ if (link.kind === "unsupported") return {
10699
+ key: link.key,
10700
+ label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
10701
+ url: link.url,
10702
+ ok: false,
10703
+ reason: unsupportedReason(link.provider),
10704
+ folders: []
10705
+ };
10706
+ const label = `${link.owner}/${link.repo} #${link.number}`;
10707
+ const candidates = candidatesFor(folders, link, summaries);
10708
+ if (candidates.length === 0) return {
10709
+ key: link.key,
10710
+ label,
10711
+ url: link.url,
10712
+ ok: false,
10713
+ reason: "Local repository not found",
10714
+ needsFolder: true,
10715
+ folders: []
10716
+ };
10717
+ const reviewed = reviewedIn(candidates, link, summaries);
10718
+ return {
10719
+ key: link.key,
10720
+ label,
10721
+ url: link.url,
10722
+ ok: true,
10723
+ folders: candidates.map((folder) => folderOption(folder, deps.home)),
10724
+ folder: candidates[0].path,
10725
+ ...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
10726
+ };
10727
+ }),
10728
+ limit: MAX_BATCH
10729
+ }
10730
+ };
10731
+ }
10732
+ var batches = /* @__PURE__ */ new Map();
10733
+ function forgetFinished(now) {
10734
+ for (const [id, batch] of batches) if (batch.finishedAt !== null && now - batch.finishedAt > FINISHED_BATCH_TTL_MS) batches.delete(id);
10735
+ }
10736
+ function newBatch(rows) {
10737
+ forgetFinished(Date.now());
10738
+ const batch = {
10739
+ id: randomUUID(),
10740
+ rows,
10741
+ finishedAt: null
10742
+ };
10743
+ batches.set(batch.id, batch);
10744
+ return batch;
10745
+ }
10746
+ var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
10747
+ /**
10748
+ * Create the review for a row's target, or find the one it has, under the per-target lock. The
10749
+ * caller holds an analysis slot: nothing is created without one, so nothing created waits.
10750
+ */
10751
+ async function prepareRow(row, target, agentId, model, deps) {
10752
+ const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, false, model));
10753
+ row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
10754
+ if (prepared.kind === "existing") row.status = "already-reviewed";
10755
+ else row.status = "reviewing";
10756
+ if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
10757
+ return prepared;
10758
+ }
10759
+ async function settleRow(row, deps) {
10760
+ const settled = await deps.settle(row.reviewId);
10761
+ if (settled.ok) row.status = "ready";
10762
+ else {
10763
+ row.status = "failed";
10764
+ row.reason = settled.stopped ? "Stopped" : settled.reason;
10765
+ }
10766
+ }
10767
+ function keptOf(agentId, model, deps) {
10768
+ return {
10769
+ agent: deps.agentName(agentId),
10770
+ ...model ? { model } : {}
10771
+ };
10772
+ }
10773
+ /**
10774
+ * Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
10775
+ * dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
10776
+ *
10777
+ * Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
10778
+ * other batch and every branch review, and is resolved and created only once it has one.
10779
+ */
10780
+ async function startPullRequestBatch(body, deps) {
10781
+ const raw = Array.isArray(body?.items) ? body.items : null;
10782
+ if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
10783
+ if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
10784
+ const items = [];
10785
+ for (const entry of raw) {
10786
+ const url = typeof entry?.url === "string" ? entry.url : "";
10787
+ const link = parsePullRequestLink(url);
10788
+ if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
10789
+ const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
10790
+ if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
10791
+ items.push({
10792
+ url: link.url,
10793
+ owner: link.owner,
10794
+ repo: link.repo,
10795
+ number: link.number,
10796
+ key: link.key,
10797
+ folder
10798
+ });
10799
+ }
10800
+ const model = modelOf(body?.model);
10801
+ if (isAnswer(model)) return model;
10802
+ const agent = await agentFor(body?.agent, deps);
10803
+ if (isAnswer(agent)) return agent;
10804
+ const batch = newBatch(items.map((item) => ({
10805
+ key: item.key,
10806
+ label: `${item.owner}/${item.repo} #${item.number}`,
10807
+ status: "waiting"
10808
+ })));
10809
+ /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
10810
+ const claimed = /* @__PURE__ */ new Map();
10811
+ const lane = async (item, index) => {
10812
+ const row = batch.rows[index];
10813
+ const release = await deps.slots.acquire();
10814
+ row.status = "reviewing";
10815
+ try {
10816
+ const remote = await deps.remoteOf(item.folder);
10817
+ if (!isGitHubRepository(remote, item.owner, item.repo)) {
10818
+ row.status = "failed";
10819
+ row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
10820
+ return;
10821
+ }
10822
+ const target = await deps.resolvePullRequest(item.folder, item.url);
10823
+ const identity = batchIdentity(target);
10824
+ const first = claimed.get(identity);
10825
+ if (first !== void 0) {
10826
+ row.status = "already-reviewed";
10827
+ row.reason = `Same pull request as ${batch.rows[first].label}`;
10828
+ return;
10829
+ }
10830
+ claimed.set(identity, index);
10831
+ if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
10832
+ } catch (error) {
10833
+ row.status = "failed";
10834
+ row.reason = message(error);
10835
+ } finally {
10836
+ release();
10837
+ }
10838
+ };
10839
+ deps.track(runBatch(items, {
10840
+ concurrency: deps.concurrency,
10841
+ run: lane
10842
+ })).catch(() => {}).finally(() => {
10843
+ batch.finishedAt = Date.now();
10844
+ });
10845
+ return {
10846
+ status: 200,
10847
+ body: {
10848
+ batchId: batch.id,
10849
+ rows: rowsOf(batch)
10850
+ }
10851
+ };
10852
+ }
10853
+ /**
10854
+ * A batch's rows as they stand. A batch this server does not know was either finished long ago,
10855
+ * which an open panel never asks about because it stops asking once a batch finishes, or lost to a
10856
+ * restart, which is the case worth saying plainly: whatever had not started never will.
10857
+ */
10858
+ function batchRows(id) {
10859
+ const batch = batches.get(id);
10860
+ if (!batch) return refuse(404, "The workspace server no longer knows this batch, most likely because it restarted. Reviews that had started are in your list. Any that were still waiting did not start.");
10861
+ return {
10862
+ status: 200,
10863
+ body: {
10864
+ rows: rowsOf(batch),
10865
+ finished: batch.finishedAt !== null
10866
+ }
10867
+ };
10868
+ }
10869
+ /** A target refusal in the panel's words where the CLI's would name a command. */
10870
+ function targetRefusal(error) {
10871
+ if (error instanceof DetachedHeadError) return "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead.";
10872
+ return message(error);
10873
+ }
10874
+ /** The recent and discoverable folders, for the Branch panel's list. */
10875
+ async function listFolders(deps) {
10876
+ return {
10877
+ status: 200,
10878
+ body: { folders: (await deps.folders()).map((folder) => ({
10879
+ ...folderOption(folder, deps.home),
10880
+ repo: folder.remote ? folder.remote.repo : basename(folder.path)
10881
+ })) }
10882
+ };
10883
+ }
10884
+ /**
10885
+ * What reviewing this folder would review, read without changing anything: the same target a bare
10886
+ * `prreviewbuddy` here would resolve, and the review it would find.
10887
+ *
10888
+ * With the base the reviewer named, when they named one, so what this checks is the comparison
10889
+ * that would be submitted. Without it, a repository whose default base is ambiguous could never be
10890
+ * reviewed from here, though naming the base is exactly what settles it.
10891
+ */
10892
+ async function inspectFolder(rawPath, deps, rawBase) {
10893
+ const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
10894
+ if (!path) return {
10895
+ status: 200,
10896
+ body: {
10897
+ ok: false,
10898
+ error: "Enter the full path of a folder, or choose one."
10899
+ }
10900
+ };
10901
+ const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
10902
+ let target;
10903
+ try {
10904
+ target = await deps.resolveBranch(path, named);
10905
+ } catch (error) {
10906
+ return {
10907
+ status: 200,
10908
+ body: {
10909
+ ok: false,
10910
+ error: targetRefusal(error)
10911
+ }
10912
+ };
10913
+ }
10914
+ const [remote, base, existing] = await Promise.all([
10915
+ deps.remoteOf(target.originRepoPath),
10916
+ named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
10917
+ deps.findExisting(target)
10918
+ ]);
10919
+ const kept = existing ? deps.resumeOf(existing.id) : null;
10920
+ return {
10921
+ status: 200,
10922
+ body: {
10923
+ ok: true,
10924
+ path: target.originRepoPath,
10925
+ display: displayPath(target.originRepoPath, deps.home),
10926
+ repo: remote ? remote.repo : basename(target.originRepoPath),
10927
+ branch: target.branch,
10928
+ base,
10929
+ ...existing ? { reviewed: {
10930
+ reviewId: existing.id,
10931
+ inProgress: existing.inProgress,
10932
+ ...kept ? { kept } : {}
10933
+ } } : {}
10934
+ }
10935
+ };
10936
+ }
10937
+ /**
10938
+ * Review the branch checked out in a folder: `prreviewbuddy` run there.
10939
+ *
10940
+ * With a free analysis slot it answers once the review exists, and the analysis runs on in this
10941
+ * process, counted by `track` like a Retry. With none free it answers `waiting` and a one-row batch
10942
+ * to watch, and creates nothing until a slot frees: a review created to wait is a job nobody is
10943
+ * running, which the sweep would fail. A review the branch already has needs no slot to hand back.
10944
+ */
10945
+ async function startBranchReview(body, deps) {
10946
+ const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
10947
+ if (!path) return refuse(400, "Choose the working folder to review.");
10948
+ const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
10949
+ const model = modelOf(body?.model);
10950
+ if (isAnswer(model)) return model;
10951
+ let target;
10952
+ try {
10953
+ target = await deps.resolveBranch(path, base);
10954
+ } catch (error) {
10955
+ return refuse(400, targetRefusal(error));
10956
+ }
10957
+ const agent = await agentFor(body?.agent, deps);
10958
+ if (isAnswer(agent)) return agent;
10959
+ const row = {
10960
+ key: batchIdentity(target),
10961
+ label: target.branch,
10962
+ status: "waiting"
10963
+ };
10964
+ const release = deps.slots.tryAcquire();
10965
+ if (!release) {
10966
+ const existing = await deps.findExisting(target);
10967
+ if (existing && !deps.resumeOf(existing.id)) return {
10968
+ status: 200,
10969
+ body: {
10970
+ outcome: "existing",
10971
+ reviewId: existing.id
10972
+ }
10973
+ };
10974
+ const batch = newBatch([row]);
10975
+ deps.track((async () => {
10976
+ const later = await deps.slots.acquire();
10977
+ try {
10978
+ if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
10979
+ } catch (error) {
10980
+ row.status = "failed";
10981
+ row.reason = message(error);
10982
+ } finally {
10983
+ later();
10984
+ }
10985
+ })()).finally(() => {
10986
+ batch.finishedAt = Date.now();
10987
+ });
10988
+ return {
10989
+ status: 200,
10990
+ body: {
10991
+ outcome: "waiting",
10992
+ batchId: batch.id
10993
+ }
10994
+ };
10995
+ }
10996
+ let prepared;
10997
+ try {
10998
+ prepared = await prepareRow(row, target, agent.agentId, model, deps);
10999
+ } catch (error) {
11000
+ release();
11001
+ return refuse(409, message(error));
11002
+ }
11003
+ if (prepared.kind === "existing") release();
11004
+ else deps.track(deps.settle(row.reviewId)).catch(() => {}).finally(release);
11005
+ return {
11006
+ status: 200,
11007
+ body: {
11008
+ outcome: prepared.kind,
11009
+ reviewId: row.reviewId,
11010
+ ...row.kept ? { kept: row.kept } : {}
11011
+ }
11012
+ };
11013
+ }
11014
+ async function chooseFolder(deps) {
11015
+ if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
11016
+ try {
11017
+ const path = await deps.chooseFolder();
11018
+ return {
11019
+ status: 200,
11020
+ body: path ? { path } : { cancelled: true }
11021
+ };
11022
+ } catch (error) {
11023
+ return refuse(500, message(error));
11024
+ }
11025
+ }
11026
+ //#endregion
10375
11027
  //#region ../../packages/review-harness/src/workspace/ask.ts
10376
11028
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
10377
11029
  var ASK_TIMEOUT_MS = 18e4;
@@ -10412,7 +11064,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
10412
11064
  const env = agentEnvOf(workspace);
10413
11065
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10414
11066
  try {
10415
- return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
11067
+ return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor$1(env).run({
10416
11068
  capability: "read-code",
10417
11069
  prompt: buildPrompt$2(workspace, question, sectionId, cwd),
10418
11070
  cwd,
@@ -10448,7 +11100,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
10448
11100
  function explained(error, env) {
10449
11101
  if (!(error instanceof Error) || error instanceof AgentCancelledError) return error;
10450
11102
  try {
10451
- const plain = agentFor(env).explainFailure?.(error.message, env);
11103
+ const plain = agentFor$1(env).explainFailure?.(error.message, env);
10452
11104
  return plain ? new AskFailedError(plain) : error;
10453
11105
  } catch {
10454
11106
  return error;
@@ -10560,7 +11212,7 @@ async function drawDiagram(workspace, sectionId, signal) {
10560
11212
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10561
11213
  let raw;
10562
11214
  try {
10563
- raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
11215
+ raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor$1(env).run({
10564
11216
  capability: "read-code",
10565
11217
  prompt: buildPrompt$1(workspace, sectionId, cwd),
10566
11218
  cwd,
@@ -10747,7 +11399,7 @@ async function drawChangeMap(workspace, signal) {
10747
11399
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10748
11400
  let raw;
10749
11401
  try {
10750
- raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor(env).run({
11402
+ raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor$1(env).run({
10751
11403
  capability: "read-code",
10752
11404
  prompt: buildPrompt(workspace, cwd),
10753
11405
  cwd,
@@ -11445,6 +12097,7 @@ async function handle(req, res, options) {
11445
12097
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
11446
12098
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
11447
12099
  if (url.pathname === "/restart") return handleIndexRestart(req, res, options);
12100
+ if (url.pathname.startsWith("/add-review/")) return handleAddReview(req, res, url.pathname, options);
11448
12101
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
11449
12102
  if (!match) return send(res, 404, { error: "Not found" });
11450
12103
  const [, id, action] = match;
@@ -12002,6 +12655,43 @@ async function handleFetchUpdates(req, res, indexToken) {
12002
12655
  send(res, 200, await runFetchUpdates(new Set(ids)));
12003
12656
  }
12004
12657
  /**
12658
+ * Add review: start pull request and branch reviews from any workspace page.
12659
+ *
12660
+ * The index credential, as the cookie, because starting a review is the machine's business rather
12661
+ * than any one review's, the same authority as `/queue` and `/fetch-updates`. The cookie is set on
12662
+ * `Path=/`, so a review page carries it too. See `add_review.ts` for what each route does.
12663
+ */
12664
+ async function handleAddReview(req, res, pathname, options) {
12665
+ if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
12666
+ const deps = options.addReview ?? (addReviewDeps ??= realAddReviewDeps());
12667
+ const route = pathname.slice(12);
12668
+ const answer = async () => {
12669
+ if (req.method === "GET") {
12670
+ if (route === "options") return addReviewOptions(deps);
12671
+ if (route === "folders") return listFolders(deps);
12672
+ const batch = /^batches\/([0-9a-f-]{36})$/.exec(route);
12673
+ if (batch) return batchRows(batch[1]);
12674
+ return null;
12675
+ }
12676
+ if (req.method !== "POST") return {
12677
+ status: 405,
12678
+ body: { error: "Method not allowed." }
12679
+ };
12680
+ if (route === "folder/choose") return chooseFolder(deps);
12681
+ const body = await readJson(req);
12682
+ if (route === "pull-requests/preview") return previewPullRequests(body?.text, deps);
12683
+ if (route === "pull-requests") return startPullRequestBatch(body, deps);
12684
+ if (route === "folder/inspect") return inspectFolder(body?.path, deps, body?.base);
12685
+ if (route === "branch") return startBranchReview(body, deps);
12686
+ return null;
12687
+ };
12688
+ const result = await answer();
12689
+ if (!result) return send(res, 404, { error: "Not found." });
12690
+ send(res, result.status, result.body);
12691
+ }
12692
+ /** Built on first use, so a server nobody adds a review on never runs the detectors. */
12693
+ var addReviewDeps;
12694
+ /**
12005
12695
  * Fetch remote updates, from one review's own page, on its own token.
12006
12696
  *
12007
12697
  * The list's fetch scoped to this review: `GET` says whether it applies (the page draws the item