prreviewbuddy 0.25.8 → 0.25.13
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 +86 -0
- package/dist/main.js +58 -7
- package/dist/{relative_time-D760FOL9.js → relative_time-vn8Ex9E8.js} +395 -80
- package/dist/server.js +312 -42
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-CIsM3PMS.js +1 -0
- package/static/{chunk-B9H2wUlQ.js → chunk-gBDI1-Cy.js} +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +48 -0
- package/static/workspace.js +44 -44
|
@@ -2002,15 +2002,11 @@ async function defaultRemoteBranch(repoPath) {
|
|
|
2002
2002
|
async function buildChangeSet(options) {
|
|
2003
2003
|
const repoPath = (await git(options.repoPath, ["rev-parse", "--show-toplevel"])).trim();
|
|
2004
2004
|
const base = options.resolvedBase ?? await resolveBase(repoPath, options.base);
|
|
2005
|
-
const mergeBase =
|
|
2006
|
-
"merge-base",
|
|
2007
|
-
base.revision,
|
|
2008
|
-
"HEAD"
|
|
2009
|
-
])).trim();
|
|
2005
|
+
const mergeBase = await mergeBaseOf(repoPath, base, "HEAD");
|
|
2010
2006
|
const headRef = await currentBranch(repoPath);
|
|
2011
2007
|
const isLinkedWorktree = await linkedWorktree(repoPath);
|
|
2012
2008
|
const files = await collectFiles(repoPath, [mergeBase, "HEAD"]);
|
|
2013
|
-
if (files.length === 0) throw emptyChange(headRef, base.display, options.requestNumber);
|
|
2009
|
+
if (files.length === 0) throw emptyChange(headRef, base.display, options.requestNumber === void 0 ? {} : { requestNumber: options.requestNumber });
|
|
2014
2010
|
disagreement(files.length, base.display, options.expected);
|
|
2015
2011
|
const blobShas = await headBlobShas(repoPath);
|
|
2016
2012
|
for (const file of files) file.sha = blobShas.get(file.name) ?? "";
|
|
@@ -2073,10 +2069,19 @@ function disagreement(found, baseRef, expected) {
|
|
|
2073
2069
|
*
|
|
2074
2070
|
* The message names both refs, because "no changes" on its own reads as a bug when the branch is
|
|
2075
2071
|
* visibly full of work and the real story is that it was compared against itself.
|
|
2072
|
+
*
|
|
2073
|
+
* `uncommitted` is only ever counted by a caller standing in the reviewer's own checkout, which is
|
|
2074
|
+
* `resolveTarget`. This module usually runs inside the isolated worktree, where `git status` is
|
|
2075
|
+
* clean by construction, so it could never see the edits it would be explaining.
|
|
2076
2076
|
*/
|
|
2077
|
-
function emptyChange(headRef, baseRef, requestNumber) {
|
|
2077
|
+
function emptyChange(headRef, baseRef, { requestNumber, uncommitted = 0 } = {}) {
|
|
2078
2078
|
if (requestNumber !== void 0) return new EmptyChangeError(`Pull request ${requestNumber} is already merged into ${baseRef}, so there is nothing left to compare. PR Review Buddy reviews a change before it lands, and a merged request has no diff against the branch it went into. Review the branch it was merged from, if it is still around, or a request that is still open.`);
|
|
2079
|
-
|
|
2079
|
+
const comparison = headRef === baseRef ? `${headRef} is the base it would be compared against` : `${headRef} has no commits that ${baseRef} does not`;
|
|
2080
|
+
if (uncommitted === 0) return new EmptyChangeError(`No committed changes to review. ${comparison}. Switch to the branch containing your changes, or commit them first.`);
|
|
2081
|
+
const remedy = headRef === baseRef ? "Commit them on a branch, then try again." : "Commit them, then try again.";
|
|
2082
|
+
return new EmptyChangeError(`No committed changes to review. ${comparison}.\n\nYou have ${uncommitted} uncommitted ${uncommitted === 1 ? "change" : "changes"}. PR Review Buddy reviews a fixed snapshot of your code, so uncommitted changes are not included.
|
|
2083
|
+
|
|
2084
|
+
` + remedy);
|
|
2080
2085
|
}
|
|
2081
2086
|
/**
|
|
2082
2087
|
* A linked worktree has its own `.git` file pointing into the main repository's directory, so the
|
|
@@ -2189,12 +2194,44 @@ async function headBlobShas(repoPath) {
|
|
|
2189
2194
|
}
|
|
2190
2195
|
return map;
|
|
2191
2196
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2197
|
+
/**
|
|
2198
|
+
* The comparison every question about "what changed" is asked with.
|
|
2199
|
+
*
|
|
2200
|
+
* One definition, because `resolveTarget` asks whether a change is empty before any worktree
|
|
2201
|
+
* exists and `buildChangeSet` asks again inside one. Two spellings of the range would let the two
|
|
2202
|
+
* answers drift, and the early refusal would then turn away a change the review could have read.
|
|
2203
|
+
*/
|
|
2204
|
+
function rangeDiff(range) {
|
|
2205
|
+
return [
|
|
2194
2206
|
"diff",
|
|
2195
2207
|
"--find-renames",
|
|
2196
2208
|
...range
|
|
2197
2209
|
];
|
|
2210
|
+
}
|
|
2211
|
+
async function mergeBaseOf(repoPath, base, head) {
|
|
2212
|
+
return (await git(repoPath, [
|
|
2213
|
+
"merge-base",
|
|
2214
|
+
base.revision,
|
|
2215
|
+
head
|
|
2216
|
+
])).trim();
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Whether `head` carries any committed change against `base`, by the same comparison
|
|
2220
|
+
* `buildChangeSet` makes.
|
|
2221
|
+
*
|
|
2222
|
+
* Asks for the file list rather than comparing the merge base with `head`, because those disagree:
|
|
2223
|
+
* a branch whose commits cancel out (a change and its revert) is ahead of its base and still has
|
|
2224
|
+
* nothing to review, and the change set would refuse it.
|
|
2225
|
+
*/
|
|
2226
|
+
async function hasCommittedChanges(repoPath, base, head) {
|
|
2227
|
+
return parseNameStatus(await git(repoPath, [
|
|
2228
|
+
...rangeDiff([await mergeBaseOf(repoPath, base, head), head]),
|
|
2229
|
+
"--name-status",
|
|
2230
|
+
"-z"
|
|
2231
|
+
])).length > 0;
|
|
2232
|
+
}
|
|
2233
|
+
async function collectFiles(repoPath, range) {
|
|
2234
|
+
const diffArgs = rangeDiff(range);
|
|
2198
2235
|
const [nameStatus, numstat, combinedPatch] = await Promise.all([
|
|
2199
2236
|
git(repoPath, [
|
|
2200
2237
|
...diffArgs,
|
|
@@ -2581,6 +2618,53 @@ async function verifyDefaultBase(root) {
|
|
|
2581
2618
|
}
|
|
2582
2619
|
}
|
|
2583
2620
|
/**
|
|
2621
|
+
* Refuse a review of nothing before one is created, rather than from inside it.
|
|
2622
|
+
*
|
|
2623
|
+
* `buildChangeSet` refuses an empty comparison too, but it runs in the job's `context` phase: after
|
|
2624
|
+
* the workspace is minted and a full `git worktree add`, so the refusal was reported as a *paused*
|
|
2625
|
+
* review whose "run it again and it carries on" refuses identically for ever. Called from `startJob`
|
|
2626
|
+
* rather than `resolveTarget`, because a resolved target may still be handed an existing review, and
|
|
2627
|
+
* a branch that has since been merged must keep opening the review it already has.
|
|
2628
|
+
*
|
|
2629
|
+
* Asks the question `buildChangeSet` asks, through `hasCommittedChanges`, against the base the job
|
|
2630
|
+
* will use (`baseFor` in `review_job.ts`), so the two cannot disagree about what "empty" means. A
|
|
2631
|
+
* base that does not resolve is left for the job, which already says so in its own words.
|
|
2632
|
+
*/
|
|
2633
|
+
async function refuseEmptyTarget(target) {
|
|
2634
|
+
const root = target.originRepoPath;
|
|
2635
|
+
let changed;
|
|
2636
|
+
let baseDisplay;
|
|
2637
|
+
try {
|
|
2638
|
+
const base = target.resolvedBase ?? await resolveBase(root, target.base);
|
|
2639
|
+
baseDisplay = base.display;
|
|
2640
|
+
changed = await hasCommittedChanges(root, base, target.sha);
|
|
2641
|
+
} catch {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (changed) return;
|
|
2645
|
+
throw emptyChange(target.branch, baseDisplay, {
|
|
2646
|
+
...target.pullRequest ? { requestNumber: target.pullRequest.number } : {},
|
|
2647
|
+
uncommitted: await uncommittedIn(root, target)
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* How many changes `git status` would show, but only when this checkout is the thing under review.
|
|
2652
|
+
*
|
|
2653
|
+
* Your edits explain an empty review only when they sit on the commit being reviewed. Standing on
|
|
2654
|
+
* `main` while naming a level `feat/x`, they are somebody else's business, and `--branch` promises
|
|
2655
|
+
* never to read the checkout at all. Untracked files count, because they are visible work too, and
|
|
2656
|
+
* the count is by line of `--porcelain` rather than `-z`, where a rename takes two records.
|
|
2657
|
+
*/
|
|
2658
|
+
async function uncommittedIn(root, target) {
|
|
2659
|
+
if (target.pullRequest) return 0;
|
|
2660
|
+
try {
|
|
2661
|
+
if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf(root) !== target.branch) return 0;
|
|
2662
|
+
return (await git(root, ["status", "--porcelain"])).split("\n").filter((line) => line.trim()).length;
|
|
2663
|
+
} catch {
|
|
2664
|
+
return 0;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2584
2668
|
* The base half of a target, rebuilt from a review that already recorded one.
|
|
2585
2669
|
*
|
|
2586
2670
|
* Three places rebuild a target from a stored review: re-analysing one, resuming a job, and pinning
|
|
@@ -3485,6 +3569,81 @@ function saveWorkspace(workspace) {
|
|
|
3485
3569
|
if (!existsSync(workspacePath(workspace.id))) return;
|
|
3486
3570
|
writeWorkspace(workspace);
|
|
3487
3571
|
}
|
|
3572
|
+
/**
|
|
3573
|
+
* An opaque token representing the observed version of this review's mutable state.
|
|
3574
|
+
*
|
|
3575
|
+
* **Tokens support equality comparison only.** The implementation derives the token from
|
|
3576
|
+
* filesystem metadata; no caller may depend on that, and none may order two tokens. mtime is not a
|
|
3577
|
+
* monotonic application revision — a clock change, a file restored from a backup and a filesystem
|
|
3578
|
+
* with coarse timestamps all break ordering, and none of them break equality. Keeping the contract
|
|
3579
|
+
* this narrow is what lets a counter, a content hash or a composite over several files replace the
|
|
3580
|
+
* body of this function later without touching the wire or the page.
|
|
3581
|
+
*
|
|
3582
|
+
* So a consumer asking "did the workspace change under this page" writes:
|
|
3583
|
+
*
|
|
3584
|
+
* if (pageRevision != null && serverRevision != null && pageRevision !== serverRevision) {
|
|
3585
|
+
* // state changed
|
|
3586
|
+
* }
|
|
3587
|
+
*
|
|
3588
|
+
* Never the bare `!==`, and never `>`. **An absent token means unknown, not equal**: two reviews
|
|
3589
|
+
* that both failed to produce one have told you nothing, and a bare `!==` over two `undefined`s
|
|
3590
|
+
* silently concludes "unchanged", which is the opposite of what happened.
|
|
3591
|
+
*
|
|
3592
|
+
* Opaque includes its shape. `mtimeMs` carries sub-millisecond precision, so the token is a float
|
|
3593
|
+
* and reaches the page as one; a consumer that `parseInt`s it would throw away the digits that
|
|
3594
|
+
* distinguish two writes in the same millisecond. Compare what you were given, unchanged.
|
|
3595
|
+
*
|
|
3596
|
+
* Nothing reads this yet, deliberately. It is laid down so that a later auto-refresh can ask a
|
|
3597
|
+
* cheap question without a protocol having to be invented at the same time as the feature, and
|
|
3598
|
+
* building the polling now would be committing to an answer before there is a question.
|
|
3599
|
+
*
|
|
3600
|
+
* `ENOENT` only. A review whose file has gone is the one condition "unknown" honestly describes,
|
|
3601
|
+
* and it is a real race: a route loads the workspace and then stats it, so a deletion in between
|
|
3602
|
+
* lands here. Any other stat failure is a fault on this machine and is left to surface, because a
|
|
3603
|
+
* permissions problem dressed as an absent token is a product where nothing is wrong and nothing
|
|
3604
|
+
* works.
|
|
3605
|
+
*/
|
|
3606
|
+
function workspaceRevision(workspace) {
|
|
3607
|
+
try {
|
|
3608
|
+
return statSync(workspacePath(workspace.id)).mtimeMs;
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
if (error?.code === "ENOENT") return void 0;
|
|
3611
|
+
throw error;
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* Every stored review as an id and a revision token, without reading one of them.
|
|
3616
|
+
*
|
|
3617
|
+
* Here rather than in `reviews_revision.ts` because this module owns the layout of `workspaces/`,
|
|
3618
|
+
* the same reason `deleteWorkspace` is here. What the caller gets is deliberately not a review: it
|
|
3619
|
+
* is the pair that answers "has anything the list draws changed", for every review at once.
|
|
3620
|
+
*
|
|
3621
|
+
* **`stat` only, never a parse.** A review record holds the whole diff and can be megabytes, and
|
|
3622
|
+
* this is read on a one-second poll while an analysis runs. Reading them properly through
|
|
3623
|
+
* `recentWorkspaces` would be parsing every diff on the machine several times a minute to find out
|
|
3624
|
+
* whether anything had changed, which is a worse bargain than the staleness it fixes.
|
|
3625
|
+
*
|
|
3626
|
+
* **Sorted by id.** `readdirSync` promises no order, so an unsorted answer would produce a
|
|
3627
|
+
* different token for an unchanged directory whenever the filesystem felt like enumerating
|
|
3628
|
+
* differently. On a design that reloads the page when the token changes, that is a reload the
|
|
3629
|
+
* reviewer cannot account for.
|
|
3630
|
+
*/
|
|
3631
|
+
function workspaceStamps() {
|
|
3632
|
+
let names;
|
|
3633
|
+
try {
|
|
3634
|
+
names = readdirSync(WORKSPACES_DIR);
|
|
3635
|
+
} catch {
|
|
3636
|
+
return [];
|
|
3637
|
+
}
|
|
3638
|
+
const stamps = [];
|
|
3639
|
+
for (const name of names) {
|
|
3640
|
+
if (!name.endsWith(".json")) continue;
|
|
3641
|
+
try {
|
|
3642
|
+
stamps.push([name.slice(0, -5), statSync(join(WORKSPACES_DIR, name)).mtimeMs]);
|
|
3643
|
+
} catch {}
|
|
3644
|
+
}
|
|
3645
|
+
return stamps.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
3646
|
+
}
|
|
3488
3647
|
function writeWorkspace(workspace) {
|
|
3489
3648
|
mkdirSync(WORKSPACES_DIR, { recursive: true });
|
|
3490
3649
|
writeFileSync(workspacePath(workspace.id), JSON.stringify(workspace), { mode: 384 });
|
|
@@ -4102,6 +4261,10 @@ var WORK_PHASES = [
|
|
|
4102
4261
|
"conversation",
|
|
4103
4262
|
"analysing"
|
|
4104
4263
|
];
|
|
4264
|
+
/** A record written before jobs said what they were for was always a review. */
|
|
4265
|
+
function purposeOf(job) {
|
|
4266
|
+
return job.purpose ?? "review";
|
|
4267
|
+
}
|
|
4105
4268
|
/**
|
|
4106
4269
|
* Two forms per phase, because a finished step and a running one are different sentences.
|
|
4107
4270
|
*
|
|
@@ -4136,6 +4299,49 @@ var PHASES = {
|
|
|
4136
4299
|
}
|
|
4137
4300
|
};
|
|
4138
4301
|
/**
|
|
4302
|
+
* The same phases, as an update runs them.
|
|
4303
|
+
*
|
|
4304
|
+
* An update reuses the phase enum because the sweep, the claims and `isTerminal` all key on it, but
|
|
4305
|
+
* the work behind each phase is different and so is the order: the conversation is re-read before
|
|
4306
|
+
* any checkout is made, because a review with no new commits stops there and needs no checkout.
|
|
4307
|
+
*/
|
|
4308
|
+
var UPDATE_PHASES = {
|
|
4309
|
+
preparing: {
|
|
4310
|
+
doing: "Fetching the latest commits",
|
|
4311
|
+
done: "Fetched the latest commits"
|
|
4312
|
+
},
|
|
4313
|
+
conversation: {
|
|
4314
|
+
doing: "Re-reading pull request conversation",
|
|
4315
|
+
done: "Re-read pull request conversation"
|
|
4316
|
+
},
|
|
4317
|
+
context: {
|
|
4318
|
+
doing: "Preparing a checkout of the new commits",
|
|
4319
|
+
done: "Prepared a checkout of the new commits"
|
|
4320
|
+
},
|
|
4321
|
+
analysing: {
|
|
4322
|
+
doing: "Reassessing the review against the new commits",
|
|
4323
|
+
done: "Reassessed the review against the new commits"
|
|
4324
|
+
},
|
|
4325
|
+
done: {
|
|
4326
|
+
doing: "Finishing",
|
|
4327
|
+
done: "Finished"
|
|
4328
|
+
},
|
|
4329
|
+
failed: {
|
|
4330
|
+
doing: "Stopped",
|
|
4331
|
+
done: "Stopped"
|
|
4332
|
+
}
|
|
4333
|
+
};
|
|
4334
|
+
var UPDATE_ORDER = [
|
|
4335
|
+
"preparing",
|
|
4336
|
+
"conversation",
|
|
4337
|
+
"context",
|
|
4338
|
+
"analysing"
|
|
4339
|
+
];
|
|
4340
|
+
/** The names this job's phases go by. */
|
|
4341
|
+
function phasesFor(job) {
|
|
4342
|
+
return purposeOf(job) === "update" ? UPDATE_PHASES : PHASES;
|
|
4343
|
+
}
|
|
4344
|
+
/**
|
|
4139
4345
|
* Whether a phase is part of *this* review, rather than part of the pipeline in general.
|
|
4140
4346
|
*
|
|
4141
4347
|
* Only `conversation` is ever optional, and reviewing a branch with no pull request is an ordinary
|
|
@@ -4171,12 +4377,15 @@ function appliesTo(job, phase) {
|
|
|
4171
4377
|
*/
|
|
4172
4378
|
function progressSteps(job) {
|
|
4173
4379
|
const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
|
|
4174
|
-
|
|
4175
|
-
|
|
4380
|
+
const update = purposeOf(job) === "update";
|
|
4381
|
+
const names = phasesFor(job);
|
|
4382
|
+
const order = (update ? UPDATE_ORDER : WORK_PHASES).filter((phase) => appliesTo(job, phase));
|
|
4383
|
+
return (update && job.phase === "done" ? order.filter((phase) => job.completed.includes(phase)) : order).map((phase) => {
|
|
4384
|
+
const complete = job.completed.includes(phase) || !update && job.phase === "done";
|
|
4176
4385
|
const failed = failedAt === phase;
|
|
4177
4386
|
const active = !complete && !failed && job.phase === phase;
|
|
4178
4387
|
const state = failed ? "failed" : complete ? "complete" : active ? "active" : "pending";
|
|
4179
|
-
const label = complete ?
|
|
4388
|
+
const label = complete ? names[phase].done : names[phase].doing;
|
|
4180
4389
|
const note = active && job.progress && !restates(job.progress, label) ? job.progress : void 0;
|
|
4181
4390
|
return {
|
|
4182
4391
|
phase,
|
|
@@ -4342,6 +4551,20 @@ function recordFailure(job, message, driver, kind, detail) {
|
|
|
4342
4551
|
saveJob(failed);
|
|
4343
4552
|
return failed;
|
|
4344
4553
|
}
|
|
4554
|
+
/**
|
|
4555
|
+
* When this job stopped doing work.
|
|
4556
|
+
*
|
|
4557
|
+
* `finishedAt` is written on both terminal transitions, and a record older than the field falls
|
|
4558
|
+
* back to the last time anything touched it -- which, for a job that has ended, is the write that
|
|
4559
|
+
* ended it. Lifted out of `sweep.ts`, where it was private, once a second caller needed the same
|
|
4560
|
+
* question: two runs on one review can overlap, so "which of these ended last" is not answered by
|
|
4561
|
+
* which of them started last.
|
|
4562
|
+
*
|
|
4563
|
+
* Meaningless for a job still running, and no caller asks it of one.
|
|
4564
|
+
*/
|
|
4565
|
+
function endedAt(job) {
|
|
4566
|
+
return job.finishedAt ?? job.updatedAt;
|
|
4567
|
+
}
|
|
4345
4568
|
function isTerminal(job) {
|
|
4346
4569
|
return job.phase === "done" || job.phase === "failed";
|
|
4347
4570
|
}
|
|
@@ -8439,6 +8662,7 @@ async function startJob(input) {
|
|
|
8439
8662
|
base: input.base,
|
|
8440
8663
|
resolveRequest: input.resolveRequest
|
|
8441
8664
|
}), input.base);
|
|
8665
|
+
await refuseEmptyTarget(target);
|
|
8442
8666
|
const agent = input.agentId ? agentById(input.agentId) : defaultAgent();
|
|
8443
8667
|
const review = input.review ?? { type: "standard" };
|
|
8444
8668
|
const workspace = createWorkspace({
|
|
@@ -8454,6 +8678,7 @@ async function startJob(input) {
|
|
|
8454
8678
|
workspaceId: workspace.id,
|
|
8455
8679
|
target,
|
|
8456
8680
|
env: agent.captureEnv(),
|
|
8681
|
+
purpose: "review",
|
|
8457
8682
|
...review.type === "kiss" ? {
|
|
8458
8683
|
reviewType: "kiss",
|
|
8459
8684
|
audience: review.audience
|
|
@@ -8485,13 +8710,13 @@ async function startJob(input) {
|
|
|
8485
8710
|
* The previous result is snapshotted before the session is replaced, so the review keeps saying who
|
|
8486
8711
|
* made the analysis it is retiring as well as who made the one it now shows.
|
|
8487
8712
|
*/
|
|
8488
|
-
async function reanalyseReview(workspaceId, agentId) {
|
|
8713
|
+
async function reanalyseReview(workspaceId, agentId, onStarted) {
|
|
8489
8714
|
const workspace = loadWorkspace(workspaceId);
|
|
8490
8715
|
if (!workspace) throw new Error("No such review.");
|
|
8491
8716
|
const sha = workspace.session.metadata?.commitSha;
|
|
8492
8717
|
if (!sha) throw new Error("This review does not record which commit it describes, so it cannot be analysed again.");
|
|
8493
8718
|
const agent = agentId ? agentById(agentId) : agentById(agentIdOf(workspace.session));
|
|
8494
|
-
|
|
8719
|
+
const started = await withReviewLock(workspaceId, () => {
|
|
8495
8720
|
if (workspace.session.result) workspace.previousResult = {
|
|
8496
8721
|
result: workspace.session.result,
|
|
8497
8722
|
commitSha: sha,
|
|
@@ -8508,13 +8733,17 @@ async function reanalyseReview(workspaceId, agentId) {
|
|
|
8508
8733
|
const job = createJob({
|
|
8509
8734
|
workspaceId: workspace.id,
|
|
8510
8735
|
target,
|
|
8511
|
-
env: agent.captureEnv()
|
|
8736
|
+
env: agent.captureEnv(),
|
|
8737
|
+
purpose: "review"
|
|
8512
8738
|
});
|
|
8513
8739
|
saveJob(job);
|
|
8514
8740
|
workspace.jobId = job.id;
|
|
8515
8741
|
workspace.session = beginReviewSession(sha, agent);
|
|
8516
8742
|
saveWorkspace(workspace);
|
|
8517
|
-
|
|
8743
|
+
return job;
|
|
8744
|
+
});
|
|
8745
|
+
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
8746
|
+
onStarted?.(started.id);
|
|
8518
8747
|
return runJob(workspaceId);
|
|
8519
8748
|
}
|
|
8520
8749
|
/**
|
|
@@ -8984,6 +9213,7 @@ async function recordKissRun(input) {
|
|
|
8984
9213
|
workspaceId: workspace.id,
|
|
8985
9214
|
target,
|
|
8986
9215
|
env: agent.captureEnv(),
|
|
9216
|
+
purpose: "review",
|
|
8987
9217
|
reviewType: "kiss",
|
|
8988
9218
|
audience: input.audience
|
|
8989
9219
|
});
|
|
@@ -9377,6 +9607,56 @@ function countByStatus(issues) {
|
|
|
9377
9607
|
return counts;
|
|
9378
9608
|
}
|
|
9379
9609
|
//#endregion
|
|
9610
|
+
//#region ../../packages/review-harness/src/workspace/running_jobs.ts
|
|
9611
|
+
/**
|
|
9612
|
+
* What is being made right now, as opposed to what a record says.
|
|
9613
|
+
*
|
|
9614
|
+
* Its own module because two commands need the same answer and neither should have to import the
|
|
9615
|
+
* other to get it: `uninstall` refuses while anything is running, and deleting a review refuses
|
|
9616
|
+
* while that review is. Splitting it out is the same move `store_root.ts` made out of `store.ts`.
|
|
9617
|
+
*/
|
|
9618
|
+
/**
|
|
9619
|
+
* Reviews being made at this moment.
|
|
9620
|
+
*
|
|
9621
|
+
* `liveClaim` rather than the job phase alone, and the distinction matters more here than anywhere:
|
|
9622
|
+
* a job whose driver was killed sits in a working phase for up to half an hour before the sweep
|
|
9623
|
+
* calls it stopped, and refusing over one of those would be refusing on behalf of a review that
|
|
9624
|
+
* nothing is making. The claim answers about a process rather than about a record.
|
|
9625
|
+
*/
|
|
9626
|
+
function runningJobs(now = Date.now()) {
|
|
9627
|
+
return allJobs().filter((job) => !isTerminal(job)).flatMap((job) => {
|
|
9628
|
+
const claim = liveClaim(job.id, now);
|
|
9629
|
+
return claim ? [{
|
|
9630
|
+
job,
|
|
9631
|
+
claim
|
|
9632
|
+
}] : [];
|
|
9633
|
+
});
|
|
9634
|
+
}
|
|
9635
|
+
/**
|
|
9636
|
+
* Somebody is already bringing this review up to date, so a second update was not started.
|
|
9637
|
+
*
|
|
9638
|
+
* Refused rather than run alongside, because two updates cannot both be kept. Each reconciles
|
|
9639
|
+
* against its own copy of the review and saves the whole of it, so the one that finished last
|
|
9640
|
+
* silently replaced the other's result, and the snapshot kept for rolling back described the wrong
|
|
9641
|
+
* predecessor. Thrown from under the review's lock; see `claimCheckoutJob`.
|
|
9642
|
+
*/
|
|
9643
|
+
var UpdateAlreadyRunningError = class extends Error {
|
|
9644
|
+
constructor(message = "This review is already being updated.") {
|
|
9645
|
+
super(message);
|
|
9646
|
+
this.name = "UpdateAlreadyRunningError";
|
|
9647
|
+
}
|
|
9648
|
+
};
|
|
9649
|
+
/**
|
|
9650
|
+
* The update being made to this review right now, if one is.
|
|
9651
|
+
*
|
|
9652
|
+
* Found in the job store rather than recorded on the workspace. An update's job is transient, and
|
|
9653
|
+
* an id written onto the review would be a second copy of "is one running" that could outlive the
|
|
9654
|
+
* answer. The newest where there are two, which only a race between two surfaces can produce.
|
|
9655
|
+
*/
|
|
9656
|
+
function liveUpdateFor(reviewId, now = Date.now()) {
|
|
9657
|
+
return runningJobs(now).map((running) => running.job).filter((job) => job.workspaceId === reviewId && purposeOf(job) === "update").sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? -1 : 1))[0] ?? null;
|
|
9658
|
+
}
|
|
9659
|
+
//#endregion
|
|
9380
9660
|
//#region ../../packages/review-harness/src/workspace/pinned_checkout.ts
|
|
9381
9661
|
/**
|
|
9382
9662
|
* A checkout of one commit, for anything that needs to read the reviewed code.
|
|
@@ -9409,8 +9689,62 @@ var NoCheckoutError = class extends Error {};
|
|
|
9409
9689
|
* look. Claimed inside the lock too, so there is no instant where a job record for this review
|
|
9410
9690
|
* exists unclaimed and a deletion between the two would see nothing running.
|
|
9411
9691
|
*/
|
|
9412
|
-
async function pinCheckout(workspace, sha) {
|
|
9692
|
+
async function pinCheckout(workspace, sha, held) {
|
|
9693
|
+
const owned = held === void 0;
|
|
9694
|
+
const claimed = held ?? await claimCheckoutJob(workspace, sha, { purpose: "ask" });
|
|
9695
|
+
const { claim } = claimed;
|
|
9696
|
+
let job = loadJob(claimed.job.id) ?? claimed.job;
|
|
9697
|
+
try {
|
|
9698
|
+
const path = await createWorktree({
|
|
9699
|
+
originRepoPath: workspace.repoPath,
|
|
9700
|
+
jobId: job.id,
|
|
9701
|
+
sha
|
|
9702
|
+
});
|
|
9703
|
+
job = {
|
|
9704
|
+
...job,
|
|
9705
|
+
target: {
|
|
9706
|
+
...job.target,
|
|
9707
|
+
sha
|
|
9708
|
+
},
|
|
9709
|
+
worktreePath: path
|
|
9710
|
+
};
|
|
9711
|
+
saveJob(job);
|
|
9712
|
+
return {
|
|
9713
|
+
path,
|
|
9714
|
+
job,
|
|
9715
|
+
claim
|
|
9716
|
+
};
|
|
9717
|
+
} catch (error) {
|
|
9718
|
+
if (owned) {
|
|
9719
|
+
saveJob(fail(job, error instanceof Error ? error.message : String(error)));
|
|
9720
|
+
claim.release();
|
|
9721
|
+
}
|
|
9722
|
+
throw error;
|
|
9723
|
+
}
|
|
9724
|
+
}
|
|
9725
|
+
/**
|
|
9726
|
+
* Register and claim the job a checkout will belong to, before the checkout exists.
|
|
9727
|
+
*
|
|
9728
|
+
* Separate from `pinCheckout` because an update needs its job long before it needs a directory: the
|
|
9729
|
+
* fetch and the conversation re-read come first and can take a while, and a job that only appeared
|
|
9730
|
+
* once they were over left the terminal and the page with nothing to show for them.
|
|
9731
|
+
*
|
|
9732
|
+
* Created under the review's lock, which is what makes the claim arrive before a deletion could
|
|
9733
|
+
* look. Claimed inside the lock too, so there is no instant where a job record for this review
|
|
9734
|
+
* exists unclaimed and a deletion between the two would see nothing running.
|
|
9735
|
+
*
|
|
9736
|
+
* The same lock carries a second invariant, for the same reason the first needed one:
|
|
9737
|
+
*
|
|
9738
|
+
* > **No update job for a review is created while another update of that review is claimed.**
|
|
9739
|
+
*
|
|
9740
|
+
* The check is inside the lock and creating an update job requires the lock, so the answer cannot
|
|
9741
|
+
* go stale between asking and creating. The lock is a file, so this holds between the page's button
|
|
9742
|
+
* in the daemon and `prreviewbuddy review --update` in a terminal, which is the pair that raced.
|
|
9743
|
+
* Only an update blocks an update: an analysis, a KISS run or an ask checkout is different work.
|
|
9744
|
+
*/
|
|
9745
|
+
async function claimCheckoutJob(workspace, sha, fields) {
|
|
9413
9746
|
const started = await withReviewLock(workspace.id, () => {
|
|
9747
|
+
if (fields.purpose === "update" && liveUpdateFor(workspace.id)) throw new UpdateAlreadyRunningError();
|
|
9414
9748
|
const target = {
|
|
9415
9749
|
originRepoPath: workspace.repoPath,
|
|
9416
9750
|
branch: workspace.changeSet.headRef,
|
|
@@ -9420,7 +9754,8 @@ async function pinCheckout(workspace, sha) {
|
|
|
9420
9754
|
const job = createJob({
|
|
9421
9755
|
workspaceId: workspace.id,
|
|
9422
9756
|
target,
|
|
9423
|
-
env: captureEnv()
|
|
9757
|
+
env: captureEnv(),
|
|
9758
|
+
...fields
|
|
9424
9759
|
});
|
|
9425
9760
|
saveJob(job);
|
|
9426
9761
|
const claim = claimJob(job.id);
|
|
@@ -9431,29 +9766,7 @@ async function pinCheckout(workspace, sha) {
|
|
|
9431
9766
|
};
|
|
9432
9767
|
});
|
|
9433
9768
|
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
9434
|
-
|
|
9435
|
-
const { claim } = started;
|
|
9436
|
-
try {
|
|
9437
|
-
const path = await createWorktree({
|
|
9438
|
-
originRepoPath: workspace.repoPath,
|
|
9439
|
-
jobId: job.id,
|
|
9440
|
-
sha
|
|
9441
|
-
});
|
|
9442
|
-
job = {
|
|
9443
|
-
...job,
|
|
9444
|
-
worktreePath: path
|
|
9445
|
-
};
|
|
9446
|
-
saveJob(job);
|
|
9447
|
-
return {
|
|
9448
|
-
path,
|
|
9449
|
-
job,
|
|
9450
|
-
claim
|
|
9451
|
-
};
|
|
9452
|
-
} catch (error) {
|
|
9453
|
-
saveJob(fail(job, error instanceof Error ? error.message : String(error)));
|
|
9454
|
-
claim.release();
|
|
9455
|
-
throw error;
|
|
9456
|
-
}
|
|
9769
|
+
return started;
|
|
9457
9770
|
}
|
|
9458
9771
|
/**
|
|
9459
9772
|
* Which checkout each open review's questions are being answered against.
|
|
@@ -9722,27 +10035,45 @@ var FETCH_TIMEOUT_MS = 6e4;
|
|
|
9722
10035
|
*/
|
|
9723
10036
|
async function updateReview(workspace, runners = {}) {
|
|
9724
10037
|
const reviewedSha = workspace.session.metadata?.commitSha || null;
|
|
10038
|
+
const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
|
|
10039
|
+
purpose: "update",
|
|
10040
|
+
...workspace.prContext ? { conversation: {
|
|
10041
|
+
kind: "attached",
|
|
10042
|
+
context: workspace.prContext
|
|
10043
|
+
} } : {}
|
|
10044
|
+
});
|
|
10045
|
+
runners.onStarted?.(held.job.id);
|
|
10046
|
+
const current = () => loadJob(held.job.id) ?? held.job;
|
|
10047
|
+
const step = (phase) => saveJob(advance(current(), phase));
|
|
10048
|
+
try {
|
|
10049
|
+
const outcome = await bringForward(workspace, runners, reviewedSha, held, step);
|
|
10050
|
+
saveJob(outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message));
|
|
10051
|
+
return outcome;
|
|
10052
|
+
} catch (error) {
|
|
10053
|
+
saveJob(fail(current(), error instanceof Error ? error.message : String(error)));
|
|
10054
|
+
throw error;
|
|
10055
|
+
} finally {
|
|
10056
|
+
held.claim.release();
|
|
10057
|
+
}
|
|
10058
|
+
}
|
|
10059
|
+
async function bringForward(workspace, runners, reviewedSha, held, step) {
|
|
9725
10060
|
const remote = await remoteHead(workspace);
|
|
9726
10061
|
if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, noRemoteHeadMessage(workspace));
|
|
10062
|
+
if (workspace.prContext) step("conversation");
|
|
9727
10063
|
const conversationOk = await reReadConversation(workspace, runners.runRefresh);
|
|
9728
10064
|
if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
9729
10065
|
if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
10066
|
+
step("context");
|
|
9730
10067
|
let worktreePath;
|
|
9731
|
-
let job;
|
|
9732
|
-
let claim;
|
|
9733
10068
|
try {
|
|
9734
|
-
({path: worktreePath
|
|
10069
|
+
({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
|
|
9735
10070
|
} catch (error) {
|
|
9736
10071
|
if (error instanceof ReviewBeingDeletedError) throw error;
|
|
9737
10072
|
return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the new commits could not be prepared, so nothing was reassessed.");
|
|
9738
10073
|
}
|
|
9739
|
-
|
|
9740
|
-
return await reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath);
|
|
9741
|
-
} finally {
|
|
9742
|
-
saveJob(advance(job, "done"));
|
|
9743
|
-
}
|
|
10074
|
+
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id);
|
|
9744
10075
|
}
|
|
9745
|
-
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath) {
|
|
10076
|
+
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId) {
|
|
9746
10077
|
const newHead = remote.sha;
|
|
9747
10078
|
let delta;
|
|
9748
10079
|
try {
|
|
@@ -9756,10 +10087,19 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
|
|
|
9756
10087
|
}
|
|
9757
10088
|
const previous = workspace.session.result;
|
|
9758
10089
|
if (!previous) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "no-result", "This review has no analysis to bring up to date.");
|
|
10090
|
+
step("analysing");
|
|
10091
|
+
const onProgress = (text) => {
|
|
10092
|
+
const current = loadJob(jobId);
|
|
10093
|
+
if (current) saveJob({
|
|
10094
|
+
...current,
|
|
10095
|
+
progress: text,
|
|
10096
|
+
updatedAt: Date.now()
|
|
10097
|
+
});
|
|
10098
|
+
};
|
|
9759
10099
|
let raw;
|
|
9760
10100
|
try {
|
|
9761
10101
|
const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
|
|
9762
|
-
raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace)) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace));
|
|
10102
|
+
raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress);
|
|
9763
10103
|
} catch (error) {
|
|
9764
10104
|
return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", error instanceof RefreshUnavailableError ? error.message : "The agent could not finish reassessing the review, so the findings are exactly as they were.");
|
|
9765
10105
|
}
|
|
@@ -10083,7 +10423,7 @@ ${buildReconciliationInstructions({
|
|
|
10083
10423
|
* that captured the daemon's environment instead was reconciling one review's findings with
|
|
10084
10424
|
* whichever agent and account the workspace server happened to be started under.
|
|
10085
10425
|
*/
|
|
10086
|
-
async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
|
|
10426
|
+
async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgress) {
|
|
10087
10427
|
try {
|
|
10088
10428
|
return await withUsageRecorded(workspaceId, "update", env, (onUsage) => agentFor(env).run({
|
|
10089
10429
|
capability: "read-code",
|
|
@@ -10092,6 +10432,7 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
|
|
|
10092
10432
|
originRepoPath,
|
|
10093
10433
|
env,
|
|
10094
10434
|
timeoutMs: UPDATE_TIMEOUT_MS,
|
|
10435
|
+
onProgress,
|
|
10095
10436
|
onUsage
|
|
10096
10437
|
}));
|
|
10097
10438
|
} catch (error) {
|
|
@@ -10569,32 +10910,6 @@ async function discardJob(job, options = {}) {
|
|
|
10569
10910
|
return { removed: true };
|
|
10570
10911
|
}
|
|
10571
10912
|
//#endregion
|
|
10572
|
-
//#region ../../packages/review-harness/src/workspace/running_jobs.ts
|
|
10573
|
-
/**
|
|
10574
|
-
* What is being made right now, as opposed to what a record says.
|
|
10575
|
-
*
|
|
10576
|
-
* Its own module because two commands need the same answer and neither should have to import the
|
|
10577
|
-
* other to get it: `uninstall` refuses while anything is running, and deleting a review refuses
|
|
10578
|
-
* while that review is. Splitting it out is the same move `store_root.ts` made out of `store.ts`.
|
|
10579
|
-
*/
|
|
10580
|
-
/**
|
|
10581
|
-
* Reviews being made at this moment.
|
|
10582
|
-
*
|
|
10583
|
-
* `liveClaim` rather than the job phase alone, and the distinction matters more here than anywhere:
|
|
10584
|
-
* a job whose driver was killed sits in a working phase for up to half an hour before the sweep
|
|
10585
|
-
* calls it stopped, and refusing over one of those would be refusing on behalf of a review that
|
|
10586
|
-
* nothing is making. The claim answers about a process rather than about a record.
|
|
10587
|
-
*/
|
|
10588
|
-
function runningJobs(now = Date.now()) {
|
|
10589
|
-
return allJobs().filter((job) => !isTerminal(job)).flatMap((job) => {
|
|
10590
|
-
const claim = liveClaim(job.id, now);
|
|
10591
|
-
return claim ? [{
|
|
10592
|
-
job,
|
|
10593
|
-
claim
|
|
10594
|
-
}] : [];
|
|
10595
|
-
});
|
|
10596
|
-
}
|
|
10597
|
-
//#endregion
|
|
10598
10913
|
//#region ../../packages/review-harness/src/workspace/delete_review.ts
|
|
10599
10914
|
/**
|
|
10600
10915
|
* Removing one review, or everything on one card.
|
|
@@ -10762,4 +11077,4 @@ function relativeTime(ms) {
|
|
|
10762
11077
|
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
10763
11078
|
}
|
|
10764
11079
|
//#endregion
|
|
10765
|
-
export {
|
|
11080
|
+
export { checkCodeFreshness as $, AgentCancelledError as $t, liveUpdateFor as A, workspaceStamps as At, recordTelemetryUploadConsent as B, displayRef as Bt, writePortPreference as C, recentWorkspaces as Ct, isUnchanged as D, summarise as Dt, refreshPrContext as E, saveWorkspace as Et, reanalyseReview as F, questionsOutstanding as Ft, withUsageRecorded as G, writeAgentPreference as Gt, BUILD_VERSION as H, agentIdOf as Ht, runJob as I, reviewerDispositions as It, fillFileUrlTemplate as J, AGENT_IDS as Jt, EXPLAIN_SIMPLY_PROMPT as K, CONFIG_PATH as Kt, startJob as L, processDiscussion as Lt, agentEnvOf as M, isIssueOutstanding as Mt, recordKissRun as N, isQuestionOutstanding as Nt, askCheckout as O, touchWorkspace as Ot, startKissJob as P, issuesOutstanding as Pt, removeWorktree as Q, detectAgents as Qt, readEvents as R, describeAuthorship as Rt, statedWorkspacePort as S, recentReviewGroups as St, updateReview as T, reviewedRepositories as Tt, PACKAGE_NAME as U, clearAgentPreference as Ut, telemetryUploadConsent as V, git as Vt, feedbackUrl as W, readAgentPreference as Wt, MANAGED_ROOT as X, agentById as Xt, ReviewBeingDeletedError as Y, DEFAULT_AGENT_ID as Yt, readMarker as Z, agentFor as Zt, DEFAULT_WORKSPACE_PORT as _, lineagePosition as _t, liveJobsFor as a, loadJob as at, parseWorkspacePort as b, positionInLineage as bt, readIndexToken as c, phasesFor as ct, ensureServer as d, clearClaim as dt, AgentUnavailableError as en, checkFreshness as et, readServerRecord as f, isClaimed as ft, writeServerRecord as g, lineageKeyFor as gt, workspaceUrl as h, latestKissRun as ht, lineageIds as i, isTerminal as it, runningJobs as j, doneVerb as jt, UpdateAlreadyRunningError as k, workspaceRevision as kt, bootstrapUrl as l, progressSteps as lt, stopServer as m, groupByLineage as mt, deleteLineage as n, endedAt as nt, wasBlocked as o, saveJob as ot, reviewsUrl as p, followedRefName as pt, forgeResolver as q, STORE_ROOT as qt, deleteReview as r, fail as rt, discardJob as s, PHASES as st, relativeTime as t, allJobs as tt, clearServerRecord as u, purposeOf as ut, MAX_WORKSPACE_PORT as v, loadWorkspace as vt, RefreshUnavailableError as w, reviewedCommit as wt, resolveWorkspacePort as x, previousKissResult as xt, MIN_WORKSPACE_PORT as y, matchingWorkspaceIds as yt, record as z, resolveTarget as zt };
|