prreviewbuddy 0.63.0 → 0.69.1
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/CHANGELOG.md +152 -0
- package/dist/{delete_review-CeJo9t7h.js → delete_review-CRPRIRKg.js} +7115 -6620
- package/dist/main.js +2 -328
- package/dist/server.js +1010 -137
- package/package.json +1 -1
- package/static/chunk-2P5KTb9d.js +1 -0
- package/static/chunk-C8kkSv38.js +1 -0
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +265 -22
- package/static/sidebar.css +2 -0
- package/static/workspace.js +83 -77
- package/static/chunk-BadSI8AL.js +0 -1
- package/static/chunk-CxNfDBSA.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { $ as
|
|
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 {
|
|
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
|
|
@@ -322,11 +324,35 @@ function countLabel(reviews, branches) {
|
|
|
322
324
|
return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
|
|
323
325
|
}
|
|
324
326
|
/**
|
|
325
|
-
* Every login the author dropdown offers, once each, in alphabetical order
|
|
326
|
-
*
|
|
327
|
+
* Every login the author dropdown offers, once each, in alphabetical order of what the reader sees:
|
|
328
|
+
* the name where there is one, the login otherwise, regardless of case. Read from the cards the page
|
|
329
|
+
* is showing, so it never lists somebody with nothing to show.
|
|
330
|
+
*
|
|
331
|
+
* A login's name is the one on its newest review that has a name, chosen here by `createdAt` rather
|
|
332
|
+
* than by the order the caller happens to pass, so an account renamed upstream reads as it does now.
|
|
327
333
|
*/
|
|
328
|
-
function authorOptions(
|
|
329
|
-
|
|
334
|
+
function authorOptions(cards) {
|
|
335
|
+
const newest = /* @__PURE__ */ new Map();
|
|
336
|
+
for (const card of cards) {
|
|
337
|
+
if (!card.handle) continue;
|
|
338
|
+
const seen = newest.get(card.handle);
|
|
339
|
+
if (!seen) newest.set(card.handle, {
|
|
340
|
+
name: card.name || void 0,
|
|
341
|
+
createdAt: card.createdAt
|
|
342
|
+
});
|
|
343
|
+
else if (card.name && (!seen.name || card.createdAt > seen.createdAt)) newest.set(card.handle, {
|
|
344
|
+
name: card.name,
|
|
345
|
+
createdAt: card.createdAt
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return [...newest].map(([handle, { name }]) => name && name !== handle ? {
|
|
349
|
+
handle,
|
|
350
|
+
name
|
|
351
|
+
} : { handle }).sort((a, b) => (a.name ?? a.handle).localeCompare(b.name ?? b.handle, void 0, { sensitivity: "base" }));
|
|
352
|
+
}
|
|
353
|
+
/** An author option as the dropdown spells it: the name and the login, or the login alone. */
|
|
354
|
+
function authorOptionLabel(option) {
|
|
355
|
+
return option.name ? `${option.name} · @${option.handle}` : `@${option.handle}`;
|
|
330
356
|
}
|
|
331
357
|
/**
|
|
332
358
|
* A card menu's status item, which sets the state it names. The bulk bar's words for the same two
|
|
@@ -334,7 +360,7 @@ function authorOptions(handles) {
|
|
|
334
360
|
* the browser relabels the item after a change, and that renderer reaches the store.
|
|
335
361
|
*/
|
|
336
362
|
function markItemLabel(mark) {
|
|
337
|
-
return mark === "complete" ? "Mark reviewed" : "Mark
|
|
363
|
+
return mark === "complete" ? "Mark reviewed" : "Mark not reviewed";
|
|
338
364
|
}
|
|
339
365
|
/**
|
|
340
366
|
* The review page's Pin item, which says what pressing it will do. The words of the index's star,
|
|
@@ -371,7 +397,7 @@ function queueOptionLabel(queued) {
|
|
|
371
397
|
*/
|
|
372
398
|
function statusLabel(status) {
|
|
373
399
|
if (status === "complete") return "Reviewed";
|
|
374
|
-
return status === "in-progress" ? "
|
|
400
|
+
return status === "in-progress" ? "Not reviewed" : "New";
|
|
375
401
|
}
|
|
376
402
|
/**
|
|
377
403
|
* The bulk bar's way to tick everything the filters show, across pages: "Select all 7".
|
|
@@ -422,6 +448,206 @@ function anythingRunning(states) {
|
|
|
422
448
|
var FETCH_REVIEW_LABEL = "Fetch remote updates";
|
|
423
449
|
var FETCH_SELECTED_LABEL = "Fetch selected";
|
|
424
450
|
//#endregion
|
|
451
|
+
//#region ../../packages/review-harness/src/domain/ui/components/message_copy.ts
|
|
452
|
+
var MESSAGE_COPY_CLASS = "pr-helper-msg-copy";
|
|
453
|
+
var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
|
|
454
|
+
/** The Markdown itself, for surfaces that hold no message state to resolve an id against. */
|
|
455
|
+
var MESSAGE_SOURCE_ATTR = "data-copy-source";
|
|
456
|
+
var STATES$1 = {
|
|
457
|
+
idle: {
|
|
458
|
+
icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
|
|
459
|
+
label: "",
|
|
460
|
+
aria: "Copy message"
|
|
461
|
+
},
|
|
462
|
+
copied: {
|
|
463
|
+
icon: "<path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>",
|
|
464
|
+
label: "Copied",
|
|
465
|
+
aria: "Copied"
|
|
466
|
+
},
|
|
467
|
+
failed: {
|
|
468
|
+
icon: "<path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>",
|
|
469
|
+
label: "Copy failed",
|
|
470
|
+
aria: "Copy failed"
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The same row, carrying the Markdown itself.
|
|
475
|
+
*
|
|
476
|
+
* For a surface with nothing to resolve an id against — the local workspace renders its stored
|
|
477
|
+
* transcript to HTML server-side and appends live answers straight to the DOM, so there is no
|
|
478
|
+
* conversation object on the client at all. Duplicating the source into the attribute is the
|
|
479
|
+
* honest trade there, and it is the same shape the prompt bank already uses to ship its data.
|
|
480
|
+
*/
|
|
481
|
+
function renderMessageCopySource(source) {
|
|
482
|
+
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
|
|
483
|
+
}
|
|
484
|
+
/** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
|
|
485
|
+
var FINDING_COPY_CLASS = "pr-helper-finding-copy";
|
|
486
|
+
/**
|
|
487
|
+
* The same control on an issue or question card, carrying that finding as Markdown.
|
|
488
|
+
*
|
|
489
|
+
* It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
|
|
490
|
+
* the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
|
|
491
|
+
* answered, which change the review: those keep the footer to themselves, so the footer holds
|
|
492
|
+
* only actions that change something.
|
|
493
|
+
*
|
|
494
|
+
* Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
|
|
495
|
+
* buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
|
|
496
|
+
* would give a utility the weight of the title beside it. What it does is said by a hover
|
|
497
|
+
* tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
|
|
498
|
+
* accessible name. After a click it shows a green check and says "Copied" (or "Copy failed") in
|
|
499
|
+
* the bubble below it, the words used everywhere else, but not inline: beside it is Ask Buddy. The
|
|
500
|
+
* resting name and tip are kept on the button so the reset returns to "Copy finding" rather than to
|
|
501
|
+
* a chat reply's "Copy message".
|
|
502
|
+
*/
|
|
503
|
+
function renderFindingCopySource(source, kind) {
|
|
504
|
+
const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
|
|
505
|
+
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
|
|
506
|
+
label: "",
|
|
507
|
+
aria: name,
|
|
508
|
+
tip: name
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The same control with its word showing, for a footer of worded actions.
|
|
513
|
+
*
|
|
514
|
+
* A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
|
|
515
|
+
* would read as decoration. `extra` is for attributes the caller's own client needs, such as the
|
|
516
|
+
* handle Copy recommendations is found by; the source may be updated after render, since the
|
|
517
|
+
* listener reads it at the moment of the click.
|
|
518
|
+
*/
|
|
519
|
+
function renderLabelledCopySource(source, label, extra = "") {
|
|
520
|
+
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
|
|
521
|
+
label,
|
|
522
|
+
aria: label,
|
|
523
|
+
tip: label
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
|
|
527
|
+
var IDLE_LABEL_ATTR = "data-copy-idle-label";
|
|
528
|
+
var IDLE_ARIA_ATTR = "data-copy-idle-aria";
|
|
529
|
+
var IDLE_TIP_ATTR = "data-copy-idle-tip";
|
|
530
|
+
function button(handle, idle) {
|
|
531
|
+
const { icon } = STATES$1.idle;
|
|
532
|
+
const label = idle?.label ?? STATES$1.idle.label;
|
|
533
|
+
const aria = idle?.aria ?? STATES$1.idle.aria;
|
|
534
|
+
const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}" ${IDLE_TIP_ATTR}="${escapeHtml$1(idle.tip)}"` : "";
|
|
535
|
+
const control = `<button type="button" class="${idle ? `${MESSAGE_COPY_CLASS} ${FINDING_COPY_CLASS}` : MESSAGE_COPY_CLASS}" ${handle}${remembered}${idle ? ` data-tip="${escapeHtml$1(idle.tip)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
|
|
536
|
+
return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
|
|
537
|
+
}
|
|
538
|
+
function svg(paths) {
|
|
539
|
+
return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region ../../packages/review-harness/src/workspace/feedback_view.ts
|
|
543
|
+
/**
|
|
544
|
+
* Feedback selection: the reviewer picks the Issues and Questions they mean to raise with the
|
|
545
|
+
* author, across both panes, and takes them away as one Markdown draft.
|
|
546
|
+
*
|
|
547
|
+
* The words and the composition, in one module the server renders from and the client imports.
|
|
548
|
+
* The same split the inbox's bulk bar settled (`reviews_index.ts`): every sentence here carries a
|
|
549
|
+
* number only the browser knows, so the whole sentence lives where both sides can reach it, and
|
|
550
|
+
* where the copy style and vocabulary scans already look. Nothing here may import a node builtin.
|
|
551
|
+
*
|
|
552
|
+
* Each item is the text the card's own Copy button carries (`finding_copy.ts`), read from the card.
|
|
553
|
+
* There is one rendering of a finding as text, so what this exports, what Copy gives and what Ask
|
|
554
|
+
* Buddy is asked about can never disagree. That is also why no severity, theme or status appears:
|
|
555
|
+
* those sort the findings for the reviewer and are not part of what the author is told.
|
|
556
|
+
*/
|
|
557
|
+
function count(n, noun) {
|
|
558
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The tray's count line, in its three parts: "5 chosen", "2 issues", "3 questions".
|
|
562
|
+
*
|
|
563
|
+
* Chosen rather than selected: the reviewer is choosing what to tell the author, not operating a
|
|
564
|
+
* list.
|
|
565
|
+
* "Nothing chosen" rather than "0 chosen", the inbox bar's reason: the mode opens in this
|
|
566
|
+
* state, and a zero reads as a count of something that happened. A side with nothing picked is
|
|
567
|
+
* empty, and left out, rather than said as a zero beside the side that has some. The tray draws
|
|
568
|
+
* the two sides as buttons back to their panes; everywhere else reads the joined sentence.
|
|
569
|
+
*/
|
|
570
|
+
function feedbackSelectionParts(issues, questions) {
|
|
571
|
+
const total = issues + questions;
|
|
572
|
+
return {
|
|
573
|
+
total: total === 0 ? "Nothing chosen" : `${total} chosen`,
|
|
574
|
+
issues: issues ? count(issues, "issue") : "",
|
|
575
|
+
questions: questions ? count(questions, "question") : ""
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/** "5 chosen · 2 issues · 3 questions", "1 chosen · 1 question", "Nothing chosen". */
|
|
579
|
+
function feedbackSelectionLabel(issues, questions) {
|
|
580
|
+
const parts = feedbackSelectionParts(issues, questions);
|
|
581
|
+
return [
|
|
582
|
+
parts.total,
|
|
583
|
+
parts.issues,
|
|
584
|
+
parts.questions
|
|
585
|
+
].filter(Boolean).join(" · ");
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The pane's way into the mode, and out of it. Named for what it is for rather than for the
|
|
589
|
+
* mechanism ("Select"). It shares its name with the tray's last step on purpose, and the two are
|
|
590
|
+
* never on screen together: pressed, this one reads Done with a tick, the same act as the tray's
|
|
591
|
+
* Done, and that is when the tray appears. A checklist, not a sparkle, because nothing here is AI.
|
|
592
|
+
* Both faces are in the markup and the stylesheet shows the one `aria-pressed` calls for, so the
|
|
593
|
+
* hidden one is not part of the name a screen reader hears.
|
|
594
|
+
*/
|
|
595
|
+
var PREPARE_LABEL = "Prepare feedback";
|
|
596
|
+
var DONE_LABEL = "Done";
|
|
597
|
+
var ICON = (paths) => `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
598
|
+
var LIST_CHECK = ICON("<path d=\"m3 17 2 2 4-4M3 7l2 2 4-4M13 6h8M13 12h8M13 18h8\"/>");
|
|
599
|
+
var CHECK = ICON("<path d=\"M20 6 9 17l-5-5\"/>");
|
|
600
|
+
function renderFeedbackSelectButton() {
|
|
601
|
+
return `<button type="button" class="prb-selection-toggle" data-feedback-select aria-pressed="false"><span class="prb-selection-toggle-idle">${LIST_CHECK}${PREPARE_LABEL}</span><span class="prb-selection-toggle-active">${CHECK}${DONE_LABEL}</span></button>`;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The tray, at the foot of the document pane on every view while the mode is on. The mode belongs
|
|
605
|
+
* to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
|
|
606
|
+
* counts are the way back to each pane.
|
|
607
|
+
*/
|
|
608
|
+
function renderFeedbackTray() {
|
|
609
|
+
return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
|
|
610
|
+
<p class="prb-selection-count" aria-live="polite">
|
|
611
|
+
<span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
|
|
612
|
+
</p>
|
|
613
|
+
<span class="prb-selection-spacer"></span>
|
|
614
|
+
<button type="button" class="prb-selection-button" data-feedback-clear>Clear</button>
|
|
615
|
+
<button type="button" class="prb-selection-button" data-feedback-done>${DONE_LABEL}</button>
|
|
616
|
+
<button type="button" class="prb-selection-button prb-selection-primary" data-feedback-prepare disabled>${PREPARE_LABEL}</button>
|
|
617
|
+
</div>`;
|
|
618
|
+
}
|
|
619
|
+
/** A side's count, which is also the way back to its pane. The dot is text, so the line reads whole. */
|
|
620
|
+
function renderGoto(pane) {
|
|
621
|
+
return `<button type="button" class="prb-selection-goto" data-feedback-goto="${pane}" hidden><span class="prb-selection-sep" aria-hidden="true"> · </span><span data-feedback-goto-label></span></button>`;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Where the draft is read, edited and copied.
|
|
625
|
+
*
|
|
626
|
+
* Two views of one draft, as tabs, because that is what they are: Preview, the default, for reading
|
|
627
|
+
* what the author will see, and Markdown for changing it. Both are drawn from the same text, the
|
|
628
|
+
* text area's, so an edit is in the preview the moment the tab changes, and Copy takes exactly what
|
|
629
|
+
* is in the box: the client rewrites the button's source on every keystroke.
|
|
630
|
+
*/
|
|
631
|
+
function renderFeedbackDialog() {
|
|
632
|
+
const tab = (name, label, selected) => `<button type="button" role="tab" class="prb-draft-tab" id="prb-draft-tab-${name}" data-draft-tab="${name}" aria-controls="prb-draft-${name}" aria-selected="${selected}" tabindex="${selected ? 0 : -1}">${label}</button>`;
|
|
633
|
+
return `<dialog class="prb-dialog prb-draft-dialog" data-feedback-dialog aria-labelledby="prb-feedback-title">
|
|
634
|
+
<div class="prb-draft-head">
|
|
635
|
+
<h2 id="prb-feedback-title">Prepare feedback</h2>
|
|
636
|
+
<p class="prb-draft-count" data-feedback-dialog-count></p>
|
|
637
|
+
</div>
|
|
638
|
+
<div class="prb-draft-tabs" role="tablist" aria-label="Draft">${tab("preview", "Preview", true)}${tab("markdown", "Markdown", false)}</div>
|
|
639
|
+
<div class="prb-draft-panel prb-draft-preview prb-md" id="prb-draft-preview" role="tabpanel" aria-labelledby="prb-draft-tab-preview" tabindex="0" data-feedback-preview></div>
|
|
640
|
+
<div class="prb-draft-panel" id="prb-draft-markdown" role="tabpanel" aria-labelledby="prb-draft-tab-markdown" hidden>
|
|
641
|
+
<textarea class="prb-draft-text" data-feedback-text aria-label="Markdown for the author" spellcheck="true" autocomplete="off"></textarea>
|
|
642
|
+
</div>
|
|
643
|
+
<div class="prb-dialog-actions">
|
|
644
|
+
<span class="prb-dialog-spacer"></span>
|
|
645
|
+
<button type="button" data-feedback-close>Close</button>
|
|
646
|
+
${renderLabelledCopySource("", "Copy Markdown", "data-feedback-copy")}
|
|
647
|
+
</div>
|
|
648
|
+
</dialog>`;
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
425
651
|
//#region ../../packages/review-harness/src/workspace/way_back.ts
|
|
426
652
|
var CLI = {
|
|
427
653
|
command: "prreviewbuddy open",
|
|
@@ -842,97 +1068,6 @@ function deriveReviewMetrics(result) {
|
|
|
842
1068
|
};
|
|
843
1069
|
}
|
|
844
1070
|
//#endregion
|
|
845
|
-
//#region ../../packages/review-harness/src/domain/ui/components/message_copy.ts
|
|
846
|
-
var MESSAGE_COPY_CLASS = "pr-helper-msg-copy";
|
|
847
|
-
var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
|
|
848
|
-
/** The Markdown itself, for surfaces that hold no message state to resolve an id against. */
|
|
849
|
-
var MESSAGE_SOURCE_ATTR = "data-copy-source";
|
|
850
|
-
var STATES$1 = {
|
|
851
|
-
idle: {
|
|
852
|
-
icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
|
|
853
|
-
label: "",
|
|
854
|
-
aria: "Copy message"
|
|
855
|
-
},
|
|
856
|
-
copied: {
|
|
857
|
-
icon: "<path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>",
|
|
858
|
-
label: "Copied",
|
|
859
|
-
aria: "Copied"
|
|
860
|
-
},
|
|
861
|
-
failed: {
|
|
862
|
-
icon: "<path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>",
|
|
863
|
-
label: "Copy failed",
|
|
864
|
-
aria: "Copy failed"
|
|
865
|
-
}
|
|
866
|
-
};
|
|
867
|
-
/**
|
|
868
|
-
* The same row, carrying the Markdown itself.
|
|
869
|
-
*
|
|
870
|
-
* For a surface with nothing to resolve an id against — the local workspace renders its stored
|
|
871
|
-
* transcript to HTML server-side and appends live answers straight to the DOM, so there is no
|
|
872
|
-
* conversation object on the client at all. Duplicating the source into the attribute is the
|
|
873
|
-
* honest trade there, and it is the same shape the prompt bank already uses to ship its data.
|
|
874
|
-
*/
|
|
875
|
-
function renderMessageCopySource(source) {
|
|
876
|
-
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
|
|
877
|
-
}
|
|
878
|
-
/** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
|
|
879
|
-
var FINDING_COPY_CLASS = "pr-helper-finding-copy";
|
|
880
|
-
/**
|
|
881
|
-
* The same control on an issue or question card, carrying that finding as Markdown.
|
|
882
|
-
*
|
|
883
|
-
* It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
|
|
884
|
-
* the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
|
|
885
|
-
* answered, which change the review: those keep the footer to themselves, so the footer holds
|
|
886
|
-
* only actions that change something.
|
|
887
|
-
*
|
|
888
|
-
* Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
|
|
889
|
-
* buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
|
|
890
|
-
* would give a utility the weight of the title beside it. What it does is said by a hover
|
|
891
|
-
* tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
|
|
892
|
-
* accessible name. After a click it shows a green check and says "Copied" (or "Copy failed") in
|
|
893
|
-
* the bubble below it, the words used everywhere else, but not inline: beside it is Ask Buddy. The
|
|
894
|
-
* resting name and tip are kept on the button so the reset returns to "Copy finding" rather than to
|
|
895
|
-
* a chat reply's "Copy message".
|
|
896
|
-
*/
|
|
897
|
-
function renderFindingCopySource(source, kind) {
|
|
898
|
-
const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
|
|
899
|
-
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
|
|
900
|
-
label: "",
|
|
901
|
-
aria: name,
|
|
902
|
-
tip: name
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
/**
|
|
906
|
-
* The same control with its word showing, for a footer of worded actions.
|
|
907
|
-
*
|
|
908
|
-
* A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
|
|
909
|
-
* would read as decoration. `extra` is for attributes the caller's own client needs, such as the
|
|
910
|
-
* handle Copy recommendations is found by; the source may be updated after render, since the
|
|
911
|
-
* listener reads it at the moment of the click.
|
|
912
|
-
*/
|
|
913
|
-
function renderLabelledCopySource(source, label, extra = "") {
|
|
914
|
-
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
|
|
915
|
-
label,
|
|
916
|
-
aria: label,
|
|
917
|
-
tip: label
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
/** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
|
|
921
|
-
var IDLE_LABEL_ATTR = "data-copy-idle-label";
|
|
922
|
-
var IDLE_ARIA_ATTR = "data-copy-idle-aria";
|
|
923
|
-
var IDLE_TIP_ATTR = "data-copy-idle-tip";
|
|
924
|
-
function button(handle, idle) {
|
|
925
|
-
const { icon } = STATES$1.idle;
|
|
926
|
-
const label = idle?.label ?? STATES$1.idle.label;
|
|
927
|
-
const aria = idle?.aria ?? STATES$1.idle.aria;
|
|
928
|
-
const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}" ${IDLE_TIP_ATTR}="${escapeHtml$1(idle.tip)}"` : "";
|
|
929
|
-
const control = `<button type="button" class="${idle ? `${MESSAGE_COPY_CLASS} ${FINDING_COPY_CLASS}` : MESSAGE_COPY_CLASS}" ${handle}${remembered}${idle ? ` data-tip="${escapeHtml$1(idle.tip)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
|
|
930
|
-
return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
|
|
931
|
-
}
|
|
932
|
-
function svg(paths) {
|
|
933
|
-
return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
|
|
934
|
-
}
|
|
935
|
-
//#endregion
|
|
936
1071
|
//#region ../../packages/review-harness/src/domain/ui/components/ask_buddy_menu.ts
|
|
937
1072
|
/**
|
|
938
1073
|
* Ask Buddy on a finding card: a sparkle beside Copy that opens the assistant's actions for the
|
|
@@ -2397,6 +2532,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
2397
2532
|
<div class="pr-helper-issue-card pr-helper-severity-${issue.severity}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
|
|
2398
2533
|
<div class="pr-helper-issue-header">
|
|
2399
2534
|
<div class="pr-helper-issue-title-row">
|
|
2535
|
+
${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title) : ""}
|
|
2400
2536
|
<p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
|
|
2401
2537
|
${renderFindingCopySource(issueMarkdown(issue), "issue")}
|
|
2402
2538
|
${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
|
|
@@ -2499,6 +2635,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
|
|
|
2499
2635
|
<div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${questionHome(q) ? ` data-section-id="${escapeHtml$1(questionHome(q))}"` : ""}>
|
|
2500
2636
|
<div class="pr-helper-issue-header">
|
|
2501
2637
|
<div class="pr-helper-issue-title-row">
|
|
2638
|
+
${options?.feedbackSelect ? renderFeedbackPick("question", q.question) : ""}
|
|
2502
2639
|
<p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
|
|
2503
2640
|
${renderFindingCopySource(questionMarkdown(q), "question")}
|
|
2504
2641
|
${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
|
|
@@ -2550,6 +2687,17 @@ function renderQuestionsProgress(questions, answered) {
|
|
|
2550
2687
|
if (counts.total < 2) return "";
|
|
2551
2688
|
return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
|
|
2552
2689
|
}
|
|
2690
|
+
/**
|
|
2691
|
+
* The card's feedback checkbox. Named by the finding, so a list of them is not one word announced
|
|
2692
|
+
* fifteen times. The id is the card's own `data-finding-card`.
|
|
2693
|
+
*
|
|
2694
|
+
* `autocomplete="off"` because the browser otherwise restores a box's tick when the page is loaded
|
|
2695
|
+
* again from history, and the selection it belonged to went with the page that made it: Back to a
|
|
2696
|
+
* review showed a ticked box that nothing had selected.
|
|
2697
|
+
*/
|
|
2698
|
+
function renderFeedbackPick(kind, title) {
|
|
2699
|
+
return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
|
|
2700
|
+
}
|
|
2553
2701
|
function renderQuestionJump(file, options) {
|
|
2554
2702
|
if (!file || options?.suppressFileJump) return "";
|
|
2555
2703
|
return `<button type="button" class="pr-helper-question-jump" data-file="${escapeHtml$1(file)}">Jump to file →</button>`;
|
|
@@ -5441,6 +5589,7 @@ ${updating(view) ? "" : renderUpdateOutcome(view.lastUpdate)}
|
|
|
5441
5589
|
${updating(view) ? "" : renderLineageNotice(view)}
|
|
5442
5590
|
${renderReviewBody(view, sections)}
|
|
5443
5591
|
${view.token ? renderPromptDialog() : ""}
|
|
5592
|
+
${offersFeedback(view) ? renderFeedbackDialog() : ""}
|
|
5444
5593
|
<script type="module" src="/assets/workspace.js"><\/script>
|
|
5445
5594
|
</body>
|
|
5446
5595
|
</html>`;
|
|
@@ -5498,12 +5647,22 @@ function renderReviewBody(view, sections) {
|
|
|
5498
5647
|
${renderRail(view, sections, updating(view))}
|
|
5499
5648
|
<main class="prb-doc">
|
|
5500
5649
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
5650
|
+
${offersFeedback(view) ? renderFeedbackTray() : ""}
|
|
5501
5651
|
</main>
|
|
5502
5652
|
${result ? renderAsk(view, sections, updating(view)) : ""}
|
|
5503
5653
|
${result ? renderAskTab() : ""}
|
|
5504
5654
|
</div>`;
|
|
5505
5655
|
}
|
|
5506
5656
|
/**
|
|
5657
|
+
* Whether the reviewer can choose findings to raise with the author: a standard result with at least
|
|
5658
|
+
* one issue or question, and no update running over it. Select, the tray and the dialog are drawn
|
|
5659
|
+
* together or not at all.
|
|
5660
|
+
*/
|
|
5661
|
+
function offersFeedback(view) {
|
|
5662
|
+
const result = view.session.result;
|
|
5663
|
+
return Boolean(result && (result.issues.length > 0 || result.questions.length > 0)) && !updating(view);
|
|
5664
|
+
}
|
|
5665
|
+
/**
|
|
5507
5666
|
* Buddy AI's own way in: a tab on the window's right edge, where its pane opens.
|
|
5508
5667
|
*
|
|
5509
5668
|
* Out of the header, which says where the review stands; the assistant is a tool beside the review,
|
|
@@ -5658,15 +5817,19 @@ function renderStandardViews(view, sections) {
|
|
|
5658
5817
|
${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
|
|
5659
5818
|
</section>
|
|
5660
5819
|
<section class="prb-view" data-view="issues" hidden>
|
|
5661
|
-
${renderDocHead("Issues", renderFocusToggle("issues", "Issues"))}
|
|
5820
|
+
${renderDocHead("Issues", `${(result?.issues.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("issues", "Issues")}`)}
|
|
5662
5821
|
${renderAttributionAge(view)}
|
|
5663
|
-
${renderIssuesTab(session, null, {
|
|
5822
|
+
${renderIssuesTab(session, null, {
|
|
5823
|
+
askBuddy: true,
|
|
5824
|
+
feedbackSelect: true
|
|
5825
|
+
})}
|
|
5664
5826
|
</section>
|
|
5665
5827
|
<section class="prb-view" data-view="questions" hidden>
|
|
5666
|
-
${renderDocHead("Questions", renderFocusToggle("questions", "Questions"))}
|
|
5828
|
+
${renderDocHead("Questions", `${(result?.questions.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("questions", "Questions")}`)}
|
|
5667
5829
|
${renderQuestionsTab(session, null, {
|
|
5668
5830
|
suppressFileJump: true,
|
|
5669
|
-
askBuddy: true
|
|
5831
|
+
askBuddy: true,
|
|
5832
|
+
feedbackSelect: true
|
|
5670
5833
|
})}
|
|
5671
5834
|
</section>
|
|
5672
5835
|
<section class="prb-view" data-view="prcontext" hidden>
|
|
@@ -5763,7 +5926,7 @@ function renderLineageNotice(view) {
|
|
|
5763
5926
|
* Two facts per row and no more. The commit is the one that does the real work, because two
|
|
5764
5927
|
* analyses an hour apart are told apart by what they were of rather than by when they happened;
|
|
5765
5928
|
* the risk says whether the newer one found something worse, which is the reason to go and read
|
|
5766
|
-
* it. There is deliberately no `New` / `
|
|
5929
|
+
* it. There is deliberately no `New` / `Not reviewed` / `Reviewed` here: those describe where
|
|
5767
5930
|
* somebody is in a piece of work, and on a superseded review that is a claim about work nobody
|
|
5768
5931
|
* should be continuing. The index's history rows withhold it for the same reason.
|
|
5769
5932
|
*/
|
|
@@ -5894,7 +6057,7 @@ function renderReviewBar(view, freshness) {
|
|
|
5894
6057
|
/**
|
|
5895
6058
|
* Where this review stands, as pills that say and never do.
|
|
5896
6059
|
*
|
|
5897
|
-
* Two, because they answer two questions. Reviewed or
|
|
6060
|
+
* Two, because they answer two questions. Reviewed or Not reviewed is what the reviewer has done;
|
|
5898
6061
|
* Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
|
|
5899
6062
|
* single pill would have to drop one of them. Active draws nothing, as an open pull request does
|
|
5900
6063
|
* not: it is where every review starts, and a pill that never varies teaches nothing.
|
|
@@ -6047,8 +6210,8 @@ function renderRail(view, sections, stale = false) {
|
|
|
6047
6210
|
${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
6048
6211
|
</div>
|
|
6049
6212
|
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
|
|
6050
|
-
${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
|
|
6051
|
-
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
|
|
6213
|
+
${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
|
|
6214
|
+
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
|
|
6052
6215
|
${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
|
|
6053
6216
|
${view.prContext ? railLink("prcontext", "prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "prcontext", "PR context", null, "No pull request was connected to this review")}`
|
|
6054
6217
|
}) : "";
|
|
@@ -6255,10 +6418,15 @@ function renderBadge(badge) {
|
|
|
6255
6418
|
return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
|
|
6256
6419
|
}
|
|
6257
6420
|
/** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
|
|
6258
|
-
|
|
6421
|
+
/**
|
|
6422
|
+
* `selectable` leaves room for the feedback selection's quiet "2 selected" under the title, empty and
|
|
6423
|
+
* hidden until the client has something to say there.
|
|
6424
|
+
*/
|
|
6425
|
+
function railLink(view, icon, label, badge, note, alsoCurrentOn, selectable = false) {
|
|
6426
|
+
const selected = selectable ? `<span class="prb-rail-selected" data-rail-selected="${view}" hidden></span>` : "";
|
|
6259
6427
|
return `<a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(hashForView(view))}" data-view-nav="${view}"${alsoCurrentOn ? ` data-view-also="${alsoCurrentOn}"` : ""}${note ? ` title="${escapeHtml$1(note)}"` : ""}>
|
|
6260
6428
|
${railIcon(icon)}
|
|
6261
|
-
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span
|
|
6429
|
+
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span>${selected}</span>
|
|
6262
6430
|
${renderBadge(badge)}
|
|
6263
6431
|
</a>`;
|
|
6264
6432
|
}
|
|
@@ -7075,7 +7243,7 @@ function startArchitectureRun(workspace, scope, deps = {}) {
|
|
|
7075
7243
|
const now = deps.now ?? Date.now;
|
|
7076
7244
|
const startedAt = now();
|
|
7077
7245
|
const env = agentEnvOf(workspace);
|
|
7078
|
-
const agent = agentFor(env);
|
|
7246
|
+
const agent = agentFor$1(env);
|
|
7079
7247
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
7080
7248
|
const record = {
|
|
7081
7249
|
version: 1,
|
|
@@ -7427,7 +7595,7 @@ async function fetchHead(cwd, request, env = {}) {
|
|
|
7427
7595
|
* What each pull request's forge last said about whether it is still open.
|
|
7428
7596
|
*
|
|
7429
7597
|
* **A fact about the request, never about the review.** A review can be `Reviewed` while its PR is
|
|
7430
|
-
* open, and `
|
|
7598
|
+
* open, and `Not reviewed` while its PR has merged; the reviewer's decision lives on the workspace
|
|
7431
7599
|
* record as `completedAt`, and nothing here reads or writes it.
|
|
7432
7600
|
*
|
|
7433
7601
|
* **Its own file, not a field of `watch.json`**, because the two have different lives. `watch.json`
|
|
@@ -8775,7 +8943,11 @@ ${renderGlobalBar({
|
|
|
8775
8943
|
unread: view.unreadNotifications ?? 0,
|
|
8776
8944
|
driving: view.driving
|
|
8777
8945
|
})}
|
|
8778
|
-
${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((
|
|
8946
|
+
${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map(({ latest }) => ({
|
|
8947
|
+
handle: latest.authorHandle,
|
|
8948
|
+
name: latest.authorName,
|
|
8949
|
+
createdAt: latest.createdAt
|
|
8950
|
+
}))), ordered.some((group) => group.latest.prNumber !== void 0)))}
|
|
8779
8951
|
<div class="prb-shell">
|
|
8780
8952
|
${renderWorkspaceRail({
|
|
8781
8953
|
current: "reviews",
|
|
@@ -8801,19 +8973,19 @@ ${renderWorkspaceRail({
|
|
|
8801
8973
|
*
|
|
8802
8974
|
* Search covers everything a person might remember about a review — the repository, the branch,
|
|
8803
8975
|
* the title, the model's summary, and the paths it touched — because which of those they recall
|
|
8804
|
-
* is not predictable. The
|
|
8805
|
-
* would be four more controls for a question ("show me only the risky ones") that
|
|
8806
|
-
* recency already answers badly and that scanning answers well.
|
|
8976
|
+
* is not predictable. The review state filter is the reviewer's own say and nothing else; a filter
|
|
8977
|
+
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
8978
|
+
* sorting by recency already answers badly and that scanning answers well.
|
|
8807
8979
|
*/
|
|
8808
8980
|
function renderControls(queued, authors, anyRequest) {
|
|
8809
8981
|
return `<div class="prb-index-controls">
|
|
8810
8982
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
8811
8983
|
${renderAuthorFilter(authors)}
|
|
8812
|
-
<label class="prb-index-status">
|
|
8813
|
-
<select data-status-filter aria-label="Filter by
|
|
8984
|
+
<label class="prb-index-status">Review state:
|
|
8985
|
+
<select data-status-filter aria-label="Filter by review state">
|
|
8814
8986
|
<option value="all">All</option>
|
|
8815
8987
|
<option value="queue" data-queue-option>${escapeHtml$1(queueOptionLabel(queued))}</option>
|
|
8816
|
-
<option value="in-progress">
|
|
8988
|
+
<option value="in-progress">Not reviewed</option>
|
|
8817
8989
|
<option value="complete">Reviewed</option>
|
|
8818
8990
|
</select>
|
|
8819
8991
|
</label>
|
|
@@ -8821,13 +8993,13 @@ function renderControls(queued, authors, anyRequest) {
|
|
|
8821
8993
|
</div>`;
|
|
8822
8994
|
}
|
|
8823
8995
|
/**
|
|
8824
|
-
* The pull request's state upstream, beside
|
|
8996
|
+
* The pull request's state upstream, beside Review state and not inside it, so the two combine.
|
|
8825
8997
|
*
|
|
8826
8998
|
* Absent when no card is of a pull request, for the author filter's reason: a select whose every
|
|
8827
8999
|
* choice but Any shows nothing is not a choice.
|
|
8828
9000
|
*/
|
|
8829
9001
|
function renderPrFilter() {
|
|
8830
|
-
return `<label class="prb-index-status"
|
|
9002
|
+
return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}PR:
|
|
8831
9003
|
<select data-pr-filter aria-label="Filter by pull request state">${PR_FILTER_OPTIONS.map((option) => `<option value="${option.value}">${escapeHtml$1(option.label)}</option>`).join("")}</select>
|
|
8832
9004
|
</label>`;
|
|
8833
9005
|
}
|
|
@@ -8837,16 +9009,25 @@ function prStateOf(review, states) {
|
|
|
8837
9009
|
return states?.get(requestKey(review.repoPath, review.prNumber));
|
|
8838
9010
|
}
|
|
8839
9011
|
/**
|
|
8840
|
-
*
|
|
9012
|
+
* Marks the filters whose answers come from GitHub, Author and PR, so the mark means one thing.
|
|
9013
|
+
* Review state is the reviewer's own and goes without. Hidden from assistive technology: each
|
|
9014
|
+
* select's label already names it.
|
|
9015
|
+
*/
|
|
9016
|
+
var GITHUB_MARK = "<svg class=\"prb-index-forge-mark\" viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" focusable=\"false\" aria-hidden=\"true\" fill=\"currentColor\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\"/></svg>";
|
|
9017
|
+
/**
|
|
9018
|
+
* Whose pull requests to show, by name where GitHub gave one. The login is still what it filters
|
|
9019
|
+
* by, and is kept beside the name because two people can share a name and nobody shares a login.
|
|
8841
9020
|
*
|
|
8842
9021
|
* Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
|
|
8843
9022
|
* whose only choice is Everyone is not a choice.
|
|
8844
9023
|
*/
|
|
8845
9024
|
function renderAuthorFilter(authors) {
|
|
8846
9025
|
if (authors.length === 0) return "";
|
|
8847
|
-
return `<
|
|
8848
|
-
<
|
|
8849
|
-
|
|
9026
|
+
return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}Author:
|
|
9027
|
+
<select name="author" data-author-filter aria-label="Filter by pull request author">
|
|
9028
|
+
<option value="">Everyone</option>${authors.map((author) => `<option value="${escapeHtml$1(author.handle)}">${escapeHtml$1(authorOptionLabel(author))}</option>`).join("")}
|
|
9029
|
+
</select>
|
|
9030
|
+
</label>`;
|
|
8850
9031
|
}
|
|
8851
9032
|
/**
|
|
8852
9033
|
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
@@ -8899,8 +9080,8 @@ function renderBulkBar() {
|
|
|
8899
9080
|
<button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
|
|
8900
9081
|
<button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
|
|
8901
9082
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
8902
|
-
<button type="button" class="prb-bulk-button" data-bulk-mark="complete"
|
|
8903
|
-
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress"
|
|
9083
|
+
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
|
|
9084
|
+
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
|
|
8904
9085
|
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
8905
9086
|
<button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
|
|
8906
9087
|
<button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
|
|
@@ -9096,9 +9277,9 @@ function renderHistory(group, now) {
|
|
|
9096
9277
|
* branch started. The same numbering the workspace itself uses, so "Review 1 of 3" there and
|
|
9097
9278
|
* "Review 1" here are the same review.
|
|
9098
9279
|
*
|
|
9099
|
-
* **Only the latest row carries a workflow status.** `New`, `
|
|
9280
|
+
* **Only the latest row carries a workflow status.** `New`, `Not reviewed` and `Reviewed` describe
|
|
9100
9281
|
* where someone is in a piece of work, and on a superseded review that is a claim about work
|
|
9101
|
-
* nobody should be continuing: a row reading `
|
|
9282
|
+
* nobody should be continuing: a row reading `NOT REVIEWED` under a newer review of the same branch
|
|
9102
9283
|
* invites exactly the wrong click. The second slot is the lineage instead, `Latest` or `Previous`,
|
|
9103
9284
|
* which is the only thing about an older review that a reader needs before opening it.
|
|
9104
9285
|
*/
|
|
@@ -10193,6 +10374,656 @@ function reviewsRevision(live = analysisStates()) {
|
|
|
10193
10374
|
return hash.digest("base64url");
|
|
10194
10375
|
}
|
|
10195
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
|
|
10196
11027
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
10197
11028
|
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
10198
11029
|
var ASK_TIMEOUT_MS = 18e4;
|
|
@@ -10233,7 +11064,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
|
|
|
10233
11064
|
const env = agentEnvOf(workspace);
|
|
10234
11065
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
10235
11066
|
try {
|
|
10236
|
-
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({
|
|
10237
11068
|
capability: "read-code",
|
|
10238
11069
|
prompt: buildPrompt$2(workspace, question, sectionId, cwd),
|
|
10239
11070
|
cwd,
|
|
@@ -10269,7 +11100,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
|
|
|
10269
11100
|
function explained(error, env) {
|
|
10270
11101
|
if (!(error instanceof Error) || error instanceof AgentCancelledError) return error;
|
|
10271
11102
|
try {
|
|
10272
|
-
const plain = agentFor(env).explainFailure?.(error.message, env);
|
|
11103
|
+
const plain = agentFor$1(env).explainFailure?.(error.message, env);
|
|
10273
11104
|
return plain ? new AskFailedError(plain) : error;
|
|
10274
11105
|
} catch {
|
|
10275
11106
|
return error;
|
|
@@ -10381,7 +11212,7 @@ async function drawDiagram(workspace, sectionId, signal) {
|
|
|
10381
11212
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
10382
11213
|
let raw;
|
|
10383
11214
|
try {
|
|
10384
|
-
raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
|
|
11215
|
+
raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor$1(env).run({
|
|
10385
11216
|
capability: "read-code",
|
|
10386
11217
|
prompt: buildPrompt$1(workspace, sectionId, cwd),
|
|
10387
11218
|
cwd,
|
|
@@ -10568,7 +11399,7 @@ async function drawChangeMap(workspace, signal) {
|
|
|
10568
11399
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
10569
11400
|
let raw;
|
|
10570
11401
|
try {
|
|
10571
|
-
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({
|
|
10572
11403
|
capability: "read-code",
|
|
10573
11404
|
prompt: buildPrompt(workspace, cwd),
|
|
10574
11405
|
cwd,
|
|
@@ -11266,6 +12097,7 @@ async function handle(req, res, options) {
|
|
|
11266
12097
|
if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
|
|
11267
12098
|
if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
|
|
11268
12099
|
if (url.pathname === "/restart") return handleIndexRestart(req, res, options);
|
|
12100
|
+
if (url.pathname.startsWith("/add-review/")) return handleAddReview(req, res, url.pathname, options);
|
|
11269
12101
|
const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
|
|
11270
12102
|
if (!match) return send(res, 404, { error: "Not found" });
|
|
11271
12103
|
const [, id, action] = match;
|
|
@@ -11583,17 +12415,17 @@ function handleNotificationOpen(req, res, params, indexToken) {
|
|
|
11583
12415
|
return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}${hash}`);
|
|
11584
12416
|
}
|
|
11585
12417
|
/**
|
|
11586
|
-
* Mark a selection reviewed, or put it back to
|
|
12418
|
+
* Mark a selection reviewed, or put it back to not reviewed.
|
|
11587
12419
|
*
|
|
11588
12420
|
* Authorised by the index credential, like pinning and deleting, because this is the list acting on
|
|
11589
12421
|
* itself. Deliberately not `/api/<id>/state`, which needs a review's own token: routing a bulk mark
|
|
11590
12422
|
* through it would mean putting fifteen credentials into one request, and the index is the one page
|
|
11591
12423
|
* that holds them all.
|
|
11592
12424
|
*
|
|
11593
|
-
* **Marking
|
|
12425
|
+
* **Marking not reviewed fills in `lastViewedAt` where there is none.** Clearing `completedAt` alone
|
|
11594
12426
|
* would leave a review that was never opened showing `New`, and a button that names a state has to
|
|
11595
|
-
* produce it.
|
|
11596
|
-
*
|
|
12427
|
+
* produce it. Taking the mark back is a claim that the work has started, which is what that field
|
|
12428
|
+
* records. It is only ever filled in, never moved, so a review that has been opened
|
|
11597
12429
|
* keeps the time it was opened.
|
|
11598
12430
|
*
|
|
11599
12431
|
* No lock and no running-job check, unlike deletion. A mark is a note about where the reviewer is,
|
|
@@ -11823,6 +12655,43 @@ async function handleFetchUpdates(req, res, indexToken) {
|
|
|
11823
12655
|
send(res, 200, await runFetchUpdates(new Set(ids)));
|
|
11824
12656
|
}
|
|
11825
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
|
+
/**
|
|
11826
12695
|
* Fetch remote updates, from one review's own page, on its own token.
|
|
11827
12696
|
*
|
|
11828
12697
|
* The list's fetch scoped to this review: `GET` says whether it applies (the page draws the item
|
|
@@ -12936,7 +13805,11 @@ function applyState(workspace, body) {
|
|
|
12936
13805
|
if (Array.isArray(body?.dismissedIssues)) reviewer.dismissedIssues = body.dismissedIssues.filter((entry) => typeof entry?.id === "string" && typeof entry?.sessionId === "string");
|
|
12937
13806
|
if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
|
|
12938
13807
|
if (typeof body?.dismissKissFreshnessSha === "string" || body?.dismissKissFreshnessSha === null) workspace.dismissedKissFreshnessSha = body.dismissKissFreshnessSha ?? void 0;
|
|
12939
|
-
if (typeof body?.complete === "boolean")
|
|
13808
|
+
if (typeof body?.complete === "boolean") {
|
|
13809
|
+
const now = Date.now();
|
|
13810
|
+
workspace.completedAt = body.complete ? now : void 0;
|
|
13811
|
+
if (!body.complete) workspace.lastViewedAt = workspace.lastViewedAt ?? now;
|
|
13812
|
+
}
|
|
12940
13813
|
if (typeof body?.pinned === "boolean") workspace.pinned = body.pinned;
|
|
12941
13814
|
}
|
|
12942
13815
|
/** 1 MB is far more than any question or id list, and far less than a memory problem. */
|