prreviewbuddy 0.18.0 → 0.19.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/dist/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { $ as agentIdOf, B as MANAGED_ROOT, C as LANDING_PAGE, Ct as agentById, D as updateReview, G as allJobs, H as removeWorktree, I as forgeResolver, J as loadJob, M as reanalyseReview, N as runJob, P as startJob, Q as git, R as clearClaim, St as AGENT_IDS, Tt as detectAgents, U as checkFreshness, V as readMarker, W as WORK_PHASES, X as resolveTarget, Z as displayRef, a as liveJobsFor, at as positionInLineage, bt as STORE_ROOT, dt as summarise, et as followedRefName, f as ensureServer, g as workspaceUrl, h as stopServer, it as loadWorkspace, l as record, lt as reviewedRepositories, m as reviewsUrl, nt as lineageKeyFor, o as runningJobs, p as readServerRecord, q as isTerminal, r as deleteReview$1, st as recentWorkspaces, t as relativeTime, tt as groupByLineage, u as bootstrapUrl, ut as saveWorkspace, v as BUILD_VERSION, y as PACKAGE_NAME, z as isClaimed } from "./relative_time-Ckbb4Srd.js";
2
+ import { A as startJob, C as updateReview, Ct as git, Dt as STORE_ROOT, Et as writeConfigValues, F as BUILD_VERSION, G as readMarker, H as clearClaim, I as PACKAGE_NAME, J as WORK_PHASES, K as removeWorktree, M as record, N as recordTelemetryUploadConsent, O as reanalyseReview, Ot as AGENT_IDS, P as telemetryUploadConsent, Q as loadJob, St as displayRef, Tt as readConfigFile, U as isClaimed, W as MANAGED_ROOT, Y as allJobs, Z as isTerminal, a as liveJobsFor, at as positionInLineage, d as ensureServer, dt as summarise, et as followedRefName, f as readServerRecord, h as workspaceUrl, it as loadWorkspace, jt as detectAgents, k as runJob, kt as agentById, l as bootstrapUrl, lt as reviewedRepositories, m as stopServer, nt as lineageKeyFor, o as wasBlocked, p as reviewsUrl, q as checkFreshness, r as deleteReview$1, s as runningJobs, st as recentWorkspaces, t as relativeTime, tt as groupByLineage, ut as saveWorkspace, wt as agentIdOf, xt as resolveTarget, y as LANDING_PAGE, z as forgeResolver } from "./relative_time-BdTOYOad.js";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { hostname } from "node:os";
7
7
  import { createInterface } from "node:readline";
8
8
  import { platform } from "node:process";
@@ -250,18 +250,13 @@ function unknownCommand(head) {
250
250
  * No validation of the id here, deliberately. A person may configure an agent before installing it,
251
251
  * and this module has no business knowing which ids exist: the registry does, and the CLI edge is
252
252
  * where an unknown one is refused with a list of the real ones.
253
+ *
254
+ * The read-modify-write that keeps everybody else's keys used to live here. It is in
255
+ * `config_file.ts` now, because being the only careful writer of a shared file is not a property
256
+ * one module can hold.
253
257
  */
254
- var CONFIG_PATH = join(STORE_ROOT, "config.json");
255
258
  /** The key this module owns. Everything else in the file belongs to somebody else and is kept. */
256
259
  var KEY = "agent";
257
- function readConfig() {
258
- try {
259
- const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
260
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
261
- } catch {
262
- return {};
263
- }
264
- }
265
260
  /**
266
261
  * The configured default, or nothing.
267
262
  *
@@ -271,25 +266,11 @@ function readConfig() {
271
266
  * lie if this invented one.
272
267
  */
273
268
  function readAgentPreference() {
274
- const value = readConfig()[KEY];
269
+ const value = readConfigFile()[KEY];
275
270
  return typeof value === "string" && value !== "" ? value : null;
276
271
  }
277
- /**
278
- * Remember an agent, keeping every setting this module does not own.
279
- *
280
- * Read-modify-write rather than a bare write. `config.json` holds `editor` and whatever else the
281
- * reviewer has put there, and a preference that quietly dropped their editor while saving their
282
- * agent would be a worse bargain than not remembering anything.
283
- */
284
272
  function writeAgentPreference(id) {
285
- save({
286
- ...readConfig(),
287
- [KEY]: id
288
- });
289
- }
290
- function save(config) {
291
- mkdirSync(STORE_ROOT, { recursive: true });
292
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
273
+ writeConfigValues({ [KEY]: id });
293
274
  }
294
275
  //#endregion
295
276
  //#region ../../packages/review-harness/src/agents/resolve.ts
@@ -665,6 +646,10 @@ your own checkout is never read or written while it works.
665
646
  prreviewbuddy agents <agent> show details for ${AGENT_NAMES}
666
647
  prreviewbuddy config get agent the coding agent used when you do not name one
667
648
  prreviewbuddy config set agent <agent> remember ${AGENT_NAMES}
649
+ prreviewbuddy config get telemetry-upload
650
+ whether anonymous usage counts are sent
651
+ prreviewbuddy config set telemetry-upload on|off
652
+ send them, or stop
668
653
 
669
654
  prreviewbuddy uninstall remove everything this put on your machine
670
655
  prreviewbuddy uninstall --keep-reviews keep your reviews and settings, clear the rest
@@ -773,6 +758,53 @@ function unknownSetting(key, known) {
773
758
  return `There is no setting called \`${key}\`. This can read and set: ${known.join(", ")}.`;
774
759
  }
775
760
  /**
761
+ * The first-run question about transmission, in three lines.
762
+ *
763
+ * Three because a first run is not the place for a privacy policy, and the policy is one line
764
+ * away. What it names is what a person would otherwise have to assume: the list of things that are
765
+ * never sent is longer than the list of things that are, and printing the longer one is the honest
766
+ * way round.
767
+ */
768
+ function telemetryUploadQuestion() {
769
+ return [
770
+ "",
771
+ "Help improve PR Review Buddy by sending anonymous usage counts?",
772
+ "Never your code, diffs, file paths, repository names, prompts or review content.",
773
+ "https://prreviewbuddy.com/privacy lists every field, exactly.",
774
+ ""
775
+ ].join("\n");
776
+ }
777
+ function telemetryUploadUnclear() {
778
+ return "Please answer y or n.\n";
779
+ }
780
+ function telemetryUploadGranted() {
781
+ return "Thank you. Turn it off whenever you like with `prreviewbuddy config set telemetry-upload off`.";
782
+ }
783
+ function telemetryUploadDeclined() {
784
+ return "Nothing will be sent. Turn it on with `prreviewbuddy config set telemetry-upload on`.";
785
+ }
786
+ /**
787
+ * What `config get telemetry-upload` says, including for the machine that has not been asked.
788
+ *
789
+ * Three answers rather than two, because "off" and "never asked" are different facts and only one
790
+ * of them is a decision somebody made.
791
+ */
792
+ function telemetryUploadIs(consent) {
793
+ if (consent === null) return [
794
+ "Anonymous usage counts are not being sent, and nobody has been asked yet.",
795
+ "",
796
+ "The question is asked the first time you run PR Review Buddy in a terminal.",
797
+ "Answer it now with `prreviewbuddy config set telemetry-upload on` or `off`."
798
+ ].join("\n");
799
+ return consent ? "Anonymous usage counts are being sent. Turn them off with `prreviewbuddy config set telemetry-upload off`." : "Anonymous usage counts are not being sent.";
800
+ }
801
+ function telemetryUploadSet(on) {
802
+ return on ? "Anonymous usage counts will be sent. Never your code, diffs, file paths, repository names, prompts or review content." : "Nothing will be sent. The usage log on this machine is unchanged; `PRB_TELEMETRY=off` turns that off too.";
803
+ }
804
+ function unknownSettingValue(key, value, known) {
805
+ return `\`${key}\` cannot be set to \`${value}\`. It takes: ${known.join(", ")}.`;
806
+ }
807
+ /**
776
808
  * What to say when `resolveAgent` could not resolve one.
777
809
  *
778
810
  * Each outcome gets its own sentence rather than a shared "could not choose an agent", because the
@@ -1087,6 +1119,17 @@ var DELETE_QUESTION = "Delete it?";
1087
1119
  function deleteUnattended(id) {
1088
1120
  return `Deleting a review asks for confirmation, and there is nothing here to ask. Run \`prreviewbuddy review --review ${id} --delete --yes\` if you meant it.`;
1089
1121
  }
1122
+ /**
1123
+ * Something else held the review for the moment the deletion asked for it.
1124
+ *
1125
+ * Not `deleteRunning`, which names a branch and an analysis, because this is the case where there
1126
+ * is nothing to name: another surface deleting the same card, or a job being created in the same
1127
+ * instant. "Try again" is honest here and would not be there -- an analysis takes minutes and this
1128
+ * takes milliseconds.
1129
+ */
1130
+ function deleteBusy() {
1131
+ return "This review is being worked on right now, so nothing was deleted. Try again in a moment.";
1132
+ }
1090
1133
  function deleteCancelled() {
1091
1134
  return "Nothing was deleted.";
1092
1135
  }
@@ -1234,7 +1277,12 @@ async function deleteReview(command, deps = REAL$1) {
1234
1277
  return 1;
1235
1278
  }
1236
1279
  }
1237
- out(deleteFinished(await deps.remove(id)));
1280
+ const report = await deps.remove(id);
1281
+ if (wasBlocked(report)) {
1282
+ err(report.live.length > 0 ? deleteRunning(report.live[0]?.job.target.branch ?? workspace.changeSet.headRef) : deleteBusy());
1283
+ return 1;
1284
+ }
1285
+ out(deleteFinished(report));
1238
1286
  return 0;
1239
1287
  }
1240
1288
  //#endregion
@@ -1328,6 +1376,7 @@ function phaseLines(job, colour) {
1328
1376
  const lines = [];
1329
1377
  const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
1330
1378
  for (const phase of WORK_PHASES) {
1379
+ if (!applies(job, phase)) continue;
1331
1380
  const complete = job.completed.includes(phase) || job.phase === "done";
1332
1381
  const failed = failedAt === phase;
1333
1382
  const current = !complete && !failed && job.phase === phase;
@@ -1343,6 +1392,31 @@ function phaseLines(job, colour) {
1343
1392
  }
1344
1393
  return lines;
1345
1394
  }
1395
+ /**
1396
+ * Whether a phase is part of *this* review, rather than part of the pipeline in general.
1397
+ *
1398
+ * Only `conversation` is ever optional, and reviewing a branch with no pull request is an ordinary
1399
+ * supported thing to do rather than a degraded version of something else. Ticking "Read pull
1400
+ * request conversation" on such a review claims a step that did not happen and makes a complete
1401
+ * review look like an incomplete one; printing "No pull request found" instead is no better,
1402
+ * because a line in a checklist reads as something missing whatever words are in it.
1403
+ *
1404
+ * So the rule is that an optional stage which did not apply is not rendered at all:
1405
+ *
1406
+ * - A pull request review always shows it. It is the point of that review, and it is known before
1407
+ * the phase runs, so the line never appears late.
1408
+ * - A branch review shows it only once a conversation was actually attached, which happens when the
1409
+ * branch turns out to have a request on a forge we read. Real work, worth saying.
1410
+ * - Everything else — no request, an unsupported host, `gh` missing — shows nothing.
1411
+ *
1412
+ * The absent cases are still explained, once, at the end: the closing summary says the review was
1413
+ * made without the conversation and why. That is the right place for it, because by then it is a
1414
+ * fact about the finished review rather than a gap in a list of things still happening.
1415
+ */
1416
+ function applies(job, phase) {
1417
+ if (phase !== "conversation") return true;
1418
+ return job.target.pullRequest !== void 0 || job.conversation?.kind === "attached";
1419
+ }
1346
1420
  /** Same words as the phase heading, give or take the articles and the trailing full stop. */
1347
1421
  function restates(note, label) {
1348
1422
  const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
@@ -1787,19 +1861,27 @@ async function agents(command) {
1787
1861
  //#endregion
1788
1862
  //#region src/commands/config.ts
1789
1863
  /**
1790
- * `prreviewbuddy config get agent` and `config set agent <agent>`.
1864
+ * `prreviewbuddy config get <key>` and `config set <key> <value>`.
1791
1865
  *
1792
- * One setting, which is the whole of it today. The command is general because the file is: reading
1793
- * and writing one key by name is a shape that survives a second setting, and a `default-agent`
1794
- * command would not.
1866
+ * Two settings now, which is what the shape was always for: reading and writing one key by name
1867
+ * survives a second setting, and a `default-agent` command would not have.
1868
+ *
1869
+ * The keys are hyphenated at the command line and camel-cased in `config.json`, and the difference
1870
+ * is deliberate rather than an oversight. `telemetryUpload` is the storage key and stays put; what
1871
+ * somebody types is `telemetry-upload`, because that is how every other flag on this binary reads.
1795
1872
  */
1796
1873
  /** The keys this command owns. Anything else is refused by name rather than silently stored. */
1797
- var KEYS = ["agent"];
1874
+ var KEYS = ["agent", "telemetry-upload"];
1875
+ /** What the second one takes. A boolean setting, so a closed pair rather than anything truthy. */
1876
+ var SWITCH_VALUES = ["on", "off"];
1798
1877
  function config(command) {
1799
1878
  if (!KEYS.includes(command.key)) {
1800
1879
  err(unknownSetting(command.key, KEYS));
1801
1880
  return 1;
1802
1881
  }
1882
+ return command.key === "agent" ? agent(command) : telemetryUpload(command);
1883
+ }
1884
+ function agent(command) {
1803
1885
  if (command.action === "get") {
1804
1886
  const preferred = readAgentPreference();
1805
1887
  out(preferred ? agentPreferenceIs(preferred) : noAgentPreference());
@@ -1814,6 +1896,28 @@ function config(command) {
1814
1896
  out(agentPreferenceSet(id));
1815
1897
  return 0;
1816
1898
  }
1899
+ /**
1900
+ * The answer to the first-run question, readable and changeable afterwards.
1901
+ *
1902
+ * This is the only way to say yes without being asked, and the only way to change the answer
1903
+ * without editing a file by hand. Both directions matter: a person who declined at a prompt they
1904
+ * were half reading needs a way back, and a person who agreed needs one out.
1905
+ */
1906
+ function telemetryUpload(command) {
1907
+ if (command.action === "get") {
1908
+ out(telemetryUploadIs(telemetryUploadConsent()));
1909
+ return 0;
1910
+ }
1911
+ const value = command.value.toLowerCase();
1912
+ if (!SWITCH_VALUES.includes(value)) {
1913
+ err(unknownSettingValue("telemetry-upload", command.value, SWITCH_VALUES));
1914
+ return 1;
1915
+ }
1916
+ const on = value === "on";
1917
+ recordTelemetryUploadConsent(on);
1918
+ out(telemetryUploadSet(on));
1919
+ return 0;
1920
+ }
1817
1921
  //#endregion
1818
1922
  //#region ../../packages/review-harness/src/workspace/uninstall.ts
1819
1923
  /**
@@ -2008,10 +2112,12 @@ function isInside(path, root) {
2008
2112
  * round deletes somebody's data to satisfy a list written before the data existed.
2009
2113
  *
2010
2114
  * So what stays is everything else, and today that means their reviews (`workspaces`,
2011
- * `reviews.html`), their settings (`config.json`, `prompts.json`) and the two records of having
2012
- * been told something once (`onboarding-shown`, `telemetry-notice-shown`). Those last two are not
2013
- * runtime state: it is the same person on the same machine, they have read both notices, and
2014
- * showing either again would be this product forgetting a conversation rather than clearing one.
2115
+ * `reviews.html`), their settings (`config.json`, `prompts.json`) and the record of having been
2116
+ * greeted once (`onboarding-shown`). That last one is not runtime state: it is the same person on
2117
+ * the same machine, they have read the banner, and showing it again would be this product
2118
+ * forgetting a conversation rather than clearing one. The answer to the telemetry question is in
2119
+ * `config.json` with the rest of the settings, for the same reason and a stronger one: re-asking a
2120
+ * question somebody has already answered is worse than repeating a notice they have already read.
2015
2121
  * The usage log itself (`events.jsonl`) does go, because that is data about them rather than a note
2016
2122
  * to ourselves.
2017
2123
  */
@@ -2174,6 +2280,65 @@ function recordGreeting() {
2174
2280
  } catch {}
2175
2281
  }
2176
2282
  //#endregion
2283
+ //#region src/telemetry_consent.ts
2284
+ /**
2285
+ * The one question this product asks about itself, asked once per machine.
2286
+ *
2287
+ * `packages/review-harness/src/telemetry/` has recorded usage events to a local file since it
2288
+ * existed, disclosed and on by default. Sending a subset of them to a server we run is a different
2289
+ * act, and `apps/website/src/pages/Privacy.tsx` promised in as many words that if it ever happened
2290
+ * it would be opt in and off by default. This is how that promise is kept.
2291
+ *
2292
+ * Hand-rolled on `node:readline` beside `select.ts` and `confirm.ts`, for the reason they both
2293
+ * give: this binary has no runtime dependencies and one question is not worth acquiring one. The io
2294
+ * is injected for the same reason theirs is, so what the answer means is testable without a shell.
2295
+ *
2296
+ * **A bare Return re-asks rather than declining.** `confirm.ts` next door defaults to no on Return,
2297
+ * which is right there because every one of its callers is about to delete something. Here it would
2298
+ * be wrong in a subtler way: "no default" was the decision, and a capitalised `N` in the brackets
2299
+ * turns it into opt-out-by-keystroke, which is a different thing wearing the same words. Somebody
2300
+ * who has not read the question has not answered it.
2301
+ *
2302
+ * **No terminal means no question and no answer.** A durable job, a CI run and a piped invocation
2303
+ * must not block on a question nobody can see, and must not be recorded as having declined one:
2304
+ * the setting stays unset, nothing is sent, and the same machine is asked properly the first time
2305
+ * a person runs this interactively.
2306
+ */
2307
+ async function askTelemetryUpload(options) {
2308
+ const stored = telemetryUploadConsent();
2309
+ if (stored !== null) return { kind: stored ? "granted" : "declined" };
2310
+ if (!options.isTTY) return { kind: "unanswered" };
2311
+ const reader = createInterface({ input: options.input });
2312
+ const lines = reader[Symbol.asyncIterator]();
2313
+ try {
2314
+ options.write(`${telemetryUploadQuestion()}\n`);
2315
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2316
+ options.write("[y/n] ");
2317
+ const next = await lines.next();
2318
+ if (next.done) return { kind: "unanswered" };
2319
+ const answer = next.value.trim().toLowerCase();
2320
+ if (answer === "y" || answer === "yes") return settle(options, true);
2321
+ if (answer === "n" || answer === "no") return settle(options, false);
2322
+ options.write(telemetryUploadUnclear());
2323
+ }
2324
+ return { kind: "unanswered" };
2325
+ } finally {
2326
+ reader.close();
2327
+ }
2328
+ }
2329
+ /**
2330
+ * Store the answer, and say what was stored.
2331
+ *
2332
+ * Said out loud in both directions, because a setting written to a file the person did not open is
2333
+ * a change they need to know how to undo, and because "yes" and "nothing happened" look identical
2334
+ * from a terminal otherwise.
2335
+ */
2336
+ function settle(options, answer) {
2337
+ recordTelemetryUploadConsent(answer);
2338
+ options.write(`${answer ? telemetryUploadGranted() : telemetryUploadDeclined()}\n`);
2339
+ return { kind: answer ? "granted" : "declined" };
2340
+ }
2341
+ //#endregion
2177
2342
  //#region src/main.ts
2178
2343
  /**
2179
2344
  * PR Review Buddy at the command line.
@@ -2206,9 +2371,33 @@ function greet(command) {
2206
2371
  err(firstRun(BUILD_VERSION));
2207
2372
  recordGreeting();
2208
2373
  }
2374
+ /**
2375
+ * The one question, asked after the hello and before the work.
2376
+ *
2377
+ * The greeting's commands minus `config`, which is where the answer is changed: somebody typing
2378
+ * `config set telemetry-upload off` is answering the question, and prompting them for it first
2379
+ * would be this binary interrupting the answer in order to ask.
2380
+ *
2381
+ * On stderr with its input from stdin, like every other question here, so a piped stdout still
2382
+ * carries only the result and a piped stdin is a run with nobody to ask.
2383
+ */
2384
+ var ASKED = [
2385
+ "review",
2386
+ "open",
2387
+ "agents"
2388
+ ];
2389
+ async function ask(command) {
2390
+ if (!ASKED.includes(command.name)) return;
2391
+ await askTelemetryUpload({
2392
+ isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
2393
+ input: process.stdin,
2394
+ write: (chunk) => void process.stderr.write(chunk)
2395
+ });
2396
+ }
2209
2397
  async function main(argv) {
2210
2398
  const command = parse(argv);
2211
2399
  greet(command);
2400
+ await ask(command);
2212
2401
  switch (command.name) {
2213
2402
  case "help":
2214
2403
  out(HELP);