prreviewbuddy 0.80.3 → 0.82.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +3 -4
- package/dist/{delete_review-CYiIfV5A.js → delete_review-BED4mPxB.js} +471 -35
- package/dist/main.js +21 -1
- package/dist/server.js +575 -45
- package/package.json +2 -4
- package/static/chunk-CQNkZ9TG.js +1 -0
- package/static/{chunk-DnOyRP8m.js → chunk-CWRKWlhO.js} +1 -1
- package/static/{chunk-C3E91izb.js → chunk-CxFmUaBj.js} +1 -1
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/runs.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +128 -0
- package/static/workspace.js +79 -79
- package/static/chunk-C8d9FvLk.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as MIN_WORKSPACE_PORT, $i as resolveModel, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as isGradeFor, An as escapeHtml$1, At as loadArchitecture, Bi as WITHHELD_REASON, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as ANALYSIS_ONLY, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as findingAssessment, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as currentGradeIn, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as followUpFindingId, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as isCommitReview, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as resolveTarget, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as asString, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as shortNameOf, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as resolveBase, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as extractJsonText, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as reassessIn, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as sourcePhrase, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as findingIn, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as wasReassessed, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveAgent, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as objectWithAnyOf, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as withExplanation, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as assessmentLookup, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as originRemote, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as kissOutcome, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as DetachedHeadError, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as git, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as shorthandOf, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as refExists, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _i as processDiscussion, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as STORE_ROOT, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, bi as explanationOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as agentFor$1, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as isAgentId, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as agentIdOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as parkedReason, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as AgentUnavailableError, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as promptGrade, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as gradeScale, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as agentIdentityOf, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as AgentCancelledError, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as readAgentPreference, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as AGENT_IDS, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as runnableAgents, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as AmbiguousRefError, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as writeAgentPreference, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as agentById, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as clearAgentPreference, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ua as detectAgents, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, vi as recommendationsOf, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as analysisRefOf, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xi as isExplanationAction, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, yi as explanationActionOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as describeAuthorship, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-BED4mPxB.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";
|
|
@@ -282,15 +282,37 @@ var INDEX_SECTION_NAMES = {
|
|
|
282
282
|
};
|
|
283
283
|
/**
|
|
284
284
|
* Whether a section starts open. The reviewer's choice wins, and otherwise everything is open but
|
|
285
|
-
* Done,
|
|
285
|
+
* the archives: Done, and each collection's completed reviews. A new collection starts open. The
|
|
286
|
+
* one place that default is written.
|
|
286
287
|
*/
|
|
287
288
|
function sectionOpen(section, collapsed) {
|
|
288
|
-
return !(collapsed?.[section] ?? section === "done");
|
|
289
|
+
return !(collapsed?.[section] ?? (section === "done" || section.startsWith("collection-done:")));
|
|
289
290
|
}
|
|
290
291
|
/** A section the list draws a heading for, and so one whose fold can be stored. */
|
|
291
292
|
function isListSection(value) {
|
|
292
293
|
return typeof value === "string" && LIST_SECTIONS.includes(value);
|
|
293
294
|
}
|
|
295
|
+
/** A fold the store accepts: a list section, or a collection's, by an id shaped like one. */
|
|
296
|
+
function isFoldKey(value) {
|
|
297
|
+
if (isListSection(value)) return true;
|
|
298
|
+
if (typeof value !== "string") return false;
|
|
299
|
+
const match = /^collection(?:-done)?:(.*)$/.exec(value);
|
|
300
|
+
return match !== null && isCollectionId(match[1]);
|
|
301
|
+
}
|
|
302
|
+
function collectionFold(id) {
|
|
303
|
+
return `collection:${id}`;
|
|
304
|
+
}
|
|
305
|
+
function completedFold(id) {
|
|
306
|
+
return `collection-done:${id}`;
|
|
307
|
+
}
|
|
308
|
+
var COLLECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
309
|
+
/**
|
|
310
|
+
* Whether a value is shaped like a collection's id. Here, in the module both halves share, because
|
|
311
|
+
* the hash and the fold store check it in the browser and on the server alike.
|
|
312
|
+
*/
|
|
313
|
+
function isCollectionId(value) {
|
|
314
|
+
return typeof value === "string" && COLLECTION_ID.test(value);
|
|
315
|
+
}
|
|
294
316
|
/**
|
|
295
317
|
* Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
|
|
296
318
|
*
|
|
@@ -302,6 +324,69 @@ function indexSectionOf(pinned, lifecycle, activity, filter) {
|
|
|
302
324
|
return pinned && !ordersExplicitly(filter) ? "pinned" : sectionOf(lifecycle, activity);
|
|
303
325
|
}
|
|
304
326
|
/**
|
|
327
|
+
* Where a review's own card is drawn, or absent when it is drawn only inside its collections.
|
|
328
|
+
*
|
|
329
|
+
* Pinned first and exclusive, as it always was: a pinned review is drawn in Pinned and nowhere else,
|
|
330
|
+
* collected or not, and unpinning it puts it back where the rest of these rules say. A review in a
|
|
331
|
+
* collection is drawn there instead of in Active, Waiting or Done, so those three list what nobody
|
|
332
|
+
* has grouped. Under the Queue filter neither applies: the list is the queue in its order, each card
|
|
333
|
+
* in its lifecycle section, the same reason `indexSectionOf` gives for Pinned.
|
|
334
|
+
*/
|
|
335
|
+
function homeSection(pinned, lifecycle, activity, collected, filter) {
|
|
336
|
+
if (collected && !pinned && !ordersExplicitly(filter)) return void 0;
|
|
337
|
+
return indexSectionOf(pinned, lifecycle, activity, filter);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Where a review is drawn inside each collection it belongs to: with the collection's reviews still
|
|
341
|
+
* needing attention, under its completed reviews, or not at all.
|
|
342
|
+
*
|
|
343
|
+
* Completed means Done, by the same derivation that places the card everywhere else, so Waiting is
|
|
344
|
+
* still in play and the Reviewed mark has nothing to do with it. One state for every collection the
|
|
345
|
+
* review is in: marking it Done moves it in all of them at once. Not drawn while pinned (Pinned is
|
|
346
|
+
* exclusive) or under the Queue filter (see `homeSection`).
|
|
347
|
+
*/
|
|
348
|
+
function collectionPlacement(pinned, lifecycle, activity, filter) {
|
|
349
|
+
if (pinned || ordersExplicitly(filter)) return void 0;
|
|
350
|
+
return sectionOf(lifecycle, activity) === "done" ? "completed" : "active";
|
|
351
|
+
}
|
|
352
|
+
/** A collection heading's count: what still needs attention, and the members drawn in Pinned. */
|
|
353
|
+
function collectionCountLabel(active, pinned) {
|
|
354
|
+
const label = `${active} active`;
|
|
355
|
+
return pinned === 0 ? label : `${label} · ${pinned} pinned`;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The quiet line on a card naming its collections, or absent when it would say nothing new.
|
|
359
|
+
*
|
|
360
|
+
* Inside a collection the heading already names that one, so the line says only where else the
|
|
361
|
+
* review is ("Also in Release 0.82"), and nothing for a review in one collection. Outside every
|
|
362
|
+
* collection heading, which is Pinned and the Queue filter, it names them all.
|
|
363
|
+
*/
|
|
364
|
+
function collectionsLine(memberOf, collections, current) {
|
|
365
|
+
const names = collections.filter((collection) => collection.id !== current && memberOf.includes(collection.id)).map((collection) => collection.name);
|
|
366
|
+
if (names.length === 0) return void 0;
|
|
367
|
+
return `${current === void 0 ? "In" : "Also in"} ${names.join(", ")}`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* What a collection says when its main list is empty, or absent when it is not. A collection is
|
|
371
|
+
* never hidden for having nothing active: it is still somewhere its finished reviews live, and
|
|
372
|
+
* somewhere to put the next one.
|
|
373
|
+
*/
|
|
374
|
+
function collectionEmptyText(counts, filtered) {
|
|
375
|
+
if (counts.active > 0) return void 0;
|
|
376
|
+
if (filtered) return "Nothing in this collection matches the filters.";
|
|
377
|
+
if (counts.members === 0) return "Nothing here yet. Add reviews from a card’s menu, or select several and choose Manage collections.";
|
|
378
|
+
if (counts.pinned > 0 && counts.completed === 0) return "Its reviews are pinned, so they are listed under Pinned.";
|
|
379
|
+
return "Nothing here needs attention.";
|
|
380
|
+
}
|
|
381
|
+
/** Said under a collection whose oldest members are not drawn, so it never looks smaller than it is. */
|
|
382
|
+
function omittedText(omitted) {
|
|
383
|
+
return omitted === 1 ? "1 older review in this collection is not listed." : `${omitted} older reviews in this collection are not listed.`;
|
|
384
|
+
}
|
|
385
|
+
/** The disclosure holding a collection's Done reviews. */
|
|
386
|
+
function completedLabel(completed) {
|
|
387
|
+
return `Show completed (${completed})`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
305
390
|
* Whether a filter replaces the activity order with one the reviewer set. Queue is the only one:
|
|
306
391
|
* the list is the queue, in its order.
|
|
307
392
|
* `orderIndex` and `indexSectionOf` both ask this, so the ordering and the placement cannot disagree.
|
|
@@ -4228,7 +4313,7 @@ function renderFailureBody(job, options = {}) {
|
|
|
4228
4313
|
if (job.failure.stopped) return `<p>${options.lead === false ? "" : "<strong>Review stopped.</strong> "}Nothing went wrong, and what it had finished is kept.</p>
|
|
4229
4314
|
<button type="button" class="prb-job-retry" data-job-retry>Carry on</button>
|
|
4230
4315
|
<p class="prb-job-note">It picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
|
|
4231
|
-
const next = job.failure.kind === "agent_authentication_failed" ? `<p class="prb-job-note">Signing that agent in is what changes this. Once it is signed in, update the review.</p>` : `<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
|
|
4316
|
+
const next = job.failure.parked ? "" : job.failure.kind === "agent_authentication_failed" ? `<p class="prb-job-note">Signing that agent in is what changes this. Once it is signed in, update the review.</p>` : `<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
|
|
4232
4317
|
<p class="prb-job-note">Retry picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
|
|
4233
4318
|
return `<p>${options.lead === false ? "" : "<strong>Review paused.</strong> "}${escapeHtml$1(job.failure.message)}</p>
|
|
4234
4319
|
${next}`;
|
|
@@ -7701,6 +7786,8 @@ function startArchitectureRun(workspace, scope, deps = {}) {
|
|
|
7701
7786
|
const now = deps.now ?? Date.now;
|
|
7702
7787
|
const startedAt = now();
|
|
7703
7788
|
const env = agentEnvOf(workspace);
|
|
7789
|
+
const parked = parkedReason(env.agentId);
|
|
7790
|
+
if (parked) return { refused: parked };
|
|
7704
7791
|
const agent = agentFor$1(env);
|
|
7705
7792
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
7706
7793
|
const record = {
|
|
@@ -9531,27 +9618,63 @@ function operationLine(operation) {
|
|
|
9531
9618
|
}
|
|
9532
9619
|
//#endregion
|
|
9533
9620
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
9534
|
-
function
|
|
9621
|
+
function indexContext(view) {
|
|
9535
9622
|
const now = view.now ?? Date.now();
|
|
9536
9623
|
const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
|
|
9537
9624
|
at: group.latest.createdAt,
|
|
9538
9625
|
kind: "created"
|
|
9539
9626
|
};
|
|
9540
9627
|
const ordered = [...view.groups].sort((a, b) => activityOf(b).at - activityOf(a).at);
|
|
9628
|
+
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
9629
|
+
const operations = view.operations ?? [];
|
|
9630
|
+
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));
|
|
9631
|
+
const queue = view.queue ?? [];
|
|
9632
|
+
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
9633
|
+
const collections = view.collections ?? [];
|
|
9634
|
+
const refs = collections.map(({ id, name }) => ({
|
|
9635
|
+
id,
|
|
9636
|
+
name
|
|
9637
|
+
}));
|
|
9638
|
+
const memberOf = membershipsByLineage(collections);
|
|
9639
|
+
const renderCards = (groups, placement, hidden = () => false) => 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), {
|
|
9640
|
+
placement,
|
|
9641
|
+
memberOf: memberOf.get(group.key) ?? [],
|
|
9642
|
+
refs,
|
|
9643
|
+
hidden: hidden(group)
|
|
9644
|
+
})).join("");
|
|
9645
|
+
return {
|
|
9646
|
+
now,
|
|
9647
|
+
ordered,
|
|
9648
|
+
activityOf,
|
|
9649
|
+
inProgressOnly,
|
|
9650
|
+
positions,
|
|
9651
|
+
queue,
|
|
9652
|
+
refs,
|
|
9653
|
+
collections,
|
|
9654
|
+
memberOf,
|
|
9655
|
+
renderCards
|
|
9656
|
+
};
|
|
9657
|
+
}
|
|
9658
|
+
function renderReviewsPage(view) {
|
|
9659
|
+
const context = indexContext(view);
|
|
9660
|
+
const { ordered, activityOf, inProgressOnly, positions, queue, memberOf, renderCards } = context;
|
|
9661
|
+
const now = context.now;
|
|
9662
|
+
const operations = view.operations ?? [];
|
|
9541
9663
|
const sections = {
|
|
9542
9664
|
pinned: [],
|
|
9543
9665
|
active: [],
|
|
9544
9666
|
waiting: [],
|
|
9545
9667
|
done: []
|
|
9546
9668
|
};
|
|
9547
|
-
|
|
9548
|
-
const
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9669
|
+
const collectedHome = /* @__PURE__ */ new Set();
|
|
9670
|
+
for (const group of ordered) {
|
|
9671
|
+
const collected = (memberOf.get(group.key) ?? []).length > 0;
|
|
9672
|
+
const home = homeSection(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), collected, "all");
|
|
9673
|
+
if (home === void 0) collectedHome.add(group);
|
|
9674
|
+
sections[home ?? sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
|
|
9675
|
+
}
|
|
9552
9676
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
9553
|
-
const
|
|
9554
|
-
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
9677
|
+
const renderSectionAt = (section) => renderSection(section, sections[section], (groups) => renderCards(groups, "home", (group) => collectedHome.has(group)), sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id) && !collectedHome.has(group)).length);
|
|
9555
9678
|
return `<!doctype html>
|
|
9556
9679
|
<html lang="en"${themeAttribute(view.theme)}>
|
|
9557
9680
|
<head>
|
|
@@ -9562,7 +9685,7 @@ function renderReviewsPage(view) {
|
|
|
9562
9685
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
9563
9686
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
9564
9687
|
</head>
|
|
9565
|
-
<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)}">
|
|
9688
|
+
<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)}"`}${view.snapshot === void 0 ? "" : ` data-cards-snapshot="${escapeHtml$1(view.snapshot)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
|
|
9566
9689
|
${renderGlobalBar({
|
|
9567
9690
|
current: "reviews",
|
|
9568
9691
|
unread: view.unreadNotifications ?? 0,
|
|
@@ -9572,7 +9695,7 @@ ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls$1(queue
|
|
|
9572
9695
|
handle: latest.authorHandle,
|
|
9573
9696
|
name: latest.authorName,
|
|
9574
9697
|
createdAt: latest.createdAt
|
|
9575
|
-
}))), ordered.some((group) => group.latest.prNumber !== void 0)))}
|
|
9698
|
+
}))), ordered.some((group) => group.latest.prNumber !== void 0), context.refs.length > 0))}
|
|
9576
9699
|
<div class="prb-shell">
|
|
9577
9700
|
${renderWorkspaceRail({
|
|
9578
9701
|
current: "reviews",
|
|
@@ -9583,11 +9706,14 @@ ${renderWorkspaceRail({
|
|
|
9583
9706
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9584
9707
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9585
9708
|
</div>`}
|
|
9586
|
-
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9587
|
-
${
|
|
9709
|
+
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed), memberOf)}
|
|
9710
|
+
${renderSectionAt("pinned")}
|
|
9711
|
+
${ordered.length === 0 ? "" : collectionsArea(context, view.sectionsCollapsed)}
|
|
9712
|
+
${INDEX_SECTIONS.filter((section) => section !== "pinned").map(renderSectionAt).join("\n ")}
|
|
9588
9713
|
${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>`}
|
|
9589
9714
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
9590
|
-
|
|
9715
|
+
${ordered.length === 0 ? "" : renderCollectionsDialog()}
|
|
9716
|
+
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned, Done and queued reviews, and reviews in a collection, 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>
|
|
9591
9717
|
</main>
|
|
9592
9718
|
</div>
|
|
9593
9719
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
@@ -9603,7 +9729,7 @@ ${renderWorkspaceRail({
|
|
|
9603
9729
|
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
9604
9730
|
* sorting by recency already answers badly and that scanning answers well.
|
|
9605
9731
|
*/
|
|
9606
|
-
function renderControls$1(queued, authors, anyRequest) {
|
|
9732
|
+
function renderControls$1(queued, authors, anyRequest, anyCollection) {
|
|
9607
9733
|
return `<div class="prb-index-controls">
|
|
9608
9734
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
9609
9735
|
${renderAuthorFilter(authors)}
|
|
@@ -9616,10 +9742,29 @@ function renderControls$1(queued, authors, anyRequest) {
|
|
|
9616
9742
|
</select>
|
|
9617
9743
|
</label>
|
|
9618
9744
|
${anyRequest ? renderPrFilter() : ""}
|
|
9745
|
+
${renderCollectionsFilter(anyCollection)}
|
|
9619
9746
|
${renderClearFilters()}
|
|
9620
9747
|
</div>`;
|
|
9621
9748
|
}
|
|
9622
9749
|
/**
|
|
9750
|
+
* Which collections to show, any of several, and Uncollected for the reviews in none.
|
|
9751
|
+
*
|
|
9752
|
+
* A menu holding checkboxes rather than a select, because a select chooses one thing and this
|
|
9753
|
+
* chooses several, and because the list can be long enough to want its own search. The options are
|
|
9754
|
+
* filled in by the browser from the page's list of collections, so one made or renamed on the page
|
|
9755
|
+
* appears here without a reload. Hidden while there are no collections: a filter whose only choice
|
|
9756
|
+
* is Uncollected is not a choice.
|
|
9757
|
+
*/
|
|
9758
|
+
function renderCollectionsFilter(anyCollection) {
|
|
9759
|
+
return `<div class="prb-index-status prb-collections-filter" data-menu data-menu-form data-collections-filter${anyCollection ? "" : " hidden"}>
|
|
9760
|
+
<button type="button" class="prb-collections-filter-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Filter by collection">Collections: <span data-collections-filter-label>All</span></button>
|
|
9761
|
+
<div class="prb-menu-panel prb-collections-filter-panel" data-menu-panel hidden>
|
|
9762
|
+
<input type="search" class="prb-collections-filter-search" data-collections-filter-search placeholder="Find a collection" aria-label="Find a collection" spellcheck="false">
|
|
9763
|
+
<div class="prb-collections-filter-options" data-collections-filter-options role="group" aria-label="Collections"></div>
|
|
9764
|
+
</div>
|
|
9765
|
+
</div>`;
|
|
9766
|
+
}
|
|
9767
|
+
/**
|
|
9623
9768
|
* Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
|
|
9624
9769
|
* list: the hash is the browser's to read, and without script there is nothing to clear.
|
|
9625
9770
|
*/
|
|
@@ -9669,6 +9814,141 @@ function renderAuthorFilter(authors) {
|
|
|
9669
9814
|
</label>`;
|
|
9670
9815
|
}
|
|
9671
9816
|
/**
|
|
9817
|
+
* The Collections area, between Pinned and Active: one disclosure per collection, each listing its
|
|
9818
|
+
* reviews that still need attention, with its Done reviews folded beneath.
|
|
9819
|
+
*
|
|
9820
|
+
* Exported for the routes that change collections, which answer with this markup so the browser can
|
|
9821
|
+
* swap the area in place: every card instance is drawn by `renderCard`, here and on a full load, and
|
|
9822
|
+
* never copied in the browser.
|
|
9823
|
+
*/
|
|
9824
|
+
/** Collection ids by lineage key, in collection order: what a card's `data-collections` says. */
|
|
9825
|
+
function membershipsByLineage(collections) {
|
|
9826
|
+
const memberOf = /* @__PURE__ */ new Map();
|
|
9827
|
+
for (const collection of collections) for (const key of collection.members) memberOf.set(key, [...memberOf.get(key) ?? [], collection.id]);
|
|
9828
|
+
return memberOf;
|
|
9829
|
+
}
|
|
9830
|
+
function renderCollectionsArea(view) {
|
|
9831
|
+
return collectionsArea(indexContext(view), view.sectionsCollapsed);
|
|
9832
|
+
}
|
|
9833
|
+
function collectionsArea(context, collapsed) {
|
|
9834
|
+
const { ordered, refs, collections, memberOf, activityOf, inProgressOnly, renderCards } = context;
|
|
9835
|
+
const registry = JSON.stringify(refs).replace(/</g, "\\u003c");
|
|
9836
|
+
const blocks = collections.map((collection) => {
|
|
9837
|
+
const members = ordered.filter((group) => (memberOf.get(group.key) ?? []).includes(collection.id));
|
|
9838
|
+
const place = (group) => collectionPlacement(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all");
|
|
9839
|
+
const drawn = members.filter((group) => !inProgressOnly.has(group.latest.id));
|
|
9840
|
+
const active = members.filter((group) => place(group) !== "completed");
|
|
9841
|
+
const completed = members.filter((group) => place(group) === "completed");
|
|
9842
|
+
return renderCollection(collection, {
|
|
9843
|
+
active: drawn.filter((group) => place(group) === "active").length,
|
|
9844
|
+
completed: drawn.filter((group) => place(group) === "completed").length,
|
|
9845
|
+
pinned: drawn.filter((group) => group.latest.pinned === true).length,
|
|
9846
|
+
members: members.length
|
|
9847
|
+
}, collection.members.length - members.length, collapsed, {
|
|
9848
|
+
active: renderCards(active, { collection: collection.id }, (group) => place(group) === void 0),
|
|
9849
|
+
completed: renderCards(completed, { collection: collection.id })
|
|
9850
|
+
});
|
|
9851
|
+
});
|
|
9852
|
+
return `<section class="prb-collections" data-collections-area aria-label="Collections">
|
|
9853
|
+
<script type="application/json" data-collections-registry>${registry}<\/script>
|
|
9854
|
+
<div class="prb-collections-head">
|
|
9855
|
+
<h2 class="prb-collections-title">Collections</h2>
|
|
9856
|
+
${collections.length === 0 ? "<p class=\"prb-collections-hint\">Group reviews that belong together, such as one refactor or one release.</p>" : ""}
|
|
9857
|
+
</div>
|
|
9858
|
+
${blocks.join("\n ")}
|
|
9859
|
+
${renderNewCollection()}
|
|
9860
|
+
</section>`;
|
|
9861
|
+
}
|
|
9862
|
+
function renderCollection(collection, counts, omitted, collapsed, cards) {
|
|
9863
|
+
const id = escapeHtml$1(collection.id);
|
|
9864
|
+
const empty = collectionEmptyText(counts, false);
|
|
9865
|
+
return `<div class="prb-collection" data-collection="${id}">
|
|
9866
|
+
<details class="prb-index-section" data-section="${escapeHtml$1(collectionFold(collection.id))}"${sectionOpen(collectionFold(collection.id), collapsed) ? " open" : ""}>
|
|
9867
|
+
<summary class="prb-index-section-head"><span data-collection-name>${escapeHtml$1(collection.name)}</span> · <span data-section-count>${escapeHtml$1(collectionCountLabel(counts.active, counts.pinned))}</span></summary>
|
|
9868
|
+
<p class="prb-collection-empty" data-collection-empty${empty === void 0 ? " hidden" : ""}>${escapeHtml$1(empty ?? "")}</p>
|
|
9869
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(collectionFold(collection.id))}">
|
|
9870
|
+
${cards.active}
|
|
9871
|
+
</div>
|
|
9872
|
+
${renderLoadMore$1(collectionFold(collection.id))}
|
|
9873
|
+
${omitted > 0 ? `<p class="prb-collection-empty" data-collection-omitted>${escapeHtml$1(omittedText(omitted))}</p>` : ""}
|
|
9874
|
+
<details class="prb-collection-completed" data-section="${escapeHtml$1(completedFold(collection.id))}"${sectionOpen(completedFold(collection.id), collapsed) ? " open" : ""}${counts.completed === 0 ? " hidden" : ""}>
|
|
9875
|
+
<summary class="prb-collection-completed-head" data-completed-label>${escapeHtml$1(completedLabel(counts.completed))}</summary>
|
|
9876
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(completedFold(collection.id))}">
|
|
9877
|
+
${cards.completed}
|
|
9878
|
+
</div>
|
|
9879
|
+
${renderLoadMore$1(completedFold(collection.id))}
|
|
9880
|
+
</details>
|
|
9881
|
+
</details>
|
|
9882
|
+
${renderCollectionMenu(collection)}
|
|
9883
|
+
</div>`;
|
|
9884
|
+
}
|
|
9885
|
+
/**
|
|
9886
|
+
* A collection's own menu: rename it in place, or delete it.
|
|
9887
|
+
*
|
|
9888
|
+
* Deleting says what it does not do, which is the thing a reader worries about: the reviews stay.
|
|
9889
|
+
* It says what does change, too, because collected reviews are kept for longer and these go back to
|
|
9890
|
+
* the ordinary thirty days unless something else keeps them.
|
|
9891
|
+
*/
|
|
9892
|
+
function renderCollectionMenu(collection) {
|
|
9893
|
+
const id = escapeHtml$1(collection.id);
|
|
9894
|
+
return `<div class="prb-menu prb-collection-menu" data-menu data-menu-form>
|
|
9895
|
+
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(`Actions for collection ${collection.name}`)}">⋯</button>
|
|
9896
|
+
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
9897
|
+
<div data-menu-items>
|
|
9898
|
+
<button type="button" class="prb-menu-item" data-collection-rename="${id}">Rename collection</button>
|
|
9899
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-delete="${id}">Delete collection</button>
|
|
9900
|
+
</div>
|
|
9901
|
+
<form class="prb-collection-rename" data-collection-rename-form="${id}" hidden>
|
|
9902
|
+
<label class="prb-menu-question" for="prb-rename-${id}">New name</label>
|
|
9903
|
+
<input type="text" id="prb-rename-${id}" class="prb-collection-name" data-collection-name-input value="${escapeHtml$1(collection.name)}" maxlength="60" autocomplete="off" spellcheck="false">
|
|
9904
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9905
|
+
<div class="prb-menu-buttons">
|
|
9906
|
+
<button type="submit" class="prb-menu-item">Save</button>
|
|
9907
|
+
<button type="button" class="prb-menu-item" data-collection-cancel>Cancel</button>
|
|
9908
|
+
</div>
|
|
9909
|
+
</form>
|
|
9910
|
+
<div class="prb-menu-confirm" data-menu-confirm hidden>
|
|
9911
|
+
<p class="prb-menu-question">${escapeHtml$1(`Delete the collection ${collection.name}?`)}</p>
|
|
9912
|
+
<p class="prb-menu-note">Its reviews stay where they are. They go back to being kept for thirty days unless they are pinned, Done or in your review queue.</p>
|
|
9913
|
+
<div class="prb-menu-buttons">
|
|
9914
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-confirm-delete="${id}">Delete</button>
|
|
9915
|
+
<button type="button" class="prb-menu-item" data-cancel-delete>Cancel</button>
|
|
9916
|
+
</div>
|
|
9917
|
+
</div>
|
|
9918
|
+
</div>
|
|
9919
|
+
</div>`;
|
|
9920
|
+
}
|
|
9921
|
+
/** New collection, which opens a name field in place. */
|
|
9922
|
+
function renderNewCollection() {
|
|
9923
|
+
return `<div class="prb-collection-new">
|
|
9924
|
+
<button type="button" class="prb-index-more prb-collection-new-button" data-collection-new><span aria-hidden="true">+</span> New collection</button>
|
|
9925
|
+
<form class="prb-collection-new-form" data-collection-new-form hidden>
|
|
9926
|
+
<input type="text" class="prb-collection-name" data-collection-name-input placeholder="Collection name" aria-label="New collection name" maxlength="60" autocomplete="off" spellcheck="false">
|
|
9927
|
+
<button type="submit" class="prb-bulk-button">Create</button>
|
|
9928
|
+
<button type="button" class="prb-bulk-button" data-collection-cancel>Cancel</button>
|
|
9929
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9930
|
+
</form>
|
|
9931
|
+
</div>`;
|
|
9932
|
+
}
|
|
9933
|
+
/**
|
|
9934
|
+
* Manage collections, for one card or for a selection. Built once; the browser fills the list from
|
|
9935
|
+
* the page's collections and the reviews it was opened for, because the boxes' states depend on
|
|
9936
|
+
* which reviews those are.
|
|
9937
|
+
*/
|
|
9938
|
+
function renderCollectionsDialog() {
|
|
9939
|
+
return `<dialog class="prb-dialog prb-collections-dialog" data-collections-dialog aria-labelledby="prb-collections-dialog-title">
|
|
9940
|
+
<h2 id="prb-collections-dialog-title">Collections</h2>
|
|
9941
|
+
<p data-collections-dialog-for></p>
|
|
9942
|
+
<input type="search" class="prb-collections-dialog-search" data-collections-dialog-search placeholder="Find or create a collection" aria-label="Find or create a collection" spellcheck="false" autocomplete="off">
|
|
9943
|
+
<div class="prb-collections-dialog-list" data-collections-dialog-list role="group" aria-label="Collections"></div>
|
|
9944
|
+
<button type="button" class="prb-collections-dialog-create" data-collections-dialog-create hidden></button>
|
|
9945
|
+
<p class="prb-collection-error" data-collections-dialog-error hidden role="alert"></p>
|
|
9946
|
+
<div class="prb-dialog-actions">
|
|
9947
|
+
<button type="button" data-collections-dialog-close>Done</button>
|
|
9948
|
+
</div>
|
|
9949
|
+
</dialog>`;
|
|
9950
|
+
}
|
|
9951
|
+
/**
|
|
9672
9952
|
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
9673
9953
|
*
|
|
9674
9954
|
* One per section rather than one pager for the page, so revealing older Done reviews never costs
|
|
@@ -9706,10 +9986,11 @@ function renderSection(section, groups, renderCards, open, shown = groups.length
|
|
|
9706
9986
|
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9707
9987
|
* counts once here and once in its own section, which is the point.
|
|
9708
9988
|
*/
|
|
9709
|
-
function renderInProgress$1(operations, groups, positions, prStates, now, open) {
|
|
9989
|
+
function renderInProgress$1(operations, groups, positions, prStates, now, open, memberOf = /* @__PURE__ */ new Map()) {
|
|
9710
9990
|
if (operations.length === 0) return "";
|
|
9711
9991
|
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9712
|
-
const
|
|
9992
|
+
const collections = operationCollections(operations, groups, memberOf);
|
|
9993
|
+
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now, collections[operation.key] ?? [])).join("");
|
|
9713
9994
|
return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
|
|
9714
9995
|
<summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
9715
9996
|
<div class="prb-cards" data-operations>
|
|
@@ -9723,6 +10004,23 @@ function renderInProgress$1(operations, groups, positions, prStates, now, open)
|
|
|
9723
10004
|
* one, which is what that review's title, author and state still are: the filters then treat a
|
|
9724
10005
|
* re-review of a known pull request as that pull request, rather than as somebody unknown.
|
|
9725
10006
|
*/
|
|
10007
|
+
/**
|
|
10008
|
+
* The collections each piece of work's review is in, by the work's key: the lineage of whichever
|
|
10009
|
+
* review `reviewOf` finds, so a waiting row with no review yet still answers the Collections filter
|
|
10010
|
+
* the way its card will. Also what the collections routes send back, so a change of membership
|
|
10011
|
+
* reaches the In progress section without a reload.
|
|
10012
|
+
*/
|
|
10013
|
+
function operationCollections(operations, groups, memberOf) {
|
|
10014
|
+
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
10015
|
+
const lineage = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, group.key])));
|
|
10016
|
+
const answer = {};
|
|
10017
|
+
for (const operation of operations) {
|
|
10018
|
+
const review = reviewOf(operation, reviews);
|
|
10019
|
+
const key = review ? lineage.get(review.id) : void 0;
|
|
10020
|
+
answer[operation.key] = key === void 0 ? [] : [...memberOf.get(key) ?? []];
|
|
10021
|
+
}
|
|
10022
|
+
return answer;
|
|
10023
|
+
}
|
|
9726
10024
|
function reviewOf(operation, reviews) {
|
|
9727
10025
|
if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
|
|
9728
10026
|
const row = operation.row;
|
|
@@ -9744,7 +10042,7 @@ function reviewOf(operation, reviews) {
|
|
|
9744
10042
|
* A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
|
|
9745
10043
|
* for a slot has no review to open, so it is not a link.
|
|
9746
10044
|
*/
|
|
9747
|
-
function renderOperation(operation, review, positions, prStates, now) {
|
|
10045
|
+
function renderOperation(operation, review, positions, prStates, now, collections = []) {
|
|
9748
10046
|
const row = operation.row;
|
|
9749
10047
|
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
9750
10048
|
const repoPath = review?.repoPath ?? row?.repoPath;
|
|
@@ -9775,7 +10073,7 @@ function renderOperation(operation, review, positions, prStates, now) {
|
|
|
9775
10073
|
${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
|
|
9776
10074
|
</p>`;
|
|
9777
10075
|
const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
|
|
9778
|
-
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)}">
|
|
10076
|
+
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)}" data-collections="${escapeHtml$1(collections.join(" "))}">
|
|
9779
10077
|
${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
9780
10078
|
</article>`;
|
|
9781
10079
|
}
|
|
@@ -9800,6 +10098,7 @@ function renderBulkBar() {
|
|
|
9800
10098
|
<button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
|
|
9801
10099
|
<button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
|
|
9802
10100
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
10101
|
+
<button type="button" class="prb-bulk-button" data-bulk-collections disabled>Manage collections</button>
|
|
9803
10102
|
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
|
|
9804
10103
|
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
|
|
9805
10104
|
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
@@ -9836,13 +10135,21 @@ function renderBulkBar() {
|
|
|
9836
10135
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
9837
10136
|
* than a rebuild of the list.
|
|
9838
10137
|
*/
|
|
9839
|
-
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false
|
|
10138
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false, instance = {
|
|
10139
|
+
placement: "home",
|
|
10140
|
+
memberOf: [],
|
|
10141
|
+
refs: [],
|
|
10142
|
+
hidden: false
|
|
10143
|
+
}) {
|
|
9840
10144
|
const review = group.latest;
|
|
10145
|
+
const placement = instance.placement === "home" ? "home" : `collection:${instance.placement.collection}`;
|
|
10146
|
+
const line = collectionsLine(instance.memberOf, instance.refs, instance.placement === "home" ? void 0 : instance.placement.collection);
|
|
10147
|
+
const hidden = inProgressOnly || instance.hidden;
|
|
9841
10148
|
const status = statusOf(review);
|
|
9842
10149
|
const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
|
|
9843
10150
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
9844
10151
|
const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
|
|
9845
|
-
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" : ""}>
|
|
10152
|
+
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))}" data-placement="${escapeHtml$1(placement)}" data-collections="${escapeHtml$1(instance.memberOf.join(" "))}"${inProgressOnly ? " data-in-progress-only" : ""}${hidden ? " hidden" : ""}>
|
|
9846
10153
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
9847
10154
|
<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>
|
|
9848
10155
|
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
@@ -9868,6 +10175,7 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
|
|
|
9868
10175
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
9869
10176
|
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
9870
10177
|
${renderAnalysisState(analysis)}
|
|
10178
|
+
<span class="prb-card-collections" data-collections-line${line === void 0 ? " hidden" : ""}>${escapeHtml$1(line ?? "")}</span>
|
|
9871
10179
|
<span class="prb-card-when"><time datetime="${new Date(activity.at).toISOString()}" title="${escapeHtml$1(new Date(activity.at).toLocaleString())}">${escapeHtml$1(`${activityLabel(activity.kind)} ${relativeTime(activity.at, now)}`)}</time></span>
|
|
9872
10180
|
</p>
|
|
9873
10181
|
</a>
|
|
@@ -9908,7 +10216,8 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9908
10216
|
queue: queued ? "remove" : "add",
|
|
9909
10217
|
mark,
|
|
9910
10218
|
lifecycle,
|
|
9911
|
-
fetch: true
|
|
10219
|
+
fetch: true,
|
|
10220
|
+
collections: true
|
|
9912
10221
|
});
|
|
9913
10222
|
const all = `all ${count} reviews following ${followed}`;
|
|
9914
10223
|
return renderMenu({
|
|
@@ -9921,6 +10230,7 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9921
10230
|
mark,
|
|
9922
10231
|
lifecycle,
|
|
9923
10232
|
fetch: true,
|
|
10233
|
+
collections: true,
|
|
9924
10234
|
copy: "Copy latest review ID"
|
|
9925
10235
|
});
|
|
9926
10236
|
}
|
|
@@ -9951,6 +10261,7 @@ function renderMenu(item) {
|
|
|
9951
10261
|
${markItem}
|
|
9952
10262
|
${lifecycleItems}
|
|
9953
10263
|
${queueItem}
|
|
10264
|
+
${item.collections ? `<button type="button" class="prb-menu-item" data-collections-for="${id}">Manage collections…</button>` : ""}
|
|
9954
10265
|
<button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
|
|
9955
10266
|
<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
|
|
9956
10267
|
</div>
|
|
@@ -10361,14 +10672,15 @@ function writeChangeMapRegister(register) {
|
|
|
10361
10672
|
}, null, 2)}\n`);
|
|
10362
10673
|
} catch {}
|
|
10363
10674
|
}
|
|
10364
|
-
/**
|
|
10675
|
+
/**
|
|
10676
|
+
* Known sections and collection folds with a boolean only. A hand-edited key we do not draw is
|
|
10677
|
+
* dropped on the next write. A fold for a collection since deleted elsewhere is kept until
|
|
10678
|
+
* `forgetSections` is told, which costs one line of JSON and nothing on the page.
|
|
10679
|
+
*/
|
|
10365
10680
|
function readReviewSections(value) {
|
|
10366
10681
|
if (!value || typeof value !== "object") return {};
|
|
10367
10682
|
const sections = {};
|
|
10368
|
-
for (const section of
|
|
10369
|
-
const collapsed = value[section];
|
|
10370
|
-
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10371
|
-
}
|
|
10683
|
+
for (const [section, collapsed] of Object.entries(value)) if (isFoldKey(section) && typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10372
10684
|
return Object.keys(sections).length === 0 ? {} : { reviewSections: sections };
|
|
10373
10685
|
}
|
|
10374
10686
|
/**
|
|
@@ -10390,6 +10702,19 @@ function writeSectionCollapsed(section, collapsed) {
|
|
|
10390
10702
|
}, null, 2)}\n`);
|
|
10391
10703
|
} catch {}
|
|
10392
10704
|
}
|
|
10705
|
+
/** Drop folds nothing will draw again: a deleted collection's. Writes nothing when none was stored. */
|
|
10706
|
+
function forgetSections(sections) {
|
|
10707
|
+
try {
|
|
10708
|
+
const current = readPreferences();
|
|
10709
|
+
const stored = current.reviewSections;
|
|
10710
|
+
if (!stored || !sections.some((section) => section in stored)) return;
|
|
10711
|
+
const reviewSections = Object.fromEntries(Object.entries(stored).filter(([section]) => !sections.includes(section)));
|
|
10712
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
10713
|
+
...current,
|
|
10714
|
+
reviewSections
|
|
10715
|
+
}, null, 2)}\n`);
|
|
10716
|
+
} catch {}
|
|
10717
|
+
}
|
|
10393
10718
|
/** As `readReviewSections`, for the Runs page. */
|
|
10394
10719
|
function readRunsSections(value) {
|
|
10395
10720
|
if (!value || typeof value !== "object") return {};
|
|
@@ -10489,6 +10814,7 @@ function readSettings() {
|
|
|
10489
10814
|
},
|
|
10490
10815
|
agent: readAgentPreference(),
|
|
10491
10816
|
agents: AGENT_IDS,
|
|
10817
|
+
agentParked: parkedReason(readAgentPreference() ?? void 0) ?? null,
|
|
10492
10818
|
usageCounts: telemetryUploadConsent(),
|
|
10493
10819
|
theme: readPreferences().theme ?? "system"
|
|
10494
10820
|
};
|
|
@@ -10513,6 +10839,11 @@ function saveSettings(change, minPort, maxPort) {
|
|
|
10513
10839
|
error: `\`${change.port}\` is not a port PR Review Buddy can use. Choose one between ${minPort} and ${maxPort}.`
|
|
10514
10840
|
};
|
|
10515
10841
|
}
|
|
10842
|
+
const parked = change.agent ? parkedReason(change.agent) : void 0;
|
|
10843
|
+
if (parked) return {
|
|
10844
|
+
settings: readSettings(),
|
|
10845
|
+
error: parked
|
|
10846
|
+
};
|
|
10516
10847
|
if (change.agent !== void 0 && change.agent !== "" && !AGENT_IDS.includes(change.agent)) return {
|
|
10517
10848
|
settings: readSettings(),
|
|
10518
10849
|
error: `There is no agent called \`${change.agent}\`.`
|
|
@@ -10668,11 +10999,15 @@ function renderReviewsSection(settings) {
|
|
|
10668
10999
|
<h2>Reviews</h2>
|
|
10669
11000
|
<label class="prb-field prb-settings-field">
|
|
10670
11001
|
<span>Default coding agent</span>
|
|
10671
|
-
<select data-settings-agent aria-describedby="prb-agent-help">${[
|
|
11002
|
+
<select data-settings-agent aria-describedby="prb-agent-help">${[
|
|
11003
|
+
`<option value=""${settings.agent ? "" : " selected"}>Ask each time</option>`,
|
|
11004
|
+
...settings.agents.map((id) => `<option value="${escapeHtml$1(id)}"${settings.agent === id ? " selected" : ""}>${escapeHtml$1(id)}</option>`),
|
|
11005
|
+
...settings.agentParked && settings.agent ? [`<option value="${escapeHtml$1(settings.agent)}" selected disabled>${escapeHtml$1(settings.agent)} (turned off)</option>`] : []
|
|
11006
|
+
].join("")}</select>
|
|
10672
11007
|
</label>
|
|
10673
11008
|
<p class="prb-settings-help" id="prb-agent-help">Used when a review does not name one. PR Review
|
|
10674
11009
|
Buddy uses an agent already installed on this machine and its existing sign-in, so this changes
|
|
10675
|
-
which one runs and never asks you for a credential.</p
|
|
11010
|
+
which one runs and never asks you for a credential.</p>${settings.agentParked ? `\n <p class="prb-settings-help" role="alert">${escapeHtml$1(settings.agentParked)} Choose another default, or Ask each time.</p>` : ""}
|
|
10676
11011
|
</section>`;
|
|
10677
11012
|
}
|
|
10678
11013
|
/**
|
|
@@ -11152,6 +11487,25 @@ feature: E</pre>
|
|
|
11152
11487
|
<p>Deleting a review removes its record and the isolated checkouts it was made in. It never
|
|
11153
11488
|
touches your branch or its commits. Delete one from its menu, or tick reviews in the Reviews
|
|
11154
11489
|
list, one at a time or a whole branch at once, and delete them together.</p>`
|
|
11490
|
+
},
|
|
11491
|
+
{
|
|
11492
|
+
id: "collections",
|
|
11493
|
+
title: "Collections",
|
|
11494
|
+
body: `<p>A collection groups reviews that belong together, such as the pull requests of one
|
|
11495
|
+
refactor or one release. It says what belongs together; whether each review still needs your
|
|
11496
|
+
attention is still Active, Waiting or Done, so adding or removing a review never changes its
|
|
11497
|
+
state.</p>
|
|
11498
|
+
<p>Make one with New collection on the Reviews page, or from Manage collections in a card's menu.
|
|
11499
|
+
To add several reviews at once, choose Select, tick them, and choose Manage collections. A review
|
|
11500
|
+
can be in more than one collection. Ticking a collection adds every selected review to it and
|
|
11501
|
+
leaves their other collections alone.</p>
|
|
11502
|
+
<p>Each collection lists its reviews that are not Done. Marking one Done moves it under Show
|
|
11503
|
+
completed, in every collection it is in, and Move to active brings it back. A review in a
|
|
11504
|
+
collection is listed there rather than under Active, Waiting or Done. A pinned review stays under
|
|
11505
|
+
Pinned. The Collections filter shows chosen collections, or Uncollected for reviews in none.</p>
|
|
11506
|
+
<p>Reviews in a collection are kept for ninety days, like pinned and Done reviews, counted from
|
|
11507
|
+
when you last opened or marked them. Adding one to a collection doesn't restart that count.
|
|
11508
|
+
Deleting a collection keeps its reviews.</p>`
|
|
11155
11509
|
},
|
|
11156
11510
|
{
|
|
11157
11511
|
id: "several-pull-requests",
|
|
@@ -11205,8 +11559,8 @@ feature: E</pre>
|
|
|
11205
11559
|
automatically:</p>
|
|
11206
11560
|
<ul>
|
|
11207
11561
|
<li><b>Reviews</b> are kept for thirty days after you last opened or changed them.</li>
|
|
11208
|
-
<li><b>Reviews you kept</b> (saved, marked Done, or on your review queue) are
|
|
11209
|
-
days instead.</li>
|
|
11562
|
+
<li><b>Reviews you kept</b> (saved, marked Done, in a collection, or on your review queue) are
|
|
11563
|
+
kept for ninety days instead.</li>
|
|
11210
11564
|
<li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
|
|
11211
11565
|
<a href="#isolated-checkouts">Isolated checkouts</a>.</li>
|
|
11212
11566
|
<li><b>Runs</b>, the record on the Runs page of what ran and how it ended, are kept for seven
|
|
@@ -11383,6 +11737,50 @@ function renderNotificationRow(notification, gone) {
|
|
|
11383
11737
|
</li>`;
|
|
11384
11738
|
}
|
|
11385
11739
|
//#endregion
|
|
11740
|
+
//#region ../../packages/review-harness/src/workspace/collections_live.ts
|
|
11741
|
+
/**
|
|
11742
|
+
* Collections as they relate to reviews that actually exist.
|
|
11743
|
+
*
|
|
11744
|
+
* `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
|
|
11745
|
+
* `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
|
|
11746
|
+
* lives here, which imports both and is imported by neither.
|
|
11747
|
+
*/
|
|
11748
|
+
/**
|
|
11749
|
+
* Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
|
|
11750
|
+
* the result if that changed anything. Read against every group, not the windowed list, so a branch
|
|
11751
|
+
* the recency window happens to hide keeps its place.
|
|
11752
|
+
*
|
|
11753
|
+
* Compacted again under the lock rather than written from what was read, so a member somebody added
|
|
11754
|
+
* since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
|
|
11755
|
+
*/
|
|
11756
|
+
function settleCollections(groups) {
|
|
11757
|
+
const live = new Set(groups.map((group) => group.key));
|
|
11758
|
+
const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
|
|
11759
|
+
...collection,
|
|
11760
|
+
members: collection.members.filter((key) => live.has(key))
|
|
11761
|
+
})) : null;
|
|
11762
|
+
const read = readCollections();
|
|
11763
|
+
const compacted = compact(read);
|
|
11764
|
+
if (compacted === null) return read;
|
|
11765
|
+
return updateCollections((current) => compact(current)) ?? compacted;
|
|
11766
|
+
}
|
|
11767
|
+
/**
|
|
11768
|
+
* The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
|
|
11769
|
+
* the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
|
|
11770
|
+
* left out, and the caller says so.
|
|
11771
|
+
*/
|
|
11772
|
+
function lineageKeysFor(ids) {
|
|
11773
|
+
const keys = /* @__PURE__ */ new Map();
|
|
11774
|
+
for (const id of new Set(ids)) {
|
|
11775
|
+
const workspace = loadWorkspace(id);
|
|
11776
|
+
if (!workspace) continue;
|
|
11777
|
+
try {
|
|
11778
|
+
keys.set(id, lineageKey(summarise(workspace)));
|
|
11779
|
+
} catch {}
|
|
11780
|
+
}
|
|
11781
|
+
return keys;
|
|
11782
|
+
}
|
|
11783
|
+
//#endregion
|
|
11386
11784
|
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11387
11785
|
/**
|
|
11388
11786
|
* The local Git working folders PR Review Buddy can find right now.
|
|
@@ -11602,11 +12000,7 @@ function modelHelpOf(agentId) {
|
|
|
11602
12000
|
}
|
|
11603
12001
|
}
|
|
11604
12002
|
function agentNameOf(agentId) {
|
|
11605
|
-
|
|
11606
|
-
return agentById(agentId).displayName;
|
|
11607
|
-
} catch {
|
|
11608
|
-
return agentId;
|
|
11609
|
-
}
|
|
12003
|
+
return isAgentId(agentId) ? agentIdentityOf(agentId).displayName : agentId;
|
|
11610
12004
|
}
|
|
11611
12005
|
var refuse = (status, error, extra = {}) => ({
|
|
11612
12006
|
status,
|
|
@@ -12602,6 +12996,7 @@ function reviewsRevision(live = analysisStates(), operations = liveOperations())
|
|
|
12602
12996
|
}
|
|
12603
12997
|
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`);
|
|
12604
12998
|
hash.update(`queue:${queueStamp()}\n`);
|
|
12999
|
+
hash.update(`collections:${collectionsStamp()}\n`);
|
|
12605
13000
|
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
12606
13001
|
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
12607
13002
|
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`);
|
|
@@ -12793,7 +13188,7 @@ function reviewHref(review) {
|
|
|
12793
13188
|
}
|
|
12794
13189
|
/** The agent's name, only where the record names one this build knows. */
|
|
12795
13190
|
function agentName(agentId) {
|
|
12796
|
-
return agentId && isAgentId(agentId) ?
|
|
13191
|
+
return agentId && isAgentId(agentId) ? agentIdentityOf(agentId).displayName : void 0;
|
|
12797
13192
|
}
|
|
12798
13193
|
/** Who asked and who ran it, each only when known. Two facts, never one guessed from the other. */
|
|
12799
13194
|
function provenance(source, agentId) {
|
|
@@ -14598,6 +14993,8 @@ async function handle(req, res, options) {
|
|
|
14598
14993
|
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
14599
14994
|
if (url.pathname === "/sections") return handleSections(req, res, options.indexToken);
|
|
14600
14995
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
14996
|
+
if (url.pathname === "/collections") return handleCollections(req, res, options.indexToken);
|
|
14997
|
+
if (url.pathname === "/collections/members") return handleCollectionMembers(req, res, options.indexToken);
|
|
14601
14998
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
14602
14999
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
14603
15000
|
if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
|
|
@@ -14697,18 +15094,44 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14697
15094
|
}
|
|
14698
15095
|
const cookie = indexCookieHeader(indexToken);
|
|
14699
15096
|
if (onward) return redirect(res, onward, cookie);
|
|
15097
|
+
sendHtml(res, 200, renderReviewsPage(indexView()), cookie);
|
|
15098
|
+
}
|
|
15099
|
+
/**
|
|
15100
|
+
* Which cards a page is drawn with: each branch, by its newest review. Equality only. Changes when a
|
|
15101
|
+
* branch is analysed again, or a card is added or deleted, which are the changes that make a
|
|
15102
|
+
* fragment drawn now wrong for a page drawn then. Nothing else moves it.
|
|
15103
|
+
*/
|
|
15104
|
+
function cardsSnapshot(groups) {
|
|
15105
|
+
const hash = createHash("sha1");
|
|
15106
|
+
for (const line of groups.map((group) => `${group.key}\t${group.latest.id}`).sort()) hash.update(`${line}\n`);
|
|
15107
|
+
return hash.digest("hex").slice(0, 16);
|
|
15108
|
+
}
|
|
15109
|
+
/**
|
|
15110
|
+
* Everything the reviews index is drawn from, read in one pass.
|
|
15111
|
+
*
|
|
15112
|
+
* Shared by the page and by the collections routes, which answer with the Collections area drawn
|
|
15113
|
+
* from the same view, so a card in a collection after a change is the card a reload would draw.
|
|
15114
|
+
*/
|
|
15115
|
+
function indexView() {
|
|
14700
15116
|
const states = analysisStates();
|
|
14701
15117
|
const operations = liveOperations();
|
|
14702
15118
|
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
14703
15119
|
const everyGroup = groupByLineage(reviews);
|
|
14704
15120
|
const queue = settleQueue(everyGroup);
|
|
15121
|
+
const collections = settleCollections(everyGroup);
|
|
14705
15122
|
const kept = indexKeptIds(reviews, queue);
|
|
14706
15123
|
const activity = reviewActivity(everyGroup);
|
|
14707
|
-
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup
|
|
15124
|
+
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup, {
|
|
15125
|
+
keys: new Set(collections.flatMap((collection) => collection.members)),
|
|
15126
|
+
limit: MAX_COLLECTED_BRANCHES
|
|
15127
|
+
});
|
|
14708
15128
|
const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
|
|
14709
|
-
|
|
15129
|
+
const live = queue.filter((id) => rendered.has(id));
|
|
15130
|
+
return {
|
|
14710
15131
|
groups,
|
|
14711
|
-
|
|
15132
|
+
snapshot: cardsSnapshot(groups),
|
|
15133
|
+
queue: live,
|
|
15134
|
+
collections,
|
|
14712
15135
|
analyses: states,
|
|
14713
15136
|
operations,
|
|
14714
15137
|
moved: movedReviews(),
|
|
@@ -14719,7 +15142,7 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14719
15142
|
driving: isDriving(),
|
|
14720
15143
|
theme: readPreferences().theme,
|
|
14721
15144
|
sectionsCollapsed: readPreferences().reviewSections
|
|
14722
|
-
}
|
|
15145
|
+
};
|
|
14723
15146
|
}
|
|
14724
15147
|
/**
|
|
14725
15148
|
* Where `?go=` is allowed to send someone, which is one shape and no other.
|
|
@@ -15373,7 +15796,7 @@ async function handleSections(req, res, indexToken) {
|
|
|
15373
15796
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15374
15797
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15375
15798
|
const body = await readJson(req);
|
|
15376
|
-
if (!
|
|
15799
|
+
if (!isFoldKey(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
15377
15800
|
writeSectionCollapsed(body.section, body.collapsed);
|
|
15378
15801
|
send(res, 200, { ok: true });
|
|
15379
15802
|
}
|
|
@@ -15442,6 +15865,102 @@ function addMany(res, ids) {
|
|
|
15442
15865
|
});
|
|
15443
15866
|
}
|
|
15444
15867
|
/**
|
|
15868
|
+
* Making, renaming and deleting collections, from the Reviews page.
|
|
15869
|
+
*
|
|
15870
|
+
* The index cookie, like the queue: this is the list acting on itself. Every answer that changed
|
|
15871
|
+
* something carries the collections as stored and the Collections area redrawn from them, so the
|
|
15872
|
+
* page swaps in exactly what a reload would show rather than building cards of its own.
|
|
15873
|
+
*/
|
|
15874
|
+
async function handleCollections(req, res, indexToken) {
|
|
15875
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15876
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15877
|
+
const body = await readJson(req);
|
|
15878
|
+
const action = body?.action;
|
|
15879
|
+
const name = typeof body?.name === "string" ? body.name : "";
|
|
15880
|
+
const id = isCollectionId(body?.id) ? body.id : null;
|
|
15881
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15882
|
+
if (action === "create") return answerCollections(res, createCollection(name), [], snapshot);
|
|
15883
|
+
if (action === "rename" && id) return answerCollections(res, renameCollection(id, name), [], snapshot);
|
|
15884
|
+
if (action === "delete" && id) {
|
|
15885
|
+
const outcome = deleteCollection(id);
|
|
15886
|
+
if (outcome.ok) forgetSections([collectionFold(id), completedFold(id)]);
|
|
15887
|
+
return answerCollections(res, outcome, [], snapshot);
|
|
15888
|
+
}
|
|
15889
|
+
send(res, 400, { error: "Unknown collections action." });
|
|
15890
|
+
}
|
|
15891
|
+
/**
|
|
15892
|
+
* Putting reviews into one collection, or taking them out of it: one card's menu or the bulk bar.
|
|
15893
|
+
*
|
|
15894
|
+
* The page sends review ids, the ids on its cards, and the lineage each belongs to is derived here,
|
|
15895
|
+
* because a lineage is what a collection holds and the server is the only side that can say which.
|
|
15896
|
+
* Only the named collection changes, whatever else the reviews belong to. `create` makes the
|
|
15897
|
+
* collection first, by name, so "Create Refactor" from the panel is one request and cannot leave an
|
|
15898
|
+
* empty collection behind a failed assignment without saying so.
|
|
15899
|
+
*/
|
|
15900
|
+
async function handleCollectionMembers(req, res, indexToken) {
|
|
15901
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15902
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15903
|
+
const body = await readJson(req);
|
|
15904
|
+
const action = body?.action;
|
|
15905
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((one) => typeof one === "string") : [];
|
|
15906
|
+
if (action !== "add" && action !== "remove" || ids.length === 0) return send(res, 400, { error: "Say which reviews, and whether to add or remove them." });
|
|
15907
|
+
const keys = lineageKeysFor(ids);
|
|
15908
|
+
const gone = ids.filter((one) => !keys.has(one));
|
|
15909
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15910
|
+
if (action === "add" && typeof body?.create === "string" && !isCollectionId(body?.collection)) {
|
|
15911
|
+
if (gone.length > 0) return send(res, 409, { error: "A review you selected has been deleted, so nothing was created. Reload the page and try again." });
|
|
15912
|
+
return answerCollections(res, createWithMembers(body.create, [...keys.values()]), [], snapshot);
|
|
15913
|
+
}
|
|
15914
|
+
const collection = isCollectionId(body?.collection) ? body.collection : null;
|
|
15915
|
+
if (collection === null) return send(res, 400, { error: "Say which collection." });
|
|
15916
|
+
answerCollections(res, setMembership([...keys.values()], collection, action), gone.map((one) => ({
|
|
15917
|
+
id: one,
|
|
15918
|
+
error: "A review you selected has been deleted."
|
|
15919
|
+
})), snapshot);
|
|
15920
|
+
}
|
|
15921
|
+
/**
|
|
15922
|
+
* The answer every collections route gives: what is stored, and the area drawn from it, or why not.
|
|
15923
|
+
*
|
|
15924
|
+
* **Only for the page it would fit.** The area's cards and the memberships are keyed by each
|
|
15925
|
+
* branch's newest review, and a page drawn before a branch was analysed again, or before one was
|
|
15926
|
+
* added or deleted, holds cards under other ids. Swapping a fragment drawn from today's reviews into
|
|
15927
|
+
* that page would mix the two. So the page says which cards it was drawn with (`snapshot`, from
|
|
15928
|
+
* `cardsSnapshot`), and when that is not what the server would draw now the answer is `stale`, with
|
|
15929
|
+
* no fragment: the change itself is stored either way, by lineage, and the page reloads to show it.
|
|
15930
|
+
*/
|
|
15931
|
+
function answerCollections(res, outcome, refused, snapshot) {
|
|
15932
|
+
if (!outcome.ok) {
|
|
15933
|
+
if (outcome.reason === "missing") return send(res, 404, { error: "That collection has been deleted." });
|
|
15934
|
+
if (outcome.reason === "full") return send(res, 409, { error: "That is more than a collection can hold." });
|
|
15935
|
+
if (outcome.reason === "unsaved") return send(res, 500, { error: "The collections could not be saved." });
|
|
15936
|
+
return send(res, 409, {
|
|
15937
|
+
error: nameProblemText(outcome.reason),
|
|
15938
|
+
reason: outcome.reason
|
|
15939
|
+
});
|
|
15940
|
+
}
|
|
15941
|
+
const view = indexView();
|
|
15942
|
+
const collections = (view.collections ?? []).map(({ id, name }) => ({
|
|
15943
|
+
id,
|
|
15944
|
+
name
|
|
15945
|
+
}));
|
|
15946
|
+
if (snapshot === "" || snapshot !== view.snapshot) return send(res, 200, {
|
|
15947
|
+
stale: true,
|
|
15948
|
+
collections,
|
|
15949
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15950
|
+
refused
|
|
15951
|
+
});
|
|
15952
|
+
const memberOf = {};
|
|
15953
|
+
for (const group of view.groups) memberOf[group.latest.id] = (view.collections ?? []).filter((one) => one.members.includes(group.key)).map((one) => one.id);
|
|
15954
|
+
send(res, 200, {
|
|
15955
|
+
collections,
|
|
15956
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15957
|
+
memberOf,
|
|
15958
|
+
operations: operationCollections(view.operations ?? [], view.groups, membershipsByLineage(view.collections ?? [])),
|
|
15959
|
+
area: renderCollectionsArea(view),
|
|
15960
|
+
refused
|
|
15961
|
+
});
|
|
15962
|
+
}
|
|
15963
|
+
/**
|
|
15445
15964
|
* Delete one review, or everything on one card.
|
|
15446
15965
|
*
|
|
15447
15966
|
* Authorised the same way pinning is, and for the same reason: this is the list acting on itself,
|
|
@@ -15598,6 +16117,13 @@ var MAX_LISTED_BRANCHES = 50;
|
|
|
15598
16117
|
*/
|
|
15599
16118
|
var MAX_KEPT_BRANCHES = 50;
|
|
15600
16119
|
/**
|
|
16120
|
+
* How many collected branches the list draws, in a window of their own beside the two above, so a
|
|
16121
|
+
* collection never competes with Pinned or the queue for a place. One collection's worth: a single
|
|
16122
|
+
* collection is always drawn whole, and only collections holding more than this between them leave
|
|
16123
|
+
* their oldest members out, which each collection then says.
|
|
16124
|
+
*/
|
|
16125
|
+
var MAX_COLLECTED_BRANCHES = 500;
|
|
16126
|
+
/**
|
|
15601
16127
|
* A finished review's workspace was served.
|
|
15602
16128
|
*
|
|
15603
16129
|
* **This counts page serves, not people and not sessions.** A reload, a restored tab, the CLI
|
|
@@ -15996,6 +16522,8 @@ async function handleReanalyse(req, res, workspace) {
|
|
|
15996
16522
|
if (req.method !== "POST") return send(res, 405, { error: "Analysing a review again needs a POST." });
|
|
15997
16523
|
const current = workspace.jobId ? loadJob(workspace.jobId) : null;
|
|
15998
16524
|
if (current && (!isTerminal(current) || isClaimed(current.id))) return send(res, 409, { error: "This review is still being made. Wait for it to finish before analysing it again." });
|
|
16525
|
+
const parked = parkedReason(agentIdOf(workspace.session));
|
|
16526
|
+
if (parked) return send(res, 409, { error: `${parked} To analyse this review again with another agent, run \`prreviewbuddy review --review ${workspace.id} --reanalyse\` with ${runnableAgents().flags}.` });
|
|
15999
16527
|
await new Promise((resolve) => {
|
|
16000
16528
|
const run = reanalyseReview(workspace.id, void 0, () => {
|
|
16001
16529
|
send(res, 200, { ok: true });
|
|
@@ -16036,6 +16564,8 @@ async function handleKiss(req, res, workspace) {
|
|
|
16036
16564
|
if (!audience) return send(res, 400, { error: "A KISS review is written either for the person who wrote the code or for somebody reviewing it. Choose which before running it." });
|
|
16037
16565
|
const current = latestKissRun(workspace);
|
|
16038
16566
|
if (current && current.status === "generating" && isClaimed(current.jobId)) return send(res, 409, { error: "A KISS review of this change is already running." });
|
|
16567
|
+
const parked = parkedReason(kissAgentId(workspace));
|
|
16568
|
+
if (parked) return send(res, 409, { error: `${parked} A KISS review started here uses the agent this review was made with. To run one with another agent, start it from the terminal with \`--lens kiss\` and ${runnableAgents().flags}.` });
|
|
16039
16569
|
startKissJob({
|
|
16040
16570
|
workspaceId: workspace.id,
|
|
16041
16571
|
audience,
|