@bensandee/forgejo-cli 0.1.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/bin.mjs ADDED
@@ -0,0 +1,4232 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, statSync, writeSync } from "node:fs";
3
+ import * as path$1 from "node:path";
4
+ import path from "node:path";
5
+ import { parseArgs, parseEnv } from "node:util";
6
+ import { FatalError, TransientError, UnexpectedError, describeError, redact, redact as redact$1 } from "@bensandee/common";
7
+ import { z } from "zod";
8
+ import { defineCommand, runCommand, runMain } from "citty";
9
+ import { execFileSync } from "node:child_process";
10
+ //#region ../cli-kit/src/json.ts
11
+ /**
12
+ * Pure logic for the `--json` entry path of a bin (`run.ts`). Kept apart from
13
+ * that I/O-heavy entry point so it stays pure and unit-testable.
14
+ */
15
+ /** True when `rawArgs` ask for JSON mode: a `--json` token (bare or `--json=<truthy>`), no
16
+ * `--help`/`-h`, and not the bare `--version` query. The `=<value>` form is matched because citty
17
+ * coerces a boolean flag that way (`value !== "false"`), so `--json=true` DOES put the command in
18
+ * json mode; matching only the bare token left that invocation with `createCliOutput` suppressing
19
+ * prose into a buffer tooling's `runBstCli` never flushed -- zero bytes of output for exit code 0.
20
+ * `--json=false` and `--no-json` stay excluded, the way citty parses them. A `--json` that arrives
21
+ * as the *value* of a preceding string flag (e.g. `--body --json`) is scanned the same way and is a
22
+ * known, accepted ambiguity: this is a raw-argument scan, not an arg-aware parse. */
23
+ function resolveJsonMode(rawArgs) {
24
+ if (!rawArgs.some(requestsJson)) return false;
25
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) return false;
26
+ if (rawArgs.length === 1 && (rawArgs[0] === "--version" || rawArgs[0] === "-v")) return false;
27
+ return true;
28
+ }
29
+ /** One raw token: whether it asks for json mode, mirroring citty's boolean coercion of a value
30
+ * (anything but the literal `false` is truthy). */
31
+ function requestsJson(token) {
32
+ if (token === "--json") return true;
33
+ if (!token.startsWith("--json=")) return false;
34
+ return token !== "--json=false";
35
+ }
36
+ /** The first token that is not a flag and not a flag's value, mirroring citty's own
37
+ * `findSubCommandIndex` for a command whose own `args` is empty (true of `bst`'s top-level
38
+ * command) -- a `--` sentinel ends the scan with no command found, same as citty. */
39
+ function resolveCommandName(rawArgs) {
40
+ for (const arg of rawArgs) {
41
+ if (arg === "--") return void 0;
42
+ if (!arg.startsWith("-")) return arg;
43
+ }
44
+ }
45
+ /** Whether `--verbose`/`-v`/`--trace` (flag or the matching env var) was requested -- the gate on
46
+ * whether an error document's `detail` may carry a stack trace. Mirrors `resolveLogLevel`'s
47
+ * flag/env sources at the raw-argument level, since JSON-mode resolution runs before citty has
48
+ * parsed anything, and must keep naming the same tokens tooling's `runBstCli` raises the log level
49
+ * from: a run whose diagnostics are on and whose error document withholds its detail is the same
50
+ * invocation answering the same question two ways. */
51
+ function verboseRequested(rawArgs) {
52
+ if (process.env["TOOLING_TRACE"] === "true" || process.env["TOOLING_DEBUG"] === "true") return true;
53
+ return rawArgs.includes("--verbose") || rawArgs.includes("-v") || rawArgs.includes("--trace");
54
+ }
55
+ /** Narrow a subcommand map entry to a plain `CommandDef`, rejecting the function/Promise
56
+ * variants `Resolvable` allows. None of this repo's commands are ever wrapped that way (every
57
+ * one is a direct `defineCommand()` result), so this is a conservative "not resolvable
58
+ * synchronously" rather than a real gap: the JSON path never invokes citty's own async
59
+ * resolution machinery (see `run.ts`, `resolveSubCommand` note). */
60
+ function asCommandDef(value) {
61
+ if (value === void 0) return void 0;
62
+ if (typeof value === "function") return void 0;
63
+ if (value instanceof Promise) return void 0;
64
+ return value;
65
+ }
66
+ /** Whether a resolved command declares a `json` argument -- what the coverage test reads to
67
+ * decide between "granted --json" and its `EXEMPT` list. */
68
+ function commandDeclaresJson(cmd) {
69
+ if (cmd === void 0) return false;
70
+ const argsDef = cmd.args;
71
+ if (argsDef === void 0) return false;
72
+ if (typeof argsDef === "function") return false;
73
+ if (argsDef instanceof Promise) return false;
74
+ return "json" in argsDef;
75
+ }
76
+ /** Build the structured error document a `--json` failure flushes instead of prose --
77
+ * `{ ok: false, command, version, error: { kind, message, detail? } }`. `notices` is not this
78
+ * function's concern: `flushJsonOutput` folds the buffered notices into whatever document it
79
+ * writes, error or payload alike. */
80
+ function buildErrorDocument(input) {
81
+ const error = input.detail === void 0 ? {
82
+ kind: input.kind,
83
+ message: input.message
84
+ } : {
85
+ kind: input.kind,
86
+ message: input.message,
87
+ detail: input.detail
88
+ };
89
+ return {
90
+ ok: false,
91
+ command: input.command,
92
+ version: input.version,
93
+ error
94
+ };
95
+ }
96
+ /**
97
+ * The last-resort net for `--json`: if the process exits without `run.ts`'s flush ever
98
+ * having written a document -- an await that never settles, for instance -- this writes one
99
+ * parseable `{ok: false}` document and marks the exit nonzero, instead of nothing and Node's own
100
+ * unsettled-top-level-await exit code. Distinct from the "completed without recording a JSON
101
+ * payload" net in `run.ts`: that one runs after a command *returns*, so it cannot
102
+ * see a process that never returns at all.
103
+ *
104
+ * On Node's unsettled-top-level-await path specifically, the loader prints its own warning to
105
+ * stderr before any userland `exit` hook runs, so a merged `2>&1` capture still does not parse as
106
+ * JSON alone -- this restores the stdout document and the exit code; it does not restore stderr's
107
+ * cleanliness.
108
+ */
109
+ function runJsonExitNet(params) {
110
+ if (params.wroteDocument()) return;
111
+ const label = params.command ?? params.binName;
112
+ const document = buildErrorDocument({
113
+ command: params.command,
114
+ version: params.version,
115
+ kind: "runtime",
116
+ message: `${label} --json exited before the command produced a result: the process ended with the command still outstanding, so no document was recorded.`
117
+ });
118
+ try {
119
+ params.write(JSON.stringify(document) + "\n");
120
+ } catch (_writeError) {}
121
+ process.exitCode = 1;
122
+ }
123
+ /** Install {@link runJsonExitNet} as a `process` `"exit"` listener, writing synchronously to fd 1
124
+ * via `node:fs`. */
125
+ function installJsonExitNet(binName, command, version, wroteDocument) {
126
+ process.on("exit", () => {
127
+ runJsonExitNet({
128
+ write: (text) => writeSync(1, text),
129
+ wroteDocument,
130
+ command,
131
+ version,
132
+ binName
133
+ });
134
+ });
135
+ }
136
+ /** Classify a caught `unknown` into the error document's `kind`/`message`/`detail`. citty's
137
+ * `CLIError` (arg-parse failures, an unknown subcommand, no command specified) is not exported
138
+ * by the package, so it is recognised by `name` rather than `instanceof` -- still no typecast,
139
+ * since `error instanceof Error` narrows `unknown` to `Error` first. Everything else, including
140
+ * `FatalError`/`UnexpectedError`, is `"runtime"`. */
141
+ function classifyThrown(error, verbose) {
142
+ const kind = error instanceof Error && error.name === "CLIError" ? "usage" : "runtime";
143
+ const message = redact(describeError(error));
144
+ if (!verbose) return {
145
+ kind,
146
+ message
147
+ };
148
+ const detail = error instanceof Error && typeof error.stack === "string" ? redact(error.stack) : void 0;
149
+ return detail === void 0 ? {
150
+ kind,
151
+ message
152
+ } : {
153
+ kind,
154
+ message,
155
+ detail
156
+ };
157
+ }
158
+ //#endregion
159
+ //#region ../cli-kit/src/env-file.ts
160
+ /**
161
+ * The one way a file feeds this process's environment: `BST_ENV_FILE=<path>` names an env file
162
+ * (`KEY=value` lines, `#` comments, an optional `export` prefix) that a bin loads
163
+ * before anything else runs (each bin passes its own name and version). Unset means no file is touched.
164
+ *
165
+ * Explicit and opt-in rather than "load `.env` if one exists", so a production container, which
166
+ * receives every variable through `docker run -e`, can never pick up a stray file -- nothing sets
167
+ * this variable there. A value that names an unreadable file is fatal instead of a warning, since
168
+ * configuration that fails to resolve should fail where it was written, not later as a missing
169
+ * credential. The environment always wins over the file, the same precedence as Node's own
170
+ * `--env-file`, so a shell export or a `-e` on a container is never overridden.
171
+ */
172
+ const ENV_FILE_VAR = "BST_ENV_FILE";
173
+ /**
174
+ * Load `$BST_ENV_FILE` into `process.env`, returning what happened, or `undefined` when the
175
+ * variable is unset or blank. Throws {@link FatalError} on a file that cannot be read.
176
+ */
177
+ function loadEnvFileFromEnv() {
178
+ const raw = process.env[ENV_FILE_VAR]?.trim();
179
+ if (raw === void 0 || raw === "") return void 0;
180
+ const file = path$1.resolve(raw);
181
+ let content;
182
+ try {
183
+ content = readFileSync(file, "utf8");
184
+ } catch (error) {
185
+ const reason = error instanceof Error ? error.message : String(error);
186
+ throw new FatalError(`${ENV_FILE_VAR} names ${file}, which could not be read (${reason}). Unset ${ENV_FILE_VAR} or point it at a readable env file.`);
187
+ }
188
+ const added = [];
189
+ const kept = [];
190
+ for (const [name, value] of Object.entries(parseEnv(content))) if (process.env[name] === void 0) {
191
+ process.env[name] = value;
192
+ added.push(name);
193
+ } else kept.push(name);
194
+ return {
195
+ path: file,
196
+ added,
197
+ kept
198
+ };
199
+ }
200
+ /**
201
+ * One diagnostic line about a load -- the path and the variable names, never a value -- or, for
202
+ * `undefined`, the fact that no file was configured. The absent case is reported rather than
203
+ * passed over because "my credential did not load" and "no file was ever named" look identical
204
+ * from outside the process, and that is exactly the question this line is read to answer.
205
+ */
206
+ function describeEnvFileLoad(load) {
207
+ if (load === void 0) return `No env file loaded -- ${ENV_FILE_VAR} is unset or empty.`;
208
+ const parts = [`Loaded ${String(load.added.length)} variable(s) from ${load.path} (${ENV_FILE_VAR}): ` + (load.added.length === 0 ? "none" : load.added.join(", "))];
209
+ if (load.kept.length > 0) parts.push(`${String(load.kept.length)} already set in the environment kept their value: ` + load.kept.join(", "));
210
+ return parts.join("; ");
211
+ }
212
+ const processIo = {
213
+ out: (text) => {
214
+ process.stdout.write(`${text}\n`);
215
+ },
216
+ err: (text) => {
217
+ process.stderr.write(`${text}\n`);
218
+ },
219
+ exit: (code) => process.exit(code)
220
+ };
221
+ /**
222
+ * {@link loadEnvFileFromEnv} for a bin entry point: a failure ends the process with exit code 1
223
+ * instead of propagating as an unhandled rejection with a stack trace. This runs before the CLI
224
+ * exists, so it renders the failure itself -- as the one `{ok: false}` document `--json` promises
225
+ * when `rawArgs` ask for it, else as a single stderr line.
226
+ */
227
+ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
228
+ try {
229
+ return loadEnvFileFromEnv();
230
+ } catch (error) {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ if (resolveJsonMode(rawArgs)) {
233
+ const document = buildErrorDocument({
234
+ command: resolveCommandName(rawArgs),
235
+ version: bin.version,
236
+ kind: "usage",
237
+ message
238
+ });
239
+ io.out(JSON.stringify(document));
240
+ } else io.err(`${bin.name}: ${message}`);
241
+ return io.exit(1);
242
+ }
243
+ }
244
+ //#endregion
245
+ //#region src/version.ts
246
+ const parsed = z.object({ version: z.string() }).safeParse({
247
+ name: "@bensandee/forgejo-cli",
248
+ version: "0.1.0",
249
+ description: "bst-fj: Forgejo API commands without the rest of bst",
250
+ license: "MIT",
251
+ repository: {
252
+ "type": "git",
253
+ "url": "https://codeberg.org/bensandee/tooling.git",
254
+ "directory": "packages/forgejo-cli"
255
+ },
256
+ bin: { "bst-fj": "./bin/bst-fj.mjs" },
257
+ files: ["bin", "dist"],
258
+ type: "module",
259
+ imports: { "#src/*.ts": "./src/*.ts" },
260
+ exports: {
261
+ "./package.json": "./package.json",
262
+ "./*": "./src/*.ts",
263
+ "./test/*": "./test/*.ts"
264
+ },
265
+ publishConfig: {
266
+ "exports": { "./package.json": "./package.json" },
267
+ "access": "public",
268
+ "registry": "https://registry.npmjs.org"
269
+ },
270
+ scripts: {
271
+ "build": "tsdown",
272
+ "dev": "tsdown --watch",
273
+ "test": "vitest run"
274
+ },
275
+ dependencies: {
276
+ "@bensandee/common": "0.2.0",
277
+ "citty": "^0.2.2",
278
+ "zod": "^4.6.5"
279
+ },
280
+ devDependencies: {
281
+ "@bensandee/cli-kit": "workspace:*",
282
+ "@bensandee/forgejo": "workspace:*",
283
+ "@types/node": "24.13.5",
284
+ "tsdown": "0.23.0"
285
+ }
286
+ });
287
+ if (!parsed.success) throw new UnexpectedError(`@bensandee/forgejo-cli's package.json has no string version.`);
288
+ /** The package version, stamped on `--version` and on every `--json` error document. */
289
+ const BST_FJ_VERSION = parsed.data.version;
290
+ //#endregion
291
+ //#region src/env-file-preload.ts
292
+ /**
293
+ * The `$BST_ENV_FILE` load as a module side effect, so a bin entry point can run it **before any
294
+ * other module evaluates** simply by importing this module first. That ordering is the whole
295
+ * point: a module that reads the environment at import time would otherwise read an environment
296
+ * the file had not joined yet. A dynamic `import()` cannot guarantee it either: the bundler inlines
297
+ * the target and evaluates it with the rest of the bundle, ahead of the call.
298
+ *
299
+ * Only the bin entry point imports this module. Nothing under test does, so a developer's exported
300
+ * `BST_ENV_FILE` never reaches a vitest worker.
301
+ */
302
+ const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
303
+ name: "bst-fj",
304
+ version: BST_FJ_VERSION
305
+ });
306
+ //#endregion
307
+ //#region ../cli-kit/src/logger.ts
308
+ const RANK = {
309
+ error: 0,
310
+ warn: 1,
311
+ info: 2,
312
+ debug: 3,
313
+ trace: 4
314
+ };
315
+ let moduleLevel = "info";
316
+ /** Set the process-global minimum level (default `info`). Read live by every non-pinned logger. */
317
+ function setLogLevel(level) {
318
+ moduleLevel = level;
319
+ }
320
+ let moduleTimestamps = false;
321
+ /**
322
+ * Resolve the minimum level from flags + env, cheapest-wins: `--trace`/`TOOLING_TRACE` -> `trace`
323
+ * (the exec/fetch firehose), else `--verbose`/`TOOLING_DEBUG` -> `debug`, else `info`. Trace
324
+ * outranks verbose. Pure; a command passes its parsed flags and feeds the result to
325
+ * {@link setLogLevel}.
326
+ */
327
+ function resolveLogLevel(flags) {
328
+ if (flags.trace === true || process.env["TOOLING_TRACE"] === "true") return "trace";
329
+ if (flags.verbose === true || process.env["TOOLING_DEBUG"] === "true") return "debug";
330
+ return "info";
331
+ }
332
+ const isCI = Boolean(process.env["CI"]);
333
+ const out = (msg) => console.log(msg);
334
+ const err = (line) => console.error(line);
335
+ /** The plain user-facing adapter -- stdout with level tags, mirroring the pre-unification `log`.
336
+ * The tag is what carries the level once clack's coloured symbol is gone. */
337
+ const plainUserSink = {
338
+ info: out,
339
+ warn: (msg) => out(`[warn] ${msg}`),
340
+ error: (msg) => out(`[error] ${msg}`),
341
+ success: (msg) => out(`[ok] ${msg}`)
342
+ };
343
+ let plainOutput = isCI;
344
+ let jsonMode = false;
345
+ /** Silence the four user-facing levels process-wide (default off). Diagnostic levels are
346
+ * unaffected -- see the `jsonMode` comment above. */
347
+ function setJsonMode(enabled) {
348
+ jsonMode = enabled;
349
+ }
350
+ /** Read the json-mode global for the one user-facing sink outside the logger (`note()` in
351
+ * tooling's `utils/log.ts`), which the sink-selection gate below cannot reach. */
352
+ function isJsonMode() {
353
+ return jsonMode;
354
+ }
355
+ let jsonNoticeSink;
356
+ /** Give a suppressed `warn`/`error` line somewhere to go while `jsonMode` is on. Pass `undefined`
357
+ * to go back to dropping them. */
358
+ function setJsonNoticeSink(sink) {
359
+ jsonNoticeSink = sink;
360
+ }
361
+ /**
362
+ * Render the tag a text reader sees. Widen the payload freely; widen this only for something a
363
+ * reader needs on the line it is reading -- a multi-repo agent's reader needs the repository,
364
+ * because a bare `#42` names an object in whichever of several repos the cycle happens to be in,
365
+ * and recovering which one meant scrolling back to the claim line.
366
+ *
367
+ * `repo` renders as its trailing segment alone. What discriminates two units is the repo name; the
368
+ * owner costs width on every line and usually repeats, and the claim line still names `owner/repo`
369
+ * in full once per cycle, so a short name that is genuinely ambiguous is one scroll from the whole
370
+ * one.
371
+ */
372
+ function formatContext(context) {
373
+ const parts = [];
374
+ if (context.repo !== void 0) {
375
+ const segment = context.repo.split("/").findLast((part) => part.length > 0);
376
+ if (segment !== void 0) parts.push(segment);
377
+ }
378
+ if (context.issue !== void 0) parts.push(`#${String(context.issue)}`);
379
+ if (context.pr !== void 0) parts.push(`PR#${String(context.pr)}`);
380
+ if (context.phase !== void 0) parts.push(context.phase);
381
+ return parts.length > 0 ? `[${parts.join(" ")}]` : "";
382
+ }
383
+ function build(config) {
384
+ const contextTag = formatContext(config.context);
385
+ const enabled = (level) => RANK[level] <= RANK[config.level ?? moduleLevel];
386
+ const stamp = () => `[${config.now().toISOString()}]`;
387
+ const userFacing = (level, threshold) => (msg) => {
388
+ if (!enabled(threshold)) return;
389
+ if (jsonMode) {
390
+ if ((level === "warn" || level === "error") && jsonNoticeSink) jsonNoticeSink(redact$1(msg));
391
+ return;
392
+ }
393
+ const segments = [];
394
+ if (config.timestamps ?? moduleTimestamps) segments.push(stamp());
395
+ if (contextTag) segments.push(contextTag);
396
+ segments.push(redact$1(msg));
397
+ (config.userSink ?? (plainOutput ? plainUserSink : plainUserSink))[level](segments.join(" "));
398
+ };
399
+ const diagnostic = (level) => (msg) => {
400
+ if (!enabled(level)) return;
401
+ const segments = [stamp()];
402
+ if (contextTag) segments.push(contextTag);
403
+ segments.push(`[${level}]`, redact$1(msg));
404
+ config.diagnosticSink(segments.join(" "));
405
+ };
406
+ return {
407
+ info: userFacing("info", "info"),
408
+ warn: userFacing("warn", "warn"),
409
+ error: userFacing("error", "error"),
410
+ success: userFacing("success", "info"),
411
+ debug: diagnostic("debug"),
412
+ trace: diagnostic("trace"),
413
+ child: (context) => build({
414
+ ...config,
415
+ context: {
416
+ ...config.context,
417
+ ...context
418
+ }
419
+ })
420
+ };
421
+ }
422
+ /** Create a {@link Logger}. All seams default to real sinks and the live global level. */
423
+ function createLogger(options = {}) {
424
+ return build({
425
+ level: options.level,
426
+ timestamps: options.timestamps,
427
+ context: options.context ?? {},
428
+ userSink: options.userSink,
429
+ diagnosticSink: options.diagnosticSink ?? err,
430
+ now: options.now ?? (() => /* @__PURE__ */ new Date())
431
+ });
432
+ }
433
+ /** The process-wide root logger the shims delegate to. Pins nothing, so it follows both process
434
+ * globals -- which is what makes `setLogTimestamps` reach every module that logs through `log`. */
435
+ const rootLogger = createLogger();
436
+ //#endregion
437
+ //#region ../cli-kit/src/flags.ts
438
+ /**
439
+ * Pure logic over an invocation's flags: whether it carries one its resolved command never
440
+ * declared, whether a `[dir]` positional names a directory (through an injected probe), and how a
441
+ * flag's own value reads.
442
+ * Kept apart from the bins' I/O-heavy entry points so it stays pure and unit-testable.
443
+ * No `process`, `node:fs` or logger import -- see `flags.test.ts`'s "no I/O" assertion.
444
+ *
445
+ * The detection mirrors citty@0.2.2's own parser (`_parser.ts`/`args.ts` in
446
+ * `node_modules/citty/dist/index.mjs`) closely enough that it agrees with what citty would
447
+ * silently accept: the same option table (declared name, declared alias, camelCase/kebab-case
448
+ * pair), the same `--no-<flag>` pre-pass, and the same `node:util` `parseArgs({ strict: false,
449
+ * allowPositionals: true })` call. Anything that call surfaces as a key we did not expect is a
450
+ * flag citty would otherwise absorb without a word -- see `flags-differential.test.ts`, which
451
+ * pins this mirror against the real citty rather than trusting the copy. The mirror is literal,
452
+ * with no exception: a `--no-<x>` token is known exactly when `x` is declared, because that is the
453
+ * key citty's pre-pass writes.
454
+ */
455
+ /** The carve-out set: citty's own builtins (`--help`/`-h`, `--version`/`-v`) plus the flags every
456
+ * command honours regardless of whether it declares them. Accepted on every command, declared or
457
+ * not -- and each one is honoured somewhere: `--help`/`-h` short-circuit this check wherever they
458
+ * appear (`hasHelpFlag`) and citty renders usage; `--version` and a lone `-v` are citty's version
459
+ * query (`runMain` intercepts it only when it is the whole argv, so `isLoneVersionQuery` mirrors
460
+ * that); `-v` beside anything else is read as `--verbose`, and `--verbose`/`--trace` set the
461
+ * process log level in tooling's `runBstCli`. The one member with no meaning of its own past that
462
+ * is `--version` written beside other arguments, which citty ignores. */
463
+ const UNIVERSAL_FLAGS = [
464
+ "--help",
465
+ "-h",
466
+ "--version",
467
+ "-v",
468
+ "--verbose",
469
+ "--trace"
470
+ ];
471
+ /** Derived from {@link UNIVERSAL_FLAGS} rather than restated beside it: two hand-written copies of
472
+ * one set are one edit away from disagreeing, and nothing would catch the half that was missed. */
473
+ const UNIVERSAL_NAMES = new Set(UNIVERSAL_FLAGS.map(flagName));
474
+ function hasHelpFlag(rawArgs) {
475
+ return rawArgs.includes("--help") || rawArgs.includes("-h");
476
+ }
477
+ function isLoneVersionQuery(rawArgs) {
478
+ return rawArgs.length === 1 && (rawArgs[0] === "--version" || rawArgs[0] === "-v");
479
+ }
480
+ /** The token citty would read as a command name, mirroring its own `findSubCommandIndex`: every
481
+ * "-"-prefixed token is skipped, and so is the token after one naming a `string`/`enum` arg the
482
+ * command declares, since citty consumes that as the flag's value (`--repo x/y`) before it looks
483
+ * for a name. `argsDef` is omitted for `bst`'s own top-level command, which declares no args of
484
+ * its own, so nothing is consumed there. A `--` sentinel ends the scan with no command found,
485
+ * same as `resolveCommandName` in @bensandee/cli-kit/json. */
486
+ function splitAtCommand(rawArgs, argsDef) {
487
+ for (let i = 0; i < rawArgs.length; i++) {
488
+ const token = rawArgs[i];
489
+ if (token === "--") return void 0;
490
+ if (token.startsWith("-")) {
491
+ if (argsDef !== void 0 && !token.includes("=") && declaresValueFlag(token, argsDef)) i++;
492
+ continue;
493
+ }
494
+ return {
495
+ index: i,
496
+ before: rawArgs.slice(0, i)
497
+ };
498
+ }
499
+ }
500
+ /** citty's `_isValueFlag`: whether `token` names a declared arg whose value is a separate token. */
501
+ function declaresValueFlag(token, argsDef) {
502
+ const name = flagName(token);
503
+ const normalized = toCamelCase(name);
504
+ for (const [key, def] of Object.entries(argsDef)) {
505
+ if (def.type !== "string" && def.type !== "enum") continue;
506
+ if (normalized === toCamelCase(key)) return true;
507
+ const alias = "alias" in def ? def.alias : void 0;
508
+ if (alias === void 0) continue;
509
+ if (Array.isArray(alias) ? alias.includes(name) : alias === name) return true;
510
+ }
511
+ return false;
512
+ }
513
+ function flagBase(token) {
514
+ const eq = token.indexOf("=");
515
+ return eq === -1 ? token : token.slice(0, eq);
516
+ }
517
+ function flagName(token) {
518
+ return flagBase(token).replace(/^-{1,2}/, "");
519
+ }
520
+ /** Narrow a resolved command's `args` to a plain `ArgsDef`, rejecting the function/Promise
521
+ * variants `Resolvable` allows -- the same conservative narrowing `commandDeclaresJson` applies
522
+ * to a command's `json` argument. No command in this repo defines `args` dynamically. */
523
+ function resolveStaticArgsDef(cmd) {
524
+ const argsDef = cmd.args;
525
+ if (argsDef === void 0) return void 0;
526
+ if (typeof argsDef === "function") return void 0;
527
+ if (argsDef instanceof Promise) return void 0;
528
+ return argsDef;
529
+ }
530
+ /** citty's own `camelCase`/`kebabCase` (from the bundled `scule`) are not part of its public
531
+ * exports, so this is a minimal reimplementation over the hyphenated, all-lowercase names this
532
+ * repo's `args` definitions actually use (`body-file`, `dry-run`, ...) -- not a general-purpose
533
+ * case converter. The differential test pins that this is faithful enough for every declared arg
534
+ * in the tree. */
535
+ function toCamelCase(name) {
536
+ return name.replace(/-([a-z0-9])/g, (_match, ch) => ch.toUpperCase());
537
+ }
538
+ function toKebabCase(name) {
539
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
540
+ }
541
+ /** Build the option table citty's `parseArgs` (`args.ts`) builds from a command's declared
542
+ * `args`: every declared name plus its declared alias(es) and its camelCase/kebab-case pair
543
+ * becomes its own entry in the table `node:util`'s `parseArgs` consults, typed by walking the
544
+ * alias relationship back to whichever side declared `boolean`.
545
+ *
546
+ * One structural difference from citty, which additionally hands a one-character alias to
547
+ * `parseArgs` as the partner entry's `short`: an entry registered under its own single-letter name
548
+ * is matched by `-x` and consumes the next token exactly as a `short` mapping does, so the two
549
+ * tables agree on every name a parse reports as unknown -- they differ only in which of the pair
550
+ * holds the value, which nothing here reads. Pinned by the one-character-alias cases in
551
+ * `flags-differential.test.ts`. */
552
+ function buildParseTable(argsDef) {
553
+ const booleanNames = /* @__PURE__ */ new Set();
554
+ const aliasesByMain = /* @__PURE__ */ new Map();
555
+ const knownNames = new Set(UNIVERSAL_NAMES);
556
+ const acceptedFlags = [];
557
+ for (const [name, def] of Object.entries(argsDef)) {
558
+ if (def.type === "positional") continue;
559
+ if (def.type === "boolean") booleanNames.add(name);
560
+ const declaredAlias = "alias" in def ? def.alias : void 0;
561
+ let declaredAliases;
562
+ if (declaredAlias === void 0) declaredAliases = [];
563
+ else if (Array.isArray(declaredAlias)) declaredAliases = declaredAlias;
564
+ else declaredAliases = [declaredAlias];
565
+ const aliases = [...declaredAliases];
566
+ const camel = toCamelCase(name);
567
+ const kebab = toKebabCase(name);
568
+ if (camel !== name && !aliases.includes(camel)) aliases.push(camel);
569
+ if (kebab !== name && !aliases.includes(kebab)) aliases.push(kebab);
570
+ aliasesByMain.set(name, aliases);
571
+ knownNames.add(name);
572
+ for (const alias of aliases) knownNames.add(alias);
573
+ acceptedFlags.push(`--${name}`);
574
+ }
575
+ function typeFor(name) {
576
+ if (booleanNames.has(name)) return "boolean";
577
+ for (const [main, aliases] of aliasesByMain) if ((main === name || aliases.includes(name)) && booleanNames.has(main)) return "boolean";
578
+ return "string";
579
+ }
580
+ const nodeOptions = {};
581
+ for (const [main, aliases] of aliasesByMain) {
582
+ nodeOptions[main] = { type: typeFor(main) };
583
+ for (const alias of aliases) nodeOptions[alias] = { type: typeFor(alias) };
584
+ }
585
+ return {
586
+ nodeOptions,
587
+ knownNames,
588
+ acceptedFlags
589
+ };
590
+ }
591
+ /** Mirror citty's `parseRawArgs`: strip `--no-<flag>` tokens before they ever reach
592
+ * `node:util`'s parser (citty accepts the negated form for *any* name, declared or not -- so an
593
+ * undeclared `--no-bogus` must still surface as `bogus` for the known-name check below, the same
594
+ * way an undeclared `--bogus` would), then run the same `strict: false` /
595
+ * `allowPositionals: true` parse citty runs, falling back to an empty parse on any throw exactly
596
+ * as citty's own `try/catch` does. */
597
+ function parsePostCommandArgs(postCommandArgs, nodeOptions) {
598
+ const negatedNames = [];
599
+ const processedArgs = [];
600
+ for (let i = 0; i < postCommandArgs.length; i++) {
601
+ const token = postCommandArgs[i];
602
+ if (token === "--") {
603
+ processedArgs.push(...postCommandArgs.slice(i));
604
+ break;
605
+ }
606
+ if (token.startsWith("--no-")) {
607
+ negatedNames.push(token.slice(5));
608
+ continue;
609
+ }
610
+ processedArgs.push(token);
611
+ }
612
+ let values = {};
613
+ let positionals = [];
614
+ try {
615
+ const parsed = parseArgs({
616
+ args: processedArgs,
617
+ options: Object.keys(nodeOptions).length > 0 ? nodeOptions : void 0,
618
+ allowPositionals: true,
619
+ strict: false
620
+ });
621
+ values = parsed.values;
622
+ positionals = parsed.positionals;
623
+ } catch {
624
+ values = {};
625
+ }
626
+ return {
627
+ values,
628
+ positionals,
629
+ negatedNames
630
+ };
631
+ }
632
+ /** The first positional declaring `valueHint: "dir"` whose value `probe` says is not a directory.
633
+ * citty fills positionals in declaration order, so the Nth declared positional takes the Nth
634
+ * positional token. An omitted positional is left to the command's own default. */
635
+ function findBadDirArg(postCommandArgs, argsDef, nodeOptions, probe) {
636
+ const { positionals } = parsePostCommandArgs(postCommandArgs, nodeOptions);
637
+ const declared = Object.entries(argsDef).filter(([, def]) => def.type === "positional");
638
+ for (const [index, [argName, def]] of declared.entries()) {
639
+ const value = positionals.at(index);
640
+ if (value === void 0 || def.valueHint !== "dir") continue;
641
+ const kind = probe(value);
642
+ if (kind !== "directory") return {
643
+ argName,
644
+ value,
645
+ missing: kind === "missing"
646
+ };
647
+ }
648
+ }
649
+ /** The bare key names citty's own parse would carry that `argsDef` never declared -- directly
650
+ * comparable to what a stub command's `run` would see via `Object.keys(args)` for a key citty
651
+ * never intended, which is exactly what the differential test compares against. Note that a
652
+ * negated `--no-<x>` produces the key `x`, not `no-<x>`: citty's pre-pass strips the token down
653
+ * to `x` and assigns `out[x] = false` *unconditionally*, whether or not `x` itself is a declared
654
+ * name. So a negated name is known exactly when the BARE name is declared, with no second lookup
655
+ * under `no-<x>`: no command declares a `no-`-prefixed name and both packages'
656
+ * `flag-strictness.test.ts` forbid one, so if that guard is ever bypassed, being rejected as an
657
+ * unknown flag is the better outcome -- the flag would otherwise parse, be accepted here, and
658
+ * silently do nothing. */
659
+ function computeUnknownArgNames(postCommandArgs, argsDef) {
660
+ const table = buildParseTable(argsDef);
661
+ const { values, negatedNames } = parsePostCommandArgs(postCommandArgs, table.nodeOptions);
662
+ const unknown = /* @__PURE__ */ new Set();
663
+ for (const name of Object.keys(values)) if (!table.knownNames.has(name)) unknown.add(name);
664
+ for (const name of negatedNames) if (!table.knownNames.has(name)) unknown.add(name);
665
+ return [...unknown];
666
+ }
667
+ /** Map each bare key `computeUnknownArgNames` can produce back to the literal token that
668
+ * produced it, so the rendered message shows what the caller actually typed (`--no-coverage`,
669
+ * not `--coverage`) rather than reconstructing a plausible-looking guess. */
670
+ function buildDisplayTokenMap(postCommandArgs) {
671
+ const map = /* @__PURE__ */ new Map();
672
+ for (const token of postCommandArgs) {
673
+ if (token === "--") break;
674
+ if (token.startsWith("--no-")) {
675
+ const key = token.slice(5);
676
+ if (!map.has(key)) map.set(key, flagBase(token));
677
+ continue;
678
+ }
679
+ if (!token.startsWith("-")) continue;
680
+ const key = flagName(token);
681
+ if (!map.has(key)) map.set(key, flagBase(token));
682
+ }
683
+ return map;
684
+ }
685
+ /** Fallback for a bare name with no matching raw token (e.g. one letter of a bundled short-flag
686
+ * group such as `-yes`) -- vanishingly rare for this repo's single-letter aliases, but a
687
+ * conservative guess beats dropping the flag from the message entirely. */
688
+ function formatFlagToken(bareName) {
689
+ return bareName.length === 1 ? `-${bareName}` : `--${bareName}`;
690
+ }
691
+ /** Narrow a command's `subCommands` to a plain map, rejecting the function/Promise variants
692
+ * `Resolvable` allows -- the same conservative narrowing `resolveStaticArgsDef` applies to
693
+ * `args`. No command in this repo defines `subCommands` dynamically. */
694
+ function resolveStaticSubCommands(cmd) {
695
+ const subCommands = cmd.subCommands;
696
+ if (subCommands === void 0) return void 0;
697
+ if (typeof subCommands === "function") return void 0;
698
+ if (subCommands instanceof Promise) return void 0;
699
+ return subCommands;
700
+ }
701
+ /** Walk down a command that routes to `subCommands` -- mirrors citty's own `runCommand`, which
702
+ * recurses into `rawArgs.slice(subCommandArgIndex + 1)` against the named subcommand and measures
703
+ * the flags there. **A router's own `args` does not stop the descent**: citty looks for the
704
+ * subcommand token whether or not the router declares flags of its own, so a command with both
705
+ * (`bst-fj labels`, whose `--json` exists for the `--json`-coverage test to see) must be
706
+ * descended past rather than treated as the leaf -- otherwise every flag its subcommands declare
707
+ * is measured against the router's table and rejected. The router's own table is the answer only
708
+ * when no subcommand token follows. Returns `undefined` on anything the top-level check already
709
+ * treats conservatively: an unregistered name (citty reports that itself), an entry that cannot
710
+ * be narrowed, or a router with neither a subcommand token nor args of its own. */
711
+ function resolveLeafCommand(cmd, chain, postCommandArgs) {
712
+ const argsDef = resolveStaticArgsDef(cmd);
713
+ const subCommands = resolveStaticSubCommands(cmd);
714
+ if (subCommands !== void 0) {
715
+ const split = splitAtCommand(postCommandArgs, argsDef);
716
+ if (split !== void 0) {
717
+ const subName = postCommandArgs[split.index];
718
+ if (!(subName in subCommands)) return void 0;
719
+ const subCmd = asCommandDef(subCommands[subName]);
720
+ if (subCmd === void 0) return void 0;
721
+ return resolveLeafCommand(subCmd, [...chain, subName], postCommandArgs.slice(split.index + 1));
722
+ }
723
+ }
724
+ return argsDef === void 0 ? void 0 : {
725
+ chain,
726
+ argsDef,
727
+ postCommandArgs
728
+ };
729
+ }
730
+ /** Returns the offending flags plus the resolved command name and its accepted flag list, or a
731
+ * "nothing to report" result. Short-circuits, in order: `--help`/`-h` anywhere or a lone
732
+ * `--version`/`-v` (citty's `runMain` intercepts both); no command name, or a name not in
733
+ * `subCommands` (citty already reports those); an entry `asCommandDef` cannot narrow
734
+ * (conservatively -- no command in this repo is a function or Promise). Flags before the command
735
+ * name are checked against the universal set only, since the top-level `bst` command declares no
736
+ * args of its own and citty slices them away before the subcommand ever sees them. A command
737
+ * routing to nested `subCommands` descends via `resolveLeafCommand` -- citty parses the *nested*
738
+ * command's args against the remaining argv, so that is where an unknown flag actually surfaces.
739
+ * A flag written between a router and its subcommand name (`bst-fj labels --zzz sync`) goes
740
+ * unreported: citty's own `strict: false` parse at the router absorbs it, and the subcommand
741
+ * never sees it -- which is also why `--json` written there is silently dropped rather than
742
+ * honoured, so a machine reader must write it after the subcommand name.
743
+ *
744
+ * With a `probe`, a positional declaring `valueHint: "dir"` whose value is not a directory is
745
+ * reported too, after the flag checks. Commands hand that value to git, pnpm, or a file read as a
746
+ * working directory, where a file produced a stack trace and `docs:check` passed without checking
747
+ * anything. */
748
+ function findUnknownFlags(rawArgs, subCommands, probe) {
749
+ if (hasHelpFlag(rawArgs) || isLoneVersionQuery(rawArgs)) return { kind: "none" };
750
+ const split = splitAtCommand(rawArgs);
751
+ if (split === void 0) return { kind: "none" };
752
+ const commandName = rawArgs[split.index];
753
+ if (!(commandName in subCommands)) return { kind: "none" };
754
+ const cmd = asCommandDef(subCommands[commandName]);
755
+ if (cmd === void 0) return { kind: "none" };
756
+ for (const token of split.before) if (!UNIVERSAL_NAMES.has(flagName(token))) return {
757
+ kind: "before-command",
758
+ command: commandName,
759
+ flag: flagBase(token)
760
+ };
761
+ const leaf = resolveLeafCommand(cmd, [commandName], rawArgs.slice(split.index + 1));
762
+ if (leaf === void 0) return { kind: "none" };
763
+ const table = buildParseTable(leaf.argsDef);
764
+ const unknownNames = computeUnknownArgNames(leaf.postCommandArgs, leaf.argsDef);
765
+ if (unknownNames.length === 0) {
766
+ const badDir = probe === void 0 ? void 0 : findBadDirArg(leaf.postCommandArgs, leaf.argsDef, table.nodeOptions, probe);
767
+ if (badDir === void 0) return { kind: "none" };
768
+ return {
769
+ kind: "not-a-directory",
770
+ command: commandName,
771
+ commandPath: leaf.chain.join(" "),
772
+ ...badDir
773
+ };
774
+ }
775
+ const displayTokens = buildDisplayTokenMap(leaf.postCommandArgs);
776
+ return {
777
+ kind: "unknown",
778
+ command: commandName,
779
+ commandPath: leaf.chain.join(" "),
780
+ unknownFlags: unknownNames.map((name) => displayTokens.get(name) ?? formatFlagToken(name)),
781
+ accepted: table.acceptedFlags
782
+ };
783
+ }
784
+ /** `Unknown flag --repo for issue:comment. Nothing ran.` followed by
785
+ * `Accepted: --body, --body-file, --json, --dry-run, --verbose (plus --help, --trace).` -- the
786
+ * "(plus ...)" tail is {@link UNIVERSAL_FLAGS} minus what the command already declares, minus the
787
+ * short aliases (`-h`/`-v` say nothing their long spelling does not) and minus `--version`, which
788
+ * is a lone-invocation query citty never treats as a per-command flag (see `isLoneVersionQuery`),
789
+ * so listing it as "accepted" here would overstate what actually happens on `<command>
790
+ * --version`. */
791
+ function formatUnknownFlagsMessage(result) {
792
+ const first = `Unknown ${result.unknownFlags.length === 1 ? "flag" : "flags"} ${result.unknownFlags.join(", ")} for ${result.commandPath}. Nothing ran.`;
793
+ const extras = UNIVERSAL_FLAGS.filter((flag) => flag.startsWith("--") && flag !== "--version" && !result.accepted.includes(flag));
794
+ return `${first}\nAccepted: ${extras.length === 0 ? result.accepted.join(", ") : `${result.accepted.join(", ")} (plus ${extras.join(", ")})`}.`;
795
+ }
796
+ /** `--json must come after the command name.` */
797
+ function formatFlagBeforeCommandMessage(result) {
798
+ return `${result.flag} must come after the command name.`;
799
+ }
800
+ /** `No such directory: nope. ascii:check takes a directory as [dir]. Nothing ran.` */
801
+ function formatNotADirectoryMessage(result) {
802
+ return `${result.missing ? `No such directory: ${result.value}.` : `${result.value} is not a directory.`} ${result.commandPath} takes a directory as [${result.argName}]. Nothing ran.`;
803
+ }
804
+ /** The single message any non-`"none"` variant renders, so `run.ts` needs one call
805
+ * regardless of which shape the check found. */
806
+ function formatFlagCheckMessage(result) {
807
+ if (result.kind === "before-command") return formatFlagBeforeCommandMessage(result);
808
+ if (result.kind === "not-a-directory") return formatNotADirectoryMessage(result);
809
+ return formatUnknownFlagsMessage(result);
810
+ }
811
+ /** `<n>[s|m|h]`, a bare integer meaning seconds. */
812
+ const DURATION_PATTERN = /^(\d+)(s|m|h)?$/;
813
+ const UNIT_MS = {
814
+ s: 1e3,
815
+ m: 6e4,
816
+ h: 36e5
817
+ };
818
+ /**
819
+ * Parse a `--timeout`/`--interval`-shaped duration (`<n>[s|m|h]`, a bare integer meaning seconds)
820
+ * into milliseconds. Anything else is a `FatalError` naming `flag`, so an unreadable value throws
821
+ * into the standard error document rather than silently falling back to a default.
822
+ */
823
+ function parseDuration(value, flag) {
824
+ const match = DURATION_PATTERN.exec(value.trim());
825
+ if (!match) throw new FatalError(`${flag} expects a duration like "30s", "15m", or "1h" (a bare integer means seconds) -- got ${JSON.stringify(value)}.`);
826
+ const [, amount, unit] = match;
827
+ const multiplier = UNIT_MS[unit ?? "s"];
828
+ return Number.parseInt(amount ?? "0", 10) * multiplier;
829
+ }
830
+ /**
831
+ * Read a repeatable comma-separated list flag (`--skip`, `--add`) out of the RAW argv, unioning
832
+ * every occurrence.
833
+ *
834
+ * **The union is the point, and the raw argv is the only place it survives.** pnpm appends
835
+ * CI-forwarded flags to the END of the resolved script, so a repo whose `check` script already
836
+ * carries `--skip build` sees TWO occurrences the moment CI or an agent adds `--skip 'docker:*'`.
837
+ * citty declares this arg as `type: "string"` and collapses a repeated one to the LAST value
838
+ * before the handler ever runs -- probed against citty 0.2.2, `--skip build --skip 'docker:*'`
839
+ * arrives as the bare string `"docker:*"`, with no array and no trace of the first occurrence.
840
+ * So reading `args.skip` cannot be made additive; the fix has to go to `rawArgs`. That collapse
841
+ * is how this repo's `ci:check` came to run a second full build on every CI run, and it
842
+ * contradicts the semantics the `.tooling.json#checks.skip` path already documents: a skip is
843
+ * additive everywhere else, so it must be additive here too.
844
+ *
845
+ * Handles both spellings (`--skip a` and `--skip=a`), stops at a bare `--`, and ignores an
846
+ * occurrence whose value is missing. A malformed flag contributes nothing rather than throwing
847
+ * -- it must not take down a check run.
848
+ *
849
+ * "Missing" is specifically a following token starting with `--`, not one starting with `-`.
850
+ * The looser test reads `--skip -x` as a forgotten value and silently drops a skip the caller
851
+ * did ask for, and it makes the two spellings disagree: `--skip=-x` has no such ambiguity and
852
+ * would keep the value. Only a long flag can be mistaken for the next argument, so only a long
853
+ * flag terminates.
854
+ */
855
+ function parseListFlag(rawArgs, flag) {
856
+ const long = `--${flag}`;
857
+ const values = [];
858
+ for (const [index, token] of rawArgs.entries()) {
859
+ if (token === "--") break;
860
+ if (token.startsWith(`${long}=`)) {
861
+ values.push(token.slice(long.length + 1));
862
+ continue;
863
+ }
864
+ if (token !== long) continue;
865
+ const next = rawArgs[index + 1];
866
+ if (next !== void 0 && !next.startsWith("--")) values.push(next);
867
+ }
868
+ return values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value.length > 0);
869
+ }
870
+ /** True when `--<flag>` (or `--<flag>=...`) appears anywhere in `rawArgs`, independent of what it
871
+ * resolved to. `parseListFlag` silently drops a value that resolves to empty -- an unset `$VAR` in
872
+ * `--repo "$REPO"`, or a trailing `--repo` whose value fell off the line -- so a caller that only
873
+ * checks the parsed list cannot tell "no --repo given" apart from "a --repo given that resolved to
874
+ * nothing" and falls back to the current repo with no word said (the same failure shape
875
+ * `parseRepoTarget`, in `@bensandee/forgejo/connection`, refuses for the issue commands). */
876
+ function listFlagPresent(rawArgs, flag) {
877
+ const long = `--${flag}`;
878
+ return rawArgs.some((token) => token === long || token.startsWith(`${long}=`));
879
+ }
880
+ //#endregion
881
+ //#region ../cli-kit/src/output.ts
882
+ const stdout = (msg) => console.log(msg);
883
+ function initialState() {
884
+ return {
885
+ payload: void 0,
886
+ hasPayload: false,
887
+ error: void 0,
888
+ hasError: false,
889
+ notices: [],
890
+ wrote: false
891
+ };
892
+ }
893
+ let state = initialState();
894
+ function isJsonRecord(value) {
895
+ return typeof value === "object" && value !== null && !Array.isArray(value);
896
+ }
897
+ function createCliOutput(jsonMode) {
898
+ const effective = jsonMode || isJsonMode();
899
+ return {
900
+ json: (payload) => {
901
+ if (!effective) return;
902
+ if (state.hasPayload) rootLogger.debug("output: a second json() call overwrote the first -- last call wins.");
903
+ state.payload = payload;
904
+ state.hasPayload = true;
905
+ },
906
+ text: (message) => {
907
+ if (!effective) stdout(message);
908
+ },
909
+ notice: (message) => {
910
+ if (effective) state.notices.push(redact$1(message));
911
+ else stdout(message);
912
+ }
913
+ };
914
+ }
915
+ /** Replace what {@link flushJsonOutput} writes with an error document, discarding any payload
916
+ * already recorded by `json()` -- buffered notices are not discarded, they fold into the error
917
+ * document the same way they fold into a payload. */
918
+ function recordJsonError(document) {
919
+ state.error = document;
920
+ state.hasError = true;
921
+ }
922
+ function buildFlushDocument() {
923
+ let base;
924
+ if (state.hasError) base = state.error;
925
+ else if (state.hasPayload) base = state.payload;
926
+ if (base === void 0) return void 0;
927
+ if (state.notices.length === 0) return base;
928
+ if (isJsonRecord(base)) return {
929
+ ...base,
930
+ notices: state.notices
931
+ };
932
+ return {
933
+ payload: base,
934
+ notices: state.notices
935
+ };
936
+ }
937
+ /** Write the single recorded document -- the error document if `recordJsonError` was called,
938
+ * otherwise the recorded payload, each with buffered notices folded in under `notices` when
939
+ * the buffer is non-empty -- and report whether anything was written. */
940
+ function flushJsonOutput() {
941
+ const document = buildFlushDocument();
942
+ if (document === void 0) return false;
943
+ stdout(JSON.stringify(document));
944
+ state.wrote = true;
945
+ return true;
946
+ }
947
+ /** Whether {@link flushJsonOutput} has already written a document this process -- the guard the
948
+ * premature-exit net (`@bensandee/cli-kit/json`) reads to tell a normal flush from a process that
949
+ * died with the command still outstanding. */
950
+ function jsonDocumentWritten() {
951
+ return state.wrote;
952
+ }
953
+ //#endregion
954
+ //#region ../cli-kit/src/path-probe.ts
955
+ /** The real {@link PathKind} probe the pre-run argument check uses. A path that exists but is not
956
+ * a directory (a file, a socket) reads as `"file"`. */
957
+ function probeCliPath(value) {
958
+ const stat = statSync(value, { throwIfNoEntry: false });
959
+ if (stat === void 0) return "missing";
960
+ return stat.isDirectory() ? "directory" : "file";
961
+ }
962
+ //#endregion
963
+ //#region ../cli-kit/src/run.ts
964
+ /**
965
+ * Wait until a stream's internally-buffered data has been flushed to the OS.
966
+ *
967
+ * On Linux, `process.stdout`/`process.stderr` are ASYNCHRONOUS when the fd is
968
+ * a pipe (only files and TTYs get synchronous writes). A `write("", cb)` call
969
+ * queues behind any pending writes and invokes `cb` once the underlying fd has
970
+ * actually accepted everything buffered so far -- including when nothing was
971
+ * pending, so this is safe to call unconditionally. Errors (e.g. EPIPE from a
972
+ * reader that closed early, such as `| head`) are swallowed: there is nothing
973
+ * further to flush in that case, and `runCli()` below must not throw here.
974
+ */
975
+ function drain(stream) {
976
+ return new Promise((resolve) => {
977
+ stream.write("", () => resolve());
978
+ });
979
+ }
980
+ /**
981
+ * Raise the process log level to what `--verbose`/`-v`/`--trace` in `rawArgs` ask for.
982
+ *
983
+ * `--verbose`/`-v`/`--trace` are universal (see flags.ts): honoured on every command whether
984
+ * or not it declares any of them, not just the ones that parse them from their own args. Resolving
985
+ * the level from raw argv, in addition to the environment-only default at module load, is what
986
+ * makes that carve-out honest rather than a flag the CLI accepts but never acts on. `-v` reads as
987
+ * verbose here and as citty's version query only when it is the whole argv, which is the sole
988
+ * shape citty's `runMain` intercepts -- so the two never contend.
989
+ *
990
+ * Exported because each bin must call it **before** {@link runCli}: a bin reports its
991
+ * `$BST_ENV_FILE` load at module top level, which is ahead of anything that parses a flag, so
992
+ * without this call that one line is the only diagnostic in the CLI the flags cannot reach.
993
+ * Idempotent, and deliberately called twice -- `runCli` resolves the same level again off the
994
+ * same argv, so a caller that is not a bin is unaffected.
995
+ */
996
+ function applyVerbosityFromArgs(rawArgs) {
997
+ setLogLevel(resolveLogLevel({
998
+ verbose: rawArgs.includes("--verbose") || rawArgs.includes("-v"),
999
+ trace: rawArgs.includes("--trace")
1000
+ }));
1001
+ }
1002
+ /**
1003
+ * Build and run a bin's CLI from its {@link RunCliConfig}.
1004
+ *
1005
+ * citty only calls process.exit() for --help and errors. On normal completion
1006
+ * a command may leave the event loop alive (bst's @clack/prompts leaves readline
1007
+ * interfaces, signal listeners and spinner intervals), so we still call
1008
+ * process.exit() explicitly -- but only after draining stdout/stderr. Exiting
1009
+ * immediately after `runMain` resolves used to truncate piped `--json` output
1010
+ * at the ~64KB pipe buffer:
1011
+ * `process.stdout.write(bigJson)` returns before the OS has accepted all of it,
1012
+ * and `process.exit()` discards whatever is still queued. See tooling-cli's
1013
+ * test/bin-json-piping.test.ts.
1014
+ */
1015
+ async function runCli(config) {
1016
+ const allSubCommands = config.subCommands;
1017
+ const main = defineCommand({
1018
+ meta: {
1019
+ name: config.name,
1020
+ version: config.version,
1021
+ description: config.description
1022
+ },
1023
+ subCommands: allSubCommands
1024
+ });
1025
+ /**
1026
+ * Render a `--json` error document and exit -- the shared tail every JSON-mode rejection here
1027
+ * needs, whether from the strict-flag check, the `--json`-support check, a thrown error
1028
+ * mid-command, or a command that recorded no payload. `never` return type: every call site
1029
+ * ends the process, so nothing after a call to this needs its own unreachable-code guard.
1030
+ */
1031
+ const exitWithJsonError = async (command, kind, message, detail) => {
1032
+ recordJsonError(buildErrorDocument({
1033
+ command,
1034
+ version: config.version,
1035
+ kind,
1036
+ message,
1037
+ detail
1038
+ }));
1039
+ if ((process.exitCode ?? 0) === 0) process.exitCode = 1;
1040
+ flushJsonOutput();
1041
+ await Promise.all([drain(process.stdout), drain(process.stderr)]);
1042
+ process.exit(process.exitCode ?? 0);
1043
+ };
1044
+ const rawArgs = process.argv.slice(2);
1045
+ applyVerbosityFromArgs(rawArgs);
1046
+ const commandName = resolveCommandName(rawArgs);
1047
+ if (!resolveJsonMode(rawArgs)) {
1048
+ const flagCheck = findUnknownFlags(rawArgs, allSubCommands, probeCliPath);
1049
+ if (flagCheck.kind !== "none") {
1050
+ console.error(formatFlagCheckMessage(flagCheck));
1051
+ process.exitCode = 1;
1052
+ await Promise.all([drain(process.stdout), drain(process.stderr)]);
1053
+ process.exit(process.exitCode ?? 0);
1054
+ }
1055
+ if (config.banner !== void 0) console.error(config.banner);
1056
+ await runMain(main);
1057
+ await Promise.all([drain(process.stdout), drain(process.stderr)]);
1058
+ process.exit(process.exitCode ?? 0);
1059
+ }
1060
+ setJsonMode(true);
1061
+ setJsonNoticeSink((msg) => createCliOutput(true).notice(msg));
1062
+ installJsonExitNet(config.name, commandName, config.version, jsonDocumentWritten);
1063
+ const jsonFlagCheck = findUnknownFlags(rawArgs, allSubCommands, probeCliPath);
1064
+ if (jsonFlagCheck.kind !== "none") await exitWithJsonError(jsonFlagCheck.command, "usage", formatFlagCheckMessage(jsonFlagCheck));
1065
+ const resolvedCommand = asCommandDef(commandName !== void 0 && commandName in allSubCommands ? allSubCommands[commandName] : void 0);
1066
+ if (commandName === void 0 || resolvedCommand === void 0 || !commandDeclaresJson(resolvedCommand)) {
1067
+ let message;
1068
+ if (commandName === void 0) message = "No command specified.";
1069
+ else if (resolvedCommand === void 0) message = `Unknown command ${commandName}.`;
1070
+ else message = `Command ${commandName} does not support --json.`;
1071
+ await exitWithJsonError(commandName, "usage", message);
1072
+ }
1073
+ try {
1074
+ await runCommand(main, { rawArgs });
1075
+ } catch (error) {
1076
+ const classified = classifyThrown(error, verboseRequested(rawArgs));
1077
+ await exitWithJsonError(commandName, classified.kind, classified.message, classified.detail);
1078
+ }
1079
+ if (!flushJsonOutput()) await exitWithJsonError(commandName, "runtime", `${commandName} --json completed without recording a JSON payload. This is a bug in the command's --json support -- it does not necessarily mean the command's underlying action failed.`);
1080
+ await Promise.all([drain(process.stdout), drain(process.stderr)]);
1081
+ process.exit(process.exitCode ?? 0);
1082
+ }
1083
+ let current = {
1084
+ warn(message) {
1085
+ process.stderr.write(`${message}\n`);
1086
+ },
1087
+ debug() {}
1088
+ };
1089
+ function setForgejoLogger(logger) {
1090
+ current = logger;
1091
+ }
1092
+ /** Stable call-site surface: modules bind `log` once; injection swaps the target underneath. */
1093
+ const log = {
1094
+ warn(message) {
1095
+ current.warn(message);
1096
+ },
1097
+ debug(message) {
1098
+ current.debug(message);
1099
+ }
1100
+ };
1101
+ //#endregion
1102
+ //#region ../forgejo/src/fetch-retry.ts
1103
+ /** Production policy: 5 attempts, 250ms->2s exponential backoff (four gaps, ~3.75s total). */
1104
+ const DEFAULT_RETRY_POLICY = {
1105
+ attempts: 5,
1106
+ baseDelayMs: 250,
1107
+ maxDelayMs: 2e3
1108
+ };
1109
+ /**
1110
+ * Per-request wall-clock timeout for every forge fetch -- a backstop against an *infinite* hang,
1111
+ * not a tight SLA. `globalThis.fetch` has no timeout, so a stalled keep-alive socket (the peer
1112
+ * sent FIN but never answers) makes a request neither resolve nor reject: the agent then hangs
1113
+ * forever on it, and `retryOnTransient` cannot help because it only catches *thrown* errors, not
1114
+ * an indefinite stall. This was the root cause of "the loop stalls when a task fails" -- the
1115
+ * stall lands on `unclaimOnError`'s non-retried failure-comment POST. Generous (healthy forge
1116
+ * calls finish in well under a second) so it never trips a slow-but-live request; the one call
1117
+ * that can legitimately exceed it -- a large release-asset upload -- opts out with `timeoutMs: 0`.
1118
+ */
1119
+ const DEFAULT_FETCH_TIMEOUT_MS = 12e4;
1120
+ /**
1121
+ * A transport-level error a retry can recover from by opening a fresh socket: undici's thrown
1122
+ * `TypeError` ("fetch failed": DNS blip, dead keep-alive, socket hang-up), plus the
1123
+ * `AbortSignal.timeout` abort we attach ourselves (a `DOMException` named `TimeoutError`, or
1124
+ * `AbortError` on some undici paths) -- a stalled read is exactly what a retry is for. A
1125
+ * caller's own cancel also surfaces as an abort here; {@link retryOnTransient} checks the
1126
+ * caller's signal before this classifier, so a real cancel is rethrown and never retried.
1127
+ */
1128
+ function isTransientTransportError(error) {
1129
+ if (error instanceof TypeError) return true;
1130
+ return error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
1131
+ }
1132
+ /**
1133
+ * Bound every request by the wall-clock cap, composing with -- never replaced by -- a signal the
1134
+ * caller brought: with both present the request aborts on whichever fires first, so threading a
1135
+ * cancel signal in can never silently disable the stall backstop. A disabled cap (`timeoutMs <=
1136
+ * 0`) returns `init` untouched, caller signal included. Called once per attempt -- an
1137
+ * already-fired timeout signal stays aborted, so a retried request must mint a new one rather
1138
+ * than reuse the timed-out attempt's.
1139
+ */
1140
+ function withTimeoutSignal(init, timeoutMs) {
1141
+ if (timeoutMs <= 0) return init;
1142
+ const timeout = AbortSignal.timeout(timeoutMs);
1143
+ if (init?.signal != null) return {
1144
+ ...init,
1145
+ signal: AbortSignal.any([init.signal, timeout])
1146
+ };
1147
+ return {
1148
+ ...init,
1149
+ signal: timeout
1150
+ };
1151
+ }
1152
+ /** Exponential backoff (capped) between attempts; a non-positive base is a no-op (the test seam). */
1153
+ async function retryBackoff(attempt, baseDelayMs, maxDelayMs) {
1154
+ if (baseDelayMs <= 0) return;
1155
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
1156
+ await new Promise((resolve) => setTimeout(resolve, delay));
1157
+ }
1158
+ /**
1159
+ * Retry `fn` on a transient transport error (see {@link isTransientTransportError}: undici's
1160
+ * "fetch failed" TypeError, or our own `AbortSignal.timeout` abort) up to `policy.attempts`
1161
+ * times, with exponential backoff between tries. Any other thrown error propagates on the first
1162
+ * attempt -- only a transport-level error is transient. A resolved value (including a 4xx/5xx
1163
+ * `Response`) is never a throw, so it is never retried. After the last attempt fails, the final
1164
+ * error is re-thrown.
1165
+ *
1166
+ * Idempotency is the caller's contract: only wrap a `fn` whose re-execution converges to the same
1167
+ * state. `retryOnTransient` cannot and does not verify that.
1168
+ *
1169
+ * `callerSignal` is the caller's own cancel: once it has fired, every error -- transient-shaped
1170
+ * or not -- is rethrown at once, because a cancelled call must never be retried.
1171
+ */
1172
+ async function retryOnTransient(fn, policy = DEFAULT_RETRY_POLICY, callerSignal = null) {
1173
+ let lastError;
1174
+ for (let attempt = 1; attempt <= policy.attempts; attempt++) try {
1175
+ return await fn();
1176
+ } catch (error) {
1177
+ if (callerSignal?.aborted) throw error;
1178
+ if (!isTransientTransportError(error)) throw error;
1179
+ lastError = error;
1180
+ if (attempt < policy.attempts) await retryBackoff(attempt, policy.baseDelayMs, policy.maxDelayMs);
1181
+ }
1182
+ throw lastError;
1183
+ }
1184
+ /**
1185
+ * Wrap a fetch so idempotent requests retry on a transient `TypeError` (see {@link
1186
+ * retryOnTransient}). A request is idempotent when its method is GET/HEAD (a read) OR the caller
1187
+ * passed `{ idempotent: true }` as the third arg -- the escape hatch for a mutating call that
1188
+ * converges (e.g. `updatePr`'s PATCH). A non-idempotent mutation (POST create/comment) is never
1189
+ * retried: "fetch failed" does not prove the request never landed server-side, so a re-send could
1190
+ * duplicate it. `policy` is injectable so tests run with `baseDelayMs: 0` and never wait.
1191
+ */
1192
+ function fetchWithRetry(fetchImpl, policy = DEFAULT_RETRY_POLICY, defaultTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1193
+ return (input, init, retry) => {
1194
+ const method = (init?.method ?? "GET").toUpperCase();
1195
+ const idempotent = method === "GET" || method === "HEAD" || retry?.idempotent === true;
1196
+ const timeoutMs = retry?.timeoutMs ?? defaultTimeoutMs;
1197
+ const callerSignal = init?.signal ?? null;
1198
+ const attempt = () => fetchImpl(input, withTimeoutSignal(init, timeoutMs));
1199
+ if (!idempotent) return attempt();
1200
+ return retryOnTransient(attempt, retry?.attempts === void 0 ? policy : {
1201
+ ...policy,
1202
+ attempts: retry.attempts
1203
+ }, callerSignal);
1204
+ };
1205
+ }
1206
+ /**
1207
+ * A real-network {@link ForgeFetcher} with the default retry policy, for a caller that has no
1208
+ * `ReleaseExecutor` (the standalone Forgejo CLI). `globalThis.fetch` is read at call time, so a
1209
+ * stub installed after this call is still seen.
1210
+ */
1211
+ function createForgeFetcher(policy = DEFAULT_RETRY_POLICY) {
1212
+ return { fetch: fetchWithRetry((input, init) => globalThis.fetch(input, init), policy) };
1213
+ }
1214
+ //#endregion
1215
+ //#region src/fetcher.ts
1216
+ /**
1217
+ * The one place a command gets its real fetcher. Every command reaches the network only through
1218
+ * this function, so a test mocks this module and no other.
1219
+ */
1220
+ function cliFetcher() {
1221
+ return createForgeFetcher();
1222
+ }
1223
+ //#endregion
1224
+ //#region ../forgejo/src/errors.ts
1225
+ /**
1226
+ * A refusal about the caller's reach rather than about the request: 401/403 (the token lacks the
1227
+ * scope) and 404 (the resource is absent, or present and invisible to this token -- the two are
1228
+ * indistinguishable by design on both forges).
1229
+ *
1230
+ * It extends `FatalError` and carries the identical message, so every existing caller sees exactly
1231
+ * what it saw before. The subclass exists for a caller reading an *optional* enrichment **behind a
1232
+ * proof the resource exists**, which can therefore degrade instead of failing: `runIssueView`'s
1233
+ * pulls read is that caller, and the only one. Catching plain `FatalError` there would also
1234
+ * swallow a 422 or a schema mismatch as "no access", which is why the narrower type exists.
1235
+ *
1236
+ * Whether to degrade stays with the caller; this layer only reports what the forge said.
1237
+ *
1238
+ * `name` stays `"FatalError"`: the base class types it as that literal, and the discriminator here
1239
+ * is `instanceof`, not the string.
1240
+ */
1241
+ var ForgeAccessError = class extends FatalError {};
1242
+ /**
1243
+ * Request headers shared by every Forgejo API caller in this package. Sitting on the leaf module
1244
+ * that already owns {@link ForgejoConnection} is what lets `issues.ts`, `issue-labels.ts`,
1245
+ * `issue-comments.ts`, and `label-provisioning.ts` all use them with no edge back to `issues.ts`.
1246
+ */
1247
+ function authHeaders$1(conn) {
1248
+ return { Authorization: `token ${conn.token}` };
1249
+ }
1250
+ function jsonHeaders(conn) {
1251
+ return {
1252
+ ...authHeaders$1(conn),
1253
+ "Content-Type": "application/json"
1254
+ };
1255
+ }
1256
+ /** Shared "not found" wording so it isn't duplicated across every function that reads a repo. */
1257
+ function repoNotFoundMessage(conn) {
1258
+ return `Repository ${conn.repository} not found (or the token cannot see it)`;
1259
+ }
1260
+ /**
1261
+ * Field names come from a real Forgejo response. Note `browser_download_url` --
1262
+ * NOT `download_url`. The URL is a web route (`/attachments/<uuid>`), not an
1263
+ * `/api/v1` route, and an API token does authenticate against it.
1264
+ *
1265
+ * Lives on this leaf module -- rather than on `issues.ts`, which normalizes the attachments
1266
+ * inline on an issue, or `issue-comments.ts`, which reads a comment's own attachments -- because
1267
+ * both need it and neither may import from the other.
1268
+ */
1269
+ const AttachmentSchema = z.object({
1270
+ id: z.number(),
1271
+ name: z.string(),
1272
+ size: z.number(),
1273
+ created_at: z.string(),
1274
+ browser_download_url: z.string()
1275
+ });
1276
+ z.array(AttachmentSchema);
1277
+ /** Safely read response body text for inclusion in error messages. */
1278
+ async function responseBodyText(res) {
1279
+ try {
1280
+ const text = await res.text();
1281
+ const truncated = text.length > 500 ? text.slice(0, 500) + "..." : text;
1282
+ return redact(truncated);
1283
+ } catch {
1284
+ return "(could not read response body)";
1285
+ }
1286
+ }
1287
+ const SwaggerEnvelopeSchema = z.union([z.object({ message: z.literal("") }), z.object({
1288
+ message: z.string(),
1289
+ url: z.string().regex(/\/api\/swagger$/)
1290
+ })]);
1291
+ /**
1292
+ * Detect Forgejo's signature "route not handled" response shape -- empty body
1293
+ * or a JSON envelope with either an empty `message` or a `url` pointing at
1294
+ * `/api/swagger`. Forgejo returns this from a 500 (and sometimes 404) when no
1295
+ * route handles the request, even though the status code suggests a
1296
+ * server-side fault.
1297
+ */
1298
+ function isForgejoGenericServerErrorBody(body) {
1299
+ if (body === "") return true;
1300
+ try {
1301
+ const parsed = JSON.parse(body);
1302
+ return SwaggerEnvelopeSchema.safeParse(parsed).success;
1303
+ } catch (_error) {
1304
+ return false;
1305
+ }
1306
+ }
1307
+ const DISPATCH_VERB_PATTERN = /\bdispatch\b/i;
1308
+ /**
1309
+ * Map a non-2xx Forgejo response to a human-readable hint.
1310
+ *
1311
+ * Returns the most-likely cause first; callers still surface the raw body so an
1312
+ * uncommon failure mode isn't masked. Returns "" for unmapped codes (2xx/3xx,
1313
+ * teapots) so the caller can decide whether to include a Hint line at all.
1314
+ */
1315
+ function forgejoErrorHint(status, body, operation) {
1316
+ const isDispatch = DISPATCH_VERB_PATTERN.test(operation.verb);
1317
+ const resource = operation.resource ?? "the resource";
1318
+ if (status === 401 || status === 403) return `Forgejo rejected the token. It needs the scope required for "${operation.verb}" on ${resource}.`;
1319
+ if (status === 404) {
1320
+ const base = `"${resource}" was not found on the server. Verify the name${operation.ref ? `, that it exists on ${operation.ref}` : ""}, and that the token can see it.`;
1321
+ return isDispatch ? `${base} For dispatch, also confirm the workflow has an "on: workflow_dispatch:" trigger.` : base;
1322
+ }
1323
+ if (status === 400 || status === 422) {
1324
+ const base = `Forgejo rejected the request payload for "${operation.verb}". Check for missing or invalid fields.`;
1325
+ return isDispatch ? `${base} For dispatch, common causes are a missing "on: workflow_dispatch:" trigger or a required input not being provided.` : base;
1326
+ }
1327
+ if (status >= 500 && status < 600) {
1328
+ if (isForgejoGenericServerErrorBody(body)) {
1329
+ const ref = operation.ref ? ` on ${operation.ref}` : "";
1330
+ return `Forgejo returned a generic server error. Most often this means the requested route is unavailable${isDispatch ? ` (e.g. "${resource}" doesn't exist${ref}, or the workflow lacks an "on: workflow_dispatch:" trigger)` : ""}. Less commonly: the token lacks scope, or it is a server-side bug.`;
1331
+ }
1332
+ return `Forgejo server error -- usually transient. Retry; if it persists, check the server logs.`;
1333
+ }
1334
+ return "";
1335
+ }
1336
+ /**
1337
+ * Build a richer error message for a non-2xx Forgejo API response.
1338
+ *
1339
+ * Reads, truncates, and redacts the body via {@link responseBodyText}; appends
1340
+ * a `Hint:` line when {@link forgejoErrorHint} has one; appends the raw body as
1341
+ * a `Response:` block. Callers still pick the error class (Transient vs Fatal).
1342
+ */
1343
+ async function forgejoErrorMessage(res, operation) {
1344
+ const body = await responseBodyText(res);
1345
+ const hint = forgejoErrorHint(res.status, body, operation);
1346
+ return `Failed to ${operation.verb} on Forgejo: ${String(res.status)} ${res.statusText}` + (hint ? `\nHint: ${hint}` : "") + (body ? `\nResponse: ${body}` : "");
1347
+ }
1348
+ /**
1349
+ * Classify a non-2xx Forgejo response. 404 is bad input (the named resource
1350
+ * doesn't exist -- not retryable). 401/403 is a credentials/scope problem --
1351
+ * also not retryable, and the single most common failure mode here (a token
1352
+ * missing the required scope returns 403). 422 is a validation failure (e.g.
1353
+ * a nonexistent label on create) -- retrying with the same input can never
1354
+ * succeed. 410 means issues are disabled on the repository -- also permanent
1355
+ * until repo config changes, not something a retry fixes. Anything else is
1356
+ * presumed transient (server hiccups, rate limiting, etc.).
1357
+ *
1358
+ * `notFoundMessage` lets a caller supply a resource-specific 404 message
1359
+ * (e.g. "Issue #<n> not found in owner/repo"); when omitted, the 404 case
1360
+ * falls back to {@link forgejoErrorMessage}'s generic (but still hinted)
1361
+ * message, which is what every `pr.ts` call site gets.
1362
+ *
1363
+ * The 404 and 401/403 cases throw {@link ForgeAccessError} -- a `FatalError` subclass with the
1364
+ * same message, so nothing here changes for a caller that only distinguishes fatal from transient.
1365
+ */
1366
+ async function throwForgejoFailure(res, operation, notFoundMessage) {
1367
+ if (res.status === 404) throw new ForgeAccessError(notFoundMessage ?? await forgejoErrorMessage(res, operation));
1368
+ const message = await forgejoErrorMessage(res, operation);
1369
+ if (res.status === 401 || res.status === 403) throw new ForgeAccessError(message);
1370
+ if (res.status === 422) throw new FatalError(`${message}\nForgejo rejected this as invalid (422) -- retrying with the same input cannot succeed (e.g. a nonexistent label or an assignee the repository will not accept).`);
1371
+ if (res.status === 410) throw new FatalError(`${message}\nForgejo returned 410 Gone -- issues are likely disabled on this repository; retrying will not help.`);
1372
+ throw new TransientError(message);
1373
+ }
1374
+ //#endregion
1375
+ //#region ../forgejo/src/commit-status.ts
1376
+ /**
1377
+ * The combined commit status, read as the per-job report the response already carries rather than
1378
+ * reduced to a single boolean. `CommitChecks.green` alone is a real consumer's whole need -- the
1379
+ * automerge gate only ever asks "is it green" -- but a read-only caller that reports on a PR's CI
1380
+ * wants the per-job list too, and the endpoint already returns it.
1381
+ *
1382
+ * The parse is deliberately two layers, and the layering is the whole point. The top-level schema
1383
+ * is byte-for-byte what `pr-merge.ts` has always required (`{ state, statuses }`), and `green` is
1384
+ * computed from it *before any entry is inspected*. Entries are then projected through a second,
1385
+ * all-nullish schema, so an entry shape this build cannot read degrades that one row of the report
1386
+ * and can never move the verdict. `pr-merge.ts`'s doc comment records the lesson this shape
1387
+ * encodes in prose: a schema that required a field the forge never sent (`state` on an entry, where
1388
+ * Forgejo sends `status`) once withheld every automerge, silently, because the whole response
1389
+ * failed to parse over one field the verdict never reads.
1390
+ */
1391
+ const CombinedStatusSchema = z.object({
1392
+ state: z.string(),
1393
+ statuses: z.array(z.unknown())
1394
+ });
1395
+ const CheckEntrySchema = z.object({
1396
+ context: z.string().nullish(),
1397
+ status: z.string().nullish(),
1398
+ description: z.string().nullish(),
1399
+ target_url: z.string().nullish(),
1400
+ updated_at: z.string().nullish(),
1401
+ id: z.number().nullish()
1402
+ });
1403
+ /** A single trailing ` (<word>)` parenthetical, stripped from a context to get its `name`. */
1404
+ const TRAILING_EVENT = / \([^()]+\)$/;
1405
+ function classifyVerdict(state) {
1406
+ if (state === "success") return "good";
1407
+ if (state === "pending") return "pending";
1408
+ if (state === "skipped" || state === "neutral") return "skipped";
1409
+ return "red";
1410
+ }
1411
+ function entryKey(entry, index) {
1412
+ if (typeof entry.context === "string" && entry.context.length > 0) return entry.context;
1413
+ return `#${String(index)}`;
1414
+ }
1415
+ function updatedAtMillis(value) {
1416
+ if (!value) return 0;
1417
+ const parsed = Date.parse(value);
1418
+ return Number.isNaN(parsed) ? 0 : parsed;
1419
+ }
1420
+ /**
1421
+ * Deduplicate entries by `context`, newest `updated_at` winning, falling back to `id`, then to
1422
+ * array order. An entry with no (or empty) `context` is keyed by its own index instead, so two
1423
+ * context-less entries never collapse into one row.
1424
+ */
1425
+ function dedupeEntries(entries) {
1426
+ const byKey = /* @__PURE__ */ new Map();
1427
+ entries.forEach((entry, index) => {
1428
+ const key = entryKey(entry, index);
1429
+ const existing = byKey.get(key);
1430
+ if (!existing) {
1431
+ byKey.set(key, {
1432
+ entry,
1433
+ index
1434
+ });
1435
+ return;
1436
+ }
1437
+ const existingUpdated = updatedAtMillis(existing.entry.updated_at);
1438
+ const candidateUpdated = updatedAtMillis(entry.updated_at);
1439
+ if (candidateUpdated > existingUpdated) {
1440
+ byKey.set(key, {
1441
+ entry,
1442
+ index
1443
+ });
1444
+ return;
1445
+ }
1446
+ if (candidateUpdated === existingUpdated) {
1447
+ const existingId = existing.entry.id ?? -Infinity;
1448
+ if ((entry.id ?? -Infinity) > existingId) byKey.set(key, {
1449
+ entry,
1450
+ index
1451
+ });
1452
+ }
1453
+ });
1454
+ const sorted = [...byKey.values()];
1455
+ sorted.sort((a, b) => a.index - b.index);
1456
+ return sorted;
1457
+ }
1458
+ /**
1459
+ * Read a commit's combined status as a per-job report, scoped to `sha`. An unparseable response
1460
+ * returns `parsed: false` rather than throwing: a broken read must never look like a decision a
1461
+ * caller is entitled to make.
1462
+ */
1463
+ async function readCommitChecks(executor, conn, sha) {
1464
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/commits/${sha}/status`;
1465
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
1466
+ if (!res.ok) await throwForgejoFailure(res, {
1467
+ verb: "read commit status",
1468
+ resource: sha
1469
+ });
1470
+ const body = await res.json();
1471
+ const parsed = CombinedStatusSchema.safeParse(body);
1472
+ if (!parsed.success) return {
1473
+ parsed: false,
1474
+ state: "",
1475
+ green: false,
1476
+ contexts: []
1477
+ };
1478
+ const green = parsed.data.state === "success" && parsed.data.statuses.length > 0;
1479
+ const EMPTY_ENTRY = {
1480
+ context: null,
1481
+ status: null,
1482
+ description: null,
1483
+ target_url: null,
1484
+ updated_at: null,
1485
+ id: null
1486
+ };
1487
+ const contexts = dedupeEntries(parsed.data.statuses.map((raw) => {
1488
+ const entryParsed = CheckEntrySchema.safeParse(raw);
1489
+ return entryParsed.success ? entryParsed.data : EMPTY_ENTRY;
1490
+ })).map(({ entry }) => {
1491
+ const context = entry.context ?? "";
1492
+ return {
1493
+ context,
1494
+ name: context.replace(TRAILING_EVENT, ""),
1495
+ state: entry.status ?? "",
1496
+ verdict: classifyVerdict(entry.status),
1497
+ description: entry.description ?? null,
1498
+ targetUrl: entry.target_url ?? null,
1499
+ updatedAt: entry.updated_at ?? null
1500
+ };
1501
+ });
1502
+ return {
1503
+ parsed: true,
1504
+ state: parsed.data.state,
1505
+ green,
1506
+ contexts
1507
+ };
1508
+ }
1509
+ z.object({ default_branch: z.string() });
1510
+ const BranchHeadSchema = z.object({ commit: z.object({ id: z.string() }) });
1511
+ z.object({
1512
+ total_commits: z.number(),
1513
+ commits: z.array(z.unknown())
1514
+ });
1515
+ z.object({ sha: z.string() });
1516
+ z.object({
1517
+ sha: z.string(),
1518
+ created: z.string()
1519
+ });
1520
+ z.array(z.unknown());
1521
+ function repoBase(conn) {
1522
+ return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
1523
+ }
1524
+ function authHeaders(conn) {
1525
+ return { Authorization: `token ${conn.token}` };
1526
+ }
1527
+ /** A branch's own "not found" wording -- distinct from {@link repoNotFoundMessage} so a stale
1528
+ * cached branch name (e.g. after a default-branch rename) reports the resource that is actually
1529
+ * missing, not the repo it lives in. */
1530
+ function branchNotFoundMessage(conn, branch) {
1531
+ return `Branch ${branch} not found in ${conn.repository}`;
1532
+ }
1533
+ /** A branch's head commit SHA -- the cheap "has anything landed?" probe a caller uses to decide
1534
+ * whether a clone is worth doing at all. */
1535
+ async function getBranchHeadSha(executor, conn, branch) {
1536
+ const url = `${repoBase(conn)}/branches/${encodeURIComponent(branch)}`;
1537
+ const res = await executor.fetch(url, { headers: authHeaders(conn) });
1538
+ if (!res.ok) await throwForgejoFailure(res, {
1539
+ verb: "read branch",
1540
+ resource: branch
1541
+ }, branchNotFoundMessage(conn, branch));
1542
+ const parsed = BranchHeadSchema.safeParse(await res.json());
1543
+ if (!parsed.success) throw new UnexpectedError(`Unexpected branch response: ${parsed.error.message}`);
1544
+ return parsed.data.commit.id;
1545
+ }
1546
+ //#endregion
1547
+ //#region ../forgejo/src/connection.ts
1548
+ function parseGitUrl(urlStr) {
1549
+ try {
1550
+ const url = new URL(urlStr);
1551
+ const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
1552
+ if (!pathname.includes("/")) return null;
1553
+ return {
1554
+ protocol: url.protocol,
1555
+ hostname: url.hostname,
1556
+ repository: pathname
1557
+ };
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ }
1562
+ /**
1563
+ * The one place RELEASE_TOKEN / FORGEJO_TOKEN precedence lives. `||` (not `??`) on
1564
+ * purpose: CI injects RELEASE_TOKEN="" for an unset secret, which must fall through to
1565
+ * FORGEJO_TOKEN instead of masking it.
1566
+ */
1567
+ function forgeTokenFromEnv(env) {
1568
+ return env["RELEASE_TOKEN"] || env["FORGEJO_TOKEN"];
1569
+ }
1570
+ /**
1571
+ * Resolve the Forgejo connection from the environment and one repository URL the caller finds
1572
+ * (git remote origin, else package.json; this module does no I/O). `repoUrl` is a thunk, called
1573
+ * only once the environment has not settled the connection, so a complete environment never
1574
+ * costs a git subprocess or a package.json read (and never fails on a malformed one).
1575
+ *
1576
+ * Priority:
1577
+ * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, RELEASE_TOKEN)
1578
+ * 2. `repoUrl` (server URL and owner/repo parsed from it)
1579
+ *
1580
+ * For Forgejo, a token is always required ({@link forgeTokenFromEnv}). Returns `null` where the
1581
+ * repository is on github.com or there is no repository URL at all: the caller treats that as
1582
+ * GitHub (the gh CLI handles auth).
1583
+ */
1584
+ function resolveForgejoConnection(env, repoUrl) {
1585
+ const serverUrl = env["FORGEJO_SERVER_URL"];
1586
+ const repository = env["FORGEJO_REPOSITORY"];
1587
+ const token = forgeTokenFromEnv(env);
1588
+ if (serverUrl && repository && token) return {
1589
+ serverUrl,
1590
+ repository,
1591
+ token
1592
+ };
1593
+ const url = repoUrl();
1594
+ const parsed = url === null ? null : parseGitUrl(url);
1595
+ if (parsed === null) {
1596
+ if (serverUrl) {
1597
+ if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
1598
+ if (!token) throw new FatalError("RELEASE_TOKEN environment variable is required");
1599
+ }
1600
+ return null;
1601
+ }
1602
+ if (parsed.hostname === "github.com") return null;
1603
+ if (!token) throw new FatalError("RELEASE_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
1604
+ return {
1605
+ serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
1606
+ repository: repository ?? parsed.repository,
1607
+ token
1608
+ };
1609
+ }
1610
+ const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/(?:issues|pulls?)\/(\d+)\/?$/;
1611
+ /** Parse a bare issue number or a full issue URL. A URL also carries repo coordinates. */
1612
+ function parseIssueRef(input) {
1613
+ if (input === void 0 || input.trim() === "") return {};
1614
+ const value = input.trim();
1615
+ if (/^\d+$/.test(value)) return { number: Number.parseInt(value, 10) };
1616
+ let url;
1617
+ try {
1618
+ url = new URL(value);
1619
+ } catch {
1620
+ url = void 0;
1621
+ }
1622
+ if (url !== void 0 && url.protocol !== "http:" && url.protocol !== "https:") throw new FatalError(`Not an issue number or issue URL: "${value}" (expected e.g. 273 or https://host/owner/repo/issues/273)`);
1623
+ if (url !== void 0) {
1624
+ const match = ISSUE_PATH.exec(url.pathname);
1625
+ if (match) {
1626
+ const [, repository, num] = match;
1627
+ if (repository !== void 0 && num !== void 0) return {
1628
+ number: Number.parseInt(num, 10),
1629
+ target: {
1630
+ serverUrl: url.origin,
1631
+ repository,
1632
+ hostname: url.hostname
1633
+ }
1634
+ };
1635
+ }
1636
+ }
1637
+ throw new FatalError(`Not an issue number or issue URL: "${value}" (expected e.g. 273 or https://host/owner/repo/issues/273)`);
1638
+ }
1639
+ /**
1640
+ * Parse a `--repo <url>` value into a target. `undefined` -- the flag absent -- is the only input
1641
+ * meaning "not given": a present but blank value refuses instead of resolving from the working
1642
+ * directory, because both shapes that produce one come from a caller who believes they targeted
1643
+ * another repository. `--repo "$REPO"` with the variable unset arrives as `""`, and so does a
1644
+ * trailing `--repo` whose value fell off the end of the line, which citty coerces from `true` to
1645
+ * `""` for a string-typed arg. Answering either from the current repo is the same number, a
1646
+ * different object, and not a word said -- the silence the `--repo` handling exists to close.
1647
+ */
1648
+ function parseRepoTarget(input) {
1649
+ if (input === void 0) return void 0;
1650
+ if (input.trim() === "") throw new FatalError("--repo was given with no repository URL (expected e.g. https://host/owner/repo).");
1651
+ const match = /^(https?:\/\/[^/]+)\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(input.trim());
1652
+ if (!match) throw new FatalError(`Not a repository URL: "${input}"`);
1653
+ const [, serverUrl, repository] = match;
1654
+ if (serverUrl === void 0 || repository === void 0) throw new FatalError(`Not a repository URL: "${input}"`);
1655
+ return {
1656
+ serverUrl,
1657
+ repository,
1658
+ hostname: new URL(serverUrl).hostname
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Reconcile an issue ref's own target (from an issue URL) with an explicit `--repo`. Before this,
1663
+ * every call site let `ref.target ?? parseRepoTarget(repo)` discard a `--repo` the moment the ref
1664
+ * carried a target of its own -- silently, the same failure class the strict-flag check exists to
1665
+ * close, one layer in: a caller who passes both a URL and a disagreeing `--repo` gets an answer
1666
+ * from the URL's repository with no sign the flag was ignored.
1667
+ *
1668
+ * Absent one side, the other wins outright. Present on both sides, agreement (compared on the
1669
+ * parsed `serverUrl`/`repository`, not the raw strings, so a trailing slash or `.git` -- both of
1670
+ * which `parseRepoTarget` already normalizes away -- is never mistaken for a disagreement) resolves
1671
+ * quietly; disagreement throws, naming both repositories rather than picking one without a word.
1672
+ */
1673
+ function resolveIssueTarget(refTarget, repo) {
1674
+ const repoTarget = parseRepoTarget(repo);
1675
+ if (refTarget === void 0) return repoTarget;
1676
+ if (repoTarget === void 0) return refTarget;
1677
+ if (refTarget.serverUrl === repoTarget.serverUrl && refTarget.repository === repoTarget.repository) return refTarget;
1678
+ throw new FatalError(`--repo disagrees with the issue URL: --repo names ${repoTarget.serverUrl}/${repoTarget.repository}, the issue URL names ${refTarget.serverUrl}/${refTarget.repository}.`);
1679
+ }
1680
+ /**
1681
+ * Normalize a git remote URL to a clean HTTPS URL.
1682
+ * Handles SSH (git@host:owner/repo.git) and HTTPS variants.
1683
+ */
1684
+ function normalizeGitUrl(raw) {
1685
+ const sshMatch = raw.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
1686
+ if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
1687
+ try {
1688
+ const url = new URL(raw);
1689
+ url.pathname = url.pathname.replace(/\.git$/, "");
1690
+ url.username = "";
1691
+ url.password = "";
1692
+ return url.toString().replace(/\/$/, "");
1693
+ } catch (_error) {
1694
+ return null;
1695
+ }
1696
+ }
1697
+ //#endregion
1698
+ //#region ../cli-kit/src/git.ts
1699
+ const BRANCH_REF_PREFIX = "refs/heads/";
1700
+ /** The origin read gives up after this long, so a wedged remote lookup cannot hang a command. */
1701
+ const ORIGIN_READ_TIMEOUT_MS = 5e3;
1702
+ function defaultGitRunner(args, cwd, options) {
1703
+ try {
1704
+ const trimmed = execFileSync("git", args, {
1705
+ cwd,
1706
+ stdio: [
1707
+ "ignore",
1708
+ "pipe",
1709
+ "ignore"
1710
+ ],
1711
+ timeout: options?.timeoutMs
1712
+ }).toString("utf-8").trim();
1713
+ return trimmed === "" ? void 0 : trimmed;
1714
+ } catch {
1715
+ return;
1716
+ }
1717
+ }
1718
+ /**
1719
+ * Resolve the branch the current run is on.
1720
+ *
1721
+ * CI env wins: `GITHUB_REF` (also set by Forgejo Actions) is the source of truth
1722
+ * for whether this is a branch event -- tag and pull-request refs are deliberately
1723
+ * reported as `undefined` since they have no meaningful branch. On a branch event
1724
+ * the short name comes from `GITHUB_REF_NAME` (falling back to stripping the
1725
+ * prefix off `GITHUB_REF`). Without `GITHUB_REF`, falls back to local git.
1726
+ *
1727
+ * Returns `undefined` when the branch cannot be determined or the ref is not a
1728
+ * branch (tag, PR merge ref, detached HEAD).
1729
+ */
1730
+ function resolveCurrentBranch(options) {
1731
+ const env = options.env ?? process.env;
1732
+ const ref = env["GITHUB_REF"];
1733
+ if (ref !== void 0 && ref !== "") {
1734
+ if (!ref.startsWith(BRANCH_REF_PREFIX)) return void 0;
1735
+ const name = env["GITHUB_REF_NAME"];
1736
+ return name !== void 0 && name !== "" ? name : ref.slice(11);
1737
+ }
1738
+ const branch = (options.git ?? defaultGitRunner)([
1739
+ "rev-parse",
1740
+ "--abbrev-ref",
1741
+ "HEAD"
1742
+ ], options.cwd);
1743
+ if (branch === void 0 || branch === "HEAD") return void 0;
1744
+ return branch;
1745
+ }
1746
+ /**
1747
+ * The raw output of `git remote get-url origin`, or `null` when there is no origin or `cwd` is not
1748
+ * a repository. Not normalized: an SSH remote comes back as written.
1749
+ */
1750
+ function readOriginUrl(cwd, git = defaultGitRunner) {
1751
+ return git([
1752
+ "remote",
1753
+ "get-url",
1754
+ "origin"
1755
+ ], cwd, { timeoutMs: ORIGIN_READ_TIMEOUT_MS }) ?? null;
1756
+ }
1757
+ const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
1758
+ /** The `repository` field of `cwd`'s `package.json` as a URL string, or `null` when absent. */
1759
+ function readPackageRepositoryUrl(cwd) {
1760
+ const pkgPath = path.join(cwd, "package.json");
1761
+ let raw;
1762
+ try {
1763
+ raw = readFileSync(pkgPath, "utf-8");
1764
+ } catch {
1765
+ return null;
1766
+ }
1767
+ const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
1768
+ if (!pkg.success) return null;
1769
+ const repo = pkg.data.repository;
1770
+ if (!repo) return null;
1771
+ return typeof repo === "string" ? repo : repo.url;
1772
+ }
1773
+ //#endregion
1774
+ //#region src/connection.ts
1775
+ /**
1776
+ * The repository URL for `cwd`: the `origin` remote first, since `package.json` may advertise a
1777
+ * public mirror rather than the host the commands act on; `package.json#repository` only as a
1778
+ * last resort. `null` when neither parses.
1779
+ */
1780
+ function resolveRepoUrl(cwd) {
1781
+ const originUrl = normalizeGitUrl(readOriginUrl(cwd) ?? "");
1782
+ if (originUrl !== null && parseGitUrl(originUrl) !== null) return originUrl;
1783
+ const pkgUrl = readPackageRepositoryUrl(cwd);
1784
+ return pkgUrl !== null && parseGitUrl(pkgUrl) !== null ? pkgUrl : null;
1785
+ }
1786
+ /**
1787
+ * The Forgejo connection a command acts on. An explicit target (from an issue URL or `--repo`)
1788
+ * wins over the working directory. `githubRefusal` is the command's own sentence for a github.com
1789
+ * repository, which owes the reader the `gh` alternative. The environment settles the connection
1790
+ * before any git or `package.json` read, so CI with only the environment needs neither.
1791
+ */
1792
+ function requireForgejoTarget(cwd, target, githubRefusal, env = process.env) {
1793
+ if (target !== void 0) {
1794
+ if (target.hostname === "github.com") throw new FatalError(githubRefusal);
1795
+ const token = forgeTokenFromEnv(env);
1796
+ if (!token) throw new FatalError("bst-fj needs a forge token -- set RELEASE_TOKEN (preferred) or FORGEJO_TOKEN.");
1797
+ return {
1798
+ serverUrl: target.serverUrl,
1799
+ repository: target.repository,
1800
+ token
1801
+ };
1802
+ }
1803
+ const conn = resolveForgejoConnection(env, () => resolveRepoUrl(cwd));
1804
+ if (conn === null) throw new FatalError(githubRefusal);
1805
+ return conn;
1806
+ }
1807
+ //#endregion
1808
+ //#region ../forgejo/src/check-poll.ts
1809
+ /**
1810
+ * Commit-check classification and poll-read helpers shared by the forge-side check readers -- both
1811
+ * `pr:checks` and `commit:status` read the same `commit-status` primitive and poll it the same way;
1812
+ * only what each does with a settled result (a PR baseline vs an arbitrary ref's URL) differs. The
1813
+ * `--timeout`/`--interval` flag parsing that sets the poll cadence stays with the CLI.
1814
+ */
1815
+ function classifyBaseStatus(checks) {
1816
+ if (!checks.parsed) return "unparseable";
1817
+ if (checks.contexts.length === 0) return "no-status";
1818
+ if (checks.green) return "green";
1819
+ if (checks.contexts.some((check) => check.verdict === "pending")) return "pending";
1820
+ return "red";
1821
+ }
1822
+ /** At least one status, and no entry still pending -- the same definition `--wait` polls toward. */
1823
+ function isSettled(checks) {
1824
+ if (!checks.parsed || checks.contexts.length === 0) return false;
1825
+ return !checks.contexts.some((check) => check.verdict === "pending");
1826
+ }
1827
+ /**
1828
+ * Whether the per-entry classification disagrees with the forge's own combined reduction --
1829
+ * either direction: every entry reads `good` or `skipped` but the combined state is not
1830
+ * `success`, or the combined state is `success` while some entry reads neither. A skipped entry
1831
+ * is not a divergence -- it is a job that did not run, which carries no information about the
1832
+ * code -- so it is folded into "settled good" here the same way it is everywhere else. The
1833
+ * combined state always wins the verdict (`classifyBaseStatus` never looks at this); a caller
1834
+ * only needs to know whether to surface a notice about a genuine one.
1835
+ */
1836
+ function disagreesWithCombinedState(checks) {
1837
+ if (!checks.parsed || checks.contexts.length === 0) return false;
1838
+ const allSettledGood = checks.contexts.every((check) => check.verdict === "good" || check.verdict === "skipped");
1839
+ return checks.green !== allSettledGood;
1840
+ }
1841
+ function pushDisagreementNotice(notices, sha, checks) {
1842
+ if (!disagreesWithCombinedState(checks)) return;
1843
+ const notice = `The combined status ("${checks.state}") for ${sha} disagrees with this build's per-check classification -- the combined status wins the verdict.`;
1844
+ if (!notices.includes(notice)) notices.push(notice);
1845
+ }
1846
+ /** No commit status was read -- the sentinel a `head-moved` report (which never reads the new
1847
+ * head's checks) or an exhausted retry budget (which has no trustworthy checks left) carries. */
1848
+ const EMPTY_CHECKS = {
1849
+ parsed: false,
1850
+ state: "",
1851
+ green: false,
1852
+ contexts: []
1853
+ };
1854
+ /** One poll's commit-status read, reduced to a {@link PollRead} a retry budget can count. */
1855
+ async function readPollChecks(fetcher, conn, sha) {
1856
+ try {
1857
+ const result = await readCommitChecks(fetcher, conn, sha);
1858
+ if (result.parsed) return { checks: result };
1859
+ return { failureDetail: "The commit status response did not match the shape this build expects." };
1860
+ } catch (error) {
1861
+ return { failureDetail: describeError(error) };
1862
+ }
1863
+ }
1864
+ //#endregion
1865
+ //#region src/poll-timings.ts
1866
+ const DEFAULT_TIMEOUT_MS = 12e5;
1867
+ const DEFAULT_INTERVAL_MS = 15e3;
1868
+ const MIN_INTERVAL_MS = 5e3;
1869
+ /** The test-only override, ignored unless it is a positive integer, and never above production. */
1870
+ function envMsOverride(raw, productionMs) {
1871
+ if (raw === void 0) return productionMs;
1872
+ const parsed = Number(raw.trim());
1873
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return productionMs;
1874
+ return Math.min(parsed, productionMs);
1875
+ }
1876
+ /** `--timeout`/`--interval` in milliseconds, defaulted and floored. Separated from the wait loop
1877
+ * so a command can refuse an unparseable duration before it makes a single request. */
1878
+ function resolvePollTimings(options) {
1879
+ const timeoutMs = options.timeout !== void 0 ? parseDuration(options.timeout, "--timeout") : DEFAULT_TIMEOUT_MS;
1880
+ const requestedIntervalMs = options.interval !== void 0 ? parseDuration(options.interval, "--interval") : DEFAULT_INTERVAL_MS;
1881
+ return {
1882
+ timeoutMs,
1883
+ intervalMs: envMsOverride(process.env["BST_TEST_CHECK_POLL_INTERVAL_MS"], Math.max(requestedIntervalMs, MIN_INTERVAL_MS))
1884
+ };
1885
+ }
1886
+ //#endregion
1887
+ //#region src/sleep.ts
1888
+ /** The production delay between `--wait` polls. Tests inject their own through the runners' `sleep` option. */
1889
+ function sleep(ms) {
1890
+ return new Promise((resolve) => {
1891
+ setTimeout(resolve, ms);
1892
+ });
1893
+ }
1894
+ //#endregion
1895
+ //#region src/commit-status.ts
1896
+ /**
1897
+ * `bst-fj commit:status` -- query and report Forgejo commit status checks for an arbitrary commit
1898
+ * SHA, branch name, tag, or local git ref over `@bensandee/forgejo`'s `readCommitChecks`.
1899
+ *
1900
+ * Unlike `pr:checks` which requires an open pull request, this command queries commit check
1901
+ * contexts and the combined status reduction directly for any ref.
1902
+ */
1903
+ /**
1904
+ * Resolve `--repo`, cwd connection, and the commit ref into a `CommitStatusTarget`.
1905
+ */
1906
+ async function resolveCommitStatusTarget(fetcher, options) {
1907
+ const targetRepo = parseRepoTarget(options.repo);
1908
+ const conn = requireForgejoTarget(options.cwd, targetRepo, "commit:status is Forgejo-only: GitHub Actions publishes check runs, not commit statuses. Use `gh run list` / `gh run view` instead.");
1909
+ const rawRef = options.ref !== void 0 && options.ref.trim() !== "" ? options.ref.trim() : "HEAD";
1910
+ let resolvedSha = rawRef;
1911
+ if (!FULL_SHA.test(rawRef)) {
1912
+ if (targetRepo === void 0) {
1913
+ const git = options.git ?? defaultGitRunner;
1914
+ const symbolic = rawRef === "HEAD" ? void 0 : git([
1915
+ "rev-parse",
1916
+ "--symbolic-full-name",
1917
+ rawRef
1918
+ ], options.cwd);
1919
+ if (symbolic !== void 0 && symbolic.startsWith("refs/heads/")) resolvedSha = await forgeBranchHeadSha(fetcher, conn, rawRef) ?? rawRef;
1920
+ else {
1921
+ const rev = git(["rev-parse", `${rawRef}^{commit}`], options.cwd);
1922
+ resolvedSha = rev !== void 0 && rev.length === 40 ? rev : await forgeBranchHeadSha(fetcher, conn, rawRef) ?? rawRef;
1923
+ }
1924
+ } else resolvedSha = await forgeBranchHeadSha(fetcher, conn, rawRef) ?? rawRef;
1925
+ }
1926
+ const path = FULL_SHA.test(resolvedSha) ? "commit" : "commits";
1927
+ const url = `${conn.serverUrl}/${conn.repository}/${path}/${resolvedSha}`;
1928
+ return {
1929
+ conn,
1930
+ ref: rawRef,
1931
+ sha: resolvedSha,
1932
+ url
1933
+ };
1934
+ }
1935
+ /** A ref that is already a full commit SHA needs no resolution at all. */
1936
+ const FULL_SHA = /^[0-9a-f]{40}$/;
1937
+ /**
1938
+ * The forge's head SHA for `ref` as a branch, or `undefined` when the forge has no such branch --
1939
+ * a tag, or any other ref the commit-status API can read directly, which then passes through
1940
+ * verbatim. A server error still throws: passing through on a 5xx would silently answer about the
1941
+ * wrong commit. `ForgeAccessError` also covers 401/403, where pass-through costs nothing extra --
1942
+ * the status read that follows fails on the same credential.
1943
+ */
1944
+ async function forgeBranchHeadSha(fetcher, conn, ref) {
1945
+ try {
1946
+ return await getBranchHeadSha(fetcher, conn, ref);
1947
+ } catch (error) {
1948
+ if (error instanceof ForgeAccessError) return void 0;
1949
+ throw error;
1950
+ }
1951
+ }
1952
+ function buildReport$1(target, checks, ctx, extras = {}) {
1953
+ const { notices, waited, elapsedMs } = ctx;
1954
+ const status = extras.statusOverride ?? classifyBaseStatus(checks);
1955
+ const report = {
1956
+ sha: target.sha,
1957
+ ref: target.ref,
1958
+ repository: target.conn.repository,
1959
+ url: target.url,
1960
+ status,
1961
+ settled: isSettled(checks),
1962
+ combinedState: checks.state,
1963
+ waited,
1964
+ elapsedMs,
1965
+ checks: checks.contexts.map((check) => ({ ...check })),
1966
+ notices
1967
+ };
1968
+ if (extras.detailOverride !== void 0) report.detail = extras.detailOverride;
1969
+ else if (status === "unparseable") report.detail = "The commit status response did not match the shape this build expects.";
1970
+ return report;
1971
+ }
1972
+ async function waitForSettledChecks$1(fetcher, target, notices, poll) {
1973
+ const { timeoutMs, intervalMs, sleep, now } = poll;
1974
+ const startedAt = now();
1975
+ let consecutiveFailures = 0;
1976
+ let lastFailureDetail;
1977
+ let lastGoodChecks;
1978
+ let previousSettledContexts = null;
1979
+ for (;;) {
1980
+ const sha = target.sha;
1981
+ const { checks, failureDetail } = await readPollChecks(fetcher, target.conn, sha);
1982
+ if (failureDetail !== void 0) lastFailureDetail = failureDetail;
1983
+ if (checks === void 0) {
1984
+ consecutiveFailures += 1;
1985
+ if (consecutiveFailures >= 3) return buildReport$1(target, EMPTY_CHECKS, {
1986
+ notices,
1987
+ waited: true,
1988
+ elapsedMs: now() - startedAt
1989
+ }, {
1990
+ statusOverride: "unparseable",
1991
+ detailOverride: lastFailureDetail
1992
+ });
1993
+ } else {
1994
+ consecutiveFailures = 0;
1995
+ lastGoodChecks = checks;
1996
+ pushDisagreementNotice(notices, sha, checks);
1997
+ if (isSettled(checks)) {
1998
+ const contexts = new Set(checks.contexts.map((check) => check.context));
1999
+ if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return buildReport$1(target, checks, {
2000
+ notices,
2001
+ waited: true,
2002
+ elapsedMs: now() - startedAt
2003
+ });
2004
+ previousSettledContexts = contexts;
2005
+ } else previousSettledContexts = null;
2006
+ }
2007
+ if (now() - startedAt >= timeoutMs) return buildReport$1(target, lastGoodChecks ?? EMPTY_CHECKS, {
2008
+ notices,
2009
+ waited: true,
2010
+ elapsedMs: now() - startedAt
2011
+ }, { statusOverride: "timeout" });
2012
+ await sleep(intervalMs);
2013
+ }
2014
+ }
2015
+ /**
2016
+ * Query commit checks for the resolved target ref/SHA and produce a `CommitStatusReport`.
2017
+ */
2018
+ async function runCommitStatus(fetcher, options) {
2019
+ const timings = options.wait === true ? resolvePollTimings(options) : void 0;
2020
+ const target = await resolveCommitStatusTarget(fetcher, options);
2021
+ const notices = [];
2022
+ if (timings !== void 0) return waitForSettledChecks$1(fetcher, target, notices, {
2023
+ ...timings,
2024
+ sleep: options.sleep ?? sleep,
2025
+ now: options.now ?? Date.now
2026
+ });
2027
+ const checks = await readCommitChecks(fetcher, target.conn, target.sha);
2028
+ pushDisagreementNotice(notices, target.sha, checks);
2029
+ return buildReport$1(target, checks, {
2030
+ notices,
2031
+ waited: false,
2032
+ elapsedMs: 0
2033
+ });
2034
+ }
2035
+ /**
2036
+ * Render a `commit:status` report as human-readable text.
2037
+ */
2038
+ function renderCommitStatus(report) {
2039
+ const lines = report.checks.map((check) => {
2040
+ const parts = [check.state, check.context];
2041
+ if (check.description !== null) parts.push(check.description);
2042
+ let line = parts.join(" -- ");
2043
+ if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
2044
+ return line;
2045
+ });
2046
+ const shortSha = FULL_SHA.test(report.sha) ? report.sha.slice(0, 12) : report.sha;
2047
+ const redCount = report.checks.filter((c) => c.verdict === "red").length;
2048
+ const pendingCount = report.checks.filter((c) => c.verdict === "pending").length;
2049
+ const goodCount = report.checks.filter((c) => c.verdict === "good").length;
2050
+ const skippedCount = report.checks.filter((c) => c.verdict === "skipped").length;
2051
+ const summaryParts = [
2052
+ `commit ${shortSha}: ${report.status}`,
2053
+ `${String(redCount)} red, ${String(pendingCount)} pending, ${String(goodCount)} good, ${String(skippedCount)} skipped`,
2054
+ report.url
2055
+ ];
2056
+ lines.push(summaryParts.join(" -- "));
2057
+ return lines.join("\n");
2058
+ }
2059
+ //#endregion
2060
+ //#region src/commands/commit-status.ts
2061
+ const commitStatusCommand = defineCommand({
2062
+ meta: {
2063
+ name: "commit:status",
2064
+ description: "Query Forgejo commit status checks for an arbitrary commit SHA, branch, tag, or local git ref. Unlike pr:checks, which requires an open PR, commit:status inspects commit checks directly."
2065
+ },
2066
+ args: {
2067
+ ref: {
2068
+ type: "positional",
2069
+ description: "Commit SHA, branch, tag, or git ref (default: HEAD)",
2070
+ required: false
2071
+ },
2072
+ repo: {
2073
+ type: "string",
2074
+ description: "Repository URL (default: this repo)"
2075
+ },
2076
+ wait: {
2077
+ type: "boolean",
2078
+ description: "Poll until every check for the commit has settled"
2079
+ },
2080
+ timeout: {
2081
+ type: "string",
2082
+ description: "--wait's give-up duration, e.g. 20m (default: 20m). Checked once per poll, so a wait can run up to one --interval past it."
2083
+ },
2084
+ interval: {
2085
+ type: "string",
2086
+ description: "--wait's poll interval, e.g. 15s (default: 15s, floored at 5s)"
2087
+ },
2088
+ json: {
2089
+ type: "boolean",
2090
+ description: "Emit the machine-readable report on stdout"
2091
+ },
2092
+ verbose: {
2093
+ type: "boolean",
2094
+ description: "Emit detailed debug output"
2095
+ }
2096
+ },
2097
+ async run({ args }) {
2098
+ const out = createCliOutput(args.json === true);
2099
+ const report = await runCommitStatus(cliFetcher(), {
2100
+ cwd: process.cwd(),
2101
+ ref: args.ref,
2102
+ repo: args.repo,
2103
+ wait: args.wait === true,
2104
+ timeout: args.timeout,
2105
+ interval: args.interval
2106
+ });
2107
+ const { notices, ...payload } = report;
2108
+ for (const notice of notices) out.notice(notice);
2109
+ out.json(payload);
2110
+ out.text(renderCommitStatus(report));
2111
+ process.exitCode = report.status === "green" ? 0 : 1;
2112
+ }
2113
+ });
2114
+ //#endregion
2115
+ //#region ../forgejo/src/label-provisioning.ts
2116
+ const LabelSchema = z.object({
2117
+ id: z.number(),
2118
+ name: z.string(),
2119
+ color: z.string().optional(),
2120
+ description: z.string().optional(),
2121
+ exclusive: z.boolean().optional()
2122
+ });
2123
+ const LabelListSchema = z.array(LabelSchema);
2124
+ /** Page size for `GET .../labels`. Also the signal that another page may exist. */
2125
+ const LABELS_PAGE_SIZE = 100;
2126
+ /** Hard cap on pages fetched, so a misbehaving server can't spin this forever. */
2127
+ const LABELS_MAX_PAGES = 20;
2128
+ /**
2129
+ * Fetch every repository label, paginating until a short page (or the page
2130
+ * cap) is hit. Without pagination, a repo with more labels than one page
2131
+ * holds would silently see only the first page -- turning a real label into a
2132
+ * false "no label named X" failure (see {@link resolveLabelIds}).
2133
+ */
2134
+ async function fetchAllForgejoLabels(executor, conn) {
2135
+ const labels = [];
2136
+ for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
2137
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels?limit=${String(LABELS_PAGE_SIZE)}&page=${String(page)}`, { headers: authHeaders$1(conn) });
2138
+ if (!res.ok) await throwForgejoFailure(res, {
2139
+ verb: "list labels",
2140
+ resource: conn.repository
2141
+ }, repoNotFoundMessage(conn));
2142
+ const parsed = LabelListSchema.safeParse(await res.json());
2143
+ if (!parsed.success) throw new UnexpectedError(`Unexpected label-list response: ${parsed.error.message}`);
2144
+ labels.push(...parsed.data);
2145
+ if (parsed.data.length < LABELS_PAGE_SIZE) break;
2146
+ }
2147
+ return labels;
2148
+ }
2149
+ /** {@link AgentLabelSpec} as a zod schema, so a hand-written spec fails at load rather than at the
2150
+ * first API call. Strict: an unknown field is a typo, not an extension point. */
2151
+ const LabelSpecSchema = z.strictObject({
2152
+ name: z.string().min(1),
2153
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
2154
+ description: z.string(),
2155
+ exclusive: z.boolean().optional()
2156
+ });
2157
+ /** Named label sets: each set name maps to the specs it provisions. */
2158
+ const LabelSetsSchema = z.record(z.string(), z.array(LabelSpecSchema));
2159
+ /** Forgejo echoes label color without a leading '#'; the spec writes one. Normalize before comparing. */
2160
+ function normalizeColor(color) {
2161
+ return color.replace(/^#/, "").toLowerCase();
2162
+ }
2163
+ /** True when an existing label's color or description differs from what the spec asks for. Exclusivity is judged separately (see {@link auditForgejoLabels}) because it is a gate condition, not cosmetic. */
2164
+ function hasCosmeticDrift(existing, spec) {
2165
+ return normalizeColor(existing.color ?? "") !== normalizeColor(spec.color) || (existing.description ?? "") !== spec.description;
2166
+ }
2167
+ /** True when a spec asks for `exclusive: true` and the live label does not carry it -- the one direction
2168
+ * that produces the silent wrong answer `labelDrifted` used to repair as a side effect (two `agent/`
2169
+ * labels can coexist, and `stageOf` returns whichever the forge lists first). */
2170
+ function isNonExclusive(existing, spec) {
2171
+ return (spec.exclusive ?? false) && !(existing.exclusive ?? false);
2172
+ }
2173
+ /** True when the live label's exclusivity differs from the spec's in either direction -- what
2174
+ * `ensureForgejoLabels` reconciles. `isNonExclusive` stays one-directional: it is the gate
2175
+ * condition `auditRepoLabels` reads, and a spec asking for `exclusive: false` was never a gate
2176
+ * failure. */
2177
+ function isExclusivityDrifted(existing, spec) {
2178
+ return (existing.exclusive ?? false) !== (spec.exclusive ?? false);
2179
+ }
2180
+ /**
2181
+ * Audit a repo's labels against `specs` with a single list fetch and no write -- the read half of
2182
+ * what `ensureForgejoLabels` used to do in one pass. Returns the name->id map for every existing
2183
+ * label (so a caller keeps the fetch-avoidance `ensureForgejoLabels` callers relied on) plus four
2184
+ * name lists: labels missing entirely, labels present but not exclusive when the spec asks for it,
2185
+ * labels whose exclusivity drifted in either direction, and labels with cosmetic (color/description)
2186
+ * drift alone. A missing label and a non-exclusive one are both gate conditions; exclusivity drift
2187
+ * and cosmetic drift are reported but never block.
2188
+ */
2189
+ async function auditForgejoLabels(executor, conn, specs) {
2190
+ const existing = await fetchAllForgejoLabels(executor, conn);
2191
+ const byName = new Map(existing.map((label) => [label.name, label]));
2192
+ const labelIds = new Map(existing.map((label) => [label.name, label.id]));
2193
+ const missing = [];
2194
+ const nonExclusive = [];
2195
+ const exclusivityDrift = [];
2196
+ const cosmeticDrift = [];
2197
+ for (const spec of specs) {
2198
+ const current = byName.get(spec.name);
2199
+ if (current === void 0) {
2200
+ missing.push(spec.name);
2201
+ continue;
2202
+ }
2203
+ if (isNonExclusive(current, spec)) nonExclusive.push(spec.name);
2204
+ if (isExclusivityDrifted(current, spec)) exclusivityDrift.push(spec.name);
2205
+ if (hasCosmeticDrift(current, spec)) cosmeticDrift.push(spec.name);
2206
+ }
2207
+ return {
2208
+ labelIds,
2209
+ missing,
2210
+ nonExclusive,
2211
+ exclusivityDrift,
2212
+ cosmeticDrift
2213
+ };
2214
+ }
2215
+ /** The `<scope>/` portion of a label name, or `null` for a leaf-only name like `bug`. */
2216
+ function labelScope(name) {
2217
+ const slash = name.indexOf("/");
2218
+ return slash === -1 ? null : name.slice(0, slash + 1);
2219
+ }
2220
+ /**
2221
+ * The live labels a set has left behind: present on the repo, carrying a scope the set owns, and
2222
+ * not a member of the set. This is the drift {@link auditForgejoLabels} structurally cannot report
2223
+ * -- the audit is a fact about exactly the specs it is given, so a name deleted from a set is
2224
+ * absent from both the specs and the audit, and nothing notices it is still on the repo.
2225
+ *
2226
+ * Ownership is derived from the specs rather than declared, so a label set needs no new key: a set
2227
+ * owns a scope when one of its own members carries it. A leaf-only name (`bug`) owns nothing and
2228
+ * falls under nothing, which is what keeps a repo's ordinary labels out of the report in both
2229
+ * directions.
2230
+ *
2231
+ * Deliberately not a field on {@link LabelAudit}: the agent audits against its *required* subset,
2232
+ * so a staleness computed from the audit's own specs would report every ensured-but-not-required
2233
+ * label as a stray. A caller passes the full set it provisions from.
2234
+ */
2235
+ function staleLabelNames(specs, live) {
2236
+ const owned = /* @__PURE__ */ new Set();
2237
+ for (const spec of specs) {
2238
+ const scope = labelScope(spec.name);
2239
+ if (scope !== null) owned.add(scope);
2240
+ }
2241
+ const wanted = new Set(specs.map((spec) => spec.name));
2242
+ const stale = [];
2243
+ for (const name of live) {
2244
+ if (wanted.has(name)) continue;
2245
+ const scope = labelScope(name);
2246
+ if (scope !== null && owned.has(scope)) stale.push(name);
2247
+ }
2248
+ stale.sort((a, b) => a.localeCompare(b));
2249
+ return stale;
2250
+ }
2251
+ /**
2252
+ * Ensure every label in `specs` exists on the repo with the spec's color and description,
2253
+ * creating any that are missing and PATCHing any that {@link auditForgejoLabels} reports as
2254
+ * non-exclusive or cosmetically drifted. An ensure-loop like `ensureRelease`: instance label
2255
+ * templates only apply at repo creation, so a repo the agent has never touched has none of these.
2256
+ * Implemented over the audit so the two can never disagree about what "drifted" means.
2257
+ *
2258
+ * Reconciling existing labels (not just creating missing ones) is what backfills descriptions
2259
+ * onto labels created before they carried one -- the create-only version left every already-present
2260
+ * label with whatever description it happened to have, so a repo the agent had touched never
2261
+ * gained the self-documenting text.
2262
+ *
2263
+ * Returns the name->id map the audit already computed (existing labels plus any just created), so a
2264
+ * caller that goes on to mutate labels for the same repo (the agent's claim) can thread it
2265
+ * through `addForgejoIssueLabel` / `removeForgejoIssueLabel` and skip their own redundant list
2266
+ * fetch.
2267
+ */
2268
+ async function ensureForgejoLabels(executor, conn, specs) {
2269
+ const audit = await auditForgejoLabels(executor, conn, specs);
2270
+ const labelIds = new Map(audit.labelIds);
2271
+ const missing = new Set(audit.missing);
2272
+ const drifted = /* @__PURE__ */ new Set([...audit.exclusivityDrift, ...audit.cosmeticDrift]);
2273
+ const labelsBase = `${conn.serverUrl}/api/v1/repos/${conn.repository}/labels`;
2274
+ for (const spec of specs) {
2275
+ if (missing.has(spec.name)) {
2276
+ const res = await executor.fetch(labelsBase, {
2277
+ method: "POST",
2278
+ headers: jsonHeaders(conn),
2279
+ body: JSON.stringify({
2280
+ name: spec.name,
2281
+ color: spec.color,
2282
+ description: spec.description,
2283
+ exclusive: spec.exclusive ?? false
2284
+ })
2285
+ });
2286
+ if (!res.ok) await throwForgejoFailure(res, {
2287
+ verb: "create label",
2288
+ resource: spec.name
2289
+ }, repoNotFoundMessage(conn));
2290
+ const parsed = LabelSchema.safeParse(await res.json());
2291
+ if (!parsed.success) throw new UnexpectedError(`Unexpected label-create response: ${parsed.error.message}`);
2292
+ labelIds.set(spec.name, parsed.data.id);
2293
+ continue;
2294
+ }
2295
+ if (drifted.has(spec.name)) {
2296
+ const id = labelIds.get(spec.name);
2297
+ if (id === void 0) throw new UnexpectedError(`Audit reported drift for "${spec.name}" with no known id`);
2298
+ const res = await executor.fetch(`${labelsBase}/${String(id)}`, {
2299
+ method: "PATCH",
2300
+ headers: jsonHeaders(conn),
2301
+ body: JSON.stringify({
2302
+ name: spec.name,
2303
+ color: spec.color,
2304
+ description: spec.description,
2305
+ exclusive: spec.exclusive ?? false
2306
+ })
2307
+ });
2308
+ if (!res.ok) await throwForgejoFailure(res, {
2309
+ verb: "update label",
2310
+ resource: spec.name
2311
+ }, repoNotFoundMessage(conn));
2312
+ }
2313
+ }
2314
+ return labelIds;
2315
+ }
2316
+ /**
2317
+ * Delete a repository label definition by id -- the write half of `--prune`: a label the
2318
+ * caller has already determined no longer belongs to a set's declared universe. Idempotent, like
2319
+ * `removeForgejoIssueLabel`: Forgejo returns 204 whether or not the label still exists, so a
2320
+ * retry after a transient blip is safe.
2321
+ */
2322
+ async function deleteForgejoLabel(executor, conn, id) {
2323
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels/${String(id)}`, {
2324
+ method: "DELETE",
2325
+ headers: authHeaders$1(conn)
2326
+ }, { idempotent: true });
2327
+ if (!res.ok) await throwForgejoFailure(res, {
2328
+ verb: "delete label",
2329
+ resource: String(id)
2330
+ }, repoNotFoundMessage(conn));
2331
+ }
2332
+ //#endregion
2333
+ //#region ../forgejo/src/issues.ts
2334
+ /**
2335
+ * Timestamps are plain strings on purpose: Forgejo returns offset-form
2336
+ * (`2026-07-13T09:23:13-05:00`), which `z.string().datetime()` rejects.
2337
+ * Unknown keys are stripped rather than rejected, so upstream additions
2338
+ * cannot break parsing.
2339
+ */
2340
+ const IssueResponseSchema = z.object({
2341
+ number: z.number(),
2342
+ title: z.string(),
2343
+ state: z.string(),
2344
+ html_url: z.string(),
2345
+ body: z.string().nullish(),
2346
+ user: z.object({ login: z.string() }).nullish(),
2347
+ labels: z.array(z.object({ name: z.string() })).nullish(),
2348
+ assignees: z.array(z.object({ login: z.string() })).nullish(),
2349
+ created_at: z.string(),
2350
+ updated_at: z.string(),
2351
+ closed_at: z.string().nullish(),
2352
+ pull_request: z.unknown().nullish(),
2353
+ /**
2354
+ * Already inline on the issue response -- no separate fetch is needed to
2355
+ * surface attachments. `nullish()` because the field may be absent or
2356
+ * explicitly `null` depending on the endpoint.
2357
+ */
2358
+ assets: z.array(AttachmentSchema).nullish()
2359
+ });
2360
+ z.array(IssueResponseSchema);
2361
+ const DependencyResponseSchema = z.object({
2362
+ number: z.number(),
2363
+ state: z.string(),
2364
+ repository: z.object({ full_name: z.string() }).nullish()
2365
+ });
2366
+ z.array(DependencyResponseSchema);
2367
+ /** Page size for `GET /user/repos`. Also the "another page may exist" signal. */
2368
+ const USER_REPOS_PAGE_SIZE = 50;
2369
+ /** Hard cap on pages, so a misbehaving server cannot spin startup forever. */
2370
+ const USER_REPOS_MAX_PAGES = 20;
2371
+ const UserRepoSchema = z.object({
2372
+ full_name: z.string(),
2373
+ default_branch: z.string().nullish(),
2374
+ archived: z.boolean().nullish(),
2375
+ permissions: z.object({
2376
+ push: z.boolean().optional(),
2377
+ admin: z.boolean().optional()
2378
+ }).nullish()
2379
+ });
2380
+ const UserRepoListSchema = z.array(UserRepoSchema);
2381
+ /**
2382
+ * Every repo the bot (token) can access, via Forgejo's `/user/repos`, each tagged with whether the
2383
+ * bot has write (push or admin) access. This is the agent's opt-in signal for creating the agent
2384
+ * label vocabulary: inviting the bot as a collaborator is the grant, and membership exists the
2385
+ * moment it is invited -- before any label does. `canWrite` is false for a read-only repo; the
2386
+ * caller enumerates it but writes no labels there. Paginates until an empty page: Forgejo clamps
2387
+ * `limit` (ToCorrectPageSize), so a short page is not a reliable end signal -- same reason as
2388
+ * {@link searchForgejoIssues}.
2389
+ */
2390
+ async function fetchAccessibleRepos(executor, serverUrl, token) {
2391
+ const headers = { Authorization: `token ${token}` };
2392
+ const repos = [];
2393
+ for (let page = 1; page <= USER_REPOS_MAX_PAGES; page++) {
2394
+ const params = new URLSearchParams({
2395
+ limit: String(USER_REPOS_PAGE_SIZE),
2396
+ page: String(page)
2397
+ });
2398
+ const res = await executor.fetch(`${serverUrl}/api/v1/user/repos?${params.toString()}`, { headers });
2399
+ if (!res.ok) await throwForgejoFailure(res, {
2400
+ verb: "list repositories",
2401
+ resource: serverUrl
2402
+ }, `Repository listing failed on ${serverUrl} (or the token cannot see any repositories)`);
2403
+ const parsed = UserRepoListSchema.safeParse(await res.json());
2404
+ if (!parsed.success) throw new UnexpectedError(`Unexpected user-repos response: ${parsed.error.message}`);
2405
+ for (const repo of parsed.data) repos.push({
2406
+ fullName: repo.full_name,
2407
+ canWrite: repo.permissions?.push === true || repo.permissions?.admin === true,
2408
+ defaultBranch: repo.default_branch ?? null,
2409
+ archived: repo.archived === true
2410
+ });
2411
+ if (parsed.data.length === 0) break;
2412
+ if (page === USER_REPOS_MAX_PAGES) log.warn(`Repository listing on ${serverUrl} hit the ${String(USER_REPOS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
2413
+ }
2414
+ return repos;
2415
+ }
2416
+ //#endregion
2417
+ //#region ../forgejo/src/label-sync.ts
2418
+ /** `--check` wins over `--dry-run`; neither means apply. */
2419
+ function resolveSyncMode(check, dryRun) {
2420
+ if (check) return "check";
2421
+ if (dryRun) return "dry-run";
2422
+ return "apply";
2423
+ }
2424
+ /** A `--repo owner/repo` target shares the current repo's server and token -- only the
2425
+ * repository portion changes, the same convention `--fleet` uses. */
2426
+ function targetConnection(base, repo) {
2427
+ if (repo === void 0) return base;
2428
+ return {
2429
+ ...base,
2430
+ repository: repo
2431
+ };
2432
+ }
2433
+ /** Every repo the invoking token can write to, discovered live -- no configured repo list.
2434
+ * The server URL and token come from the current repo's connection; only the repository portion
2435
+ * varies per target, the same convention `--repo` uses. */
2436
+ async function resolveFleetTargets(fetcher, base) {
2437
+ return (await fetchAccessibleRepos(fetcher, base.serverUrl, base.token)).filter((repo) => repo.canWrite).map((repo) => targetConnection(base, repo.fullName));
2438
+ }
2439
+ function outcomeFromAudit(conn, specs, audit, lists) {
2440
+ const missing = new Set(audit.missing);
2441
+ const drifted = /* @__PURE__ */ new Set([...audit.exclusivityDrift, ...audit.cosmeticDrift]);
2442
+ const unchanged = specs.map((spec) => spec.name).filter((name) => !missing.has(name) && !drifted.has(name));
2443
+ return {
2444
+ repository: conn.repository,
2445
+ created: audit.missing,
2446
+ updated: [...drifted],
2447
+ unchanged,
2448
+ ...lists
2449
+ };
2450
+ }
2451
+ async function syncOneRepo(params) {
2452
+ const { fetcher, conn, specs, mode, prune, allowedPruneNames, resolveTargetSpecs } = params;
2453
+ const target = resolveTargetSpecs === void 0 ? {
2454
+ specs,
2455
+ omitted: [],
2456
+ aliasNames: []
2457
+ } : await resolveTargetSpecs(conn, specs);
2458
+ const audit = await auditForgejoLabels(fetcher, conn, target.specs);
2459
+ const stale = staleLabelNames(specs, audit.labelIds.keys()).filter((name) => !target.aliasNames.includes(name));
2460
+ const pruneCandidates = prune ? [...target.omitted, ...stale].filter((name) => audit.labelIds.has(name) && (allowedPruneNames === void 0 || allowedPruneNames.includes(name))) : [];
2461
+ const outcome = outcomeFromAudit(conn, target.specs, audit, {
2462
+ omitted: target.omitted,
2463
+ pruned: pruneCandidates,
2464
+ stale: prune ? stale.filter((name) => !pruneCandidates.includes(name)) : stale
2465
+ });
2466
+ if (mode === "apply") {
2467
+ if (outcome.created.length > 0 || outcome.updated.length > 0) await ensureForgejoLabels(fetcher, conn, target.specs);
2468
+ for (const name of pruneCandidates) {
2469
+ const id = audit.labelIds.get(name);
2470
+ if (id === void 0) throw new UnexpectedError(`Prune candidate "${name}" had no known id after the audit`);
2471
+ await deleteForgejoLabel(fetcher, conn, id);
2472
+ }
2473
+ }
2474
+ return outcome;
2475
+ }
2476
+ /**
2477
+ * Ensure (or, under `check`/`dry-run`, only report) that every target repo carries `specs`.
2478
+ * Never touches a label outside `specs` -- both `auditForgejoLabels` and `ensureForgejoLabels`
2479
+ * only ever look at the names they are given. `apply` re-audits inside `ensureForgejoLabels`
2480
+ * rather than threading the first audit's label-id map through: one extra idempotent GET per
2481
+ * repo, traded for not reaching into `ensureForgejoLabels`'s internals from the caller.
2482
+ *
2483
+ * `contained` governs failure handling only: by default a throw from one repo's sync propagates
2484
+ * and stops the run. With `contained: true` one repo's failure is recorded in `failures` and the
2485
+ * sweep continues over the rest.
2486
+ */
2487
+ async function syncLabelSet(params) {
2488
+ const { fetcher, targets, specs, setName, mode, contained, prune, pruneAllowlist } = params;
2489
+ const specList = [...specs];
2490
+ const repos = [];
2491
+ const failures = [];
2492
+ for (const conn of targets) {
2493
+ const allowedPruneNames = pruneAllowlist?.get(conn.repository);
2494
+ const syncOneRepoParams = {
2495
+ fetcher,
2496
+ conn,
2497
+ specs: specList,
2498
+ mode,
2499
+ prune: prune === true,
2500
+ allowedPruneNames,
2501
+ resolveTargetSpecs: params.resolveTargetSpecs
2502
+ };
2503
+ if (contained !== true) {
2504
+ repos.push(await syncOneRepo(syncOneRepoParams));
2505
+ continue;
2506
+ }
2507
+ try {
2508
+ repos.push(await syncOneRepo(syncOneRepoParams));
2509
+ } catch (error) {
2510
+ failures.push({
2511
+ repository: conn.repository,
2512
+ error: describeError(error)
2513
+ });
2514
+ }
2515
+ }
2516
+ return {
2517
+ set: setName,
2518
+ mode,
2519
+ repos,
2520
+ failures,
2521
+ drifted: repos.some((repo) => repo.created.length > 0 || repo.updated.length > 0 || repo.pruned.length > 0)
2522
+ };
2523
+ }
2524
+ function syncSummaryLines(result) {
2525
+ const verb = result.mode === "apply" ? "" : "would ";
2526
+ const repoLines = [];
2527
+ for (const repo of result.repos) {
2528
+ const actions = [];
2529
+ if (repo.created.length > 0) actions.push(`${verb}create: ${repo.created.join(", ")}`);
2530
+ if (repo.updated.length > 0) actions.push(`${verb}update: ${repo.updated.join(", ")}`);
2531
+ if (repo.omitted.length > 0) actions.push(`omitted (repo declares its own priority vocabulary): ${repo.omitted.join(", ")}`);
2532
+ if (repo.pruned.length > 0) actions.push(`${verb}prune: ${repo.pruned.join(", ")}`);
2533
+ if (repo.stale.length > 0) actions.push(`stale (present, not in this set): ${repo.stale.join(", ")}`);
2534
+ if (actions.length === 0) {
2535
+ repoLines.push(`${repo.repository}: up to date (${String(repo.unchanged.length)} unchanged)`);
2536
+ continue;
2537
+ }
2538
+ repoLines.push(`${repo.repository}: ${actions.join("; ")}`);
2539
+ }
2540
+ return {
2541
+ repoLines,
2542
+ failureLines: result.failures.map((failure) => `${failure.repository}: failed: ${failure.error}`)
2543
+ };
2544
+ }
2545
+ //#endregion
2546
+ //#region src/label-sets-file.ts
2547
+ /**
2548
+ * The specs of one named set in a label-sets file: a JSON object whose keys are set names and
2549
+ * whose values are arrays of label specs. Every failure names the file, and all of them happen
2550
+ * before any request, so a bad file never leaves a repo half-synced.
2551
+ */
2552
+ function readLabelSet(path, setName) {
2553
+ let raw;
2554
+ try {
2555
+ raw = readFileSync(path, "utf-8");
2556
+ } catch (error) {
2557
+ throw new FatalError(`Cannot read label-sets file ${path}: ${describeError(error)}`);
2558
+ }
2559
+ let data;
2560
+ try {
2561
+ data = JSON.parse(raw);
2562
+ } catch (error) {
2563
+ throw new FatalError(`Label-sets file ${path} is not valid JSON: ${describeError(error)}`);
2564
+ }
2565
+ const parsed = LabelSetsSchema.safeParse(data);
2566
+ if (!parsed.success) {
2567
+ const issue = parsed.error.issues[0];
2568
+ const where = issue === void 0 || issue.path.length === 0 ? "(root)" : issue.path.join(".");
2569
+ throw new FatalError(`Label-sets file ${path} is invalid at ${where}: ${issue?.message ?? "unknown error"}`);
2570
+ }
2571
+ const specs = parsed.data[setName];
2572
+ if (specs === void 0) {
2573
+ const known = Object.keys(parsed.data).toSorted().join(", ") || "(none)";
2574
+ throw new FatalError(`Unknown label set "${setName}" in ${path}. Known sets: ${known}`);
2575
+ }
2576
+ return specs;
2577
+ }
2578
+ //#endregion
2579
+ //#region src/labels.ts
2580
+ async function runLabelsList(fetcher, conn) {
2581
+ const labels = await fetchAllForgejoLabels(fetcher, conn);
2582
+ return {
2583
+ repository: conn.repository,
2584
+ labels
2585
+ };
2586
+ }
2587
+ function renderList(result) {
2588
+ if (result.labels.length === 0) {
2589
+ rootLogger.info(`No labels found in ${result.repository}`);
2590
+ return;
2591
+ }
2592
+ for (const label of result.labels.toSorted((a, b) => a.name.localeCompare(b.name))) {
2593
+ const exclusive = label.exclusive === true ? " [exclusive]" : "";
2594
+ rootLogger.info(`${label.name}${exclusive} #${label.color ?? ""} ${label.description ?? ""}`);
2595
+ }
2596
+ }
2597
+ async function runLabelsSync(params) {
2598
+ const { fetcher, targets, specs, setName, mode, contained } = params;
2599
+ return syncLabelSet({
2600
+ fetcher,
2601
+ targets,
2602
+ specs,
2603
+ setName,
2604
+ mode,
2605
+ contained
2606
+ });
2607
+ }
2608
+ function renderSync(result) {
2609
+ const { repoLines, failureLines } = syncSummaryLines(result);
2610
+ for (const line of repoLines) rootLogger.info(line);
2611
+ for (const line of failureLines) rootLogger.warn(line);
2612
+ }
2613
+ //#endregion
2614
+ //#region src/commands/labels.ts
2615
+ const GITHUB_REFUSAL = "bst-fj labels requires a Forgejo repository";
2616
+ const listCommand$1 = defineCommand({
2617
+ meta: {
2618
+ name: "list",
2619
+ description: "List a repository's existing labels"
2620
+ },
2621
+ args: {
2622
+ repo: {
2623
+ type: "string",
2624
+ description: "Target owner/repo (default: this repo)"
2625
+ },
2626
+ json: {
2627
+ type: "boolean",
2628
+ description: "Emit results as JSON instead of a human-readable list"
2629
+ }
2630
+ },
2631
+ async run({ args, rawArgs }) {
2632
+ const base = requireForgejoTarget(process.cwd(), void 0, GITHUB_REFUSAL);
2633
+ const repoNames = parseListFlag(rawArgs, "repo");
2634
+ if (repoNames.length === 0 && listFlagPresent(rawArgs, "repo")) throw new FatalError("--repo was given with no repository name (expected e.g. owner/repo). Refusing rather than falling back to the current repo.");
2635
+ const conn = targetConnection(base, repoNames.at(-1));
2636
+ const result = await runLabelsList(cliFetcher(), conn);
2637
+ if (args.json === true) {
2638
+ createCliOutput(true).json(result);
2639
+ return;
2640
+ }
2641
+ renderList(result);
2642
+ }
2643
+ });
2644
+ const syncCommand = defineCommand({
2645
+ meta: {
2646
+ name: "sync",
2647
+ description: "Create/update a repo's labels from a named set in a label-sets file (never deletes a label)"
2648
+ },
2649
+ args: {
2650
+ file: {
2651
+ type: "string",
2652
+ required: true,
2653
+ description: "JSON file mapping each set name to an array of label specs"
2654
+ },
2655
+ set: {
2656
+ type: "string",
2657
+ required: true,
2658
+ description: "Label set name, a key of --file"
2659
+ },
2660
+ repo: {
2661
+ type: "string",
2662
+ description: "Target owner/repo. Repeatable (unions); default: this repo"
2663
+ },
2664
+ fleet: {
2665
+ type: "boolean",
2666
+ description: "Target every repo the token can write to, discovered live. Mutually exclusive with --repo"
2667
+ },
2668
+ check: {
2669
+ type: "boolean",
2670
+ description: "Report drift without writing; exits 1 if any label is missing or drifted"
2671
+ },
2672
+ "dry-run": {
2673
+ type: "boolean",
2674
+ description: "Report what would change without writing"
2675
+ },
2676
+ json: {
2677
+ type: "boolean",
2678
+ description: "Emit results as JSON instead of a human-readable summary"
2679
+ }
2680
+ },
2681
+ async run({ args, rawArgs }) {
2682
+ const base = requireForgejoTarget(process.cwd(), void 0, GITHUB_REFUSAL);
2683
+ const repoNames = parseListFlag(rawArgs, "repo");
2684
+ if (repoNames.length === 0 && listFlagPresent(rawArgs, "repo")) throw new FatalError("--repo was given with no repository name (expected e.g. owner/repo). Refusing rather than falling back to the current repo.");
2685
+ const fleet = args.fleet === true;
2686
+ if (fleet && repoNames.length > 0) throw new FatalError("--repo and --fleet are mutually exclusive");
2687
+ const specs = readLabelSet(args.file, args.set);
2688
+ const fetcher = cliFetcher();
2689
+ let targets;
2690
+ if (fleet) targets = await resolveFleetTargets(fetcher, base);
2691
+ else if (repoNames.length > 0) targets = repoNames.map((repo) => targetConnection(base, repo));
2692
+ else targets = [base];
2693
+ const mode = resolveSyncMode(args.check === true, args["dry-run"] === true);
2694
+ const result = await runLabelsSync({
2695
+ fetcher,
2696
+ targets,
2697
+ specs,
2698
+ setName: args.set,
2699
+ mode,
2700
+ contained: fleet
2701
+ });
2702
+ if (args.json === true) createCliOutput(true).json(result);
2703
+ else renderSync(result);
2704
+ if (mode === "check" && result.drifted || result.failures.length > 0) process.exitCode = 1;
2705
+ }
2706
+ });
2707
+ const labelsCommand = defineCommand({
2708
+ meta: {
2709
+ name: "labels",
2710
+ description: "Provision and audit a repository's label definitions from a named label set (sync, list)"
2711
+ },
2712
+ args: { json: {
2713
+ type: "boolean",
2714
+ description: "Emit results as JSON instead of a human-readable summary"
2715
+ } },
2716
+ subCommands: {
2717
+ list: listCommand$1,
2718
+ sync: syncCommand
2719
+ }
2720
+ });
2721
+ z.object({
2722
+ number: z.number(),
2723
+ html_url: z.string().optional()
2724
+ });
2725
+ /** Compose a PR's web URL from the connection coordinates. */
2726
+ function prWebUrl(conn, prNumber) {
2727
+ return `${conn.serverUrl}/${conn.repository}/pulls/${String(prNumber)}`;
2728
+ }
2729
+ /** Requested page size. Forgejo clamps it, so a short page is NOT an end signal -- only an empty
2730
+ * one is (same reasoning as `searchForgejoIssues`). */
2731
+ const PULLS_PAGE_SIZE = 50;
2732
+ /** Hard cap on pages, so a misbehaving server cannot spin a caller forever. */
2733
+ const PULLS_MAX_PAGES = 20;
2734
+ /**
2735
+ * Find the open PR with the given head branch -- its number and its base ref -- or null.
2736
+ *
2737
+ * Filters client-side by head.ref rather than relying on Forgejo's query parameter filtering,
2738
+ * which behaves inconsistently. `base` is required on the wire: the endpoint serves full
2739
+ * pull-request objects (the same shape {@link getPrRefs} reads off `/pulls/<n>`), and a caller
2740
+ * reporting a PR's base must fail loudly rather than fall back to a locally guessed one.
2741
+ *
2742
+ * Paged to an empty page or to {@link PULLS_MAX_PAGES}, sharing both constants with
2743
+ * {@link listPulls} rather than trusting the server's default page size. The walk ends on an
2744
+ * empty page rather than a short one because Forgejo clamps `limit`, so a short page may be the
2745
+ * clamp rather than the end (same reasoning `listPulls` and `searchForgejoIssues` already
2746
+ * record). Exhausting the cap throws rather than returning `null`: this function's answer is a
2747
+ * yes/no, so a truncated read does not degrade it, it inverts it into precisely the value that
2748
+ * makes `pr:create` and `openWipPr` write a duplicate to the forge -- unlike `listPulls`, whose
2749
+ * caller reads a whole set and can tolerate a truncated tail.
2750
+ */
2751
+ async function findOpenPrEntry(executor, conn, head, signal = null) {
2752
+ let capped = false;
2753
+ const match = (await listPulls(executor, conn, {
2754
+ state: "open",
2755
+ stopWhen: (pr) => pr.headRef === head,
2756
+ onPageCap: () => {
2757
+ capped = true;
2758
+ },
2759
+ signal
2760
+ })).find((pr) => pr.headRef === head);
2761
+ if (match !== void 0) {
2762
+ if (match.baseRef === null) throw new UnexpectedError(`Open PR #${String(match.number)} on ${conn.repository} carries no base branch`);
2763
+ return {
2764
+ number: match.number,
2765
+ base: match.baseRef
2766
+ };
2767
+ }
2768
+ if (capped) throw new FatalError(`Open PR lookup on ${conn.repository} for head "${head}" hit the ${String(PULLS_MAX_PAGES)}-page cap without finding it or reaching an empty page; a truncated read here reads as "no open PR" and makes a caller create a duplicate`);
2769
+ return null;
2770
+ }
2771
+ /**
2772
+ * The open PR number for a head branch, or null. A wrapper over {@link findOpenPrEntry}: most
2773
+ * callers only ask whether a PR exists, and one fetch with one schema serves both questions.
2774
+ */
2775
+ async function findOpenPr(executor, conn, head, signal = null) {
2776
+ return (await findOpenPrEntry(executor, conn, head, signal))?.number ?? null;
2777
+ }
2778
+ const PullListItemSchema = z.object({
2779
+ number: z.number(),
2780
+ title: z.string().nullish(),
2781
+ head: z.object({ ref: z.string() }),
2782
+ base: z.object({ ref: z.string() }).nullish(),
2783
+ state: z.enum(["open", "closed"]),
2784
+ merged: z.boolean().nullish(),
2785
+ merged_at: z.string().nullish(),
2786
+ html_url: z.string().nullish(),
2787
+ draft: z.boolean().nullish(),
2788
+ user: z.object({ login: z.string().nullish() }).nullish(),
2789
+ updated_at: z.string().nullish(),
2790
+ labels: z.array(z.object({ name: z.string() })).nullish()
2791
+ });
2792
+ /**
2793
+ * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
2794
+ * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
2795
+ * {@link PULLS_MAX_PAGES}, whichever comes first.
2796
+ *
2797
+ * The one paged PR listing: `branch:prune` reads it to completion because it decides over the whole
2798
+ * set, and the agent agent passes `stopWhen` to answer "does a pull request already exist on this
2799
+ * head, in any state" at the cost of one page in the common case (Forgejo lists recently touched
2800
+ * pull requests first). That existence question is the reason this exists alongside
2801
+ * {@link findOpenPr}: a caller deciding whether to CREATE must not ask an open-only question, since
2802
+ * a closed pull request is still one that exists and creating over it mints a fresh one on every
2803
+ * re-entry.
2804
+ *
2805
+ * Hitting the page cap **warns** rather than returning quietly. A silently truncated read is
2806
+ * indistinguishable from "no such pull request", which is precisely the answer that makes a caller
2807
+ * create a duplicate.
2808
+ */
2809
+ async function listPulls(executor, conn, options = {}) {
2810
+ const headers = { Authorization: `token ${conn.token}` };
2811
+ const pulls = [];
2812
+ for (let page = 1; page <= PULLS_MAX_PAGES; page++) {
2813
+ options.signal?.throwIfAborted();
2814
+ const params = new URLSearchParams({
2815
+ state: options.state ?? "all",
2816
+ limit: String(PULLS_PAGE_SIZE),
2817
+ page: String(page)
2818
+ });
2819
+ if (options.sort !== void 0) params.set("sort", options.sort);
2820
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
2821
+ const res = await executor.fetch(url, {
2822
+ headers,
2823
+ signal: options.signal ?? null
2824
+ });
2825
+ if (!res.ok) await throwForgejoFailure(res, {
2826
+ verb: "list PRs",
2827
+ resource: conn.repository
2828
+ });
2829
+ const parsed = PullListItemSchema.array().safeParse(await res.json());
2830
+ if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
2831
+ const items = parsed.data.map((pr) => ({
2832
+ number: pr.number,
2833
+ title: pr.title ?? null,
2834
+ headRef: pr.head.ref,
2835
+ baseRef: pr.base?.ref ?? null,
2836
+ state: pr.state,
2837
+ merged: pr.merged ?? pr.merged_at != null,
2838
+ mergedAt: pr.merged_at ?? null,
2839
+ url: pr.html_url ?? null,
2840
+ draft: pr.draft ?? null,
2841
+ author: pr.user?.login ?? null,
2842
+ updatedAt: pr.updated_at ?? null,
2843
+ labels: (pr.labels ?? []).map((label) => label.name)
2844
+ }));
2845
+ pulls.push(...items);
2846
+ if (items.length === 0) break;
2847
+ if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
2848
+ if (options.limit !== void 0 && pulls.length >= options.limit) break;
2849
+ if (page === PULLS_MAX_PAGES) {
2850
+ log.warn(`PR listing on ${conn.repository} hit the ${String(PULLS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
2851
+ options.onPageCap?.();
2852
+ }
2853
+ }
2854
+ return pulls;
2855
+ }
2856
+ z.object({ body: z.string().nullish() });
2857
+ /**
2858
+ * Read one projection of `.../pulls/<n>` -- the shared body of every single-PR GET below.
2859
+ *
2860
+ * They all issue the identical request and differ only in which slice of the payload they need,
2861
+ * so the schema and a `what` noun are the parameters; `what` spells both the failure verb
2862
+ * ("read PR head") and the schema-mismatch message ("Unexpected PR head response"), which keeps
2863
+ * an error naming the field the caller actually asked for. Reading the whole payload and picking
2864
+ * fields off it in the caller is the tempting alternative and is worse: a narrow schema is what
2865
+ * makes a forge that stops serving a field fail loudly here, rather than surfacing `undefined`
2866
+ * somewhere further out.
2867
+ *
2868
+ * Note this is the *pulls* endpoint, which is not interchangeable with the issues endpoint
2869
+ * `getPrBody` uses -- only this one carries `head`, `base`, and `draft`.
2870
+ */
2871
+ async function readPrField(executor, conn, prNumber, schema, what) {
2872
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
2873
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
2874
+ if (!res.ok) await throwForgejoFailure(res, {
2875
+ verb: `read PR ${what}`,
2876
+ resource: `#${String(prNumber)}`
2877
+ });
2878
+ const parsed = schema.safeParse(await res.json());
2879
+ if (!parsed.success) throw new UnexpectedError(`Unexpected PR ${what} response: ${parsed.error.message}`);
2880
+ return parsed.data;
2881
+ }
2882
+ z.object({ head: z.object({ ref: z.string() }) });
2883
+ z.object({
2884
+ head: z.object({ ref: z.string() }),
2885
+ base: z.object({ ref: z.string() })
2886
+ });
2887
+ const PrBranchRefRawSchema = z.object({
2888
+ ref: z.string(),
2889
+ sha: z.string(),
2890
+ repo: z.object({ full_name: z.string() }).nullish()
2891
+ });
2892
+ const PrDetailSchema = z.object({
2893
+ head: PrBranchRefRawSchema,
2894
+ base: PrBranchRefRawSchema,
2895
+ draft: z.boolean(),
2896
+ merged: z.boolean(),
2897
+ mergeable: z.boolean().nullish(),
2898
+ state: z.string()
2899
+ });
2900
+ /**
2901
+ * Map a validated pulls-endpoint payload to {@link PrDetail}: an absent `repo` becomes `null`
2902
+ * rather than an invented repository name, and an absent `mergeable` becomes `null` rather than
2903
+ * `false` -- both forges can omit or null this field while still computing it.
2904
+ */
2905
+ function normalizePrDetail(data) {
2906
+ return {
2907
+ head: {
2908
+ ref: data.head.ref,
2909
+ sha: data.head.sha,
2910
+ repo: data.head.repo?.full_name ?? null
2911
+ },
2912
+ base: {
2913
+ ref: data.base.ref,
2914
+ sha: data.base.sha,
2915
+ repo: data.base.repo?.full_name ?? null
2916
+ },
2917
+ draft: data.draft,
2918
+ merged: data.merged,
2919
+ mergeable: data.mergeable ?? null,
2920
+ state: data.state
2921
+ };
2922
+ }
2923
+ /**
2924
+ * Fetch a PR's head and base branch details, draft/merged state, and tri-state mergeability in one
2925
+ * `/pulls/<n>` read -- the projection `bst issue:view` reports as its `pr` block, so a review
2926
+ * workflow that starts from a PR number reaches its branch without a `git ls-remote` reverse
2927
+ * lookup. `ref`/`sha`/`draft`/`merged` are required on the wire: a forge that stops serving one of
2928
+ * them should fail loudly here rather than surface `undefined` further out (see `readPrField`'s
2929
+ * doc comment). `mergeable` and `repo` are nullish because absent and explicitly-null are both
2930
+ * real answers.
2931
+ */
2932
+ async function getForgejoPrDetail(executor, conn, prNumber) {
2933
+ return normalizePrDetail(await readPrField(executor, conn, prNumber, PrDetailSchema, "detail"));
2934
+ }
2935
+ z.object({
2936
+ title: z.string(),
2937
+ body: z.string().nullish()
2938
+ });
2939
+ z.object({
2940
+ title: z.string(),
2941
+ draft: z.boolean()
2942
+ });
2943
+ const PullReviewSchema = z.object({
2944
+ id: z.number(),
2945
+ body: z.string(),
2946
+ commit_id: z.string().nullish(),
2947
+ user: z.object({ login: z.string() }).nullish(),
2948
+ html_url: z.string().nullish(),
2949
+ submitted_at: z.string().nullish()
2950
+ });
2951
+ z.array(PullReviewSchema);
2952
+ z.object({ head: z.object({ sha: z.string() }) });
2953
+ const PullReviewCommentSchema = z.object({
2954
+ body: z.string(),
2955
+ path: z.string().optional(),
2956
+ diff_hunk: z.string().optional(),
2957
+ user: z.object({ login: z.string() }).nullish()
2958
+ });
2959
+ z.array(PullReviewCommentSchema);
2960
+ //#endregion
2961
+ //#region src/pr-checks.ts
2962
+ /**
2963
+ * `bst-fj pr:checks` -- the PR CI verdict, read live off the PR's current head SHA over the same
2964
+ * `@bensandee/forgejo` primitive the automerge gate uses (`readCommitChecks`), so the agent and
2965
+ * the CLI cannot drift about what green means.
2966
+ *
2967
+ * This module owns the whole runner (`runPrChecks`); `commands/pr-checks.ts` is the thin citty
2968
+ * definition over it. Resolution -- turning `--pr`/`--branch`/`--repo`/the current checkout into a
2969
+ * PR number, a live `ForgejoConnection`, and that PR's detail -- is `resolvePrChecksTarget` below,
2970
+ * kept as its own function because it is where every refusal lives: no PR for a branch, a
2971
+ * `--repo` with neither `--pr` nor `--branch`, or a github.com target. Reads GET-only, at every
2972
+ * step, under every flag.
2973
+ */
2974
+ /**
2975
+ * Resolve `--pr`/`--branch`/`--repo`/the current checkout into a PR number, a live
2976
+ * `ForgejoConnection`, and that PR's detail -- the whole set of ways this command can be told "no"
2977
+ * before it ever reads a check.
2978
+ *
2979
+ * Order: `--pr` (a number or URL, the URL carrying its own repository) wins; otherwise `--branch`
2980
+ * resolves through {@link findOpenPr}; otherwise the current checkout's branch does, through the
2981
+ * same lookup. `--repo` requires an explicit `--pr` or `--branch`: the current branch of this
2982
+ * checkout means nothing in another repository, and inferring it there would silently answer
2983
+ * about the wrong PR. A github.com target refuses before any request -- GitHub Actions publishes
2984
+ * check runs, not commit statuses, so a combined-status read against it would confidently report
2985
+ * `no-status` for a green PR.
2986
+ */
2987
+ async function resolvePrChecksTarget(fetcher, options) {
2988
+ const ref = parseIssueRef(options.pr);
2989
+ if (options.repo !== void 0 && ref.number === void 0 && options.branch === void 0) throw new FatalError("--repo requires --pr or --branch -- the current branch of this checkout means nothing in another repository, and inferring it there would silently answer about the wrong PR.");
2990
+ const conn = requireForgejoTarget(options.cwd, resolveIssueTarget(ref.target, options.repo), "pr:checks is Forgejo-only: GitHub Actions publishes check runs, not commit statuses, so a combined-status read against a github.com PR would confidently report `no-status` for a green PR. Use `gh pr checks --watch` instead.");
2991
+ let prNumber;
2992
+ if (ref.number !== void 0) prNumber = ref.number;
2993
+ else {
2994
+ const branch = options.branch ?? resolveCurrentBranch({
2995
+ cwd: options.cwd,
2996
+ git: options.git,
2997
+ env: options.env
2998
+ });
2999
+ if (branch === void 0) throw new FatalError("Could not determine the current branch (detached HEAD, or not a git repository) -- pass --branch or --pr explicitly.");
3000
+ const found = await findOpenPr(fetcher, conn, branch);
3001
+ if (found === null) throw new FatalError(`No open pull request found for branch "${branch}".`);
3002
+ prNumber = found;
3003
+ }
3004
+ const detail = await getForgejoPrDetail(fetcher, conn, prNumber);
3005
+ return {
3006
+ prNumber,
3007
+ conn,
3008
+ url: prWebUrl(conn, prNumber),
3009
+ detail
3010
+ };
3011
+ }
3012
+ /**
3013
+ * Read the base branch's current head checks and report which normalized names read red there --
3014
+ * the baseline a PR head's own red checks are compared against. Matched by normalized `name`, not
3015
+ * raw `context`: Forgejo folds the triggering event into the context, so the same job reads
3016
+ * `CI / check (pull_request)` on the PR and `CI / check (push)` on `main`, and exact-string
3017
+ * matching would fail in precisely the case this annotation exists for.
3018
+ *
3019
+ * `baseline: false` (`--no-baseline`) skips both reads entirely and reports `"skipped"`. Any other
3020
+ * failure to establish a clean baseline -- the branch-head read failing, the status read failing,
3021
+ * an unparseable status body, or zero statuses on the base head -- reports `"unavailable"` with a
3022
+ * `reason`, never `"read"` with an empty `redNames`: an empty list must mean "read, and none were
3023
+ * red", not "could not tell".
3024
+ *
3025
+ * Exported for the agent's automerge sweep, which annotates its CI-red hold with the same
3026
+ * baseline.
3027
+ */
3028
+ async function readBaseline(fetcher, conn, baseRef, enabled) {
3029
+ if (!enabled) return {
3030
+ ref: baseRef,
3031
+ status: "skipped",
3032
+ redNames: []
3033
+ };
3034
+ const unavailable = (reason, sha) => sha === void 0 ? {
3035
+ ref: baseRef,
3036
+ status: "unavailable",
3037
+ reason,
3038
+ redNames: []
3039
+ } : {
3040
+ ref: baseRef,
3041
+ sha,
3042
+ status: "unavailable",
3043
+ reason,
3044
+ redNames: []
3045
+ };
3046
+ let baseSha;
3047
+ try {
3048
+ baseSha = await getBranchHeadSha(fetcher, conn, baseRef);
3049
+ } catch (error) {
3050
+ return unavailable(describeError(error));
3051
+ }
3052
+ let baseChecks;
3053
+ try {
3054
+ baseChecks = await readCommitChecks(fetcher, conn, baseSha);
3055
+ } catch (error) {
3056
+ return unavailable(describeError(error), baseSha);
3057
+ }
3058
+ if (!baseChecks.parsed) return unavailable("The base branch's commit status response did not match the shape this build expects.", baseSha);
3059
+ if (baseChecks.contexts.length === 0) return unavailable(`No checks have been posted for ${baseRef}'s current head (${baseSha}).`, baseSha);
3060
+ const redNames = baseChecks.contexts.filter((check) => check.verdict === "red").map((check) => check.name);
3061
+ return {
3062
+ ref: baseRef,
3063
+ sha: baseSha,
3064
+ status: "read",
3065
+ redNames
3066
+ };
3067
+ }
3068
+ /**
3069
+ * Assemble a {@link PrChecksReport} from a PR detail read, a commit-status read, and the
3070
+ * once-per-invocation baseline -- the one place that shape is built, so the read-once path and
3071
+ * every `--wait` exit (settled, `head-moved`, `timeout`, retry-budget exhaustion) produce
3072
+ * identically-shaped reports.
3073
+ */
3074
+ function buildReport(detail, checks, ctx, extras = {}) {
3075
+ const { target, baseline, notices, waited, elapsedMs } = ctx;
3076
+ const preexistingNames = new Set(baseline.status === "read" ? baseline.redNames : []);
3077
+ const status = extras.statusOverride ?? classifyBaseStatus(checks);
3078
+ const report = {
3079
+ pr: target.prNumber,
3080
+ repository: target.conn.repository,
3081
+ url: target.url,
3082
+ state: detail.state,
3083
+ headSha: detail.head.sha,
3084
+ baseRef: detail.base.ref,
3085
+ status,
3086
+ settled: isSettled(checks),
3087
+ combinedState: checks.state,
3088
+ waited,
3089
+ elapsedMs,
3090
+ checks: checks.contexts.map((check) => ({
3091
+ ...check,
3092
+ preexisting: check.verdict === "red" && preexistingNames.has(check.name)
3093
+ })),
3094
+ baseline,
3095
+ notices
3096
+ };
3097
+ if (extras.detailOverride !== void 0) report.detail = extras.detailOverride;
3098
+ else if (status === "unparseable") report.detail = "The commit status response did not match the shape this build expects.";
3099
+ if (extras.previousHeadSha !== void 0) report.previousHeadSha = extras.previousHeadSha;
3100
+ return report;
3101
+ }
3102
+ /**
3103
+ * Poll the PR's live head until its checks settle, following the spec's poll contract: each poll
3104
+ * re-reads the PR detail (never pinning the head SHA from an earlier poll) and then that head's
3105
+ * commit status -- exactly two reads. A changed head SHA ends the wait immediately as
3106
+ * `head-moved`, carrying both SHAs, rather than re-pinning and continuing: a verdict for a commit
3107
+ * that is no longer head is meaningless, and following a rebasing head would let the wait outlive
3108
+ * its own timeout. Settlement additionally requires a **quiet interval**: two consecutive polls,
3109
+ * one interval apart, both settled with the same set of contexts -- a job that has posted no status
3110
+ * yet is invisible to this endpoint, so a workflow registering moments after another finished would
3111
+ * otherwise be reported settled before it ever ran. A red check never short-circuits the wait; the
3112
+ * point of `--wait` is a complete report. Up to {@link RETRY_BUDGET} consecutive failed or
3113
+ * unparseable status reads are absorbed (the counter resets on any good read) before the wait gives
3114
+ * up as `unparseable`; a timeout still reports whatever was last observed. The per-poll PR-detail
3115
+ * read carries its own budget of the same size, exhausting into a rethrow.
3116
+ */
3117
+ async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
3118
+ const { timeoutMs, intervalMs, sleep, now } = poll;
3119
+ const startedAt = now();
3120
+ let currentDetail = target.detail;
3121
+ let consecutiveFailures = 0;
3122
+ let consecutiveDetailFailures = 0;
3123
+ let lastFailureDetail;
3124
+ let lastGoodChecks;
3125
+ let previousSettledContexts = null;
3126
+ for (;;) {
3127
+ const sha = currentDetail.head.sha;
3128
+ const { checks, failureDetail } = await readPollChecks(fetcher, target.conn, sha);
3129
+ if (failureDetail !== void 0) lastFailureDetail = failureDetail;
3130
+ if (checks === void 0) {
3131
+ consecutiveFailures += 1;
3132
+ if (consecutiveFailures >= 3) return buildReport(currentDetail, EMPTY_CHECKS, {
3133
+ target,
3134
+ baseline,
3135
+ notices,
3136
+ waited: true,
3137
+ elapsedMs: now() - startedAt
3138
+ }, {
3139
+ statusOverride: "unparseable",
3140
+ detailOverride: lastFailureDetail
3141
+ });
3142
+ } else {
3143
+ consecutiveFailures = 0;
3144
+ lastGoodChecks = checks;
3145
+ pushDisagreementNotice(notices, sha, checks);
3146
+ if (isSettled(checks)) {
3147
+ const contexts = new Set(checks.contexts.map((check) => check.context));
3148
+ if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return buildReport(currentDetail, checks, {
3149
+ target,
3150
+ baseline,
3151
+ notices,
3152
+ waited: true,
3153
+ elapsedMs: now() - startedAt
3154
+ });
3155
+ previousSettledContexts = contexts;
3156
+ } else previousSettledContexts = null;
3157
+ }
3158
+ if (now() - startedAt >= timeoutMs) return buildReport(currentDetail, lastGoodChecks ?? EMPTY_CHECKS, {
3159
+ target,
3160
+ baseline,
3161
+ notices,
3162
+ waited: true,
3163
+ elapsedMs: now() - startedAt
3164
+ }, { statusOverride: "timeout" });
3165
+ await sleep(intervalMs);
3166
+ let nextDetail;
3167
+ try {
3168
+ nextDetail = await getForgejoPrDetail(fetcher, target.conn, target.prNumber);
3169
+ consecutiveDetailFailures = 0;
3170
+ } catch (error) {
3171
+ consecutiveDetailFailures += 1;
3172
+ if (consecutiveDetailFailures >= 3) throw error;
3173
+ continue;
3174
+ }
3175
+ if (nextDetail.head.sha !== sha) return buildReport(nextDetail, EMPTY_CHECKS, {
3176
+ target,
3177
+ baseline,
3178
+ notices,
3179
+ waited: true,
3180
+ elapsedMs: now() - startedAt
3181
+ }, {
3182
+ statusOverride: "head-moved",
3183
+ previousHeadSha: sha
3184
+ });
3185
+ currentDetail = nextDetail;
3186
+ }
3187
+ }
3188
+ /**
3189
+ * Read the PR's current head SHA's checks and produce a report -- classified into
3190
+ * {@link PrChecksStatus}, over one combined-status read by default or, with `options.wait`, over
3191
+ * {@link waitForSettledChecks}'s poll loop. Never touches `process.exitCode` and never prints: that
3192
+ * is `commands/pr-checks.ts`'s job, which also relays `report.notices` through `CliOutput.notice`
3193
+ * and strips it before emitting the JSON payload.
3194
+ */
3195
+ async function runPrChecks(fetcher, options) {
3196
+ const timings = options.wait === true ? resolvePollTimings(options) : void 0;
3197
+ const target = await resolvePrChecksTarget(fetcher, options);
3198
+ const notices = [];
3199
+ const closed = target.detail.merged || target.detail.state !== "open";
3200
+ if (target.detail.merged) notices.push(`PR #${String(target.prNumber)} is merged -- reporting its last checks rather than a live wait.`);
3201
+ else if (target.detail.state !== "open") notices.push(`PR #${String(target.prNumber)} is closed (not merged) -- reporting its last checks rather than a live wait.`);
3202
+ const baseline = await readBaseline(fetcher, target.conn, target.detail.base.ref, options.baseline ?? true);
3203
+ if (timings !== void 0 && !closed) return waitForSettledChecks(fetcher, target, baseline, notices, {
3204
+ ...timings,
3205
+ sleep: options.sleep ?? sleep,
3206
+ now: options.now ?? Date.now
3207
+ });
3208
+ const checks = await readCommitChecks(fetcher, target.conn, target.detail.head.sha);
3209
+ pushDisagreementNotice(notices, target.detail.head.sha, checks);
3210
+ return buildReport(target.detail, checks, {
3211
+ target,
3212
+ baseline,
3213
+ notices,
3214
+ waited: false,
3215
+ elapsedMs: 0
3216
+ });
3217
+ }
3218
+ /**
3219
+ * The baseline summary clause, e.g. `3 red, 1 of them already red on main` -- present only when
3220
+ * the baseline was actually read and at least one check is red. An unavailable or skipped
3221
+ * baseline never earns a clause: saying nothing is the honest answer when "how many were
3222
+ * pre-existing" cannot be established, never an implied zero.
3223
+ */
3224
+ function baselineClause(report) {
3225
+ if (report.baseline.status !== "read") return null;
3226
+ const redChecks = report.checks.filter((check) => check.verdict === "red");
3227
+ if (redChecks.length === 0) return null;
3228
+ const preexistingCount = redChecks.filter((check) => check.preexisting).length;
3229
+ return `${String(redChecks.length)} red, ${String(preexistingCount)} of them already red on ` + report.baseline.ref;
3230
+ }
3231
+ /**
3232
+ * Render a `pr:checks` report as text: one line per check (its state, context and description,
3233
+ * with the target URL appended only for a red check -- the line a human is about to go read), then
3234
+ * a summary line carrying the head SHA, the verdict, the baseline clause when there is one, and
3235
+ * the PR URL.
3236
+ */
3237
+ function renderPrChecks(report) {
3238
+ const lines = report.checks.map((check) => {
3239
+ const parts = [check.state, check.context];
3240
+ if (check.description !== null) parts.push(check.description);
3241
+ let line = parts.join(" -- ");
3242
+ if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
3243
+ return line;
3244
+ });
3245
+ const summaryParts = [`head ${report.headSha.slice(0, 12)}: ${report.status}`];
3246
+ const clause = baselineClause(report);
3247
+ if (clause !== null) summaryParts.push(clause);
3248
+ summaryParts.push(report.url);
3249
+ lines.push(summaryParts.join(" -- "));
3250
+ return lines.join("\n");
3251
+ }
3252
+ //#endregion
3253
+ //#region src/commands/pr-checks.ts
3254
+ const prChecksCommand = defineCommand({
3255
+ meta: {
3256
+ name: "pr:checks",
3257
+ description: "Read a PR's CI verdict off its current head SHA, over the same combined-status read the automerge gate uses -- optionally --wait for it to settle. It runs nothing locally: it reads what the forge's CI already said about a PR."
3258
+ },
3259
+ args: {
3260
+ pr: {
3261
+ type: "string",
3262
+ description: "PR number or URL (default: the current branch's open PR)"
3263
+ },
3264
+ branch: {
3265
+ type: "string",
3266
+ description: "Resolve the open PR whose head is this branch"
3267
+ },
3268
+ repo: {
3269
+ type: "string",
3270
+ description: "Target repository (requires --pr or --branch)"
3271
+ },
3272
+ wait: {
3273
+ type: "boolean",
3274
+ description: "Poll until every check for the head SHA has settled"
3275
+ },
3276
+ timeout: {
3277
+ type: "string",
3278
+ description: "--wait's give-up duration, e.g. 20m (default: 20m). Checked once per poll, so a wait can run up to one --interval past it."
3279
+ },
3280
+ interval: {
3281
+ type: "string",
3282
+ description: "--wait's poll interval, e.g. 15s (default: 15s, floored at 5s)"
3283
+ },
3284
+ baseline: {
3285
+ type: "boolean",
3286
+ default: true,
3287
+ description: "Also read the base branch's checks, to mark which red checks are pre-existing. --no-baseline skips both reads."
3288
+ },
3289
+ json: {
3290
+ type: "boolean",
3291
+ description: "Emit the machine-readable report on stdout"
3292
+ },
3293
+ verbose: {
3294
+ type: "boolean",
3295
+ description: "Emit detailed debug output"
3296
+ }
3297
+ },
3298
+ async run({ args }) {
3299
+ const out = createCliOutput(args.json === true);
3300
+ const report = await runPrChecks(cliFetcher(), {
3301
+ cwd: process.cwd(),
3302
+ pr: args.pr,
3303
+ branch: args.branch,
3304
+ repo: args.repo,
3305
+ wait: args.wait === true,
3306
+ timeout: args.timeout,
3307
+ interval: args.interval,
3308
+ baseline: args.baseline
3309
+ });
3310
+ const { notices, ...payload } = report;
3311
+ for (const notice of notices) out.notice(notice);
3312
+ out.json(payload);
3313
+ out.text(renderPrChecks(report));
3314
+ process.exitCode = report.status === "green" ? 0 : 1;
3315
+ }
3316
+ });
3317
+ //#endregion
3318
+ //#region src/pr-list.ts
3319
+ /**
3320
+ * `bst-fj pr:list` -- a read-only, Forgejo-only pull request listing over the shared `listPulls`
3321
+ * pager (`@bensandee/forgejo/pr`), so a duplicate-work check needs no raw API call. GitHub-only
3322
+ * PR listing already has a first-class tool (`gh pr list`), the same precedent `pr:checks`
3323
+ * follows for the same structural reason.
3324
+ */
3325
+ function parseState(state) {
3326
+ if (state === "open" || state === "closed" || state === "all") return state;
3327
+ throw new FatalError(`Invalid --state "${state}" (expected open, closed, or all).`);
3328
+ }
3329
+ function parseLimit(raw) {
3330
+ const limit = Number(raw);
3331
+ if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
3332
+ return limit;
3333
+ }
3334
+ /**
3335
+ * List a repository's pull requests, most recently updated first, sliced to `--limit`. Refuses a
3336
+ * github.com target before any fetch -- `gh pr list` already serves GitHub, first-class.
3337
+ */
3338
+ async function runPrList(fetcher, options) {
3339
+ const state = parseState(options.state);
3340
+ const limit = parseLimit(options.limit);
3341
+ rootLogger.debug(`pr:list state=${state} limit=${String(limit)}`);
3342
+ const conn = requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:list is Forgejo-only -- gh pr list already serves GitHub, first-class. Use that instead.");
3343
+ const notices = [];
3344
+ return {
3345
+ pulls: (await listPulls(fetcher, conn, {
3346
+ state,
3347
+ sort: "recentupdate",
3348
+ limit,
3349
+ onPageCap: () => {
3350
+ notices.push("PR listing hit the page cap before an empty page; results may be truncated.");
3351
+ }
3352
+ })).slice(0, limit),
3353
+ notices
3354
+ };
3355
+ }
3356
+ function statusLabel(pr) {
3357
+ if (pr.merged) return "merged";
3358
+ if (pr.draft === true) return `${pr.state}/draft`;
3359
+ return pr.state;
3360
+ }
3361
+ /** Human-readable rendering: one line per PR, most recently updated first. */
3362
+ function renderPrList(result) {
3363
+ if (result.pulls.length === 0) return "No pull requests.";
3364
+ return result.pulls.map((pr) => {
3365
+ const branches = `${pr.headRef} -> ${pr.baseRef ?? "?"}`;
3366
+ const labels = pr.labels.length === 0 ? "" : ` [${pr.labels.join(", ")}]`;
3367
+ const url = pr.url === null ? "" : ` ${pr.url}`;
3368
+ return `#${String(pr.number)} ${statusLabel(pr)} ${branches}: ${pr.title ?? "(no title)"}${labels}${url}`;
3369
+ }).join("\n");
3370
+ }
3371
+ //#endregion
3372
+ //#region src/commands/pr-list.ts
3373
+ const prListCommand = defineCommand({
3374
+ meta: {
3375
+ name: "pr:list",
3376
+ description: "List a repository's pull requests (Forgejo-only; use gh pr list on GitHub)"
3377
+ },
3378
+ args: {
3379
+ state: {
3380
+ type: "string",
3381
+ default: "open",
3382
+ description: "open | closed | all"
3383
+ },
3384
+ limit: {
3385
+ type: "string",
3386
+ default: "30",
3387
+ description: "Maximum pull requests to return"
3388
+ },
3389
+ repo: {
3390
+ type: "string",
3391
+ description: "Repository URL (default: this repo)"
3392
+ },
3393
+ json: {
3394
+ type: "boolean",
3395
+ description: "Emit JSON on stdout"
3396
+ },
3397
+ verbose: {
3398
+ type: "boolean",
3399
+ description: "Emit detailed debug output"
3400
+ }
3401
+ },
3402
+ async run({ args }) {
3403
+ const out = createCliOutput(args.json === true);
3404
+ const result = await runPrList(cliFetcher(), {
3405
+ cwd: process.cwd(),
3406
+ state: args.state,
3407
+ limit: args.limit,
3408
+ repo: args.repo ?? void 0
3409
+ });
3410
+ for (const notice of result.notices) out.notice(notice);
3411
+ out.json({ pulls: result.pulls });
3412
+ out.text(renderPrList(result));
3413
+ }
3414
+ });
3415
+ //#endregion
3416
+ //#region ../forgejo/src/releases.ts
3417
+ /** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
3418
+ const SETTLE_ATTEMPTS = 4;
3419
+ /** Base backoff between patient-repair attempts; doubles each attempt. */
3420
+ const SETTLE_BASE_DELAY_MS = 2e3;
3421
+ /** Cap on a single patient-repair backoff. */
3422
+ const SETTLE_MAX_DELAY_MS = 16e3;
3423
+ const ReleaseSchema = z.object({
3424
+ id: z.number(),
3425
+ tag_name: z.string().optional(),
3426
+ created_at: z.string().optional(),
3427
+ published_at: z.string().optional(),
3428
+ html_url: z.string().nullish(),
3429
+ prerelease: z.boolean().optional()
3430
+ });
3431
+ /**
3432
+ * Detect the signature of a broken Forgejo release: a `created_at` that maps to
3433
+ * the Unix epoch (`created_unix = 0`).
3434
+ *
3435
+ * Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
3436
+ * If our `POST /releases` races that placeholder, Forgejo promotes the
3437
+ * placeholder in place (flipping `is_tag` to false) **without** resetting
3438
+ * `created_unix`, leaving a published release stuck at the epoch. Such a
3439
+ * release sorts as the oldest, so `/releases/latest` never returns it.
3440
+ *
3441
+ * The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
3442
+ * depending on the server timezone; both parse to `<= 0` ms. A missing or
3443
+ * unparseable date is treated as broken too, so the caller repairs rather than
3444
+ * ships it.
3445
+ */
3446
+ function isEpochReleaseDate(date) {
3447
+ if (!date) return true;
3448
+ const ms = new Date(date).getTime();
3449
+ return Number.isNaN(ms) || ms <= 0;
3450
+ }
3451
+ /**
3452
+ * Fetch the Forgejo release for a tag, returning the parsed object or `null`
3453
+ * when no release exists (404). Unlike {@link findRelease}, this exposes the
3454
+ * release `id` and dates so callers can inspect/repair an epoch-dated release.
3455
+ */
3456
+ async function getReleaseByTag(executor, conn, tag) {
3457
+ const encodedTag = encodeURIComponent(tag);
3458
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
3459
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3460
+ if (res.status === 404) return null;
3461
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3462
+ verb: "check release",
3463
+ resource: tag
3464
+ }));
3465
+ const parsed = ReleaseSchema.safeParse(await res.json());
3466
+ if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
3467
+ return parsed.data;
3468
+ }
3469
+ /**
3470
+ * Create a Forgejo release for a given tag.
3471
+ *
3472
+ * When `options.target` (a commit SHA or branch) is provided and the tag does
3473
+ * not yet exist, Forgejo **creates the tag itself** from that commitish and
3474
+ * stamps `created_unix = now` synchronously -- sidestepping the buggy async
3475
+ * stamping of a pushed tag. The created tag is lightweight.
3476
+ */
3477
+ async function createRelease(executor, conn, tag, options = {}) {
3478
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
3479
+ const payload = {
3480
+ tag_name: tag,
3481
+ name: tag,
3482
+ body: `Published ${tag}`,
3483
+ prerelease: options.prerelease ?? false
3484
+ };
3485
+ if (options.target) payload["target_commitish"] = options.target;
3486
+ const res = await executor.fetch(url, {
3487
+ method: "POST",
3488
+ headers: {
3489
+ Authorization: `token ${conn.token}`,
3490
+ "Content-Type": "application/json"
3491
+ },
3492
+ body: JSON.stringify(payload)
3493
+ });
3494
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3495
+ verb: "create release",
3496
+ resource: tag
3497
+ }));
3498
+ }
3499
+ /**
3500
+ * Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
3501
+ *
3502
+ * Used by {@link ensureRelease} to take tag ownership away from the buggy
3503
+ * push-ingestion path: once the tag is gone, recreating the release with a
3504
+ * `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
3505
+ * (already gone) is treated as success.
3506
+ */
3507
+ async function deleteTag(executor, conn, tag) {
3508
+ const encodedTag = encodeURIComponent(tag);
3509
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
3510
+ const res = await executor.fetch(url, {
3511
+ method: "DELETE",
3512
+ headers: { Authorization: `token ${conn.token}` }
3513
+ });
3514
+ if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
3515
+ verb: "delete tag",
3516
+ resource: tag
3517
+ }));
3518
+ }
3519
+ const TagSchema = z.looseObject({
3520
+ name: z.string(),
3521
+ id: z.string(),
3522
+ commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
3523
+ });
3524
+ /**
3525
+ * The commit a tag points at (`GET /repos/{repo}/tags/{tag}`), or `null` when the forge has no
3526
+ * such tag. For an annotated tag, `id` and `commit.url` hold the tag object's SHA; only
3527
+ * `commit.sha` is the commit, so that is the field read.
3528
+ */
3529
+ async function getTagCommit(executor, conn, tag) {
3530
+ const encodedTag = encodeURIComponent(tag);
3531
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
3532
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3533
+ if (res.status === 404) return null;
3534
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3535
+ verb: "read tag",
3536
+ resource: tag
3537
+ }));
3538
+ const parsed = TagSchema.safeParse(await res.json());
3539
+ if (!parsed.success) throw new UnexpectedError(`Unexpected tag response: ${parsed.error.message}`);
3540
+ return parsed.data.commit.sha;
3541
+ }
3542
+ /**
3543
+ * Delete a Forgejo release by id. Does **not** delete the underlying git tag
3544
+ * (no `delete_tag` query param) -- the tag is the source of truth, and a
3545
+ * subsequent `POST /releases` relies on it still existing to insert a fresh
3546
+ * row with `created_unix = now`. A 404 (already gone) is treated as success.
3547
+ */
3548
+ async function deleteRelease(executor, conn, releaseId) {
3549
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
3550
+ const res = await executor.fetch(url, {
3551
+ method: "DELETE",
3552
+ headers: { Authorization: `token ${conn.token}` }
3553
+ });
3554
+ if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
3555
+ verb: "delete release",
3556
+ resource: String(releaseId)
3557
+ }));
3558
+ }
3559
+ /**
3560
+ * Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
3561
+ *
3562
+ * {@link ensureRelease} uses this on the path where the release was already there, and so does the
3563
+ * `release:changesets` rerun, so a rerun over a release an earlier unflagged run created can still
3564
+ * keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
3565
+ * why the body is fixed at
3566
+ * `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
3567
+ * convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
3568
+ * `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
3569
+ *
3570
+ * The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
3571
+ * but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
3572
+ * vanished mid-run means the flag was not applied, and the caller must know.
3573
+ */
3574
+ async function markReleasePrerelease(executor, conn, releaseId, tag) {
3575
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
3576
+ const res = await executor.fetch(url, {
3577
+ method: "PATCH",
3578
+ headers: {
3579
+ Authorization: `token ${conn.token}`,
3580
+ "Content-Type": "application/json"
3581
+ },
3582
+ body: JSON.stringify({ prerelease: true })
3583
+ }, { idempotent: true });
3584
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3585
+ verb: "edit release",
3586
+ resource: tag
3587
+ }));
3588
+ }
3589
+ /**
3590
+ * Ensure a Forgejo release exists for a tag **with a real created date**.
3591
+ *
3592
+ * Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
3593
+ * creation that races that stamp freezes the row at the Unix epoch (see
3594
+ * {@link isEpochReleaseDate}) -- a published-but-invisible release, because
3595
+ * `/releases/latest` sorts by date. The failure is intermittent. The repair:
3596
+ *
3597
+ * 1. A real-dated release already exists -> `"exists"`.
3598
+ * 2. Otherwise create the release and read it back. On epoch, delete the frozen
3599
+ * release and wait (increasing backoff) for Forgejo to settle the tag's date,
3600
+ * then retry -- **preserving the pushed (annotated) tag**. The frozen release
3601
+ * is never deleted-and-left-empty.
3602
+ * 3. If the date still never settles and a `target` commit is known, delete the
3603
+ * tag and recreate the release with `target_commitish` so **Forgejo creates
3604
+ * the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
3605
+ * 4. If nothing yields a real date, leave a visible release row (rather than only
3606
+ * an invisible tag) and throw loudly.
3607
+ *
3608
+ * Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
3609
+ */
3610
+ async function ensureRelease(executor, conn, tag, options = {}) {
3611
+ const attempts = options.attempts ?? SETTLE_ATTEMPTS;
3612
+ const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
3613
+ const existing = await getReleaseByTag(executor, conn, tag);
3614
+ if (existing && !isEpochReleaseDate(existing.created_at)) {
3615
+ if (options.prerelease === true && existing.prerelease === false) await markReleasePrerelease(executor, conn, existing.id, tag);
3616
+ return {
3617
+ status: "exists",
3618
+ release: releaseRef(existing)
3619
+ };
3620
+ }
3621
+ let current = existing;
3622
+ let repairing = false;
3623
+ for (let attempt = 1; attempt <= attempts; attempt++) {
3624
+ if (current && isEpochReleaseDate(current.created_at)) {
3625
+ await deleteRelease(executor, conn, current.id);
3626
+ current = null;
3627
+ repairing = true;
3628
+ await settleBackoff(attempt, baseDelayMs);
3629
+ }
3630
+ try {
3631
+ await createRelease(executor, conn, tag, {
3632
+ target: options.target,
3633
+ prerelease: options.prerelease
3634
+ });
3635
+ } catch (error) {
3636
+ const raced = await getReleaseByTag(executor, conn, tag);
3637
+ if (raced && !isEpochReleaseDate(raced.created_at)) return {
3638
+ status: "race",
3639
+ release: releaseRef(raced)
3640
+ };
3641
+ current = raced;
3642
+ if (attempt >= attempts) break;
3643
+ log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${describeError(error)}`);
3644
+ await settleBackoff(attempt, baseDelayMs);
3645
+ continue;
3646
+ }
3647
+ current = await getReleaseByTag(executor, conn, tag);
3648
+ if (current && !isEpochReleaseDate(current.created_at)) return {
3649
+ status: repairing ? "repaired" : "created",
3650
+ release: releaseRef(current)
3651
+ };
3652
+ if (attempt < attempts) log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(attempts)}) -- waiting for Forgejo to settle the tag's date before retrying`);
3653
+ }
3654
+ const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
3655
+ target: options.target,
3656
+ prerelease: options.prerelease,
3657
+ current,
3658
+ attempts,
3659
+ baseDelayMs
3660
+ }) : null;
3661
+ if (recreated) return {
3662
+ status: "repaired",
3663
+ release: releaseRef(recreated)
3664
+ };
3665
+ throw new TransientError(`Release for ${tag} never got a real (non-epoch) created date after ${String(attempts)} attempts. This is an intermittent Forgejo bug: a pushed tag's date settles asynchronously and a racing release creation freezes it at the Unix epoch (1969/1970). ` + (options.target ? `Recreating the tag via the API did not help -- investigate the server or upgrade Forgejo.` : `Pass the release tag's target commit so the tag can be recreated, or retry the release.`));
3666
+ }
3667
+ async function recreateTagViaApi(executor, conn, tag, opts) {
3668
+ const { target, prerelease, current, attempts, baseDelayMs } = opts;
3669
+ log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
3670
+ if (current) await deleteRelease(executor, conn, current.id);
3671
+ await deleteTag(executor, conn, tag);
3672
+ for (let attempt = 1; attempt <= attempts; attempt++) {
3673
+ try {
3674
+ await createRelease(executor, conn, tag, {
3675
+ target,
3676
+ prerelease
3677
+ });
3678
+ } catch (error) {
3679
+ if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${describeError(error)}. The tag no longer exists on the server -- re-push it (or re-run the release) to restore it.`);
3680
+ await settleBackoff(attempt, baseDelayMs);
3681
+ continue;
3682
+ }
3683
+ const recreated = await getReleaseByTag(executor, conn, tag);
3684
+ if (recreated && !isEpochReleaseDate(recreated.created_at)) return recreated;
3685
+ return null;
3686
+ }
3687
+ return null;
3688
+ }
3689
+ /**
3690
+ * The `{ id, url }` projection of a release row carried back to a caller. A server that omits
3691
+ * `html_url` yields `url: null` -- the id is still the row's identity.
3692
+ */
3693
+ function releaseRef(release) {
3694
+ return {
3695
+ id: release.id,
3696
+ url: release.html_url ?? null
3697
+ };
3698
+ }
3699
+ /** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
3700
+ async function settleBackoff(attempt, baseDelayMs) {
3701
+ if (baseDelayMs <= 0) return;
3702
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
3703
+ await new Promise((resolve) => setTimeout(resolve, delay));
3704
+ }
3705
+ //#endregion
3706
+ //#region src/commands/release-create.ts
3707
+ const releaseCreateCommand = defineCommand({
3708
+ meta: {
3709
+ name: "release:create",
3710
+ description: "Create a Forgejo release for a tag the forge already has, or with --target, for a new tag created at that commit"
3711
+ },
3712
+ args: {
3713
+ tag: {
3714
+ type: "string",
3715
+ description: "Git tag to create a release for",
3716
+ required: true
3717
+ },
3718
+ prerelease: {
3719
+ type: "boolean",
3720
+ description: "Flag the release as a prerelease, so the forge's Latest pointer skips it",
3721
+ default: false
3722
+ },
3723
+ target: {
3724
+ type: "string",
3725
+ description: "Commit SHA the tag points at (default: read from the forge's tags API). A tag the forge lacks is created at this commit; the epoch-date repair recreates the tag from it."
3726
+ },
3727
+ json: {
3728
+ type: "boolean",
3729
+ description: "Emit the release result as JSON"
3730
+ }
3731
+ },
3732
+ async run({ args }) {
3733
+ const out = createCliOutput(args.json === true);
3734
+ const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj release:create requires a Forgejo repository");
3735
+ if (args.target !== void 0 && args.target.trim() === "") throw new FatalError("--target needs a commit SHA; it was given an empty value.");
3736
+ const fetcher = cliFetcher();
3737
+ const prerelease = args.prerelease === true;
3738
+ const target = args.target ?? await getTagCommit(fetcher, conn, args.tag);
3739
+ if (target === null) throw new FatalError(`Tag ${args.tag} does not exist on ${conn.repository}. Push the tag first, or pass --target <sha> naming the commit it points at.`);
3740
+ const outcome = await ensureRelease(fetcher, conn, args.tag, {
3741
+ target,
3742
+ prerelease
3743
+ });
3744
+ out.json({
3745
+ status: outcome.status,
3746
+ tag: args.tag,
3747
+ target,
3748
+ prerelease,
3749
+ release: outcome.release
3750
+ });
3751
+ if (outcome.status === "exists") rootLogger.info(`Release for ${args.tag} already exists -- skipping`);
3752
+ else rootLogger.info(`Created Forgejo release for ${args.tag}`);
3753
+ }
3754
+ });
3755
+ //#endregion
3756
+ //#region ../forgejo/src/secrets.ts
3757
+ const ActionSecretListSchema = z.object({
3758
+ secrets: z.array(z.object({ name: z.string() })),
3759
+ total_count: z.number()
3760
+ });
3761
+ z.object({ sha1: z.string() });
3762
+ /**
3763
+ * The forge's own rules for an Actions secret name, mirrored from Forgejo's
3764
+ * `models/secret/secret.go` (`ValidateName`). GitHub reserves `GITHUB_` and a
3765
+ * leading digit as well, so a name this accepts is usable on both forges.
3766
+ *
3767
+ * A rejected name is not a soft failure: the API answers `invalid secret name`,
3768
+ * so the secret can never be created, and every workflow referencing it
3769
+ * resolves `${{ secrets.X }}` to the empty string instead.
3770
+ */
3771
+ const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
3772
+ const FORBIDDEN_PREFIX_PATTERN = /^(FORGEJO_|GITEA_|GITHUB_|[0-9])/i;
3773
+ function isValidSecretName(name) {
3774
+ return NAME_PATTERN.test(name) && !FORBIDDEN_PREFIX_PATTERN.test(name);
3775
+ }
3776
+ /**
3777
+ * Set (or overwrite) a Forgejo Actions secret on a repository.
3778
+ *
3779
+ * Uses PUT which is idempotent -- creates or overwrites.
3780
+ */
3781
+ async function setActionSecret(executor, conn, secretName, secretValue) {
3782
+ const [owner, repo] = conn.repository.split("/");
3783
+ const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
3784
+ const res = await executor.fetch(url, {
3785
+ method: "PUT",
3786
+ headers: {
3787
+ Authorization: `token ${conn.token}`,
3788
+ "Content-Type": "application/json"
3789
+ },
3790
+ body: JSON.stringify({ data: secretValue })
3791
+ });
3792
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3793
+ verb: "set secret",
3794
+ resource: secretName
3795
+ }));
3796
+ }
3797
+ /**
3798
+ * List Forgejo Actions secret names for a repository.
3799
+ *
3800
+ * Returns only names -- Forgejo never exposes secret values.
3801
+ * Paginates automatically to collect all secrets.
3802
+ */
3803
+ async function listActionSecrets(executor, conn) {
3804
+ const [owner, repo] = conn.repository.split("/");
3805
+ const names = [];
3806
+ let page = 1;
3807
+ const limit = 50;
3808
+ for (;;) {
3809
+ const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets?page=${String(page)}&limit=${String(limit)}`;
3810
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3811
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3812
+ verb: "list secrets",
3813
+ resource: `${owner}/${repo}`
3814
+ }));
3815
+ const parsed = ActionSecretListSchema.safeParse(await res.json());
3816
+ if (!parsed.success) throw new UnexpectedError(`Unexpected secrets list response: ${parsed.error.message}`);
3817
+ for (const secret of parsed.data.secrets) names.push(secret.name);
3818
+ if (names.length >= parsed.data.total_count) break;
3819
+ page++;
3820
+ }
3821
+ return names;
3822
+ }
3823
+ /**
3824
+ * Delete a Forgejo Actions secret from a repository.
3825
+ *
3826
+ * Returns silently on success. Throws on failure (including 404 if the secret doesn't exist).
3827
+ */
3828
+ async function deleteActionSecret(executor, conn, secretName) {
3829
+ const [owner, repo] = conn.repository.split("/");
3830
+ const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
3831
+ const res = await executor.fetch(url, {
3832
+ method: "DELETE",
3833
+ headers: { Authorization: `token ${conn.token}` }
3834
+ });
3835
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3836
+ verb: "delete secret",
3837
+ resource: secretName
3838
+ }));
3839
+ }
3840
+ //#endregion
3841
+ //#region src/secrets.ts
3842
+ async function runSecretsSet(fetcher, config) {
3843
+ if (!isValidSecretName(config.secretName)) throw new FatalError(`Invalid secret name "${config.secretName}". A name must match [A-Za-z_][A-Za-z0-9_]* and must not start with FORGEJO_, GITEA_, GITHUB_, or a digit.`);
3844
+ if (config.dryRun) {
3845
+ rootLogger.info(`[dry-run] Would set secret ${config.secretName} on ${config.conn.repository}`);
3846
+ return {
3847
+ status: "would-set",
3848
+ name: config.secretName,
3849
+ repository: config.conn.repository
3850
+ };
3851
+ }
3852
+ await setActionSecret(fetcher, config.conn, config.secretName, config.secretValue);
3853
+ rootLogger.success(`Set secret ${config.secretName}`);
3854
+ return {
3855
+ status: "set",
3856
+ name: config.secretName,
3857
+ repository: config.conn.repository
3858
+ };
3859
+ }
3860
+ async function runSecretsList(fetcher, config) {
3861
+ const names = await listActionSecrets(fetcher, config.conn);
3862
+ if (names.length === 0) rootLogger.info("No secrets found");
3863
+ else for (const name of names) rootLogger.info(name);
3864
+ return names;
3865
+ }
3866
+ async function runSecretsDelete(fetcher, config) {
3867
+ if (config.dryRun) {
3868
+ rootLogger.info(`[dry-run] Would delete secret ${config.secretName} from ${config.conn.repository}`);
3869
+ return {
3870
+ status: "would-delete",
3871
+ name: config.secretName,
3872
+ repository: config.conn.repository
3873
+ };
3874
+ }
3875
+ await deleteActionSecret(fetcher, config.conn, config.secretName);
3876
+ rootLogger.success(`Deleted secret ${config.secretName}`);
3877
+ return {
3878
+ status: "deleted",
3879
+ name: config.secretName,
3880
+ repository: config.conn.repository
3881
+ };
3882
+ }
3883
+ //#endregion
3884
+ //#region src/commands/secrets.ts
3885
+ /** Read secret value from --body flag or stdin. */
3886
+ function readSecretValue(body) {
3887
+ if (body) return body;
3888
+ try {
3889
+ const value = readFileSync("/dev/stdin", "utf-8").trim();
3890
+ if (value.length === 0) throw new FatalError("No secret value provided. Use --body or pipe a value via stdin.");
3891
+ return value;
3892
+ } catch (error) {
3893
+ if (error instanceof FatalError) throw error;
3894
+ throw new FatalError("No secret value provided. Use --body or pipe a value via stdin.");
3895
+ }
3896
+ }
3897
+ const setCommand = defineCommand({
3898
+ meta: {
3899
+ name: "set",
3900
+ description: "Set a repository secret (reads value from --body or stdin)"
3901
+ },
3902
+ args: {
3903
+ name: {
3904
+ type: "positional",
3905
+ description: "Secret name",
3906
+ required: true
3907
+ },
3908
+ body: {
3909
+ type: "string",
3910
+ description: "Secret value (reads from stdin if omitted)"
3911
+ },
3912
+ "dry-run": {
3913
+ type: "boolean",
3914
+ description: "Show what would be done without making API calls"
3915
+ },
3916
+ verbose: {
3917
+ type: "boolean",
3918
+ description: "Enable verbose output"
3919
+ },
3920
+ json: {
3921
+ type: "boolean",
3922
+ description: "Emit results as JSON instead of a human-readable summary"
3923
+ }
3924
+ },
3925
+ async run({ args }) {
3926
+ const out = createCliOutput(args.json === true);
3927
+ const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj secrets requires a Forgejo repository");
3928
+ const fetcher = cliFetcher();
3929
+ const secretValue = readSecretValue(args.body);
3930
+ const result = await runSecretsSet(fetcher, {
3931
+ conn,
3932
+ secretName: args.name,
3933
+ secretValue,
3934
+ dryRun: args["dry-run"] === true
3935
+ });
3936
+ out.json(result);
3937
+ }
3938
+ });
3939
+ const listCommand = defineCommand({
3940
+ meta: {
3941
+ name: "list",
3942
+ description: "List repository secret names"
3943
+ },
3944
+ args: {
3945
+ verbose: {
3946
+ type: "boolean",
3947
+ description: "Enable verbose output"
3948
+ },
3949
+ json: {
3950
+ type: "boolean",
3951
+ description: "Emit results as JSON instead of a human-readable summary"
3952
+ }
3953
+ },
3954
+ async run({ args }) {
3955
+ const out = createCliOutput(args.json === true);
3956
+ const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj secrets requires a Forgejo repository");
3957
+ const secrets = await runSecretsList(cliFetcher(), { conn });
3958
+ out.json({
3959
+ repository: conn.repository,
3960
+ secrets
3961
+ });
3962
+ }
3963
+ });
3964
+ const deleteCommand = defineCommand({
3965
+ meta: {
3966
+ name: "delete",
3967
+ description: "Delete a repository secret"
3968
+ },
3969
+ args: {
3970
+ name: {
3971
+ type: "positional",
3972
+ description: "Secret name to delete",
3973
+ required: true
3974
+ },
3975
+ "dry-run": {
3976
+ type: "boolean",
3977
+ description: "Show what would be done without making API calls"
3978
+ },
3979
+ verbose: {
3980
+ type: "boolean",
3981
+ description: "Enable verbose output"
3982
+ },
3983
+ json: {
3984
+ type: "boolean",
3985
+ description: "Emit results as JSON instead of a human-readable summary"
3986
+ }
3987
+ },
3988
+ async run({ args }) {
3989
+ const out = createCliOutput(args.json === true);
3990
+ const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj secrets requires a Forgejo repository");
3991
+ const result = await runSecretsDelete(cliFetcher(), {
3992
+ conn,
3993
+ secretName: args.name,
3994
+ dryRun: args["dry-run"] === true
3995
+ });
3996
+ out.json(result);
3997
+ }
3998
+ });
3999
+ const secretsCommand = defineCommand({
4000
+ meta: {
4001
+ name: "secrets",
4002
+ description: "Low-level CRUD for individual Forgejo Actions secrets (set, list, delete). Use bst setup:secrets (in @bensandee/tooling) for initial repo onboarding"
4003
+ },
4004
+ args: { json: {
4005
+ type: "boolean",
4006
+ description: "Emit results as JSON instead of a human-readable summary"
4007
+ } },
4008
+ subCommands: {
4009
+ set: setCommand,
4010
+ list: listCommand,
4011
+ delete: deleteCommand
4012
+ }
4013
+ });
4014
+ //#endregion
4015
+ //#region ../forgejo/src/webhooks.ts
4016
+ const WebhookSchema = z.object({
4017
+ id: z.number(),
4018
+ type: z.string(),
4019
+ active: z.boolean(),
4020
+ config: z.record(z.string(), z.string()).optional()
4021
+ });
4022
+ const WebhookListSchema = z.array(WebhookSchema);
4023
+ /**
4024
+ * Returns a status struct instead of throwing, so it deliberately does not use
4025
+ * {@link forgejoErrorMessage} -- the caller branches on `status` rather than
4026
+ * catching an error.
4027
+ */
4028
+ async function fetchHooks(executor, conn, endpoint) {
4029
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
4030
+ if (res.status === 401 || res.status === 403) return {
4031
+ status: "unauthorized",
4032
+ httpStatus: res.status
4033
+ };
4034
+ if (!res.ok) {
4035
+ const body = await responseBodyText(res);
4036
+ return {
4037
+ status: "error",
4038
+ httpStatus: res.status,
4039
+ message: `${res.statusText}\n${body}`
4040
+ };
4041
+ }
4042
+ const parsed = WebhookListSchema.safeParse(await res.json());
4043
+ if (!parsed.success) throw new UnexpectedError(`Unexpected webhook list response for ${endpoint}: ${parsed.error.message}`);
4044
+ return {
4045
+ status: "ok",
4046
+ hooks: parsed.data
4047
+ };
4048
+ }
4049
+ /** List system-level webhooks. Requires admin token (read:admin scope). */
4050
+ async function listSystemHooks(executor, conn) {
4051
+ return fetchHooks(executor, conn, "/admin/hooks");
4052
+ }
4053
+ /** List webhooks owned by the authenticated user. Requires read:user scope. */
4054
+ async function listUserHooks(executor, conn) {
4055
+ return fetchHooks(executor, conn, "/user/hooks");
4056
+ }
4057
+ /** List webhooks for an organization. Requires read:organization scope. */
4058
+ async function listOrgHooks(executor, conn, org) {
4059
+ return fetchHooks(executor, conn, `/orgs/${encodeURIComponent(org)}/hooks`);
4060
+ }
4061
+ /** List webhooks for a repository. Requires read:repository + repo admin. */
4062
+ async function listRepoHooks(executor, conn, repository) {
4063
+ return fetchHooks(executor, conn, `/repos/${repository}/hooks`);
4064
+ }
4065
+ const AuthenticatedUserSchema = z.object({ login: z.string() });
4066
+ /** Return the login of the authenticated user, or null if the token is invalid. */
4067
+ async function getAuthenticatedUser(executor, conn) {
4068
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, { headers: { Authorization: `token ${conn.token}` } });
4069
+ if (!res.ok) return {
4070
+ ok: false,
4071
+ httpStatus: res.status
4072
+ };
4073
+ const parsed = AuthenticatedUserSchema.safeParse(await res.json());
4074
+ if (!parsed.success) throw new UnexpectedError(`Unexpected /user response: ${parsed.error.message}`);
4075
+ return {
4076
+ ok: true,
4077
+ login: parsed.data.login
4078
+ };
4079
+ }
4080
+ /**
4081
+ * Probe whether the token has admin access by requesting `/admin/users?limit=1`.
4082
+ *
4083
+ * Returns true on 2xx, false on 401/403. Any other status is treated as not-admin
4084
+ * to keep enumeration conservative.
4085
+ */
4086
+ async function hasAdminAccess(executor, conn) {
4087
+ return (await executor.fetch(`${conn.serverUrl}/api/v1/admin/users?limit=1`, { headers: { Authorization: `token ${conn.token}` } })).ok;
4088
+ }
4089
+ const OrgSchema = z.object({ username: z.string() });
4090
+ const OrgListSchema = z.array(OrgSchema);
4091
+ const PAGE_LIMIT = 50;
4092
+ async function paginate(executor, conn, endpoint, verb, extract) {
4093
+ const all = [];
4094
+ let page = 1;
4095
+ const separator = endpoint.includes("?") ? "&" : "?";
4096
+ for (;;) {
4097
+ const url = `${conn.serverUrl}/api/v1${endpoint}${separator}page=${String(page)}&limit=${String(PAGE_LIMIT)}`;
4098
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
4099
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
4100
+ verb,
4101
+ resource: `${endpoint} (page ${String(page)})`
4102
+ }));
4103
+ const items = extract(await res.json());
4104
+ all.push(...items);
4105
+ if (items.length < PAGE_LIMIT) break;
4106
+ page++;
4107
+ }
4108
+ return all;
4109
+ }
4110
+ /**
4111
+ * List organizations. When `mode` is "admin", uses `/admin/orgs` (all orgs on
4112
+ * the instance). When "user", uses `/user/orgs` (only the token user's orgs).
4113
+ */
4114
+ async function listOrgs(executor, conn, mode) {
4115
+ const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
4116
+ return (await paginate(executor, conn, endpoint, "list orgs", (raw) => {
4117
+ const parsed = OrgListSchema.safeParse(raw);
4118
+ if (!parsed.success) throw new UnexpectedError(`Unexpected ${endpoint} response: ${parsed.error.message}`);
4119
+ return parsed.data;
4120
+ })).map((o) => o.username);
4121
+ }
4122
+ const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
4123
+ /** List repositories visible to the token via `/repos/search`. */
4124
+ async function searchRepos(executor, conn) {
4125
+ return (await paginate(executor, conn, "/repos/search", "list repos", (raw) => {
4126
+ const parsed = RepoSearchSchema.safeParse(raw);
4127
+ if (!parsed.success) throw new UnexpectedError(`Unexpected /repos/search response: ${parsed.error.message}`);
4128
+ return parsed.data.data;
4129
+ })).map((r) => r.full_name);
4130
+ }
4131
+ //#endregion
4132
+ //#region src/commands/webhooks.ts
4133
+ /** Render a WebhookFetchStatus, logging warnings for unauthorized/error cases. */
4134
+ function collectGroup(label, scopeHint, status) {
4135
+ if (status.status === "ok") return status.hooks.length > 0 ? {
4136
+ label,
4137
+ hooks: status.hooks
4138
+ } : null;
4139
+ if (status.status === "unauthorized") {
4140
+ rootLogger.warn(`${label}: HTTP ${String(status.httpStatus)} -- token likely missing scope: ${scopeHint}`);
4141
+ return null;
4142
+ }
4143
+ rootLogger.warn(`${label}: HTTP ${String(status.httpStatus)} ${status.message}`);
4144
+ return null;
4145
+ }
4146
+ async function runListWebhooks(fetcher, conn) {
4147
+ const userResult = await getAuthenticatedUser(fetcher, conn);
4148
+ if (!userResult.ok) throw new FatalError(`Token is invalid or lacks read:user scope (HTTP ${String(userResult.httpStatus)})`);
4149
+ const user = userResult.login;
4150
+ const isAdmin = await hasAdminAccess(fetcher, conn);
4151
+ const groups = [];
4152
+ const systemGroup = collectGroup("System", "read:admin", await listSystemHooks(fetcher, conn));
4153
+ if (systemGroup) groups.push(systemGroup);
4154
+ const userGroup = collectGroup(`User: ${user}`, "read:user", await listUserHooks(fetcher, conn));
4155
+ if (userGroup) groups.push(userGroup);
4156
+ const orgs = await listOrgs(fetcher, conn, isAdmin ? "admin" : "user");
4157
+ for (const org of orgs) {
4158
+ const orgGroup = collectGroup(`Org: ${org}`, "read:organization", await listOrgHooks(fetcher, conn, org));
4159
+ if (orgGroup) groups.push(orgGroup);
4160
+ }
4161
+ const repos = await searchRepos(fetcher, conn);
4162
+ for (const repo of repos) {
4163
+ const repoGroup = collectGroup(`Repo: ${repo}`, "read:repository + repo admin", await listRepoHooks(fetcher, conn, repo));
4164
+ if (repoGroup) groups.push(repoGroup);
4165
+ }
4166
+ return {
4167
+ user,
4168
+ isAdmin,
4169
+ groups,
4170
+ totalCount: groups.reduce((sum, g) => sum + g.hooks.length, 0)
4171
+ };
4172
+ }
4173
+ function renderText(inventory, serverUrl) {
4174
+ rootLogger.info(`Forgejo Webhook Inventory -- ${serverUrl}`);
4175
+ rootLogger.info(`Token user: ${inventory.user}${inventory.isAdmin ? " (admin)" : ""}`);
4176
+ if (inventory.groups.length === 0) {
4177
+ rootLogger.info("No webhooks found.");
4178
+ return;
4179
+ }
4180
+ for (const group of inventory.groups) {
4181
+ rootLogger.info(`${group.label} (${String(group.hooks.length)} webhook(s))`);
4182
+ for (const hook of group.hooks) {
4183
+ const url = hook.config?.["url"] ?? "n/a";
4184
+ rootLogger.info(` id=${String(hook.id)} type=${hook.type} active=${String(hook.active)} url=${url}`);
4185
+ }
4186
+ }
4187
+ rootLogger.info(`Total: ${String(inventory.totalCount)} webhook(s)`);
4188
+ if (inventory.isAdmin) rootLogger.info("Note: User-level webhooks for other accounts cannot be enumerated via the API. Check the admin UI or run this command with tokens from individual user accounts.");
4189
+ }
4190
+ //#endregion
4191
+ //#region src/registry.ts
4192
+ const bstFjSubCommands = {
4193
+ "commit:status": commitStatusCommand,
4194
+ labels: labelsCommand,
4195
+ "pr:checks": prChecksCommand,
4196
+ "pr:list": prListCommand,
4197
+ "release:create": releaseCreateCommand,
4198
+ secrets: secretsCommand,
4199
+ webhooks: defineCommand({
4200
+ meta: {
4201
+ name: "webhooks",
4202
+ description: "Enumerate webhooks across a Forgejo instance (system, user, org, and repo levels)"
4203
+ },
4204
+ args: { json: {
4205
+ type: "boolean",
4206
+ description: "Emit results as JSON instead of a human-readable summary"
4207
+ } },
4208
+ async run({ args }) {
4209
+ const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj webhooks requires a Forgejo repository");
4210
+ const inventory = await runListWebhooks(cliFetcher(), conn);
4211
+ if (args.json) {
4212
+ createCliOutput(true).json(inventory);
4213
+ return;
4214
+ }
4215
+ renderText(inventory, conn.serverUrl);
4216
+ }
4217
+ })
4218
+ };
4219
+ //#endregion
4220
+ //#region src/bin.ts
4221
+ applyVerbosityFromArgs(process.argv.slice(2));
4222
+ rootLogger.debug(describeEnvFileLoad(envFileLoad));
4223
+ setForgejoLogger(rootLogger);
4224
+ await runCli({
4225
+ name: "bst-fj",
4226
+ version: BST_FJ_VERSION,
4227
+ description: "Forgejo API commands: labels, secrets, webhooks, releases, PR and commit checks",
4228
+ banner: void 0,
4229
+ subCommands: bstFjSubCommands
4230
+ });
4231
+ //#endregion
4232
+ export {};