prreviewbuddy 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/log-DdPaO6Wo.js +6640 -0
- package/dist/main.js +873 -0
- package/dist/server.js +3849 -0
- package/package.json +27 -0
- package/static/comfortaa-700.woff2 +0 -0
- package/static/icon128.png +0 -0
- package/static/reviews.js +1 -0
- package/static/shell.css +2018 -0
- package/static/sidebar.css +4802 -0
- package/static/workspace.js +0 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { $ as agentById, A as WORK_PHASES, F as loadJob, G as saveWorkspace, H as positionInLineage, K as summarise, L as displayRef, O as relativeTime, P as isTerminal, Q as AGENT_IDS, V as loadWorkspace, W as recentWorkspaces, X as resolveTarget, Z as withBase, d as ensureServer, f as readServerRecord, g as BUILD_VERSION, i as updateReview, k as checkFreshness, l as LANDING_PAGE, m as workspaceUrl, n as record, nt as AgentUnavailableError, p as reviewsUrl, tt as defaultAgent, v as runJob, w as isClaimed, x as forgeResolver, y as startJob, z as groupByLineage } from "./log-DdPaO6Wo.js";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { platform } from "node:process";
|
|
6
|
+
//#region src/args.ts
|
|
7
|
+
/** Flags that take the next argument as their value, per command. */
|
|
8
|
+
var VALUED = {
|
|
9
|
+
review: ["--base", "--agent"],
|
|
10
|
+
open: [],
|
|
11
|
+
update: []
|
|
12
|
+
};
|
|
13
|
+
/** Flags that stand alone, per command. */
|
|
14
|
+
var BARE = {
|
|
15
|
+
review: ["--fresh"],
|
|
16
|
+
open: [],
|
|
17
|
+
update: []
|
|
18
|
+
};
|
|
19
|
+
function parse(argv) {
|
|
20
|
+
if (argv.length === 0) return { name: "help" };
|
|
21
|
+
const [head, ...rest] = argv;
|
|
22
|
+
if (head === "--help" || head === "-h" || head === "help") return { name: "help" };
|
|
23
|
+
if (head === "--version" || head === "-v") return { name: "version" };
|
|
24
|
+
if (head === void 0 || !(head in VALUED)) return {
|
|
25
|
+
name: "error",
|
|
26
|
+
message: unknownCommand(head)
|
|
27
|
+
};
|
|
28
|
+
const values = {};
|
|
29
|
+
const flags = /* @__PURE__ */ new Set();
|
|
30
|
+
const positional = [];
|
|
31
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
32
|
+
const argument = rest[index];
|
|
33
|
+
if (!argument.startsWith("--")) {
|
|
34
|
+
positional.push(argument);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const equals = argument.indexOf("=");
|
|
38
|
+
const name = equals === -1 ? argument : argument.slice(0, equals);
|
|
39
|
+
if (VALUED[head]?.includes(name)) {
|
|
40
|
+
const value = equals === -1 ? rest[index + 1] : argument.slice(equals + 1);
|
|
41
|
+
if (!value) return {
|
|
42
|
+
name: "error",
|
|
43
|
+
message: `${name} needs a value after it.`
|
|
44
|
+
};
|
|
45
|
+
values[name] = value;
|
|
46
|
+
if (equals === -1) index += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (BARE[head]?.includes(name) && equals === -1) {
|
|
50
|
+
flags.add(name);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
name: "error",
|
|
55
|
+
message: `\`${head}\` does not take ${name}. Run \`prreviewbuddy --help\` to see what it takes.`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (positional.length > 1) return {
|
|
59
|
+
name: "error",
|
|
60
|
+
message: `\`${head}\` takes one argument at most, and was given ${positional.length}.`
|
|
61
|
+
};
|
|
62
|
+
const first = positional[0];
|
|
63
|
+
if (head === "review") return {
|
|
64
|
+
name: "review",
|
|
65
|
+
...first ? { argument: first } : {},
|
|
66
|
+
...values["--base"] ? { base: values["--base"] } : {},
|
|
67
|
+
...values["--agent"] ? { agentId: values["--agent"] } : {},
|
|
68
|
+
fresh: flags.has("--fresh")
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
name: head,
|
|
72
|
+
...first ? { id: first } : {}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function unknownCommand(head) {
|
|
76
|
+
return `There is no \`${head}\` command. This does three things: review, open and update. Run \`prreviewbuddy --help\` for what each one takes.`;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region ../../packages/review-harness/src/workspace/existing_review.ts
|
|
80
|
+
/**
|
|
81
|
+
* Is there already a review of what I am standing in?
|
|
82
|
+
*
|
|
83
|
+
* `/prreviewbuddy:review` used to mean "make another review", every time, which made the most-typed
|
|
84
|
+
* command in the product the one way to end up with two reviews of one branch and no idea which was
|
|
85
|
+
* which. It now means **"give me the review of this branch"**: it creates one the first time and
|
|
86
|
+
* hands back the existing one afterwards. Starting again is a separate, deliberate command.
|
|
87
|
+
*
|
|
88
|
+
* Three properties matter here and all of them are about the answer being trustworthy rather than
|
|
89
|
+
* merely fast:
|
|
90
|
+
*
|
|
91
|
+
* - **It runs before the change set is built.** Building one reads every changed file and its
|
|
92
|
+
* patch, which on a large change is seconds of work thrown away the moment the answer turns out
|
|
93
|
+
* to be "you already have this". The lookup is a branch name and a directory.
|
|
94
|
+
* - **It says how old the answer is.** Reviews are kept for thirty days, so on a long-lived branch
|
|
95
|
+
* the honest answer is often "yes, from three weeks and fifty commits ago". Handing back a link
|
|
96
|
+
* with no age attached would turn a helpful reply into a trap.
|
|
97
|
+
* - **It answers the question that was asked.** A branch is not the whole of what a review is of:
|
|
98
|
+
* `--base develop` and `--base main` are two different comparisons of the same commits. Keying
|
|
99
|
+
* only on the branch meant a reviewer who named a base was handed a review made against a
|
|
100
|
+
* different one, under a sentence saying it was a review of what they asked for. See
|
|
101
|
+
* `findExistingReview`.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* The latest review of a resolved target, or null.
|
|
105
|
+
*
|
|
106
|
+
* Takes a target rather than a path because the current branch stopped being the answer the
|
|
107
|
+
* moment a review could run in a worktree. Reviewing `!45` from `main` must find the lineage of
|
|
108
|
+
* `app-1276`, and file into it, or the one-review-per-branch rule holds only for reviewers who
|
|
109
|
+
* happen to be standing in the right place.
|
|
110
|
+
*
|
|
111
|
+
* **An explicit base narrows the lineage.** A base the reviewer named is part of what they asked
|
|
112
|
+
* for, so only a review made against that same base can be the answer to it; the newest review of
|
|
113
|
+
* this branch against some other base is a different comparison wearing the right branch name. A
|
|
114
|
+
* base nobody named narrows nothing, because the command claimed nothing about it: any review of
|
|
115
|
+
* the branch is a truthful answer to "give me the review of this branch".
|
|
116
|
+
*/
|
|
117
|
+
async function findExistingReview(target) {
|
|
118
|
+
const group = groupByLineage(recentWorkspaces(Number.MAX_SAFE_INTEGER)).find((entry) => entry.latest.repoPath === target.originRepoPath && entry.latest.branch === target.branch);
|
|
119
|
+
if (!group) return null;
|
|
120
|
+
const history = target.base ? group.history.filter(madeAgainst(target.base)) : group.history;
|
|
121
|
+
if (history.length === 0) return null;
|
|
122
|
+
const summary = history[0];
|
|
123
|
+
const found = {
|
|
124
|
+
id: summary.id,
|
|
125
|
+
token: summary.token,
|
|
126
|
+
title: summary.title,
|
|
127
|
+
branch: summary.branch,
|
|
128
|
+
baseRef: summary.baseRef,
|
|
129
|
+
createdAt: summary.createdAt,
|
|
130
|
+
commitSha: summary.commitSha ?? null,
|
|
131
|
+
commitsBehind: null,
|
|
132
|
+
baselineGone: false,
|
|
133
|
+
total: history.length,
|
|
134
|
+
inProgress: false
|
|
135
|
+
};
|
|
136
|
+
const workspace = loadWorkspace(summary.id);
|
|
137
|
+
if (!workspace) return found;
|
|
138
|
+
const inProgress = isRunning(workspace.jobId);
|
|
139
|
+
try {
|
|
140
|
+
const code = (await checkFreshness(workspace)).code;
|
|
141
|
+
const baselineGone = code.rewritten || code.reason === "missing-commit";
|
|
142
|
+
return {
|
|
143
|
+
...found,
|
|
144
|
+
inProgress,
|
|
145
|
+
commitSha: code.reviewedSha ?? found.commitSha,
|
|
146
|
+
commitsBehind: baselineGone ? null : code.state === "current" ? 0 : code.newCommits,
|
|
147
|
+
baselineGone
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return {
|
|
151
|
+
...found,
|
|
152
|
+
inProgress
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Was this review made against the base the reviewer named?
|
|
158
|
+
*
|
|
159
|
+
* Compared in `displayRef`'s short form, which is the form the store holds, so `--base origin/main`
|
|
160
|
+
* and a review made against the default remote branch recognise each other. Anything else is
|
|
161
|
+
* compared as typed: a tag or a sha is a base like any other, and two different spellings of one
|
|
162
|
+
* commit read here as two comparisons, which costs a redundant review rather than a wrong answer.
|
|
163
|
+
*
|
|
164
|
+
* An empty `baseRef` is a review that stopped before it worked out what it was comparing against,
|
|
165
|
+
* and it never matches. Unknown is not the same as "the one you asked for", and this is the one
|
|
166
|
+
* place where guessing would put a review of some other base behind a sentence naming this one.
|
|
167
|
+
* The cost of being strict is small and bounded: a second review starts, of the comparison that
|
|
168
|
+
* was actually requested.
|
|
169
|
+
*/
|
|
170
|
+
function madeAgainst(requested) {
|
|
171
|
+
const wanted = displayRef(requested);
|
|
172
|
+
return (review) => review.baseRef !== "" && displayRef(review.baseRef) === wanted;
|
|
173
|
+
}
|
|
174
|
+
/** Whether the job behind a stored review is still working. A review with no job never was. */
|
|
175
|
+
function isRunning(jobId) {
|
|
176
|
+
if (!jobId) return false;
|
|
177
|
+
const job = loadJob(jobId);
|
|
178
|
+
return !!job && !isTerminal(job);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* What the review is of, as the clause both surfaces hang off "Made two hours ago".
|
|
182
|
+
*
|
|
183
|
+
* The base is in it because a review is of a comparison rather than of a branch, and the one thing
|
|
184
|
+
* a reviewer cannot see from the outside is which comparison they are being handed. The command
|
|
185
|
+
* that named a base now gets one made against it, but the far commoner case is the command that
|
|
186
|
+
* named none, and there this clause is the only thing that says what was compared.
|
|
187
|
+
*
|
|
188
|
+
* Each half is dropped when it is not known, rather than rendered as a placeholder: a review made
|
|
189
|
+
* before commits were recorded, or one that stopped before it resolved its base, says less rather
|
|
190
|
+
* than saying something untrue.
|
|
191
|
+
*/
|
|
192
|
+
function describeSubject(existing) {
|
|
193
|
+
const parts = [...existing.commitSha ? [`of commit ${existing.commitSha.slice(0, 7)}`] : [], ...existing.baseRef ? [`against ${existing.baseRef}`] : []];
|
|
194
|
+
return parts.length ? `, ${parts.join(" ")}` : "";
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* How far the branch has moved since the review was made, in one sentence.
|
|
198
|
+
*
|
|
199
|
+
* Exported because both surfaces say it and they say the same thing. The sentences around it
|
|
200
|
+
* differ, because one is addressed to a session that must decide not to review anything and the
|
|
201
|
+
* other to a person deciding whether to press update, but this fact reads identically to both.
|
|
202
|
+
*/
|
|
203
|
+
function describeMovement(existing) {
|
|
204
|
+
if (existing.baselineGone) return "The branch has been rebuilt since, so that commit is no longer on it.";
|
|
205
|
+
if (existing.commitsBehind === null) return "How far the branch has moved since could not be worked out.";
|
|
206
|
+
if (existing.commitsBehind === 0) return "It is of the commit currently checked out.";
|
|
207
|
+
return `The checkout has moved ${existing.commitsBehind} commit${existing.commitsBehind === 1 ? "" : "s"} since.`;
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/progress.ts
|
|
211
|
+
/**
|
|
212
|
+
* What the review is doing, while it does it.
|
|
213
|
+
*
|
|
214
|
+
* The job record is the source of truth, and it is the same one the workspace page polls over
|
|
215
|
+
* HTTP. Read here directly from the store rather than through the server, because this process is
|
|
216
|
+
* the one driving the job and a loopback request to ask itself what it is doing would be a strange
|
|
217
|
+
* way to find out.
|
|
218
|
+
*
|
|
219
|
+
* This used to print `job.progress ?? job.phase` whenever it changed, which meant the raw phase
|
|
220
|
+
* enum reached the terminal as a bare `preparing` before the first progress note arrived, and every
|
|
221
|
+
* later note appended a line to a growing log. A reviewer could not tell what had finished, what
|
|
222
|
+
* was running, or how much was left.
|
|
223
|
+
*
|
|
224
|
+
* It now renders one block: a header, then a line per phase with a mark for its state, and the
|
|
225
|
+
* agent's own notes indented under whichever phase is currently running. On a terminal the block is
|
|
226
|
+
* redrawn in place, so the review reads as one task progressing rather than as a transcript. Where
|
|
227
|
+
* stderr is not a terminal (a pipe, a CI log, a file) it falls back to appending only the lines
|
|
228
|
+
* that changed, because cursor movement written into a log is worse than a plain list.
|
|
229
|
+
*
|
|
230
|
+
* Progress goes to stderr and the review's link goes to stdout, so `prreviewbuddy review | pbcopy`
|
|
231
|
+
* copies a URL rather than a transcript.
|
|
232
|
+
*/
|
|
233
|
+
var POLL_INTERVAL_MS = 500;
|
|
234
|
+
/**
|
|
235
|
+
* Two forms per phase, because a finished step and a running one are different sentences.
|
|
236
|
+
*
|
|
237
|
+
* The page says these differently again (`job_view.ts`), and deliberately: it is describing a
|
|
238
|
+
* review to someone who arrived after the fact, while this is narrating one to someone watching it
|
|
239
|
+
* happen. Sharing the strings would force one of the two to read wrongly.
|
|
240
|
+
*/
|
|
241
|
+
var PHASES = {
|
|
242
|
+
preparing: {
|
|
243
|
+
doing: "Preparing isolated checkout",
|
|
244
|
+
done: "Prepared isolated checkout"
|
|
245
|
+
},
|
|
246
|
+
context: {
|
|
247
|
+
doing: "Reading what changed",
|
|
248
|
+
done: "Read what changed"
|
|
249
|
+
},
|
|
250
|
+
conversation: {
|
|
251
|
+
doing: "Reading pull request conversation",
|
|
252
|
+
done: "Read pull request conversation"
|
|
253
|
+
},
|
|
254
|
+
analysing: {
|
|
255
|
+
doing: "Analysing the change",
|
|
256
|
+
done: "Analysed the change"
|
|
257
|
+
},
|
|
258
|
+
done: {
|
|
259
|
+
doing: "Finishing",
|
|
260
|
+
done: "Finished"
|
|
261
|
+
},
|
|
262
|
+
failed: {
|
|
263
|
+
doing: "Stopped",
|
|
264
|
+
done: "Stopped"
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Colour only where it means something, and never where it cannot be seen.
|
|
269
|
+
*
|
|
270
|
+
* `NO_COLOR` is honoured because this writes to stderr, which people redirect into files and CI
|
|
271
|
+
* logs where escape codes are noise rather than emphasis.
|
|
272
|
+
*/
|
|
273
|
+
function styling(stream) {
|
|
274
|
+
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
275
|
+
}
|
|
276
|
+
var DIM = "\x1B[2m";
|
|
277
|
+
var GREEN = "\x1B[32m";
|
|
278
|
+
var RED = "\x1B[31m";
|
|
279
|
+
var RESET = "\x1B[0m";
|
|
280
|
+
/** The header above the phases: what is being reviewed, and the facts about how. */
|
|
281
|
+
function header(target, colour) {
|
|
282
|
+
const meta = `${target.sha.slice(0, 7)} · isolated checkout · ${target.originRepoPath}`;
|
|
283
|
+
return [
|
|
284
|
+
`Reviewing ${target.branch}`,
|
|
285
|
+
colour ? `${DIM}${meta}${RESET}` : meta,
|
|
286
|
+
""
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* One line per phase, marked with its state, and the agent's note under the running one.
|
|
291
|
+
*
|
|
292
|
+
* Completed phases stay visible because they are the part that says how far along this is. Their
|
|
293
|
+
* detail does not: a note about a file read two phases ago is history, and history is what the
|
|
294
|
+
* transcript was made of.
|
|
295
|
+
*/
|
|
296
|
+
function phaseLines(job, colour) {
|
|
297
|
+
const lines = [];
|
|
298
|
+
const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
|
|
299
|
+
for (const phase of WORK_PHASES) {
|
|
300
|
+
const complete = job.completed.includes(phase) || job.phase === "done";
|
|
301
|
+
const failed = failedAt === phase;
|
|
302
|
+
const current = !complete && !failed && job.phase === phase;
|
|
303
|
+
const mark = failed ? "✕" : complete ? "✓" : current ? "⟳" : " ";
|
|
304
|
+
const label = complete || failed ? PHASES[phase].done : PHASES[phase].doing;
|
|
305
|
+
const paint = failed ? RED : complete ? GREEN : "";
|
|
306
|
+
if (!complete && !current && !failed) {
|
|
307
|
+
lines.push(colour ? `${DIM} ${mark} ${label}${RESET}` : ` ${mark} ${label}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
lines.push(colour && paint ? `${paint}${mark}${RESET} ${label}` : `${mark} ${label}`);
|
|
311
|
+
if (current && job.progress && !restates(job.progress, label)) lines.push(colour ? `${DIM} ${job.progress}${RESET}` : ` ${job.progress}`);
|
|
312
|
+
}
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
315
|
+
/** Same words as the phase heading, give or take the articles and the trailing full stop. */
|
|
316
|
+
function restates(note, label) {
|
|
317
|
+
const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
|
|
318
|
+
return bare(note) === bare(label);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Watch a job and keep one block on the screen describing it.
|
|
322
|
+
*
|
|
323
|
+
* Returns the stop function the caller must call: nothing here may outlive the command, and a
|
|
324
|
+
* stray interval would make `review` hang after the review finished.
|
|
325
|
+
*/
|
|
326
|
+
function followJob(jobId, target, options = {}) {
|
|
327
|
+
const stream = options.stream ?? process.stderr;
|
|
328
|
+
const read = options.read ?? loadJob;
|
|
329
|
+
const colour = styling(stream);
|
|
330
|
+
const inPlace = stream.isTTY === true;
|
|
331
|
+
let drawn = 0;
|
|
332
|
+
let last = "";
|
|
333
|
+
const paint = () => {
|
|
334
|
+
const job = read(jobId);
|
|
335
|
+
if (!job) return;
|
|
336
|
+
const lines = [...header(target, colour), ...phaseLines(job, colour)];
|
|
337
|
+
const block = lines.join("\n");
|
|
338
|
+
if (block === last) return;
|
|
339
|
+
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
340
|
+
stream.write(`${block}\n`);
|
|
341
|
+
last = block;
|
|
342
|
+
drawn = lines.length;
|
|
343
|
+
};
|
|
344
|
+
const timer = setInterval(paint, POLL_INTERVAL_MS);
|
|
345
|
+
timer.unref();
|
|
346
|
+
paint();
|
|
347
|
+
return () => {
|
|
348
|
+
clearInterval(timer);
|
|
349
|
+
paint();
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/text.ts
|
|
354
|
+
/**
|
|
355
|
+
* Everything this binary says, in one place.
|
|
356
|
+
*
|
|
357
|
+
* Kept out of the commands for the same reason `review_tool_text.ts` is kept out of the MCP
|
|
358
|
+
* server: copy is the part worth testing and the hardest part to reach through a command that
|
|
359
|
+
* spawns processes and writes to a store. It is also the part the house style rule applies to, and
|
|
360
|
+
* a rule that depends on everybody remembering is a rule that decays.
|
|
361
|
+
*
|
|
362
|
+
* The audience here is a person at a terminal, which is why almost none of it is shared with the
|
|
363
|
+
* plugin's strings. Those are addressed to a session that has to decide not to analyse anything
|
|
364
|
+
* itself, and carry instructions a person would find baffling.
|
|
365
|
+
*/
|
|
366
|
+
var HELP = `PR Review Buddy reviews a branch in an isolated checkout and opens the result
|
|
367
|
+
in your browser. The review runs in a separate process against a worktree pinned to one commit, so
|
|
368
|
+
your own checkout is never read or written while it works.
|
|
369
|
+
|
|
370
|
+
prreviewbuddy review review the current branch
|
|
371
|
+
prreviewbuddy review <url> review the change on a pull request URL
|
|
372
|
+
prreviewbuddy review --base main compare against a base branch of your choosing
|
|
373
|
+
prreviewbuddy review --fresh review this branch again, keeping the existing review
|
|
374
|
+
prreviewbuddy review --agent <id> review with a named agent rather than the default
|
|
375
|
+
|
|
376
|
+
prreviewbuddy open list your reviews and open the index
|
|
377
|
+
prreviewbuddy open <id> open one review
|
|
378
|
+
prreviewbuddy update bring the newest review of this repository up to date
|
|
379
|
+
prreviewbuddy update <id> bring one review up to date
|
|
380
|
+
|
|
381
|
+
There is no account, no API key and no configuration. The analysis runs through the agent you have
|
|
382
|
+
already installed and signed in to, and nothing here ever asks you for a credential.
|
|
383
|
+
|
|
384
|
+
Reviews are kept in ~/.prreviewbuddy for thirty days. The workspace server outlives this command,
|
|
385
|
+
so a review stays open after the terminal that started it has closed. There is no command to stop
|
|
386
|
+
it, on purpose: open any review and use "Stop server" in the left rail. One server serves all of
|
|
387
|
+
them, and the page says so before it asks.
|
|
388
|
+
|
|
389
|
+
The isolated checkout a review was made in is kept for six hours after it finishes, because that is
|
|
390
|
+
what the assistant reads when you ask it questions, and is then removed along with the rest.`;
|
|
391
|
+
/**
|
|
392
|
+
* An agent asked for by name that this build does not have.
|
|
393
|
+
*
|
|
394
|
+
* Its own sentence rather than the registry's, because the registry's is written for the other
|
|
395
|
+
* direction: reading a stored job whose agent this version no longer knows how to run. Told to
|
|
396
|
+
* somebody who just typed the name, "this review was made with" describes a review that does not
|
|
397
|
+
* exist. What they need is the list.
|
|
398
|
+
*/
|
|
399
|
+
function unknownAgent(id, known) {
|
|
400
|
+
return `There is no agent called \`${id}\`. This version can run: ${known.join(", ")}.`;
|
|
401
|
+
}
|
|
402
|
+
/** Said before anything expensive happens, so nobody waits on a worktree to be told this. */
|
|
403
|
+
function agentMissing(reason) {
|
|
404
|
+
return `${reason}\n\nNothing was started. PR Review Buddy runs the analysis through an agent you install and sign in to yourself, so there is no key to set here instead.`;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The link, labelled, and one sentence about what it is.
|
|
408
|
+
*
|
|
409
|
+
* What was reviewed and where now belongs to the live progress block in `progress.ts`, which draws
|
|
410
|
+
* it as a header above the phases. This is the part that goes to stdout and survives a pipe, so it
|
|
411
|
+
* is the link and nothing that would be strange to find in a paste buffer.
|
|
412
|
+
*
|
|
413
|
+
* Labelled because the same URL is printed again when the review finishes. Two bare URLs read as a
|
|
414
|
+
* mistake; `Open workspace now` and `Review ready` read as a beginning and an end.
|
|
415
|
+
*/
|
|
416
|
+
function startedReview(url, _target) {
|
|
417
|
+
return [`Open workspace now ${url}`, "The review continues if this terminal closes."].join("\n");
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The one-review-per-branch rule, said to a person rather than to a session.
|
|
421
|
+
*
|
|
422
|
+
* The plugin's version of this is a set of instructions about what not to do next. A person does
|
|
423
|
+
* not need to be told not to run the command again; they need the link, its age, and the two ways
|
|
424
|
+
* out, one of which is a flag on the command they just typed.
|
|
425
|
+
*
|
|
426
|
+
* Both ways out are wrong for a review that has not finished, which is why the first thing this
|
|
427
|
+
* asks is whether there is anything to be done to it yet. Offering to update an analysis that has
|
|
428
|
+
* never produced a result is offering to reconcile something against itself.
|
|
429
|
+
*/
|
|
430
|
+
function existingReview(existing, url) {
|
|
431
|
+
if (existing.inProgress) return [
|
|
432
|
+
url,
|
|
433
|
+
"",
|
|
434
|
+
`A review of ${existing.branch} is being made right now, so here it is rather than a second one.`,
|
|
435
|
+
`Started ${relativeTime(existing.createdAt)}${describeSubject(existing)}. The link works now and fills in as it goes.`,
|
|
436
|
+
"",
|
|
437
|
+
"There is nothing to do but wait. If it stops before it finishes, running this command again picks it up from the phase it reached."
|
|
438
|
+
].join("\n");
|
|
439
|
+
const made = `Made ${relativeTime(existing.createdAt)}${describeSubject(existing)}. ${describeMovement(existing)}`;
|
|
440
|
+
return [
|
|
441
|
+
url,
|
|
442
|
+
"",
|
|
443
|
+
`A review of ${existing.branch} already exists, so here it is rather than a second one.`,
|
|
444
|
+
made,
|
|
445
|
+
"",
|
|
446
|
+
existing.baselineGone ? "This branch has been rebuilt since, so the commit the review was made from is no longer on it. There is nothing left to update it against, so this one needs starting fresh: `prreviewbuddy review --fresh`." : "To update the review in place, keeping your transcript and your place in it, run `prreviewbuddy update`. To start fresh alongside it, run `prreviewbuddy review --fresh`."
|
|
447
|
+
].join("\n");
|
|
448
|
+
}
|
|
449
|
+
function reviewFinished(url, summary, conversation) {
|
|
450
|
+
const facts = [
|
|
451
|
+
summary.overallRisk === "unknown" ? "Risk not rated" : `${cap(summary.overallRisk)} risk`,
|
|
452
|
+
count(summary.issues, "finding"),
|
|
453
|
+
count(summary.questions, "question")
|
|
454
|
+
];
|
|
455
|
+
const missing = conversation ? conversationMissing(conversation) : "";
|
|
456
|
+
return `\nReview ready · ${facts.join(" · ")}${missing ? `\n${missing}` : ""}\n${url}`;
|
|
457
|
+
}
|
|
458
|
+
function cap(word) {
|
|
459
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* An input the review did not get, said to the person who just waited for it.
|
|
463
|
+
*
|
|
464
|
+
* Until now this line existed only on the page, so somebody who ran the command, read the counts
|
|
465
|
+
* and clicked nothing was told a review had been made and not what it had been made from. The
|
|
466
|
+
* counts are the same either way, which is exactly why the silence was dangerous: a review with no
|
|
467
|
+
* conversation cannot know what a colleague already raised, and it looks identical to one that can.
|
|
468
|
+
*
|
|
469
|
+
* Two of the six outcomes stay silent, for the reasons `conversationNote` gives on the page. The
|
|
470
|
+
* other four differ from the page's wording in one respect that matters here: the way out is a
|
|
471
|
+
* command this person can type, not a button they would have to go and find.
|
|
472
|
+
*/
|
|
473
|
+
function conversationMissing(outcome) {
|
|
474
|
+
const without = "Made without the pull request conversation";
|
|
475
|
+
switch (outcome.kind) {
|
|
476
|
+
case "attached":
|
|
477
|
+
case "no-request": return "";
|
|
478
|
+
case "unsupported-forge": return outcome.host ? `${without}: PR Review Buddy does not read it on ${outcome.host} yet. This is a review of the code alone.` : `${without}: this repository has no remote to look a pull request up on. This is a review of the code alone.`;
|
|
479
|
+
case "cli-missing": return `${without}: \`${outcome.cli}\` is not installed. Install it and run \`prreviewbuddy update\` to read it in.`;
|
|
480
|
+
case "not-authenticated": return `${without}: \`${outcome.cli}\` is not signed in. Run \`${outcome.cli} auth login\`, then \`prreviewbuddy update\` to read it in.`;
|
|
481
|
+
case "failed": return `${without}. ${outcome.detail} Run \`prreviewbuddy update\` to try again.`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* A review that stopped, and the one thing worth knowing about it: nothing has to be redone. The
|
|
486
|
+
* worktree is pinned to a commit, so a resumed phase reads byte-identical code, which is what makes
|
|
487
|
+
* resuming and restarting the same thing.
|
|
488
|
+
*/
|
|
489
|
+
function reviewFailed(message, url) {
|
|
490
|
+
return [
|
|
491
|
+
"",
|
|
492
|
+
`Review paused. ${message}`,
|
|
493
|
+
"",
|
|
494
|
+
"Nothing was lost. Run the same command again and it carries on from the phase that stopped rather than starting over.",
|
|
495
|
+
url
|
|
496
|
+
].join("\n");
|
|
497
|
+
}
|
|
498
|
+
/** Said when the command is picking up a review that stopped, rather than making a second one. */
|
|
499
|
+
function resumingReview(failure) {
|
|
500
|
+
return `A review of this branch is paused: ${failure}\nCarrying on from where it stopped.`;
|
|
501
|
+
}
|
|
502
|
+
function noReviews() {
|
|
503
|
+
return "No reviews stored yet. Run `prreviewbuddy review` in a repository to make one.";
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Every stored review, with the facts that tell two of them apart.
|
|
507
|
+
*
|
|
508
|
+
* The row used to be the branch, the title, the age and the link, which is enough right up until
|
|
509
|
+
* two rows say the same thing. That happens more readily than it sounds. The title of a branch
|
|
510
|
+
* review is the subject of its oldest commit, so a branch cut from the tip of another inherits
|
|
511
|
+
* that branch's title and both derive it legitimately. This list is machine-wide, so two
|
|
512
|
+
* repositories contribute to it side by side. And reviewing one branch twice, which is a supported
|
|
513
|
+
* thing to do, produced two rows identical but for their age.
|
|
514
|
+
*
|
|
515
|
+
* The first time that happened it was read as contamination and cost an investigation, which is
|
|
516
|
+
* the whole argument for the second line. None of it is new information: the repository, the base
|
|
517
|
+
* and the size are already on the review, and the index page in the browser has shown them since
|
|
518
|
+
* it was rebuilt around branches rather than runs. The terminal never asked for them.
|
|
519
|
+
*
|
|
520
|
+
* The position appears only where there is something to position against. "Review 1 of 1" on the
|
|
521
|
+
* common case is noise dressed as precision.
|
|
522
|
+
*/
|
|
523
|
+
function reviewList(reviews) {
|
|
524
|
+
return reviews.map((review) => `${review.summary.branch} ${review.summary.title}\n ${describeListed(review)}\n ${review.url}`).join("\n\n");
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Answered in the order the questions get asked, which is the order the browser's card answers
|
|
528
|
+
* them in: where is it, what was it compared against, how big, how long ago, and which analysis of
|
|
529
|
+
* it this is.
|
|
530
|
+
*/
|
|
531
|
+
function describeListed({ summary, position, total }) {
|
|
532
|
+
return [
|
|
533
|
+
basename(summary.repoPath),
|
|
534
|
+
...summary.baseRef ? [`against ${summary.baseRef}`] : [],
|
|
535
|
+
count(summary.changedFiles, "file"),
|
|
536
|
+
relativeTime(summary.createdAt),
|
|
537
|
+
...total > 1 ? [`review ${position} of ${total}`] : []
|
|
538
|
+
].join(", ");
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The index and the bookmark, in that order and both of them.
|
|
542
|
+
*
|
|
543
|
+
* The index is one page that stays correct for as long as the server runs. The bookmark is the
|
|
544
|
+
* version of it that survives a restart, and it is rewritten every time the server starts or
|
|
545
|
+
* stops, so it is right afterwards without anyone maintaining it. A person told only the index URL
|
|
546
|
+
* learns the wrong thing to keep.
|
|
547
|
+
*/
|
|
548
|
+
function indexLinks(index, landingPage) {
|
|
549
|
+
if (!index) return "";
|
|
550
|
+
return [`All reviews: ${index}`, `Bookmark this instead, it survives a restart: file://${landingPage}`].join("\n");
|
|
551
|
+
}
|
|
552
|
+
function noSuchReview(id) {
|
|
553
|
+
return `There is no review called ${id}. Run \`prreviewbuddy open\` to see the ones there are.`;
|
|
554
|
+
}
|
|
555
|
+
function nothingToUpdate(repoPath) {
|
|
556
|
+
return `No review of ${repoPath} to bring up to date. Run \`prreviewbuddy review\` to make one.`;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The update's own sentence, which the harness already wrote for the workspace strip, plus the
|
|
560
|
+
* link. Not rephrased here: an update that stopped early has a specific reason and the reviewer
|
|
561
|
+
* gets the same reason whichever surface asked for it.
|
|
562
|
+
*/
|
|
563
|
+
function updateFinished(outcome, url) {
|
|
564
|
+
return `${outcome.message}\n${url}`;
|
|
565
|
+
}
|
|
566
|
+
function count(value, noun) {
|
|
567
|
+
return `${value} ${noun}${value === 1 ? "" : "s"}`;
|
|
568
|
+
}
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/write.ts
|
|
571
|
+
/**
|
|
572
|
+
* Where the two kinds of output go, decided once.
|
|
573
|
+
*
|
|
574
|
+
* Links and results to stdout, everything else to stderr, so `prreviewbuddy review | pbcopy`
|
|
575
|
+
* copies a URL and progress still reaches the terminal it was meant for.
|
|
576
|
+
*/
|
|
577
|
+
function out(text) {
|
|
578
|
+
process.stdout.write(`${text}\n`);
|
|
579
|
+
}
|
|
580
|
+
function err(text) {
|
|
581
|
+
process.stderr.write(`${text}\n`);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/commands/review.ts
|
|
585
|
+
/**
|
|
586
|
+
* `prreviewbuddy review`.
|
|
587
|
+
*
|
|
588
|
+
* The same four calls `prb_review` makes, in the same order and through the same functions:
|
|
589
|
+
* resolve the target, check whether a review of it already exists, mint the job, start the
|
|
590
|
+
* workspace server. That is what makes this a second surface on one engine rather than a second
|
|
591
|
+
* implementation of the review workflow.
|
|
592
|
+
*
|
|
593
|
+
* One thing differs, and it is a property of the surface rather than of the review. An MCP tool
|
|
594
|
+
* returns to a session that must not sit and wait, so it fires `runJob` and returns the link at
|
|
595
|
+
* once. A person who typed a command is already waiting, so this awaits the job and reports what
|
|
596
|
+
* it is doing. Either way the job is durable and either way the link works from the moment it is
|
|
597
|
+
* printed, so a terminal closed halfway through loses nothing.
|
|
598
|
+
*/
|
|
599
|
+
async function review(command, repoPath) {
|
|
600
|
+
let agent;
|
|
601
|
+
try {
|
|
602
|
+
agent = command.agentId ? agentById(command.agentId) : defaultAgent();
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
605
|
+
err(unknownAgent(command.agentId, AGENT_IDS));
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
const availability = await agent.available(agent.captureEnv());
|
|
609
|
+
if (!availability.ok) {
|
|
610
|
+
err(agentMissing(availability.reason));
|
|
611
|
+
return 1;
|
|
612
|
+
}
|
|
613
|
+
const target = withBase(await resolveTarget({
|
|
614
|
+
repoPath,
|
|
615
|
+
argument: command.argument,
|
|
616
|
+
resolveRequest: forgeResolver
|
|
617
|
+
}), command.base);
|
|
618
|
+
if (!command.fresh) {
|
|
619
|
+
const existing = await findExistingReview(target);
|
|
620
|
+
if (existing) {
|
|
621
|
+
const port = await ensureServer();
|
|
622
|
+
const url = workspaceUrl(port, existing.id, existing.token);
|
|
623
|
+
const failed = failureOf(existing.id);
|
|
624
|
+
if (!failed) {
|
|
625
|
+
out(existingReview(existing, url));
|
|
626
|
+
return 0;
|
|
627
|
+
}
|
|
628
|
+
err(resumingReview(failed));
|
|
629
|
+
return drive(existing.id, url);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const workspaceId = await startJob({
|
|
633
|
+
target,
|
|
634
|
+
...command.agentId ? { agentId: command.agentId } : {}
|
|
635
|
+
});
|
|
636
|
+
const port = await ensureServer();
|
|
637
|
+
const workspace = loadWorkspace(workspaceId);
|
|
638
|
+
const url = workspaceUrl(port, workspaceId, workspace.token);
|
|
639
|
+
out(startedReview(url, target));
|
|
640
|
+
return drive(workspaceId, url);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Why this review's job stopped, if it stopped badly. Null for a review that finished, and for one
|
|
644
|
+
* still being made.
|
|
645
|
+
*
|
|
646
|
+
* A job can read as failed and still be driven: the sweep calls a job stopped after half an hour
|
|
647
|
+
* without a write, and an analysis that streams nothing is silent while entirely alive. Resuming
|
|
648
|
+
* that one would start a second driver against the worktree the first is still standing in, so the
|
|
649
|
+
* claim is asked as well as the phase. `runJob` would refuse anyway, but by then this command has
|
|
650
|
+
* printed that it is picking the review up, and would report the refusal as a failed review.
|
|
651
|
+
*/
|
|
652
|
+
function failureOf(workspaceId) {
|
|
653
|
+
const workspace = loadWorkspace(workspaceId);
|
|
654
|
+
if (!workspace?.jobId) return null;
|
|
655
|
+
const job = loadJob(workspace.jobId);
|
|
656
|
+
if (!job || !isTerminal(job) || job.phase !== "failed") return null;
|
|
657
|
+
if (isClaimed(job.id)) return null;
|
|
658
|
+
return job.failure?.message ?? "The reason was not recorded.";
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Run the job to its end, saying what it is doing while it does it.
|
|
662
|
+
*
|
|
663
|
+
* The one place a review is awaited, whether it was just minted or is being resumed, so the two
|
|
664
|
+
* cannot drift into reporting the same outcome differently.
|
|
665
|
+
*/
|
|
666
|
+
async function drive(workspaceId, url) {
|
|
667
|
+
const workspace = loadWorkspace(workspaceId);
|
|
668
|
+
const job = loadJob(workspace.jobId);
|
|
669
|
+
const stop = followJob(workspace.jobId, job?.target ?? {
|
|
670
|
+
branch: "",
|
|
671
|
+
sha: "",
|
|
672
|
+
originRepoPath: ""
|
|
673
|
+
});
|
|
674
|
+
let finished;
|
|
675
|
+
try {
|
|
676
|
+
finished = await runJob(workspaceId);
|
|
677
|
+
if (finished.phase === "failed") {
|
|
678
|
+
err(reviewFailed(finished.failure?.message ?? "The reason was not recorded.", await linkNow(workspace, url)));
|
|
679
|
+
return 1;
|
|
680
|
+
}
|
|
681
|
+
} catch (error) {
|
|
682
|
+
err(reviewFailed(error instanceof Error ? error.message : String(error), await linkNow(workspace, url)));
|
|
683
|
+
return 1;
|
|
684
|
+
} finally {
|
|
685
|
+
stop();
|
|
686
|
+
}
|
|
687
|
+
out(reviewFinished(await linkNow(workspace, url), summarise(loadWorkspace(workspaceId)), finished.conversation));
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The link to this workspace as it is now, rather than as it was when the review started.
|
|
692
|
+
*
|
|
693
|
+
* The port is the one part of that link which is not durable. A review takes minutes, one server
|
|
694
|
+
* serves every review, and every workspace page carries "Stop server", so the server this command
|
|
695
|
+
* printed a link to at the start can be a different server by the time it finishes. Found doing
|
|
696
|
+
* exactly that: a nine minute review ended on a connection-refused link while the review itself sat
|
|
697
|
+
* healthy on the port that had replaced it.
|
|
698
|
+
*
|
|
699
|
+
* `ensureServer` starts one if none is running, which is what makes this the right call to make at
|
|
700
|
+
* the end rather than a check: the reviewer is being handed somewhere to go.
|
|
701
|
+
*
|
|
702
|
+
* Falls back to the opening link rather than failing. A link that might be stale is worth more than
|
|
703
|
+
* no link at all beside a result that has just been produced and cannot otherwise be reached.
|
|
704
|
+
*/
|
|
705
|
+
async function linkNow(workspace, fallback) {
|
|
706
|
+
try {
|
|
707
|
+
return workspaceUrl(await ensureServer(), workspace.id, workspace.token);
|
|
708
|
+
} catch {
|
|
709
|
+
return fallback;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/browser.ts
|
|
714
|
+
/**
|
|
715
|
+
* Opening a URL, which is what `open` promises and what a person typing it expects.
|
|
716
|
+
*
|
|
717
|
+
* Best effort on purpose. The URL is printed before this is called, so a machine with no browser,
|
|
718
|
+
* a container, or an SSH session all still get the working link; a failure here is worth nothing
|
|
719
|
+
* more than silence. Nothing is read back from the process and nothing waits for it.
|
|
720
|
+
*/
|
|
721
|
+
var LAUNCHERS = {
|
|
722
|
+
darwin: {
|
|
723
|
+
command: "open",
|
|
724
|
+
args: []
|
|
725
|
+
},
|
|
726
|
+
win32: {
|
|
727
|
+
command: "cmd",
|
|
728
|
+
args: [
|
|
729
|
+
"/c",
|
|
730
|
+
"start",
|
|
731
|
+
""
|
|
732
|
+
]
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
function launchBrowser(url) {
|
|
736
|
+
const launcher = LAUNCHERS[platform] ?? {
|
|
737
|
+
command: "xdg-open",
|
|
738
|
+
args: []
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
const child = spawn(launcher.command, [...launcher.args, url], {
|
|
742
|
+
stdio: "ignore",
|
|
743
|
+
detached: true
|
|
744
|
+
});
|
|
745
|
+
child.on("error", () => {});
|
|
746
|
+
child.unref();
|
|
747
|
+
} catch {}
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/commands/open.ts
|
|
751
|
+
/**
|
|
752
|
+
* `prreviewbuddy open`.
|
|
753
|
+
*
|
|
754
|
+
* With an id, one review. Without, the index, which is the page worth having: it lists every
|
|
755
|
+
* stored review with a working link into each and stays correct for as long as the server runs.
|
|
756
|
+
*
|
|
757
|
+
* Starting the server is the point of this command as much as opening anything is. A workspace URL
|
|
758
|
+
* carries a port, the server takes a new one every time it starts, and stopping it leaves every
|
|
759
|
+
* review intact on disk with every bookmark to it dead. This is how a review is got back.
|
|
760
|
+
*/
|
|
761
|
+
async function open(command) {
|
|
762
|
+
const port = await ensureServer();
|
|
763
|
+
if (command.id) {
|
|
764
|
+
const workspace = loadWorkspace(command.id);
|
|
765
|
+
if (!workspace) {
|
|
766
|
+
err(noSuchReview(command.id));
|
|
767
|
+
return 1;
|
|
768
|
+
}
|
|
769
|
+
const url = workspaceUrl(port, workspace.id, workspace.token);
|
|
770
|
+
out(url);
|
|
771
|
+
launchBrowser(url);
|
|
772
|
+
return 0;
|
|
773
|
+
}
|
|
774
|
+
const index = reviewsUrl(port, readServerRecord()?.token);
|
|
775
|
+
const all = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
776
|
+
const links = indexLinks(index, LANDING_PAGE);
|
|
777
|
+
if (links) out(links);
|
|
778
|
+
err(all.length === 0 ? noReviews() : reviewList(listed(port, all)));
|
|
779
|
+
if (index) launchBrowser(index);
|
|
780
|
+
return 0;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* How many rows a terminal gets. The browser index takes the whole store and gives it a search
|
|
784
|
+
* box; a list printed into a scrollback wants to end.
|
|
785
|
+
*/
|
|
786
|
+
var LISTED = 10;
|
|
787
|
+
function listed(port, all) {
|
|
788
|
+
return all.slice(0, LISTED).map((summary) => ({
|
|
789
|
+
summary,
|
|
790
|
+
url: workspaceUrl(port, summary.id, summary.token),
|
|
791
|
+
...positionInLineage(summary, [...all])
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
794
|
+
//#endregion
|
|
795
|
+
//#region src/commands/update.ts
|
|
796
|
+
/**
|
|
797
|
+
* `prreviewbuddy update`.
|
|
798
|
+
*
|
|
799
|
+
* The same call the workspace's Update review button makes, through the same function, with the
|
|
800
|
+
* same saving afterwards. `updateReview` computes and mutates in memory and leaves persistence to
|
|
801
|
+
* its caller, which is what lets the whole path be driven in a test without writing into anyone's
|
|
802
|
+
* store, so the two lines after it here are the whole difference between this and the HTTP route.
|
|
803
|
+
*
|
|
804
|
+
* Without an id it updates the newest review of the repository you are standing in, which is the
|
|
805
|
+
* one you meant. Picking the newest of all reviews everywhere would silently update somebody
|
|
806
|
+
* else's branch from the wrong directory.
|
|
807
|
+
*/
|
|
808
|
+
async function update(command, repoPath) {
|
|
809
|
+
const workspace = command.id ? loadWorkspace(command.id) : newestOf(repoPath);
|
|
810
|
+
if (!workspace) {
|
|
811
|
+
err(command.id ? noSuchReview(command.id) : nothingToUpdate(repoPath));
|
|
812
|
+
return 1;
|
|
813
|
+
}
|
|
814
|
+
const outcome = await updateReview(workspace);
|
|
815
|
+
workspace.lastUpdate = outcome;
|
|
816
|
+
saveWorkspace(workspace);
|
|
817
|
+
record({
|
|
818
|
+
event: "review_updated",
|
|
819
|
+
reviewId: workspace.id,
|
|
820
|
+
ok: outcome.ok,
|
|
821
|
+
stop: outcome.stop,
|
|
822
|
+
moved: outcome.moved,
|
|
823
|
+
resolved: outcome.counts?.resolved ?? 0,
|
|
824
|
+
stillOpen: outcome.counts?.stillOpen ?? 0,
|
|
825
|
+
affected: outcome.counts?.affected ?? 0,
|
|
826
|
+
newIssues: outcome.counts?.newIssues ?? 0,
|
|
827
|
+
questionsAnswered: outcome.counts?.questionsAnswered ?? 0
|
|
828
|
+
});
|
|
829
|
+
const port = await ensureServer();
|
|
830
|
+
out(updateFinished(outcome, workspaceUrl(port, workspace.id, workspace.token)));
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
function newestOf(repoPath) {
|
|
834
|
+
const mine = recentWorkspaces(Number.MAX_SAFE_INTEGER).find((summary) => summary.repoPath === repoPath);
|
|
835
|
+
return mine ? loadWorkspace(mine.id) : null;
|
|
836
|
+
}
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/main.ts
|
|
839
|
+
/**
|
|
840
|
+
* PR Review Buddy at the command line.
|
|
841
|
+
*
|
|
842
|
+
* One of two surfaces over the same engine. Everything below the three commands here is the
|
|
843
|
+
* harness, in process rather than shelled out to, which is the arrangement that makes this a
|
|
844
|
+
* caller of the review workflow rather than a second copy of it.
|
|
845
|
+
*
|
|
846
|
+
* No account, no API key and no configuration, which is a product decision the code has to keep:
|
|
847
|
+
* the analysis runs through the agent the developer has already installed and signed in to, and
|
|
848
|
+
* authentication stays between them and whoever makes it. There is nowhere in this binary to put
|
|
849
|
+
* a credential and nothing in it that asks for one.
|
|
850
|
+
*/
|
|
851
|
+
async function main(argv) {
|
|
852
|
+
const command = parse(argv);
|
|
853
|
+
switch (command.name) {
|
|
854
|
+
case "help":
|
|
855
|
+
out(HELP);
|
|
856
|
+
return 0;
|
|
857
|
+
case "version":
|
|
858
|
+
out(BUILD_VERSION);
|
|
859
|
+
return 0;
|
|
860
|
+
case "error":
|
|
861
|
+
err(command.message);
|
|
862
|
+
return 1;
|
|
863
|
+
case "review": return review(command, process.cwd());
|
|
864
|
+
case "open": return open(command);
|
|
865
|
+
case "update": return update(command, process.cwd());
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
process.exitCode = await main(process.argv.slice(2)).catch((error) => {
|
|
869
|
+
err(error instanceof Error ? error.message : String(error));
|
|
870
|
+
return 1;
|
|
871
|
+
});
|
|
872
|
+
//#endregion
|
|
873
|
+
export {};
|