@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.
- package/README.md +35 -19
- package/config.example.toml +41 -9
- package/dist/extensions/cli/login-route.js +5 -0
- package/dist/extensions/cli/login-route.js.map +1 -0
- package/dist/extensions/login-route.js +180 -0
- package/dist/extensions/login-route.js.map +1 -0
- package/dist/extensions/onboarding.js +29 -6
- package/dist/extensions/onboarding.js.map +1 -1
- package/dist/src/ciretry.js +2 -1
- package/dist/src/ciretry.js.map +1 -1
- package/dist/src/cli.js +43 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/cliops.js +3 -2
- package/dist/src/cliops.js.map +1 -1
- package/dist/src/commands.js +25 -2
- package/dist/src/commands.js.map +1 -1
- package/dist/src/config.js +16 -2
- package/dist/src/config.js.map +1 -1
- package/dist/src/daemon.js +24 -8
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/debugroundpi.js +5 -1
- package/dist/src/debugroundpi.js.map +1 -1
- package/dist/src/dispatch.js +5 -4
- package/dist/src/dispatch.js.map +1 -1
- package/dist/src/engine/debugpi.js +17 -5
- package/dist/src/engine/debugpi.js.map +1 -1
- package/dist/src/execboundary.js +10 -3
- package/dist/src/execboundary.js.map +1 -1
- package/dist/src/github.js +51 -16
- package/dist/src/github.js.map +1 -1
- package/dist/src/githubtoken.js +346 -0
- package/dist/src/githubtoken.js.map +1 -0
- package/dist/src/loginhint.js +8 -3
- package/dist/src/loginhint.js.map +1 -1
- package/dist/src/loginrefresh/pirefresh.js +3 -0
- package/dist/src/loginrefresh/pirefresh.js.map +1 -1
- package/dist/src/npmverbs.js +4 -2
- package/dist/src/npmverbs.js.map +1 -1
- package/dist/src/onboard/choices.js +29 -1
- package/dist/src/onboard/choices.js.map +1 -1
- package/dist/src/onboard/engine.js +8 -23
- package/dist/src/onboard/engine.js.map +1 -1
- package/dist/src/onboard/launch.js +12 -20
- package/dist/src/onboard/launch.js.map +1 -1
- package/dist/src/onboard/pipin.js +22 -0
- package/dist/src/onboard/pipin.js.map +1 -0
- package/dist/src/onboard/stepforward.js +41 -0
- package/dist/src/onboard/stepforward.js.map +1 -0
- package/dist/src/onboard/steps/appslug.js +4 -1
- package/dist/src/onboard/steps/appslug.js.map +1 -1
- package/dist/src/onboard/steps/compose.js +29 -0
- package/dist/src/onboard/steps/compose.js.map +1 -1
- package/dist/src/onboard/steps/configfill.js +102 -32
- package/dist/src/onboard/steps/configfill.js.map +1 -1
- package/dist/src/onboard/steps/githubaccount.js +595 -0
- package/dist/src/onboard/steps/githubaccount.js.map +1 -0
- package/dist/src/onboard/steps/githubapp.js +65 -7
- package/dist/src/onboard/steps/githubapp.js.map +1 -1
- package/dist/src/onboard/steps/golive.js +2 -0
- package/dist/src/onboard/steps/golive.js.map +1 -1
- package/dist/src/onboard/steps/index.js +11 -0
- package/dist/src/onboard/steps/index.js.map +1 -1
- package/dist/src/onboard/steps/ingress.js +80 -25
- package/dist/src/onboard/steps/ingress.js.map +1 -1
- package/dist/src/onboard/steps/llmauth.js +103 -30
- package/dist/src/onboard/steps/llmauth.js.map +1 -1
- package/dist/src/onboard/steps/memory.js +53 -1
- package/dist/src/onboard/steps/memory.js.map +1 -1
- package/dist/src/onboard/steps/proof.js +366 -56
- package/dist/src/onboard/steps/proof.js.map +1 -1
- package/dist/src/onboard/steps/topology.js +6 -6
- package/dist/src/onboard/steps/topology.js.map +1 -1
- package/dist/src/onboard/steps/transport.js +9 -1
- package/dist/src/onboard/steps/transport.js.map +1 -1
- package/dist/src/onboard/steps/tuiroute.js +102 -0
- package/dist/src/onboard/steps/tuiroute.js.map +1 -0
- package/dist/src/onboard/steps/verifyup.js +17 -1
- package/dist/src/onboard/steps/verifyup.js.map +1 -1
- package/dist/src/onboard/toolnames.js +30 -0
- package/dist/src/onboard/toolnames.js.map +1 -0
- package/dist/src/operatorwrite.js +23 -0
- package/dist/src/operatorwrite.js.map +1 -0
- package/dist/src/outcomes.js +4 -1
- package/dist/src/outcomes.js.map +1 -1
- package/dist/src/posting-journal.js +2 -1
- package/dist/src/posting-journal.js.map +1 -1
- package/dist/src/posting-outbox.js +6 -2
- package/dist/src/posting-outbox.js.map +1 -1
- package/dist/src/posting-receipt.js +14 -1
- package/dist/src/posting-receipt.js.map +1 -1
- package/dist/src/recheck.js +2 -1
- package/dist/src/recheck.js.map +1 -1
- package/dist/src/reconcile.js +22 -2
- package/dist/src/reconcile.js.map +1 -1
- package/dist/src/reviewhistory.js +32 -6
- package/dist/src/reviewhistory.js.map +1 -1
- package/dist/src/reviewnow.js +45 -0
- package/dist/src/reviewnow.js.map +1 -0
- package/dist/src/state.js.map +1 -1
- package/dist/src/statemachine.js +4 -1
- package/dist/src/statemachine.js.map +1 -1
- package/dist/src/takeoverpi.js +13 -0
- package/dist/src/takeoverpi.js.map +1 -1
- package/dist/src/tui/app.js +49 -0
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/board.js +88 -12
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/tui/cmdbar.js +4 -1
- package/dist/src/tui/cmdbar.js.map +1 -1
- package/dist/src/tui/companion.js +74 -10
- package/dist/src/tui/companion.js.map +1 -1
- package/dist/src/tui/connect.js +7 -1
- package/dist/src/tui/connect.js.map +1 -1
- package/dist/src/tui/filters.js +5 -2
- package/dist/src/tui/filters.js.map +1 -1
- package/dist/src/tui/ghread.js +61 -5
- package/dist/src/tui/ghread.js.map +1 -1
- package/dist/src/tui/index.js +106 -20
- package/dist/src/tui/index.js.map +1 -1
- package/dist/src/tui/memforward.js +39 -2
- package/dist/src/tui/memforward.js.map +1 -1
- package/dist/src/tui/model.js +25 -0
- package/dist/src/tui/model.js.map +1 -1
- package/dist/src/tui/prpause.js +7 -0
- package/dist/src/tui/prpause.js.map +1 -1
- package/dist/src/tui/remote.js +7 -1
- package/dist/src/tui/remote.js.map +1 -1
- package/dist/src/tui/route.js +143 -0
- package/dist/src/tui/route.js.map +1 -0
- package/dist/src/tui/setupstate.js +29 -0
- package/dist/src/tui/setupstate.js.map +1 -0
- package/dist/src/tui/sshtriage.js +102 -5
- package/dist/src/tui/sshtriage.js.map +1 -1
- package/dist/src/tui/theme.js +2 -1
- package/dist/src/tui/theme.js.map +1 -1
- package/dist/src/tui/unwedge.js +2 -12
- package/dist/src/tui/unwedge.js.map +1 -1
- package/dist/src/verdicts.js +17 -5
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/webhook.js +12 -1
- package/dist/src/webhook.js.map +1 -1
- package/dist/src/wire.js +28 -6
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerworld.js +7 -1
- package/dist/src/workerworld.js.map +1 -1
- package/docker/companion/entrypoint.sh +20 -1
- package/docker/entrypoint.sh +17 -0
- package/extensions/cli/login-route.ts +10 -0
- package/extensions/login-route.ts +220 -0
- package/extensions/onboarding.ts +35 -6
- package/package.json +2 -2
- package/prompts/onboarding.md +57 -18
- package/scripts/mint-token.sh +25 -4
- package/src/ciretry.ts +2 -1
- package/src/cli.ts +49 -14
- package/src/cliops.ts +3 -2
- package/src/commands.ts +28 -2
- package/src/config.ts +24 -4
- package/src/daemon.ts +32 -7
- package/src/debugroundpi.ts +11 -1
- package/src/dispatch.ts +5 -4
- package/src/engine/debugpi.ts +17 -5
- package/src/execboundary.ts +16 -4
- package/src/github.ts +57 -15
- package/src/githubtoken.ts +435 -0
- package/src/loginhint.ts +8 -3
- package/src/loginrefresh/pirefresh.ts +3 -0
- package/src/npmverbs.ts +4 -2
- package/src/onboard/choices.ts +34 -1
- package/src/onboard/engine.ts +22 -3
- package/src/onboard/launch.ts +17 -29
- package/src/onboard/pipin.ts +31 -0
- package/src/onboard/stepforward.ts +61 -0
- package/src/onboard/steps/appslug.ts +4 -1
- package/src/onboard/steps/compose.ts +31 -0
- package/src/onboard/steps/configfill.ts +121 -33
- package/src/onboard/steps/githubaccount.ts +716 -0
- package/src/onboard/steps/githubapp.ts +92 -8
- package/src/onboard/steps/golive.ts +2 -0
- package/src/onboard/steps/index.ts +11 -0
- package/src/onboard/steps/ingress.ts +98 -31
- package/src/onboard/steps/llmauth.ts +114 -29
- package/src/onboard/steps/memory.ts +58 -1
- package/src/onboard/steps/proof.ts +473 -55
- package/src/onboard/steps/topology.ts +7 -6
- package/src/onboard/steps/transport.ts +15 -0
- package/src/onboard/steps/tuiroute.ts +119 -0
- package/src/onboard/steps/verifyup.ts +20 -1
- package/src/onboard/toolnames.ts +34 -0
- package/src/operatorwrite.ts +25 -0
- package/src/outcomes.ts +8 -1
- package/src/posting-journal.ts +6 -1
- package/src/posting-outbox.ts +10 -2
- package/src/posting-receipt.ts +17 -0
- package/src/recheck.ts +2 -1
- package/src/reconcile.ts +29 -3
- package/src/reviewhistory.ts +20 -6
- package/src/reviewnow.ts +76 -0
- package/src/state.ts +18 -0
- package/src/statemachine.ts +4 -1
- package/src/takeoverpi.ts +13 -0
- package/src/tui/app.ts +47 -0
- package/src/tui/board.ts +111 -14
- package/src/tui/cmdbar.ts +4 -1
- package/src/tui/companion.ts +78 -10
- package/src/tui/connect.ts +7 -1
- package/src/tui/filters.ts +7 -3
- package/src/tui/ghread.ts +74 -7
- package/src/tui/index.ts +114 -23
- package/src/tui/memforward.ts +52 -3
- package/src/tui/model.ts +36 -2
- package/src/tui/prpause.ts +11 -2
- package/src/tui/remote.ts +9 -1
- package/src/tui/route.ts +173 -0
- package/src/tui/setupstate.ts +52 -0
- package/src/tui/sshtriage.ts +127 -6
- package/src/tui/theme.ts +2 -1
- package/src/tui/unwedge.ts +2 -14
- package/src/verdicts.ts +23 -5
- package/src/version.ts +1 -1
- package/src/webhook.ts +10 -1
- package/src/wire.ts +36 -7
- 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
13
|
-
// mutation gate wrote INSTEAD of calling GitHub
|
|
14
|
-
//
|
|
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
|
-
//
|
|
23
|
-
// dry-run directory
|
|
24
|
-
//
|
|
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 {
|
|
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
|
|
213
|
-
if (
|
|
214
|
-
return
|
|
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
|
|
243
|
-
"
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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: `
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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: "
|
|
561
|
+
name: "review-now",
|
|
312
562
|
ok: false,
|
|
313
|
-
detail: `
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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" +
|