prreviewbuddy 0.78.1 → 0.79.0
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 +27 -0
- package/dist/{delete_review-BHOzMQ0_.js → delete_review-DwF1NMJ4.js} +1 -1
- package/dist/main.js +1 -1
- package/dist/server.js +1349 -1074
- package/package.json +1 -1
- package/static/{chunk-D8xJNBjR.js → chunk-B6LyLjZ7.js} +1 -1
- package/static/chunk-DYrelqtW.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 +13 -0
- package/static/sidebar.css +7 -1
- package/static/workspace.js +82 -82
- package/static/chunk-BMAMO0Tf.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as resolveWorkspacePort, $n as saveJob, $r as
|
|
1
|
+
import { $ as resolveWorkspacePort, $n as saveJob, $r as recommendationsOf, $t as pruneNotifications, A as parsePullRequestLink, Ai as shorthandOf, An as ReviewBeingDeletedError, Ar as LIFECYCLES, At as repositoryKey, Bi as AGENT_IDS, Bn as findExistingReview, Br as queueAdd, Bt as announceJob, C as completeRun, Ci as resolveTarget, Cn as GITHUB_MARKDOWN, Cr as recentWorkspaces, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as resolveBase, Dn as escapeHtml$1, Dr as touchWorkspace, Dt as loadArchitecture, E as stopRun, Ei as isCommitReview, En as CODE_COPY_CLASS, Et as checkArchitecture, Fi as clearAgentPreference, Fn as readMarker, Fr as lifecycleItemLabel, Ft as QUOTE_LIMITS, G as stopChild, Gi as AgentUnavailableError, Gn as fetchHead, Gr as doneVerb, Gt as createPrState, Hi as agentFor$1, Hn as checkReview, Hr as queueRemove, Ht as subjectOf, I as settleQueue, Ii as readAgentPreference, In as removeWorktree, Ir as lifecyclePill, It as REPOSITORY_SCOPE, J as writeServerRecord, Jn as allJobs, Jr as issuesOutstanding, Jt as listNotifications, Kn as forgeResolver, Kr as isIssueOutstanding, Kt as createPrUpdate, L as readIndexToken, Li as writeAgentPreference, Ln as requestJobStop, Lr as offersLifecycle, Lt as ancestorsOf, M as runBatch, Mi as refExists, Mn as createWorktree, Mr as activityLabel, Mt as storedScopes, N as activateQueued, Ni as resolveAgent, Nn as holdWorktree, Nr as isLifecycle, Nt as trackedFiles, O as batchIdentity, Oi as AmbiguousRefError, On as renderInlineText, Or as workspaceRevision, Ot as partFiles, P as compactQueue, Pi as resolveModel, Pn as isWorktreeHeld, Pr as lifecycleChip, Pt as ownersOf, Q as parseWorkspacePort, Qn as loadJob, Qr as processDiscussion, Qt as moveIds, Rr as parseLifecycle, Rt as isArchitectureId, S as beginRun, Si as kissOutcome, Sn as hasAuthorContent, Sr as previousKissResult, St as recordRuntimeBestEffort, T as reportRun, Ti as DetachedHeadError, Tn as CODE_BLOCK_CLASS, Tt as ARCHITECTURE_DIR, U as serverEntryExists, Ui as detectAgents, Un as followedBranch, Ur as queueStamp, Ut as categoryOf, V as readServerRecord, Vi as agentById, Vn as checkCodeFreshness, Vr as queueAddMany, Vt as branchOf, W as spawnServer, Wi as AgentCancelledError, Wn as reviewedShaOf, Wr as readQueue, Wt as createArchitectureNotification, X as MAX_WORKSPACE_PORT, Xn as fail$1, Xr as resolvedByConversation, Xt as markRead, Y as DEFAULT_WORKSPACE_PORT, Yn as endedAt, Yr as questionsOutstanding, Yt as markAllRead, Z as MIN_WORKSPACE_PORT, Zn as isTerminal, Zr as reviewerDispositions, Zt as markReadForReview, _i as asString, _n as agentEnvOf, _r as loadWorkspace, _t as BUILD_VERSION, a as wasBlocked, ai as analysisRefOf, an as unreadAround, ar as purposeOf, at as settleReview, b as track, bi as describeAuthorship, bn as UNESTABLISHED_MOTIVATION, bt as waitingHistory, c as RefreshUnavailableError, ci as findingAssessment, cn as existingPaths, cr as decideKissRecommendation, d as mergeUpdateInto, di as isGradeFor, dn as prepareConversationWithin, dr as indexKeptIds, dt as record, ei as explanationActionOf, en as readNotification, et as statedWorkspacePort, f as updateReview, fi as promptGrade, fn as saveRefresh, fr as indexReviewGroups, gi as followUpFindingId, gn as isUnchanged, gr as lineagePosition, h as liveUpdateFor, hi as wasReassessed, hn as refreshPrContext, hr as latestKissRun, ht as telemetryUploadConsent, i as liveJobsFor, ii as ANALYSIS_ONLY, in as toneOf, ir as progressSteps, it as prepareReview, j as unsupportedReason, ji as git, jn as MANAGED_ROOT, jr as activityAttributes, jt as storeArchitecture, k as extractPullRequestLinks, ki as shortNameOf, kr as workspaceStamps, kt as readArchitecture, l as cancelUpdate, li as findingIn, ln as ASK_REREAD_CEILING_MS, lr as followedRefName, m as UpdateAlreadyRunningError, mi as sourcePhrase, mn as unforeseenFailure, mr as kissDecisionsOf, mt as recordTelemetryUploadConsent, n as deleteReview, ni as isExplanationAction, nn as removeNotification, nr as isTerminalJobPhase, nt as failureOf$1, o as discardJob, oi as assessmentLookup, on as unreadCount$1, ot as reanalyseReview, p as askCheckout, pi as reassessIn, pn as storedConversation, pr as kissDecisionRefusal, qn as fillFileUrlTemplate, qr as isQuestionOutstanding, qt as eventName, r as lineageIds, ri as withExplanation, rn as retireNotification, rr as phasesFor, rt as observeReview, s as FETCH_TIMEOUT_MS, si as currentGradeIn, sn as withUsageRecorded, sr as isClaimed, st as runJob, t as deleteLineage, ti as explanationOf, tn as readStateOf, tr as isLiveJob, tt as writePortPreference, u as cancellableHere, ui as gradeScale, un as adoptLegacyBaseline, ur as groupByLineage, ut as readEvents, v as startKissJob, vi as extractJsonText, vn as EXPLAIN_SIMPLY_PROMPT, w as endRun, wi as originRemote, wn as renderMarkdown, wr as reviewedCommit, wt as describeRuntimes, x as RUN_KINDS, xi as WITHHELD_REASON, xn as authorPreview, xr as presentedKissRunOf, xt as mapRuntimes, y as isDriving, yi as objectWithAnyOf, yn as chatConversationSection, yr as mutateWorkspace, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as STORE_ROOT, zr as sectionOf, zt as writeJsonAtomically } from "./delete_review-DwF1NMJ4.js";
|
|
2
2
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -243,12 +243,26 @@ var INDEX_SECTIONS = [
|
|
|
243
243
|
"done"
|
|
244
244
|
];
|
|
245
245
|
/**
|
|
246
|
+
* Every section the list draws, top to bottom: In progress, then the card sections.
|
|
247
|
+
*
|
|
248
|
+
* In progress is not in `INDEX_SECTIONS` because no card is ever placed there. It lists work, one
|
|
249
|
+
* entry per running job or waiting row (`deriveOperations`), and a review being updated stays a
|
|
250
|
+
* card in its own section while its update is listed here. Windows, Load more, the selection and
|
|
251
|
+
* the counts are all about cards, so they keep reading `INDEX_SECTIONS`; folding, its memory and its
|
|
252
|
+
* keyboard commands are about headings, so they read this.
|
|
253
|
+
*/
|
|
254
|
+
var LIST_SECTIONS = ["in-progress", ...INDEX_SECTIONS];
|
|
255
|
+
/**
|
|
246
256
|
* Each section's name, as its heading says it, and the key that folds it from the keyboard
|
|
247
257
|
* (⌘K L and this key). The commands are made from this table, so a section added here is a section
|
|
248
258
|
* the keyboard can fold, with no second list to remember.
|
|
249
259
|
*/
|
|
250
260
|
var lifecycleName = (id) => LIFECYCLES.find((lifecycle) => lifecycle.id === id).name;
|
|
251
261
|
var INDEX_SECTION_NAMES = {
|
|
262
|
+
"in-progress": {
|
|
263
|
+
title: "In progress",
|
|
264
|
+
key: "i"
|
|
265
|
+
},
|
|
252
266
|
pinned: {
|
|
253
267
|
title: "Pinned",
|
|
254
268
|
key: "p"
|
|
@@ -273,8 +287,9 @@ var INDEX_SECTION_NAMES = {
|
|
|
273
287
|
function sectionOpen(section, collapsed) {
|
|
274
288
|
return !(collapsed?.[section] ?? section === "done");
|
|
275
289
|
}
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
/** A section the list draws a heading for, and so one whose fold can be stored. */
|
|
291
|
+
function isListSection(value) {
|
|
292
|
+
return typeof value === "string" && LIST_SECTIONS.includes(value);
|
|
278
293
|
}
|
|
279
294
|
/**
|
|
280
295
|
* Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
|
|
@@ -435,7 +450,8 @@ var PR_FILTER_OPTIONS = [
|
|
|
435
450
|
* once a second about a review that finished failing an hour ago, for as long as the tab stayed
|
|
436
451
|
* open.
|
|
437
452
|
*/
|
|
438
|
-
function anythingRunning(states) {
|
|
453
|
+
function anythingRunning(states, operations = []) {
|
|
454
|
+
if (operations.length > 0) return true;
|
|
439
455
|
for (const state of states.values()) if (state.phase !== "failed") return true;
|
|
440
456
|
return false;
|
|
441
457
|
}
|
|
@@ -9396,6 +9412,98 @@ async function checkPrStateNow(workspace, deps = defaultDeps()) {
|
|
|
9396
9412
|
} catch {}
|
|
9397
9413
|
}
|
|
9398
9414
|
//#endregion
|
|
9415
|
+
//#region ../../packages/review-harness/src/workspace/operations.ts
|
|
9416
|
+
/** What a running job is, in the words the section uses. */
|
|
9417
|
+
function operationKind(job) {
|
|
9418
|
+
if (purposeOf(job) === "update") return "updating";
|
|
9419
|
+
if (job.reanalysis) return "re-running";
|
|
9420
|
+
return job.phase === "preparing" ? "starting" : "analysing";
|
|
9421
|
+
}
|
|
9422
|
+
/**
|
|
9423
|
+
* Every operation, running first and newest first, then the queue in the order it was submitted.
|
|
9424
|
+
*
|
|
9425
|
+
* A row is dropped once its review has a job that is running, or one that ended after the row took
|
|
9426
|
+
* its slot. Both halves are about the hand-over, which has two windows. A row learns its review id
|
|
9427
|
+
* before that review's job is claimed, so "has a job" alone would leave nothing on the page for a
|
|
9428
|
+
* moment; and a row learns its job ended only after it has, so "has a running job" alone would
|
|
9429
|
+
* bring a finished review back as Starting. An ending from before the slot was taken is an earlier
|
|
9430
|
+
* attempt, a Retry's predecessor, and says nothing about this one.
|
|
9431
|
+
*
|
|
9432
|
+
* An ending hands over only a row that has its slot. A Retry puts a row back to waiting with the
|
|
9433
|
+
* review, and the job that failed, it already had; until the retry starts, that ending is the
|
|
9434
|
+
* attempt being retried, not this one, and treating it as the hand-over hid a queued retry for as
|
|
9435
|
+
* long as it waited. A running job hands over a row at any stage: the work is on the page either
|
|
9436
|
+
* way, and a waiting row beside it would be the same review listed twice.
|
|
9437
|
+
*
|
|
9438
|
+
* Both orders are total, ending on the key, because the result is hashed into the index token and a
|
|
9439
|
+
* tie left to the directory's order would reload the page for no reason a reviewer could see.
|
|
9440
|
+
*/
|
|
9441
|
+
function deriveOperations(jobs, isLive, rows) {
|
|
9442
|
+
const running = [];
|
|
9443
|
+
const liveOrEnded = [];
|
|
9444
|
+
for (const job of jobs) {
|
|
9445
|
+
if (purposeOf(job) === "ask") continue;
|
|
9446
|
+
if (isTerminalJobPhase(job.phase)) {
|
|
9447
|
+
liveOrEnded.push(job);
|
|
9448
|
+
continue;
|
|
9449
|
+
}
|
|
9450
|
+
if (!isLive(job)) continue;
|
|
9451
|
+
liveOrEnded.push(job);
|
|
9452
|
+
running.push({
|
|
9453
|
+
key: job.id,
|
|
9454
|
+
kind: operationKind(job),
|
|
9455
|
+
reviewId: job.workspaceId,
|
|
9456
|
+
phase: job.phase,
|
|
9457
|
+
purpose: purposeOf(job),
|
|
9458
|
+
...job.reviewType && job.reviewType !== "standard" ? { lens: job.reviewType } : {},
|
|
9459
|
+
startedAt: job.startedAt
|
|
9460
|
+
});
|
|
9461
|
+
}
|
|
9462
|
+
const handedOver = (row) => row.reviewId !== void 0 && liveOrEnded.some((job) => job.workspaceId === row.reviewId && (!isTerminalJobPhase(job.phase) || row.status === "reviewing" && (job.finishedAt ?? job.updatedAt) >= (row.takenAt ?? Number.POSITIVE_INFINITY)));
|
|
9463
|
+
const queued = [];
|
|
9464
|
+
for (const row of rows) {
|
|
9465
|
+
if (handedOver(row)) continue;
|
|
9466
|
+
const base = {
|
|
9467
|
+
key: row.key,
|
|
9468
|
+
row,
|
|
9469
|
+
...row.reviewId ? { reviewId: row.reviewId } : {}
|
|
9470
|
+
};
|
|
9471
|
+
if (row.status === "waiting") queued.push({
|
|
9472
|
+
...base,
|
|
9473
|
+
kind: "queued"
|
|
9474
|
+
});
|
|
9475
|
+
else running.push({
|
|
9476
|
+
...base,
|
|
9477
|
+
kind: "starting",
|
|
9478
|
+
startedAt: row.takenAt ?? 0
|
|
9479
|
+
});
|
|
9480
|
+
}
|
|
9481
|
+
const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
9482
|
+
running.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || byKey(a, b));
|
|
9483
|
+
queued.sort((a, b) => (a.row?.order ?? 0) - (b.row?.order ?? 0) || byKey(a, b));
|
|
9484
|
+
return [...running, ...queued];
|
|
9485
|
+
}
|
|
9486
|
+
var LEADS = {
|
|
9487
|
+
queued: "Queued",
|
|
9488
|
+
starting: "Starting",
|
|
9489
|
+
analysing: "Analysing",
|
|
9490
|
+
updating: "Updating review",
|
|
9491
|
+
"re-running": "Re-running"
|
|
9492
|
+
};
|
|
9493
|
+
/**
|
|
9494
|
+
* The entry's status line: what kind of work, then the step it is on, in the words of `PHASES` /
|
|
9495
|
+
* `UPDATE_PHASES`, which the card, the terminal and the review page's checklist already use.
|
|
9496
|
+
*
|
|
9497
|
+
* "Analysing the change" stands alone rather than after "Analysing", which would say it twice.
|
|
9498
|
+
*/
|
|
9499
|
+
function operationLine(operation) {
|
|
9500
|
+
const lead = operation.lens === "kiss" ? "KISS review" : LEADS[operation.kind];
|
|
9501
|
+
if (operation.phase === void 0) return lead;
|
|
9502
|
+
const step = phasesFor({ purpose: operation.purpose })[operation.phase].doing;
|
|
9503
|
+
if (step.toLowerCase().startsWith(lead.toLowerCase())) return step;
|
|
9504
|
+
return `${lead} · ${step}`;
|
|
9505
|
+
}
|
|
9506
|
+
//#endregion
|
|
9399
9507
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
9400
9508
|
function renderReviewsPage(view) {
|
|
9401
9509
|
const now = view.now ?? Date.now();
|
|
@@ -9412,7 +9520,9 @@ function renderReviewsPage(view) {
|
|
|
9412
9520
|
};
|
|
9413
9521
|
for (const group of ordered) sections[indexSectionOf(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all")].push(group);
|
|
9414
9522
|
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
9415
|
-
const
|
|
9523
|
+
const operations = view.operations ?? [];
|
|
9524
|
+
const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
|
|
9525
|
+
const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates), inProgressOnly.has(group.latest.id))).join("");
|
|
9416
9526
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
9417
9527
|
const queue = view.queue ?? [];
|
|
9418
9528
|
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
@@ -9426,7 +9536,7 @@ function renderReviewsPage(view) {
|
|
|
9426
9536
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
9427
9537
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
9428
9538
|
</head>
|
|
9429
|
-
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
|
|
9539
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
|
|
9430
9540
|
${renderGlobalBar({
|
|
9431
9541
|
current: "reviews",
|
|
9432
9542
|
unread: view.unreadNotifications ?? 0,
|
|
@@ -9447,7 +9557,8 @@ ${renderWorkspaceRail({
|
|
|
9447
9557
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9448
9558
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9449
9559
|
</div>`}
|
|
9450
|
-
${
|
|
9560
|
+
${renderInProgress(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9561
|
+
${INDEX_SECTIONS.map((section) => renderSection(section, sections[section], renderCards, sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id)).length)).join("\n ")}
|
|
9451
9562
|
${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
|
|
9452
9563
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
9453
9564
|
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
@@ -9551,10 +9662,10 @@ function renderLoadMore$1(section) {
|
|
|
9551
9662
|
* empty and hidden rather than left out, so a card the browser moves here after a mark or a pin has
|
|
9552
9663
|
* somewhere to go, and an empty section keeps its fold for when something arrives.
|
|
9553
9664
|
*/
|
|
9554
|
-
function renderSection(section, groups, renderCards, open) {
|
|
9665
|
+
function renderSection(section, groups, renderCards, open, shown = groups.length) {
|
|
9555
9666
|
const title = INDEX_SECTION_NAMES[section].title;
|
|
9556
|
-
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${
|
|
9557
|
-
<summary class="prb-index-section-head">${title} · <span data-section-count>${
|
|
9667
|
+
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${shown === 0 ? " hidden" : ""}>
|
|
9668
|
+
<summary class="prb-index-section-head">${title} · <span data-section-count>${shown}</span></summary>
|
|
9558
9669
|
<div class="prb-cards" data-cards="${section}">
|
|
9559
9670
|
${renderCards(groups)}
|
|
9560
9671
|
</div>
|
|
@@ -9562,6 +9673,87 @@ function renderSection(section, groups, renderCards, open) {
|
|
|
9562
9673
|
</details>`;
|
|
9563
9674
|
}
|
|
9564
9675
|
/**
|
|
9676
|
+
* In progress: the work going on now, above everything else on the page.
|
|
9677
|
+
*
|
|
9678
|
+
* Not drawn at all when nothing is going on, rather than drawn empty and hidden like the card
|
|
9679
|
+
* sections: nothing ever moves into it in the browser, because every change to it is a change to the
|
|
9680
|
+
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9681
|
+
* counts once here and once in its own section, which is the point.
|
|
9682
|
+
*/
|
|
9683
|
+
function renderInProgress(operations, groups, positions, prStates, now, open) {
|
|
9684
|
+
if (operations.length === 0) return "";
|
|
9685
|
+
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9686
|
+
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now)).join("");
|
|
9687
|
+
return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
|
|
9688
|
+
<summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
9689
|
+
<div class="prb-cards" data-operations>
|
|
9690
|
+
${entries}
|
|
9691
|
+
</div>
|
|
9692
|
+
</details>`;
|
|
9693
|
+
}
|
|
9694
|
+
/**
|
|
9695
|
+
* The review an entry is about. Its own where it has one. A row still waiting has none yet, and
|
|
9696
|
+
* takes the newest review of the same pull request or branch from the same folder where there is
|
|
9697
|
+
* one, which is what that review's title, author and state still are: the filters then treat a
|
|
9698
|
+
* re-review of a known pull request as that pull request, rather than as somebody unknown.
|
|
9699
|
+
*/
|
|
9700
|
+
function reviewOf(operation, reviews) {
|
|
9701
|
+
if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
|
|
9702
|
+
const row = operation.row;
|
|
9703
|
+
if (!row?.repoPath) return void 0;
|
|
9704
|
+
let newest;
|
|
9705
|
+
for (const review of reviews.values()) {
|
|
9706
|
+
if (review.repoPath !== row.repoPath) continue;
|
|
9707
|
+
if ((row.prNumber !== void 0 ? review.prNumber === row.prNumber : review.prNumber === void 0 && review.branch === row.branch) && (!newest || review.createdAt > newest.createdAt)) newest = review;
|
|
9708
|
+
}
|
|
9709
|
+
return newest;
|
|
9710
|
+
}
|
|
9711
|
+
/**
|
|
9712
|
+
* One piece of work, in a card's clothes and with none of a card's controls.
|
|
9713
|
+
*
|
|
9714
|
+
* It carries the same filter attributes as its review's card, so the search and the filters answer
|
|
9715
|
+
* the same question about both. No risk, no counts, no review state: there are no results yet, or the
|
|
9716
|
+
* results on show are about to be replaced, and either way they are the card's to show.
|
|
9717
|
+
*
|
|
9718
|
+
* A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
|
|
9719
|
+
* for a slot has no review to open, so it is not a link.
|
|
9720
|
+
*/
|
|
9721
|
+
function renderOperation(operation, review, positions, prStates, now) {
|
|
9722
|
+
const row = operation.row;
|
|
9723
|
+
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
9724
|
+
const repoPath = review?.repoPath ?? row?.repoPath;
|
|
9725
|
+
const prNumber = review?.prNumber ?? row?.prNumber;
|
|
9726
|
+
const branch = review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : row?.branch;
|
|
9727
|
+
const prState = prNumber !== void 0 && repoPath ? prStates?.get(requestKey(repoPath, prNumber)) : void 0;
|
|
9728
|
+
const queued = own ? positions.get(own.id) : void 0;
|
|
9729
|
+
const status = own ? statusOf(own) : "new";
|
|
9730
|
+
const search = [
|
|
9731
|
+
review ? searchText(review) : "",
|
|
9732
|
+
row?.label ?? "",
|
|
9733
|
+
repoPath ? lastSegment(repoPath) : "",
|
|
9734
|
+
prNumber === void 0 ? "" : `#${prNumber} ${prNumber}`,
|
|
9735
|
+
branch ?? ""
|
|
9736
|
+
].join(" ").toLowerCase();
|
|
9737
|
+
const title = own?.title || review?.title || row?.label || "Review";
|
|
9738
|
+
const running = operation.kind !== "queued";
|
|
9739
|
+
const body = `<div class="prb-card-head">
|
|
9740
|
+
<h2>${escapeHtml$1(title)}</h2>
|
|
9741
|
+
</div>
|
|
9742
|
+
<p class="prb-card-where">
|
|
9743
|
+
${repoPath ? `<span class="prb-card-repo">${escapeHtml$1(lastSegment(repoPath))}</span>` : ""}
|
|
9744
|
+
${prNumber === void 0 ? "" : `<span class="prb-card-pr">#${prNumber}</span>`}
|
|
9745
|
+
${branch ? `<span class="prb-card-branch">${escapeHtml$1(branch)}</span>` : ""}
|
|
9746
|
+
</p>
|
|
9747
|
+
<p class="prb-card-meta">
|
|
9748
|
+
<span class="prb-card-analysis" data-analysis="${running ? "running" : "queued"}"><i></i>${escapeHtml$1(operationLine(operation))}</span>
|
|
9749
|
+
${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
|
|
9750
|
+
</p>`;
|
|
9751
|
+
const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
|
|
9752
|
+
return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}">
|
|
9753
|
+
${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
9754
|
+
</article>`;
|
|
9755
|
+
}
|
|
9756
|
+
/**
|
|
9565
9757
|
* The bulk bar, with its counts left out.
|
|
9566
9758
|
*
|
|
9567
9759
|
* Same division as the pager: every word is written here, where the house style tests read it, and
|
|
@@ -9618,13 +9810,13 @@ function renderBulkBar() {
|
|
|
9618
9810
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
9619
9811
|
* than a rebuild of the list.
|
|
9620
9812
|
*/
|
|
9621
|
-
function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
|
|
9813
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false) {
|
|
9622
9814
|
const review = group.latest;
|
|
9623
9815
|
const status = statusOf(review);
|
|
9624
9816
|
const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
|
|
9625
9817
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
9626
9818
|
const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
|
|
9627
|
-
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
|
|
9819
|
+
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}"${inProgressOnly ? " data-in-progress-only hidden" : ""}>
|
|
9628
9820
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
9629
9821
|
<button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "★" : "☆"}</button>
|
|
9630
9822
|
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
@@ -10039,7 +10231,7 @@ function writeChangeMapRegister(register) {
|
|
|
10039
10231
|
function readReviewSections(value) {
|
|
10040
10232
|
if (!value || typeof value !== "object") return {};
|
|
10041
10233
|
const sections = {};
|
|
10042
|
-
for (const section of
|
|
10234
|
+
for (const section of LIST_SECTIONS) {
|
|
10043
10235
|
const collapsed = value[section];
|
|
10044
10236
|
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10045
10237
|
}
|
|
@@ -11023,545 +11215,105 @@ function renderNotificationRow(notification, gone) {
|
|
|
11023
11215
|
</li>`;
|
|
11024
11216
|
}
|
|
11025
11217
|
//#endregion
|
|
11026
|
-
//#region ../../packages/review-harness/src/workspace/
|
|
11027
|
-
/**
|
|
11028
|
-
* Whether anything the reviews index draws has changed, as one token.
|
|
11029
|
-
*
|
|
11030
|
-
* The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
|
|
11031
|
-
* never needs to know *which* review changed, so it asks one question whose cost is the same for
|
|
11032
|
-
* five reviews and five hundred. Per-card revisions would be more information than the page has any
|
|
11033
|
-
* use for, and a request per card to carry it.
|
|
11034
|
-
*
|
|
11035
|
-
* Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
|
|
11036
|
-
* tokens for equality, never order them, never parse one. The implementation below is a hash of
|
|
11037
|
-
* facts the page happens to draw today, and the whole point of saying so here is that it can be
|
|
11038
|
-
* replaced by a counter, a watch, or anything else without a page having to be told.
|
|
11039
|
-
*
|
|
11040
|
-
* ## What is in it, and what is deliberately not
|
|
11041
|
-
*
|
|
11042
|
-
* In: every review's id and file revision, and the phase of every analysis running right now.
|
|
11043
|
-
* Between them those cover everything a card shows — the summary, the risk badge, the counts, the
|
|
11044
|
-
* pin, the reviewed mark, the running line — because every one of those reaches disk through
|
|
11045
|
-
* `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
|
|
11046
|
-
*
|
|
11047
|
-
* Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
|
|
11048
|
-
* streams its progress into the job record every few seconds, so a token derived from job file
|
|
11049
|
-
* mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
|
|
11050
|
-
* would then reload every few seconds for the whole length of an analysis — a page thrashing
|
|
11051
|
-
* itself, with the reviewer's scroll position going with it. Phase only, which changes four times
|
|
11052
|
-
* in a run.
|
|
11053
|
-
*/
|
|
11218
|
+
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11054
11219
|
/**
|
|
11055
|
-
*
|
|
11056
|
-
*
|
|
11057
|
-
* Keyed by review, valued by the phase the card should name. Two states reach it and a third
|
|
11058
|
-
* deliberately does not:
|
|
11059
|
-
*
|
|
11060
|
-
* - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
|
|
11061
|
-
* load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
|
|
11062
|
-
* before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
|
|
11063
|
-
* be the index confidently reporting work that nothing is doing.
|
|
11064
|
-
* - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
|
|
11065
|
-
* this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
|
|
11066
|
-
* `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
|
|
11067
|
-
* and the card's "Analysis stopped" branch was unreachable in production while passing a unit
|
|
11068
|
-
* test that handed it a state the product could not produce. A review whose analysis failed went
|
|
11069
|
-
* back to reading "No standard review yet" the moment its driver let go.
|
|
11070
|
-
* - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
|
|
11071
|
-
* result on the card already, and an abandoned one is the sweep's to judge rather than this
|
|
11072
|
-
* renderer's to pre-empt.
|
|
11073
|
-
*
|
|
11074
|
-
* **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
|
|
11075
|
-
* a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
|
|
11076
|
-
* KISS run, and the review page's poll is built around one finishing while the other goes on. An
|
|
11077
|
-
* earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
|
|
11078
|
-
* first spoke for a standard analysis that was still working, and the card fell silent on work that
|
|
11079
|
-
* was happening in front of the reviewer.
|
|
11220
|
+
* The local Git working folders PR Review Buddy can find right now.
|
|
11080
11221
|
*
|
|
11081
|
-
*
|
|
11082
|
-
*
|
|
11222
|
+
* One question and nothing more: which folders on this machine could a review run in. The answer is
|
|
11223
|
+
* derived each time from what is already known, the folders past reviews ran in and the other
|
|
11224
|
+
* worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
|
|
11225
|
+
* up because `git worktree list` names it, not because anything remembered it.
|
|
11083
11226
|
*
|
|
11084
|
-
*
|
|
11085
|
-
*
|
|
11227
|
+
* A folder is a folder. A linked worktree is its own entry with its own path and is never folded
|
|
11228
|
+
* into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
|
|
11229
|
+
* where the branch is checked out.
|
|
11086
11230
|
*
|
|
11087
|
-
*
|
|
11088
|
-
* that passes the card quietly returns to saying nothing, which is the same silence every review
|
|
11089
|
-
* had before this existed.
|
|
11231
|
+
* Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
|
|
11090
11232
|
*/
|
|
11091
|
-
function
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
|
|
11095
|
-
|
|
11096
|
-
if (held) held.push(job);
|
|
11097
|
-
else byReview.set(job.workspaceId, [job]);
|
|
11233
|
+
function canonical(path) {
|
|
11234
|
+
try {
|
|
11235
|
+
return realpathSync(path);
|
|
11236
|
+
} catch {
|
|
11237
|
+
return path;
|
|
11098
11238
|
}
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
11106
|
-
|
|
11107
|
-
|
|
11108
|
-
|
|
11109
|
-
|
|
11110
|
-
|
|
11111
|
-
|
|
11112
|
-
const
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
|
|
11116
|
-
|
|
11117
|
-
const
|
|
11118
|
-
|
|
11119
|
-
|
|
11120
|
-
|
|
11121
|
-
|
|
11239
|
+
}
|
|
11240
|
+
/** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
|
|
11241
|
+
async function worktreesOf(repoPath) {
|
|
11242
|
+
let output;
|
|
11243
|
+
try {
|
|
11244
|
+
output = await git(repoPath, [
|
|
11245
|
+
"worktree",
|
|
11246
|
+
"list",
|
|
11247
|
+
"--porcelain"
|
|
11248
|
+
]);
|
|
11249
|
+
} catch {
|
|
11250
|
+
return [];
|
|
11251
|
+
}
|
|
11252
|
+
const entries = [];
|
|
11253
|
+
for (const block of output.split(/\n\s*\n/)) {
|
|
11254
|
+
const lines = block.split("\n");
|
|
11255
|
+
const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
|
|
11256
|
+
if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
|
|
11257
|
+
const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
|
|
11258
|
+
entries.push({
|
|
11259
|
+
path,
|
|
11260
|
+
branch: ref ? ref.replace(/^refs\/heads\//, "") : null
|
|
11261
|
+
});
|
|
11262
|
+
}
|
|
11263
|
+
return entries;
|
|
11264
|
+
}
|
|
11265
|
+
async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
|
|
11266
|
+
const lastReviewed = /* @__PURE__ */ new Map();
|
|
11267
|
+
for (const summary of summaries) {
|
|
11268
|
+
if (!summary.repoPath) continue;
|
|
11269
|
+
const key = canonical(summary.repoPath);
|
|
11270
|
+
lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
|
|
11271
|
+
}
|
|
11272
|
+
const found = /* @__PURE__ */ new Map();
|
|
11273
|
+
for (const seed of lastReviewed.keys()) {
|
|
11274
|
+
if (found.has(seed) || !existsSync(seed)) continue;
|
|
11275
|
+
(await worktreesOf(seed)).forEach((entry, index) => {
|
|
11276
|
+
const key = canonical(entry.path);
|
|
11277
|
+
if (!found.has(key) && existsSync(key)) found.set(key, {
|
|
11278
|
+
entry: {
|
|
11279
|
+
...entry,
|
|
11280
|
+
path: key
|
|
11281
|
+
},
|
|
11282
|
+
main: index === 0
|
|
11122
11283
|
});
|
|
11123
|
-
continue;
|
|
11124
|
-
}
|
|
11125
|
-
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
11126
|
-
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
11127
|
-
phase: "failed",
|
|
11128
|
-
purpose: "review",
|
|
11129
|
-
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
11130
11284
|
});
|
|
11131
11285
|
}
|
|
11132
|
-
return
|
|
11286
|
+
return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
|
|
11287
|
+
path: entry.path,
|
|
11288
|
+
remote: await originRemote(entry.path),
|
|
11289
|
+
branch: entry.branch,
|
|
11290
|
+
main,
|
|
11291
|
+
lastReviewedAt: lastReviewed.get(entry.path) ?? null
|
|
11292
|
+
})))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
|
|
11293
|
+
}
|
|
11294
|
+
/** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
|
|
11295
|
+
function isGitHubRepository(remote, owner, repo) {
|
|
11296
|
+
return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
|
|
11133
11297
|
}
|
|
11134
11298
|
/**
|
|
11135
|
-
* The
|
|
11299
|
+
* The folders a pull request could be reviewed in, the suggested one first.
|
|
11136
11300
|
*
|
|
11137
|
-
*
|
|
11138
|
-
*
|
|
11139
|
-
*
|
|
11140
|
-
*
|
|
11301
|
+
* Every match is returned, because which one is right is the reviewer's call: an agent's worktree
|
|
11302
|
+
* and the main clone hold the same objects and differ in what is checked out. The order only
|
|
11303
|
+
* decides the default. A folder that already has a review of this pull request comes first, so
|
|
11304
|
+
* reviewing it again finds that review rather than starting a second one filed under another
|
|
11305
|
+
* folder; then the main worktree; then whichever was reviewed in most recently.
|
|
11141
11306
|
*/
|
|
11142
|
-
function
|
|
11143
|
-
const
|
|
11144
|
-
|
|
11145
|
-
|
|
11146
|
-
|
|
11147
|
-
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
11148
|
-
}
|
|
11149
|
-
hash.update(`queue:${queueStamp()}\n`);
|
|
11150
|
-
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
11151
|
-
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
11152
|
-
for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
|
|
11153
|
-
for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
|
|
11154
|
-
return hash.digest("base64url");
|
|
11307
|
+
function candidatesFor(folders, link, summaries) {
|
|
11308
|
+
const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
|
|
11309
|
+
const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
|
|
11310
|
+
const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
|
|
11311
|
+
return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
|
|
11155
11312
|
}
|
|
11156
|
-
|
|
11157
|
-
|
|
11158
|
-
|
|
11159
|
-
|
|
11160
|
-
function parseReassessmentRequest(value) {
|
|
11161
|
-
const body = value;
|
|
11162
|
-
if (body?.op === "decide") {
|
|
11163
|
-
if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
|
|
11164
|
-
if (body.decision !== "accepted" && body.decision !== "declined") return null;
|
|
11165
|
-
return {
|
|
11166
|
-
op: "decide",
|
|
11167
|
-
proposalId: body.proposalId,
|
|
11168
|
-
decision: body.decision
|
|
11169
|
-
};
|
|
11170
|
-
}
|
|
11171
|
-
if (body?.op === "set") {
|
|
11172
|
-
const kind = asKind(body.kind);
|
|
11173
|
-
if (!kind || typeof body.id !== "string" || body.id === "") return null;
|
|
11174
|
-
if (!isGradeFor(kind, body.to)) return null;
|
|
11175
|
-
if (body.from !== null && !isGradeFor(kind, body.from)) return null;
|
|
11176
|
-
if (body.reason !== void 0 && typeof body.reason !== "string") return null;
|
|
11177
|
-
const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
|
|
11178
|
-
return {
|
|
11179
|
-
op: "set",
|
|
11180
|
-
kind,
|
|
11181
|
-
findingId: body.id,
|
|
11182
|
-
from: body.from,
|
|
11183
|
-
to: body.to,
|
|
11184
|
-
...reason ? { reason } : {}
|
|
11185
|
-
};
|
|
11186
|
-
}
|
|
11187
|
-
return null;
|
|
11188
|
-
}
|
|
11189
|
-
var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
|
|
11190
|
-
var REASSESSMENT_REFUSED = {
|
|
11191
|
-
"not-a-grade": "That is not a grade this finding can have.",
|
|
11192
|
-
unchanged: "The finding already has that grade.",
|
|
11193
|
-
stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
|
|
11194
|
-
outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
|
|
11195
|
-
"no-reason": "A suggestion needs a reason.",
|
|
11196
|
-
unknown: "This review has no suggestion with that id.",
|
|
11197
|
-
decided: "That suggestion has already been answered.",
|
|
11198
|
-
"no-finding": "This review has no such finding."
|
|
11199
|
-
};
|
|
11200
|
-
function gradeFragments$1(session, kind, id) {
|
|
11201
|
-
const finding = findingIn(session, kind, id);
|
|
11202
|
-
if (!finding) return null;
|
|
11203
|
-
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
11204
|
-
const options = {
|
|
11205
|
-
editable: true,
|
|
11206
|
-
askMore: true
|
|
11207
|
-
};
|
|
11208
|
-
return {
|
|
11209
|
-
kind,
|
|
11210
|
-
id,
|
|
11211
|
-
current: assessment.current,
|
|
11212
|
-
cardClass: cardGradeClass(kind, assessment.current),
|
|
11213
|
-
grade: renderGradeControl(kind, assessment, options),
|
|
11214
|
-
region: renderAssessmentRegion(kind, assessment, options),
|
|
11215
|
-
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
11216
|
-
};
|
|
11217
|
-
}
|
|
11218
|
-
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
11219
|
-
function findingOfRequest(session, request) {
|
|
11220
|
-
if (request.op === "set") return {
|
|
11221
|
-
kind: request.kind,
|
|
11222
|
-
id: request.findingId
|
|
11223
|
-
};
|
|
11224
|
-
const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
|
|
11225
|
-
return proposal ? {
|
|
11226
|
-
kind: proposal.kind,
|
|
11227
|
-
id: proposal.findingId
|
|
11228
|
-
} : null;
|
|
11229
|
-
}
|
|
11230
|
-
/** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
|
|
11231
|
-
function refusedReply(session, request, refusal) {
|
|
11232
|
-
const finding = findingOfRequest(session, request);
|
|
11233
|
-
const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
|
|
11234
|
-
return {
|
|
11235
|
-
status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
|
|
11236
|
-
body: {
|
|
11237
|
-
error: REASSESSMENT_REFUSED[refusal],
|
|
11238
|
-
refusal,
|
|
11239
|
-
...card ? { card } : {}
|
|
11240
|
-
}
|
|
11241
|
-
};
|
|
11242
|
-
}
|
|
11243
|
-
//#endregion
|
|
11244
|
-
//#region ../../packages/review-harness/src/workspace/reassessment_state.ts
|
|
11245
|
-
/**
|
|
11246
|
-
* The reviewer's grade moves and their answers to proposals, as `/state` receives them.
|
|
11247
|
-
*
|
|
11248
|
-
* The request, its refusals and the card parts a reply carries are shared with the extension
|
|
11249
|
-
* (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
|
|
11250
|
-
* against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
|
|
11251
|
-
* the write, where the answer is final.
|
|
11252
|
-
*/
|
|
11253
|
-
/** A stored review's card parts for one finding. */
|
|
11254
|
-
function gradeFragments(workspace, kind, id) {
|
|
11255
|
-
return gradeFragments$1(workspace.session, kind, id);
|
|
11256
|
-
}
|
|
11257
|
-
/**
|
|
11258
|
-
* One write, onto the review as it is now, answered with the card as it now stands. `now` is
|
|
11259
|
-
* injectable so a test can name the time an entry records.
|
|
11260
|
-
*/
|
|
11261
|
-
function applyReassessmentRequest(workspaceId, value, now = Date.now) {
|
|
11262
|
-
const request = parseReassessmentRequest(value);
|
|
11263
|
-
if (!request) return {
|
|
11264
|
-
status: 400,
|
|
11265
|
-
body: { error: BAD_REASSESSMENT_REQUEST }
|
|
11266
|
-
};
|
|
11267
|
-
const before = loadWorkspace(workspaceId);
|
|
11268
|
-
if (!before) return {
|
|
11269
|
-
status: 404,
|
|
11270
|
-
body: { error: "No such review" }
|
|
11271
|
-
};
|
|
11272
|
-
const early = reassessIn(structuredClone(before.session), request, now());
|
|
11273
|
-
if (!early.ok) return refusedReply(before.session, request, early.refusal);
|
|
11274
|
-
let outcome = null;
|
|
11275
|
-
const saved = mutateWorkspace(workspaceId, (current) => {
|
|
11276
|
-
outcome = reassessIn(current.session, request, now());
|
|
11277
|
-
});
|
|
11278
|
-
if (!saved || !outcome) return {
|
|
11279
|
-
status: 404,
|
|
11280
|
-
body: { error: "No such review" }
|
|
11281
|
-
};
|
|
11282
|
-
const written = outcome;
|
|
11283
|
-
if (!written.ok) return refusedReply(saved.session, request, written.refusal);
|
|
11284
|
-
const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
|
|
11285
|
-
return {
|
|
11286
|
-
status: 200,
|
|
11287
|
-
body: {
|
|
11288
|
-
ok: true,
|
|
11289
|
-
entry: written.entry,
|
|
11290
|
-
...card ? { card } : {}
|
|
11291
|
-
}
|
|
11292
|
-
};
|
|
11293
|
-
}
|
|
11294
|
-
//#endregion
|
|
11295
|
-
//#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
|
|
11296
|
-
/** More than any real answer cites; a list longer than this is padding, not evidence. */
|
|
11297
|
-
var MAX_EVIDENCE = 10;
|
|
11298
|
-
function scaleText(kind) {
|
|
11299
|
-
return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
|
|
11300
|
-
}
|
|
11301
|
-
/** What the grade means, in the words the analysis prompt uses for it. */
|
|
11302
|
-
function gradeMeaning(kind) {
|
|
11303
|
-
return kind === "issue" ? "Severity is how much the issue would matter if it is real. It is not how sure anyone is that it is real." : "Importance is how much of the reviewer's attention the question deserves before they can approve.";
|
|
11304
|
-
}
|
|
11305
|
-
function findingText(kind, finding) {
|
|
11306
|
-
if (kind === "issue") {
|
|
11307
|
-
const issue = finding;
|
|
11308
|
-
const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
|
|
11309
|
-
return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
|
|
11310
|
-
}
|
|
11311
|
-
const question = finding;
|
|
11312
|
-
const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
|
|
11313
|
-
return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
|
|
11314
|
-
}
|
|
11315
|
-
function historyText(assessment) {
|
|
11316
|
-
if (!assessment.history.length) return "It has not been reassessed before.";
|
|
11317
|
-
return ["Earlier moves of this grade, oldest first:", ...assessment.history.map((step) => `- ${step.from ?? "ungraded"} → ${step.to}, ${sourcePhrase(step.source)}${step.reason ? `: ${step.reason}` : ""}`)].join("\n");
|
|
11318
|
-
}
|
|
11319
|
-
function threadText(thread) {
|
|
11320
|
-
if (!thread) return [];
|
|
11321
|
-
const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
|
|
11322
|
-
if (!comments.length) return [];
|
|
11323
|
-
return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
|
|
11324
|
-
}
|
|
11325
|
-
/** The block a Reassess turn adds to the ordinary Buddy prompt. */
|
|
11326
|
-
function buildReassessInstructions(input) {
|
|
11327
|
-
const { kind, finding, assessment } = input;
|
|
11328
|
-
const noun = kind === "issue" ? "severity" : "importance";
|
|
11329
|
-
const current = assessment.current;
|
|
11330
|
-
return [
|
|
11331
|
-
"---",
|
|
11332
|
-
`THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
|
|
11333
|
-
findingText(kind, finding),
|
|
11334
|
-
`Its ${noun} now: ${promptGrade(assessment)}.`,
|
|
11335
|
-
historyText(assessment),
|
|
11336
|
-
...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
|
|
11337
|
-
...threadText(input.thread),
|
|
11338
|
-
"---",
|
|
11339
|
-
`Judge whether its ${noun} should change, using the code in the checkout and everything above, including this conversation. ${gradeMeaning(kind)} Read the code that decides it rather than reasoning from the finding's wording.`,
|
|
11340
|
-
`Raising it is as legitimate as lowering it. "keep" is a good answer, and the right one whenever the evidence does not clearly move it; do not change it because you were asked.`,
|
|
11341
|
-
"Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
|
|
11342
|
-
`{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
|
|
11343
|
-
`"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
|
|
11344
|
-
"\"reason\" is one to three sentences the reviewer will read on the card: name what in the code or the conversation moves it, or what leaves it where it is.",
|
|
11345
|
-
"\"evidence\" is the places in the code that settle it, as paths relative to the repository root. Omit it rather than citing a file you did not read."
|
|
11346
|
-
];
|
|
11347
|
-
}
|
|
11348
|
-
var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
|
|
11349
|
-
function readEvidence(value) {
|
|
11350
|
-
if (!Array.isArray(value)) return [];
|
|
11351
|
-
const out = [];
|
|
11352
|
-
for (const item of value) {
|
|
11353
|
-
const entry = item;
|
|
11354
|
-
const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
|
|
11355
|
-
if (!file) continue;
|
|
11356
|
-
const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
|
|
11357
|
-
out.push(line === void 0 ? { file } : {
|
|
11358
|
-
file,
|
|
11359
|
-
line
|
|
11360
|
-
});
|
|
11361
|
-
if (out.length === MAX_EVIDENCE) break;
|
|
11362
|
-
}
|
|
11363
|
-
return out;
|
|
11364
|
-
}
|
|
11365
|
-
/**
|
|
11366
|
-
* The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
|
|
11367
|
-
* grade on this finding's scale that differs from the current one, and every answer must give a
|
|
11368
|
-
* reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
|
|
11369
|
-
* use it, and nothing is proposed.
|
|
11370
|
-
*/
|
|
11371
|
-
function parseReassessAnswer(raw, kind, current) {
|
|
11372
|
-
let parsed;
|
|
11373
|
-
try {
|
|
11374
|
-
parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
|
|
11375
|
-
} catch {
|
|
11376
|
-
return null;
|
|
11377
|
-
}
|
|
11378
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
11379
|
-
const obj = parsed;
|
|
11380
|
-
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
11381
|
-
if (!reason) return null;
|
|
11382
|
-
const evidence = readEvidence(obj.evidence);
|
|
11383
|
-
if (obj.verdict === "keep") return {
|
|
11384
|
-
verdict: "keep",
|
|
11385
|
-
reason,
|
|
11386
|
-
evidence
|
|
11387
|
-
};
|
|
11388
|
-
if (obj.verdict !== "change") return null;
|
|
11389
|
-
if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
|
|
11390
|
-
return {
|
|
11391
|
-
verdict: "change",
|
|
11392
|
-
to: obj.to,
|
|
11393
|
-
reason,
|
|
11394
|
-
evidence
|
|
11395
|
-
};
|
|
11396
|
-
}
|
|
11397
|
-
//#endregion
|
|
11398
|
-
//#region ../../packages/review-harness/src/workspace/reassess_turn.ts
|
|
11399
|
-
/**
|
|
11400
|
-
* The answer, with every cited place checked against the commit the review describes. A path that
|
|
11401
|
-
* is not there is dropped and the suggestion kept: the judgement may stand even when the model
|
|
11402
|
-
* misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
|
|
11403
|
-
* itself fails, no evidence is kept rather than unchecked evidence.
|
|
11404
|
-
*
|
|
11405
|
-
* Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
|
|
11406
|
-
* no verified code evidence rather than drawing it like one whose citations held. Acceptance does
|
|
11407
|
-
* not depend on it; a reason may be sound without a citation.
|
|
11408
|
-
*/
|
|
11409
|
-
async function readReassessTurn(raw, target, where, check = existingPaths) {
|
|
11410
|
-
const answer = parseReassessAnswer(raw, target.kind, target.from);
|
|
11411
|
-
if (!answer || !answer.evidence.length) return {
|
|
11412
|
-
answer,
|
|
11413
|
-
raw,
|
|
11414
|
-
droppedEvidence: 0
|
|
11415
|
-
};
|
|
11416
|
-
let real;
|
|
11417
|
-
try {
|
|
11418
|
-
real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
|
|
11419
|
-
} catch {
|
|
11420
|
-
real = /* @__PURE__ */ new Set();
|
|
11421
|
-
}
|
|
11422
|
-
const evidence = answer.evidence.filter((place) => real.has(place.file));
|
|
11423
|
-
return {
|
|
11424
|
-
answer: {
|
|
11425
|
-
...answer,
|
|
11426
|
-
evidence
|
|
11427
|
-
},
|
|
11428
|
-
raw,
|
|
11429
|
-
droppedEvidence: answer.evidence.length - evidence.length
|
|
11430
|
-
};
|
|
11431
|
-
}
|
|
11432
|
-
/**
|
|
11433
|
-
* Writes the suggestion onto the session, when there is one to write. Run inside the store write on
|
|
11434
|
-
* the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
|
|
11435
|
-
* while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
|
|
11436
|
-
* grade it never saw.
|
|
11437
|
-
*/
|
|
11438
|
-
function keepReassessTurn(session, target, turn, askedAt) {
|
|
11439
|
-
if (!turn.answer) return "unreadable";
|
|
11440
|
-
if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
|
|
11441
|
-
return reassessIn(session, {
|
|
11442
|
-
op: "propose",
|
|
11443
|
-
kind: target.kind,
|
|
11444
|
-
findingId: target.id,
|
|
11445
|
-
from: target.from,
|
|
11446
|
-
to: turn.answer.to,
|
|
11447
|
-
reason: turn.answer.reason,
|
|
11448
|
-
...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
|
|
11449
|
-
...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
|
|
11450
|
-
source: {
|
|
11451
|
-
kind: "buddy",
|
|
11452
|
-
askedAt
|
|
11453
|
-
}
|
|
11454
|
-
}, askedAt).ok ? "proposed" : "stale";
|
|
11455
|
-
}
|
|
11456
|
-
/** What the conversation shows for the turn, in words, never the JSON. */
|
|
11457
|
-
function reassessTurnText(target, turn, outcome) {
|
|
11458
|
-
const from = target.from ?? "ungraded";
|
|
11459
|
-
const answer = turn.answer;
|
|
11460
|
-
if (!answer) return `${turn.raw.trim()}\n\n*This answer was not in the form a suggestion needs, so nothing was suggested and the grade is unchanged.*`;
|
|
11461
|
-
if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
|
|
11462
|
-
const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
|
|
11463
|
-
return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
|
|
11464
|
-
}
|
|
11465
|
-
//#endregion
|
|
11466
|
-
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11467
|
-
/**
|
|
11468
|
-
* The local Git working folders PR Review Buddy can find right now.
|
|
11469
|
-
*
|
|
11470
|
-
* One question and nothing more: which folders on this machine could a review run in. The answer is
|
|
11471
|
-
* derived each time from what is already known, the folders past reviews ran in and the other
|
|
11472
|
-
* worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
|
|
11473
|
-
* up because `git worktree list` names it, not because anything remembered it.
|
|
11474
|
-
*
|
|
11475
|
-
* A folder is a folder. A linked worktree is its own entry with its own path and is never folded
|
|
11476
|
-
* into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
|
|
11477
|
-
* where the branch is checked out.
|
|
11478
|
-
*
|
|
11479
|
-
* Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
|
|
11480
|
-
*/
|
|
11481
|
-
function canonical(path) {
|
|
11482
|
-
try {
|
|
11483
|
-
return realpathSync(path);
|
|
11484
|
-
} catch {
|
|
11485
|
-
return path;
|
|
11486
|
-
}
|
|
11487
|
-
}
|
|
11488
|
-
/** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
|
|
11489
|
-
async function worktreesOf(repoPath) {
|
|
11490
|
-
let output;
|
|
11491
|
-
try {
|
|
11492
|
-
output = await git(repoPath, [
|
|
11493
|
-
"worktree",
|
|
11494
|
-
"list",
|
|
11495
|
-
"--porcelain"
|
|
11496
|
-
]);
|
|
11497
|
-
} catch {
|
|
11498
|
-
return [];
|
|
11499
|
-
}
|
|
11500
|
-
const entries = [];
|
|
11501
|
-
for (const block of output.split(/\n\s*\n/)) {
|
|
11502
|
-
const lines = block.split("\n");
|
|
11503
|
-
const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
|
|
11504
|
-
if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
|
|
11505
|
-
const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
|
|
11506
|
-
entries.push({
|
|
11507
|
-
path,
|
|
11508
|
-
branch: ref ? ref.replace(/^refs\/heads\//, "") : null
|
|
11509
|
-
});
|
|
11510
|
-
}
|
|
11511
|
-
return entries;
|
|
11512
|
-
}
|
|
11513
|
-
async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
|
|
11514
|
-
const lastReviewed = /* @__PURE__ */ new Map();
|
|
11515
|
-
for (const summary of summaries) {
|
|
11516
|
-
if (!summary.repoPath) continue;
|
|
11517
|
-
const key = canonical(summary.repoPath);
|
|
11518
|
-
lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
|
|
11519
|
-
}
|
|
11520
|
-
const found = /* @__PURE__ */ new Map();
|
|
11521
|
-
for (const seed of lastReviewed.keys()) {
|
|
11522
|
-
if (found.has(seed) || !existsSync(seed)) continue;
|
|
11523
|
-
(await worktreesOf(seed)).forEach((entry, index) => {
|
|
11524
|
-
const key = canonical(entry.path);
|
|
11525
|
-
if (!found.has(key) && existsSync(key)) found.set(key, {
|
|
11526
|
-
entry: {
|
|
11527
|
-
...entry,
|
|
11528
|
-
path: key
|
|
11529
|
-
},
|
|
11530
|
-
main: index === 0
|
|
11531
|
-
});
|
|
11532
|
-
});
|
|
11533
|
-
}
|
|
11534
|
-
return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
|
|
11535
|
-
path: entry.path,
|
|
11536
|
-
remote: await originRemote(entry.path),
|
|
11537
|
-
branch: entry.branch,
|
|
11538
|
-
main,
|
|
11539
|
-
lastReviewedAt: lastReviewed.get(entry.path) ?? null
|
|
11540
|
-
})))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
|
|
11541
|
-
}
|
|
11542
|
-
/** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
|
|
11543
|
-
function isGitHubRepository(remote, owner, repo) {
|
|
11544
|
-
return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
|
|
11545
|
-
}
|
|
11546
|
-
/**
|
|
11547
|
-
* The folders a pull request could be reviewed in, the suggested one first.
|
|
11548
|
-
*
|
|
11549
|
-
* Every match is returned, because which one is right is the reviewer's call: an agent's worktree
|
|
11550
|
-
* and the main clone hold the same objects and differ in what is checked out. The order only
|
|
11551
|
-
* decides the default. A folder that already has a review of this pull request comes first, so
|
|
11552
|
-
* reviewing it again finds that review rather than starting a second one filed under another
|
|
11553
|
-
* folder; then the main worktree; then whichever was reviewed in most recently.
|
|
11554
|
-
*/
|
|
11555
|
-
function candidatesFor(folders, link, summaries) {
|
|
11556
|
-
const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
|
|
11557
|
-
const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
|
|
11558
|
-
const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
|
|
11559
|
-
return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
|
|
11560
|
-
}
|
|
11561
|
-
/** The review of this pull request already filed under one of these folders, if there is one. */
|
|
11562
|
-
function reviewedIn(folders, link, summaries) {
|
|
11563
|
-
const paths = new Set(folders.map((folder) => folder.path));
|
|
11564
|
-
return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
|
|
11313
|
+
/** The review of this pull request already filed under one of these folders, if there is one. */
|
|
11314
|
+
function reviewedIn(folders, link, summaries) {
|
|
11315
|
+
const paths = new Set(folders.map((folder) => folder.path));
|
|
11316
|
+
return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
|
|
11565
11317
|
}
|
|
11566
11318
|
//#endregion
|
|
11567
11319
|
//#region ../../packages/review-harness/src/workspace/choose_folder.ts
|
|
@@ -11714,625 +11466,1145 @@ function baseChoiceOf(error) {
|
|
|
11714
11466
|
};
|
|
11715
11467
|
}
|
|
11716
11468
|
/**
|
|
11717
|
-
* What the panel says for an error a review could not get past.
|
|
11718
|
-
*
|
|
11719
|
-
* Two errors are known by their class and said in the panel's own words. Anything else keeps its
|
|
11720
|
-
* message, which is already written for a person, but only its first paragraph is the sentence:
|
|
11721
|
-
* the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
|
|
11722
|
-
* needs to diagnose it.
|
|
11469
|
+
* What the panel says for an error a review could not get past.
|
|
11470
|
+
*
|
|
11471
|
+
* Two errors are known by their class and said in the panel's own words. Anything else keeps its
|
|
11472
|
+
* message, which is already written for a person, but only its first paragraph is the sentence:
|
|
11473
|
+
* the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
|
|
11474
|
+
* needs to diagnose it.
|
|
11475
|
+
*/
|
|
11476
|
+
function refusalOf(error) {
|
|
11477
|
+
const text = message(error);
|
|
11478
|
+
if (error instanceof AmbiguousRefError) {
|
|
11479
|
+
const choice = baseChoiceOf(error);
|
|
11480
|
+
return {
|
|
11481
|
+
error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
|
|
11482
|
+
detail: text,
|
|
11483
|
+
baseChoice: choice
|
|
11484
|
+
};
|
|
11485
|
+
}
|
|
11486
|
+
if (error instanceof DetachedHeadError) return { error: "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." };
|
|
11487
|
+
return briefly(text);
|
|
11488
|
+
}
|
|
11489
|
+
/** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
|
|
11490
|
+
function briefly(text) {
|
|
11491
|
+
const first = text.split(/\n\s*\n/)[0].trim();
|
|
11492
|
+
return first && first !== text.trim() ? {
|
|
11493
|
+
error: first,
|
|
11494
|
+
detail: text
|
|
11495
|
+
} : { error: text };
|
|
11496
|
+
}
|
|
11497
|
+
/** A path as a person reads it, with their home folder as `~`. */
|
|
11498
|
+
function displayPath(path, home) {
|
|
11499
|
+
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
11500
|
+
}
|
|
11501
|
+
/** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
|
|
11502
|
+
function expandPath(raw, home) {
|
|
11503
|
+
const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
|
|
11504
|
+
if (!trimmed) return null;
|
|
11505
|
+
const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
|
|
11506
|
+
return isAbsolute(expanded) ? expanded : null;
|
|
11507
|
+
}
|
|
11508
|
+
function message(error) {
|
|
11509
|
+
return error instanceof Error ? error.message : String(error);
|
|
11510
|
+
}
|
|
11511
|
+
/** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
|
|
11512
|
+
function unresolvedAgent(resolution) {
|
|
11513
|
+
switch (resolution.kind) {
|
|
11514
|
+
case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
|
|
11515
|
+
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.`;
|
|
11516
|
+
case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
|
|
11517
|
+
}
|
|
11518
|
+
}
|
|
11519
|
+
/**
|
|
11520
|
+
* The agent a submission runs under: the one named under Review options, or else the same answer
|
|
11521
|
+
* the CLI reaches with no flag (the configured default, or the only one installed). Never the
|
|
11522
|
+
* harness's own fallback: an id is always passed down, so `startJob` never has to guess.
|
|
11523
|
+
*/
|
|
11524
|
+
async function agentFor(flag, deps) {
|
|
11525
|
+
if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
|
|
11526
|
+
const resolution = await deps.resolveAgent(flag || void 0);
|
|
11527
|
+
if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
|
|
11528
|
+
if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
|
|
11529
|
+
return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
|
|
11530
|
+
}
|
|
11531
|
+
function modelOf(value) {
|
|
11532
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
11533
|
+
if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
|
|
11534
|
+
return value.trim() || void 0;
|
|
11535
|
+
}
|
|
11536
|
+
/**
|
|
11537
|
+
* The tail of each target's queue of creations, by batch identity. In-process, like the fetch
|
|
11538
|
+
* queue in `target.ts`.
|
|
11539
|
+
*
|
|
11540
|
+
* `prepareReview` looks for an existing review and then creates one, and nothing between the two
|
|
11541
|
+
* stops a second start of the same target, from another tab or another paste, from looking in the
|
|
11542
|
+
* same gap and creating a second review of it. One at a time per target closes that gap for every
|
|
11543
|
+
* start this server makes; the second one finds the first one's review.
|
|
11544
|
+
*/
|
|
11545
|
+
var creations = /* @__PURE__ */ new Map();
|
|
11546
|
+
function oneCreationAtATime(target, work) {
|
|
11547
|
+
const key = batchIdentity(target);
|
|
11548
|
+
const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
|
|
11549
|
+
const tail = mine.catch(() => {});
|
|
11550
|
+
creations.set(key, tail);
|
|
11551
|
+
tail.then(() => {
|
|
11552
|
+
if (creations.get(key) === tail) creations.delete(key);
|
|
11553
|
+
});
|
|
11554
|
+
return mine;
|
|
11555
|
+
}
|
|
11556
|
+
var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
|
|
11557
|
+
async function addReviewOptions(deps) {
|
|
11558
|
+
const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
|
|
11559
|
+
return {
|
|
11560
|
+
status: 200,
|
|
11561
|
+
body: {
|
|
11562
|
+
agents,
|
|
11563
|
+
agent: resolution.kind === "resolved" ? {
|
|
11564
|
+
kind: "resolved",
|
|
11565
|
+
id: resolution.agent.id,
|
|
11566
|
+
displayName: resolution.agent.displayName
|
|
11567
|
+
} : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
|
|
11568
|
+
kind: "refused",
|
|
11569
|
+
error: unresolvedAgent(resolution)
|
|
11570
|
+
},
|
|
11571
|
+
canChooseFolder: deps.chooseFolder !== null
|
|
11572
|
+
}
|
|
11573
|
+
};
|
|
11574
|
+
}
|
|
11575
|
+
function reviewedRow(reviewId, deps) {
|
|
11576
|
+
const kept = deps.resumeOf(reviewId);
|
|
11577
|
+
return {
|
|
11578
|
+
reviewId,
|
|
11579
|
+
...kept ? { kept } : {}
|
|
11580
|
+
};
|
|
11581
|
+
}
|
|
11582
|
+
function folderOption(folder, home) {
|
|
11583
|
+
return {
|
|
11584
|
+
path: folder.path,
|
|
11585
|
+
display: displayPath(folder.path, home),
|
|
11586
|
+
branch: folder.branch
|
|
11587
|
+
};
|
|
11588
|
+
}
|
|
11589
|
+
/**
|
|
11590
|
+
* What a paste would start, without starting anything: rows, the folders each could run in, and
|
|
11591
|
+
* which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
|
|
11592
|
+
* run on every pause in typing.
|
|
11593
|
+
*
|
|
11594
|
+
* Every link gets a row, however many there are. Past the batch limit the panel refuses to start
|
|
11595
|
+
* rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
|
|
11596
|
+
* got twenty reviews would not find out which one was missing.
|
|
11597
|
+
*/
|
|
11598
|
+
async function previewPullRequests(text, deps) {
|
|
11599
|
+
if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
|
|
11600
|
+
const links = extractPullRequestLinks(text);
|
|
11601
|
+
const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
|
|
11602
|
+
return {
|
|
11603
|
+
status: 200,
|
|
11604
|
+
body: {
|
|
11605
|
+
rows: links.map((link) => {
|
|
11606
|
+
if (link.kind === "unsupported") return {
|
|
11607
|
+
key: link.key,
|
|
11608
|
+
label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
|
|
11609
|
+
url: link.url,
|
|
11610
|
+
ok: false,
|
|
11611
|
+
reason: unsupportedReason(link.provider),
|
|
11612
|
+
folders: []
|
|
11613
|
+
};
|
|
11614
|
+
const label = `${link.owner}/${link.repo} #${link.number}`;
|
|
11615
|
+
const candidates = candidatesFor(folders, link, summaries);
|
|
11616
|
+
if (candidates.length === 0) return {
|
|
11617
|
+
key: link.key,
|
|
11618
|
+
label,
|
|
11619
|
+
url: link.url,
|
|
11620
|
+
ok: false,
|
|
11621
|
+
reason: "Local repository not found",
|
|
11622
|
+
needsFolder: true,
|
|
11623
|
+
folders: []
|
|
11624
|
+
};
|
|
11625
|
+
const reviewed = reviewedIn(candidates, link, summaries);
|
|
11626
|
+
return {
|
|
11627
|
+
key: link.key,
|
|
11628
|
+
label,
|
|
11629
|
+
url: link.url,
|
|
11630
|
+
ok: true,
|
|
11631
|
+
folders: candidates.map((folder) => folderOption(folder, deps.home)),
|
|
11632
|
+
folder: candidates[0].path,
|
|
11633
|
+
...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
|
|
11634
|
+
};
|
|
11635
|
+
}),
|
|
11636
|
+
limit: MAX_BATCH
|
|
11637
|
+
}
|
|
11638
|
+
};
|
|
11639
|
+
}
|
|
11640
|
+
var batches = /* @__PURE__ */ new Map();
|
|
11641
|
+
var facts = /* @__PURE__ */ new WeakMap();
|
|
11642
|
+
var submitted = 0;
|
|
11643
|
+
function noteRow(row, known) {
|
|
11644
|
+
submitted += 1;
|
|
11645
|
+
facts.set(row, {
|
|
11646
|
+
order: submitted,
|
|
11647
|
+
...known
|
|
11648
|
+
});
|
|
11649
|
+
}
|
|
11650
|
+
/** The row has its slot and is under way. When it took it is kept from the first time only. */
|
|
11651
|
+
function begin(row) {
|
|
11652
|
+
const known = facts.get(row);
|
|
11653
|
+
if (known && row.status !== "reviewing") known.takenAt = Date.now();
|
|
11654
|
+
row.status = "reviewing";
|
|
11655
|
+
}
|
|
11656
|
+
/**
|
|
11657
|
+
* Every row still to start or under way, for the Reviews page's In progress section. Rows that
|
|
11658
|
+
* ended (ready, failed, already reviewed) are not here: what they made is in the list, and how they
|
|
11659
|
+
* failed is in the panel and on the review.
|
|
11660
|
+
*
|
|
11661
|
+
* This server's rows only, from memory: see `liveOperations` for why the queue is process-local.
|
|
11662
|
+
*/
|
|
11663
|
+
function pendingBatchRows() {
|
|
11664
|
+
const pending = [];
|
|
11665
|
+
for (const batch of batches.values()) batch.rows.forEach((row, index) => {
|
|
11666
|
+
if (row.status !== "waiting" && row.status !== "reviewing") return;
|
|
11667
|
+
const known = facts.get(row);
|
|
11668
|
+
pending.push({
|
|
11669
|
+
key: `${batch.id}:${index}`,
|
|
11670
|
+
status: row.status,
|
|
11671
|
+
label: row.label,
|
|
11672
|
+
order: known?.order ?? 0,
|
|
11673
|
+
...known?.takenAt === void 0 ? {} : { takenAt: known.takenAt },
|
|
11674
|
+
...row.reviewId ? { reviewId: row.reviewId } : {},
|
|
11675
|
+
...known?.repoPath ? { repoPath: known.repoPath } : {},
|
|
11676
|
+
...known?.prNumber === void 0 ? {} : { prNumber: known.prNumber },
|
|
11677
|
+
...known?.branch ? { branch: known.branch } : {}
|
|
11678
|
+
});
|
|
11679
|
+
});
|
|
11680
|
+
return pending;
|
|
11681
|
+
}
|
|
11682
|
+
function forgetFinished(now) {
|
|
11683
|
+
for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
|
|
11684
|
+
}
|
|
11685
|
+
function newBatch(rows) {
|
|
11686
|
+
forgetFinished(Date.now());
|
|
11687
|
+
const now = Date.now();
|
|
11688
|
+
const batch = {
|
|
11689
|
+
id: randomUUID(),
|
|
11690
|
+
rows,
|
|
11691
|
+
finishedAt: now,
|
|
11692
|
+
seenAt: now,
|
|
11693
|
+
running: 0,
|
|
11694
|
+
rerun: () => {}
|
|
11695
|
+
};
|
|
11696
|
+
batches.set(batch.id, batch);
|
|
11697
|
+
return batch;
|
|
11698
|
+
}
|
|
11699
|
+
/**
|
|
11700
|
+
* Count work against a batch, inside the restart guard. The batch is finished only when nothing
|
|
11701
|
+
* counted against it is still going, so a retry reopens a batch an open panel can then follow again.
|
|
11702
|
+
*/
|
|
11703
|
+
function follow(batch, work, deps, driving = true) {
|
|
11704
|
+
batch.running += 1;
|
|
11705
|
+
batch.finishedAt = null;
|
|
11706
|
+
(driving ? deps.track(work) : work).catch(() => {}).finally(() => {
|
|
11707
|
+
batch.running -= 1;
|
|
11708
|
+
if (batch.running === 0) batch.finishedAt = Date.now();
|
|
11709
|
+
});
|
|
11710
|
+
}
|
|
11711
|
+
function fail(row, error) {
|
|
11712
|
+
const refusal = refusalOf(error);
|
|
11713
|
+
row.status = "failed";
|
|
11714
|
+
row.reason = refusal.error;
|
|
11715
|
+
if (refusal.detail) row.detail = refusal.detail;
|
|
11716
|
+
}
|
|
11717
|
+
var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
|
|
11718
|
+
/**
|
|
11719
|
+
* Create the review for a row's target, or find the one it has, under the per-target lock. The
|
|
11720
|
+
* caller holds an analysis slot: nothing is created without one, so nothing created waits.
|
|
11721
|
+
*/
|
|
11722
|
+
/**
|
|
11723
|
+
* `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
|
|
11724
|
+
* branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
|
|
11725
|
+
* the same branch or request, which for a fresh start is the very failure being started over.
|
|
11726
|
+
*/
|
|
11727
|
+
async function prepareRow(row, target, agentId, model, deps, fresh = false) {
|
|
11728
|
+
const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
|
|
11729
|
+
row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
|
|
11730
|
+
if (prepared.kind === "existing" && !prepared.existing.inProgress) row.status = "already-reviewed";
|
|
11731
|
+
else begin(row);
|
|
11732
|
+
if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
|
|
11733
|
+
else delete row.kept;
|
|
11734
|
+
return prepared;
|
|
11735
|
+
}
|
|
11736
|
+
/**
|
|
11737
|
+
* Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
|
|
11738
|
+
* something else is already analysing is watched instead, never driven: the watch is handed to the
|
|
11739
|
+
* batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
|
|
11740
|
+
* stays Analysing until that analysis ends. Counted against the batch, not against restarts.
|
|
11723
11741
|
*/
|
|
11724
|
-
function
|
|
11725
|
-
|
|
11726
|
-
if (
|
|
11727
|
-
const choice = baseChoiceOf(error);
|
|
11728
|
-
return {
|
|
11729
|
-
error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
|
|
11730
|
-
detail: text,
|
|
11731
|
-
baseChoice: choice
|
|
11732
|
-
};
|
|
11733
|
-
}
|
|
11734
|
-
if (error instanceof DetachedHeadError) return { error: "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." };
|
|
11735
|
-
return briefly(text);
|
|
11736
|
-
}
|
|
11737
|
-
/** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
|
|
11738
|
-
function briefly(text) {
|
|
11739
|
-
const first = text.split(/\n\s*\n/)[0].trim();
|
|
11740
|
-
return first && first !== text.trim() ? {
|
|
11741
|
-
error: first,
|
|
11742
|
-
detail: text
|
|
11743
|
-
} : { error: text };
|
|
11744
|
-
}
|
|
11745
|
-
/** A path as a person reads it, with their home folder as `~`. */
|
|
11746
|
-
function displayPath(path, home) {
|
|
11747
|
-
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
11742
|
+
async function completeRow(row, prepared, batch, deps) {
|
|
11743
|
+
if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
|
|
11744
|
+
if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
|
|
11748
11745
|
}
|
|
11749
|
-
|
|
11750
|
-
|
|
11751
|
-
|
|
11752
|
-
if (
|
|
11753
|
-
|
|
11754
|
-
|
|
11746
|
+
async function settleRow(row, settling) {
|
|
11747
|
+
const settled = await settling;
|
|
11748
|
+
if (settled.ok) row.status = "ready";
|
|
11749
|
+
else if (settled.stopped) {
|
|
11750
|
+
row.status = "failed";
|
|
11751
|
+
row.reason = "Stopped";
|
|
11752
|
+
} else fail(row, settled.reason);
|
|
11755
11753
|
}
|
|
11756
|
-
|
|
11757
|
-
|
|
11754
|
+
/**
|
|
11755
|
+
* A retry runs against the commit the row was first resolved to, never a newer one: a retry
|
|
11756
|
+
* resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
|
|
11757
|
+
* that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
|
|
11758
|
+
* says so and offers the fresh start instead of quietly reviewing something else.
|
|
11759
|
+
*/
|
|
11760
|
+
async function stillThere(row, target, what, deps) {
|
|
11761
|
+
if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
|
|
11762
|
+
row.status = "failed";
|
|
11763
|
+
row.reason = `The commit this review was of (${target.sha.slice(0, 7)}) is no longer in this repository, so it can't be retried as it was. Start a fresh review to analyse the ${what} as it is now.`;
|
|
11764
|
+
row.stale = true;
|
|
11765
|
+
return false;
|
|
11758
11766
|
}
|
|
11759
|
-
/**
|
|
11760
|
-
|
|
11761
|
-
|
|
11762
|
-
|
|
11763
|
-
|
|
11764
|
-
|
|
11767
|
+
/**
|
|
11768
|
+
* Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
|
|
11769
|
+
* finds the latest review of the branch or request: one made since, of newer commits, would be
|
|
11770
|
+
* handed back in place of the one that failed, and a retry would quietly become a different review.
|
|
11771
|
+
*
|
|
11772
|
+
* Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
|
|
11773
|
+
* something has taken it up since (that Retry, a terminal), and it is watched rather than run again.
|
|
11774
|
+
*/
|
|
11775
|
+
async function retryReview(row, batch, deps) {
|
|
11776
|
+
const reviewId = row.reviewId;
|
|
11777
|
+
const kept = deps.resumeOf(reviewId);
|
|
11778
|
+
begin(row);
|
|
11779
|
+
if (kept) {
|
|
11780
|
+
row.kept = kept;
|
|
11781
|
+
return settleRow(row, deps.settle(reviewId));
|
|
11765
11782
|
}
|
|
11783
|
+
follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
|
|
11766
11784
|
}
|
|
11767
11785
|
/**
|
|
11768
|
-
*
|
|
11769
|
-
* the
|
|
11770
|
-
*
|
|
11786
|
+
* A fresh start that ended before it made its review leaves the row exactly as it was: the old
|
|
11787
|
+
* target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
|
|
11788
|
+
* Retry finds the old commit gone again, rather than resuming the old review against it.
|
|
11771
11789
|
*/
|
|
11772
|
-
|
|
11773
|
-
if (
|
|
11774
|
-
const resolution = await deps.resolveAgent(flag || void 0);
|
|
11775
|
-
if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
|
|
11776
|
-
if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
|
|
11777
|
-
return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
|
|
11790
|
+
function keepFreshPending(row, replaced) {
|
|
11791
|
+
if (row.status === "failed" && !replaced) row.stale = true;
|
|
11778
11792
|
}
|
|
11779
|
-
function
|
|
11780
|
-
|
|
11781
|
-
|
|
11782
|
-
|
|
11793
|
+
function keptOf(agentId, model, deps) {
|
|
11794
|
+
return {
|
|
11795
|
+
agent: deps.agentName(agentId),
|
|
11796
|
+
...model ? { model } : {}
|
|
11797
|
+
};
|
|
11783
11798
|
}
|
|
11784
11799
|
/**
|
|
11785
|
-
*
|
|
11786
|
-
*
|
|
11800
|
+
* Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
|
|
11801
|
+
* dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
|
|
11787
11802
|
*
|
|
11788
|
-
*
|
|
11789
|
-
*
|
|
11790
|
-
* same gap and creating a second review of it. One at a time per target closes that gap for every
|
|
11791
|
-
* start this server makes; the second one finds the first one's review.
|
|
11803
|
+
* Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
|
|
11804
|
+
* other batch and every branch review, and is resolved and created only once it has one.
|
|
11792
11805
|
*/
|
|
11793
|
-
|
|
11794
|
-
|
|
11795
|
-
|
|
11796
|
-
|
|
11797
|
-
const
|
|
11798
|
-
|
|
11799
|
-
|
|
11800
|
-
|
|
11801
|
-
|
|
11802
|
-
|
|
11806
|
+
async function startPullRequestBatch(body, deps) {
|
|
11807
|
+
const raw = Array.isArray(body?.items) ? body.items : null;
|
|
11808
|
+
if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
|
|
11809
|
+
if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
|
|
11810
|
+
const items = [];
|
|
11811
|
+
for (const entry of raw) {
|
|
11812
|
+
const url = typeof entry?.url === "string" ? entry.url : "";
|
|
11813
|
+
const link = parsePullRequestLink(url);
|
|
11814
|
+
if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
|
|
11815
|
+
const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
|
|
11816
|
+
if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
|
|
11817
|
+
items.push({
|
|
11818
|
+
url: link.url,
|
|
11819
|
+
owner: link.owner,
|
|
11820
|
+
repo: link.repo,
|
|
11821
|
+
number: link.number,
|
|
11822
|
+
key: link.key,
|
|
11823
|
+
folder
|
|
11824
|
+
});
|
|
11825
|
+
}
|
|
11826
|
+
const model = modelOf(body?.model);
|
|
11827
|
+
if (isAnswer(model)) return model;
|
|
11828
|
+
const agent = await agentFor(body?.agent, deps);
|
|
11829
|
+
if (isAnswer(agent)) return agent;
|
|
11830
|
+
const batch = newBatch(items.map((item) => ({
|
|
11831
|
+
key: item.key,
|
|
11832
|
+
label: `${item.owner}/${item.repo} #${item.number}`,
|
|
11833
|
+
status: "waiting"
|
|
11834
|
+
})));
|
|
11835
|
+
batch.rows.forEach((row, index) => noteRow(row, {
|
|
11836
|
+
repoPath: items[index].folder,
|
|
11837
|
+
prNumber: items[index].number
|
|
11838
|
+
}));
|
|
11839
|
+
/** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
|
|
11840
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
11841
|
+
/** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
|
|
11842
|
+
const resolved = [];
|
|
11843
|
+
const lane = async (item, index, fresh = false) => {
|
|
11844
|
+
const row = batch.rows[index];
|
|
11845
|
+
const release = await deps.slots.acquire();
|
|
11846
|
+
begin(row);
|
|
11847
|
+
/** A fresh start has made its review, so the row now belongs to the new target. */
|
|
11848
|
+
let replaced = false;
|
|
11849
|
+
try {
|
|
11850
|
+
let target = fresh ? void 0 : resolved[index];
|
|
11851
|
+
if (target) {
|
|
11852
|
+
if (!await stillThere(row, target, "pull request", deps)) return;
|
|
11853
|
+
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
11854
|
+
} else {
|
|
11855
|
+
const remote = await deps.remoteOf(item.folder);
|
|
11856
|
+
if (!isGitHubRepository(remote, item.owner, item.repo)) {
|
|
11857
|
+
row.status = "failed";
|
|
11858
|
+
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}`;
|
|
11859
|
+
return;
|
|
11860
|
+
}
|
|
11861
|
+
target = await deps.resolvePullRequest(item.folder, item.url);
|
|
11862
|
+
if (!fresh) resolved[index] = target;
|
|
11863
|
+
}
|
|
11864
|
+
const identity = batchIdentity(target);
|
|
11865
|
+
const first = claimed.get(identity);
|
|
11866
|
+
if (first !== void 0 && first !== index) {
|
|
11867
|
+
row.status = "already-reviewed";
|
|
11868
|
+
row.reason = `Same pull request as ${batch.rows[first].label}`;
|
|
11869
|
+
return;
|
|
11870
|
+
}
|
|
11871
|
+
claimed.set(identity, index);
|
|
11872
|
+
const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
|
|
11873
|
+
if (fresh) {
|
|
11874
|
+
resolved[index] = target;
|
|
11875
|
+
replaced = true;
|
|
11876
|
+
}
|
|
11877
|
+
await completeRow(row, prepared, batch, deps);
|
|
11878
|
+
} catch (error) {
|
|
11879
|
+
fail(row, error);
|
|
11880
|
+
} finally {
|
|
11881
|
+
if (fresh) keepFreshPending(row, replaced);
|
|
11882
|
+
release();
|
|
11883
|
+
}
|
|
11884
|
+
};
|
|
11885
|
+
batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
|
|
11886
|
+
follow(batch, runBatch(items, {
|
|
11887
|
+
concurrency: deps.concurrency,
|
|
11888
|
+
run: lane
|
|
11889
|
+
}), deps);
|
|
11890
|
+
return {
|
|
11891
|
+
status: 200,
|
|
11892
|
+
body: {
|
|
11893
|
+
batchId: batch.id,
|
|
11894
|
+
rows: rowsOf(batch)
|
|
11895
|
+
}
|
|
11896
|
+
};
|
|
11803
11897
|
}
|
|
11804
|
-
|
|
11805
|
-
|
|
11806
|
-
|
|
11898
|
+
/**
|
|
11899
|
+
* A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
|
|
11900
|
+
* about is never forgotten, so its Retry stays good for as long as the panel is open.
|
|
11901
|
+
*/
|
|
11902
|
+
function batchRows(id) {
|
|
11903
|
+
const batch = batches.get(id);
|
|
11904
|
+
if (!batch) return lostBatch();
|
|
11905
|
+
batch.seenAt = Date.now();
|
|
11906
|
+
return {
|
|
11907
|
+
status: 200,
|
|
11908
|
+
body: {
|
|
11909
|
+
rows: rowsOf(batch),
|
|
11910
|
+
finished: batch.finishedAt !== null
|
|
11911
|
+
}
|
|
11912
|
+
};
|
|
11913
|
+
}
|
|
11914
|
+
/**
|
|
11915
|
+
* Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
|
|
11916
|
+
* a while is forgotten too; the server cannot tell which happened, so it says what follows from
|
|
11917
|
+
* either.
|
|
11918
|
+
*/
|
|
11919
|
+
var lostBatch = () => refuse(404, "This batch is no longer on the workspace server, so it can’t be followed or retried here. Reviews that had started are in your reviews list. Any that were still queued did not start.");
|
|
11920
|
+
/**
|
|
11921
|
+
* Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
|
|
11922
|
+
* and base, and the same commits, never the branch or pull request as it has since moved on. A
|
|
11923
|
+
* review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
|
|
11924
|
+
* one exception, sent only when those commits have gone: it looks again at what is there now.
|
|
11925
|
+
* The other rows are left exactly as they are, and the batch is followed again until it ends.
|
|
11926
|
+
*/
|
|
11927
|
+
function retryBatchRow(id, key, fresh = false) {
|
|
11928
|
+
const batch = batches.get(id);
|
|
11929
|
+
if (!batch) return lostBatch();
|
|
11930
|
+
const index = batch.rows.findIndex((row) => row.key === key);
|
|
11931
|
+
const row = batch.rows[index];
|
|
11932
|
+
if (!row) return refuse(404, "That review is not part of this batch.");
|
|
11933
|
+
if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
|
|
11934
|
+
row.status = "waiting";
|
|
11935
|
+
const known = facts.get(row);
|
|
11936
|
+
if (known) delete known.takenAt;
|
|
11937
|
+
delete row.reason;
|
|
11938
|
+
delete row.detail;
|
|
11939
|
+
delete row.stale;
|
|
11940
|
+
batch.seenAt = Date.now();
|
|
11941
|
+
batch.rerun(index, fresh === true);
|
|
11807
11942
|
return {
|
|
11808
11943
|
status: 200,
|
|
11809
11944
|
body: {
|
|
11810
|
-
|
|
11811
|
-
|
|
11812
|
-
kind: "resolved",
|
|
11813
|
-
id: resolution.agent.id,
|
|
11814
|
-
displayName: resolution.agent.displayName
|
|
11815
|
-
} : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
|
|
11816
|
-
kind: "refused",
|
|
11817
|
-
error: unresolvedAgent(resolution)
|
|
11818
|
-
},
|
|
11819
|
-
canChooseFolder: deps.chooseFolder !== null
|
|
11945
|
+
rows: rowsOf(batch),
|
|
11946
|
+
finished: false
|
|
11820
11947
|
}
|
|
11821
11948
|
};
|
|
11822
11949
|
}
|
|
11823
|
-
|
|
11824
|
-
|
|
11825
|
-
return {
|
|
11826
|
-
reviewId,
|
|
11827
|
-
...kept ? { kept } : {}
|
|
11828
|
-
};
|
|
11829
|
-
}
|
|
11830
|
-
function folderOption(folder, home) {
|
|
11950
|
+
/** The recent and discoverable folders, for the Branch panel's list. */
|
|
11951
|
+
async function listFolders(deps) {
|
|
11831
11952
|
return {
|
|
11832
|
-
|
|
11833
|
-
|
|
11834
|
-
|
|
11953
|
+
status: 200,
|
|
11954
|
+
body: { folders: (await deps.folders()).map((folder) => ({
|
|
11955
|
+
...folderOption(folder, deps.home),
|
|
11956
|
+
repo: folder.remote ? folder.remote.repo : basename(folder.path)
|
|
11957
|
+
})) }
|
|
11835
11958
|
};
|
|
11836
11959
|
}
|
|
11837
11960
|
/**
|
|
11838
|
-
* What
|
|
11839
|
-
*
|
|
11840
|
-
* run on every pause in typing.
|
|
11961
|
+
* What reviewing this folder would review, read without changing anything: the same target a bare
|
|
11962
|
+
* `prreviewbuddy` here would resolve, and the review it would find.
|
|
11841
11963
|
*
|
|
11842
|
-
*
|
|
11843
|
-
*
|
|
11844
|
-
*
|
|
11964
|
+
* With the base the reviewer named, when they named one, so what this checks is the comparison
|
|
11965
|
+
* that would be submitted. Without it, a repository whose default base is ambiguous could never be
|
|
11966
|
+
* reviewed from here, though naming the base is exactly what settles it.
|
|
11845
11967
|
*/
|
|
11846
|
-
async function
|
|
11847
|
-
|
|
11848
|
-
|
|
11849
|
-
const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
|
|
11850
|
-
return {
|
|
11968
|
+
async function inspectFolder(rawPath, deps, rawBase) {
|
|
11969
|
+
const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
|
|
11970
|
+
if (!path) return {
|
|
11851
11971
|
status: 200,
|
|
11852
11972
|
body: {
|
|
11853
|
-
|
|
11854
|
-
|
|
11855
|
-
key: link.key,
|
|
11856
|
-
label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
|
|
11857
|
-
url: link.url,
|
|
11858
|
-
ok: false,
|
|
11859
|
-
reason: unsupportedReason(link.provider),
|
|
11860
|
-
folders: []
|
|
11861
|
-
};
|
|
11862
|
-
const label = `${link.owner}/${link.repo} #${link.number}`;
|
|
11863
|
-
const candidates = candidatesFor(folders, link, summaries);
|
|
11864
|
-
if (candidates.length === 0) return {
|
|
11865
|
-
key: link.key,
|
|
11866
|
-
label,
|
|
11867
|
-
url: link.url,
|
|
11868
|
-
ok: false,
|
|
11869
|
-
reason: "Local repository not found",
|
|
11870
|
-
needsFolder: true,
|
|
11871
|
-
folders: []
|
|
11872
|
-
};
|
|
11873
|
-
const reviewed = reviewedIn(candidates, link, summaries);
|
|
11874
|
-
return {
|
|
11875
|
-
key: link.key,
|
|
11876
|
-
label,
|
|
11877
|
-
url: link.url,
|
|
11878
|
-
ok: true,
|
|
11879
|
-
folders: candidates.map((folder) => folderOption(folder, deps.home)),
|
|
11880
|
-
folder: candidates[0].path,
|
|
11881
|
-
...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
|
|
11882
|
-
};
|
|
11883
|
-
}),
|
|
11884
|
-
limit: MAX_BATCH
|
|
11973
|
+
ok: false,
|
|
11974
|
+
error: "Enter the full path of a folder, or choose one."
|
|
11885
11975
|
}
|
|
11886
11976
|
};
|
|
11887
|
-
|
|
11888
|
-
|
|
11889
|
-
|
|
11890
|
-
|
|
11891
|
-
}
|
|
11892
|
-
|
|
11893
|
-
|
|
11894
|
-
|
|
11895
|
-
|
|
11896
|
-
|
|
11897
|
-
|
|
11898
|
-
|
|
11899
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
11977
|
+
const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
|
|
11978
|
+
let target;
|
|
11979
|
+
try {
|
|
11980
|
+
target = await deps.resolveBranch(path, named);
|
|
11981
|
+
} catch (error) {
|
|
11982
|
+
return {
|
|
11983
|
+
status: 200,
|
|
11984
|
+
body: {
|
|
11985
|
+
ok: false,
|
|
11986
|
+
...refusalOf(error)
|
|
11987
|
+
}
|
|
11988
|
+
};
|
|
11989
|
+
}
|
|
11990
|
+
const [remote, base, existing] = await Promise.all([
|
|
11991
|
+
deps.remoteOf(target.originRepoPath),
|
|
11992
|
+
named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
|
|
11993
|
+
deps.findExisting(target)
|
|
11994
|
+
]);
|
|
11995
|
+
const kept = existing ? deps.resumeOf(existing.id) : null;
|
|
11996
|
+
return {
|
|
11997
|
+
status: 200,
|
|
11998
|
+
body: {
|
|
11999
|
+
ok: true,
|
|
12000
|
+
path: target.originRepoPath,
|
|
12001
|
+
display: displayPath(target.originRepoPath, deps.home),
|
|
12002
|
+
repo: remote ? remote.repo : basename(target.originRepoPath),
|
|
12003
|
+
branch: target.branch,
|
|
12004
|
+
base,
|
|
12005
|
+
...existing ? { reviewed: {
|
|
12006
|
+
reviewId: existing.id,
|
|
12007
|
+
inProgress: existing.inProgress,
|
|
12008
|
+
...kept ? { kept } : {}
|
|
12009
|
+
} } : {}
|
|
12010
|
+
}
|
|
11902
12011
|
};
|
|
11903
|
-
batches.set(batch.id, batch);
|
|
11904
|
-
return batch;
|
|
11905
12012
|
}
|
|
11906
12013
|
/**
|
|
11907
|
-
*
|
|
11908
|
-
*
|
|
12014
|
+
* Review the branch checked out in a folder: `prreviewbuddy` run there.
|
|
12015
|
+
*
|
|
12016
|
+
* Always answers with a one-row batch, which is what the panel follows, so a branch shows its
|
|
12017
|
+
* progress exactly as a pull request does. With a free analysis slot it answers once the review
|
|
12018
|
+
* exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
|
|
12019
|
+
* it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
|
|
12020
|
+
* nobody is running, which the sweep would fail. A review the branch already has needs no slot.
|
|
12021
|
+
*
|
|
12022
|
+
* A refusal before anything exists comes back as a refusal, with the base question when that is
|
|
12023
|
+
* what it was, so the panel can ask it beside the folder rather than as a failed row.
|
|
11909
12024
|
*/
|
|
11910
|
-
function
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
12025
|
+
async function startBranchReview(body, deps) {
|
|
12026
|
+
const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
|
|
12027
|
+
if (!path) return refuse(400, "Choose the working folder to review.");
|
|
12028
|
+
const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
|
|
12029
|
+
const model = modelOf(body?.model);
|
|
12030
|
+
if (isAnswer(model)) return model;
|
|
12031
|
+
let target;
|
|
12032
|
+
try {
|
|
12033
|
+
target = await deps.resolveBranch(path, base);
|
|
12034
|
+
} catch (error) {
|
|
12035
|
+
return {
|
|
12036
|
+
status: 400,
|
|
12037
|
+
body: refusalOf(error)
|
|
12038
|
+
};
|
|
12039
|
+
}
|
|
12040
|
+
const agent = await agentFor(body?.agent, deps);
|
|
12041
|
+
if (isAnswer(agent)) return agent;
|
|
12042
|
+
const row = {
|
|
12043
|
+
key: batchIdentity(target),
|
|
12044
|
+
label: target.branch,
|
|
12045
|
+
status: "waiting"
|
|
12046
|
+
};
|
|
12047
|
+
const batch = newBatch([row]);
|
|
12048
|
+
noteRow(row, {
|
|
12049
|
+
repoPath: target.originRepoPath,
|
|
12050
|
+
branch: target.branch
|
|
11916
12051
|
});
|
|
12052
|
+
/**
|
|
12053
|
+
* The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
|
|
12054
|
+
* branch has moved since, unless `fresh`, which looks at the folder again as it is now.
|
|
12055
|
+
*/
|
|
12056
|
+
const run = async (release, again) => {
|
|
12057
|
+
const fresh = again === "fresh";
|
|
12058
|
+
let replaced = false;
|
|
12059
|
+
begin(row);
|
|
12060
|
+
try {
|
|
12061
|
+
const next = fresh ? await deps.resolveBranch(path, base) : target;
|
|
12062
|
+
if (again === "retry") {
|
|
12063
|
+
if (!await stillThere(row, target, "branch", deps)) return;
|
|
12064
|
+
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
12065
|
+
}
|
|
12066
|
+
const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
|
|
12067
|
+
target = next;
|
|
12068
|
+
replaced = fresh;
|
|
12069
|
+
await completeRow(row, prepared, batch, deps);
|
|
12070
|
+
} catch (error) {
|
|
12071
|
+
fail(row, error);
|
|
12072
|
+
} finally {
|
|
12073
|
+
if (fresh) keepFreshPending(row, replaced);
|
|
12074
|
+
release();
|
|
12075
|
+
}
|
|
12076
|
+
};
|
|
12077
|
+
batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
|
|
12078
|
+
const answer = (outcome) => ({
|
|
12079
|
+
status: 200,
|
|
12080
|
+
body: {
|
|
12081
|
+
outcome,
|
|
12082
|
+
batchId: batch.id,
|
|
12083
|
+
rows: rowsOf(batch),
|
|
12084
|
+
...row.reviewId ? { reviewId: row.reviewId } : {},
|
|
12085
|
+
...row.kept ? { kept: row.kept } : {}
|
|
12086
|
+
}
|
|
12087
|
+
});
|
|
12088
|
+
const release = deps.slots.tryAcquire();
|
|
12089
|
+
if (!release) {
|
|
12090
|
+
const existing = await deps.findExisting(target);
|
|
12091
|
+
if (existing && !deps.resumeOf(existing.id)) {
|
|
12092
|
+
row.reviewId = existing.id;
|
|
12093
|
+
if (!existing.inProgress) {
|
|
12094
|
+
row.status = "already-reviewed";
|
|
12095
|
+
return answer("existing");
|
|
12096
|
+
}
|
|
12097
|
+
begin(row);
|
|
12098
|
+
follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
|
|
12099
|
+
return answer("existing");
|
|
12100
|
+
}
|
|
12101
|
+
follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
|
|
12102
|
+
return answer("waiting");
|
|
12103
|
+
}
|
|
12104
|
+
let prepared;
|
|
12105
|
+
try {
|
|
12106
|
+
prepared = await prepareRow(row, target, agent.agentId, model, deps);
|
|
12107
|
+
} catch (error) {
|
|
12108
|
+
release();
|
|
12109
|
+
batches.delete(batch.id);
|
|
12110
|
+
return {
|
|
12111
|
+
status: 409,
|
|
12112
|
+
body: refusalOf(error)
|
|
12113
|
+
};
|
|
12114
|
+
}
|
|
12115
|
+
if (prepared.kind === "existing") {
|
|
12116
|
+
await completeRow(row, prepared, batch, deps);
|
|
12117
|
+
release();
|
|
12118
|
+
} else follow(batch, (async () => {
|
|
12119
|
+
try {
|
|
12120
|
+
await settleRow(row, deps.settle(row.reviewId));
|
|
12121
|
+
} catch (error) {
|
|
12122
|
+
fail(row, error);
|
|
12123
|
+
} finally {
|
|
12124
|
+
release();
|
|
12125
|
+
}
|
|
12126
|
+
})(), deps);
|
|
12127
|
+
return answer(prepared.kind);
|
|
11917
12128
|
}
|
|
11918
|
-
function
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
11929
|
-
/**
|
|
11930
|
-
* `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
|
|
11931
|
-
* branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
|
|
11932
|
-
* the same branch or request, which for a fresh start is the very failure being started over.
|
|
11933
|
-
*/
|
|
11934
|
-
async function prepareRow(row, target, agentId, model, deps, fresh = false) {
|
|
11935
|
-
const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
|
|
11936
|
-
row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
|
|
11937
|
-
if (prepared.kind === "existing") row.status = prepared.existing.inProgress ? "reviewing" : "already-reviewed";
|
|
11938
|
-
else row.status = "reviewing";
|
|
11939
|
-
if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
|
|
11940
|
-
else delete row.kept;
|
|
11941
|
-
return prepared;
|
|
12129
|
+
async function chooseFolder(deps) {
|
|
12130
|
+
if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
|
|
12131
|
+
try {
|
|
12132
|
+
const path = await deps.chooseFolder();
|
|
12133
|
+
return {
|
|
12134
|
+
status: 200,
|
|
12135
|
+
body: path ? { path } : { cancelled: true }
|
|
12136
|
+
};
|
|
12137
|
+
} catch (error) {
|
|
12138
|
+
return refuse(500, message(error));
|
|
12139
|
+
}
|
|
11942
12140
|
}
|
|
12141
|
+
//#endregion
|
|
12142
|
+
//#region ../../packages/review-harness/src/workspace/live_operations.ts
|
|
11943
12143
|
/**
|
|
11944
|
-
*
|
|
11945
|
-
*
|
|
11946
|
-
*
|
|
11947
|
-
*
|
|
12144
|
+
* The review work going on now, as one list: the one place that gathers it.
|
|
12145
|
+
*
|
|
12146
|
+
* The Reviews page's In progress section is its first reader, and anything else that monitors
|
|
12147
|
+
* execution should read this rather than gathering the same facts again. The rule that turns the
|
|
12148
|
+
* facts into entries is `deriveOperations`; this only supplies them, with the claim test
|
|
12149
|
+
* `analysisStates` uses, so the list and a card's analysis line never disagree about what is running.
|
|
12150
|
+
*
|
|
12151
|
+
* **Two scopes, on purpose.**
|
|
12152
|
+
*
|
|
12153
|
+
* - **Running work is machine-wide.** Jobs live in the store on disk and are claimed by whichever
|
|
12154
|
+
* process drives them, so a review started from a terminal, an MCP session or this server is
|
|
12155
|
+
* listed here alike.
|
|
12156
|
+
* - **Queued work is this process's only.** Rows waiting for an analysis slot are held in memory by
|
|
12157
|
+
* the server that accepted them (`pendingBatchRows`, from `add_review.ts`). Another process's
|
|
12158
|
+
* waiting work, such as the later pull requests of a `prreviewbuddy review --pr …` batch, is not
|
|
12159
|
+
* listed until it starts and has a job. A restart forgets this server's own waiting rows too.
|
|
12160
|
+
*
|
|
12161
|
+
* Nothing persists or shares the queue across processes, and nothing here should claim it does.
|
|
12162
|
+
*
|
|
12163
|
+
* `jobs` and `rows` are injected so a caller with its own snapshot, or a test, can hand them in.
|
|
11948
12164
|
*/
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
|
|
11952
|
-
}
|
|
11953
|
-
async function settleRow(row, settling) {
|
|
11954
|
-
const settled = await settling;
|
|
11955
|
-
if (settled.ok) row.status = "ready";
|
|
11956
|
-
else if (settled.stopped) {
|
|
11957
|
-
row.status = "failed";
|
|
11958
|
-
row.reason = "Stopped";
|
|
11959
|
-
} else fail(row, settled.reason);
|
|
12165
|
+
function liveOperations(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
|
|
12166
|
+
return deriveOperations(jobs, (job) => isClaimed(job.id, now), rows);
|
|
11960
12167
|
}
|
|
12168
|
+
//#endregion
|
|
12169
|
+
//#region ../../packages/review-harness/src/workspace/reviews_revision.ts
|
|
11961
12170
|
/**
|
|
11962
|
-
*
|
|
11963
|
-
*
|
|
11964
|
-
*
|
|
11965
|
-
*
|
|
12171
|
+
* Whether anything the reviews index draws has changed, as one token.
|
|
12172
|
+
*
|
|
12173
|
+
* The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
|
|
12174
|
+
* never needs to know *which* review changed, so it asks one question whose cost is the same for
|
|
12175
|
+
* five reviews and five hundred. Per-card revisions would be more information than the page has any
|
|
12176
|
+
* use for, and a request per card to carry it.
|
|
12177
|
+
*
|
|
12178
|
+
* Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
|
|
12179
|
+
* tokens for equality, never order them, never parse one. The implementation below is a hash of
|
|
12180
|
+
* facts the page happens to draw today, and the whole point of saying so here is that it can be
|
|
12181
|
+
* replaced by a counter, a watch, or anything else without a page having to be told.
|
|
12182
|
+
*
|
|
12183
|
+
* ## What is in it, and what is deliberately not
|
|
12184
|
+
*
|
|
12185
|
+
* In: every review's id and file revision, and the phase of every analysis running right now.
|
|
12186
|
+
* Between them those cover everything a card shows — the summary, the risk badge, the counts, the
|
|
12187
|
+
* pin, the reviewed mark, the running line — because every one of those reaches disk through
|
|
12188
|
+
* `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
|
|
12189
|
+
*
|
|
12190
|
+
* Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
|
|
12191
|
+
* streams its progress into the job record every few seconds, so a token derived from job file
|
|
12192
|
+
* mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
|
|
12193
|
+
* would then reload every few seconds for the whole length of an analysis — a page thrashing
|
|
12194
|
+
* itself, with the reviewer's scroll position going with it. Phase only, which changes four times
|
|
12195
|
+
* in a run.
|
|
11966
12196
|
*/
|
|
11967
|
-
async function stillThere(row, target, what, deps) {
|
|
11968
|
-
if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
|
|
11969
|
-
row.status = "failed";
|
|
11970
|
-
row.reason = `The commit this review was of (${target.sha.slice(0, 7)}) is no longer in this repository, so it can't be retried as it was. Start a fresh review to analyse the ${what} as it is now.`;
|
|
11971
|
-
row.stale = true;
|
|
11972
|
-
return false;
|
|
11973
|
-
}
|
|
11974
12197
|
/**
|
|
11975
|
-
*
|
|
11976
|
-
* finds the latest review of the branch or request: one made since, of newer commits, would be
|
|
11977
|
-
* handed back in place of the one that failed, and a retry would quietly become a different review.
|
|
12198
|
+
* What is being done to each review, or was last done and stopped.
|
|
11978
12199
|
*
|
|
11979
|
-
*
|
|
11980
|
-
*
|
|
12200
|
+
* Keyed by review, valued by the phase the card should name. Two states reach it and a third
|
|
12201
|
+
* deliberately does not:
|
|
12202
|
+
*
|
|
12203
|
+
* - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
|
|
12204
|
+
* load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
|
|
12205
|
+
* before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
|
|
12206
|
+
* be the index confidently reporting work that nothing is doing.
|
|
12207
|
+
* - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
|
|
12208
|
+
* this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
|
|
12209
|
+
* `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
|
|
12210
|
+
* and the card's "Analysis stopped" branch was unreachable in production while passing a unit
|
|
12211
|
+
* test that handed it a state the product could not produce. A review whose analysis failed went
|
|
12212
|
+
* back to reading "No standard review yet" the moment its driver let go.
|
|
12213
|
+
* - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
|
|
12214
|
+
* result on the card already, and an abandoned one is the sweep's to judge rather than this
|
|
12215
|
+
* renderer's to pre-empt.
|
|
12216
|
+
*
|
|
12217
|
+
* **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
|
|
12218
|
+
* a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
|
|
12219
|
+
* KISS run, and the review page's poll is built around one finishing while the other goes on. An
|
|
12220
|
+
* earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
|
|
12221
|
+
* first spoke for a standard analysis that was still working, and the card fell silent on work that
|
|
12222
|
+
* was happening in front of the reviewer.
|
|
12223
|
+
*
|
|
12224
|
+
* So the precedence is running, then the most recent ending, and it is stated in that order because
|
|
12225
|
+
* that is the property: **nothing that has ended can hide something that has not.**
|
|
12226
|
+
*
|
|
12227
|
+
* `jobs` is injected only so a test can hand the same set in two different orders and prove the
|
|
12228
|
+
* answer does not move. Production always reads the directory.
|
|
12229
|
+
*
|
|
12230
|
+
* A failure is visible for as long as the job record survives, which is the sweep's retention. Once
|
|
12231
|
+
* that passes the card quietly returns to saying nothing, which is the same silence every review
|
|
12232
|
+
* had before this existed.
|
|
11981
12233
|
*/
|
|
11982
|
-
|
|
11983
|
-
const
|
|
11984
|
-
const
|
|
11985
|
-
|
|
11986
|
-
|
|
11987
|
-
|
|
11988
|
-
|
|
12234
|
+
function analysisStates(now = Date.now(), jobs = allJobs()) {
|
|
12235
|
+
const byReview = /* @__PURE__ */ new Map();
|
|
12236
|
+
for (const job of jobs) {
|
|
12237
|
+
if (purposeOf(job) === "ask") continue;
|
|
12238
|
+
const held = byReview.get(job.workspaceId);
|
|
12239
|
+
if (held) held.push(job);
|
|
12240
|
+
else byReview.set(job.workspaceId, [job]);
|
|
11989
12241
|
}
|
|
11990
|
-
|
|
12242
|
+
/**
|
|
12243
|
+
* Both comparators are **total**, and that is a requirement rather than tidiness.
|
|
12244
|
+
*
|
|
12245
|
+
* `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
|
|
12246
|
+
* happened to return. This function's output is hashed into the index token, and the page reloads
|
|
12247
|
+
* whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
|
|
12248
|
+
* reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
|
|
12249
|
+
* same hazard the sorting rule was written for, one level further down.
|
|
12250
|
+
*
|
|
12251
|
+
* Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
|
|
12252
|
+
* before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
|
|
12253
|
+
* the last resort because it is the only field guaranteed to differ.
|
|
12254
|
+
*/
|
|
12255
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
12256
|
+
const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
|
|
12257
|
+
const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
|
|
12258
|
+
const states = /* @__PURE__ */ new Map();
|
|
12259
|
+
for (const [reviewId, jobs] of byReview) {
|
|
12260
|
+
const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
|
|
12261
|
+
if (running.length > 0) {
|
|
12262
|
+
states.set(reviewId, {
|
|
12263
|
+
phase: running[0].phase,
|
|
12264
|
+
purpose: purposeOf(running[0])
|
|
12265
|
+
});
|
|
12266
|
+
continue;
|
|
12267
|
+
}
|
|
12268
|
+
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
12269
|
+
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
12270
|
+
phase: "failed",
|
|
12271
|
+
purpose: "review",
|
|
12272
|
+
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
12273
|
+
});
|
|
12274
|
+
}
|
|
12275
|
+
return states;
|
|
11991
12276
|
}
|
|
11992
12277
|
/**
|
|
11993
|
-
*
|
|
11994
|
-
*
|
|
11995
|
-
*
|
|
12278
|
+
* The token.
|
|
12279
|
+
*
|
|
12280
|
+
* Takes the live analyses rather than reading them, so that one request answers with a token and a
|
|
12281
|
+
* running state that describe the same instant. Computing them separately would let a phase change
|
|
12282
|
+
* land between the two, and the page would then be told "nothing has changed" alongside a state it
|
|
12283
|
+
* had not seen.
|
|
11996
12284
|
*/
|
|
11997
|
-
function
|
|
11998
|
-
|
|
12285
|
+
function reviewsRevision(live = analysisStates(), operations = liveOperations()) {
|
|
12286
|
+
const hash = createHash("sha1");
|
|
12287
|
+
for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
|
|
12288
|
+
for (const id of [...live.keys()].sort()) {
|
|
12289
|
+
const state = live.get(id);
|
|
12290
|
+
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
12291
|
+
}
|
|
12292
|
+
for (const operation of [...operations].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
|
|
12293
|
+
hash.update(`queue:${queueStamp()}\n`);
|
|
12294
|
+
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
12295
|
+
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
12296
|
+
for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
|
|
12297
|
+
for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
|
|
12298
|
+
return hash.digest("base64url");
|
|
11999
12299
|
}
|
|
12000
|
-
function
|
|
12001
|
-
return
|
|
12002
|
-
agent: deps.agentName(agentId),
|
|
12003
|
-
...model ? { model } : {}
|
|
12004
|
-
};
|
|
12300
|
+
function asKind(value) {
|
|
12301
|
+
return value === "issue" || value === "question" ? value : null;
|
|
12005
12302
|
}
|
|
12006
|
-
/**
|
|
12007
|
-
|
|
12008
|
-
|
|
12009
|
-
|
|
12010
|
-
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12014
|
-
|
|
12015
|
-
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
const
|
|
12020
|
-
|
|
12021
|
-
if (!
|
|
12022
|
-
|
|
12023
|
-
if (
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
12303
|
+
/** What the page sent, or null when it is not a request a host understands. */
|
|
12304
|
+
function parseReassessmentRequest(value) {
|
|
12305
|
+
const body = value;
|
|
12306
|
+
if (body?.op === "decide") {
|
|
12307
|
+
if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
|
|
12308
|
+
if (body.decision !== "accepted" && body.decision !== "declined") return null;
|
|
12309
|
+
return {
|
|
12310
|
+
op: "decide",
|
|
12311
|
+
proposalId: body.proposalId,
|
|
12312
|
+
decision: body.decision
|
|
12313
|
+
};
|
|
12314
|
+
}
|
|
12315
|
+
if (body?.op === "set") {
|
|
12316
|
+
const kind = asKind(body.kind);
|
|
12317
|
+
if (!kind || typeof body.id !== "string" || body.id === "") return null;
|
|
12318
|
+
if (!isGradeFor(kind, body.to)) return null;
|
|
12319
|
+
if (body.from !== null && !isGradeFor(kind, body.from)) return null;
|
|
12320
|
+
if (body.reason !== void 0 && typeof body.reason !== "string") return null;
|
|
12321
|
+
const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
|
|
12322
|
+
return {
|
|
12323
|
+
op: "set",
|
|
12324
|
+
kind,
|
|
12325
|
+
findingId: body.id,
|
|
12326
|
+
from: body.from,
|
|
12327
|
+
to: body.to,
|
|
12328
|
+
...reason ? { reason } : {}
|
|
12329
|
+
};
|
|
12032
12330
|
}
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
let target = fresh ? void 0 : resolved[index];
|
|
12054
|
-
if (target) {
|
|
12055
|
-
if (!await stillThere(row, target, "pull request", deps)) return;
|
|
12056
|
-
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
12057
|
-
} else {
|
|
12058
|
-
const remote = await deps.remoteOf(item.folder);
|
|
12059
|
-
if (!isGitHubRepository(remote, item.owner, item.repo)) {
|
|
12060
|
-
row.status = "failed";
|
|
12061
|
-
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}`;
|
|
12062
|
-
return;
|
|
12063
|
-
}
|
|
12064
|
-
target = await deps.resolvePullRequest(item.folder, item.url);
|
|
12065
|
-
if (!fresh) resolved[index] = target;
|
|
12066
|
-
}
|
|
12067
|
-
const identity = batchIdentity(target);
|
|
12068
|
-
const first = claimed.get(identity);
|
|
12069
|
-
if (first !== void 0 && first !== index) {
|
|
12070
|
-
row.status = "already-reviewed";
|
|
12071
|
-
row.reason = `Same pull request as ${batch.rows[first].label}`;
|
|
12072
|
-
return;
|
|
12073
|
-
}
|
|
12074
|
-
claimed.set(identity, index);
|
|
12075
|
-
const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
|
|
12076
|
-
if (fresh) {
|
|
12077
|
-
resolved[index] = target;
|
|
12078
|
-
replaced = true;
|
|
12079
|
-
}
|
|
12080
|
-
await completeRow(row, prepared, batch, deps);
|
|
12081
|
-
} catch (error) {
|
|
12082
|
-
fail(row, error);
|
|
12083
|
-
} finally {
|
|
12084
|
-
if (fresh) keepFreshPending(row, replaced);
|
|
12085
|
-
release();
|
|
12086
|
-
}
|
|
12331
|
+
return null;
|
|
12332
|
+
}
|
|
12333
|
+
var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
|
|
12334
|
+
var REASSESSMENT_REFUSED = {
|
|
12335
|
+
"not-a-grade": "That is not a grade this finding can have.",
|
|
12336
|
+
unchanged: "The finding already has that grade.",
|
|
12337
|
+
stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
|
|
12338
|
+
outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
|
|
12339
|
+
"no-reason": "A suggestion needs a reason.",
|
|
12340
|
+
unknown: "This review has no suggestion with that id.",
|
|
12341
|
+
decided: "That suggestion has already been answered.",
|
|
12342
|
+
"no-finding": "This review has no such finding."
|
|
12343
|
+
};
|
|
12344
|
+
function gradeFragments$1(session, kind, id) {
|
|
12345
|
+
const finding = findingIn(session, kind, id);
|
|
12346
|
+
if (!finding) return null;
|
|
12347
|
+
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
12348
|
+
const options = {
|
|
12349
|
+
editable: true,
|
|
12350
|
+
askMore: true
|
|
12087
12351
|
};
|
|
12088
|
-
batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
|
|
12089
|
-
follow(batch, runBatch(items, {
|
|
12090
|
-
concurrency: deps.concurrency,
|
|
12091
|
-
run: lane
|
|
12092
|
-
}), deps);
|
|
12093
12352
|
return {
|
|
12094
|
-
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
|
|
12353
|
+
kind,
|
|
12354
|
+
id,
|
|
12355
|
+
current: assessment.current,
|
|
12356
|
+
cardClass: cardGradeClass(kind, assessment.current),
|
|
12357
|
+
grade: renderGradeControl(kind, assessment, options),
|
|
12358
|
+
region: renderAssessmentRegion(kind, assessment, options),
|
|
12359
|
+
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
12099
12360
|
};
|
|
12100
12361
|
}
|
|
12101
|
-
/**
|
|
12102
|
-
|
|
12103
|
-
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
|
|
12107
|
-
|
|
12108
|
-
|
|
12362
|
+
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
12363
|
+
function findingOfRequest(session, request) {
|
|
12364
|
+
if (request.op === "set") return {
|
|
12365
|
+
kind: request.kind,
|
|
12366
|
+
id: request.findingId
|
|
12367
|
+
};
|
|
12368
|
+
const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
|
|
12369
|
+
return proposal ? {
|
|
12370
|
+
kind: proposal.kind,
|
|
12371
|
+
id: proposal.findingId
|
|
12372
|
+
} : null;
|
|
12373
|
+
}
|
|
12374
|
+
/** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
|
|
12375
|
+
function refusedReply(session, request, refusal) {
|
|
12376
|
+
const finding = findingOfRequest(session, request);
|
|
12377
|
+
const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
|
|
12109
12378
|
return {
|
|
12110
|
-
status:
|
|
12379
|
+
status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
|
|
12111
12380
|
body: {
|
|
12112
|
-
|
|
12113
|
-
|
|
12381
|
+
error: REASSESSMENT_REFUSED[refusal],
|
|
12382
|
+
refusal,
|
|
12383
|
+
...card ? { card } : {}
|
|
12114
12384
|
}
|
|
12115
12385
|
};
|
|
12116
12386
|
}
|
|
12387
|
+
//#endregion
|
|
12388
|
+
//#region ../../packages/review-harness/src/workspace/reassessment_state.ts
|
|
12117
12389
|
/**
|
|
12118
|
-
*
|
|
12119
|
-
*
|
|
12120
|
-
*
|
|
12390
|
+
* The reviewer's grade moves and their answers to proposals, as `/state` receives them.
|
|
12391
|
+
*
|
|
12392
|
+
* The request, its refusals and the card parts a reply carries are shared with the extension
|
|
12393
|
+
* (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
|
|
12394
|
+
* against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
|
|
12395
|
+
* the write, where the answer is final.
|
|
12121
12396
|
*/
|
|
12122
|
-
|
|
12397
|
+
/** A stored review's card parts for one finding. */
|
|
12398
|
+
function gradeFragments(workspace, kind, id) {
|
|
12399
|
+
return gradeFragments$1(workspace.session, kind, id);
|
|
12400
|
+
}
|
|
12123
12401
|
/**
|
|
12124
|
-
*
|
|
12125
|
-
*
|
|
12126
|
-
* review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
|
|
12127
|
-
* one exception, sent only when those commits have gone: it looks again at what is there now.
|
|
12128
|
-
* The other rows are left exactly as they are, and the batch is followed again until it ends.
|
|
12402
|
+
* One write, onto the review as it is now, answered with the card as it now stands. `now` is
|
|
12403
|
+
* injectable so a test can name the time an entry records.
|
|
12129
12404
|
*/
|
|
12130
|
-
function
|
|
12131
|
-
const
|
|
12132
|
-
if (!
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12138
|
-
|
|
12139
|
-
|
|
12140
|
-
|
|
12141
|
-
|
|
12142
|
-
|
|
12405
|
+
function applyReassessmentRequest(workspaceId, value, now = Date.now) {
|
|
12406
|
+
const request = parseReassessmentRequest(value);
|
|
12407
|
+
if (!request) return {
|
|
12408
|
+
status: 400,
|
|
12409
|
+
body: { error: BAD_REASSESSMENT_REQUEST }
|
|
12410
|
+
};
|
|
12411
|
+
const before = loadWorkspace(workspaceId);
|
|
12412
|
+
if (!before) return {
|
|
12413
|
+
status: 404,
|
|
12414
|
+
body: { error: "No such review" }
|
|
12415
|
+
};
|
|
12416
|
+
const early = reassessIn(structuredClone(before.session), request, now());
|
|
12417
|
+
if (!early.ok) return refusedReply(before.session, request, early.refusal);
|
|
12418
|
+
let outcome = null;
|
|
12419
|
+
const saved = mutateWorkspace(workspaceId, (current) => {
|
|
12420
|
+
outcome = reassessIn(current.session, request, now());
|
|
12421
|
+
});
|
|
12422
|
+
if (!saved || !outcome) return {
|
|
12423
|
+
status: 404,
|
|
12424
|
+
body: { error: "No such review" }
|
|
12425
|
+
};
|
|
12426
|
+
const written = outcome;
|
|
12427
|
+
if (!written.ok) return refusedReply(saved.session, request, written.refusal);
|
|
12428
|
+
const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
|
|
12143
12429
|
return {
|
|
12144
12430
|
status: 200,
|
|
12145
12431
|
body: {
|
|
12146
|
-
|
|
12147
|
-
|
|
12432
|
+
ok: true,
|
|
12433
|
+
entry: written.entry,
|
|
12434
|
+
...card ? { card } : {}
|
|
12148
12435
|
}
|
|
12149
12436
|
};
|
|
12150
12437
|
}
|
|
12151
|
-
|
|
12152
|
-
|
|
12438
|
+
//#endregion
|
|
12439
|
+
//#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
|
|
12440
|
+
/** More than any real answer cites; a list longer than this is padding, not evidence. */
|
|
12441
|
+
var MAX_EVIDENCE = 10;
|
|
12442
|
+
function scaleText(kind) {
|
|
12443
|
+
return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
|
|
12444
|
+
}
|
|
12445
|
+
/** What the grade means, in the words the analysis prompt uses for it. */
|
|
12446
|
+
function gradeMeaning(kind) {
|
|
12447
|
+
return kind === "issue" ? "Severity is how much the issue would matter if it is real. It is not how sure anyone is that it is real." : "Importance is how much of the reviewer's attention the question deserves before they can approve.";
|
|
12448
|
+
}
|
|
12449
|
+
function findingText(kind, finding) {
|
|
12450
|
+
if (kind === "issue") {
|
|
12451
|
+
const issue = finding;
|
|
12452
|
+
const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
|
|
12453
|
+
return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
|
|
12454
|
+
}
|
|
12455
|
+
const question = finding;
|
|
12456
|
+
const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
|
|
12457
|
+
return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
|
|
12458
|
+
}
|
|
12459
|
+
function historyText(assessment) {
|
|
12460
|
+
if (!assessment.history.length) return "It has not been reassessed before.";
|
|
12461
|
+
return ["Earlier moves of this grade, oldest first:", ...assessment.history.map((step) => `- ${step.from ?? "ungraded"} → ${step.to}, ${sourcePhrase(step.source)}${step.reason ? `: ${step.reason}` : ""}`)].join("\n");
|
|
12462
|
+
}
|
|
12463
|
+
function threadText(thread) {
|
|
12464
|
+
if (!thread) return [];
|
|
12465
|
+
const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
|
|
12466
|
+
if (!comments.length) return [];
|
|
12467
|
+
return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
|
|
12468
|
+
}
|
|
12469
|
+
/** The block a Reassess turn adds to the ordinary Buddy prompt. */
|
|
12470
|
+
function buildReassessInstructions(input) {
|
|
12471
|
+
const { kind, finding, assessment } = input;
|
|
12472
|
+
const noun = kind === "issue" ? "severity" : "importance";
|
|
12473
|
+
const current = assessment.current;
|
|
12474
|
+
return [
|
|
12475
|
+
"---",
|
|
12476
|
+
`THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
|
|
12477
|
+
findingText(kind, finding),
|
|
12478
|
+
`Its ${noun} now: ${promptGrade(assessment)}.`,
|
|
12479
|
+
historyText(assessment),
|
|
12480
|
+
...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
|
|
12481
|
+
...threadText(input.thread),
|
|
12482
|
+
"---",
|
|
12483
|
+
`Judge whether its ${noun} should change, using the code in the checkout and everything above, including this conversation. ${gradeMeaning(kind)} Read the code that decides it rather than reasoning from the finding's wording.`,
|
|
12484
|
+
`Raising it is as legitimate as lowering it. "keep" is a good answer, and the right one whenever the evidence does not clearly move it; do not change it because you were asked.`,
|
|
12485
|
+
"Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
|
|
12486
|
+
`{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
|
|
12487
|
+
`"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
|
|
12488
|
+
"\"reason\" is one to three sentences the reviewer will read on the card: name what in the code or the conversation moves it, or what leaves it where it is.",
|
|
12489
|
+
"\"evidence\" is the places in the code that settle it, as paths relative to the repository root. Omit it rather than citing a file you did not read."
|
|
12490
|
+
];
|
|
12491
|
+
}
|
|
12492
|
+
var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
|
|
12493
|
+
function readEvidence(value) {
|
|
12494
|
+
if (!Array.isArray(value)) return [];
|
|
12495
|
+
const out = [];
|
|
12496
|
+
for (const item of value) {
|
|
12497
|
+
const entry = item;
|
|
12498
|
+
const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
|
|
12499
|
+
if (!file) continue;
|
|
12500
|
+
const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
|
|
12501
|
+
out.push(line === void 0 ? { file } : {
|
|
12502
|
+
file,
|
|
12503
|
+
line
|
|
12504
|
+
});
|
|
12505
|
+
if (out.length === MAX_EVIDENCE) break;
|
|
12506
|
+
}
|
|
12507
|
+
return out;
|
|
12508
|
+
}
|
|
12509
|
+
/**
|
|
12510
|
+
* The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
|
|
12511
|
+
* grade on this finding's scale that differs from the current one, and every answer must give a
|
|
12512
|
+
* reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
|
|
12513
|
+
* use it, and nothing is proposed.
|
|
12514
|
+
*/
|
|
12515
|
+
function parseReassessAnswer(raw, kind, current) {
|
|
12516
|
+
let parsed;
|
|
12517
|
+
try {
|
|
12518
|
+
parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
|
|
12519
|
+
} catch {
|
|
12520
|
+
return null;
|
|
12521
|
+
}
|
|
12522
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
12523
|
+
const obj = parsed;
|
|
12524
|
+
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
12525
|
+
if (!reason) return null;
|
|
12526
|
+
const evidence = readEvidence(obj.evidence);
|
|
12527
|
+
if (obj.verdict === "keep") return {
|
|
12528
|
+
verdict: "keep",
|
|
12529
|
+
reason,
|
|
12530
|
+
evidence
|
|
12531
|
+
};
|
|
12532
|
+
if (obj.verdict !== "change") return null;
|
|
12533
|
+
if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
|
|
12153
12534
|
return {
|
|
12154
|
-
|
|
12155
|
-
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
})) }
|
|
12535
|
+
verdict: "change",
|
|
12536
|
+
to: obj.to,
|
|
12537
|
+
reason,
|
|
12538
|
+
evidence
|
|
12159
12539
|
};
|
|
12160
12540
|
}
|
|
12541
|
+
//#endregion
|
|
12542
|
+
//#region ../../packages/review-harness/src/workspace/reassess_turn.ts
|
|
12161
12543
|
/**
|
|
12162
|
-
*
|
|
12163
|
-
*
|
|
12544
|
+
* The answer, with every cited place checked against the commit the review describes. A path that
|
|
12545
|
+
* is not there is dropped and the suggestion kept: the judgement may stand even when the model
|
|
12546
|
+
* misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
|
|
12547
|
+
* itself fails, no evidence is kept rather than unchecked evidence.
|
|
12164
12548
|
*
|
|
12165
|
-
*
|
|
12166
|
-
*
|
|
12167
|
-
*
|
|
12549
|
+
* Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
|
|
12550
|
+
* no verified code evidence rather than drawing it like one whose citations held. Acceptance does
|
|
12551
|
+
* not depend on it; a reason may be sound without a citation.
|
|
12168
12552
|
*/
|
|
12169
|
-
async function
|
|
12170
|
-
const
|
|
12171
|
-
if (!
|
|
12172
|
-
|
|
12173
|
-
|
|
12174
|
-
|
|
12175
|
-
error: "Enter the full path of a folder, or choose one."
|
|
12176
|
-
}
|
|
12553
|
+
async function readReassessTurn(raw, target, where, check = existingPaths) {
|
|
12554
|
+
const answer = parseReassessAnswer(raw, target.kind, target.from);
|
|
12555
|
+
if (!answer || !answer.evidence.length) return {
|
|
12556
|
+
answer,
|
|
12557
|
+
raw,
|
|
12558
|
+
droppedEvidence: 0
|
|
12177
12559
|
};
|
|
12178
|
-
|
|
12179
|
-
let target;
|
|
12560
|
+
let real;
|
|
12180
12561
|
try {
|
|
12181
|
-
|
|
12182
|
-
} catch
|
|
12183
|
-
|
|
12184
|
-
status: 200,
|
|
12185
|
-
body: {
|
|
12186
|
-
ok: false,
|
|
12187
|
-
...refusalOf(error)
|
|
12188
|
-
}
|
|
12189
|
-
};
|
|
12562
|
+
real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
|
|
12563
|
+
} catch {
|
|
12564
|
+
real = /* @__PURE__ */ new Set();
|
|
12190
12565
|
}
|
|
12191
|
-
const
|
|
12192
|
-
deps.remoteOf(target.originRepoPath),
|
|
12193
|
-
named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
|
|
12194
|
-
deps.findExisting(target)
|
|
12195
|
-
]);
|
|
12196
|
-
const kept = existing ? deps.resumeOf(existing.id) : null;
|
|
12566
|
+
const evidence = answer.evidence.filter((place) => real.has(place.file));
|
|
12197
12567
|
return {
|
|
12198
|
-
|
|
12199
|
-
|
|
12200
|
-
|
|
12201
|
-
|
|
12202
|
-
|
|
12203
|
-
|
|
12204
|
-
branch: target.branch,
|
|
12205
|
-
base,
|
|
12206
|
-
...existing ? { reviewed: {
|
|
12207
|
-
reviewId: existing.id,
|
|
12208
|
-
inProgress: existing.inProgress,
|
|
12209
|
-
...kept ? { kept } : {}
|
|
12210
|
-
} } : {}
|
|
12211
|
-
}
|
|
12568
|
+
answer: {
|
|
12569
|
+
...answer,
|
|
12570
|
+
evidence
|
|
12571
|
+
},
|
|
12572
|
+
raw,
|
|
12573
|
+
droppedEvidence: answer.evidence.length - evidence.length
|
|
12212
12574
|
};
|
|
12213
12575
|
}
|
|
12214
12576
|
/**
|
|
12215
|
-
*
|
|
12216
|
-
*
|
|
12217
|
-
*
|
|
12218
|
-
*
|
|
12219
|
-
* exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
|
|
12220
|
-
* it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
|
|
12221
|
-
* nobody is running, which the sweep would fail. A review the branch already has needs no slot.
|
|
12222
|
-
*
|
|
12223
|
-
* A refusal before anything exists comes back as a refusal, with the base question when that is
|
|
12224
|
-
* what it was, so the panel can ask it beside the folder rather than as a failed row.
|
|
12577
|
+
* Writes the suggestion onto the session, when there is one to write. Run inside the store write on
|
|
12578
|
+
* the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
|
|
12579
|
+
* while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
|
|
12580
|
+
* grade it never saw.
|
|
12225
12581
|
*/
|
|
12226
|
-
|
|
12227
|
-
|
|
12228
|
-
if (
|
|
12229
|
-
|
|
12230
|
-
|
|
12231
|
-
|
|
12232
|
-
|
|
12233
|
-
|
|
12234
|
-
|
|
12235
|
-
|
|
12236
|
-
|
|
12237
|
-
|
|
12238
|
-
|
|
12239
|
-
|
|
12240
|
-
|
|
12241
|
-
const agent = await agentFor(body?.agent, deps);
|
|
12242
|
-
if (isAnswer(agent)) return agent;
|
|
12243
|
-
const row = {
|
|
12244
|
-
key: batchIdentity(target),
|
|
12245
|
-
label: target.branch,
|
|
12246
|
-
status: "waiting"
|
|
12247
|
-
};
|
|
12248
|
-
const batch = newBatch([row]);
|
|
12249
|
-
/**
|
|
12250
|
-
* The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
|
|
12251
|
-
* branch has moved since, unless `fresh`, which looks at the folder again as it is now.
|
|
12252
|
-
*/
|
|
12253
|
-
const run = async (release, again) => {
|
|
12254
|
-
const fresh = again === "fresh";
|
|
12255
|
-
let replaced = false;
|
|
12256
|
-
try {
|
|
12257
|
-
const next = fresh ? await deps.resolveBranch(path, base) : target;
|
|
12258
|
-
if (again === "retry") {
|
|
12259
|
-
if (!await stillThere(row, target, "branch", deps)) return;
|
|
12260
|
-
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
12261
|
-
}
|
|
12262
|
-
const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
|
|
12263
|
-
target = next;
|
|
12264
|
-
replaced = fresh;
|
|
12265
|
-
await completeRow(row, prepared, batch, deps);
|
|
12266
|
-
} catch (error) {
|
|
12267
|
-
fail(row, error);
|
|
12268
|
-
} finally {
|
|
12269
|
-
if (fresh) keepFreshPending(row, replaced);
|
|
12270
|
-
release();
|
|
12271
|
-
}
|
|
12272
|
-
};
|
|
12273
|
-
batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
|
|
12274
|
-
const answer = (outcome) => ({
|
|
12275
|
-
status: 200,
|
|
12276
|
-
body: {
|
|
12277
|
-
outcome,
|
|
12278
|
-
batchId: batch.id,
|
|
12279
|
-
rows: rowsOf(batch),
|
|
12280
|
-
...row.reviewId ? { reviewId: row.reviewId } : {},
|
|
12281
|
-
...row.kept ? { kept: row.kept } : {}
|
|
12282
|
-
}
|
|
12283
|
-
});
|
|
12284
|
-
const release = deps.slots.tryAcquire();
|
|
12285
|
-
if (!release) {
|
|
12286
|
-
const existing = await deps.findExisting(target);
|
|
12287
|
-
if (existing && !deps.resumeOf(existing.id)) {
|
|
12288
|
-
row.reviewId = existing.id;
|
|
12289
|
-
if (!existing.inProgress) {
|
|
12290
|
-
row.status = "already-reviewed";
|
|
12291
|
-
return answer("existing");
|
|
12292
|
-
}
|
|
12293
|
-
row.status = "reviewing";
|
|
12294
|
-
follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
|
|
12295
|
-
return answer("existing");
|
|
12296
|
-
}
|
|
12297
|
-
follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
|
|
12298
|
-
return answer("waiting");
|
|
12299
|
-
}
|
|
12300
|
-
let prepared;
|
|
12301
|
-
try {
|
|
12302
|
-
prepared = await prepareRow(row, target, agent.agentId, model, deps);
|
|
12303
|
-
} catch (error) {
|
|
12304
|
-
release();
|
|
12305
|
-
batches.delete(batch.id);
|
|
12306
|
-
return {
|
|
12307
|
-
status: 409,
|
|
12308
|
-
body: refusalOf(error)
|
|
12309
|
-
};
|
|
12310
|
-
}
|
|
12311
|
-
if (prepared.kind === "existing") {
|
|
12312
|
-
await completeRow(row, prepared, batch, deps);
|
|
12313
|
-
release();
|
|
12314
|
-
} else follow(batch, (async () => {
|
|
12315
|
-
try {
|
|
12316
|
-
await settleRow(row, deps.settle(row.reviewId));
|
|
12317
|
-
} catch (error) {
|
|
12318
|
-
fail(row, error);
|
|
12319
|
-
} finally {
|
|
12320
|
-
release();
|
|
12582
|
+
function keepReassessTurn(session, target, turn, askedAt) {
|
|
12583
|
+
if (!turn.answer) return "unreadable";
|
|
12584
|
+
if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
|
|
12585
|
+
return reassessIn(session, {
|
|
12586
|
+
op: "propose",
|
|
12587
|
+
kind: target.kind,
|
|
12588
|
+
findingId: target.id,
|
|
12589
|
+
from: target.from,
|
|
12590
|
+
to: turn.answer.to,
|
|
12591
|
+
reason: turn.answer.reason,
|
|
12592
|
+
...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
|
|
12593
|
+
...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
|
|
12594
|
+
source: {
|
|
12595
|
+
kind: "buddy",
|
|
12596
|
+
askedAt
|
|
12321
12597
|
}
|
|
12322
|
-
}
|
|
12323
|
-
return answer(prepared.kind);
|
|
12598
|
+
}, askedAt).ok ? "proposed" : "stale";
|
|
12324
12599
|
}
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
} catch (error) {
|
|
12334
|
-
return refuse(500, message(error));
|
|
12335
|
-
}
|
|
12600
|
+
/** What the conversation shows for the turn, in words, never the JSON. */
|
|
12601
|
+
function reassessTurnText(target, turn, outcome) {
|
|
12602
|
+
const from = target.from ?? "ungraded";
|
|
12603
|
+
const answer = turn.answer;
|
|
12604
|
+
if (!answer) return `${turn.raw.trim()}\n\n*This answer was not in the form a suggestion needs, so nothing was suggested and the grade is unchanged.*`;
|
|
12605
|
+
if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
|
|
12606
|
+
const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
|
|
12607
|
+
return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
|
|
12336
12608
|
}
|
|
12337
12609
|
//#endregion
|
|
12338
12610
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
@@ -13592,6 +13864,7 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
13592
13864
|
const cookie = indexCookieHeader(indexToken);
|
|
13593
13865
|
if (onward) return redirect(res, onward, cookie);
|
|
13594
13866
|
const states = analysisStates();
|
|
13867
|
+
const operations = liveOperations();
|
|
13595
13868
|
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
13596
13869
|
const everyGroup = groupByLineage(reviews);
|
|
13597
13870
|
const queue = settleQueue(everyGroup);
|
|
@@ -13603,10 +13876,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
13603
13876
|
groups,
|
|
13604
13877
|
queue: queue.filter((id) => rendered.has(id)),
|
|
13605
13878
|
analyses: states,
|
|
13879
|
+
operations,
|
|
13606
13880
|
moved: movedReviews(),
|
|
13607
13881
|
prStates: prStatesByRequest(),
|
|
13608
13882
|
activity,
|
|
13609
|
-
revision: reviewsRevision(states),
|
|
13883
|
+
revision: reviewsRevision(states, operations),
|
|
13610
13884
|
unreadNotifications: unreadCount$1(),
|
|
13611
13885
|
driving: isDriving(),
|
|
13612
13886
|
theme: readPreferences().theme,
|
|
@@ -14099,9 +14373,10 @@ function establishBaseline(reviewIds) {
|
|
|
14099
14373
|
function handleRevision(req, res, indexToken) {
|
|
14100
14374
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14101
14375
|
const states = analysisStates();
|
|
14376
|
+
const operations = liveOperations();
|
|
14102
14377
|
send(res, 200, {
|
|
14103
|
-
revision: reviewsRevision(states),
|
|
14104
|
-
live: anythingRunning(states),
|
|
14378
|
+
revision: reviewsRevision(states, operations),
|
|
14379
|
+
live: anythingRunning(states, operations),
|
|
14105
14380
|
driving: isDriving(),
|
|
14106
14381
|
unread: unreadCount$1()
|
|
14107
14382
|
});
|
|
@@ -14206,7 +14481,7 @@ async function handleSections(req, res, indexToken) {
|
|
|
14206
14481
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
14207
14482
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14208
14483
|
const body = await readJson(req);
|
|
14209
|
-
if (!
|
|
14484
|
+
if (!isListSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
14210
14485
|
writeSectionCollapsed(body.section, body.collapsed);
|
|
14211
14486
|
send(res, 200, { ok: true });
|
|
14212
14487
|
}
|