@quickchatai/quickreview 1.1.0 → 1.2.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.
Files changed (225) hide show
  1. package/README.md +35 -19
  2. package/config.example.toml +41 -9
  3. package/dist/extensions/cli/login-route.js +5 -0
  4. package/dist/extensions/cli/login-route.js.map +1 -0
  5. package/dist/extensions/login-route.js +180 -0
  6. package/dist/extensions/login-route.js.map +1 -0
  7. package/dist/extensions/onboarding.js +29 -6
  8. package/dist/extensions/onboarding.js.map +1 -1
  9. package/dist/src/ciretry.js +2 -1
  10. package/dist/src/ciretry.js.map +1 -1
  11. package/dist/src/cli.js +43 -12
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/cliops.js +3 -2
  14. package/dist/src/cliops.js.map +1 -1
  15. package/dist/src/commands.js +25 -2
  16. package/dist/src/commands.js.map +1 -1
  17. package/dist/src/config.js +16 -2
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/daemon.js +24 -8
  20. package/dist/src/daemon.js.map +1 -1
  21. package/dist/src/debugroundpi.js +5 -1
  22. package/dist/src/debugroundpi.js.map +1 -1
  23. package/dist/src/dispatch.js +5 -4
  24. package/dist/src/dispatch.js.map +1 -1
  25. package/dist/src/engine/debugpi.js +17 -5
  26. package/dist/src/engine/debugpi.js.map +1 -1
  27. package/dist/src/execboundary.js +10 -3
  28. package/dist/src/execboundary.js.map +1 -1
  29. package/dist/src/github.js +51 -16
  30. package/dist/src/github.js.map +1 -1
  31. package/dist/src/githubtoken.js +346 -0
  32. package/dist/src/githubtoken.js.map +1 -0
  33. package/dist/src/loginhint.js +8 -3
  34. package/dist/src/loginhint.js.map +1 -1
  35. package/dist/src/loginrefresh/pirefresh.js +3 -0
  36. package/dist/src/loginrefresh/pirefresh.js.map +1 -1
  37. package/dist/src/npmverbs.js +4 -2
  38. package/dist/src/npmverbs.js.map +1 -1
  39. package/dist/src/onboard/choices.js +29 -1
  40. package/dist/src/onboard/choices.js.map +1 -1
  41. package/dist/src/onboard/engine.js +8 -23
  42. package/dist/src/onboard/engine.js.map +1 -1
  43. package/dist/src/onboard/launch.js +12 -20
  44. package/dist/src/onboard/launch.js.map +1 -1
  45. package/dist/src/onboard/pipin.js +22 -0
  46. package/dist/src/onboard/pipin.js.map +1 -0
  47. package/dist/src/onboard/stepforward.js +41 -0
  48. package/dist/src/onboard/stepforward.js.map +1 -0
  49. package/dist/src/onboard/steps/appslug.js +4 -1
  50. package/dist/src/onboard/steps/appslug.js.map +1 -1
  51. package/dist/src/onboard/steps/compose.js +29 -0
  52. package/dist/src/onboard/steps/compose.js.map +1 -1
  53. package/dist/src/onboard/steps/configfill.js +102 -32
  54. package/dist/src/onboard/steps/configfill.js.map +1 -1
  55. package/dist/src/onboard/steps/githubaccount.js +595 -0
  56. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  57. package/dist/src/onboard/steps/githubapp.js +65 -7
  58. package/dist/src/onboard/steps/githubapp.js.map +1 -1
  59. package/dist/src/onboard/steps/golive.js +2 -0
  60. package/dist/src/onboard/steps/golive.js.map +1 -1
  61. package/dist/src/onboard/steps/index.js +11 -0
  62. package/dist/src/onboard/steps/index.js.map +1 -1
  63. package/dist/src/onboard/steps/ingress.js +80 -25
  64. package/dist/src/onboard/steps/ingress.js.map +1 -1
  65. package/dist/src/onboard/steps/llmauth.js +103 -30
  66. package/dist/src/onboard/steps/llmauth.js.map +1 -1
  67. package/dist/src/onboard/steps/memory.js +53 -1
  68. package/dist/src/onboard/steps/memory.js.map +1 -1
  69. package/dist/src/onboard/steps/proof.js +366 -56
  70. package/dist/src/onboard/steps/proof.js.map +1 -1
  71. package/dist/src/onboard/steps/topology.js +6 -6
  72. package/dist/src/onboard/steps/topology.js.map +1 -1
  73. package/dist/src/onboard/steps/transport.js +9 -1
  74. package/dist/src/onboard/steps/transport.js.map +1 -1
  75. package/dist/src/onboard/steps/tuiroute.js +102 -0
  76. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  77. package/dist/src/onboard/steps/verifyup.js +17 -1
  78. package/dist/src/onboard/steps/verifyup.js.map +1 -1
  79. package/dist/src/onboard/toolnames.js +30 -0
  80. package/dist/src/onboard/toolnames.js.map +1 -0
  81. package/dist/src/operatorwrite.js +23 -0
  82. package/dist/src/operatorwrite.js.map +1 -0
  83. package/dist/src/outcomes.js +4 -1
  84. package/dist/src/outcomes.js.map +1 -1
  85. package/dist/src/posting-journal.js +2 -1
  86. package/dist/src/posting-journal.js.map +1 -1
  87. package/dist/src/posting-outbox.js +6 -2
  88. package/dist/src/posting-outbox.js.map +1 -1
  89. package/dist/src/posting-receipt.js +14 -1
  90. package/dist/src/posting-receipt.js.map +1 -1
  91. package/dist/src/recheck.js +2 -1
  92. package/dist/src/recheck.js.map +1 -1
  93. package/dist/src/reconcile.js +22 -2
  94. package/dist/src/reconcile.js.map +1 -1
  95. package/dist/src/reviewhistory.js +32 -6
  96. package/dist/src/reviewhistory.js.map +1 -1
  97. package/dist/src/reviewnow.js +45 -0
  98. package/dist/src/reviewnow.js.map +1 -0
  99. package/dist/src/state.js.map +1 -1
  100. package/dist/src/statemachine.js +4 -1
  101. package/dist/src/statemachine.js.map +1 -1
  102. package/dist/src/takeoverpi.js +13 -0
  103. package/dist/src/takeoverpi.js.map +1 -1
  104. package/dist/src/tui/app.js +49 -0
  105. package/dist/src/tui/app.js.map +1 -1
  106. package/dist/src/tui/board.js +88 -12
  107. package/dist/src/tui/board.js.map +1 -1
  108. package/dist/src/tui/cmdbar.js +4 -1
  109. package/dist/src/tui/cmdbar.js.map +1 -1
  110. package/dist/src/tui/companion.js +74 -10
  111. package/dist/src/tui/companion.js.map +1 -1
  112. package/dist/src/tui/connect.js +7 -1
  113. package/dist/src/tui/connect.js.map +1 -1
  114. package/dist/src/tui/filters.js +5 -2
  115. package/dist/src/tui/filters.js.map +1 -1
  116. package/dist/src/tui/ghread.js +61 -5
  117. package/dist/src/tui/ghread.js.map +1 -1
  118. package/dist/src/tui/index.js +106 -20
  119. package/dist/src/tui/index.js.map +1 -1
  120. package/dist/src/tui/memforward.js +39 -2
  121. package/dist/src/tui/memforward.js.map +1 -1
  122. package/dist/src/tui/model.js +25 -0
  123. package/dist/src/tui/model.js.map +1 -1
  124. package/dist/src/tui/prpause.js +7 -0
  125. package/dist/src/tui/prpause.js.map +1 -1
  126. package/dist/src/tui/remote.js +7 -1
  127. package/dist/src/tui/remote.js.map +1 -1
  128. package/dist/src/tui/route.js +143 -0
  129. package/dist/src/tui/route.js.map +1 -0
  130. package/dist/src/tui/setupstate.js +29 -0
  131. package/dist/src/tui/setupstate.js.map +1 -0
  132. package/dist/src/tui/sshtriage.js +102 -5
  133. package/dist/src/tui/sshtriage.js.map +1 -1
  134. package/dist/src/tui/theme.js +2 -1
  135. package/dist/src/tui/theme.js.map +1 -1
  136. package/dist/src/tui/unwedge.js +2 -12
  137. package/dist/src/tui/unwedge.js.map +1 -1
  138. package/dist/src/verdicts.js +17 -5
  139. package/dist/src/verdicts.js.map +1 -1
  140. package/dist/src/version.js +1 -1
  141. package/dist/src/version.js.map +1 -1
  142. package/dist/src/webhook.js +12 -1
  143. package/dist/src/webhook.js.map +1 -1
  144. package/dist/src/wire.js +28 -6
  145. package/dist/src/wire.js.map +1 -1
  146. package/dist/src/workerworld.js +7 -1
  147. package/dist/src/workerworld.js.map +1 -1
  148. package/docker/companion/entrypoint.sh +20 -1
  149. package/docker/entrypoint.sh +17 -0
  150. package/extensions/cli/login-route.ts +10 -0
  151. package/extensions/login-route.ts +220 -0
  152. package/extensions/onboarding.ts +35 -6
  153. package/package.json +2 -2
  154. package/prompts/onboarding.md +57 -18
  155. package/scripts/mint-token.sh +25 -4
  156. package/src/ciretry.ts +2 -1
  157. package/src/cli.ts +49 -14
  158. package/src/cliops.ts +3 -2
  159. package/src/commands.ts +28 -2
  160. package/src/config.ts +24 -4
  161. package/src/daemon.ts +32 -7
  162. package/src/debugroundpi.ts +11 -1
  163. package/src/dispatch.ts +5 -4
  164. package/src/engine/debugpi.ts +17 -5
  165. package/src/execboundary.ts +16 -4
  166. package/src/github.ts +57 -15
  167. package/src/githubtoken.ts +435 -0
  168. package/src/loginhint.ts +8 -3
  169. package/src/loginrefresh/pirefresh.ts +3 -0
  170. package/src/npmverbs.ts +4 -2
  171. package/src/onboard/choices.ts +34 -1
  172. package/src/onboard/engine.ts +22 -3
  173. package/src/onboard/launch.ts +17 -29
  174. package/src/onboard/pipin.ts +31 -0
  175. package/src/onboard/stepforward.ts +61 -0
  176. package/src/onboard/steps/appslug.ts +4 -1
  177. package/src/onboard/steps/compose.ts +31 -0
  178. package/src/onboard/steps/configfill.ts +121 -33
  179. package/src/onboard/steps/githubaccount.ts +716 -0
  180. package/src/onboard/steps/githubapp.ts +92 -8
  181. package/src/onboard/steps/golive.ts +2 -0
  182. package/src/onboard/steps/index.ts +11 -0
  183. package/src/onboard/steps/ingress.ts +98 -31
  184. package/src/onboard/steps/llmauth.ts +114 -29
  185. package/src/onboard/steps/memory.ts +58 -1
  186. package/src/onboard/steps/proof.ts +473 -55
  187. package/src/onboard/steps/topology.ts +7 -6
  188. package/src/onboard/steps/transport.ts +15 -0
  189. package/src/onboard/steps/tuiroute.ts +119 -0
  190. package/src/onboard/steps/verifyup.ts +20 -1
  191. package/src/onboard/toolnames.ts +34 -0
  192. package/src/operatorwrite.ts +25 -0
  193. package/src/outcomes.ts +8 -1
  194. package/src/posting-journal.ts +6 -1
  195. package/src/posting-outbox.ts +10 -2
  196. package/src/posting-receipt.ts +17 -0
  197. package/src/recheck.ts +2 -1
  198. package/src/reconcile.ts +29 -3
  199. package/src/reviewhistory.ts +20 -6
  200. package/src/reviewnow.ts +76 -0
  201. package/src/state.ts +18 -0
  202. package/src/statemachine.ts +4 -1
  203. package/src/takeoverpi.ts +13 -0
  204. package/src/tui/app.ts +47 -0
  205. package/src/tui/board.ts +111 -14
  206. package/src/tui/cmdbar.ts +4 -1
  207. package/src/tui/companion.ts +78 -10
  208. package/src/tui/connect.ts +7 -1
  209. package/src/tui/filters.ts +7 -3
  210. package/src/tui/ghread.ts +74 -7
  211. package/src/tui/index.ts +114 -23
  212. package/src/tui/memforward.ts +52 -3
  213. package/src/tui/model.ts +36 -2
  214. package/src/tui/prpause.ts +11 -2
  215. package/src/tui/remote.ts +9 -1
  216. package/src/tui/route.ts +173 -0
  217. package/src/tui/setupstate.ts +52 -0
  218. package/src/tui/sshtriage.ts +127 -6
  219. package/src/tui/theme.ts +2 -1
  220. package/src/tui/unwedge.ts +2 -14
  221. package/src/verdicts.ts +23 -5
  222. package/src/version.ts +1 -1
  223. package/src/webhook.ts +10 -1
  224. package/src/wire.ts +36 -7
  225. package/src/workerworld.ts +7 -1
@@ -4,14 +4,23 @@
4
4
  // the review it produced before anything could ever reach GitHub.
5
5
  //
6
6
  // The shape:
7
- // 1. ask for a real PR of theirs with the trigger label on it (the wizard
8
- // never creates a demo PR);
9
- // 2. follow the round to completion THROUGH THE STATE STORE — the daemon's
7
+ // 1. ask for one real, open PR (a number or its URL; the five most recently
8
+ // updated open PRs of the repo are offered, the person's own included —
9
+ // on the own-account route those are the main target). The wizard never
10
+ // creates a demo PR;
11
+ // 2. start its review IMMEDIATELY: one `review-now` command into the
12
+ // daemon's root-sealed operator channel (src/commands.ts, the channel
13
+ // `qcr drain` and the TUI's levers use), which queues the PR exactly as a
14
+ // fresh trigger label would — with no GitHub write and no label for the
15
+ // person to add. A daemon that cannot take the command (an older image)
16
+ // falls back to the label, said plainly;
17
+ // 3. follow the round to completion THROUGH THE STATE STORE — the daemon's
10
18
  // own record, read with the daemon's own `qcr why --json` inside the
11
19
  // container, so the wizard is reading exactly what the operator would;
12
- // 3. SHOW the finished review, read out of the dry-run record — the file the
13
- // mutation gate wrote INSTEAD of calling GitHub. The operator judges the
14
- // output while it is still impossible for it to have posted.
20
+ // 4. SHOW the finished review, read out of the dry-run record — the file the
21
+ // mutation gate wrote INSTEAD of calling GitHub: the verdict, how many
22
+ // findings, the top ones, and where the full text is. The operator judges
23
+ // the output while it is still impossible for it to have posted.
15
24
  //
16
25
  // Two paths get real guidance rather than a spinner, because they are the two
17
26
  // that actually happen: the round never starts (label spelling, webhook not
@@ -19,17 +28,26 @@
19
28
  // is cancelled (the daemon's own event log says why). Neither ever hangs: the
20
29
  // poll is bounded and every attempt logs where it got to.
21
30
  //
22
- // Nothing here writes anything anywhere. It reads the state store, reads the
23
- // dry-run directory, and records what it saw on the checklist — the record the
24
- // go-live step later demands before it will even offer the flip.
31
+ // The one thing it writes is that operator command file. Otherwise it reads the
32
+ // state store, the dry-run directory and (to offer PRs) GitHub, and records
33
+ // what it saw on the checklist — the record the go-live step later demands
34
+ // before it will even offer the flip.
35
+ import { join } from "node:path";
36
+ import { mintAppJwt, USER_TOKEN_FILE } from "@quickchatai/pi-quickreview/src/githubAuth.js";
25
37
  import { loadConfig } from "../../config.js";
26
- import { CHOICE_KEYS } from "../choices.js";
38
+ import { operatorCommandWriteScriptAt } from "../../operatorwrite.js";
39
+ import { CHOICE_KEYS, userAuthRecorded } from "../choices.js";
40
+ import { readTextOrNull } from "../engine.js";
27
41
  import { decodeDryrunRecord, sandboxReviewFrom, } from "../../compare.js";
28
- import { CONTAINER_DRYRUN_DIR, configPath, readConfigText } from "./compose.js";
29
- import { readTomlKey } from "./ingress.js";
30
- import { DEFAULT_LABELS } from "./githubapp.js";
42
+ import { CONTAINER_COMMANDS_DIR, CONTAINER_DRYRUN_DIR, configPath, readConfigText, } from "./compose.js";
43
+ import { readTomlKey, secretsDirFor } from "./ingress.js";
44
+ import { DEFAULT_LABELS, api, findInstallation, installationToken, parseRepoSlug, readCredentials, } from "./githubapp.js";
31
45
  import { stackTransportFor } from "./transport.js";
32
46
  export const PROOF_STEP_ID = "proof_round";
47
+ /** A string input or choice, else "" — never an object's stringification. */
48
+ function textOf(v) {
49
+ return typeof v === "string" ? v : "";
50
+ }
33
51
  /** The completed-proof record. The go-live step will not offer the flip
34
52
  * without it AND a state-store re-verification of it. */
35
53
  export const PROOF_CHOICE_KEY = CHOICE_KEYS.proof;
@@ -180,14 +198,42 @@ export async function readContainerDryrun(transport, limit = 40, opts = {}) {
180
198
  export function recordForPr(r, pr) {
181
199
  return r.pr === pr || new RegExp(`/(pulls|issues)/${pr}(/|$)`).test(r.path);
182
200
  }
201
+ /** How many findings the summary lists before "and N more". */
202
+ export const TOP_FINDINGS = 5;
203
+ /** The first line of a comment, trimmed to one readable line. */
204
+ function headline(body) {
205
+ const line = body
206
+ .trim()
207
+ .split("\n")
208
+ .find((l) => l.trim() !== "") ?? "";
209
+ const flat = line.trim();
210
+ return flat.length > 160 ? `${flat.slice(0, 157)}…` : flat;
211
+ }
183
212
  /** The would-be review, rendered for a human to judge. */
184
- export function renderReview(review, pr) {
213
+ export function renderReview(review, pr, extras = {}) {
214
+ const top = review.comments
215
+ .slice(0, TOP_FINDINGS)
216
+ .map((c, i) => ` ${i + 1}. ${c.path}:${c.line} — ${headline(c.body)}`);
217
+ const more = review.comments.length - top.length;
185
218
  const head = [
186
219
  `This is the review your reviewer produced for #${pr}. It was NOT posted: dry_run is on, so the`,
187
220
  `mutation gate wrote it to state/dryrun/ instead of calling GitHub.`,
188
221
  "",
189
222
  `Verdict it would have submitted: ${review.state}`,
223
+ ...(extras.verdict && extras.verdict !== review.state
224
+ ? [
225
+ `Verdict on the merits: ${extras.verdict} (stated in the review body; GitHub gets it as a ${review.state} review)`,
226
+ ]
227
+ : []),
190
228
  `Inline comments: ${review.comments.length}`,
229
+ ...(top.length > 0
230
+ ? ["Top findings:", ...top, ...(more > 0 ? [` … and ${more} more, below`] : [])]
231
+ : []),
232
+ ...(extras.file
233
+ ? [
234
+ `Full text: state/dryrun/${extras.file} in the daemon's state${extras.readCommand ? ` — \`${extras.readCommand}\`` : ""}`,
235
+ ]
236
+ : []),
191
237
  "",
192
238
  "--- review body ---",
193
239
  review.body.trim() === "" ? "(empty body)" : review.body.trim(),
@@ -195,6 +241,8 @@ export function renderReview(review, pr) {
195
241
  const comments = review.comments.map((c, i) => `--- inline comment ${i + 1}: ${c.path}:${c.line} ---\n${c.body.trim()}`);
196
242
  return [...head, ...(comments.length ? ["", ...comments] : [])].join("\n");
197
243
  }
244
+ /** How many open PRs the offer lists. */
245
+ export const PR_OFFER_SIZE = 5;
198
246
  function triggerLabelOf(adapters) {
199
247
  const text = readConfigText(adapters);
200
248
  const configured = text ? readTomlKey(text, "github", "trigger_label") : null;
@@ -207,19 +255,98 @@ function repoOf(choices, adapters) {
207
255
  const text = readConfigText(adapters);
208
256
  return (text ? readTomlKey(text, "github", "repo") : null) ?? "your repository";
209
257
  }
258
+ /** A PR answer: 17, "17", "#17", or the PR's URL (github.com/o/r/pull/17).
259
+ * A URL names its repo too, which the caller checks against the reviewed one. */
260
+ export function parsePrAnswer(raw) {
261
+ if (typeof raw === "number")
262
+ return Number.isInteger(raw) && raw > 0 ? { pr: raw, repo: null } : null;
263
+ if (typeof raw !== "string")
264
+ return null;
265
+ const v = raw.trim();
266
+ const url = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)\/pull\/(\d+)(?:[/?#].*)?$/.exec(v);
267
+ if (url)
268
+ return { pr: Number(url[2]), repo: url[1] };
269
+ const n = /^#?(\d+)$/.exec(v);
270
+ return n && Number(n[1]) > 0 ? { pr: Number(n[1]), repo: null } : null;
271
+ }
210
272
  function prFrom(input, choices) {
211
- for (const raw of [input.pr, parseProofRecord(choices[PROOF_CHOICE_KEY])?.pr]) {
212
- const n = typeof raw === "string" ? Number(raw.trim()) : raw;
213
- if (typeof n === "number" && Number.isInteger(n) && n > 0)
214
- return n;
273
+ for (const raw of [input.pr, input.url, parseProofRecord(choices[PROOF_CHOICE_KEY])?.pr]) {
274
+ const parsed = parsePrAnswer(raw);
275
+ if (parsed)
276
+ return parsed;
215
277
  }
216
278
  return null;
217
279
  }
280
+ /** A command file name the daemon's watcher serves (`*.json`), unique per call. */
281
+ export function reviewNowFileName(now, pr) {
282
+ return `${now.toISOString().replace(/[:.]/g, "-")}-onboard-reviewnow-${pr}.json`;
283
+ }
284
+ /** The argv that writes one `review-now` command into the root-sealed
285
+ * operator directory, through the same script the TUI's levers use. The
286
+ * payload is built here from a number, so it can carry no quote. */
287
+ export function reviewNowWriteArgv(name, pr) {
288
+ if (!Number.isInteger(pr) || pr <= 0)
289
+ throw new Error(`not a PR number: ${pr}`);
290
+ const payload = JSON.stringify({ command: "review-now", pr });
291
+ return [
292
+ "sh",
293
+ "-c",
294
+ `printf '%s\\n' '${payload}' | ( ${operatorCommandWriteScriptAt(CONTAINER_COMMANDS_DIR, name)} )`,
295
+ ];
296
+ }
297
+ /** The argv that prints the daemon's result for that command, or `pending`. */
298
+ export function reviewNowResultArgv(name) {
299
+ const file = `${CONTAINER_COMMANDS_DIR}/results/${name.replace(/\.json$/, "")}.result.json`;
300
+ return ["sh", "-c", `if [ -f '${file}' ]; then cat '${file}'; else echo pending; fi`];
301
+ }
302
+ /** A token that can read the reviewed repo: the person's token file on the
303
+ * own-account route, an App installation token on the App route; null when
304
+ * neither is to hand (the offer is then skipped, never guessed). */
305
+ async function readToken(ctx, repo, apiBase, secretsDir) {
306
+ if (userAuthRecorded(ctx.choices)) {
307
+ const token = (readTextOrNull(join(secretsDir, USER_TOKEN_FILE)) ?? "").trim();
308
+ return token === "" ? null : token;
309
+ }
310
+ const creds = readCredentials(secretsDir);
311
+ if (!creds.complete)
312
+ return null;
313
+ try {
314
+ const jwt = mintAppJwt(creds.appId, creds.pem, () => ctx.adapters.clock().getTime());
315
+ const found = await findInstallation(ctx.adapters.fetch, apiBase, jwt, repo.owner);
316
+ if (found.status !== "found")
317
+ return null;
318
+ const minted = await installationToken(ctx.adapters.fetch, apiBase, jwt, found.id);
319
+ return "token" in minted ? minted.token : null;
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ }
325
+ /** The repo's most recently updated open PRs (any author — the person's own
326
+ * included). Empty when GitHub cannot be read. */
327
+ export async function listOpenPrs(fetchFn, apiBase, token, repo, limit = PR_OFFER_SIZE) {
328
+ const r = await api(fetchFn, `${apiBase}/repos/${repo.owner}/${repo.name}/pulls?state=open&sort=updated&direction=desc&per_page=${limit}`, { headers: { authorization: `Bearer ${token}` } });
329
+ if (r.status !== 200 || !Array.isArray(r.body))
330
+ return [];
331
+ return r.body
332
+ .filter((p) => typeof p.number === "number")
333
+ .slice(0, limit)
334
+ .map((p) => ({
335
+ number: p.number,
336
+ title: typeof p.title === "string" ? p.title : "",
337
+ author: textOf(p.user?.login),
338
+ updated_at: typeof p.updated_at === "string" ? p.updated_at : "",
339
+ draft: p.draft === true,
340
+ }));
341
+ }
218
342
  export function proofRoundStep(opts = {}) {
219
343
  const pollAttempts = opts.pollAttempts ?? 60;
220
344
  const pollIntervalMs = opts.pollIntervalMs ?? 30_000;
221
345
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
222
346
  const dryrunLimit = opts.dryrunLimit ?? 40;
347
+ const apiBase = opts.apiBase ?? "https://api.github.com";
348
+ const resultAttempts = opts.resultAttempts ?? 15;
349
+ const resultIntervalMs = opts.resultIntervalMs ?? 2_000;
223
350
  function log(ctx, line) {
224
351
  ctx.adapters.log?.(`${PROOF_STEP_ID}: ${line}`);
225
352
  }
@@ -236,21 +363,108 @@ export function proofRoundStep(opts = {}) {
236
363
  ? { obs, detail: "" }
237
364
  : { obs: null, detail: `\`qcr why\` did not answer JSON: ${r.stdout.slice(0, 200)}` };
238
365
  }
366
+ /** Send `review-now` for `pr` and wait for the daemon's receipt. `refused`
367
+ * means the daemon read the command and said no (its reason is the answer);
368
+ * otherwise a failure is the channel itself (write or no receipt). */
369
+ async function requestReview(ctx, transport, pr) {
370
+ const name = reviewNowFileName(ctx.adapters.clock(), pr);
371
+ const write = await transport.execAsRoot(reviewNowWriteArgv(name, pr));
372
+ if (write.code !== 0)
373
+ return {
374
+ ok: false,
375
+ refused: false,
376
+ detail: `the command write failed: ${(write.stderr || write.stdout).trim().slice(0, 200) || `exit ${write.code}`}`,
377
+ };
378
+ for (let attempt = 1; attempt <= resultAttempts; attempt += 1) {
379
+ await sleep(resultIntervalMs);
380
+ const read = await transport.exec(reviewNowResultArgv(name));
381
+ const body = read.code === 0 ? read.stdout.trim() : "";
382
+ if (body === "" || body === "pending")
383
+ continue;
384
+ let v;
385
+ try {
386
+ v = JSON.parse(body);
387
+ }
388
+ catch {
389
+ continue; // the daemon may still be writing its receipt
390
+ }
391
+ if (v.ok === true)
392
+ return {
393
+ ok: true,
394
+ refused: false,
395
+ detail: `queued${typeof v.state === "string" ? `, state ${v.state}` : ""}`,
396
+ };
397
+ return {
398
+ ok: false,
399
+ refused: true,
400
+ detail: typeof v.reason === "string"
401
+ ? v.reason.replace(/\s+/g, " ").slice(0, 300)
402
+ : "no reason given",
403
+ };
404
+ }
405
+ return {
406
+ ok: false,
407
+ refused: false,
408
+ detail: `no answer to ${name} after ${resultAttempts} reads — the daemon may still be starting`,
409
+ };
410
+ }
411
+ /** No PR yet: offer the repo's open PRs, and say what happens next. */
412
+ async function askCheck(ctx, repo, label, posture, typed) {
413
+ const slug = parseRepoSlug(repo);
414
+ let offer = [];
415
+ if (slug) {
416
+ const token = await readToken(ctx, slug, apiBase, opts.secretsDir ?? secretsDirFor(ctx.adapters, ctx.choices));
417
+ if (token)
418
+ offer = await listOpenPrs(ctx.adapters.fetch, apiBase, token, slug);
419
+ }
420
+ const me = textOf(ctx.choices[CHOICE_KEYS.accountLogin]);
421
+ const rows = offer.map((p) => ` #${p.number} ${p.title}${p.draft ? " (draft)" : ""} — by ${p.author}${me !== "" && p.author === me ? " (yours)" : ""}, updated ${p.updated_at}`);
422
+ const assurance = posture === "true"
423
+ ? "Nothing will be posted to GitHub: dry_run is on, so every would-be comment and review is written to a file instead, and you will read it here before deciding anything."
424
+ : posture === "false"
425
+ ? "This installation is LIVE (dry_run = false): the review will be posted to GitHub. The flag belongs to you; these instructions do not change it."
426
+ : "The dry_run posture could not be read, so posting is unknown. Check the live config before reviewing a PR.";
427
+ return {
428
+ name: "ask",
429
+ ok: false,
430
+ detail: (typed ? "That is not a pull request number or link.\n" : "") +
431
+ "Read this out, then call this tool again with the PR number (pr) or its link (url).\n\n" +
432
+ (rows.length > 0
433
+ ? `The ${rows.length} most recently updated open pull requests in ${repo}:\n${rows.join("\n")}\n` +
434
+ "Pick one of these — or any other open PR in it — one you would actually want reviewed.\n"
435
+ : `Pick a real, open pull request in ${repo} — one you would actually want reviewed (your own is fine).\n`) +
436
+ "Its review starts as soon as you name it: nothing to add to the PR. (For later reviews, the trigger is the label " +
437
+ `"${label}" — the reviewer never reviews anything on its own initiative.)\n` +
438
+ assurance,
439
+ };
440
+ }
239
441
  return {
240
442
  id: PROOF_STEP_ID,
241
443
  title: "The proof: watch a real PR get reviewed",
242
- explain: "The wizard does not call itself done until you have seen the reviewer work. Ask the operator to open (or pick) one of " +
243
- "their own pull requests and put the trigger label on it, then call this tool with that PR number. It follows the round " +
244
- "through the daemon's own state until a verdict lands, and then SHOWS them the review it produced — read out of the " +
245
- "dry-run record, so they judge the output while it is still impossible for it to have posted to GitHub. If nothing " +
246
- "happens, or the round fails, it says what to check rather than waiting forever.",
444
+ explain: "The wizard does not call itself done until you have seen the reviewer work, on one real, open pull request. Call this " +
445
+ "tool with no arguments to get the repo's five most recently updated open PRs (the operator's own included) to pick " +
446
+ "from, then call it again with pr (a number) or url (the PR's link). The review starts IMMEDIATELY — no label to add: " +
447
+ "the wizard asks the daemon directly. It follows the round through the daemon's own state until a verdict lands, then " +
448
+ "SHOWS the review — the verdict, how many findings, the top ones, and where the full text is — read out of the " +
449
+ "dry-run record, so they judge it while it is still impossible for it to have posted to GitHub. If nothing happens, " +
450
+ "or the round fails, it says what to check rather than waiting forever.",
247
451
  order: 76,
452
+ // After setup: the board offers it; finish never waits on it.
453
+ optional: true,
248
454
  parameters: {
249
455
  type: "object",
250
456
  properties: {
251
457
  pr: {
252
458
  type: "integer",
253
- description: "The pull request number the operator labelled. Call with no arguments first to get the exact instructions to read out.",
459
+ description: "The pull request number to review now. Call with no arguments first to get the open PRs to choose from.",
460
+ },
461
+ url: {
462
+ type: "string",
463
+ description: "The pull request's URL, instead of its number (github.com/<owner>/<repo>/pull/<n>).",
464
+ },
465
+ confirm_live: {
466
+ type: "string",
467
+ description: 'Only when the installation is LIVE (dry_run = false): "yes" means the operator agreed that this review posts to GitHub.',
254
468
  },
255
469
  },
256
470
  },
@@ -258,30 +472,44 @@ export function proofRoundStep(opts = {}) {
258
472
  const transport = stackTransportFor(ctx, opts.transport);
259
473
  const repo = repoOf(ctx.choices, ctx.adapters);
260
474
  const label = triggerLabelOf(ctx.adapters);
261
- const pr = prFrom(ctx.input, ctx.choices);
262
- if (pr === null) {
263
- let posture = null;
264
- try {
265
- posture = String(loadConfig(configPath(ctx.adapters)).dry_run);
266
- }
267
- catch {
268
- /* Unknown posture stays explicit. */
269
- }
270
- const assurance = posture === "true"
271
- ? "Nothing will be posted to GitHub: dry_run is on, so every would-be comment and review is written to a file instead, and you will read it here before deciding anything."
272
- : posture === "false"
273
- ? "This installation is LIVE (dry_run = false): adding the label can post the review to GitHub. The flag belongs to you; these instructions do not change it."
274
- : "The dry_run posture could not be read, so posting is unknown. Check the live config before adding the trigger label.";
475
+ let posture = null;
476
+ try {
477
+ posture = String(loadConfig(configPath(ctx.adapters)).dry_run);
478
+ }
479
+ catch {
480
+ /* Unknown posture stays explicit. */
481
+ }
482
+ const answer = prFrom(ctx.input, ctx.choices);
483
+ const typed = ctx.input.pr !== undefined || ctx.input.url !== undefined;
484
+ if (answer === null || (typed && parsePrAnswer(ctx.input.pr ?? ctx.input.url) === null)) {
485
+ return { ok: false, checks: [await askCheck(ctx, repo, label, posture, typed)] };
486
+ }
487
+ const pr = answer.pr;
488
+ if (answer.repo !== null && answer.repo.toLowerCase() !== repo.toLowerCase()) {
275
489
  return {
276
490
  ok: false,
277
491
  checks: [
278
492
  {
279
493
  name: "ask",
280
494
  ok: false,
281
- detail: `Read this out, then call this tool again with the PR number.\n\n` +
282
- `Pick a real, open pull request of yours in ${repo} — one you would actually want reviewed. Add the label ` +
283
- `"${label}" to it. That label IS the trigger: the reviewer never reviews anything on its own initiative.\n` +
284
- assurance,
495
+ detail: `that link is a pull request in ${answer.repo}, but this reviewer reviews ${repo}. Pick an open PR in ${repo} ` +
496
+ "(or change the reviewed repo in the earlier GitHub step)",
497
+ },
498
+ ],
499
+ };
500
+ }
501
+ if (posture !== "true" && textOf(ctx.input.confirm_live).trim().toLowerCase() !== "yes") {
502
+ return {
503
+ ok: false,
504
+ checks: [
505
+ {
506
+ name: "live",
507
+ ok: false,
508
+ detail: posture === "false"
509
+ ? `This installation is LIVE (dry_run = false): reviewing #${pr} now POSTS the review to GitHub. Ask the operator; ` +
510
+ 'only on their yes, call this step again with confirm_live: "yes". The flag is theirs; this step never changes it.'
511
+ : `The dry_run posture could not be read (${configPath(ctx.adapters)}), so whether this review would post is unknown. ` +
512
+ 'Fix the config first — or, on the operator\'s explicit yes, call again with confirm_live: "yes".',
285
513
  },
286
514
  ],
287
515
  };
@@ -299,24 +527,60 @@ export function proofRoundStep(opts = {}) {
299
527
  ],
300
528
  };
301
529
  }
302
- log(ctx, `following #${pr} — checking the daemon's state every ${Math.round(pollIntervalMs / 1000)}s, up to ${pollAttempts} times`);
303
- let last = "";
304
- for (let attempt = 1; attempt <= pollAttempts; attempt += 1) {
305
- const { obs, detail } = await observe(transport, pr);
306
- if (obs === null) {
530
+ // Where the PR stands before anything is asked of the daemon.
531
+ const first = await observe(transport, pr);
532
+ if (first.obs === null)
533
+ return { ok: false, checks: [stateStoreCheck(first.detail, transport)] };
534
+ const before = first.obs.rounds.at(-1);
535
+ const recorded = parseProofRecord(ctx.choices[PROOF_CHOICE_KEY]);
536
+ const resumable = recorded !== null &&
537
+ recorded.pr === pr &&
538
+ before !== undefined &&
539
+ before.round_no === recorded.round_no &&
540
+ completedRound(before);
541
+ const inFlight = before?.status === "running" ||
542
+ first.obs.state === "queued" ||
543
+ first.obs.state === "reviewing";
544
+ const initial = classifyRounds(first.obs);
545
+ log(ctx, initial.kind === "waiting"
546
+ ? initial.detail
547
+ : `#${pr} has ${first.obs.rounds.length} round${first.obs.rounds.length === 1 ? "" : "s"} on record (newest: round ${before?.round_no}, ${before?.status})`);
548
+ // Ask for the review — unless one is already under way, or this is a
549
+ // re-run that only has to show the round it already recorded.
550
+ let requested = false;
551
+ let requestNote = "";
552
+ if (!resumable && !inFlight) {
553
+ const sent = await requestReview(ctx, transport, pr);
554
+ requested = sent.ok;
555
+ requestNote = sent.detail;
556
+ if (!sent.ok && sent.refused)
307
557
  return {
308
558
  ok: false,
309
559
  checks: [
310
560
  {
311
- name: "state-store",
561
+ name: "review-now",
312
562
  ok: false,
313
- detail: `could not read the daemon's state (${detail}) — Fix: \`${transport.logsCommand(200)}\`. The daemon ` +
314
- "may still be starting (the image is prebuilt; its first boot migrates state and reconciles), so give it a minute after bring-up and run this step again",
563
+ detail: `the daemon refused to review #${pr} now: ${sent.detail}. ` +
564
+ `If this daemon predates "review this one PR now", add the label "${label}" to the PR instead and run this step again — ` +
565
+ "the label is the trigger the daemon has always understood.",
315
566
  },
316
567
  ],
317
568
  };
318
- }
319
- const outcome = classifyRounds(obs);
569
+ log(ctx, sent.ok
570
+ ? `asked the daemon to review #${pr} now (${sent.detail}) — no label needed`
571
+ : `could not ask the daemon directly (${sent.detail}); following #${pr} in case it is labelled "${label}"`);
572
+ }
573
+ // Only a round NEWER than the ones before the request answers it.
574
+ const floor = requested ? (before?.round_no ?? 0) : -Infinity;
575
+ log(ctx, `following #${pr} — checking the daemon's state every ${Math.round(pollIntervalMs / 1000)}s, up to ${pollAttempts} times`);
576
+ let last = "";
577
+ let settledPolls = 0;
578
+ for (let attempt = 1; attempt <= pollAttempts; attempt += 1) {
579
+ const { obs, detail } = await observe(transport, pr);
580
+ if (obs === null)
581
+ return { ok: false, checks: [stateStoreCheck(detail, transport)] };
582
+ const fresh = { ...obs, rounds: obs.rounds.filter((r) => r.round_no > floor) };
583
+ const outcome = classifyRounds(fresh);
320
584
  if (outcome.kind === "done") {
321
585
  log(ctx, `round ${outcome.round.round_no} finished with ${outcome.round.verdict}`);
322
586
  return finish(ctx, transport, pr, outcome.round);
@@ -324,17 +588,36 @@ export function proofRoundStep(opts = {}) {
324
588
  if (outcome.kind === "failed") {
325
589
  return { ok: false, checks: [roundFailedCheck(outcome.round, pr, transport)] };
326
590
  }
591
+ // Asked, but the daemon settled the PR without a new round (nothing
592
+ // changed since a review of this same head): that review is the answer.
593
+ if (requested &&
594
+ outcome.kind === "waiting" &&
595
+ obs.state !== "queued" &&
596
+ obs.state !== "reviewing") {
597
+ settledPolls += 1;
598
+ const standing = obs.rounds.at(-1);
599
+ if (settledPolls >= 2 && completedRound(standing)) {
600
+ log(ctx, `the daemon reviewed nothing new — #${pr}'s head was already reviewed in round ${standing.round_no}; showing that review`);
601
+ return finish(ctx, transport, pr, standing);
602
+ }
603
+ }
604
+ else
605
+ settledPolls = 0;
327
606
  last =
328
607
  outcome.kind === "running"
329
608
  ? `round ${outcome.round.round_no} is running (started ${outcome.round.started_at})`
330
- : outcome.detail;
609
+ : requested && obs.state === "queued"
610
+ ? "queued — the round starts as soon as a review slot is free"
611
+ : outcome.detail;
331
612
  log(ctx, `${last} — attempt ${attempt}/${pollAttempts}`);
332
613
  if (attempt < pollAttempts)
333
614
  await sleep(pollIntervalMs);
334
615
  }
335
616
  return {
336
617
  ok: false,
337
- checks: [timeoutCheck(pr, repo, label, last, pollAttempts, pollIntervalMs, transport)],
618
+ checks: [
619
+ timeoutCheck(pr, repo, label, last, pollAttempts, pollIntervalMs, transport, requested ? requestNote : null),
620
+ ],
338
621
  };
339
622
  },
340
623
  async verify(ctx) {
@@ -431,7 +714,19 @@ export function proofRoundStep(opts = {}) {
431
714
  return { ok: false, checks };
432
715
  }
433
716
  const other = records.filter((r) => r.kind !== "create_review").length;
434
- checks.push({ name: "review", ok: true, detail: renderReview(review, pr) });
717
+ const file = records
718
+ .filter((r) => r.kind === "create_review" && r.reviewPurpose === undefined)
719
+ .at(-1)?.file;
720
+ checks.push({
721
+ name: "review",
722
+ ok: true,
723
+ detail: renderReview(review, pr, {
724
+ verdict: round.verdict,
725
+ ...(file
726
+ ? { file, readCommand: transport.execCommand(`cat ${CONTAINER_DRYRUN_DIR}/${file}`) }
727
+ : {}),
728
+ }),
729
+ });
435
730
  checks.push({
436
731
  name: "nothing-posted",
437
732
  ok: true,
@@ -482,13 +777,28 @@ function roundFailedCheck(round, pr, transport) {
482
777
  "and an authentication error there is the answer.",
483
778
  };
484
779
  }
485
- function timeoutCheck(pr, repo, label, last, attempts, intervalMs, transport) {
780
+ function stateStoreCheck(detail, transport) {
781
+ return {
782
+ name: "state-store",
783
+ ok: false,
784
+ detail: `could not read the daemon's state (${detail}) — Fix: \`${transport.logsCommand(200)}\`. The daemon ` +
785
+ "may still be starting (the image is prebuilt; its first boot migrates state and reconciles), so give it a minute after bring-up and run this step again",
786
+ };
787
+ }
788
+ function timeoutCheck(pr, repo, label, last, attempts, intervalMs, transport,
789
+ /** Set when the review was asked for directly (review-now): its receipt. */
790
+ requested = null) {
486
791
  const minutes = Math.round((attempts * intervalMs) / 60_000);
487
792
  return {
488
793
  name: "round",
489
794
  ok: false,
490
795
  detail: `no finished round for #${pr} after ${minutes} minutes of watching (last seen: ${last || "nothing at all"}). ` +
491
796
  "Nothing is broken by this — the wizard simply stopped waiting, and running this step again picks the watch back up.\n" +
797
+ (requested !== null
798
+ ? `The review WAS asked for directly (the daemon answered: ${requested}), so no label is needed. A first round on a ` +
799
+ "big PR can take a while; `qcr tui " +
800
+ `${pr}\` shows the round's own live log. If it never started:\n`
801
+ : "") +
492
802
  "What to check, in the order that actually finds it:\n" +
493
803
  `① the label on #${pr} must be exactly "${label}" — the daemon matches the name from [github] trigger_label, and a ` +
494
804
  "near-miss looks like nothing happening;\n" +