@bensandee/forgejo-cli 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin.mjs +99 -2240
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -8,63 +8,33 @@ import { z } from "zod";
8
8
  import { defineCommand, renderUsage, runCommand, runMain } from "citty";
9
9
  import { execFileSync, spawn } from "node:child_process";
10
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
11
  function resolveJsonMode(rawArgs) {
24
12
  if (!rawArgs.some(requestsJson)) return false;
25
13
  if (rawArgs.includes("--help") || rawArgs.includes("-h")) return false;
26
14
  if (rawArgs.length === 1 && (rawArgs[0] === "--version" || rawArgs[0] === "-v")) return false;
27
15
  return true;
28
16
  }
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
17
  function requestsJson(token) {
32
18
  if (token === "--json") return true;
33
19
  if (!token.startsWith("--json=")) return false;
34
20
  return token !== "--json=false";
35
21
  }
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
22
  function resolveCommandName(rawArgs) {
40
23
  for (const arg of rawArgs) {
41
24
  if (arg === "--") return void 0;
42
25
  if (!arg.startsWith("-")) return arg;
43
26
  }
44
27
  }
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
28
  function verboseRequested(rawArgs) {
52
29
  if (process.env["TOOLING_TRACE"] === "true" || process.env["TOOLING_DEBUG"] === "true") return true;
53
30
  return rawArgs.includes("--verbose") || rawArgs.includes("-v") || rawArgs.includes("--trace");
54
31
  }
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
32
  function asCommandDef(value) {
61
33
  if (value === void 0) return void 0;
62
34
  if (typeof value === "function") return void 0;
63
35
  if (value instanceof Promise) return void 0;
64
36
  return value;
65
37
  }
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
38
  function commandDeclaresJson(cmd) {
69
39
  if (cmd === void 0) return false;
70
40
  const argsDef = cmd.args;
@@ -73,10 +43,6 @@ function commandDeclaresJson(cmd) {
73
43
  if (argsDef instanceof Promise) return false;
74
44
  return "json" in argsDef;
75
45
  }
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
46
  function buildErrorDocument(input) {
81
47
  const error = input.detail === void 0 ? {
82
48
  kind: input.kind,
@@ -93,19 +59,6 @@ function buildErrorDocument(input) {
93
59
  error
94
60
  };
95
61
  }
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
62
  function runJsonExitNet(params) {
110
63
  if (params.wroteDocument()) return;
111
64
  const label = params.command ?? params.binName;
@@ -120,8 +73,6 @@ function runJsonExitNet(params) {
120
73
  } catch (_writeError) {}
121
74
  process.exitCode = 1;
122
75
  }
123
- /** Install {@link runJsonExitNet} as a `process` `"exit"` listener, writing synchronously to fd 1
124
- * via `node:fs`. */
125
76
  function installJsonExitNet(binName, command, version, wroteDocument) {
126
77
  process.on("exit", () => {
127
78
  runJsonExitNet({
@@ -133,11 +84,6 @@ function installJsonExitNet(binName, command, version, wroteDocument) {
133
84
  });
134
85
  });
135
86
  }
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
87
  function classifyThrown(error, verbose) {
142
88
  const kind = error instanceof Error && error.name === "CLIError" ? "usage" : "runtime";
143
89
  const message = redact(describeError(error));
@@ -157,23 +103,7 @@ function classifyThrown(error, verbose) {
157
103
  }
158
104
  //#endregion
159
105
  //#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
106
  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
107
  function loadEnvFileFromEnv() {
178
108
  const raw = process.env[ENV_FILE_VAR]?.trim();
179
109
  if (raw === void 0 || raw === "") return void 0;
@@ -197,12 +127,6 @@ function loadEnvFileFromEnv() {
197
127
  kept
198
128
  };
199
129
  }
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
130
  function describeEnvFileLoad(load) {
207
131
  if (load === void 0) return `No env file loaded -- ${ENV_FILE_VAR} is unset or empty.`;
208
132
  const parts = [`Loaded ${String(load.added.length)} variable(s) from ${load.path} (${ENV_FILE_VAR}): ` + (load.added.length === 0 ? "none" : load.added.join(", "))];
@@ -218,12 +142,6 @@ const processIo = {
218
142
  },
219
143
  exit: (code) => process.exit(code)
220
144
  };
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
145
  function loadEnvFileForBin(rawArgs, bin, io = processIo) {
228
146
  try {
229
147
  return loadEnvFileFromEnv();
@@ -245,7 +163,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
245
163
  //#region src/version.ts
246
164
  const parsed = z.object({ version: z.string() }).safeParse({
247
165
  name: "@bensandee/forgejo-cli",
248
- version: "0.4.2",
166
+ version: "0.4.4",
249
167
  description: "bst-fj: Forgejo API commands without the rest of bst",
250
168
  license: "MIT",
251
169
  repository: {
@@ -285,20 +203,9 @@ const parsed = z.object({ version: z.string() }).safeParse({
285
203
  }
286
204
  });
287
205
  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
206
  const BST_FJ_VERSION = parsed.data.version;
290
207
  //#endregion
291
208
  //#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
209
  const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
303
210
  name: "bst-fj",
304
211
  version: BST_FJ_VERSION
@@ -313,17 +220,10 @@ const RANK = {
313
220
  trace: 4
314
221
  };
315
222
  let moduleLevel = "info";
316
- /** Set the process-global minimum level (default `info`). Read live by every non-pinned logger. */
317
223
  function setLogLevel(level) {
318
224
  moduleLevel = level;
319
225
  }
320
226
  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
227
  function resolveLogLevel(flags) {
328
228
  if (flags.trace === true || process.env["TOOLING_TRACE"] === "true") return "trace";
329
229
  if (flags.verbose === true || process.env["TOOLING_DEBUG"] === "true") return "debug";
@@ -332,8 +232,6 @@ function resolveLogLevel(flags) {
332
232
  const isCI = Boolean(process.env["CI"]);
333
233
  const out = (msg) => console.log(msg);
334
234
  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
235
  const plainUserSink = {
338
236
  info: out,
339
237
  warn: (msg) => out(`[warn] ${msg}`),
@@ -342,33 +240,16 @@ const plainUserSink = {
342
240
  };
343
241
  let plainOutput = isCI;
344
242
  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
243
  function setJsonMode(enabled) {
348
244
  jsonMode = enabled;
349
245
  }
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
246
  function isJsonMode() {
353
247
  return jsonMode;
354
248
  }
355
249
  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
250
  function setJsonNoticeSink(sink) {
359
251
  jsonNoticeSink = sink;
360
252
  }
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
253
  function formatContext(context) {
373
254
  const parts = [];
374
255
  if (context.repo !== void 0) {
@@ -419,7 +300,6 @@ function build(config) {
419
300
  })
420
301
  };
421
302
  }
422
- /** Create a {@link Logger}. All seams default to real sinks and the live global level. */
423
303
  function createLogger(options = {}) {
424
304
  return build({
425
305
  level: options.level,
@@ -430,36 +310,9 @@ function createLogger(options = {}) {
430
310
  now: options.now ?? (() => /* @__PURE__ */ new Date())
431
311
  });
432
312
  }
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
313
  const rootLogger = createLogger();
436
314
  //#endregion
437
315
  //#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
316
  const UNIVERSAL_FLAGS = [
464
317
  "--help",
465
318
  "-h",
@@ -468,8 +321,6 @@ const UNIVERSAL_FLAGS = [
468
321
  "--verbose",
469
322
  "--trace"
470
323
  ];
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
324
  const UNIVERSAL_NAMES = new Set(UNIVERSAL_FLAGS.map(flagName));
474
325
  function hasHelpFlag(rawArgs) {
475
326
  return rawArgs.includes("--help") || rawArgs.includes("-h");
@@ -477,12 +328,6 @@ function hasHelpFlag(rawArgs) {
477
328
  function isLoneVersionQuery(rawArgs) {
478
329
  return rawArgs.length === 1 && (rawArgs[0] === "--version" || rawArgs[0] === "-v");
479
330
  }
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
331
  function splitAtCommand(rawArgs, argsDef) {
487
332
  for (let i = 0; i < rawArgs.length; i++) {
488
333
  const token = rawArgs[i];
@@ -497,7 +342,6 @@ function splitAtCommand(rawArgs, argsDef) {
497
342
  };
498
343
  }
499
344
  }
500
- /** citty's `_isValueFlag`: whether `token` names a declared arg whose value is a separate token. */
501
345
  function declaresValueFlag(token, argsDef) {
502
346
  const name = flagName(token);
503
347
  const normalized = toCamelCase(name);
@@ -517,9 +361,6 @@ function flagBase(token) {
517
361
  function flagName(token) {
518
362
  return flagBase(token).replace(/^-{1,2}/, "");
519
363
  }
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
364
  function resolveStaticArgsDef(cmd) {
524
365
  const argsDef = cmd.args;
525
366
  if (argsDef === void 0) return void 0;
@@ -527,28 +368,12 @@ function resolveStaticArgsDef(cmd) {
527
368
  if (argsDef instanceof Promise) return void 0;
528
369
  return argsDef;
529
370
  }
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
371
  function toCamelCase(name) {
536
372
  return name.replace(/-([a-z0-9])/g, (_match, ch) => ch.toUpperCase());
537
373
  }
538
374
  function toKebabCase(name) {
539
375
  return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
540
376
  }
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
377
  function buildParseTable(argsDef) {
553
378
  const booleanNames = /* @__PURE__ */ new Set();
554
379
  const aliasesByMain = /* @__PURE__ */ new Map();
@@ -588,12 +413,6 @@ function buildParseTable(argsDef) {
588
413
  acceptedFlags
589
414
  };
590
415
  }
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
416
  function parsePostCommandArgs(postCommandArgs, nodeOptions) {
598
417
  const negatedNames = [];
599
418
  const processedArgs = [];
@@ -629,9 +448,6 @@ function parsePostCommandArgs(postCommandArgs, nodeOptions) {
629
448
  negatedNames
630
449
  };
631
450
  }
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
451
  function findBadDirArg(postCommandArgs, argsDef, nodeOptions, probe) {
636
452
  const { positionals } = parsePostCommandArgs(postCommandArgs, nodeOptions);
637
453
  const declared = Object.entries(argsDef).filter(([, def]) => def.type === "positional");
@@ -646,16 +462,6 @@ function findBadDirArg(postCommandArgs, argsDef, nodeOptions, probe) {
646
462
  };
647
463
  }
648
464
  }
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
465
  function computeUnknownArgNames(postCommandArgs, argsDef) {
660
466
  const table = buildParseTable(argsDef);
661
467
  const { values, negatedNames } = parsePostCommandArgs(postCommandArgs, table.nodeOptions);
@@ -664,9 +470,6 @@ function computeUnknownArgNames(postCommandArgs, argsDef) {
664
470
  for (const name of negatedNames) if (!table.knownNames.has(name)) unknown.add(name);
665
471
  return [...unknown];
666
472
  }
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
473
  function buildDisplayTokenMap(postCommandArgs) {
671
474
  const map = /* @__PURE__ */ new Map();
672
475
  for (const token of postCommandArgs) {
@@ -682,15 +485,9 @@ function buildDisplayTokenMap(postCommandArgs) {
682
485
  }
683
486
  return map;
684
487
  }
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
488
  function formatFlagToken(bareName) {
689
489
  return bareName.length === 1 ? `-${bareName}` : `--${bareName}`;
690
490
  }
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
491
  function resolveStaticSubCommands(cmd) {
695
492
  const subCommands = cmd.subCommands;
696
493
  if (subCommands === void 0) return void 0;
@@ -698,16 +495,6 @@ function resolveStaticSubCommands(cmd) {
698
495
  if (subCommands instanceof Promise) return void 0;
699
496
  return subCommands;
700
497
  }
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
498
  function resolveLeafCommand(cmd, chain, postCommandArgs) {
712
499
  const argsDef = resolveStaticArgsDef(cmd);
713
500
  const subCommands = resolveStaticSubCommands(cmd);
@@ -727,24 +514,6 @@ function resolveLeafCommand(cmd, chain, postCommandArgs) {
727
514
  postCommandArgs
728
515
  };
729
516
  }
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
517
  function findUnknownFlags(rawArgs, subCommands, probe) {
749
518
  if (hasHelpFlag(rawArgs) || isLoneVersionQuery(rawArgs)) return { kind: "none" };
750
519
  const split = splitAtCommand(rawArgs);
@@ -781,50 +550,22 @@ function findUnknownFlags(rawArgs, subCommands, probe) {
781
550
  accepted: table.acceptedFlags
782
551
  };
783
552
  }
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
553
  function formatUnknownFlagsMessage(result) {
792
554
  const first = `Unknown ${result.unknownFlags.length === 1 ? "flag" : "flags"} ${result.unknownFlags.join(", ")} for ${result.commandPath}. Nothing ran.`;
793
555
  const extras = UNIVERSAL_FLAGS.filter((flag) => flag.startsWith("--") && flag !== "--version" && !result.accepted.includes(flag));
794
556
  return `${first}\nAccepted: ${extras.length === 0 ? result.accepted.join(", ") : `${result.accepted.join(", ")} (plus ${extras.join(", ")})`}.`;
795
557
  }
796
- /** `--json must come after the command name.` */
797
558
  function formatFlagBeforeCommandMessage(result) {
798
559
  return `${result.flag} must come after the command name.`;
799
560
  }
800
- /** `No such directory: nope. ascii:check takes a directory as [dir]. Nothing ran.` */
801
561
  function formatNotADirectoryMessage(result) {
802
562
  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
563
  }
804
- /** The single message any non-`"none"` variant renders, so `run.ts` needs one call
805
- * regardless of which shape the check found. */
806
564
  function formatFlagCheckMessage(result) {
807
565
  if (result.kind === "before-command") return formatFlagBeforeCommandMessage(result);
808
566
  if (result.kind === "not-a-directory") return formatNotADirectoryMessage(result);
809
567
  return formatUnknownFlagsMessage(result);
810
568
  }
811
- /**
812
- * Collect every occurrence of a repeated named flag from a command's raw CLI args.
813
- *
814
- * citty@0.2.2 keeps only the LAST occurrence of a repeated named flag (see
815
- * `collectInputArgs` in `release/publish/dispatch.ts`, which works around the same issue for
816
- * `--input`), so a declared string arg cannot capture `--label bug --label chore` -- it
817
- * silently yields just `chore`. Scanning the raw argv directly recovers all occurrences.
818
- * Both `--flag v` (two tokens) and `--flag=v` (one token) forms are supported.
819
- * Blank/whitespace-only values are dropped rather than passed through as an empty value.
820
- *
821
- * Deliberately does not comma-split a value the way `parseListFlag`
822
- * (`commands/repo-run-checks.ts`) does: comma-splitting is right for that flag's contract
823
- * and wrong here, where a value can be a file path containing a comma.
824
- *
825
- * Stops at a `--` sentinel, the same one {@link hasNegationFlag} stops at: a token after it is a
826
- * positional, never a flag citty would read.
827
- */
828
569
  function collectFlagValues(rawArgs, flag) {
829
570
  const flagEq = `${flag}=`;
830
571
  const values = [];
@@ -842,12 +583,6 @@ function collectFlagValues(rawArgs, flag) {
842
583
  }
843
584
  return values.map((v) => v.trim()).filter((v) => v !== "");
844
585
  }
845
- /**
846
- * Whether a bare boolean flag (e.g. `--no-assignee`) appears in a command's raw CLI args, the
847
- * same `--` sentinel `parsePostCommandArgs` and {@link collectFlagValues} stop at rather than a
848
- * bare `rawArgs.includes(flag)`: the literal token can otherwise match where citty would never
849
- * read it as a flag -- after a `--` sentinel.
850
- */
851
586
  function hasNegationFlag(rawArgs, flag) {
852
587
  for (const arg of rawArgs) {
853
588
  if (arg === "--") break;
@@ -855,9 +590,6 @@ function hasNegationFlag(rawArgs, flag) {
855
590
  }
856
591
  return false;
857
592
  }
858
- /** True when `flag` (or `flag=...`) appears in `rawArgs` before any `--`, whatever it resolved
859
- * to. `collectFlagValues` drops a blank value, so its list alone cannot tell "flag not given"
860
- * from "flag given, resolved to nothing". */
861
593
  function flagPresent(rawArgs, flag) {
862
594
  for (const arg of rawArgs) {
863
595
  if (arg === "--") break;
@@ -865,20 +597,6 @@ function flagPresent(rawArgs, flag) {
865
597
  }
866
598
  return false;
867
599
  }
868
- /**
869
- * Resolve a repeatable value flag and its boolean negation (`--assignee` / `--no-assignee`) from
870
- * raw args, because citty keeps only the last value of a repeated flag and writes a bare `false`
871
- * for the negation.
872
- *
873
- * - The flag and its negation together throw: a contradiction for the user, not the tool, to
874
- * resolve.
875
- * - The flag present but resolving to no value (an unset `$VAR`, or a trailing flag) throws, so a
876
- * caller never falls through to a default on a half-typed command.
877
- * - Otherwise the result says whether the flag was absent, negated, or carried values. What
878
- * "absent" means (a default, or nothing) is the caller's rule.
879
- *
880
- * `wording` names what the flag carries, for the refusal of a flag with no value.
881
- */
882
600
  function resolveRepeatableFlag(rawArgs, flag, negation, wording) {
883
601
  const values = collectFlagValues(rawArgs, flag);
884
602
  const negated = hasNegationFlag(rawArgs, negation);
@@ -894,18 +612,12 @@ function resolveRepeatableFlag(rawArgs, flag, negation, wording) {
894
612
  };
895
613
  return { kind: "absent" };
896
614
  }
897
- /** `<n>[s|m|h]`, a bare integer meaning seconds. */
898
615
  const DURATION_PATTERN = /^(\d+)(s|m|h)?$/;
899
616
  const UNIT_MS = {
900
617
  s: 1e3,
901
618
  m: 6e4,
902
619
  h: 36e5
903
620
  };
904
- /**
905
- * Parse a `--timeout`/`--interval`-shaped duration (`<n>[s|m|h]`, a bare integer meaning seconds)
906
- * into milliseconds. Anything else is a `FatalError` naming `flag`, so an unreadable value throws
907
- * into the standard error document rather than silently falling back to a default.
908
- */
909
621
  function parseDuration(value, flag) {
910
622
  const match = DURATION_PATTERN.exec(value.trim());
911
623
  if (!match) throw new FatalError(`${flag} expects a duration like "30s", "15m", or "1h" (a bare integer means seconds) -- got ${JSON.stringify(value)}.`);
@@ -913,31 +625,6 @@ function parseDuration(value, flag) {
913
625
  const multiplier = UNIT_MS[unit ?? "s"];
914
626
  return Number.parseInt(amount ?? "0", 10) * multiplier;
915
627
  }
916
- /**
917
- * Read a repeatable comma-separated list flag (`--skip`, `--add`) out of the RAW argv, unioning
918
- * every occurrence.
919
- *
920
- * **The union is the point, and the raw argv is the only place it survives.** pnpm appends
921
- * CI-forwarded flags to the END of the resolved script, so a repo whose `check` script already
922
- * carries `--skip build` sees TWO occurrences the moment CI or an agent adds `--skip 'docker:*'`.
923
- * citty declares this arg as `type: "string"` and collapses a repeated one to the LAST value
924
- * before the handler ever runs -- probed against citty 0.2.2, `--skip build --skip 'docker:*'`
925
- * arrives as the bare string `"docker:*"`, with no array and no trace of the first occurrence.
926
- * So reading `args.skip` cannot be made additive; the fix has to go to `rawArgs`. That collapse
927
- * is how this repo's `ci:check` came to run a second full build on every CI run, and it
928
- * contradicts the semantics the `.tooling.json#checks.skip` path already documents: a skip is
929
- * additive everywhere else, so it must be additive here too.
930
- *
931
- * Handles both spellings (`--skip a` and `--skip=a`), stops at a bare `--`, and ignores an
932
- * occurrence whose value is missing. A malformed flag contributes nothing rather than throwing
933
- * -- it must not take down a check run.
934
- *
935
- * "Missing" is specifically a following token starting with `--`, not one starting with `-`.
936
- * The looser test reads `--skip -x` as a forgotten value and silently drops a skip the caller
937
- * did ask for, and it makes the two spellings disagree: `--skip=-x` has no such ambiguity and
938
- * would keep the value. Only a long flag can be mistaken for the next argument, so only a long
939
- * flag terminates.
940
- */
941
628
  function parseListFlag(rawArgs, flag) {
942
629
  const long = `--${flag}`;
943
630
  const values = [];
@@ -953,12 +640,6 @@ function parseListFlag(rawArgs, flag) {
953
640
  }
954
641
  return values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value.length > 0);
955
642
  }
956
- /** True when `--<flag>` (or `--<flag>=...`) appears anywhere in `rawArgs`, independent of what it
957
- * resolved to. `parseListFlag` silently drops a value that resolves to empty -- an unset `$VAR` in
958
- * `--repo "$REPO"`, or a trailing `--repo` whose value fell off the line -- so a caller that only
959
- * checks the parsed list cannot tell "no --repo given" apart from "a --repo given that resolved to
960
- * nothing" and falls back to the current repo with no word said (the same failure shape
961
- * `parseRepoTarget`, in `@bensandee/forgejo/connection`, refuses for the issue commands). */
962
643
  function listFlagPresent(rawArgs, flag) {
963
644
  const long = `--${flag}`;
964
645
  return rawArgs.some((token) => token === long || token.startsWith(`${long}=`));
@@ -998,9 +679,6 @@ function createCliOutput(jsonMode) {
998
679
  }
999
680
  };
1000
681
  }
1001
- /** Replace what {@link flushJsonOutput} writes with an error document, discarding any payload
1002
- * already recorded by `json()` -- buffered notices are not discarded, they fold into the error
1003
- * document the same way they fold into a payload. */
1004
682
  function recordJsonError(document) {
1005
683
  state.error = document;
1006
684
  state.hasError = true;
@@ -1020,9 +698,6 @@ function buildFlushDocument() {
1020
698
  notices: state.notices
1021
699
  };
1022
700
  }
1023
- /** Write the single recorded document -- the error document if `recordJsonError` was called,
1024
- * otherwise the recorded payload, each with buffered notices folded in under `notices` when
1025
- * the buffer is non-empty -- and report whether anything was written. */
1026
701
  function flushJsonOutput() {
1027
702
  const document = buildFlushDocument();
1028
703
  if (document === void 0) return false;
@@ -1030,18 +705,11 @@ function flushJsonOutput() {
1030
705
  state.wrote = true;
1031
706
  return true;
1032
707
  }
1033
- /** Whether {@link flushJsonOutput} has already written a document this process -- the guard the
1034
- * premature-exit net (`@bensandee/cli-kit/json`) reads to tell a normal flush from a process that
1035
- * died with the command still outstanding. */
1036
708
  function jsonDocumentWritten() {
1037
709
  return state.wrote;
1038
710
  }
1039
711
  //#endregion
1040
712
  //#region ../cli-kit/src/path-probe.ts
1041
- /** The real {@link PathKind} probe the pre-run argument check uses. A path that exists but is not
1042
- * a directory (a file, a socket) reads as `"file"`. A path stat cannot resolve at all -- a symlink
1043
- * loop, an unsearchable parent -- reads as `"missing"`: `throwIfNoEntry` covers ENOENT and ENOTDIR
1044
- * only, and a stat failure here would surface as a crash where the check owes a usage error. */
1045
713
  function probeCliPath(value) {
1046
714
  try {
1047
715
  const stat = statSync(value, { throwIfNoEntry: false });
@@ -1053,7 +721,6 @@ function probeCliPath(value) {
1053
721
  }
1054
722
  //#endregion
1055
723
  //#region ../cli-kit/src/run.ts
1056
- /** {@link classifyThrown}, with the bin's `describeError` hook's wording swapped in for the message. */
1057
724
  function classifyWithHook(error, verbose, hook) {
1058
725
  const classified = classifyThrown(error, verbose);
1059
726
  const worded = hook?.(error);
@@ -1062,55 +729,17 @@ function classifyWithHook(error, verbose, hook) {
1062
729
  message: redact(worded)
1063
730
  };
1064
731
  }
1065
- /**
1066
- * Wait until a stream's internally-buffered data has been flushed to the OS.
1067
- *
1068
- * On Linux, `process.stdout`/`process.stderr` are ASYNCHRONOUS when the fd is
1069
- * a pipe (only files and TTYs get synchronous writes). A `write("", cb)` call
1070
- * queues behind any pending writes and invokes `cb` once the underlying fd has
1071
- * actually accepted everything buffered so far -- including when nothing was
1072
- * pending, so this is safe to call unconditionally. Errors (e.g. EPIPE from a
1073
- * reader that closed early, such as `| head`) are swallowed: there is nothing
1074
- * further to flush in that case, and `runCli()` below must not throw here.
1075
- */
1076
732
  function drain(stream) {
1077
733
  return new Promise((resolve) => {
1078
734
  stream.write("", () => resolve());
1079
735
  });
1080
736
  }
1081
- /**
1082
- * Raise the process log level to what `--verbose`/`-v`/`--trace` in `rawArgs` ask for.
1083
- *
1084
- * `--verbose`/`-v`/`--trace` are universal (see flags.ts): honoured on every command whether
1085
- * or not it declares any of them, not just the ones that parse them from their own args. Resolving
1086
- * the level from raw argv, in addition to the environment-only default at module load, is what
1087
- * makes that carve-out honest rather than a flag the CLI accepts but never acts on. `-v` reads as
1088
- * verbose here and as citty's version query only when it is the whole argv, which is the sole
1089
- * shape citty's `runMain` intercepts -- so the two never contend.
1090
- *
1091
- * Exported because each bin must call it **before** {@link runCli}: a bin reports its
1092
- * `$BST_ENV_FILE` load at module top level, which is ahead of anything that parses a flag, so
1093
- * without this call that one line is the only diagnostic in the CLI the flags cannot reach.
1094
- * Idempotent, and deliberately called twice -- `runCli` resolves the same level again off the
1095
- * same argv, so a caller that is not a bin is unaffected.
1096
- */
1097
737
  function applyVerbosityFromArgs(rawArgs) {
1098
738
  setLogLevel(resolveLogLevel({
1099
739
  verbose: rawArgs.includes("--verbose") || rawArgs.includes("-v"),
1100
740
  trace: rawArgs.includes("--trace")
1101
741
  }));
1102
742
  }
1103
- /**
1104
- * Wrap a command so a `FatalError` or `TransientError` it throws prints as one line on stderr and
1105
- * sets exit code 1, instead of reaching citty's `runMain`, which prints the whole stack.
1106
- *
1107
- * The guard sits on each command's own `run`, and not around `runMain`, because `runMain` is also
1108
- * what answers `--help` and `--version` and renders usage for a `CLIError`. Wrapping the commands
1109
- * leaves all three untouched. Sub-commands are wrapped lazily, as citty resolves them.
1110
- *
1111
- * Everything else keeps citty's trace: `UnexpectedError`, an unclassified error, and any error
1112
- * under `--verbose`, where the trace is what was asked for.
1113
- */
1114
743
  function guardCommand(def, verbose, hook) {
1115
744
  const { run, subCommands } = def;
1116
745
  const guarded = { ...def };
@@ -1132,20 +761,10 @@ function guardCommand(def, verbose, hook) {
1132
761
  };
1133
762
  return guarded;
1134
763
  }
1135
- /**
1136
- * The text up to and including the first period that is followed by whitespace and an uppercase
1137
- * letter, or all of it when there is none. The "x" in "e.g. x" is lowercase, so "e.g." and
1138
- * "i.e." do not end a sentence.
1139
- */
1140
764
  function firstSentence(text) {
1141
765
  const match = /^[\s\S]*?\.(?=\s+[A-Z])/.exec(text);
1142
766
  return match === null ? text : match[0];
1143
767
  }
1144
- /**
1145
- * Render `cmd`'s usage with each subcommand's description cut to its first sentence, so the
1146
- * command table stays one short row per command. `cmd`'s own description stays whole, and a
1147
- * subcommand's children are cut the same way when its own help lists them.
1148
- */
1149
768
  async function renderTableUsage(cmd, parent) {
1150
769
  return renderUsage(shortenChildren(cmd), parent);
1151
770
  }
@@ -1176,19 +795,6 @@ function shortenChildren(def) {
1176
795
  async function showTableUsage(cmd, parent) {
1177
796
  console.log(await renderTableUsage(cmd, parent) + "\n");
1178
797
  }
1179
- /**
1180
- * Build and run a bin's CLI from its {@link RunCliConfig}.
1181
- *
1182
- * citty only calls process.exit() for --help and errors. On normal completion
1183
- * a command may leave the event loop alive (bst's @clack/prompts leaves readline
1184
- * interfaces, signal listeners and spinner intervals), so we still call
1185
- * process.exit() explicitly -- but only after draining stdout/stderr. Exiting
1186
- * immediately after `runMain` resolves used to truncate piped `--json` output
1187
- * at the ~64KB pipe buffer:
1188
- * `process.stdout.write(bigJson)` returns before the OS has accepted all of it,
1189
- * and `process.exit()` discards whatever is still queued. See tooling-cli's
1190
- * test/bin-json-piping.test.ts.
1191
- */
1192
798
  async function runCli(config) {
1193
799
  const allSubCommands = config.subCommands;
1194
800
  const main = defineCommand({
@@ -1199,12 +805,6 @@ async function runCli(config) {
1199
805
  },
1200
806
  subCommands: allSubCommands
1201
807
  });
1202
- /**
1203
- * Render a `--json` error document and exit -- the shared tail every JSON-mode rejection here
1204
- * needs, whether from the strict-flag check, the `--json`-support check, a thrown error
1205
- * mid-command, or a command that recorded no payload. `never` return type: every call site
1206
- * ends the process, so nothing after a call to this needs its own unreachable-code guard.
1207
- */
1208
808
  const exitWithJsonError = async (command, kind, message, detail) => {
1209
809
  recordJsonError(buildErrorDocument({
1210
810
  command,
@@ -1266,7 +866,6 @@ let current = {
1266
866
  function setForgejoLogger(logger) {
1267
867
  current = logger;
1268
868
  }
1269
- /** Stable call-site surface: modules bind `log` once; injection swaps the target underneath. */
1270
869
  const log = {
1271
870
  warn(message) {
1272
871
  current.warn(message);
@@ -1291,42 +890,15 @@ function parseGitUrl(urlStr) {
1291
890
  return null;
1292
891
  }
1293
892
  }
1294
- /**
1295
- * The one place RELEASE_TOKEN / FORGEJO_TOKEN precedence lives. `||` (not `??`) on
1296
- * purpose: CI injects RELEASE_TOKEN="" for an unset secret, which must fall through to
1297
- * FORGEJO_TOKEN instead of masking it.
1298
- */
1299
893
  function forgeTokenFromEnv(env) {
1300
894
  return env["RELEASE_TOKEN"] || env["FORGEJO_TOKEN"];
1301
895
  }
1302
- /**
1303
- * The repository URL a caller acts on: the `origin` remote first, since a package may advertise a
1304
- * public mirror rather than the host its commands act on; the package manifest's URL only as a
1305
- * last resort. `null` when neither parses. Both reads are thunks because this module does no I/O,
1306
- * and the second is called only when the first does not parse: an eager read would let a broken
1307
- * manifest fail a repository whose `origin` already resolves. `readOrigin` returns the remote as
1308
- * written, and this function normalizes it.
1309
- */
1310
896
  function resolveRepoUrlFrom(readOrigin, readPackageUrl) {
1311
897
  const originUrl = normalizeGitUrl(readOrigin() ?? "");
1312
898
  if (originUrl !== null && parseGitUrl(originUrl) !== null) return originUrl;
1313
899
  const pkgUrl = readPackageUrl();
1314
900
  return pkgUrl !== null && parseGitUrl(pkgUrl) !== null ? pkgUrl : null;
1315
901
  }
1316
- /**
1317
- * Resolve the Forgejo connection from the environment and one repository URL the caller finds
1318
- * (git remote origin, else package.json; this module does no I/O). `repoUrl` is a thunk, called
1319
- * only once the environment has not settled the connection, so a complete environment never
1320
- * costs a git subprocess or a package.json read (and never fails on a malformed one).
1321
- *
1322
- * Priority:
1323
- * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, RELEASE_TOKEN)
1324
- * 2. `repoUrl` (server URL and owner/repo parsed from it)
1325
- *
1326
- * For Forgejo, a token is always required ({@link forgeTokenFromEnv}). Returns `null` where the
1327
- * repository is on github.com or there is no repository URL at all: the caller treats that as
1328
- * GitHub (the gh CLI handles auth).
1329
- */
1330
902
  function resolveForgejoConnection(env, repoUrl) {
1331
903
  const serverUrl = env["FORGEJO_SERVER_URL"];
1332
904
  const repository = env["FORGEJO_REPOSITORY"];
@@ -1354,7 +926,6 @@ function resolveForgejoConnection(env, repoUrl) {
1354
926
  };
1355
927
  }
1356
928
  const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/(?:issues|pulls?)\/(\d+)\/?$/;
1357
- /** Parse a bare issue number or a full issue URL. A URL also carries repo coordinates. */
1358
929
  function parseIssueRef(input) {
1359
930
  if (input === void 0 || input.trim() === "") return {};
1360
931
  const value = input.trim();
@@ -1382,21 +953,11 @@ function parseIssueRef(input) {
1382
953
  }
1383
954
  throw new FatalError(`Not an issue number or issue URL: "${value}" (expected e.g. 273 or https://host/owner/repo/issues/273)`);
1384
955
  }
1385
- /**
1386
- * A repository target that was given but blank. The message names no flag: the library does not
1387
- * know which program, or which option, a caller received the value through, so each caller words
1388
- * the refusal for its own surface.
1389
- */
1390
956
  var RepoTargetEmptyError = class extends FatalError {
1391
957
  constructor() {
1392
958
  super("No repository URL was given.");
1393
959
  }
1394
960
  };
1395
- /**
1396
- * An explicit repository target that names a different repository than the issue URL beside it.
1397
- * Both fields are `serverUrl/repository`. The message names no flag, for the reason
1398
- * {@link RepoTargetEmptyError} gives.
1399
- */
1400
961
  var RepoTargetMismatchError = class extends FatalError {
1401
962
  given;
1402
963
  fromReference;
@@ -1406,14 +967,6 @@ var RepoTargetMismatchError = class extends FatalError {
1406
967
  this.fromReference = fromReference;
1407
968
  }
1408
969
  };
1409
- /**
1410
- * Parse a repository URL option value into a target. `undefined` -- the option absent -- is the
1411
- * only input meaning "not given": a present but blank value refuses instead of resolving from the
1412
- * working directory, because both shapes that produce one come from a caller who believes they
1413
- * targeted another repository: an unset shell variable expands to `""`, and so does an option
1414
- * whose value fell off the end of the line and was coerced to an empty string. Answering either
1415
- * from the current repo is the same number, a different object, and not a word said.
1416
- */
1417
970
  function parseRepoTarget(input) {
1418
971
  if (input === void 0) return void 0;
1419
972
  if (input.trim() === "") throw new RepoTargetEmptyError();
@@ -1427,17 +980,6 @@ function parseRepoTarget(input) {
1427
980
  hostname: new URL(serverUrl).hostname
1428
981
  };
1429
982
  }
1430
- /**
1431
- * Reconcile an issue ref's own target (from an issue URL) with an explicit repository option.
1432
- * Letting `ref.target ?? parseRepoTarget(repo)` discard the option the moment the ref carries a
1433
- * target of its own is silent: a caller who passes both a URL and a disagreeing option gets an
1434
- * answer from the URL's repository with no sign the option was ignored.
1435
- *
1436
- * Absent one side, the other wins outright. Present on both sides, agreement (compared on the
1437
- * parsed `serverUrl`/`repository`, not the raw strings, so a trailing slash or `.git` -- both of
1438
- * which `parseRepoTarget` already normalizes away -- is never mistaken for a disagreement) resolves
1439
- * quietly; disagreement throws, naming both repositories rather than picking one without a word.
1440
- */
1441
983
  function resolveIssueTarget(refTarget, repo) {
1442
984
  const repoTarget = parseRepoTarget(repo);
1443
985
  if (refTarget === void 0) return repoTarget;
@@ -1445,10 +987,6 @@ function resolveIssueTarget(refTarget, repo) {
1445
987
  if (refTarget.serverUrl === repoTarget.serverUrl && refTarget.repository === repoTarget.repository) return refTarget;
1446
988
  throw new RepoTargetMismatchError(`${repoTarget.serverUrl}/${repoTarget.repository}`, `${refTarget.serverUrl}/${refTarget.repository}`);
1447
989
  }
1448
- /**
1449
- * Normalize a git remote URL to a clean HTTPS URL.
1450
- * Handles SSH (git@host:owner/repo.git) and HTTPS variants.
1451
- */
1452
990
  function normalizeGitUrl(raw) {
1453
991
  const sshMatch = raw.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
1454
992
  if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
@@ -1464,53 +1002,22 @@ function normalizeGitUrl(raw) {
1464
1002
  }
1465
1003
  //#endregion
1466
1004
  //#region src/describe-forge-error.ts
1467
- /**
1468
- * The text each `--repo` refusal carries on this bin. The library names no flag, so this is the
1469
- * one place the wording lives; the same shape sits beside `bst-fj`'s entry.
1470
- */
1471
1005
  function describeForgeError(error) {
1472
1006
  if (error instanceof RepoTargetEmptyError) return "--repo was given with no repository URL (expected e.g. https://host/owner/repo).";
1473
1007
  if (error instanceof RepoTargetMismatchError) return `--repo disagrees with the issue URL: --repo names ${error.given}, the issue URL names ${error.fromReference}.`;
1474
1008
  }
1475
1009
  //#endregion
1476
1010
  //#region ../forgejo/src/fetch-retry.ts
1477
- /** Production policy: 5 attempts, 250ms->2s exponential backoff (four gaps, ~3.75s total). */
1478
1011
  const DEFAULT_RETRY_POLICY = {
1479
1012
  attempts: 5,
1480
1013
  baseDelayMs: 250,
1481
1014
  maxDelayMs: 2e3
1482
1015
  };
1483
- /**
1484
- * Per-request wall-clock timeout for every forge fetch -- a backstop against an *infinite* hang,
1485
- * not a tight SLA. `globalThis.fetch` has no timeout, so a stalled keep-alive socket (the peer
1486
- * sent FIN but never answers) makes a request neither resolve nor reject: the caller then hangs
1487
- * forever on it, and `retryOnTransient` cannot help because it only catches *thrown* errors, not
1488
- * an indefinite stall. With the timeout, a retried read gets a fresh attempt; a non-retried
1489
- * write surfaces the timeout as its failure. Generous (healthy forge calls finish in well under a
1490
- * second) so it never trips a slow-but-live request; the one call
1491
- * that can legitimately exceed it -- a large release-asset upload -- opts out with `timeoutMs: 0`.
1492
- */
1493
1016
  const DEFAULT_FETCH_TIMEOUT_MS = 12e4;
1494
- /**
1495
- * A transport-level error a retry can recover from by opening a fresh socket: undici's thrown
1496
- * `TypeError` ("fetch failed": DNS blip, dead keep-alive, socket hang-up), plus the
1497
- * `AbortSignal.timeout` abort we attach ourselves (a `DOMException` named `TimeoutError`, or
1498
- * `AbortError` on some undici paths) -- a stalled read is exactly what a retry is for. A
1499
- * caller's own cancel also surfaces as an abort here; {@link retryOnTransient} checks the
1500
- * caller's signal before this classifier, so a real cancel is rethrown and never retried.
1501
- */
1502
1017
  function isTransientTransportError(error) {
1503
1018
  if (error instanceof TypeError) return true;
1504
1019
  return error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
1505
1020
  }
1506
- /**
1507
- * Bound every request by the wall-clock cap, composing with -- never replaced by -- a signal the
1508
- * caller brought: with both present the request aborts on whichever fires first, so threading a
1509
- * cancel signal in can never silently disable the stall backstop. A disabled cap (`timeoutMs <=
1510
- * 0`) returns `init` untouched, caller signal included. Called once per attempt -- an
1511
- * already-fired timeout signal stays aborted, so a retried request must mint a new one rather
1512
- * than reuse the timed-out attempt's.
1513
- */
1514
1021
  function withTimeoutSignal(init, timeoutMs) {
1515
1022
  if (timeoutMs <= 0) return init;
1516
1023
  const timeout = AbortSignal.timeout(timeoutMs);
@@ -1523,26 +1030,11 @@ function withTimeoutSignal(init, timeoutMs) {
1523
1030
  signal: timeout
1524
1031
  };
1525
1032
  }
1526
- /** Exponential backoff (capped) between attempts; a non-positive base is a no-op (the test seam). */
1527
1033
  async function retryBackoff(attempt, baseDelayMs, maxDelayMs) {
1528
1034
  if (baseDelayMs <= 0) return;
1529
1035
  const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
1530
1036
  await new Promise((resolve) => setTimeout(resolve, delay));
1531
1037
  }
1532
- /**
1533
- * Retry `fn` on a transient transport error (see {@link isTransientTransportError}: undici's
1534
- * "fetch failed" TypeError, or our own `AbortSignal.timeout` abort) up to `policy.attempts`
1535
- * times, with exponential backoff between tries. Any other thrown error propagates on the first
1536
- * attempt -- only a transport-level error is transient. A resolved value (including a 4xx/5xx
1537
- * `Response`) is never a throw, so it is never retried. After the last attempt fails, the final
1538
- * error is re-thrown.
1539
- *
1540
- * Idempotency is the caller's contract: only wrap a `fn` whose re-execution converges to the same
1541
- * state. `retryOnTransient` cannot and does not verify that.
1542
- *
1543
- * `callerSignal` is the caller's own cancel: once it has fired, every error -- transient-shaped
1544
- * or not -- is rethrown at once, because a cancelled call must never be retried.
1545
- */
1546
1038
  async function retryOnTransient(fn, policy = DEFAULT_RETRY_POLICY, callerSignal = null) {
1547
1039
  let lastError;
1548
1040
  for (let attempt = 1; attempt <= policy.attempts; attempt++) try {
@@ -1555,14 +1047,6 @@ async function retryOnTransient(fn, policy = DEFAULT_RETRY_POLICY, callerSignal
1555
1047
  }
1556
1048
  throw lastError;
1557
1049
  }
1558
- /**
1559
- * Wrap a fetch so idempotent requests retry on a transient `TypeError` (see {@link
1560
- * retryOnTransient}). A request is idempotent when its method is GET/HEAD (a read) OR the caller
1561
- * passed `{ idempotent: true }` as the third arg -- the escape hatch for a mutating call that
1562
- * converges (e.g. `updatePr`'s PATCH). A non-idempotent mutation (POST create/comment) is never
1563
- * retried: "fetch failed" does not prove the request never landed server-side, so a re-send could
1564
- * duplicate it. `policy` is injectable so tests run with `baseDelayMs: 0` and never wait.
1565
- */
1566
1050
  function fetchWithRetry(fetchImpl, policy = DEFAULT_RETRY_POLICY, defaultTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1567
1051
  return (input, init, retry) => {
1568
1052
  const method = (init?.method ?? "GET").toUpperCase();
@@ -1577,47 +1061,17 @@ function fetchWithRetry(fetchImpl, policy = DEFAULT_RETRY_POLICY, defaultTimeout
1577
1061
  }, callerSignal);
1578
1062
  };
1579
1063
  }
1580
- /**
1581
- * A real-network {@link ForgeFetcher} with the default retry policy, for a caller that has no
1582
- * executor of its own. `globalThis.fetch` is read at call time, so a
1583
- * stub installed after this call is still seen.
1584
- */
1585
1064
  function createForgeFetcher(policy = DEFAULT_RETRY_POLICY) {
1586
1065
  return { fetch: fetchWithRetry((input, init) => globalThis.fetch(input, init), policy) };
1587
1066
  }
1588
1067
  //#endregion
1589
1068
  //#region src/fetcher.ts
1590
- /**
1591
- * The one place a command gets its real fetcher. Every command reaches the network only through
1592
- * this function, so a test mocks this module and no other.
1593
- */
1594
1069
  function cliFetcher() {
1595
1070
  return createForgeFetcher();
1596
1071
  }
1597
1072
  //#endregion
1598
1073
  //#region ../forgejo/src/errors.ts
1599
- /**
1600
- * A refusal about the caller's reach rather than about the request: 401/403 (the token lacks the
1601
- * scope) and 404 (the resource is absent, or present and invisible to this token -- the two are
1602
- * indistinguishable by design on both forges).
1603
- *
1604
- * It extends `FatalError` and carries the identical message, so every existing caller sees exactly
1605
- * what it saw before. The subclass exists for a caller reading an *optional* enrichment **behind a
1606
- * proof the resource exists**, which can therefore degrade instead of failing: `viewIssue`'s
1607
- * pulls read is that caller, and the only one. Catching plain `FatalError` there would also
1608
- * swallow a 422 or a schema mismatch as "no access", which is why the narrower type exists.
1609
- *
1610
- * Whether to degrade stays with the caller; this layer only reports what the forge said.
1611
- *
1612
- * `name` stays `"FatalError"`: the base class types it as that literal, and the discriminator here
1613
- * is `instanceof`, not the string.
1614
- */
1615
1074
  var ForgeAccessError = class extends FatalError {};
1616
- /**
1617
- * Request headers shared by every Forgejo API caller in this package. Sitting on the leaf module
1618
- * that already owns {@link ForgejoConnection} is what lets `issues.ts`, `issue-labels.ts`,
1619
- * `issue-comments.ts`, and `label-provisioning.ts` all use them with no edge back to `issues.ts`.
1620
- */
1621
1075
  function authHeaders$2(conn) {
1622
1076
  return { Authorization: `token ${conn.token}` };
1623
1077
  }
@@ -1627,29 +1081,15 @@ function jsonHeaders(conn) {
1627
1081
  "Content-Type": "application/json"
1628
1082
  };
1629
1083
  }
1630
- /** Shared "not found" wording so it isn't duplicated across every function that reads a repo. */
1631
1084
  function repoNotFoundMessage(conn) {
1632
1085
  return `Repository ${conn.repository} not found (or the token cannot see it)`;
1633
1086
  }
1634
- /** Base URL for a repo's issues endpoint -- shared by `issues.ts`, `issue-labels.ts`, and
1635
- * `issue-comments.ts`, all of which mutate issue/PR state under this same route. */
1636
1087
  function issuesBase(conn) {
1637
1088
  return `${conn.serverUrl}/api/v1/repos/${conn.repository}/issues`;
1638
1089
  }
1639
- /** Shared "not found" wording for a numbered issue or PR (both forges number them from one
1640
- * sequence). */
1641
1090
  function issueNotFoundMessage(conn, number) {
1642
1091
  return `Issue #${String(number)} not found in ${conn.repository}`;
1643
1092
  }
1644
- /**
1645
- * Field names come from a real Forgejo response. Note `browser_download_url` --
1646
- * NOT `download_url`. The URL is a web route (`/attachments/<uuid>`), not an
1647
- * `/api/v1` route, and an API token does authenticate against it.
1648
- *
1649
- * Lives on this leaf module -- rather than on `issues.ts`, which normalizes the attachments
1650
- * inline on an issue, or `issue-comments.ts`, which reads a comment's own attachments -- because
1651
- * both need it and neither may import from the other.
1652
- */
1653
1093
  const AttachmentSchema = z.object({
1654
1094
  id: z.number(),
1655
1095
  name: z.string(),
@@ -1667,7 +1107,6 @@ function normalizeAttachment(raw) {
1667
1107
  downloadUrl: raw.browser_download_url
1668
1108
  };
1669
1109
  }
1670
- /** Safely read response body text for inclusion in error messages. */
1671
1110
  async function responseBodyText(res) {
1672
1111
  try {
1673
1112
  const text = await res.text();
@@ -1681,13 +1120,6 @@ const SwaggerEnvelopeSchema = z.union([z.object({ message: z.literal("") }), z.o
1681
1120
  message: z.string(),
1682
1121
  url: z.string().regex(/\/api\/swagger$/)
1683
1122
  })]);
1684
- /**
1685
- * Detect Forgejo's signature "route not handled" response shape -- empty body
1686
- * or a JSON envelope with either an empty `message` or a `url` pointing at
1687
- * `/api/swagger`. Forgejo returns this from a 500 (and sometimes 404) when no
1688
- * route handles the request, even though the status code suggests a
1689
- * server-side fault.
1690
- */
1691
1123
  function isForgejoGenericServerErrorBody(body) {
1692
1124
  if (body === "") return true;
1693
1125
  try {
@@ -1698,13 +1130,6 @@ function isForgejoGenericServerErrorBody(body) {
1698
1130
  }
1699
1131
  }
1700
1132
  const DISPATCH_VERB_PATTERN = /\bdispatch\b/i;
1701
- /**
1702
- * Map a non-2xx Forgejo response to a human-readable hint.
1703
- *
1704
- * Returns the most-likely cause first; callers still surface the raw body so an
1705
- * uncommon failure mode isn't masked. Returns "" for unmapped codes (2xx/3xx,
1706
- * teapots) so the caller can decide whether to include a Hint line at all.
1707
- */
1708
1133
  function forgejoErrorHint(status, body, operation) {
1709
1134
  const isDispatch = DISPATCH_VERB_PATTERN.test(operation.verb);
1710
1135
  const resource = operation.resource ?? "the resource";
@@ -1726,36 +1151,11 @@ function forgejoErrorHint(status, body, operation) {
1726
1151
  }
1727
1152
  return "";
1728
1153
  }
1729
- /**
1730
- * Build a richer error message for a non-2xx Forgejo API response.
1731
- *
1732
- * Reads, truncates, and redacts the body via {@link responseBodyText}; appends
1733
- * a `Hint:` line when {@link forgejoErrorHint} has one; appends the raw body as
1734
- * a `Response:` block. Callers still pick the error class (Transient vs Fatal).
1735
- */
1736
1154
  async function forgejoErrorMessage(res, operation) {
1737
1155
  const body = await responseBodyText(res);
1738
1156
  const hint = forgejoErrorHint(res.status, body, operation);
1739
1157
  return `Failed to ${operation.verb} on Forgejo: ${String(res.status)} ${res.statusText}` + (hint ? `\nHint: ${hint}` : "") + (body ? `\nResponse: ${body}` : "");
1740
1158
  }
1741
- /**
1742
- * Classify a non-2xx Forgejo response. 404 is bad input (the named resource
1743
- * doesn't exist -- not retryable). 401/403 is a credentials/scope problem --
1744
- * also not retryable, and the single most common failure mode here (a token
1745
- * missing the required scope returns 403). 422 is a validation failure (e.g.
1746
- * a nonexistent label on create) -- retrying with the same input can never
1747
- * succeed. 410 means issues are disabled on the repository -- also permanent
1748
- * until repo config changes, not something a retry fixes. Anything else is
1749
- * presumed transient (server hiccups, rate limiting, etc.).
1750
- *
1751
- * `notFoundMessage` lets a caller supply a resource-specific 404 message
1752
- * (e.g. "Issue #<n> not found in owner/repo"); when omitted, the 404 case
1753
- * falls back to {@link forgejoErrorMessage}'s generic (but still hinted)
1754
- * message, which is what every `pr.ts` call site gets.
1755
- *
1756
- * The 404 and 401/403 cases throw {@link ForgeAccessError} -- a `FatalError` subclass with the
1757
- * same message, so nothing here changes for a caller that only distinguishes fatal from transient.
1758
- */
1759
1159
  async function throwForgejoFailure(res, operation, notFoundMessage) {
1760
1160
  if (res.status === 404) throw new ForgeAccessError(notFoundMessage ?? await forgejoErrorMessage(res, operation));
1761
1161
  const message = await forgejoErrorMessage(res, operation);
@@ -1766,21 +1166,6 @@ async function throwForgejoFailure(res, operation, notFoundMessage) {
1766
1166
  }
1767
1167
  //#endregion
1768
1168
  //#region ../forgejo/src/commit-status.ts
1769
- /**
1770
- * The combined commit status, read as the per-job report the response already carries rather than
1771
- * reduced to a single boolean. `CommitChecks.green` alone is a real consumer's whole need -- a
1772
- * merge gate only ever asks "is it green" -- but a caller that reports on a PR's CI wants the
1773
- * per-job list too, and the endpoint already returns it.
1774
- *
1775
- * The parse is deliberately two layers, and the layering is the whole point. The top-level schema
1776
- * is byte-for-byte what `pr-merge.ts` has always required (`{ state, statuses }`), and `green` is
1777
- * computed from it *before any entry is inspected*. Entries are then projected through a second,
1778
- * all-nullish schema, so an entry shape this build cannot read degrades that one row of the report
1779
- * and can never move the verdict. `pr-merge.ts`'s doc comment records the lesson this shape
1780
- * encodes in prose: a schema that required a field the forge never sent (`state` on an entry, where
1781
- * Forgejo sends `status`) once withheld every merge, silently, because the whole response
1782
- * failed to parse over one field the verdict never reads.
1783
- */
1784
1169
  const CombinedStatusSchema = z.object({
1785
1170
  state: z.string(),
1786
1171
  statuses: z.array(z.unknown())
@@ -1793,7 +1178,6 @@ const CheckEntrySchema = z.object({
1793
1178
  updated_at: z.string().nullish(),
1794
1179
  id: z.number().nullish()
1795
1180
  });
1796
- /** A single trailing ` (<word>)` parenthetical, stripped from a context to get its `name`. */
1797
1181
  const TRAILING_EVENT = / \([^()]+\)$/;
1798
1182
  function classifyVerdict(state) {
1799
1183
  if (state === "success") return "good";
@@ -1810,11 +1194,6 @@ function updatedAtMillis(value) {
1810
1194
  const parsed = Date.parse(value);
1811
1195
  return Number.isNaN(parsed) ? 0 : parsed;
1812
1196
  }
1813
- /**
1814
- * Deduplicate entries by `context`, newest `updated_at` winning, falling back to `id`, then to
1815
- * array order. An entry with no (or empty) `context` is keyed by its own index instead, so two
1816
- * context-less entries never collapse into one row.
1817
- */
1818
1197
  function dedupeEntries(entries) {
1819
1198
  const byKey = /* @__PURE__ */ new Map();
1820
1199
  entries.forEach((entry, index) => {
@@ -1848,11 +1227,6 @@ function dedupeEntries(entries) {
1848
1227
  sorted.sort((a, b) => a.index - b.index);
1849
1228
  return sorted;
1850
1229
  }
1851
- /**
1852
- * Read a commit's combined status as a per-job report, scoped to `sha`. An unparseable response
1853
- * returns `parsed: false` rather than throwing: a broken read must never look like a decision a
1854
- * caller is entitled to make.
1855
- */
1856
1230
  async function readCommitChecks(executor, conn, sha) {
1857
1231
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/commits/${sha}/status`;
1858
1232
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
@@ -1917,14 +1291,9 @@ function repoBase$1(conn) {
1917
1291
  function authHeaders$1(conn) {
1918
1292
  return { Authorization: `token ${conn.token}` };
1919
1293
  }
1920
- /** A branch's own "not found" wording -- distinct from {@link repoNotFoundMessage} so a stale
1921
- * cached branch name (e.g. after a default-branch rename) reports the resource that is actually
1922
- * missing, not the repo it lives in. */
1923
1294
  function branchNotFoundMessage(conn, branch) {
1924
1295
  return `Branch ${branch} not found in ${conn.repository}`;
1925
1296
  }
1926
- /** A branch's head commit SHA -- the cheap "has anything landed?" probe a caller uses to decide
1927
- * whether a clone is worth doing at all. */
1928
1297
  async function getBranchHeadSha(executor, conn, branch) {
1929
1298
  const url = `${repoBase$1(conn)}/branches/${encodeURIComponent(branch)}`;
1930
1299
  const res = await executor.fetch(url, { headers: authHeaders$1(conn) });
@@ -1936,16 +1305,6 @@ async function getBranchHeadSha(executor, conn, branch) {
1936
1305
  if (!parsed.success) throw new UnexpectedError(`Unexpected branch response: ${parsed.error.message}`);
1937
1306
  return parsed.data.commit.id;
1938
1307
  }
1939
- /**
1940
- * Whether the forge knows this branch at all -- the probe a caller runs before it asks the forge
1941
- * to point a pull request at a branch.
1942
- *
1943
- * {@link getBranchHeadSha} cannot serve as this probe: its 404 goes through `throwForgejoFailure`
1944
- * and throws `ForgeAccessError`, the same class a 403 throws, so a caller catching it would read a
1945
- * token that cannot see the repository as a branch that was never pushed. Only a 404 answers
1946
- * `false` here; every other non-ok status throws, because "I cannot tell" is never "it is
1947
- * missing".
1948
- */
1949
1308
  async function branchExists(executor, conn, branch) {
1950
1309
  const url = `${repoBase$1(conn)}/branches/${encodeURIComponent(branch)}`;
1951
1310
  const res = await executor.fetch(url, { headers: authHeaders$1(conn) });
@@ -1959,7 +1318,6 @@ async function branchExists(executor, conn, branch) {
1959
1318
  //#endregion
1960
1319
  //#region ../cli-kit/src/git.ts
1961
1320
  const BRANCH_REF_PREFIX = "refs/heads/";
1962
- /** The origin read gives up after this long, so a wedged remote lookup cannot hang a command. */
1963
1321
  const ORIGIN_READ_TIMEOUT_MS = 5e3;
1964
1322
  function defaultGitRunner(args, cwd, options) {
1965
1323
  try {
@@ -1977,18 +1335,6 @@ function defaultGitRunner(args, cwd, options) {
1977
1335
  return;
1978
1336
  }
1979
1337
  }
1980
- /**
1981
- * Resolve the branch the current run is on.
1982
- *
1983
- * CI env wins: `GITHUB_REF` (also set by Forgejo Actions) is the source of truth
1984
- * for whether this is a branch event -- tag and pull-request refs are deliberately
1985
- * reported as `undefined` since they have no meaningful branch. On a branch event
1986
- * the short name comes from `GITHUB_REF_NAME` (falling back to stripping the
1987
- * prefix off `GITHUB_REF`). Without `GITHUB_REF`, falls back to local git.
1988
- *
1989
- * Returns `undefined` when the branch cannot be determined or the ref is not a
1990
- * branch (tag, PR merge ref, detached HEAD).
1991
- */
1992
1338
  function resolveCurrentBranch(options) {
1993
1339
  const env = options.env ?? process.env;
1994
1340
  const ref = env["GITHUB_REF"];
@@ -2005,10 +1351,6 @@ function resolveCurrentBranch(options) {
2005
1351
  if (branch === void 0 || branch === "HEAD") return void 0;
2006
1352
  return branch;
2007
1353
  }
2008
- /**
2009
- * The raw output of `git remote get-url origin`, or `null` when there is no origin or `cwd` is not
2010
- * a repository. Not normalized: an SSH remote comes back as written.
2011
- */
2012
1354
  function readOriginUrl(cwd, git = defaultGitRunner) {
2013
1355
  return git([
2014
1356
  "remote",
@@ -2017,7 +1359,6 @@ function readOriginUrl(cwd, git = defaultGitRunner) {
2017
1359
  ], cwd, { timeoutMs: ORIGIN_READ_TIMEOUT_MS }) ?? null;
2018
1360
  }
2019
1361
  const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
2020
- /** The `repository` field of `cwd`'s `package.json` as a URL string, or `null` when absent. */
2021
1362
  function readPackageRepositoryUrl(cwd) {
2022
1363
  const pkgPath = path.join(cwd, "package.json");
2023
1364
  let raw;
@@ -2040,29 +1381,12 @@ function readPackageRepositoryUrl(cwd) {
2040
1381
  }
2041
1382
  //#endregion
2042
1383
  //#region src/connection.ts
2043
- /**
2044
- * The repository URL for `cwd`: the `origin` remote first, since `package.json` may advertise a
2045
- * public mirror rather than the host the commands act on; `package.json#repository` only as a
2046
- * last resort. `null` when neither parses.
2047
- */
2048
1384
  function resolveRepoUrl(cwd) {
2049
1385
  return resolveRepoUrlFrom(() => readOriginUrl(cwd), () => readPackageRepositoryUrl(cwd));
2050
1386
  }
2051
- /**
2052
- * The connection a `--repo` value names. Every command in this bin needs both halves -- parse the
2053
- * value, then resolve it against the working directory -- and this module already owns the second
2054
- * and already imports the module the first lives in, so the pair is composed once here rather than
2055
- * repeated per command.
2056
- */
2057
1387
  function requireForgejoTargetFromRepo(cwd, repo, githubRefusal, env = process.env) {
2058
1388
  return requireForgejoTarget(cwd, parseRepoTarget(repo), githubRefusal, env);
2059
1389
  }
2060
- /**
2061
- * The Forgejo connection a command acts on. An explicit target (from an issue URL or `--repo`)
2062
- * wins over the working directory. `githubRefusal` is the command's own sentence for a github.com
2063
- * repository, which owes the reader the `gh` alternative. The environment settles the connection
2064
- * before any git or `package.json` read, so CI with only the environment needs neither.
2065
- */
2066
1390
  function requireForgejoTarget(cwd, target, githubRefusal, env = process.env) {
2067
1391
  if (target !== void 0) {
2068
1392
  if (target.hostname === "github.com") throw new FatalError(githubRefusal);
@@ -2080,11 +1404,6 @@ function requireForgejoTarget(cwd, target, githubRefusal, env = process.env) {
2080
1404
  }
2081
1405
  //#endregion
2082
1406
  //#region ../forgejo/src/check-poll.ts
2083
- /**
2084
- * Commit-check classification and poll-read helpers shared by the forge-side check readers -- a PR's
2085
- * checks and an arbitrary ref's status read the same `commit-status` primitive and poll it the same
2086
- * way; only what each does with a settled result differs. The poll cadence is the caller's.
2087
- */
2088
1407
  function classifyBaseStatus(checks) {
2089
1408
  if (!checks.parsed) return "unparseable";
2090
1409
  if (checks.contexts.length === 0) return "no-status";
@@ -2092,20 +1411,10 @@ function classifyBaseStatus(checks) {
2092
1411
  if (checks.contexts.some((check) => check.verdict === "pending")) return "pending";
2093
1412
  return "red";
2094
1413
  }
2095
- /** At least one status, and no entry still pending -- the same definition a wait loop polls toward. */
2096
1414
  function isSettled(checks) {
2097
1415
  if (!checks.parsed || checks.contexts.length === 0) return false;
2098
1416
  return !checks.contexts.some((check) => check.verdict === "pending");
2099
1417
  }
2100
- /**
2101
- * Whether the per-entry classification disagrees with the forge's own combined reduction --
2102
- * either direction: every entry reads `good` or `skipped` but the combined state is not
2103
- * `success`, or the combined state is `success` while some entry reads neither. A skipped entry
2104
- * is not a divergence -- it is a job that did not run, which carries no information about the
2105
- * code -- so it is folded into "settled good" here the same way it is everywhere else. The
2106
- * combined state always wins the verdict (`classifyBaseStatus` never looks at this); a caller
2107
- * only needs to know whether to surface a notice about a genuine one.
2108
- */
2109
1418
  function disagreesWithCombinedState(checks) {
2110
1419
  if (!checks.parsed || checks.contexts.length === 0) return false;
2111
1420
  const allSettledGood = checks.contexts.every((check) => check.verdict === "good" || check.verdict === "skipped");
@@ -2116,15 +1425,12 @@ function pushDisagreementNotice(notices, sha, checks) {
2116
1425
  const notice = `The combined status ("${checks.state}") for ${sha} disagrees with this build's per-check classification -- the combined status wins the verdict.`;
2117
1426
  if (!notices.includes(notice)) notices.push(notice);
2118
1427
  }
2119
- /** No commit status was read -- the sentinel a `head-moved` report (which never reads the new
2120
- * head's checks) or an exhausted retry budget (which has no trustworthy checks left) carries. */
2121
1428
  const EMPTY_CHECKS = {
2122
1429
  parsed: false,
2123
1430
  state: "",
2124
1431
  green: false,
2125
1432
  contexts: []
2126
1433
  };
2127
- /** One poll's commit-status read, reduced to a {@link PollRead} a retry budget can count. */
2128
1434
  async function readPollChecks(fetcher, conn, sha) {
2129
1435
  try {
2130
1436
  const result = await readCommitChecks(fetcher, conn, sha);
@@ -2139,15 +1445,12 @@ async function readPollChecks(fetcher, conn, sha) {
2139
1445
  const DEFAULT_TIMEOUT_MS = 12e5;
2140
1446
  const DEFAULT_INTERVAL_MS = 15e3;
2141
1447
  const MIN_INTERVAL_MS = 5e3;
2142
- /** The test-only override, ignored unless it is a positive integer, and never above production. */
2143
1448
  function envMsOverride(raw, productionMs) {
2144
1449
  if (raw === void 0) return productionMs;
2145
1450
  const parsed = Number(raw.trim());
2146
1451
  if (!Number.isSafeInteger(parsed) || parsed <= 0) return productionMs;
2147
1452
  return Math.min(parsed, productionMs);
2148
1453
  }
2149
- /** `--timeout`/`--interval` in milliseconds, defaulted and floored. Separated from the wait loop
2150
- * so a command can refuse an unparseable duration before it makes a single request. */
2151
1454
  function resolvePollTimings(options) {
2152
1455
  const timeoutMs = options.timeout !== void 0 ? parseDuration(options.timeout, "--timeout") : DEFAULT_TIMEOUT_MS;
2153
1456
  const requestedIntervalMs = options.interval !== void 0 ? parseDuration(options.interval, "--interval") : DEFAULT_INTERVAL_MS;
@@ -2158,7 +1461,6 @@ function resolvePollTimings(options) {
2158
1461
  }
2159
1462
  //#endregion
2160
1463
  //#region src/sleep.ts
2161
- /** The production delay between `--wait` polls. Tests inject their own through the runners' `sleep` option. */
2162
1464
  function sleep(ms) {
2163
1465
  return new Promise((resolve) => {
2164
1466
  setTimeout(resolve, ms);
@@ -2166,16 +1468,6 @@ function sleep(ms) {
2166
1468
  }
2167
1469
  //#endregion
2168
1470
  //#region src/commit-status.ts
2169
- /**
2170
- * `bst-fj commit:status` -- query and report Forgejo commit status checks for an arbitrary commit
2171
- * SHA, branch name, tag, or local git ref over `@bensandee/forgejo`'s `readCommitChecks`.
2172
- *
2173
- * Unlike `pr:checks` which requires an open pull request, this command queries commit check
2174
- * contexts and the combined status reduction directly for any ref.
2175
- */
2176
- /**
2177
- * Resolve `--repo`, cwd connection, and the commit ref into a `CommitStatusTarget`.
2178
- */
2179
1471
  async function resolveCommitStatusTarget(fetcher, options) {
2180
1472
  const targetRepo = parseRepoTarget(options.repo);
2181
1473
  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.");
@@ -2205,15 +1497,7 @@ async function resolveCommitStatusTarget(fetcher, options) {
2205
1497
  url
2206
1498
  };
2207
1499
  }
2208
- /** A ref that is already a full commit SHA needs no resolution at all. */
2209
1500
  const FULL_SHA = /^[0-9a-f]{40}$/;
2210
- /**
2211
- * The forge's head SHA for `ref` as a branch, or `undefined` when the forge has no such branch --
2212
- * a tag, or any other ref the commit-status API can read directly, which then passes through
2213
- * verbatim. A server error still throws: passing through on a 5xx would silently answer about the
2214
- * wrong commit. `ForgeAccessError` also covers 401/403, where pass-through costs nothing extra --
2215
- * the status read that follows fails on the same credential.
2216
- */
2217
1501
  async function forgeBranchHeadSha(fetcher, conn, ref) {
2218
1502
  try {
2219
1503
  return await getBranchHeadSha(fetcher, conn, ref);
@@ -2285,9 +1569,6 @@ async function waitForSettledChecks$1(fetcher, target, notices, poll) {
2285
1569
  await sleep(intervalMs);
2286
1570
  }
2287
1571
  }
2288
- /**
2289
- * Query commit checks for the resolved target ref/SHA and produce a `CommitStatusReport`.
2290
- */
2291
1572
  async function runCommitStatus(fetcher, options) {
2292
1573
  const timings = options.wait === true ? resolvePollTimings(options) : void 0;
2293
1574
  const target = await resolveCommitStatusTarget(fetcher, options);
@@ -2305,9 +1586,6 @@ async function runCommitStatus(fetcher, options) {
2305
1586
  elapsedMs: 0
2306
1587
  });
2307
1588
  }
2308
- /**
2309
- * Render a `commit:status` report as human-readable text.
2310
- */
2311
1589
  function renderCommitStatus(report) {
2312
1590
  const lines = report.checks.map((check) => {
2313
1591
  const parts = [check.state, check.context];
@@ -2394,16 +1672,8 @@ const LabelSchema = z.object({
2394
1672
  exclusive: z.boolean().optional()
2395
1673
  });
2396
1674
  const LabelListSchema = z.array(LabelSchema);
2397
- /** Page size for `GET .../labels`. Also the signal that another page may exist. */
2398
1675
  const LABELS_PAGE_SIZE = 100;
2399
- /** Hard cap on pages fetched, so a misbehaving server can't spin this forever. */
2400
1676
  const LABELS_MAX_PAGES = 20;
2401
- /**
2402
- * Fetch every repository label, paginating until a short page (or the page
2403
- * cap) is hit. Without pagination, a repo with more labels than one page
2404
- * holds would silently see only the first page -- turning a real label into a
2405
- * false "no label named X" failure (see {@link resolveLabelIds}).
2406
- */
2407
1677
  async function fetchAllForgejoLabels(executor, conn) {
2408
1678
  const labels = [];
2409
1679
  for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
@@ -2419,11 +1689,6 @@ async function fetchAllForgejoLabels(executor, conn) {
2419
1689
  }
2420
1690
  return labels;
2421
1691
  }
2422
- /**
2423
- * Forgejo's create-issue API takes label IDs, not names, so names must be
2424
- * resolved first. A name with no match is a hard failure -- silently dropping
2425
- * a requested label would be quiet data loss, not a graceful degradation.
2426
- */
2427
1692
  async function resolveLabelIds(executor, conn, names) {
2428
1693
  if (names.length === 0) return [];
2429
1694
  const labels = await fetchAllForgejoLabels(executor, conn);
@@ -2436,46 +1701,25 @@ async function resolveLabelIds(executor, conn, names) {
2436
1701
  return match.id;
2437
1702
  });
2438
1703
  }
2439
- /** {@link LabelSpec} as a zod schema, so a hand-written spec fails at load rather than at the
2440
- * first API call. Strict: an unknown field is a typo, not an extension point. */
2441
1704
  const LabelSpecSchema = z.strictObject({
2442
1705
  name: z.string().min(1),
2443
1706
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
2444
1707
  description: z.string(),
2445
1708
  exclusive: z.boolean().optional()
2446
1709
  });
2447
- /** Named label sets: each set name maps to the specs it provisions. */
2448
1710
  const LabelSetsSchema = z.record(z.string(), z.array(LabelSpecSchema));
2449
- /** Forgejo echoes label color without a leading '#'; the spec writes one. Normalize before comparing. */
2450
1711
  function normalizeColor(color) {
2451
1712
  return color.replace(/^#/, "").toLowerCase();
2452
1713
  }
2453
- /** 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. */
2454
1714
  function hasCosmeticDrift(existing, spec) {
2455
1715
  return normalizeColor(existing.color ?? "") !== normalizeColor(spec.color) || (existing.description ?? "") !== spec.description;
2456
1716
  }
2457
- /** True when a spec asks for `exclusive: true` and the live label does not carry it -- the one direction
2458
- * that produces the silent wrong answer `labelDrifted` used to repair as a side effect (two labels
2459
- * of one scope can coexist, and a reader sees whichever the forge lists first). */
2460
1717
  function isNonExclusive(existing, spec) {
2461
1718
  return (spec.exclusive ?? false) && !(existing.exclusive ?? false);
2462
1719
  }
2463
- /** True when the live label's exclusivity differs from the spec's in either direction -- what
2464
- * `ensureForgejoLabels` reconciles. `isNonExclusive` stays one-directional: it is the gate
2465
- * condition `auditRepoLabels` reads, and a spec asking for `exclusive: false` was never a gate
2466
- * failure. */
2467
1720
  function isExclusivityDrifted(existing, spec) {
2468
1721
  return (existing.exclusive ?? false) !== (spec.exclusive ?? false);
2469
1722
  }
2470
- /**
2471
- * Audit a repo's labels against `specs` with a single list fetch and no write -- the read half of
2472
- * what `ensureForgejoLabels` used to do in one pass. Returns the name->id map for every existing
2473
- * label (so a caller keeps the fetch-avoidance `ensureForgejoLabels` callers relied on) plus four
2474
- * name lists: labels missing entirely, labels present but not exclusive when the spec asks for it,
2475
- * labels whose exclusivity drifted in either direction, and labels with cosmetic (color/description)
2476
- * drift alone. A missing label and a non-exclusive one are both gate conditions; exclusivity drift
2477
- * and cosmetic drift are reported but never block.
2478
- */
2479
1723
  async function auditForgejoLabels(executor, conn, specs) {
2480
1724
  const existing = await fetchAllForgejoLabels(executor, conn);
2481
1725
  const byName = new Map(existing.map((label) => [label.name, label]));
@@ -2502,26 +1746,10 @@ async function auditForgejoLabels(executor, conn, specs) {
2502
1746
  cosmeticDrift
2503
1747
  };
2504
1748
  }
2505
- /** The `<scope>/` portion of a label name, or `null` for a leaf-only name like `bug`. */
2506
1749
  function labelScope(name) {
2507
1750
  const slash = name.indexOf("/");
2508
1751
  return slash === -1 ? null : name.slice(0, slash + 1);
2509
1752
  }
2510
- /**
2511
- * The live labels a set has left behind: present on the repo, carrying a scope the set owns, and
2512
- * not a member of the set. This is the drift {@link auditForgejoLabels} structurally cannot report
2513
- * -- the audit is a fact about exactly the specs it is given, so a name deleted from a set is
2514
- * absent from both the specs and the audit, and nothing notices it is still on the repo.
2515
- *
2516
- * Ownership is derived from the specs rather than declared, so a label set needs no new key: a set
2517
- * owns a scope when one of its own members carries it. A leaf-only name (`bug`) owns nothing and
2518
- * falls under nothing, which is what keeps a repo's ordinary labels out of the report in both
2519
- * directions.
2520
- *
2521
- * Deliberately not a field on {@link LabelAudit}: a caller may audit against a *required* subset,
2522
- * so a staleness computed from the audit's own specs would report every ensured-but-not-required
2523
- * label as a stray. A caller passes the full set it provisions from.
2524
- */
2525
1753
  function staleLabelNames(specs, live) {
2526
1754
  const owned = /* @__PURE__ */ new Set();
2527
1755
  for (const spec of specs) {
@@ -2538,23 +1766,6 @@ function staleLabelNames(specs, live) {
2538
1766
  stale.sort((a, b) => a.localeCompare(b));
2539
1767
  return stale;
2540
1768
  }
2541
- /**
2542
- * Ensure every label in `specs` exists on the repo with the spec's color and description,
2543
- * creating any that are missing and PATCHing any that {@link auditForgejoLabels} reports as
2544
- * non-exclusive or cosmetically drifted. An ensure-loop like `ensureRelease`: instance label
2545
- * templates only apply at repo creation, so a fresh repo has none of these.
2546
- * Implemented over the audit so the two can never disagree about what "drifted" means.
2547
- *
2548
- * Reconciling existing labels (not just creating missing ones) is what backfills descriptions
2549
- * onto labels created before they carried one -- the create-only version left every already-present
2550
- * label with whatever description it happened to have, so an existing repo never
2551
- * gained the self-documenting text.
2552
- *
2553
- * Returns the name->id map the audit already computed (existing labels plus any just created), so a
2554
- * caller that goes on to mutate labels for the same repo can thread it
2555
- * through `addForgejoIssueLabel` / `removeForgejoIssueLabel` and skip their own redundant list
2556
- * fetch.
2557
- */
2558
1769
  async function ensureForgejoLabels(executor, conn, specs) {
2559
1770
  const audit = await auditForgejoLabels(executor, conn, specs);
2560
1771
  const labelIds = new Map(audit.labelIds);
@@ -2603,12 +1814,6 @@ async function ensureForgejoLabels(executor, conn, specs) {
2603
1814
  }
2604
1815
  return labelIds;
2605
1816
  }
2606
- /**
2607
- * Delete a repository label definition by id -- the write half of pruning: a label the
2608
- * caller has already determined no longer belongs to a set's declared universe. Idempotent, like
2609
- * `removeForgejoIssueLabel`: Forgejo returns 204 whether or not the label still exists, so a
2610
- * retry after a transient blip is safe.
2611
- */
2612
1817
  async function deleteForgejoLabel(executor, conn, id) {
2613
1818
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels/${String(id)}`, {
2614
1819
  method: "DELETE",
@@ -2621,12 +1826,6 @@ async function deleteForgejoLabel(executor, conn, id) {
2621
1826
  }
2622
1827
  //#endregion
2623
1828
  //#region ../forgejo/src/issues.ts
2624
- /**
2625
- * Timestamps are plain strings on purpose: Forgejo returns offset-form
2626
- * (`2026-07-13T09:23:13-05:00`), which `z.string().datetime()` rejects.
2627
- * Unknown keys are stripped rather than rejected, so upstream additions
2628
- * cannot break parsing.
2629
- */
2630
1829
  const IssueResponseSchema = z.object({
2631
1830
  number: z.number(),
2632
1831
  title: z.string(),
@@ -2640,11 +1839,6 @@ const IssueResponseSchema = z.object({
2640
1839
  updated_at: z.string(),
2641
1840
  closed_at: z.string().nullish(),
2642
1841
  pull_request: z.unknown().nullish(),
2643
- /**
2644
- * Already inline on the issue response -- no separate fetch is needed to
2645
- * surface attachments. `nullish()` because the field may be absent or
2646
- * explicitly `null` depending on the endpoint.
2647
- */
2648
1842
  assets: z.array(AttachmentSchema).nullish()
2649
1843
  });
2650
1844
  const IssueListSchema = z.array(IssueResponseSchema);
@@ -2675,7 +1869,6 @@ function normalizeIssue(raw) {
2675
1869
  assignees: (raw.assignees ?? []).map((assignee) => assignee.login)
2676
1870
  };
2677
1871
  }
2678
- /** Fetch a single issue by number. */
2679
1872
  async function getForgejoIssue(executor, conn, number) {
2680
1873
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}`, { headers: authHeaders$2(conn) });
2681
1874
  if (!res.ok) await throwForgejoFailure(res, {
@@ -2690,15 +1883,6 @@ const DependencyResponseSchema = z.object({
2690
1883
  repository: z.object({ full_name: z.string() }).nullish()
2691
1884
  });
2692
1885
  z.array(DependencyResponseSchema);
2693
- /**
2694
- * `type` is built first and only when present, so every pre-existing caller's URL stays
2695
- * byte-identical to what it was before `since`/`sort`/`"all"` existed: a consumer may key a
2696
- * fetch mock on a URL substring that has `type=issues` before the other params, and a caller
2697
- * asking the same question as before has no reason to see a different query string.
2698
- *
2699
- * No `type` means both kinds interleaved -- verified against a live instance, where an untyped
2700
- * `state=all&sort=leastupdate` page carries issues and pull requests together.
2701
- */
2702
1886
  function issueListParams(type, query) {
2703
1887
  const params = new URLSearchParams(type === "all" ? {} : { type });
2704
1888
  params.set("state", query.state);
@@ -2717,46 +1901,12 @@ async function fetchIssueListPage(executor, conn, params) {
2717
1901
  }, repoNotFoundMessage(conn));
2718
1902
  return parseIssueListOrThrow(await res.json());
2719
1903
  }
2720
- /**
2721
- * Normalize a raw detailed-list page and, for `type: "issues"`, drop pull requests -- the same
2722
- * portable belt-and-braces filter {@link toIssueSummaries} applies for {@link listForgejoIssues},
2723
- * because the live API really does still serve PRs on this path.
2724
- */
2725
1904
  function normalizeDetailedIssues(raw, type) {
2726
1905
  const normalized = raw.map(normalizeIssue);
2727
1906
  return type === "issues" ? normalized.filter((issue) => !issue.isPullRequest) : normalized;
2728
1907
  }
2729
- /** Page size for {@link listForgejoIssuesDetailedPaged}. Also the "another page may exist"
2730
- * signal, for the same reason {@link SEARCH_PAGE_SIZE} is: Forgejo clamps `limit` to its own
2731
- * `api.MAX_RESPONSE_ITEMS` (`ToCorrectPageSize`), so a page shorter than this may just be the
2732
- * clamp, not the end. */
2733
1908
  const DETAILED_LIST_PAGE_SIZE = 50;
2734
- /** Hard cap on pages, so a misbehaving server cannot spin the read forever. */
2735
1909
  const DETAILED_LIST_MAX_PAGES = 20;
2736
- /**
2737
- * Body-preserving counterpart to {@link listForgejoIssues}, for a caller that summarizes bodies
2738
- * (the repo snapshot) rather than just listing titles. `type=pulls` reads through this same
2739
- * issues endpoint rather than the dedicated pulls endpoint: this listing carries no head branch
2740
- * by design, and the pulls endpoint's own lister ({@link listPulls} conceptually, in `pr.ts`)
2741
- * pages `state=all` to completion, which this caller does not want.
2742
- *
2743
- * Paginates because a caller's desired count (`query.limit`) may exceed what one request can
2744
- * return -- Forgejo clamps `limit` to its own `api.MAX_RESPONSE_ITEMS`, so a single unpaginated
2745
- * read at `limit: query.limit` can silently come back short of what was asked for (see
2746
- * {@link DETAILED_LIST_PAGE_SIZE}). Pages at `DETAILED_LIST_PAGE_SIZE`, accumulating until an
2747
- * empty raw page, the page cap, or until `query.limit` entries are in hand, whichever comes
2748
- * first -- stopping early keeps a caller's own "> cap" truncation check meaningful without
2749
- * reading further pages than it needs.
2750
- *
2751
- * The raw (pre-filter) page length is what decides "another page may exist", not the
2752
- * post-PR-filter count: a full raw page of issues that happens to include several PRs would
2753
- * otherwise filter down below `DETAILED_LIST_PAGE_SIZE` and end pagination early, silently
2754
- * dropping the issues on the pages after it.
2755
- *
2756
- * `options.onPageCap` fires only when the cap ended the read short of `query.limit`: the limit
2757
- * check above runs before the cap check, so a walk that reaches the last allowed page with the
2758
- * limit already satisfied never gets here, and a caller can read the callback as plain truncation.
2759
- */
2760
1910
  async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
2761
1911
  const issues = [];
2762
1912
  for (let page = 1; page <= DETAILED_LIST_MAX_PAGES; page++) {
@@ -2775,9 +1925,7 @@ async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
2775
1925
  }
2776
1926
  return issues;
2777
1927
  }
2778
- /** Page size for `GET /user/repos`. Also the "another page may exist" signal. */
2779
1928
  const USER_REPOS_PAGE_SIZE = 50;
2780
- /** Hard cap on pages, so a misbehaving server cannot spin startup forever. */
2781
1929
  const USER_REPOS_MAX_PAGES = 20;
2782
1930
  const UserRepoSchema = z.object({
2783
1931
  full_name: z.string(),
@@ -2789,14 +1937,6 @@ const UserRepoSchema = z.object({
2789
1937
  }).nullish()
2790
1938
  });
2791
1939
  const UserRepoListSchema = z.array(UserRepoSchema);
2792
- /**
2793
- * Every repo the token can access, via Forgejo's `/user/repos`, each tagged with whether it has
2794
- * write (push or admin) access. Inviting the account as a collaborator is the grant, and
2795
- * membership exists the moment it is invited. `canWrite` is false for a read-only repo; the
2796
- * caller enumerates it but should write nothing there. Paginates until an empty page: Forgejo clamps
2797
- * `limit` (ToCorrectPageSize), so a short page is not a reliable end signal -- same reason as
2798
- * {@link searchForgejoIssues}.
2799
- */
2800
1940
  async function fetchAccessibleRepos(executor, serverUrl, token) {
2801
1941
  const headers = { Authorization: `token ${token}` };
2802
1942
  const repos = [];
@@ -2823,12 +1963,6 @@ async function fetchAccessibleRepos(executor, serverUrl, token) {
2823
1963
  }
2824
1964
  return repos;
2825
1965
  }
2826
- /**
2827
- * Create an issue. Label names are resolved to IDs first (see `resolveLabelIds` in
2828
- * `label-provisioning.ts`), using `input.labelIds` where it already has a name and falling back
2829
- * to a list fetch only for the names it lacks -- the same split `replaceForgejoIssueLabelsByName`
2830
- * (`issue-labels.ts`) uses.
2831
- */
2832
1966
  async function createForgejoIssue(executor, conn, input) {
2833
1967
  const labels = [];
2834
1968
  const missing = [];
@@ -2855,18 +1989,6 @@ async function createForgejoIssue(executor, conn, input) {
2855
1989
  }, repoNotFoundMessage(conn));
2856
1990
  return normalizeIssue(parseIssueOrThrow(await res.json()));
2857
1991
  }
2858
- /**
2859
- * Edit an issue's title and/or body. A field absent from `patch` is absent from the request --
2860
- * `JSON.stringify` drops an `undefined` value, so an omitted field reaches Forgejo as "leave it
2861
- * alone" rather than as an explicit null that would blank the authored text.
2862
- *
2863
- * That distinction is the function's entire contract, not an implementation detail: resolving an
2864
- * omitted body to a *derived* one and PATCHing it over whatever an author had written is how
2865
- * several PRs lost their descriptions. This function cannot repeat that, because there is
2866
- * nothing here to derive from and nothing is sent for a field not given.
2867
- *
2868
- * Idempotent -- a PATCH to fixed values converges, so a transient network blip is safe to retry.
2869
- */
2870
1992
  async function updateForgejoIssue(executor, conn, number, patch) {
2871
1993
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}`, {
2872
1994
  method: "PATCH",
@@ -2882,15 +2004,6 @@ async function updateForgejoIssue(executor, conn, number, patch) {
2882
2004
  }, issueNotFoundMessage(conn, number));
2883
2005
  return normalizeIssue(parseIssueOrThrow(await res.json()));
2884
2006
  }
2885
- /**
2886
- * Set an issue's open/closed state. Writes issue state and nothing else -- title and body are
2887
- * untouched, unlike {@link updateForgejoIssue}'s patch, which deliberately carries neither state
2888
- * nor any other field a caller might be tempted to widen it with (see that function's doc comment
2889
- * for the data-loss bug that narrowness fixes). A third field on that patch would invite the same
2890
- * accident; state gets its own function instead.
2891
- *
2892
- * Idempotent -- Forgejo converges to the given state, so a transient blip is safe to retry.
2893
- */
2894
2007
  async function setForgejoIssueState(executor, conn, number, state) {
2895
2008
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}`, {
2896
2009
  method: "PATCH",
@@ -2905,14 +2018,11 @@ async function setForgejoIssueState(executor, conn, number, state) {
2905
2018
  }
2906
2019
  //#endregion
2907
2020
  //#region ../forgejo/src/label-sync.ts
2908
- /** `check` wins over `dryRun`; neither means apply. */
2909
2021
  function resolveSyncMode(check, dryRun) {
2910
2022
  if (check) return "check";
2911
2023
  if (dryRun) return "dry-run";
2912
2024
  return "apply";
2913
2025
  }
2914
- /** An explicit `owner/repo` target shares the base connection's server and token -- only the
2915
- * repository portion changes, the same convention `resolveFleetTargets` uses. */
2916
2026
  function targetConnection(base, repo) {
2917
2027
  if (repo === void 0) return base;
2918
2028
  return {
@@ -2920,9 +2030,6 @@ function targetConnection(base, repo) {
2920
2030
  repository: repo
2921
2031
  };
2922
2032
  }
2923
- /** Every repo the token can write to, discovered live -- no configured repo list.
2924
- * The server URL and token come from the base connection; only the repository portion varies per
2925
- * target, the same convention `targetConnection` uses. */
2926
2033
  async function resolveFleetTargets(fetcher, base) {
2927
2034
  return (await fetchAccessibleRepos(fetcher, base.serverUrl, base.token)).filter((repo) => repo.canWrite).map((repo) => targetConnection(base, repo.fullName));
2928
2035
  }
@@ -2963,17 +2070,6 @@ async function syncOneRepo(params) {
2963
2070
  }
2964
2071
  return outcome;
2965
2072
  }
2966
- /**
2967
- * Ensure (or, under `check`/`dry-run`, only report) that every target repo carries `specs`.
2968
- * Never touches a label outside `specs` -- both `auditForgejoLabels` and `ensureForgejoLabels`
2969
- * only ever look at the names they are given. `apply` re-audits inside `ensureForgejoLabels`
2970
- * rather than threading the first audit's label-id map through: one extra idempotent GET per
2971
- * repo, traded for not reaching into `ensureForgejoLabels`'s internals from the caller.
2972
- *
2973
- * `contained` governs failure handling only: by default a throw from one repo's sync propagates
2974
- * and stops the run. With `contained: true` one repo's failure is recorded in `failures` and the
2975
- * run continues over the rest.
2976
- */
2977
2073
  async function syncLabelSet(params) {
2978
2074
  const { fetcher, targets, specs, setName, mode, contained, prune, pruneAllowlist } = params;
2979
2075
  const specList = [...specs];
@@ -3034,11 +2130,6 @@ function syncSummaryLines(result) {
3034
2130
  }
3035
2131
  //#endregion
3036
2132
  //#region src/label-sets-file.ts
3037
- /**
3038
- * The specs of one named set in a label-sets file: a JSON object whose keys are set names and
3039
- * whose values are arrays of label specs. Every failure names the file, and all of them happen
3040
- * before any request, so a bad file never leaves a repo half-synced.
3041
- */
3042
2133
  function readLabelSet(path, setName) {
3043
2134
  let raw;
3044
2135
  try {
@@ -3131,10 +2222,6 @@ const listCommand$1 = defineCommand({
3131
2222
  renderList(result);
3132
2223
  }
3133
2224
  });
3134
- /**
3135
- * `sync` has no `--prune`. Deleting a label inside a scope is defensible only where the tool owns
3136
- * that scope, and this command owns none: a stale label is reported and left in place.
3137
- */
3138
2225
  const syncCommand = defineCommand({
3139
2226
  meta: {
3140
2227
  name: "sync",
@@ -3198,10 +2285,6 @@ const syncCommand = defineCommand({
3198
2285
  if (mode === "check" && result.drifted || result.failures.length > 0) process.exitCode = 1;
3199
2286
  }
3200
2287
  });
3201
- /**
3202
- * `labels` is the generic label writer: it provisions whatever set a file names. The agent's own
3203
- * label set is written by `bst-agent agent:labels`, not here.
3204
- */
3205
2288
  const labelsCommand = defineCommand({
3206
2289
  meta: {
3207
2290
  name: "labels",
@@ -3218,20 +2301,11 @@ const labelsCommand = defineCommand({
3218
2301
  });
3219
2302
  //#endregion
3220
2303
  //#region ../forgejo/src/fetcher.ts
3221
- /**
3222
- * A far larger cap for the two calls that legitimately transfer a whole file -- a release-asset
3223
- * upload and an attachment download. Still finite, so "no forge request hangs forever" stays a
3224
- * true invariant, but generous enough that a slow-but-live transfer is never aborted mid-stream.
3225
- * Threaded via `RetryHint.timeoutMs` at those call sites.
3226
- */
3227
2304
  const LONG_FETCH_TIMEOUT_MS = 6e5;
3228
2305
  //#endregion
3229
2306
  //#region ../forgejo/src/releases.ts
3230
- /** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
3231
2307
  const SETTLE_ATTEMPTS = 4;
3232
- /** Base backoff between patient-repair attempts; doubles each attempt. */
3233
2308
  const SETTLE_BASE_DELAY_MS = 2e3;
3234
- /** Cap on a single patient-repair backoff. */
3235
2309
  const SETTLE_MAX_DELAY_MS = 16e3;
3236
2310
  const ReleaseSchema = z.object({
3237
2311
  id: z.number(),
@@ -3241,31 +2315,11 @@ const ReleaseSchema = z.object({
3241
2315
  html_url: z.string().nullish(),
3242
2316
  prerelease: z.boolean().optional()
3243
2317
  });
3244
- /**
3245
- * Detect the signature of a broken Forgejo release: a `created_at` that maps to
3246
- * the Unix epoch (`created_unix = 0`).
3247
- *
3248
- * Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
3249
- * If our `POST /releases` races that placeholder, Forgejo promotes the
3250
- * placeholder in place (flipping `is_tag` to false) **without** resetting
3251
- * `created_unix`, leaving a published release stuck at the epoch. Such a
3252
- * release sorts as the oldest, so `/releases/latest` never returns it.
3253
- *
3254
- * The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
3255
- * depending on the server timezone; both parse to `<= 0` ms. A missing or
3256
- * unparseable date is treated as broken too, so the caller repairs rather than
3257
- * ships it.
3258
- */
3259
2318
  function isEpochReleaseDate(date) {
3260
2319
  if (!date) return true;
3261
2320
  const ms = new Date(date).getTime();
3262
2321
  return Number.isNaN(ms) || ms <= 0;
3263
2322
  }
3264
- /**
3265
- * Fetch the Forgejo release for a tag, returning the parsed object or `null`
3266
- * when no release exists (404). Unlike {@link findRelease}, this exposes the
3267
- * release `id` and dates so callers can inspect/repair an epoch-dated release.
3268
- */
3269
2323
  async function getReleaseByTag(executor, conn, tag) {
3270
2324
  const encodedTag = encodeURIComponent(tag);
3271
2325
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
@@ -3279,20 +2333,12 @@ async function getReleaseByTag(executor, conn, tag) {
3279
2333
  if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
3280
2334
  return parsed.data;
3281
2335
  }
3282
- /**
3283
- * Create a Forgejo release for a given tag.
3284
- *
3285
- * When `options.target` (a commit SHA or branch) is provided and the tag does
3286
- * not yet exist, Forgejo **creates the tag itself** from that commitish and
3287
- * stamps `created_unix = now` synchronously -- sidestepping the buggy async
3288
- * stamping of a pushed tag. The created tag is lightweight.
3289
- */
3290
2336
  async function createRelease(executor, conn, tag, options = {}) {
3291
2337
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
3292
2338
  const payload = {
3293
2339
  tag_name: tag,
3294
- name: tag,
3295
- body: `Published ${tag}`,
2340
+ name: options.name ?? tag,
2341
+ body: options.body ?? `Published ${tag}`,
3296
2342
  prerelease: options.prerelease ?? false
3297
2343
  };
3298
2344
  if (options.target) payload["target_commitish"] = options.target;
@@ -3309,14 +2355,6 @@ async function createRelease(executor, conn, tag, options = {}) {
3309
2355
  resource: tag
3310
2356
  }));
3311
2357
  }
3312
- /**
3313
- * Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
3314
- *
3315
- * Used by {@link ensureRelease} to take tag ownership away from the buggy
3316
- * push-ingestion path: once the tag is gone, recreating the release with a
3317
- * `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
3318
- * (already gone) is treated as success.
3319
- */
3320
2358
  async function deleteTag(executor, conn, tag) {
3321
2359
  const encodedTag = encodeURIComponent(tag);
3322
2360
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
@@ -3334,11 +2372,6 @@ const TagSchema = z.looseObject({
3334
2372
  id: z.string(),
3335
2373
  commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
3336
2374
  });
3337
- /**
3338
- * The commit a tag points at (`GET /repos/{repo}/tags/{tag}`), or `null` when the forge has no
3339
- * such tag. For an annotated tag, `id` and `commit.url` hold the tag object's SHA; only
3340
- * `commit.sha` is the commit, so that is the field read.
3341
- */
3342
2375
  async function getTagCommit(executor, conn, tag) {
3343
2376
  const encodedTag = encodeURIComponent(tag);
3344
2377
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
@@ -3352,12 +2385,6 @@ async function getTagCommit(executor, conn, tag) {
3352
2385
  if (!parsed.success) throw new UnexpectedError(`Unexpected tag response: ${parsed.error.message}`);
3353
2386
  return parsed.data.commit.sha;
3354
2387
  }
3355
- /**
3356
- * Delete a Forgejo release by id. Does **not** delete the underlying git tag
3357
- * (no `delete_tag` query param) -- the tag is the source of truth, and a
3358
- * subsequent `POST /releases` relies on it still existing to insert a fresh
3359
- * row with `created_unix = now`. A 404 (already gone) is treated as success.
3360
- */
3361
2388
  async function deleteRelease(executor, conn, releaseId) {
3362
2389
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
3363
2390
  const res = await executor.fetch(url, {
@@ -3369,21 +2396,6 @@ async function deleteRelease(executor, conn, releaseId) {
3369
2396
  resource: String(releaseId)
3370
2397
  }));
3371
2398
  }
3372
- /**
3373
- * Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
3374
- *
3375
- * {@link ensureRelease} uses this on the path where the release was already there, and so does a
3376
- * rerun, so a rerun over a release an earlier unflagged run created can still
3377
- * keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
3378
- * why the body is fixed at
3379
- * `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
3380
- * convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
3381
- * `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
3382
- *
3383
- * The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
3384
- * but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
3385
- * vanished mid-run means the flag was not applied, and the caller must know.
3386
- */
3387
2399
  async function markReleasePrerelease(executor, conn, releaseId, tag) {
3388
2400
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
3389
2401
  const res = await executor.fetch(url, {
@@ -3399,27 +2411,6 @@ async function markReleasePrerelease(executor, conn, releaseId, tag) {
3399
2411
  resource: tag
3400
2412
  }));
3401
2413
  }
3402
- /**
3403
- * Ensure a Forgejo release exists for a tag **with a real created date**.
3404
- *
3405
- * Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
3406
- * creation that races that stamp freezes the row at the Unix epoch (see
3407
- * {@link isEpochReleaseDate}) -- a published-but-invisible release, because
3408
- * `/releases/latest` sorts by date. The failure is intermittent. The repair:
3409
- *
3410
- * 1. A real-dated release already exists -> `"exists"`.
3411
- * 2. Otherwise create the release and read it back. On epoch, delete the frozen
3412
- * release and wait (increasing backoff) for Forgejo to settle the tag's date,
3413
- * then retry -- **preserving the pushed (annotated) tag**. The frozen release
3414
- * is never deleted-and-left-empty.
3415
- * 3. If the date still never settles and a `target` commit is known, delete the
3416
- * tag and recreate the release with `target_commitish` so **Forgejo creates
3417
- * the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
3418
- * 4. If nothing yields a real date, leave a visible release row (rather than only
3419
- * an invisible tag) and throw loudly.
3420
- *
3421
- * Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
3422
- */
3423
2414
  async function ensureRelease(executor, conn, tag, options = {}) {
3424
2415
  const attempts = options.attempts ?? SETTLE_ATTEMPTS;
3425
2416
  const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
@@ -3443,7 +2434,9 @@ async function ensureRelease(executor, conn, tag, options = {}) {
3443
2434
  try {
3444
2435
  await createRelease(executor, conn, tag, {
3445
2436
  target: options.target,
3446
- prerelease: options.prerelease
2437
+ prerelease: options.prerelease,
2438
+ name: options.name,
2439
+ body: options.body
3447
2440
  });
3448
2441
  } catch (error) {
3449
2442
  const raced = await getReleaseByTag(executor, conn, tag);
@@ -3467,6 +2460,8 @@ async function ensureRelease(executor, conn, tag, options = {}) {
3467
2460
  const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
3468
2461
  target: options.target,
3469
2462
  prerelease: options.prerelease,
2463
+ name: options.name,
2464
+ body: options.body,
3470
2465
  current,
3471
2466
  attempts,
3472
2467
  baseDelayMs
@@ -3478,7 +2473,7 @@ async function ensureRelease(executor, conn, tag, options = {}) {
3478
2473
  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.`));
3479
2474
  }
3480
2475
  async function recreateTagViaApi(executor, conn, tag, opts) {
3481
- const { target, prerelease, current, attempts, baseDelayMs } = opts;
2476
+ const { target, prerelease, name, body, current, attempts, baseDelayMs } = opts;
3482
2477
  log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
3483
2478
  if (current) await deleteRelease(executor, conn, current.id);
3484
2479
  await deleteTag(executor, conn, tag);
@@ -3486,7 +2481,9 @@ async function recreateTagViaApi(executor, conn, tag, opts) {
3486
2481
  try {
3487
2482
  await createRelease(executor, conn, tag, {
3488
2483
  target,
3489
- prerelease
2484
+ prerelease,
2485
+ name,
2486
+ body
3490
2487
  });
3491
2488
  } catch (error) {
3492
2489
  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.`);
@@ -3499,22 +2496,35 @@ async function recreateTagViaApi(executor, conn, tag, opts) {
3499
2496
  }
3500
2497
  return null;
3501
2498
  }
3502
- /**
3503
- * The `{ id, url }` projection of a release row carried back to a caller. A server that omits
3504
- * `html_url` yields `url: null` -- the id is still the row's identity.
3505
- */
3506
2499
  function releaseRef(release) {
3507
2500
  return {
3508
2501
  id: release.id,
3509
2502
  url: release.html_url ?? null
3510
2503
  };
3511
2504
  }
3512
- /** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
3513
2505
  async function settleBackoff(attempt, baseDelayMs) {
3514
2506
  if (baseDelayMs <= 0) return;
3515
2507
  const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
3516
2508
  await new Promise((resolve) => setTimeout(resolve, delay));
3517
2509
  }
2510
+ z.looseObject({
2511
+ id: z.number(),
2512
+ tag_name: z.string(),
2513
+ name: z.string().nullish(),
2514
+ target_commitish: z.string().nullish(),
2515
+ created_at: z.string().nullish(),
2516
+ html_url: z.string().nullish(),
2517
+ draft: z.boolean().optional(),
2518
+ prerelease: z.boolean().optional(),
2519
+ assets: z.array(z.unknown()).nullish()
2520
+ });
2521
+ z.looseObject({
2522
+ name: z.string(),
2523
+ commit: z.looseObject({
2524
+ sha: z.string(),
2525
+ created: z.string().nullish()
2526
+ })
2527
+ });
3518
2528
  //#endregion
3519
2529
  //#region src/commands/release-create.ts
3520
2530
  const releaseCreateCommand = defineCommand({
@@ -3572,25 +2582,11 @@ const ActionSecretListSchema = z.object({
3572
2582
  total_count: z.number()
3573
2583
  });
3574
2584
  z.object({ sha1: z.string() });
3575
- /**
3576
- * The forge's own rules for an Actions secret name, mirrored from Forgejo's
3577
- * `models/secret/secret.go` (`ValidateName`). GitHub reserves `GITHUB_` and a
3578
- * leading digit as well, so a name this accepts is usable on both forges.
3579
- *
3580
- * A rejected name is not a soft failure: the API answers `invalid secret name`,
3581
- * so the secret can never be created, and every workflow referencing it
3582
- * resolves `${{ secrets.X }}` to the empty string instead.
3583
- */
3584
2585
  const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
3585
2586
  const FORBIDDEN_PREFIX_PATTERN = /^(FORGEJO_|GITEA_|GITHUB_|[0-9])/i;
3586
2587
  function isValidSecretName(name) {
3587
2588
  return NAME_PATTERN.test(name) && !FORBIDDEN_PREFIX_PATTERN.test(name);
3588
2589
  }
3589
- /**
3590
- * Set (or overwrite) a Forgejo Actions secret on a repository.
3591
- *
3592
- * Uses PUT which is idempotent -- creates or overwrites.
3593
- */
3594
2590
  async function setActionSecret(executor, conn, secretName, secretValue) {
3595
2591
  const [owner, repo] = conn.repository.split("/");
3596
2592
  const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
@@ -3607,12 +2603,6 @@ async function setActionSecret(executor, conn, secretName, secretValue) {
3607
2603
  resource: secretName
3608
2604
  }));
3609
2605
  }
3610
- /**
3611
- * List Forgejo Actions secret names for a repository.
3612
- *
3613
- * Returns only names -- Forgejo never exposes secret values.
3614
- * Paginates automatically to collect all secrets.
3615
- */
3616
2606
  async function listActionSecrets(executor, conn) {
3617
2607
  const [owner, repo] = conn.repository.split("/");
3618
2608
  const names = [];
@@ -3633,11 +2623,6 @@ async function listActionSecrets(executor, conn) {
3633
2623
  }
3634
2624
  return names;
3635
2625
  }
3636
- /**
3637
- * Delete a Forgejo Actions secret from a repository.
3638
- *
3639
- * Returns silently on success. Throws on failure (including 404 if the secret doesn't exist).
3640
- */
3641
2626
  async function deleteActionSecret(executor, conn, secretName) {
3642
2627
  const [owner, repo] = conn.repository.split("/");
3643
2628
  const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
@@ -3695,7 +2680,6 @@ async function runSecretsDelete(fetcher, config) {
3695
2680
  }
3696
2681
  //#endregion
3697
2682
  //#region src/commands/secrets.ts
3698
- /** Read secret value from --body flag or stdin. */
3699
2683
  function readSecretValue(body) {
3700
2684
  if (body) return body;
3701
2685
  try {
@@ -3826,15 +2810,10 @@ const secretsCommand = defineCommand({
3826
2810
  });
3827
2811
  //#endregion
3828
2812
  //#region ../forgejo/src/user.ts
3829
- /** The user a token belongs to, and the noreply address a commit uses when that user hides theirs. */
3830
2813
  const AuthenticatedUserSchema = z.object({
3831
2814
  login: z.string(),
3832
2815
  email: z.string().nullish()
3833
2816
  });
3834
- /**
3835
- * Read `GET /user`. A non-ok response is a value, not a throw: a token missing `read:user` is an
3836
- * ordinary answer the caller words itself. An empty `email` (a private address) is `null`.
3837
- */
3838
2817
  async function getAuthenticatedUser(executor, conn, signal = null) {
3839
2818
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, {
3840
2819
  headers: authHeaders$2(conn),
@@ -3862,11 +2841,6 @@ const WebhookSchema = z.object({
3862
2841
  config: z.record(z.string(), z.string()).optional()
3863
2842
  });
3864
2843
  const WebhookListSchema = z.array(WebhookSchema);
3865
- /**
3866
- * Returns a status struct instead of throwing, so it deliberately does not use
3867
- * {@link forgejoErrorMessage} -- the caller branches on `status` rather than
3868
- * catching an error.
3869
- */
3870
2844
  async function fetchHooks(executor, conn, endpoint) {
3871
2845
  const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
3872
2846
  if (res.status === 401 || res.status === 403) return {
@@ -3888,28 +2862,18 @@ async function fetchHooks(executor, conn, endpoint) {
3888
2862
  hooks: parsed.data
3889
2863
  };
3890
2864
  }
3891
- /** List system-level webhooks. Requires admin token (read:admin scope). */
3892
2865
  async function listSystemHooks(executor, conn) {
3893
2866
  return fetchHooks(executor, conn, "/admin/hooks");
3894
2867
  }
3895
- /** List webhooks owned by the authenticated user. Requires read:user scope. */
3896
2868
  async function listUserHooks(executor, conn) {
3897
2869
  return fetchHooks(executor, conn, "/user/hooks");
3898
2870
  }
3899
- /** List webhooks for an organization. Requires read:organization scope. */
3900
2871
  async function listOrgHooks(executor, conn, org) {
3901
2872
  return fetchHooks(executor, conn, `/orgs/${encodeURIComponent(org)}/hooks`);
3902
2873
  }
3903
- /** List webhooks for a repository. Requires read:repository + repo admin. */
3904
2874
  async function listRepoHooks(executor, conn, repository) {
3905
2875
  return fetchHooks(executor, conn, `/repos/${repository}/hooks`);
3906
2876
  }
3907
- /**
3908
- * Probe whether the token has admin access by requesting `/admin/users?limit=1`.
3909
- *
3910
- * Returns true on 2xx, false on 401/403. Any other status is treated as not-admin
3911
- * to keep enumeration conservative.
3912
- */
3913
2877
  async function hasAdminAccess(executor, conn) {
3914
2878
  return (await executor.fetch(`${conn.serverUrl}/api/v1/admin/users?limit=1`, { headers: { Authorization: `token ${conn.token}` } })).ok;
3915
2879
  }
@@ -3934,10 +2898,6 @@ async function paginate(executor, conn, endpoint, verb, extract) {
3934
2898
  }
3935
2899
  return all;
3936
2900
  }
3937
- /**
3938
- * List organizations. When `mode` is "admin", uses `/admin/orgs` (all orgs on
3939
- * the instance). When "user", uses `/user/orgs` (only the token user's orgs).
3940
- */
3941
2901
  async function listOrgs(executor, conn, mode) {
3942
2902
  const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
3943
2903
  return (await paginate(executor, conn, endpoint, "list orgs", (raw) => {
@@ -3947,7 +2907,6 @@ async function listOrgs(executor, conn, mode) {
3947
2907
  })).map((o) => o.username);
3948
2908
  }
3949
2909
  const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
3950
- /** List repositories visible to the token via `/repos/search`. */
3951
2910
  async function searchRepos(executor, conn) {
3952
2911
  return (await paginate(executor, conn, "/repos/search", "list repos", (raw) => {
3953
2912
  const parsed = RepoSearchSchema.safeParse(raw);
@@ -3957,7 +2916,6 @@ async function searchRepos(executor, conn) {
3957
2916
  }
3958
2917
  //#endregion
3959
2918
  //#region src/commands/webhooks.ts
3960
- /** Render a WebhookFetchStatus, logging warnings for unauthorized/error cases. */
3961
2919
  function collectGroup(label, scopeHint, status) {
3962
2920
  if (status.status === "ok") return status.hooks.length > 0 ? {
3963
2921
  label,
@@ -4035,30 +2993,6 @@ const webhooksCommand = defineCommand({
4035
2993
  });
4036
2994
  //#endregion
4037
2995
  //#region ../forgejo/src/actions.ts
4038
- /**
4039
- * The Actions reads: the one place in this package that talks to Forgejo's Actions surface, beside
4040
- * `secrets.ts` which already speaks to `/actions/`. What used to be one composed read is now the
4041
- * pieces that compose it -- {@link getActionsRunByNumber}, {@link listActionsRunJobs},
4042
- * {@link fetchActionsJobLogById} -- with {@link fetchActionsJobLog} kept as the composed read its
4043
- * existing callers use, and {@link listActionsRuns} beside them as the paged listing.
4044
- *
4045
- * **Checked against a live instance (2026-09-29).** A status entry's `targetUrl` carries
4046
- * `{run, index}` and `run` there is the repo's
4047
- * run **number**, not the run id the API's `/actions/runs/{id}` paths take -- passing the number
4048
- * as an id silently reads an unrelated, older run. So the resolution is three reads: the run by
4049
- * `?run_number=`, its jobs (a bare array, in the web UI's `/jobs/<index>` order), then the job's
4050
- * log. It is **not** derived from `GET .../actions/tasks`, which was measured returning an
4051
- * unrelated job's log for the same input. Every caller of {@link fetchActionsJobLog} must fail open on any error --
4052
- * this module throws on one rather than swallowing it, exactly like every other read in this
4053
- * package, so the caller's fail-open decision is made once, in one place, rather than half-hidden
4054
- * in here.
4055
- */
4056
- /**
4057
- * A forge timestamp as the instance wrote it, or `null`. Nullish and empty are `null`, and so is
4058
- * any time at or before the Unix epoch: the instance writes the epoch (in its own zone, such as
4059
- * `1969-12-31T18:00:00-06:00`) for a run that has not started, and Go's zero time (`0001-...`)
4060
- * elsewhere. Passing either through would make every duration derived from it meaningless.
4061
- */
4062
2996
  function forgeTimestamp(value) {
4063
2997
  if (value === null || value === void 0 || value === "") return null;
4064
2998
  const ms = Date.parse(value);
@@ -4076,7 +3010,6 @@ const ActionsRunRecordSchema = z.object({
4076
3010
  status: z.string().nullish(),
4077
3011
  started: z.string().nullish(),
4078
3012
  stopped: z.string().nullish(),
4079
- /** Nanoseconds (a Go `time.Duration`); absent until the run finishes. */
4080
3013
  duration: z.number().nullish(),
4081
3014
  html_url: z.string().nullish()
4082
3015
  });
@@ -4104,19 +3037,7 @@ function toActionsRun(record) {
4104
3037
  url: record.html_url ?? null
4105
3038
  };
4106
3039
  }
4107
- /** A ceiling for a reader of the whole log, passed as `{ head: MAX_WHOLE_LOG_CHARS }`: well over
4108
- * the 5 MB logs measured on this repo, and low enough that no reader receives an unbounded body. */
4109
3040
  const MAX_WHOLE_LOG_CHARS = 67108864;
4110
- /**
4111
- * Look a run up by the number the web UI and every `targetUrl` use, and report the whole record
4112
- * rather than only its id. Sends `page` and `limit` with the filter -- without them the instance
4113
- * answers a `run_number` query with every run it holds.
4114
- *
4115
- * No run with that number is a {@link FatalError}: the caller named something that does not
4116
- * exist. A record whose number is not the one asked for is an {@link UnexpectedError}, because
4117
- * an instance that ignored the filter would otherwise hand back its newest run under the
4118
- * caller's number. Also throws on a non-2xx or an unparseable response.
4119
- */
4120
3041
  async function getActionsRunByNumber(executor, conn, runNumber) {
4121
3042
  const params = new URLSearchParams({
4122
3043
  run_number: runNumber,
@@ -4137,11 +3058,6 @@ async function getActionsRunByNumber(executor, conn, runNumber) {
4137
3058
  if (found.number !== null && String(found.number) !== runNumber) throw new UnexpectedError(`Asked ${conn.repository} for run ${runNumber} and got run ${String(found.number)}: the forge ignored the run_number filter`);
4138
3059
  return found;
4139
3060
  }
4140
- /**
4141
- * A run's jobs, as a bare array, with `index` set to array position -- the same order the web UI's
4142
- * `/jobs/<index>` links use. Throws on a non-2xx, an unparseable response, or a job record without
4143
- * the three fields this module requires.
4144
- */
4145
3061
  async function listActionsRunJobs(executor, conn, runId) {
4146
3062
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/runs/${String(runId)}/jobs`;
4147
3063
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
@@ -4160,11 +3076,6 @@ async function listActionsRunJobs(executor, conn, runId) {
4160
3076
  });
4161
3077
  return jobs;
4162
3078
  }
4163
- /**
4164
- * Read one job's log by the numeric id the log endpoint wants, bounded by `bound`. Never throws on
4165
- * a long log: the bound is what keeps a 68 MB-class response from reaching a caller, and
4166
- * {@link ActionsJobLogText.truncated} says when it cut something. Throws on a non-2xx.
4167
- */
4168
3079
  async function fetchActionsJobLogById(executor, conn, jobId, bound) {
4169
3080
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/jobs/${String(jobId)}/logs`;
4170
3081
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
@@ -4188,17 +3099,6 @@ async function fetchActionsJobLogById(executor, conn, jobId, bound) {
4188
3099
  };
4189
3100
  }
4190
3101
  const RunsListResponseSchema = z.object({ workflow_runs: z.array(ActionsRunRecordSchema) });
4191
- /**
4192
- * The repository's workflow runs, newest first, paged **always** (spec decision 9). Every request
4193
- * carries `page` and `limit`: measured against a live instance (2026-09-30), a runs query without
4194
- * `page` ignores `limit` and answers with the whole table -- 3443 runs, 68 MB -- so paging is
4195
- * structural here rather than a caller's obligation.
4196
- *
4197
- * The walk stops on an empty page or once `limit` rows are in hand. A page **shorter** than asked
4198
- * for does not stop it: the server may clamp `limit` to its own page size, and reading a short page
4199
- * as the end of the table would silently answer about part of it. Hitting the page cap with rows
4200
- * still arriving warns, because a truncated listing otherwise reads as absence.
4201
- */
4202
3102
  async function listActionsRuns(executor, conn, options) {
4203
3103
  const headers = { Authorization: `token ${conn.token}` };
4204
3104
  const pageSize = Math.min(options.limit, 50);
@@ -4231,21 +3131,7 @@ async function listActionsRuns(executor, conn, options) {
4231
3131
  }
4232
3132
  return runs.slice(0, options.limit);
4233
3133
  }
4234
- /** A `targetUrl` path of the shape `/actions/runs/{run}/jobs/{index}`, tolerating a scheme and
4235
- * host in front of it and a trailing path segment, query, or fragment after it. */
4236
3134
  const ACTIONS_JOB_TARGET_URL = /\/actions\/runs\/(\d+)\/jobs\/(\d+)(?:[/?#]|$)/u;
4237
- /**
4238
- * Parse a status entry's `targetUrl` into the `{run, index}` the job-log read needs. A `targetUrl`
4239
- * of any other shape -- including `null` -- yields `null`, full stop: **the id is never derived
4240
- * from the `/actions/tasks` listing**, which was measured (against a live instance, in an earlier
4241
- * investigation) returning an unrelated job's log for the same input. This module's job-log read
4242
- * accepts nothing but this parser's output for exactly that reason.
4243
- *
4244
- * The parser lives here rather than beside the incident sweep that first needed it (spec decision
4245
- * 15) because a caller holding a pasted `targetUrl` must be able to read the same object, the URL
4246
- * shape is this package's domain, and a second copy in a command-line tool would be a forked
4247
- * declaration.
4248
- */
4249
3135
  function parseActionsJobTargetUrl(targetUrl) {
4250
3136
  if (targetUrl === null) return null;
4251
3137
  const match = ACTIONS_JOB_TARGET_URL.exec(targetUrl);
@@ -4255,13 +3141,6 @@ function parseActionsJobTargetUrl(targetUrl) {
4255
3141
  index: Number(match[2])
4256
3142
  };
4257
3143
  }
4258
- /**
4259
- * The repository half of the same URL: everything before the first `/actions/runs/` is a
4260
- * repository URL when it carries a scheme, so a pasted `targetUrl` can name the repository it came
4261
- * from instead of leaving that to a separate option. A bare path carries no repository and yields
4262
- * `undefined`; a prefixed value that is not a repository URL is refused by
4263
- * {@link parseRepoTarget}, which is the answer a caller who pasted the wrong thing needs.
4264
- */
4265
3144
  function parseActionsJobTargetRepo(targetUrl) {
4266
3145
  const boundary = targetUrl.indexOf("/actions/runs/");
4267
3146
  if (boundary === -1) return void 0;
@@ -4269,12 +3148,6 @@ function parseActionsJobTargetRepo(targetUrl) {
4269
3148
  if (!prefix.startsWith("http://") && !prefix.startsWith("https://")) return void 0;
4270
3149
  return parseRepoTarget(prefix);
4271
3150
  }
4272
- /**
4273
- * Trigger a `workflow_dispatch` event. Any 2xx is success (the instance's swagger lists 201 and
4274
- * 204). A failure is a {@link FatalError} carrying {@link forgejoErrorMessage}'s text for every
4275
- * status, a 5xx included: a stable wording for every status, so a 5xx stays fatal rather than
4276
- * transient. It is not `throwForgejoFailure`, which would make a 5xx transient.
4277
- */
4278
3151
  async function dispatchWorkflow(executor, conn, options) {
4279
3152
  const repository = options.repository ?? conn.repository;
4280
3153
  const url = `${conn.serverUrl}/api/v1/repos/${repository}/actions/workflows/${encodeURIComponent(options.workflow)}/dispatches`;
@@ -4299,26 +3172,14 @@ async function dispatchWorkflow(executor, conn, options) {
4299
3172
  }
4300
3173
  //#endregion
4301
3174
  //#region ../forgejo/src/actions-log.ts
4302
- /** A CSI sequence: `ESC [` , parameter bytes, intermediate bytes, one final byte. Removed whole --
4303
- * dropping only the escape byte would leave the parameters behind as text. */
4304
3175
  const CSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
4305
- /** An OSC sequence: `ESC ]` up to a BEL or a string terminator (`ESC \`). */
4306
3176
  const OSC_SEQUENCE = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu;
4307
- /** An escape byte that began neither of those. */
4308
3177
  const LONE_ESCAPE = /\u001b/gu;
4309
- /** The C0 controls that may still be left, tab excepted, plus DEL. */
4310
3178
  const CONTROL_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f]/gu;
4311
- /** The first token of a line, when it is shaped like the timestamp the runner writes. */
4312
3179
  const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\S+$/u;
4313
- /** Remove every control sequence and control character, whole. */
4314
3180
  function stripControlSequences(text) {
4315
3181
  return text.replace(CSI_SEQUENCE, "").replace(OSC_SEQUENCE, "").replace(LONE_ESCAPE, "").replace(CONTROL_CHARS, "");
4316
3182
  }
4317
- /**
4318
- * Split one line into its timestamp and its text. The first whitespace-delimited token is the
4319
- * timestamp only when it is shaped like one **and** a clock accepts it -- a job printing a
4320
- * date-shaped word keeps it in its body rather than losing it to a `null`.
4321
- */
4322
3183
  function splitLogLine(line) {
4323
3184
  const spaceAt = line.indexOf(" ");
4324
3185
  const head = spaceAt === -1 ? line : line.slice(0, spaceAt);
@@ -4338,10 +3199,6 @@ function spanBetween(start, end) {
4338
3199
  if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
4339
3200
  return endMs - startMs;
4340
3201
  }
4341
- /**
4342
- * Read a job log as lines. The carriage return of a CRLF pair and the empty line a trailing
4343
- * newline leaves are both dropped.
4344
- */
4345
3202
  function readActionsJobLog(text) {
4346
3203
  const raw = text.split("\n");
4347
3204
  if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
@@ -4358,33 +3215,7 @@ function readActionsJobLog(text) {
4358
3215
  }
4359
3216
  //#endregion
4360
3217
  //#region src/ci-log.ts
4361
- /**
4362
- * `bst-fj ci:log` -- one CI job's log, whole, or the lines of it a `--grep` matches. GitHub has
4363
- * `gh run view --log` for this, so the command is Forgejo-only, like every other command in this
4364
- * bin.
4365
- *
4366
- * The positional is a run number, a run URL (what `ci:runs` prints), or a status `target_url` (what
4367
- * `pr:checks` and `commit:status` print beside a red check), so a caller pastes rather than
4368
- * transcribes.
4369
- *
4370
- * The log is read **whole** up to the library's ceiling, not as the 2 MiB tail the incident body
4371
- * keeps: a `--grep` over a tail would answer "not mentioned" about lines it never saw. A log cut
4372
- * by the ceiling says so in `log.truncated` and in a notice.
4373
- *
4374
- * There is no step table. The runner on the instance this was built against writes a step marker
4375
- * for its first main step only, so a step split read off the log text was wrong for every later
4376
- * step (see `@bensandee/forgejo/actions-log`).
4377
- *
4378
- * Under `--json` the payload carries `lines` only with `--grep`: a whole log is megabytes, and a
4379
- * reader that wants it all reads the text output.
4380
- */
4381
- /** A run page URL: `/actions/runs/{run}`, with nothing after it but a slash, query or fragment. */
4382
3218
  const ACTIONS_RUN_URL = /\/actions\/runs\/(\d+)\/?(?:[?#]|$)/u;
4383
- /**
4384
- * The positional: all digits is a run number; a job URL gives the run and the job index; a run
4385
- * URL gives the run alone. A URL carrying a host and `owner/repo` also names the repository,
4386
- * which then stands in for `--repo`.
4387
- */
4388
3219
  function parseCiLogTarget(target) {
4389
3220
  if (/^\d+$/.test(target)) return {
4390
3221
  run: target,
@@ -4405,7 +3236,6 @@ function parseCiLogTarget(target) {
4405
3236
  };
4406
3237
  throw new FatalError(`Not a run number or an Actions run or job URL: "${target}" (expected e.g. 6693, https://host/owner/repo/actions/runs/6693 or .../actions/runs/6693/jobs/0).`);
4407
3238
  }
4408
- /** What the run's jobs are, in the shape an error message lists them in for a caller to read. */
4409
3239
  function jobTable(jobs) {
4410
3240
  return jobs.map((candidate) => ` ${String(candidate.index)} ${candidate.name} ${candidate.status}`).join("\n");
4411
3241
  }
@@ -4414,16 +3244,6 @@ function jobAtIndex(jobs, index, label) {
4414
3244
  if (found === void 0) throw new FatalError(`${label} ${String(index)} is not one of this run's jobs. Jobs in this run:\n${jobTable(jobs)}`);
4415
3245
  return found;
4416
3246
  }
4417
- /**
4418
- * Which job's log to read.
4419
- *
4420
- * A `--job` of all digits is an index -- the same index a `target_url` carries -- and anything else
4421
- * is an exact, case-insensitive job name. When the URL already named an index, a `--job` that
4422
- * disagrees with it is a refusal naming both, because either could be what the caller meant.
4423
- *
4424
- * With neither and several jobs, this refuses rather than defaulting to index 0: the answer would
4425
- * be about the wrong job, confidently, with nothing said.
4426
- */
4427
3247
  function chooseJob(jobs, job, urlIndex) {
4428
3248
  if (job !== void 0) {
4429
3249
  const chosen = /^\d+$/.test(job) ? jobAtIndex(jobs, Number(job), `--job ${job}`) : jobs.find((candidate) => candidate.name.toLowerCase() === job.toLowerCase());
@@ -4436,11 +3256,6 @@ function chooseJob(jobs, job, urlIndex) {
4436
3256
  if (only !== void 0) return only;
4437
3257
  throw new FatalError(`This run has ${String(jobs.length)} jobs, so --job must say which one (a name or the index).\nJobs in this run:\n${jobTable(jobs)}`);
4438
3258
  }
4439
- /**
4440
- * Read one job's log: run by number, its jobs, the chosen job, the whole log. A refused target, a
4441
- * missing run or job, and a non-2xx from the forge all throw; a successful read of a failed job
4442
- * does not, because this is a readout and not a verdict.
4443
- */
4444
3259
  async function runCiLog(fetcher, options) {
4445
3260
  const target = parseCiLogTarget(options.target);
4446
3261
  let grep;
@@ -4485,7 +3300,6 @@ async function runCiLog(fetcher, options) {
4485
3300
  notices
4486
3301
  };
4487
3302
  }
4488
- /** A two-line header naming the run and job, then each line kept with its timestamp. */
4489
3303
  function renderCiLog(result) {
4490
3304
  const lines = [`run ${String(result.run.number)} ${result.run.status ?? "?"} ${result.run.workflow ?? "?"}`, `job ${String(result.job.index)} ${result.job.name} (${result.job.status})`];
4491
3305
  for (const line of result.lines) lines.push(line.at === null ? line.text : `${line.at} ${line.text}`);
@@ -4548,31 +3362,11 @@ const ciLogCommand = defineCommand({
4548
3362
  });
4549
3363
  //#endregion
4550
3364
  //#region src/ci-runs.ts
4551
- /**
4552
- * `bst-fj ci:runs` -- a read-only, Forgejo-only listing of a repository's workflow runs and their
4553
- * jobs, over the shared paged read in `@bensandee/forgejo/actions`. GitHub run listing already has
4554
- * a first-class tool (`gh run list`), the same precedent `pr:list` follows for the same structural
4555
- * reason (spec decision 2 and 13's target form: `--repo` is a repository URL, like every `pr:*`
4556
- * and `issue:*` read).
4557
- *
4558
- * `--ref` and `--pr` are one filter spelled two ways (spec decision 6): a pull-request run carries
4559
- * `refs/pull/<n>/head`, which no caller should have to know, and the branch's own push runs stay
4560
- * one `--ref <branch>` away.
4561
- */
4562
3365
  function parseLimit$2(raw) {
4563
3366
  const limit = Number(raw);
4564
3367
  if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
4565
3368
  return limit;
4566
3369
  }
4567
- /**
4568
- * The `ref` filter, from whichever of `--ref` and `--pr` was given. A bare `--ref main` names a
4569
- * branch, so it becomes `refs/heads/main`; a value that is already a full ref is sent as is;
4570
- * `--pr` becomes `refs/pull/<n>/head` (spec decision 6) and carries the repository off a PR URL so
4571
- * the URL can stand in for `--repo`.
4572
- *
4573
- * Both together is a refusal rather than a precedence: they are the same filter, and quietly
4574
- * ignoring one answers about a different set of runs than the caller asked for.
4575
- */
4576
3370
  function resolveRunsRef(ref, pr) {
4577
3371
  if (ref !== void 0 && pr !== void 0) throw new FatalError("--ref and --pr are the same filter -- give one of them (--pr 273 for a pull request's own runs, --ref main for a branch's).");
4578
3372
  if (pr !== void 0) {
@@ -4596,11 +3390,6 @@ function resolveRunsRef(ref, pr) {
4596
3390
  target: void 0
4597
3391
  };
4598
3392
  }
4599
- /**
4600
- * List a repository's workflow runs, newest first, each with its jobs. Paging is never optional on
4601
- * this read (spec decision 9): an unpaged runs query answers with the whole table. Refuses a
4602
- * github.com target before any fetch.
4603
- */
4604
3393
  async function runCiRuns(fetcher, options) {
4605
3394
  const limit = parseLimit$2(options.limit);
4606
3395
  const resolved = resolveRunsRef(options.ref, options.pr);
@@ -4632,14 +3421,9 @@ async function runCiRuns(fetcher, options) {
4632
3421
  notices
4633
3422
  };
4634
3423
  }
4635
- /** A duration, or the dash that says the forge reported none (the run has not finished). */
4636
3424
  function durationText(durationMs) {
4637
3425
  return durationMs === null ? "-" : `${String(durationMs)}ms`;
4638
3426
  }
4639
- /**
4640
- * One line per run, then one indented line per job. The run number leads its line because it is
4641
- * what `ci:log` takes as its positional.
4642
- */
4643
3427
  function renderCiRuns(result) {
4644
3428
  if (result.runs.length === 0) return `No workflow runs in ${result.repository}.`;
4645
3429
  const lines = [];
@@ -4728,15 +3512,6 @@ const ciRunsCommand = defineCommand({
4728
3512
  //#region ../cli-kit/src/workflow-inputs.ts
4729
3513
  const INPUT_FLAG = "--input";
4730
3514
  const INPUT_FLAG_EQ = "--input=";
4731
- /**
4732
- * Collect every `--input` value from a command's raw CLI args.
4733
- *
4734
- * citty@0.2.2 keeps only the LAST occurrence of a repeated named flag, so a
4735
- * declared `input` arg cannot capture `--input a=1 --input b=2` -- it silently
4736
- * yields just `b=2`. Scanning the raw argv directly recovers all occurrences.
4737
- * Both `--input k=v` (two tokens) and `--input=k=v` (one token) forms are
4738
- * supported. Throws {@link FatalError} when `--input` has no following value.
4739
- */
4740
3515
  function collectInputArgs(rawArgs) {
4741
3516
  const values = [];
4742
3517
  for (let i = 0; i < rawArgs.length; i += 1) {
@@ -4751,11 +3526,6 @@ function collectInputArgs(rawArgs) {
4751
3526
  }
4752
3527
  return values;
4753
3528
  }
4754
- /**
4755
- * Parse `key=value` workflow-input strings (as collected by
4756
- * {@link collectInputArgs}) into an inputs object. Throws {@link FatalError} on
4757
- * a malformed entry -- no `=`, or an empty key.
4758
- */
4759
3529
  function parseInputs(raw) {
4760
3530
  const inputs = {};
4761
3531
  for (const value of raw) {
@@ -4767,11 +3537,6 @@ function parseInputs(raw) {
4767
3537
  }
4768
3538
  //#endregion
4769
3539
  //#region src/workflow-dispatch.ts
4770
- /**
4771
- * `bst-fj workflow:dispatch`: a thin command over `@bensandee/forgejo/actions`. Forgejo-only;
4772
- * `gh workflow run` already serves GitHub.
4773
- */
4774
- /** Dispatch a `workflow_dispatch` event on the current repository or on `options.repo`. */
4775
3540
  async function runWorkflowDispatch(fetcher, options) {
4776
3541
  const dispatched = await dispatchWorkflow(fetcher, requireForgejoTarget(options.cwd, void 0, "workflow:dispatch is Forgejo-only -- use gh workflow run on GitHub."), {
4777
3542
  ...options.repo === void 0 ? {} : { repository: options.repo },
@@ -4847,31 +3612,11 @@ z.object({
4847
3612
  content: z.string(),
4848
3613
  encoding: z.literal("base64")
4849
3614
  });
4850
- /**
4851
- * Encode a repository path for the segment of an API URL it occupies, rejecting any segment that
4852
- * addresses something other than a name.
4853
- *
4854
- * **Segment-wise, never over the whole string.** `encodeURIComponent` applied to the path entire
4855
- * escapes `/` as `%2F` and breaks every nested path; applied per segment it leaves the separators
4856
- * that address a directory and escapes the reserved characters inside a name.
4857
- *
4858
- * **Encoding alone is not enough, which is why the rejection is here too.**
4859
- * `encodeURIComponent("..")` is `".."` -- a literal dot segment survives untouched and the URL
4860
- * parser still resolves it, walking the request out of the repository it names. Only the
4861
- * percent-encoded `%2e%2e` spelling is neutralised by escaping. So `.`, `..` and the empty segment
4862
- * are refused outright rather than passed through: a repository path addresses a file by name, and
4863
- * a caller with a relative path to resolve has a bug that a 200 from another repository hides.
4864
- */
4865
3615
  function encodeRepoPath(filePath) {
4866
3616
  const segments = filePath.split("/");
4867
3617
  if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new UnexpectedError(`Refusing a repository path with a relative segment: ${filePath}`);
4868
3618
  return segments.map(encodeURIComponent).join("/");
4869
3619
  }
4870
- /**
4871
- * One entry of a directory listing, as {@link getPathEntry} and {@link listDirEntries} need it:
4872
- * the name, id and kind, nothing else. Loose, so fields this module does not read (size,
4873
- * download_url, ...) do not need declaring here just to survive `safeParse`.
4874
- */
4875
3620
  const DirEntrySchema = z.looseObject({
4876
3621
  name: z.string(),
4877
3622
  sha: z.string(),
@@ -4880,10 +3625,6 @@ const DirEntrySchema = z.looseObject({
4880
3625
  z.array(DirEntrySchema);
4881
3626
  //#endregion
4882
3627
  //#region ../forgejo/src/branches.ts
4883
- /**
4884
- * Branch operations on a repository. One API domain: the branch endpoints under
4885
- * `/repos/{o}/{r}/branches`.
4886
- */
4887
3628
  const BranchEntrySchema = z.object({
4888
3629
  name: z.string(),
4889
3630
  protected: z.boolean().optional(),
@@ -4892,10 +3633,7 @@ const BranchEntrySchema = z.object({
4892
3633
  timestamp: z.string().optional()
4893
3634
  }).optional()
4894
3635
  });
4895
- /** Requested page size. Forgejo clamps it, so a short page is NOT an end signal -- only an empty
4896
- * one is. */
4897
3636
  const BRANCHES_PAGE_SIZE = 50;
4898
- /** Default cap on pages, so a misbehaving server cannot spin a caller forever. */
4899
3637
  const DEFAULT_MAX_PAGES = 20;
4900
3638
  function repoBase(conn) {
4901
3639
  return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
@@ -4903,7 +3641,6 @@ function repoBase(conn) {
4903
3641
  function authHeaders(conn) {
4904
3642
  return { Authorization: `token ${conn.token}` };
4905
3643
  }
4906
- /** List a repository's branches, paging until an empty page or `maxPages`. */
4907
3644
  async function listBranches(executor, conn, options = {}) {
4908
3645
  const signal = options.signal;
4909
3646
  const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
@@ -4946,7 +3683,6 @@ const DELETE_REFUSALS = {
4946
3683
  404: "not-found"
4947
3684
  };
4948
3685
  const ErrorBodySchema = z.object({ message: z.string() });
4949
- /** The `message` field of a JSON error body, else the body text itself. */
4950
3686
  async function refusalMessage(res) {
4951
3687
  const text = await responseBodyText(res);
4952
3688
  try {
@@ -4955,7 +3691,6 @@ async function refusalMessage(res) {
4955
3691
  } catch (_error) {}
4956
3692
  return text;
4957
3693
  }
4958
- /** Delete one branch. The name is encoded per segment so `feat/a/b` keeps its slashes. */
4959
3694
  async function deleteBranch(executor, conn, branch) {
4960
3695
  const res = await executor.fetch(`${repoBase(conn)}/branches/${encodeRepoPath(branch)}`, {
4961
3696
  method: "DELETE",
@@ -4976,20 +3711,14 @@ async function deleteBranch(executor, conn, branch) {
4976
3711
  }
4977
3712
  //#endregion
4978
3713
  //#region src/branch.ts
4979
- /**
4980
- * `bst-fj branch:list` and `branch:delete`: thin commands over `@bensandee/forgejo/branches`.
4981
- * Forgejo-only; git already serves every other host.
4982
- */
4983
3714
  const REFUSAL_WORDS = {
4984
3715
  forbidden: "forbidden",
4985
3716
  locked: "locked",
4986
3717
  "not-found": "not found"
4987
3718
  };
4988
- /** List a repository's branches. */
4989
3719
  function runBranchList(fetcher, options) {
4990
3720
  return listBranches(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "branch:list is Forgejo-only -- use git branch -r on GitHub."));
4991
3721
  }
4992
- /** Delete one branch; a refusal is a runtime error naming the reason. */
4993
3722
  async function runBranchDelete(fetcher, options) {
4994
3723
  const result = await deleteBranch(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "branch:delete is Forgejo-only -- use git push --delete on GitHub."), options.name);
4995
3724
  if (!result.ok) throw new FatalError(`Cannot delete branch "${options.name}": ${REFUSAL_WORDS[result.reason]} (HTTP ${String(result.status)})${result.message === "" ? "" : `: ${result.message}`}.`);
@@ -5001,7 +3730,6 @@ function renderBranch(branch) {
5001
3730
  if (branch.commitSha !== null) parts.push(branch.commitSha.slice(0, 7));
5002
3731
  return parts.join(" ");
5003
3732
  }
5004
- /** Human-readable rendering: one line per branch, then a note when the walk was cut short. */
5005
3733
  function renderBranchList(listing) {
5006
3734
  const lines = listing.branches.map(renderBranch);
5007
3735
  if (lines.length === 0) lines.push("No branches.");
@@ -5081,7 +3809,6 @@ const branchSubCommands = {
5081
3809
  };
5082
3810
  //#endregion
5083
3811
  //#region ../cli-kit/src/body-source.ts
5084
- /** Run `read`, converting any raw fs error (ENOENT, EISDIR, EACCES, ...) into a FatalError. */
5085
3812
  function tryRead(read, label) {
5086
3813
  try {
5087
3814
  return read();
@@ -5089,20 +3816,6 @@ function tryRead(read, label) {
5089
3816
  throw new FatalError(`Could not read ${label}: ${describeError(error)}`);
5090
3817
  }
5091
3818
  }
5092
- /**
5093
- * Resolve a `--body` / `--body-file` pair shared by `pr:create`, `pr:update`, `issue:create`, and
5094
- * `issue:comment`.
5095
- *
5096
- * - Both flags given -> FatalError (mutually exclusive).
5097
- * - Neither flag given -> `undefined`. Whether that is acceptable is the caller's concern:
5098
- * `pr:create` falls back to a commit-derived body, `pr:update` deliberately does NOT (it leaves
5099
- * the PR's authored body alone -- the distinction `undefined` carries is the whole point of the
5100
- * command), `issue:create` falls back to `""`, and `issue:comment` throws its own FatalError
5101
- * requiring one of the two flags.
5102
- * - `--body-file -` reads stdin, so a generated plan/body can be piped straight in.
5103
- * - A missing file, a directory, or a permission error all surface as FatalError, never a raw
5104
- * Node error (ENOENT/EISDIR/EACCES).
5105
- */
5106
3819
  function readBodySource(body, bodyFile, options = {}) {
5107
3820
  const flags = options.flags ?? {
5108
3821
  text: "--body",
@@ -5148,33 +3861,8 @@ function normalizeComment(raw) {
5148
3861
  attachments: (raw.assets ?? []).map(normalizeAttachment)
5149
3862
  };
5150
3863
  }
5151
- /**
5152
- * Page size for `GET .../issues/<n>/comments`. Also the signal that another page may exist.
5153
- * Deliberately 50: that is Forgejo's own default `api.MAX_RESPONSE_ITEMS`, the value
5154
- * `ToCorrectPageSize` clamps `limit` down to, so a default instance returns exactly what is asked
5155
- * for and a short page really is the last page.
5156
- */
5157
3864
  const COMMENTS_PAGE_SIZE = 50;
5158
- /** Hard cap on pages fetched, so a misbehaving server can't spin this forever. */
5159
3865
  const COMMENTS_MAX_PAGES = 20;
5160
- /**
5161
- * Every comment on an issue (or a PR -- both forges serve PR conversation comments through this
5162
- * same endpoint), oldest-first, paginating until a short page or the page cap.
5163
- *
5164
- * Pagination is load-bearing, not hygiene. Unpaginated, this returned whatever
5165
- * `api.DEFAULT_PAGING_NUM` is (30 by default) -- the OLDEST 30 comments -- and every caller that
5166
- * asks a "what is the newest X" question got a silently stale answer on any PR busy enough to
5167
- * matter: a caller that picks the newest comment of one author, or keeps only the comments after
5168
- * a watermark, picked a stale boundary and dropped every comment past the cut.
5169
- *
5170
- * A short page is the end signal, per `fetchAllForgejoLabels` (`label-provisioning.ts`), rather
5171
- * than the empty page `searchForgejoIssues` (`issues.ts`) insists on. The residual risk is
5172
- * bounded and stated: an instance that configures `api.MAX_RESPONSE_ITEMS` BELOW 50 clamps every
5173
- * page to that size, and a full clamped page reads as short, so a thread longer than the clamp
5174
- * can still truncate there. That is strictly better than the fixed 30 this replaced, and it costs
5175
- * one GET per listing rather than the two an empty-page probe needs on the overwhelmingly common
5176
- * single-page thread.
5177
- */
5178
3866
  async function listForgejoComments(executor, conn, number, options = {}) {
5179
3867
  const comments = [];
5180
3868
  for (let page = 1; page <= COMMENTS_MAX_PAGES; page++) {
@@ -5203,7 +3891,6 @@ const RepoCommentResponseSchema = CommentResponseSchema.extend({
5203
3891
  updated_at: z.string().nullish()
5204
3892
  });
5205
3893
  z.array(RepoCommentResponseSchema);
5206
- /** Post a new comment on an issue. */
5207
3894
  async function addForgejoComment(executor, conn, number, body) {
5208
3895
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/comments`, {
5209
3896
  method: "POST",
@@ -5218,18 +3905,7 @@ async function addForgejoComment(executor, conn, number, body) {
5218
3905
  }
5219
3906
  //#endregion
5220
3907
  //#region ../forgejo/src/pr-title.ts
5221
- /**
5222
- * The two WIP title spellings a Forgejo draft carries, and the predicates built on them. Its own
5223
- * leaf so `pr.ts` (the draft/undraft PATCHes) and any other caller can import one definition
5224
- * instead of drifting apart.
5225
- *
5226
- * An instance that configures a *different* prefix set is out of reach of any static list; that
5227
- * case is caught by `undraftForgejoPr`'s draft-but-unrecognized guard rather than papered over
5228
- * here.
5229
- */
5230
3908
  const WIP_PREFIXES = ["WIP:", "[WIP]"];
5231
- /** Whether a title carries one of {@link WIP_PREFIXES}, checked case-insensitively against the
5232
- * title exactly as given, with no trim. */
5233
3909
  function hasWipPrefix(title) {
5234
3910
  return matchedWipPrefix(title) !== void 0;
5235
3911
  }
@@ -5237,13 +3913,6 @@ function matchedWipPrefix(title) {
5237
3913
  const lower = title.toLowerCase();
5238
3914
  return WIP_PREFIXES.find((prefix) => lower.startsWith(prefix.toLowerCase()));
5239
3915
  }
5240
- /**
5241
- * Strip a single leading WIP marker off a PR title and return the rest, or the input unchanged
5242
- * when there is no such prefix. Idempotent -- stripping a non-WIP title, or a title already
5243
- * stripped, is a no-op. A superset of the exact inverse of the `WIP: ` prepend
5244
- * `withWipPrefix` (`pr-upsert.ts`) writes: it also clears the
5245
- * hand-typed variants Forgejo itself honours (see {@link WIP_PREFIXES}).
5246
- */
5247
3916
  function stripWipPrefix(title) {
5248
3917
  const prefix = matchedWipPrefix(title);
5249
3918
  if (prefix === void 0) return title;
@@ -5255,32 +3924,11 @@ const CreatedPrSchema = z.object({
5255
3924
  number: z.number(),
5256
3925
  html_url: z.string().optional()
5257
3926
  });
5258
- /** Compose a PR's web URL from the connection coordinates. */
5259
3927
  function prWebUrl(conn, prNumber) {
5260
3928
  return `${conn.serverUrl}/${conn.repository}/pulls/${String(prNumber)}`;
5261
3929
  }
5262
- /** Requested page size. Forgejo clamps it, so a short page is NOT an end signal -- only an empty
5263
- * one is (same reasoning as `searchForgejoIssues`). */
5264
3930
  const PULLS_PAGE_SIZE = 50;
5265
- /** Hard cap on pages, so a misbehaving server cannot spin a caller forever. */
5266
3931
  const PULLS_MAX_PAGES = 20;
5267
- /**
5268
- * Find the open PR with the given head branch -- its number and its base ref -- or null.
5269
- *
5270
- * Filters client-side by head.ref rather than relying on Forgejo's query parameter filtering,
5271
- * which behaves inconsistently. `base` is required on the wire: the endpoint serves full
5272
- * pull-request objects (the same shape {@link getPrRefs} reads off `/pulls/<n>`), and a caller
5273
- * reporting a PR's base must fail loudly rather than fall back to a locally guessed one.
5274
- *
5275
- * Paged to an empty page or to {@link PULLS_MAX_PAGES}, sharing both constants with
5276
- * {@link listPulls} rather than trusting the server's default page size. The walk ends on an
5277
- * empty page rather than a short one because Forgejo clamps `limit`, so a short page may be the
5278
- * clamp rather than the end (same reasoning `listPulls` and `searchForgejoIssues` already
5279
- * record). Exhausting the cap throws rather than returning `null`: this function's answer is a
5280
- * yes/no, so a truncated read does not degrade it, it inverts it into precisely the value that
5281
- * makes a create-if-absent caller write a duplicate to the forge -- unlike `listPulls`, whose
5282
- * caller reads a whole set and can tolerate a truncated tail.
5283
- */
5284
3932
  async function findOpenPrEntry(executor, conn, head, signal = null) {
5285
3933
  let capped = false;
5286
3934
  const match = (await listPulls(executor, conn, {
@@ -5301,10 +3949,6 @@ async function findOpenPrEntry(executor, conn, head, signal = null) {
5301
3949
  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`);
5302
3950
  return null;
5303
3951
  }
5304
- /**
5305
- * The open PR number for a head branch, or null. A wrapper over {@link findOpenPrEntry}: most
5306
- * callers only ask whether a PR exists, and one fetch with one schema serves both questions.
5307
- */
5308
3952
  async function findOpenPr(executor, conn, head, signal = null) {
5309
3953
  return (await findOpenPrEntry(executor, conn, head, signal))?.number ?? null;
5310
3954
  }
@@ -5325,23 +3969,6 @@ const PullListItemSchema = z.object({
5325
3969
  function updatedBefore(since) {
5326
3970
  return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
5327
3971
  }
5328
- /**
5329
- * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
5330
- * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
5331
- * {@link PULLS_MAX_PAGES}, whichever comes first.
5332
- *
5333
- * The one paged PR listing: a caller that decides over the whole set reads it to completion, and
5334
- * a caller passes `stopWhen` to answer "does a pull request already exist on this
5335
- * head, in any state" at the cost of one page in the common case (Forgejo lists recently touched
5336
- * pull requests first). That existence question is the reason this exists alongside
5337
- * {@link findOpenPr}: a caller deciding whether to CREATE must not ask an open-only question, since
5338
- * a closed pull request is still one that exists and creating over it mints a fresh one on every
5339
- * re-entry.
5340
- *
5341
- * Hitting the page cap **warns** rather than returning quietly. A silently truncated read is
5342
- * indistinguishable from "no such pull request", which is precisely the answer that makes a caller
5343
- * create a duplicate.
5344
- */
5345
3972
  async function listPulls(executor, conn, options = {}) {
5346
3973
  const headers = { Authorization: `token ${conn.token}` };
5347
3974
  const pulls = [];
@@ -5392,7 +4019,6 @@ async function listPulls(executor, conn, options = {}) {
5392
4019
  }
5393
4020
  return pulls;
5394
4021
  }
5395
- /** Create a new pull request. */
5396
4022
  async function createPr(executor, conn, options) {
5397
4023
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls`;
5398
4024
  const payload = {
@@ -5421,20 +4047,6 @@ async function createPr(executor, conn, options) {
5421
4047
  };
5422
4048
  }
5423
4049
  z.object({ body: z.string().nullish() });
5424
- /**
5425
- * Read one projection of `.../pulls/<n>` -- the shared body of every single-PR GET below.
5426
- *
5427
- * They all issue the identical request and differ only in which slice of the payload they need,
5428
- * so the schema and a `what` noun are the parameters; `what` spells both the failure verb
5429
- * ("read PR head") and the schema-mismatch message ("Unexpected PR head response"), which keeps
5430
- * an error naming the field the caller actually asked for. Reading the whole payload and picking
5431
- * fields off it in the caller is the tempting alternative and is worse: a narrow schema is what
5432
- * makes a forge that stops serving a field fail loudly here, rather than surfacing `undefined`
5433
- * somewhere further out.
5434
- *
5435
- * Note this is the *pulls* endpoint, which is not interchangeable with the issues endpoint
5436
- * `getPrBody` uses -- only this one carries `head`, `base`, and `draft`.
5437
- */
5438
4050
  async function readPrField(executor, conn, prNumber, schema, what) {
5439
4051
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
5440
4052
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
@@ -5464,11 +4076,6 @@ const PrDetailSchema = z.object({
5464
4076
  mergeable: z.boolean().nullish(),
5465
4077
  state: z.string()
5466
4078
  });
5467
- /**
5468
- * Map a validated pulls-endpoint payload to {@link PrDetail}: an absent `repo` becomes `null`
5469
- * rather than an invented repository name, and an absent `mergeable` becomes `null` rather than
5470
- * `false` -- both forges can omit or null this field while still computing it.
5471
- */
5472
4079
  function normalizePrDetail(data) {
5473
4080
  return {
5474
4081
  head: {
@@ -5487,14 +4094,6 @@ function normalizePrDetail(data) {
5487
4094
  state: data.state
5488
4095
  };
5489
4096
  }
5490
- /**
5491
- * Fetch a PR's head and base branch details, draft/merged state, and tri-state mergeability in one
5492
- * `/pulls/<n>` read -- so a workflow that starts from a PR number reaches its branch without
5493
- * a `git ls-remote` reverse lookup. `ref`/`sha`/`draft`/`merged` are required on the wire: a
5494
- * forge that stops serving one of them should fail loudly here rather than surface `undefined` further out (see `readPrField`'s
5495
- * doc comment). `mergeable` and `repo` are nullish because absent and explicitly-null are both
5496
- * real answers.
5497
- */
5498
4097
  async function getForgejoPrDetail(executor, conn, prNumber) {
5499
4098
  return normalizePrDetail(await readPrField(executor, conn, prNumber, PrDetailSchema, "detail"));
5500
4099
  }
@@ -5508,15 +4107,6 @@ const PrEditStateSchema = z.object({
5508
4107
  mergeable: z.boolean().nullish(),
5509
4108
  html_url: z.string().optional()
5510
4109
  });
5511
- /**
5512
- * Read a pull request's edit state in one `/pulls/<n>` GET -- the read both editors run before
5513
- * they write, and the one they run again to confirm the write took.
5514
- *
5515
- * `url` prefers the forge's own `html_url` because an instance that serves it knows its real web
5516
- * route (a reverse-proxied path included), and falls back to {@link prWebUrl} when it does not.
5517
- * `mergeable` stays tri-state for {@link PrDetail}'s reason: absent is "still computing", not
5518
- * "conflicted".
5519
- */
5520
4110
  async function getPrEditState(executor, conn, prNumber) {
5521
4111
  const parsed = await readPrField(executor, conn, prNumber, PrEditStateSchema, "edit state");
5522
4112
  return {
@@ -5530,16 +4120,6 @@ async function getPrEditState(executor, conn, prNumber) {
5530
4120
  url: parsed.html_url ?? prWebUrl(conn, prNumber)
5531
4121
  };
5532
4122
  }
5533
- /**
5534
- * Repoint a pull request at a new base branch with a PATCH carrying `base` and nothing else.
5535
- *
5536
- * Its own function rather than another field on {@link UpdatePrOptions}: that interface is the
5537
- * title-and-body pair whose whole contract is "an omitted key is left alone", and widening it to
5538
- * carry a branch name would let a caller that only means to fix a title move a pull request's
5539
- * base by accident. The caller confirms the write by re-reading -- Forgejo accepts an unknown
5540
- * `EditPullRequestOption` field silently, so a PATCH that answered 200 is not proof the base
5541
- * moved.
5542
- */
5543
4123
  async function retargetPr(executor, conn, prNumber, base) {
5544
4124
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
5545
4125
  const res = await executor.fetch(url, {
@@ -5559,20 +4139,6 @@ const PrContentSchema = z.object({
5559
4139
  title: z.string(),
5560
4140
  body: z.string().nullish()
5561
4141
  });
5562
- /**
5563
- * Fetch a PR's live title and body in one read.
5564
- *
5565
- * Read by `runPrUpsert` (`pr-upsert.ts`, through `forgejoPrHost`) on the update path, for the
5566
- * fields the caller did not explicitly supply: it PATCHes only what it was given, so the rest stays whatever the forge
5567
- * already holds, and the result it reports has to say so rather than echoing content that was
5568
- * never sent. (A dry-run preview is the load-bearing case -- its whole job is to be the last chance to
5569
- * notice a missing closing keyword, which it cannot do while printing a body the PR will not
5570
- * have.)
5571
- *
5572
- * The *pulls* endpoint, unlike {@link getPrBody}'s issues endpoint, because this read is paired
5573
- * with the `/pulls/<n>` PATCH that consumes it -- one endpoint for the read-modify-write, so a
5574
- * body this returns is a body {@link updatePr} could send back unchanged.
5575
- */
5576
4142
  async function getPrContent(executor, conn, prNumber) {
5577
4143
  const parsed = await readPrField(executor, conn, prNumber, PrContentSchema, "content");
5578
4144
  return {
@@ -5584,12 +4150,6 @@ z.object({
5584
4150
  title: z.string(),
5585
4151
  draft: z.boolean()
5586
4152
  });
5587
- /**
5588
- * Update an existing pull request's title and/or body. A field left out of `options` is left out
5589
- * of the PATCH -- `JSON.stringify` drops an `undefined` value, so an omitted field never reaches
5590
- * the forge as an explicit null that would blank the authored text. An `options` with neither
5591
- * field sends nothing at all rather than an empty PATCH.
5592
- */
5593
4153
  async function updatePr(executor, conn, prNumber, options) {
5594
4154
  if (options.title === void 0 && options.body === void 0) return;
5595
4155
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
@@ -5628,16 +4188,11 @@ const PullReviewCommentSchema = z.object({
5628
4188
  z.array(PullReviewCommentSchema);
5629
4189
  //#endregion
5630
4190
  //#region ../forgejo/src/issue-api.ts
5631
- /**
5632
- * Raised when a call needs an issue number and the reference carries none. The library states the
5633
- * fact and names no command; the caller words the refusal with its own example.
5634
- */
5635
4191
  var IssueNumberRequiredError = class extends FatalError {
5636
4192
  constructor() {
5637
4193
  super("An issue number or issue URL is required.");
5638
4194
  }
5639
4195
  };
5640
- /** The issue number of `ref`, or an {@link IssueNumberRequiredError} when it has none. */
5641
4196
  function requireIssueNumber(ref) {
5642
4197
  if (ref.number === void 0) throw new IssueNumberRequiredError();
5643
4198
  return ref.number;
@@ -5661,10 +4216,6 @@ function forgejoIssueApi(fetcher, conn) {
5661
4216
  }
5662
4217
  //#endregion
5663
4218
  //#region ../forgejo/src/issue-messages.ts
5664
- /**
5665
- * The `--json` help for the issue commands, one string per payload, shared so both bins
5666
- * word it byte for byte alike. Each names the keys of the result its runner returns.
5667
- */
5668
4219
  const ISSUE_LIST_JSON_HELP = "Emit one JSON document: issues ([{number, title, state, url, author, labels, createdAt, updatedAt, closedAt (null while open), isPullRequest, assignees, attachments ([{id, name, size, createdAt, downloadUrl}])}]), truncated.";
5669
4220
  const ISSUE_CREATE_JSON_HELP = "Emit one JSON document: number, url, assignees. A dry run reports number 0 and an empty url.";
5670
4221
  const ISSUE_STATE_JSON_HELP = "Emit one JSON document: number, url, action (close | reopen), state ({previous, current}), changed, comment? ({posted}; present only when a comment was given).";
@@ -5674,56 +4225,39 @@ const ISSUE_DOWNLOAD_JSON_HELP = "Emit one JSON document: dir, files ([{name, si
5674
4225
  const ISSUE_LABEL_JSON_HELP = "Emit one JSON document: number, label, action (add | remove), url.";
5675
4226
  const ISSUE_SET_LABELS_JSON_HELP = "Emit one JSON document: number, labels, previous, added, removed, url. A dry run reports the change it would make and writes nothing.";
5676
4227
  const ISSUE_VIEW_JSON_HELP = "Emit one JSON document: number, title, state, url, author, labels, createdAt, updatedAt, closedAt, body, isPullRequest, assignees, attachments, comments ([{id, author, createdAt, updatedAt?, body, url?}]), commentsTruncated, pr? ({head ({ref, sha, repo (or null)}), base ({ref, sha, repo (or null)}), draft, merged, mergeable (true | false | null), state}), prUnavailable? ({reason}). On a pull request exactly one of pr and prUnavailable is present, and neither is otherwise; repo is null for a deleted fork; commentsTruncated is true when the comment read hit its cap of about 1000.";
5677
- /**
5678
- * The refusal for an {@link IssueNumberRequiredError}. `example` is the program's own complete
5679
- * invocation, for instance the program name, the command and a sample number, so every program
5680
- * words the refusal the same way and names itself.
5681
- */
5682
4228
  function describeIssueNumberRequired(example) {
5683
4229
  return `An issue number or issue URL is required, e.g. \`${example}\`.`;
5684
4230
  }
5685
- /** The refusal for an {@link InvalidListQueryError}, naming the program's own options. */
5686
4231
  function describeInvalidListQuery(error, flags) {
5687
4232
  return error.field === "state" ? `${flags.state} must be one of open, closed, all (got "${error.value}")` : `${flags.limit} must be a positive integer (got "${error.value}")`;
5688
4233
  }
5689
- /** The refusal for a NothingToUpdateError: an update that names no field to change. */
5690
4234
  function describeNothingToUpdate(flags) {
5691
4235
  return `Nothing to change -- pass ${flags.title} and/or ${flags.body}/${flags.bodyFile}. (Omitting them leaves the issue exactly as authored; this command never derives a replacement.)`;
5692
4236
  }
5693
- /** The refusal for an EmptyCommentError, naming the program's own options. */
5694
4237
  function describeEmptyComment(flags) {
5695
4238
  return `${flags.text} / ${flags.file} resolved to empty text -- pass a comment or omit the flag.`;
5696
4239
  }
5697
- /** The refusal for a {@link PullRequestStateError}: a state write aimed at a pull request. */
5698
4240
  function describePullRequestState(error, names) {
5699
4241
  return `#${String(error.number)} is a pull request, not an issue -- ${names.command} refuses to write pull-request state. ${names.advice}`;
5700
4242
  }
5701
- /** The refusal for a {@link NoLabelsGivenError}, naming the program's own clear option. */
5702
4243
  function describeNoLabelsGiven(error, clearFlag) {
5703
4244
  return `No labels given, which would strip every label off #${String(error.number)}. Pass ${clearFlag} if that is what you mean, or name the labels the issue should end up with.`;
5704
4245
  }
5705
- /** The refusal for an {@link AttachmentExistsError}, naming the program's own force option. */
5706
4246
  function describeAttachmentExists(error, forceFlag) {
5707
4247
  return `"${error.filePath}" already exists -- pass ${forceFlag} to overwrite it.`;
5708
4248
  }
5709
4249
  //#endregion
5710
4250
  //#region ../forgejo/src/issue-run.ts
5711
- /** Raised when an update names neither a title nor a body, so there is nothing to change. */
5712
4251
  var NothingToUpdateError = class extends FatalError {
5713
4252
  constructor() {
5714
4253
  super("Nothing to change: neither a title nor a body was given.");
5715
4254
  }
5716
4255
  };
5717
- /** Raised when a create has an empty title. */
5718
4256
  var IssueTitleRequiredError = class extends FatalError {
5719
4257
  constructor() {
5720
4258
  super("An issue title is required.");
5721
4259
  }
5722
4260
  };
5723
- /**
5724
- * Raised when a list query's state or limit is not usable. The library reports the field and the
5725
- * raw value; the caller words the refusal with its own flag names.
5726
- */
5727
4261
  var InvalidListQueryError = class extends FatalError {
5728
4262
  field;
5729
4263
  value;
@@ -5733,10 +4267,6 @@ var InvalidListQueryError = class extends FatalError {
5733
4267
  this.value = value;
5734
4268
  }
5735
4269
  };
5736
- /**
5737
- * Read an issue with its comments. When the target is a pull request, also its pull detail, or the
5738
- * reason that read was refused.
5739
- */
5740
4270
  async function viewIssue(resolve, request) {
5741
4271
  const ref = parseIssueRef(request.ref);
5742
4272
  const number = requireIssueNumber(ref);
@@ -5767,26 +4297,11 @@ async function viewIssue(resolve, request) {
5767
4297
  };
5768
4298
  }
5769
4299
  }
5770
- /**
5771
- * The head branch written for the `pr:` line: bare when the head lives in the repository the view
5772
- * resolved against (compared to `base.repo`, since that repository *is* the one this view was read
5773
- * from), repo-qualified (`someone/fork:their-branch`) otherwise so a fork is visible at a glance,
5774
- * and marked rather than blank when the head repository is gone.
5775
- */
5776
4300
  function formatPrHead(pr) {
5777
4301
  if (pr.head.repo === null) return `${pr.head.ref} (head repo gone)`;
5778
4302
  if (pr.head.repo === pr.base.repo) return pr.head.ref;
5779
4303
  return `${pr.head.repo}:${pr.head.ref}`;
5780
4304
  }
5781
- /**
5782
- * The tri-state mergeable text: `null` means "not computed yet" and is never collapsed into the
5783
- * false case.
5784
- *
5785
- * False reads "not mergeable" rather than "conflicted" because a conflict is only one of the
5786
- * things it means: Forgejo answers false for a work-in-progress PR whose merge is perfectly clean
5787
- * (it folds the WIP flag into the same field), and for one it is still checking. "Not mergeable" is
5788
- * true on both forges; "conflicted" is a diagnosis the field cannot support.
5789
- */
5790
4305
  function formatMergeable(mergeable) {
5791
4306
  if (mergeable === null) return "mergeable: unknown";
5792
4307
  return mergeable ? "mergeable" : "not mergeable";
@@ -5799,11 +4314,6 @@ function formatPrLine(pr) {
5799
4314
  line += ` ${formatMergeable(pr.mergeable)}`;
5800
4315
  return line;
5801
4316
  }
5802
- /**
5803
- * The refused-read counterpart of {@link formatPrLine}, in the same one-line slot. Only the first
5804
- * line of the forge's message survives here -- the hint and the response body it carries are worth
5805
- * keeping in structured output, but not worth turning one line of a human view into five.
5806
- */
5807
4317
  function formatPrUnavailableLine(unavailable) {
5808
4318
  const breakAt = unavailable.reason.indexOf("\n");
5809
4319
  return `pr: unavailable -- ${breakAt === -1 ? unavailable.reason : unavailable.reason.slice(0, breakAt)}`;
@@ -5843,24 +4353,15 @@ function parseState$1(state) {
5843
4353
  if (state === "open" || state === "closed" || state === "all") return state;
5844
4354
  throw new InvalidListQueryError("state", state);
5845
4355
  }
5846
- /**
5847
- * The limit is the accumulation target the paged `IssueApi.list` reads up to (page size is
5848
- * the transport's own), so a non-positive or non-numeric value would either be rejected
5849
- * confusingly by the upstream API or -- worse -- silently produce an unbounded/surprising
5850
- * result set. A garbage value the caller explicitly typed is bad input, not something to clamp
5851
- * into a guess: reject it.
5852
- */
5853
4356
  function parseLimit$1(raw) {
5854
4357
  const limit = Number(raw);
5855
4358
  if (!Number.isInteger(limit) || limit <= 0) throw new InvalidListQueryError("limit", raw);
5856
4359
  return limit;
5857
4360
  }
5858
- /** An absent or whitespace-only label means "no filter" -- never send `labels=`. */
5859
4361
  function normalizeLabel(label) {
5860
4362
  if (label === void 0 || label.trim() === "") return void 0;
5861
4363
  return label;
5862
4364
  }
5863
- /** List issues, filtered by state and label, up to a limit. */
5864
4365
  async function listIssues(resolve, request) {
5865
4366
  const label = normalizeLabel(request.label);
5866
4367
  const query = {
@@ -5877,17 +4378,6 @@ async function listIssues(resolve, request) {
5877
4378
  truncated
5878
4379
  };
5879
4380
  }
5880
- /**
5881
- * Post a new comment on an issue. Always a POST, never a PATCH: an earlier
5882
- * design searched the issue's comments for a marker tag and
5883
- * edited the matching one, but that required the caller to hold comment-edit
5884
- * power and to resolve the authenticated user (`read:user` scope) to scope
5885
- * the search safely -- and even then, a substring match could still land on
5886
- * the wrong self-authored comment (e.g. a plan document that happens to quote
5887
- * the marker string). Append-only removes the entire vulnerability class:
5888
- * the worst failure mode is an extra comment (noise), never an edited or
5889
- * destroyed one.
5890
- */
5891
4381
  async function commentOnIssue(resolve, request) {
5892
4382
  const ref = parseIssueRef(request.ref);
5893
4383
  const number = requireIssueNumber(ref);
@@ -5903,30 +4393,9 @@ async function commentOnIssue(resolve, request) {
5903
4393
  url: issueUrl
5904
4394
  };
5905
4395
  }
5906
- /** Human-readable summary of {@link commentOnIssue}'s result. */
5907
4396
  function formatIssueComment(result, dryRun) {
5908
4397
  return `${dryRun ? "[dry-run] Would comment" : "Commented"} on issue #${String(result.number)}`;
5909
4398
  }
5910
- /**
5911
- * Edit an issue's title and/or body, sending **only** the fields explicitly supplied.
5912
- *
5913
- * This is a PATCH, unlike {@link commentOnIssue}, and the difference is worth stating because the
5914
- * two look superficially alike. Commenting is append-only because an earlier design *searched*
5915
- * an issue's comments for a marker and edited whichever matched -- a substring match could land on
5916
- * the wrong self-authored comment, so nothing here holds comment-edit power at all. Nothing
5917
- * here searches for anything: the caller names one issue by number or URL, and that exact object is
5918
- * what changes. The vulnerability was in the targeting, not in the verb.
5919
- *
5920
- * Nothing is ever derived. An omitted title or body is not resolved to a default computed
5921
- * from somewhere else, because that is precisely how a pull request create once replaced an
5922
- * authored description with a humanized branch name and a bullet list of commit subjects. Here an
5923
- * omitted field is simply not sent. With **neither** supplied there is nothing to do -- no
5924
- * push to be the real work, as a pull request update has -- so it refuses
5925
- * ({@link NothingToUpdateError}) rather than make a no-op API call that reports success.
5926
- *
5927
- * The issue is read first, so a dry run can show the text a new body is about to replace, and so
5928
- * the result reports the true before-and-after rather than echoing back the arguments.
5929
- */
5930
4399
  async function updateIssue(resolve, request) {
5931
4400
  const ref = parseIssueRef(request.ref);
5932
4401
  const number = requireIssueNumber(ref);
@@ -5965,14 +4434,6 @@ async function updateIssue(resolve, request) {
5965
4434
  changed
5966
4435
  };
5967
4436
  }
5968
- /**
5969
- * Human-readable summary of an issue update.
5970
- *
5971
- * A field that was sent but matched what was already there is reported as unchanged rather than as
5972
- * a change. The PATCH is harmless (it converges on the same value), but printing an arrow from a
5973
- * string to itself is the same species of untruth as reporting content that was never sent -- and
5974
- * this is usually a caller who pasted the current title back in and wants to know that.
5975
- */
5976
4437
  function formatIssueUpdate(result, dryRun) {
5977
4438
  const lines = [`${dryRun ? "Would update" : "Updated"} issue #${String(result.number)} (${result.url})`];
5978
4439
  const untouched = ["title", "body"].filter((f) => !result.changed.includes(f));
@@ -5994,26 +4455,10 @@ function formatIssueUpdate(result, dryRun) {
5994
4455
  if (untouched.length > 0) lines.push(` ${untouched.join(" and ")} left as authored`);
5995
4456
  return lines.join("\n");
5996
4457
  }
5997
- /** A requested assignee absent from the case-insensitive lowercased set of what the forge
5998
- * actually returned. */
5999
4458
  function droppedAssignees(requested, returned) {
6000
4459
  const returnedLower = new Set(returned.map((name) => name.toLowerCase()));
6001
4460
  return requested.filter((name) => !returnedLower.has(name.toLowerCase()));
6002
4461
  }
6003
- /**
6004
- * Open a new issue. Unlike {@link commentOnIssue}, there is no pre-existing issue
6005
- * number to build a URL from -- the issue doesn't exist until the POST
6006
- * succeeds -- so a dry run reports a placeholder `{ number: 0, url: "" }`
6007
- * rather than composing a guess.
6008
- *
6009
- * A non-empty `assignees` is verified against the created issue's own returned assignees
6010
- * (case-insensitively -- both forges echo canonical casing, which need not match what was
6011
- * typed). GitHub documents silently dropping an assignee the caller lacks push access to
6012
- * grant; a name requested but absent from the response is not a partial success, so this
6013
- * throws naming the dropped users **and** the issue's number and URL -- the issue was
6014
- * created, and an error that hides that would send the caller into a duplicate-create loop.
6015
- * See `docs/forge-issue-pr-linking.md` for the shape of bug a softer warning would repeat.
6016
- */
6017
4462
  async function createIssue(resolve, request) {
6018
4463
  const title = request.title.trim();
6019
4464
  if (title === "") throw new IssueTitleRequiredError();
@@ -6039,10 +4484,6 @@ async function createIssue(resolve, request) {
6039
4484
  assignees: request.assignees
6040
4485
  };
6041
4486
  }
6042
- /**
6043
- * Human-readable summary of {@link createIssue}'s result, one line per entry. `title` is the title
6044
- * as the caller gave it, since a dry run's result carries none.
6045
- */
6046
4487
  function formatIssueCreate(result, title, dryRun) {
6047
4488
  if (dryRun) return [`[dry-run] Would open an issue titled "${title}"${result.assignees.length > 0 ? ` assigned to ${result.assignees.join(", ")}` : ""}`];
6048
4489
  const lines = [`Opened issue #${String(result.number)}`];
@@ -6064,15 +4505,9 @@ function formatIssueList(result) {
6064
4505
  }
6065
4506
  //#endregion
6066
4507
  //#region src/issue-transport.ts
6067
- /** The sentence for a github.com repository, which owes the reader the alternatives. */
6068
4508
  function githubRefusal(command) {
6069
4509
  return `bst-fj is Forgejo-only -- use \`gh\` or \`bst ${command}\` for a github.com repository.`;
6070
4510
  }
6071
- /**
6072
- * The fetcher, connection and resolved coordinates for a call only Forgejo can serve. `command`
6073
- * is the command's own name (for example `issue:view`), so a refusal points at the command the
6074
- * reader was running.
6075
- */
6076
4511
  function forgejoIssueTarget(cwd, target, command) {
6077
4512
  const conn = requireForgejoTarget(cwd, target, githubRefusal(command));
6078
4513
  return {
@@ -6085,7 +4520,6 @@ function forgejoIssueTarget(cwd, target, command) {
6085
4520
  }
6086
4521
  };
6087
4522
  }
6088
- /** {@link forgejoIssueTarget}, with the issue API over its connection. */
6089
4523
  function forgejoIssueTransport(cwd, target, command) {
6090
4524
  const resolved = forgejoIssueTarget(cwd, target, command);
6091
4525
  return {
@@ -6228,10 +4662,6 @@ const issueCreateCommand = defineCommand({
6228
4662
  });
6229
4663
  //#endregion
6230
4664
  //#region ../forgejo/src/issue-run-download.ts
6231
- /**
6232
- * True only for a file that can be read. A directory or an unreadable path counts as absent, so
6233
- * the later write fails with the real error instead of a misleading "already exists".
6234
- */
6235
4665
  function isReadableFile(filePath) {
6236
4666
  try {
6237
4667
  readFileSync(filePath);
@@ -6247,7 +4677,6 @@ const diskFiles = {
6247
4677
  writeFileSync(filePath, content);
6248
4678
  }
6249
4679
  };
6250
- /** Raised when a download would overwrite a file and `force` is off. */
6251
4680
  var AttachmentExistsError = class extends FatalError {
6252
4681
  filePath;
6253
4682
  constructor(filePath) {
@@ -6255,13 +4684,6 @@ var AttachmentExistsError = class extends FatalError {
6255
4684
  this.filePath = filePath;
6256
4685
  }
6257
4686
  };
6258
- /**
6259
- * Attachment names are supplied by whoever uploaded the file: `../../.ssh/authorized_keys`
6260
- * is a legal name. Reduce to a bare basename -- strip, do not merely reject -- so nothing can
6261
- * escape the output directory. Control characters (0x00-0x1F, 0x7F) are stripped too: a NUL
6262
- * or newline riding along in the name would otherwise reach `fs` (which throws a raw
6263
- * `TypeError` for embedded NULs) or rendered output (fabricated lines, ANSI codes).
6264
- */
6265
4687
  function sanitizeAttachmentName(name) {
6266
4688
  const stripped = Array.from(name).filter((ch) => {
6267
4689
  const code = ch.codePointAt(0) ?? 0;
@@ -6271,7 +4693,6 @@ function sanitizeAttachmentName(name) {
6271
4693
  if (base === "" || base === "." || base === "..") throw new FatalError(`Refusing to write an attachment with an unusable filename: "${name}"`);
6272
4694
  return base;
6273
4695
  }
6274
- /** Give same-named attachments distinct paths (`plan.md`, `plan-2.md`) instead of clobbering. */
6275
4696
  function resolveTargetPath(outDir, name, taken) {
6276
4697
  const safe = sanitizeAttachmentName(name);
6277
4698
  const ext = path.extname(safe);
@@ -6288,20 +4709,6 @@ function resolveTargetPath(outDir, name, taken) {
6288
4709
  if (target !== root && !target.startsWith(root + path.sep)) throw new FatalError(`Refusing to write outside ${outDir}: "${name}"`);
6289
4710
  return target;
6290
4711
  }
6291
- /**
6292
- * Fetch one attachment's bytes and verify them against the size the API declared.
6293
- *
6294
- * A `200` does not prove we got the file: unauthenticated, Forgejo serves an HTML
6295
- * login page with a 200 status. Verifying the length is what stops that login page
6296
- * from landing on disk named `plan.md`, where a reader would then act on it.
6297
- * Nothing is written until the bytes check out.
6298
- *
6299
- * Exported because a caller that wants the bytes in memory rather than on disk -- one
6300
- * collecting an issue's images for a container -- needs exactly this check and has no way
6301
- * to write it without the same reasoning about the login page. It lives here, beside its
6302
- * only other caller, rather than in a new module: `packages/forgejo/src` sits at its
6303
- * crowding ceiling.
6304
- */
6305
4712
  async function downloadAttachment(fetcher, token, attachment) {
6306
4713
  const res = await fetcher.fetch(attachment.downloadUrl, { headers: { Authorization: `token ${token}` } }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
6307
4714
  if (res.status === 401 || res.status === 403 || res.status === 404) throw new FatalError(`Cannot download "${attachment.name}" (HTTP ${String(res.status)}) -- the token needs issue scope and access to this repository.`);
@@ -6311,12 +4718,6 @@ async function downloadAttachment(fetcher, token, attachment) {
6311
4718
  if (body.byteLength !== attachment.size) throw new FatalError(`Refusing to write "${attachment.name}": expected ${String(attachment.size)} bytes but received ${String(body.byteLength)}. The server returned a 200 with the wrong content -- most likely a login page, meaning the token cannot see this attachment.`);
6312
4719
  return body;
6313
4720
  }
6314
- /**
6315
- * Download every attachment on an issue: the ones inline on the issue itself, plus
6316
- * any hanging off its comments. Forgejo-only -- GitHub has no issue-attachment API
6317
- * (its attachments are Markdown links in the issue body), so the resolver is where a
6318
- * caller refuses a GitHub target, before any request is made.
6319
- */
6320
4721
  async function downloadIssueAttachments(resolve, request) {
6321
4722
  const ref = parseIssueRef(request.ref);
6322
4723
  const number = requireIssueNumber(ref);
@@ -6346,7 +4747,6 @@ async function downloadIssueAttachments(resolve, request) {
6346
4747
  files: downloaded
6347
4748
  };
6348
4749
  }
6349
- /** Human-readable summary of a download. */
6350
4750
  function formatDownload(result, dryRun) {
6351
4751
  const lines = [];
6352
4752
  const verb = dryRun ? "[dry-run] Would download" : "Downloaded";
@@ -6416,12 +4816,6 @@ const issueDownloadCommand = defineCommand({
6416
4816
  });
6417
4817
  //#endregion
6418
4818
  //#region ../forgejo/src/issue-labels.ts
6419
- /**
6420
- * Forgejo encodes a timeline label change in `body`, not a dedicated field: `"1"` marks an
6421
- * addition, `""` marks a removal. This is Forgejo/Gitea's internal representation, not a
6422
- * documented contract, and unverifiable against a live forge from a sandbox -- so any
6423
- * other value is treated as unrecognised and the entry is dropped rather than guessed at.
6424
- */
6425
4819
  const TimelineEntrySchema = z.object({
6426
4820
  id: z.number(),
6427
4821
  type: z.string(),
@@ -6431,13 +4825,6 @@ const TimelineEntrySchema = z.object({
6431
4825
  user: z.object({ login: z.string() }).nullish()
6432
4826
  });
6433
4827
  z.array(TimelineEntrySchema).nullable();
6434
- /**
6435
- * Resolve one label name to its numeric id (see `resolveLabelIds` in `label-provisioning.ts`).
6436
- * When the caller already has a name->id map (e.g. from `ensureForgejoLabels`), this
6437
- * short-circuits on it instead of re-fetching the full label list -- a caller otherwise pays
6438
- * one identical fetch per mutation (ensure + add + up to 2 removes) for information already in
6439
- * hand.
6440
- */
6441
4828
  async function resolveLabelId(executor, conn, name, labelIds) {
6442
4829
  const cached = labelIds?.get(name);
6443
4830
  if (cached !== void 0) return cached;
@@ -6445,10 +4832,6 @@ async function resolveLabelId(executor, conn, name, labelIds) {
6445
4832
  if (id === void 0) throw new UnexpectedError(`Label id resolution returned nothing for "${name}"`);
6446
4833
  return id;
6447
4834
  }
6448
- /**
6449
- * Attach a label (by name) to an issue. `labelIds`, when given, skips the name->id list
6450
- * fetch (see {@link resolveLabelId}).
6451
- */
6452
4835
  async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
6453
4836
  const id = await resolveLabelId(executor, conn, name, labelIds);
6454
4837
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
@@ -6461,11 +4844,6 @@ async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
6461
4844
  resource: `#${String(number)}`
6462
4845
  }, issueNotFoundMessage(conn, number));
6463
4846
  }
6464
- /**
6465
- * Detach a label (by name) from an issue. Idempotent -- Forgejo returns 204 whether or
6466
- * not the label was attached, so a double-drop is harmless.
6467
- * `labelIds`, when given, skips the name->id list fetch (see {@link resolveLabelId}).
6468
- */
6469
4847
  async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
6470
4848
  const id = await resolveLabelId(executor, conn, name, labelIds);
6471
4849
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
@@ -6477,12 +4855,6 @@ async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
6477
4855
  resource: `#${String(number)}`
6478
4856
  }, issueNotFoundMessage(conn, number));
6479
4857
  }
6480
- /**
6481
- * Replace an issue's entire label set in one atomic PUT (Forgejo "Replace an issue's labels").
6482
- * Unlike add/remove, this is a single transition with no half-applied window -- the primitive
6483
- * the label state machine's exactly-one invariant relies on. Idempotent: re-PUTting the same
6484
- * set re-establishes the same state, so a transient blip is safe to retry.
6485
- */
6486
4858
  async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
6487
4859
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
6488
4860
  method: "PUT",
@@ -6496,12 +4868,6 @@ async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
6496
4868
  }
6497
4869
  //#endregion
6498
4870
  //#region ../forgejo/src/issue-run-labels.ts
6499
- /**
6500
- * Add or remove a single label on an issue. Forgejo-only: the label helpers take a raw
6501
- * connection and no GitHub label transport is wired through `IssueApi`, so the resolver is where
6502
- * a caller refuses a github.com target, before any request. `repo` names a repository other than
6503
- * the working directory's; an issue URL carries its own coordinates.
6504
- */
6505
4871
  async function labelIssue(resolve, request) {
6506
4872
  const ref = parseIssueRef(request.ref);
6507
4873
  const number = requireIssueNumber(ref);
@@ -6522,7 +4888,6 @@ async function labelIssue(resolve, request) {
6522
4888
  url
6523
4889
  };
6524
4890
  }
6525
- /** Human-readable summary of a label add or remove. */
6526
4891
  function formatIssueLabel(result, dryRun) {
6527
4892
  const adding = result.action === "add";
6528
4893
  let verb;
@@ -6531,7 +4896,6 @@ function formatIssueLabel(result, dryRun) {
6531
4896
  const prep = adding ? "to" : "from";
6532
4897
  return `${verb} label "${result.label}" ${prep} #${String(result.number)} (${result.url})`;
6533
4898
  }
6534
- /** Raised when an empty label set arrives without `clear`. Carries the issue it would have stripped. */
6535
4899
  var NoLabelsGivenError = class extends FatalError {
6536
4900
  number;
6537
4901
  constructor(number) {
@@ -6539,22 +4903,6 @@ var NoLabelsGivenError = class extends FatalError {
6539
4903
  this.number = number;
6540
4904
  }
6541
4905
  };
6542
- /**
6543
- * Replace an issue's entire label set in one atomic call (Forgejo-only, like {@link labelIssue}).
6544
- *
6545
- * **The current set is read first, and the result reports the diff.** Replacement is the whole
6546
- * point of the call, so a label the caller omitted is meant to go -- but "meant to go" and
6547
- * "the caller knew it was there" are different things, and only the second is worth relying on.
6548
- * Without the read, a dry run could only echo the arguments back: it told you what you had just
6549
- * typed and nothing about what it would destroy, which is not a preview.
6550
- *
6551
- * **An empty set additionally requires `clear`.** Every other call is an explicit instruction --
6552
- * you named the labels you want. An empty list is the one shape a caller can arrive at by
6553
- * accident: a variable or glob that expanded to nothing produces exactly the request that strips
6554
- * the issue bare, including a label a caller's own scheme says must never be absent.
6555
- * Requiring `clear` costs the deliberate caller almost nothing and makes the accidental one
6556
- * impossible. It is not a confirmation prompt -- this must stay scriptable.
6557
- */
6558
4906
  async function setIssueLabels(resolve, request) {
6559
4907
  const ref = parseIssueRef(request.ref);
6560
4908
  const number = requireIssueNumber(ref);
@@ -6572,7 +4920,6 @@ async function setIssueLabels(resolve, request) {
6572
4920
  ...diff
6573
4921
  };
6574
4922
  }
6575
- /** Set difference both ways, order-preserving so the report reads in the order the caller sees. */
6576
4923
  function diffLabels(previous, next) {
6577
4924
  const before = new Set(previous);
6578
4925
  const after = new Set(next);
@@ -6581,13 +4928,6 @@ function diffLabels(previous, next) {
6581
4928
  removed: previous.filter((name) => !after.has(name))
6582
4929
  };
6583
4930
  }
6584
- /**
6585
- * Human-readable summary of a label-set replacement.
6586
- *
6587
- * Leads with what is destroyed. The resulting set is the easy half to reconstruct (the caller
6588
- * typed it); the removals are the half worth putting in front of someone, and on a dry run they
6589
- * are the entire reason to have run it.
6590
- */
6591
4931
  function formatIssueSetLabels(result, dryRun) {
6592
4932
  const head = `${dryRun ? "Would set" : "Set"} labels [${result.labels.length === 0 ? "(none)" : result.labels.join(", ")}] on #${String(result.number)} (${result.url})`;
6593
4933
  if (result.added.length === 0 && result.removed.length === 0) return `${head}\n no change (the issue already carries exactly these)`;
@@ -6719,13 +5059,11 @@ const issueSetLabelsCommand = defineCommand({
6719
5059
  });
6720
5060
  //#endregion
6721
5061
  //#region ../forgejo/src/issue-run-state.ts
6722
- /** Raised when a comment was given but is empty or only whitespace. */
6723
5062
  var EmptyCommentError = class extends FatalError {
6724
5063
  constructor() {
6725
5064
  super("The comment resolved to empty text.");
6726
5065
  }
6727
5066
  };
6728
- /** Raised when a state write targets a pull request, whose state is not written here. */
6729
5067
  var PullRequestStateError = class extends FatalError {
6730
5068
  number;
6731
5069
  action;
@@ -6739,16 +5077,6 @@ const TARGET_STATE = {
6739
5077
  close: "closed",
6740
5078
  reopen: "open"
6741
5079
  };
6742
- /**
6743
- * Write an issue's open/closed state, and nothing else. The run is: read, refuse or converge,
6744
- * comment, then write state -- see `IssueApi.setState`'s doc comment for why state never travels
6745
- * through `update`'s patch.
6746
- *
6747
- * The comment (if any) is posted **before** the state write, deliberately: a comment-landed /
6748
- * state-failed split leaves a still-open issue with an explanatory comment on it, which is
6749
- * survivable and visible. The reverse order would leave a closed issue with nothing saying why,
6750
- * and it would fail silently -- exactly the window this call exists to close.
6751
- */
6752
5080
  async function setIssueState(resolve, request) {
6753
5081
  const ref = parseIssueRef(request.ref);
6754
5082
  const number = requireIssueNumber(ref);
@@ -6785,7 +5113,6 @@ const PAST_TENSE = {
6785
5113
  close: "Closed",
6786
5114
  reopen: "Reopened"
6787
5115
  };
6788
- /** Human-readable summary of a close or reopen. */
6789
5116
  function formatIssueState(result, dryRun) {
6790
5117
  const lines = [];
6791
5118
  if (!result.changed) lines.push(`#${String(result.number)} is already ${result.state.current} -- nothing to ${result.action}. (${result.url})`);
@@ -6932,11 +5259,6 @@ const issueListCommand = defineCommand({
6932
5259
  });
6933
5260
  //#endregion
6934
5261
  //#region src/commands/issue-update.ts
6935
- /**
6936
- * Edit an existing issue's title and/or body. It PATCHes exactly the issue named, and only the
6937
- * fields passed: an omitted field is never replaced by a derived value, and passing neither is
6938
- * an error, not a no-op.
6939
- */
6940
5262
  const issueUpdateCommand = defineCommand({
6941
5263
  meta: {
6942
5264
  name: "issue:update",
@@ -7063,31 +5385,6 @@ const issueSubCommands = {
7063
5385
  };
7064
5386
  //#endregion
7065
5387
  //#region src/pr-checks.ts
7066
- /**
7067
- * `bst-fj pr:checks` -- the PR CI verdict, read live off the PR's current head SHA over the same
7068
- * `@bensandee/forgejo` primitive the automerge gate uses (`readCommitChecks`), so the agent and
7069
- * the CLI cannot drift about what green means.
7070
- *
7071
- * This module owns the whole runner (`runPrChecks`); `commands/pr-checks.ts` is the thin citty
7072
- * definition over it. Resolution -- turning `--pr`/`--branch`/`--repo`/the current checkout into a
7073
- * PR number, a live `ForgejoConnection`, and that PR's detail -- is `resolvePrChecksTarget` below,
7074
- * kept as its own function because it is where every refusal lives: no PR for a branch, a
7075
- * `--repo` with neither `--pr` nor `--branch`, or a github.com target. Reads GET-only, at every
7076
- * step, under every flag.
7077
- */
7078
- /**
7079
- * Resolve `--pr`/`--branch`/`--repo`/the current checkout into a PR number, a live
7080
- * `ForgejoConnection`, and that PR's detail -- the whole set of ways this command can be told "no"
7081
- * before it ever reads a check.
7082
- *
7083
- * Order: `--pr` (a number or URL, the URL carrying its own repository) wins; otherwise `--branch`
7084
- * resolves through {@link findOpenPr}; otherwise the current checkout's branch does, through the
7085
- * same lookup. `--repo` requires an explicit `--pr` or `--branch`: the current branch of this
7086
- * checkout means nothing in another repository, and inferring it there would silently answer
7087
- * about the wrong PR. A github.com target refuses before any request -- GitHub Actions publishes
7088
- * check runs, not commit statuses, so a combined-status read against it would confidently report
7089
- * `no-status` for a green PR.
7090
- */
7091
5388
  async function resolvePrChecksTarget(fetcher, options) {
7092
5389
  const ref = parseIssueRef(options.pr);
7093
5390
  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.");
@@ -7113,22 +5410,6 @@ async function resolvePrChecksTarget(fetcher, options) {
7113
5410
  detail
7114
5411
  };
7115
5412
  }
7116
- /**
7117
- * Read the base branch's current head checks and report which normalized names read red there --
7118
- * the baseline a PR head's own red checks are compared against. Matched by normalized `name`, not
7119
- * raw `context`: Forgejo folds the triggering event into the context, so the same job reads
7120
- * `CI / check (pull_request)` on the PR and `CI / check (push)` on `main`, and exact-string
7121
- * matching would fail in precisely the case this annotation exists for.
7122
- *
7123
- * `baseline: false` (`--no-baseline`) skips both reads entirely and reports `"skipped"`. Any other
7124
- * failure to establish a clean baseline -- the branch-head read failing, the status read failing,
7125
- * an unparseable status body, or zero statuses on the base head -- reports `"unavailable"` with a
7126
- * `reason`, never `"read"` with an empty `redNames`: an empty list must mean "read, and none were
7127
- * red", not "could not tell".
7128
- *
7129
- * Exported for the agent's automerge sweep, which annotates its CI-red hold with the same
7130
- * baseline.
7131
- */
7132
5413
  async function readBaseline(fetcher, conn, baseRef, enabled) {
7133
5414
  if (!enabled) return {
7134
5415
  ref: baseRef,
@@ -7169,12 +5450,6 @@ async function readBaseline(fetcher, conn, baseRef, enabled) {
7169
5450
  redNames
7170
5451
  };
7171
5452
  }
7172
- /**
7173
- * Assemble a {@link PrChecksReport} from a PR detail read, a commit-status read, and the
7174
- * once-per-invocation baseline -- the one place that shape is built, so the read-once path and
7175
- * every `--wait` exit (settled, `head-moved`, `timeout`, retry-budget exhaustion) produce
7176
- * identically-shaped reports.
7177
- */
7178
5453
  function buildReport(detail, checks, ctx, extras = {}) {
7179
5454
  const { target, baseline, notices, waited, elapsedMs } = ctx;
7180
5455
  const preexistingNames = new Set(baseline.status === "read" ? baseline.redNames : []);
@@ -7203,21 +5478,6 @@ function buildReport(detail, checks, ctx, extras = {}) {
7203
5478
  if (extras.previousHeadSha !== void 0) report.previousHeadSha = extras.previousHeadSha;
7204
5479
  return report;
7205
5480
  }
7206
- /**
7207
- * Poll the PR's live head until its checks settle, following the spec's poll contract: each poll
7208
- * re-reads the PR detail (never pinning the head SHA from an earlier poll) and then that head's
7209
- * commit status -- exactly two reads. A changed head SHA ends the wait immediately as
7210
- * `head-moved`, carrying both SHAs, rather than re-pinning and continuing: a verdict for a commit
7211
- * that is no longer head is meaningless, and following a rebasing head would let the wait outlive
7212
- * its own timeout. Settlement additionally requires a **quiet interval**: two consecutive polls,
7213
- * one interval apart, both settled with the same set of contexts -- a job that has posted no status
7214
- * yet is invisible to this endpoint, so a workflow registering moments after another finished would
7215
- * otherwise be reported settled before it ever ran. A red check never short-circuits the wait; the
7216
- * point of `--wait` is a complete report. Up to {@link RETRY_BUDGET} consecutive failed or
7217
- * unparseable status reads are absorbed (the counter resets on any good read) before the wait gives
7218
- * up as `unparseable`; a timeout still reports whatever was last observed. The per-poll PR-detail
7219
- * read carries its own budget of the same size, exhausting into a rethrow.
7220
- */
7221
5481
  async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
7222
5482
  const { timeoutMs, intervalMs, sleep, now } = poll;
7223
5483
  const startedAt = now();
@@ -7289,13 +5549,6 @@ async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
7289
5549
  currentDetail = nextDetail;
7290
5550
  }
7291
5551
  }
7292
- /**
7293
- * Read the PR's current head SHA's checks and produce a report -- classified into
7294
- * {@link PrChecksStatus}, over one combined-status read by default or, with `options.wait`, over
7295
- * {@link waitForSettledChecks}'s poll loop. Never touches `process.exitCode` and never prints: that
7296
- * is `commands/pr-checks.ts`'s job, which also relays `report.notices` through `CliOutput.notice`
7297
- * and strips it before emitting the JSON payload.
7298
- */
7299
5552
  async function runPrChecks(fetcher, options) {
7300
5553
  const timings = options.wait === true ? resolvePollTimings(options) : void 0;
7301
5554
  const target = await resolvePrChecksTarget(fetcher, options);
@@ -7319,12 +5572,6 @@ async function runPrChecks(fetcher, options) {
7319
5572
  elapsedMs: 0
7320
5573
  });
7321
5574
  }
7322
- /**
7323
- * The baseline summary clause, e.g. `3 red, 1 of them already red on main` -- present only when
7324
- * the baseline was actually read and at least one check is red. An unavailable or skipped
7325
- * baseline never earns a clause: saying nothing is the honest answer when "how many were
7326
- * pre-existing" cannot be established, never an implied zero.
7327
- */
7328
5575
  function baselineClause(report) {
7329
5576
  if (report.baseline.status !== "read") return null;
7330
5577
  const redChecks = report.checks.filter((check) => check.verdict === "red");
@@ -7332,12 +5579,6 @@ function baselineClause(report) {
7332
5579
  const preexistingCount = redChecks.filter((check) => check.preexisting).length;
7333
5580
  return `${String(redChecks.length)} red, ${String(preexistingCount)} of them already red on ` + report.baseline.ref;
7334
5581
  }
7335
- /**
7336
- * Render a `pr:checks` report as text: one line per check (its state, context and description,
7337
- * with the target URL appended only for a red check -- the line a human is about to go read), then
7338
- * a summary line carrying the head SHA, the verdict, the baseline clause when there is one, and
7339
- * the PR URL.
7340
- */
7341
5582
  function renderPrChecks(report) {
7342
5583
  const lines = report.checks.map((check) => {
7343
5584
  const parts = [check.state, check.context];
@@ -7420,34 +5661,10 @@ const prChecksCommand = defineCommand({
7420
5661
  });
7421
5662
  //#endregion
7422
5663
  //#region ../forgejo/src/closing-refs.ts
7423
- /**
7424
- * A closing keyword and the issue it references, e.g. `Closes #<n>`.
7425
- *
7426
- * Both boundaries mirror the forge's own matcher, and both are load-bearing against
7427
- * fabricating a link the author never wrote. Leading `(?:^|[\s([])` (Forgejo's regex uses
7428
- * exactly this set): without it `fix(es)?` matches the tail of "prefixes #12". Trailing
7429
- * `(?![\w-])`: without it a `fix` keyword followed by `#12-of-3` harvests `#12` -- text the
7430
- * forge would not treat as a reference, converted into a canonical footer line that closes a
7431
- * real issue. The
7432
- * separator is line-bound (`[ \t]`, never `\s`) so a keyword ending one line and an issue
7433
- * number starting the next stay unrelated statements. The optional colon is deliberate --
7434
- * `Fixes: #1` is a real reference (Forgejo's own regex ends `:?`), so do not codify a
7435
- * no-colon rule; but a separator of some kind is required, so `fixes` run straight into
7436
- * `#12` is nothing.
7437
- */
7438
5664
  const CLOSING_REF = /(?:^|[\s([])(clos(?:e|es|ed)|fix(?:es|ed)?|resolv(?:e|es|ed))(?::[ \t]*|[ \t]+)((?:[\w.-]+\/[\w.-]+)?#\d+)(?![\w-])/gim;
7439
- /** Uppercase the first letter, so `closes #<n>` renders as `Closes #<n>`. */
7440
5665
  function capitalize(word) {
7441
5666
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
7442
5667
  }
7443
- /**
7444
- * Harvest `Closes #<n>` references from full commit messages, deduplicated by issue,
7445
- * first keyword winning.
7446
- *
7447
- * These must be read from whole messages (the `%B` format of `git log`), not subjects: a closing
7448
- * keyword conventionally lives in the commit *footer*, and reading only `%s` silently
7449
- * dropped it -- the PR then merged without ever linking the issue.
7450
- */
7451
5668
  function extractClosingRefs(messages) {
7452
5669
  const byIssue = /* @__PURE__ */ new Map();
7453
5670
  for (const message of messages) for (const [, keyword, issue] of message.matchAll(CLOSING_REF)) {
@@ -7460,44 +5677,13 @@ function extractClosingRefs(messages) {
7460
5677
  }
7461
5678
  return [...byIssue.values()];
7462
5679
  }
7463
- /**
7464
- * The issues `text` closes, for filtering refs against a body or bullet list. Reusing
7465
- * CLOSING_REF is the point: a bare mention ("refs #281", "see upstream/lib#281") must NOT
7466
- * satisfy a closing reference -- treating it as satisfied is precisely the silent link
7467
- * loss this module exists to prevent -- and the regex's own boundaries keep `#28` from
7468
- * matching inside `#281` and `#4` from matching inside `owner/repo#4`.
7469
- */
7470
5680
  function closedIssues(text) {
7471
5681
  return new Set(extractClosingRefs([text]).map((ref) => ref.issue.toLowerCase()));
7472
5682
  }
7473
- /**
7474
- * The closing refs `messages` make that `body` does not -- the links that will be lost if `body`
7475
- * is what lands on the PR. Extracted so the derive path and the update path compute "dropped"
7476
- * from one definition: `derivePrContent` reports it for an explicit body it was handed, while
7477
- * `checkClosingLinks` reports it for a body the update is deliberately *not* rewriting (the PR's
7478
- * existing one).
7479
- * Those are the same question asked about two different bodies, and answering it twice would let
7480
- * the two drift.
7481
- */
7482
5683
  function droppedClosingRefs(messages, body) {
7483
5684
  const closed = closedIssues(body);
7484
5685
  return extractClosingRefs(messages).filter((ref) => !closed.has(ref.issue.toLowerCase()));
7485
5686
  }
7486
- /**
7487
- * Append to `target` any closing reference `source` makes that `target` does not carry, so neither
7488
- * body's closing references are lost when one replaces the other -- used both when a managed body
7489
- * replaces a preserved one (the human's own `Fixes #<m>` survives) and when a preserved body is kept
7490
- * over a managed one (the run's `Closes #<n>` still lands). Reuses {@link droppedClosingRefs}, not a
7491
- * second definition of what counts as a closing reference, so a bare mention ("refs #42") is never
7492
- * treated as satisfying one. Appended refs land one per line under a blank line; when `target` is
7493
- * empty or whitespace-only they become the whole body instead of trailing a leading blank line.
7494
- * Returns `target` unchanged when nothing is missing, so the no-op patch omission this feeds is a
7495
- * plain equality check.
7496
- *
7497
- * Exported for callers that PATCH a managed PR body directly (`updatePr`, no git operations)
7498
- * rather than through the upsert -- the same "human's `Fixes #<m>` survives" guarantee
7499
- * `planPrUpdate` gives a finalize, reused rather than redefined for a lighter-weight caller.
7500
- */
7501
5687
  function carryForwardClosingRefs(source, target) {
7502
5688
  const missing = droppedClosingRefs([source], target);
7503
5689
  if (missing.length === 0) return target;
@@ -7506,18 +5692,8 @@ function carryForwardClosingRefs(source, target) {
7506
5692
  }
7507
5693
  //#endregion
7508
5694
  //#region ../forgejo/src/git.ts
7509
- /** A git child that touches no network: `rev-parse`, `log`, `status`, `symbolic-ref`. */
7510
5695
  const GIT_LOCAL_TIMEOUT_MS = 6e4;
7511
- /** A git child that publishes: `push`. Longer than a local read, because the upload is sized by
7512
- * the branch rather than by the round trip. */
7513
5696
  const GIT_WRITE_TIMEOUT_MS = 3e5;
7514
- /**
7515
- * The repo's default branch: a non-empty `override` wins, then `originHead` (the output of
7516
- * `git symbolic-ref --short refs/remotes/origin/HEAD`) without its `origin/` prefix, then `main`.
7517
- *
7518
- * Only the leading `origin/` is stripped: a branch name can itself contain a slash (a release
7519
- * train like `release/2.x`), so taking everything after the last slash would truncate it.
7520
- */
7521
5697
  function pickDefaultBranch(override, originHead) {
7522
5698
  if (override !== void 0 && override !== "") return override;
7523
5699
  if (originHead !== void 0) {
@@ -7526,7 +5702,6 @@ function pickDefaultBranch(override, originHead) {
7526
5702
  }
7527
5703
  return "main";
7528
5704
  }
7529
- /** `origin/HEAD`'s branch name (`origin/<name>`), or undefined when git cannot say. */
7530
5705
  async function resolveOriginHead(git, cwd, signal = null) {
7531
5706
  const result = await git.run([
7532
5707
  "symbolic-ref",
@@ -7541,7 +5716,6 @@ async function resolveOriginHead(git, cwd, signal = null) {
7541
5716
  const out = result.stdout.trim();
7542
5717
  return out === "" ? void 0 : out;
7543
5718
  }
7544
- /** The checked-out branch. Throws when there is none: not a repository, or a detached HEAD. */
7545
5719
  async function currentBranch(git, cwd, signal = null) {
7546
5720
  const result = await git.run([
7547
5721
  "rev-parse",
@@ -7557,11 +5731,6 @@ async function currentBranch(git, cwd, signal = null) {
7557
5731
  if (branch === "HEAD") throw new FatalError("HEAD is detached. Check out a feature branch first.");
7558
5732
  return branch;
7559
5733
  }
7560
- /**
7561
- * Prefer `origin/<base>` as the merge-base ref: the local branch may be stale
7562
- * or absent in a worktree. Fall back to the local ref when the remote-tracking
7563
- * ref does not exist.
7564
- */
7565
5734
  async function baseRef(git, cwd, base, signal = null) {
7566
5735
  const remote = `origin/${base}`;
7567
5736
  const result = await git.run([
@@ -7576,7 +5745,6 @@ async function baseRef(git, cwd, base, signal = null) {
7576
5745
  });
7577
5746
  return result.exitCode === 0 && result.stdout.trim() !== "" ? remote : base;
7578
5747
  }
7579
- /** The subjects of the commits on `ref..HEAD`, newest first, as `git log` prints them. */
7580
5748
  async function commitSubjects(git, cwd, ref, signal = null) {
7581
5749
  return (await git.run([
7582
5750
  "log",
@@ -7588,18 +5756,6 @@ async function commitSubjects(git, cwd, ref, signal = null) {
7588
5756
  signal
7589
5757
  })).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
7590
5758
  }
7591
- /**
7592
- * Full commit messages, subject and body, one string per commit (`-z` NUL-separates them --
7593
- * a message can contain anything else, including blank lines). Per-commit granularity
7594
- * matters: concatenated, a message ending in "...the hot fix" and the next one starting
7595
- * "#12 rework..." would read as a closing reference no single author ever wrote.
7596
- *
7597
- * A separate `%B` read rather than widening the `%s` one: subjects drive the title and the
7598
- * bullet list, where a multi-line body would be noise. This exists only so closing keywords
7599
- * survive -- they conventionally sit in the commit footer, which `%s` discards. And it must
7600
- * fail loudly: swallowing a failed read here degrades to exactly the silent keyword-dropping
7601
- * bug this path exists to fix.
7602
- */
7603
5759
  async function commitMessages(git, cwd, ref, signal = null) {
7604
5760
  const result = await git.run([
7605
5761
  "log",
@@ -7614,7 +5770,6 @@ async function commitMessages(git, cwd, ref, signal = null) {
7614
5770
  if (result.exitCode !== 0) throw new FatalError(`Failed to read commit messages: ${result.stderr || result.stdout}`);
7615
5771
  return result.stdout.split("\0").map((message) => message.trim()).filter((message) => message !== "");
7616
5772
  }
7617
- /** Whether `git status --porcelain` lists anything. */
7618
5773
  async function hasUncommittedChanges(git, cwd, signal = null) {
7619
5774
  return (await git.run(["status", "--porcelain"], {
7620
5775
  cwd,
@@ -7622,19 +5777,6 @@ async function hasUncommittedChanges(git, cwd, signal = null) {
7622
5777
  signal
7623
5778
  })).stdout.trim() !== "";
7624
5779
  }
7625
- /**
7626
- * Push `branch` to `origin` and set it as the upstream. `env` is extra environment for the push
7627
- * alone -- how a caller whose clone carries no credentials supplies them without putting a token
7628
- * in argv or on disk.
7629
- *
7630
- * `forceWithLease` pushes with git's bare `--force-with-lease`, never a bare force: a caller that
7631
- * rebased the branch onto the default branch rewrote history the remote already has, so a plain
7632
- * push is rejected non-fast-forward, and the lease still refuses to clobber a remote that changed
7633
- * since the clone. A caller that needs an explicit `<branch>:<sha>` lease -- because something
7634
- * else may have moved the clone's own remote-tracking ref -- pushes itself instead.
7635
- *
7636
- * The push is not wired to an abort signal.
7637
- */
7638
5780
  async function pushBranch(git, cwd, branch, env, forceWithLease = false) {
7639
5781
  const pushArgs = forceWithLease ? [
7640
5782
  "push",
@@ -7662,7 +5804,6 @@ async function pushBranch(git, cwd, branch, env, forceWithLease = false) {
7662
5804
  }
7663
5805
  //#endregion
7664
5806
  //#region ../forgejo/src/pr-upsert.ts
7665
- /** Conventional-commit types recognized as a branch-name prefix. */
7666
5807
  const COMMIT_TYPES = /* @__PURE__ */ new Set([
7667
5808
  "feat",
7668
5809
  "fix",
@@ -7676,16 +5817,9 @@ const COMMIT_TYPES = /* @__PURE__ */ new Set([
7676
5817
  "style",
7677
5818
  "revert"
7678
5819
  ]);
7679
- /** Convert a segment to words by replacing dashes and underscores with spaces. */
7680
5820
  function words(segment) {
7681
5821
  return segment.replaceAll("-", " ").replaceAll("_", " ");
7682
5822
  }
7683
- /**
7684
- * Turn a branch name into a readable title.
7685
- *
7686
- * `feat/pr-helper` -> `feat: pr helper` when the first segment is a
7687
- * conventional-commit type; otherwise every separator becomes a space.
7688
- */
7689
5823
  function humanizeBranch(branch) {
7690
5824
  const slash = branch.indexOf("/");
7691
5825
  if (slash === -1) return words(branch);
@@ -7694,33 +5828,9 @@ function humanizeBranch(branch) {
7694
5828
  if (COMMIT_TYPES.has(type)) return `${type}: ${words(rest)}`;
7695
5829
  return `${words(type)} ${words(rest.replaceAll("/", " "))}`;
7696
5830
  }
7697
- /**
7698
- * The `WIP: ` prepend a Forgejo draft signals with, applied only when `draft` is set. Its inverse,
7699
- * `stripWipPrefix`, lives in `pr-title.ts` beside the un-draft that consumes it; the shared
7700
- * `hasWipPrefix` guard here is what keeps the two from drifting -- one definition of what counts as
7701
- * a WIP prefix, so a title is never double-prefixed (`WIP: wip: x`) in a way the remover would only
7702
- * half-clear.
7703
- */
7704
5831
  function withWipPrefix(title, draft) {
7705
5832
  return draft && !hasWipPrefix(title) ? `WIP: ${title}` : title;
7706
5833
  }
7707
- /**
7708
- * Resolve the PR title and body: explicit values win, otherwise derive from the
7709
- * branch's commits. A lone commit lends its subject; several commits mean the
7710
- * branch name is the better summary and the subjects become the body.
7711
- *
7712
- * Closing keywords found in the commits are appended to the derived body as a footer,
7713
- * unless the bullets already close that issue (a keyword in a subject carries into its
7714
- * bullet -- appending it again would render `Closes #<n>` twice). A keyword reliably
7715
- * closes an issue only from the **PR body**; in a commit message it is branch-gated on
7716
- * Forgejo (see docs/forge-issue-pr-linking.md), so the derived body is where it must land.
7717
- *
7718
- * `droppedRefs` reports the refs the commits make that `body` does not close. It is the
7719
- * caller's to surface -- losing a link silently is the failure this module exists to
7720
- * prevent. A derived body closes every harvested ref by construction, so drops can only
7721
- * come from an explicit body; computing them here, from the same extraction that built
7722
- * the footer, keeps the report structurally unable to disagree with the body.
7723
- */
7724
5834
  function derivePrContent(input) {
7725
5835
  const [only] = input.subjects;
7726
5836
  const derivedTitle = input.subjects.length === 1 && only !== void 0 ? only : humanizeBranch(input.branch);
@@ -7739,14 +5849,9 @@ function derivePrContent(input) {
7739
5849
  droppedRefs: []
7740
5850
  };
7741
5851
  }
7742
- /**
7743
- * Why {@link assertModeAllows} refused. The message names no command; a caller that wants to point
7744
- * the user at the other verb reads `reason`, `branch` and `existing` and words its own refusal.
7745
- */
7746
5852
  var PrModeError = class extends FatalError {
7747
5853
  reason;
7748
5854
  branch;
7749
- /** The open PR's number, or null when there is none. */
7750
5855
  existing;
7751
5856
  constructor(reason, branch, existing) {
7752
5857
  super(reason === "already-open" ? `${branch} already has an open PR (#${String(existing)}).` : `${branch} has no open PR to update.`);
@@ -7755,61 +5860,13 @@ var PrModeError = class extends FatalError {
7755
5860
  this.existing = existing;
7756
5861
  }
7757
5862
  };
7758
- /**
7759
- * Refuse a run whose mode does not match what the forge actually holds. A refusal is nearly always
7760
- * a case of reaching for the habitual verb rather than of genuine confusion about the PR's state,
7761
- * so a caller words it as a pointer at the other verb.
7762
- */
7763
5863
  function assertModeAllows(mode, existing, branch) {
7764
5864
  if (mode === "create" && existing !== null) throw new PrModeError("already-open", branch, existing);
7765
5865
  if (mode === "update" && existing === null) throw new PrModeError("none-open", branch, null);
7766
5866
  }
7767
- /**
7768
- * Whether planning an update needs the PR's live title and body. It is skipped only when neither of
7769
- * two reasons applies:
7770
- *
7771
- * - A field was not supplied. The result's title and body feed the dry-run body print, whose entire
7772
- * job is to be the last chance to spot a missing closing keyword; echoing content that is never
7773
- * sent would make that print a lie in exactly the place it is relied on, and it is also what the
7774
- * dropped-ref check has to be measured against.
7775
- * - The caller expressed no draft intent (`draft === undefined`) and so must not change the PR's
7776
- * draft state as a side effect of retitling it. On Forgejo the title IS the draft flag, so a
7777
- * title that drops the marker silently PUBLISHES a draft PR, and the live title is the only place
7778
- * the marker can be read from.
7779
- *
7780
- * A caller that supplies `managedBodyMarkers` always needs it: deciding ownership needs the live
7781
- * body, and there is no cheaper source.
7782
- */
7783
5867
  function prUpdateNeedsLive(request) {
7784
5868
  return request.title === void 0 || request.body === void 0 || request.draft === void 0 || request.managedBodyMarkers !== void 0;
7785
5869
  }
7786
- /**
7787
- * Resolve what an update to an ALREADY-OPEN PR should send: only the fields the caller explicitly
7788
- * supplied, never the commit-derived defaults. `live` is the PR's current content, read when
7789
- * {@link prUpdateNeedsLive} says so and null otherwise; `wipTitles` says whether this forge marks a
7790
- * draft with a title prefix.
7791
- *
7792
- * Derivation is a *create-time* convenience -- a first PR needs some title and body, and the
7793
- * branch's commits are the only material there is. On an update it is a clobber: it replaces a
7794
- * title and body somebody *authored* -- a run's summary, a person's plan seed -- with
7795
- * `humanizeBranch(branch)` and a bullet list.
7796
- *
7797
- * The {@link PrUpsertMode} split is the first half of the guard (a habitual create re-run now
7798
- * refuses instead of rewriting); this is the second, and it is the half that matters, because an
7799
- * update inherits the same habit and would land in exactly the same place. The two are
7800
- * independent: neither alone is sufficient.
7801
- *
7802
- * This is the invariant the rest of the codebase already states from both sides: the un-draft PATCH
7803
- * sends a title only, "never the body, which is the human-authored plan seed". This was the one
7804
- * path that violated it.
7805
- *
7806
- * A caller that supplies `managedBodyMarkers` is routed to
7807
- * {@link planManagedUpdate} instead of the derive-and-patch logic below: ownership is decided from
7808
- * the live body's own prefix, a managed body gets the full patch, and an unmarked (adopted) one is
7809
- * preserved outright, with the WIP marker and the closing reference reconciled rather than the
7810
- * whole body replaced. Its `previousBody` is therefore never null, so {@link checkClosingLinks}
7811
- * runs on that path too -- see there for why the exemption it once had is gone.
7812
- */
7813
5870
  function planPrUpdate(request, derived, live, wipTitles) {
7814
5871
  if (request.managedBodyMarkers !== void 0 && live !== null) return planManagedUpdate(request, derived, live, wipTitles);
7815
5872
  const retitle = request.title === void 0 ? void 0 : derived.title;
@@ -7826,27 +5883,6 @@ function planPrUpdate(request, derived, live, wipTitles) {
7826
5883
  previousBody: live?.body ?? null
7827
5884
  };
7828
5885
  }
7829
- /**
7830
- * Resolve an update whose caller supplied `managedBodyMarkers`. Ownership is decided from the live
7831
- * body: it starts with one of the markers (a **prefix** match, because quoting is not ownership) or
7832
- * it does not.
7833
- *
7834
- * **Managed row**: the derived title and body, plus any closing reference the live body carries
7835
- * that the derived one does not, so a human's `Fixes #<m>` added since the body was last written
7836
- * by the caller survives the replacement. A field the caller did not supply is not on this row at all: it
7837
- * falls to the preserve treatment below, mirroring {@link planPrUpdate}'s own `retitle` guard. That
7838
- * guard IS the fix for the PRs whose descriptions a derived title once overwrote, and without the
7839
- * same one here a marker-supplying caller that omitted `title` would PATCH `humanizeBranch(branch)`
7840
- * over a managed PR -- unreachable today, since the only such caller supplies both, which is why it
7841
- * has to be stated rather than observed.
7842
- *
7843
- * **Preserve row**: the live title and body, with exactly two mechanical edits -- the WIP draft
7844
- * marker reconciled to the draft intent ({@link reconcileWipTitle}), and any closing reference the
7845
- * derived body carries that the live one does not appended to it. No other prose moves.
7846
- *
7847
- * Either row omits a field from the patch when its computed value equals the live one, so a PR that
7848
- * already carries the right marker and the right closing line receives no PATCH at all.
7849
- */
7850
5886
  function planManagedUpdate(request, derived, live, wipTitles) {
7851
5887
  const managed = (request.managedBodyMarkers ?? []).some((marker) => live.body.startsWith(marker));
7852
5888
  const rewriteTitle = managed && request.title !== void 0;
@@ -7868,17 +5904,6 @@ function planManagedUpdate(request, derived, live, wipTitles) {
7868
5904
  previousBody: live.body
7869
5905
  };
7870
5906
  }
7871
- /**
7872
- * Reconcile the WIP draft marker on a title being preserved, not derived -- forges that mark a
7873
- * draft with a title prefix only (`wipTitles`). Elsewhere draft is a flag the host adapter already
7874
- * handles, and this must not duplicate that, so the title comes back untouched.
7875
- *
7876
- * `draft === true` and `liveTitle` carries no recognized marker prepends `WIP: `; `draft === false`
7877
- * and it does strips it ({@link stripWipPrefix}, the same definition the adder is built against);
7878
- * `draft === undefined` leaves the title alone. A strip that would leave an empty title returns
7879
- * `liveTitle` unchanged and warns instead of PATCHing a blank one -- the same refusal the un-draft
7880
- * makes, one severity down, because this is a finalize and not a merge gate.
7881
- */
7882
5907
  function reconcileWipTitle(wipTitles, draft, liveTitle) {
7883
5908
  if (!wipTitles || draft === void 0) return liveTitle;
7884
5909
  if (draft) return hasWipPrefix(liveTitle) ? liveTitle : `WIP: ${liveTitle}`;
@@ -7889,26 +5914,9 @@ function reconcileWipTitle(wipTitles, draft, liveTitle) {
7889
5914
  }
7890
5915
  return stripped;
7891
5916
  }
7892
- /**
7893
- * Carry a WIP marker from `live` onto `next` when `next` has none of its own -- so retitling a
7894
- * draft PR leaves it a draft. Normalizes to `WIP: ` rather than reproducing the exact marker
7895
- * (`[WIP]`, `wip:`) it found: {@link hasWipPrefix} accepts every spelling Forgejo does, so the one
7896
- * written back only has to be one of them, and `WIP: ` is the spelling the rest of this module
7897
- * emits. A no-op when the live title is not a draft, or when the caller supplied its own marker.
7898
- *
7899
- * Title-prefix forges only -- callers must gate on `wipTitles` themselves, the same reason
7900
- * {@link reconcileWipTitle} returns `liveTitle` untouched elsewhere: where draft is a flag,
7901
- * preserving a prefix here would silently undo an explicit retitle.
7902
- */
7903
5917
  function keepWipPrefix(live, next) {
7904
5918
  return hasWipPrefix(live) && !hasWipPrefix(next) ? `WIP: ${next}` : next;
7905
5919
  }
7906
- /**
7907
- * Thrown by {@link checkClosingLinks} when an update would remove closing references the PR body
7908
- * carried and the caller did not allow it. `lost` holds them in body order; `fromCommits` is check
7909
- * 1's finding from the same call, which the thrown error would otherwise hide from a caller that
7910
- * warns about it. The message names no flag; the caller words the refusal with the way to allow it.
7911
- */
7912
5920
  var LostClosingRefsError = class extends FatalError {
7913
5921
  lost;
7914
5922
  fromCommits;
@@ -7918,44 +5926,6 @@ var LostClosingRefsError = class extends FatalError {
7918
5926
  this.fromCommits = fromCommits;
7919
5927
  }
7920
5928
  };
7921
- /**
7922
- * Guard the closing keywords an invocation would lose. Two checks, because a `Closes #<n>` can
7923
- * reach a PR from two places -- and, deliberately, **two different severities**.
7924
- *
7925
- * 1. **The commits close an issue the resulting body does not** -- reported in
7926
- * {@link ClosingLinkReport.fromCommits}, for the caller to warn about. This one is genuinely
7927
- * *may*: a commit-carried keyword can still fire once the merge lands the commits on the default
7928
- * branch (branch-gated on Forgejo; see docs/forge-issue-pr-linking.md). Refusing would reject
7929
- * invocations whose link is fine. It only ever fires for an explicit body, since a derived body
7930
- * closes every harvested ref by construction.
7931
- *
7932
- * 2. **The body being REPLACED closed an issue the replacement does not** -- a
7933
- * {@link LostClosingRefsError}, unless `allowUnlink`. Nothing is uncertain here: the old body
7934
- * closed it, the new body does not, and if the commits closed it too then check 1's own
7935
- * extraction would have found it and this list would be empty. The link is simply gone.
7936
- *
7937
- * Check 2 is the one that would have caught the real incident, and a warning is not enough for it
7938
- * -- the entire lesson of that incident is that nothing *reported* the loss and it merged anyway.
7939
- * Two real pull requests lost their `Closes` lines out of a machine-written body; across their 49
7940
- * commits not one carried a closing keyword, so check 1 had nothing to compare against and stayed
7941
- * silent. Both merged, both issues stayed open. A second unread warning would not have changed
7942
- * that outcome; refusing would have.
7943
- *
7944
- * Unlinking is still a legitimate thing to want -- a PR that no longer closes the issue it was
7945
- * split from -- so `allowUnlink` is an opt-in, not a prohibition: the deliberate caller says so,
7946
- * the accidental one cannot do it at all. An allowed unlink comes back as
7947
- * {@link ClosingLinkReport.unlinked}.
7948
- *
7949
- * Check 2 needs the previous body, so it is skipped when `previousBody` is null -- a create, where
7950
- * there is no previous body. Every update reads live when it supplies `managedBodyMarkers` (see
7951
- * {@link planPrUpdate}), so that path has a `previousBody` and cannot fire check 2 by
7952
- * construction rather than by exemption: both the managed row and the preserve row carry forward
7953
- * every closing reference the previous body made that the new one drops (`carryForwardClosingRefs`),
7954
- * so the lost list is empty there before this function ever runs -- a pinned invariant, not an
7955
- * unread body being trusted to already close what it closed. It cannot fire on an update that
7956
- * leaves the body alone either: `effective.body` is then the previous body, and a body always
7957
- * closes whatever it closes.
7958
- */
7959
5929
  function checkClosingLinks(messages, plan, allowUnlink) {
7960
5930
  const fromCommits = droppedClosingRefs(messages, plan.effective.body);
7961
5931
  if (plan.previousBody === null) return {
@@ -7970,16 +5940,6 @@ function checkClosingLinks(messages, plan, allowUnlink) {
7970
5940
  };
7971
5941
  }
7972
5942
  const ALWAYS_PROTECTED = /* @__PURE__ */ new Set(["main", "master"]);
7973
- /**
7974
- * Push the branch and create or update its pull request, as {@link PrUpsertMode} permits.
7975
- *
7976
- * One function for both create and update because everything up to the existing-PR lookup --
7977
- * protected-branch guard, base resolution, commit reads, content derivation -- is identical, and
7978
- * the two modes differ only in which side of that lookup they accept.
7979
- *
7980
- * A mode refusal is thrown as {@link PrModeError} and a refused unlink as
7981
- * {@link LostClosingRefsError}; the caller words both.
7982
- */
7983
5943
  async function runPrUpsert(git, host, options) {
7984
5944
  const { cwd } = options;
7985
5945
  const mode = options.mode ?? "upsert";
@@ -8053,12 +6013,6 @@ async function runPrUpsert(git, host, options) {
8053
6013
  }
8054
6014
  //#endregion
8055
6015
  //#region ../forgejo/src/pr-upsert-messages.ts
8056
- /**
8057
- * The refusal for a {@link PrModeError}. Both messages name the other command, because being sent
8058
- * here is nearly always a case of reaching for the habitual verb rather than of genuine confusion
8059
- * about the PR's state. The parenthesis names the create command by its last word alone -- the
8060
- * verb, without the program's name.
8061
- */
8062
6016
  function describePrModeError(error, commands) {
8063
6017
  if (error.reason === "already-open") {
8064
6018
  const createVerb = commands.create.split(" ").at(-1) ?? commands.create;
@@ -8066,11 +6020,6 @@ function describePrModeError(error, commands) {
8066
6020
  }
8067
6021
  return `${error.branch} has no open PR to update -- use \`${commands.create}\` to open one.`;
8068
6022
  }
8069
- /**
8070
- * The warnings for what `checkClosingLinks` found, one line each, in the order they are shown:
8071
- * the commits' refs the body leaves out, then an allowed unlink. Empty when there is nothing to
8072
- * say.
8073
- */
8074
6023
  function describeClosingLinks(report, flags) {
8075
6024
  const lines = [];
8076
6025
  if (report.fromCommits.length > 0) {
@@ -8083,19 +6032,9 @@ function describeClosingLinks(report, flags) {
8083
6032
  }
8084
6033
  return lines;
8085
6034
  }
8086
- /**
8087
- * The refusal for a {@link LostClosingRefsError}. A caller shows the error's `fromCommits` first,
8088
- * through {@link describeClosingLinks}, since the thrown error would otherwise hide that finding.
8089
- */
8090
6035
  function describeLostClosingRefs(error, flags) {
8091
6036
  return `Refusing to update: this would remove ${error.lost.map((dropped) => dropped.text).join(", ")} from the PR body, and the new body does not close it -- the issue would stay open when the PR merges, and nothing else reports that. Keep the line in the body you are passing, or pass ${flags.allowUnlink} if the PR really should stop closing it.`;
8092
6037
  }
8093
- /**
8094
- * The error a program throws in place of one `runPrUpsert` threw: a {@link PrModeError} or a
8095
- * {@link LostClosingRefsError} becomes a `FatalError` in the program's own words, and anything else
8096
- * comes back unchanged. For a lost-refs refusal the commits' refs are warned first, through `warn`,
8097
- * because the thrown error would otherwise hide that finding.
8098
- */
8099
6038
  function rewordPrUpsertError(error, options) {
8100
6039
  if (error instanceof PrModeError) return new FatalError(describePrModeError(error, options.commands));
8101
6040
  if (error instanceof LostClosingRefsError) {
@@ -8117,7 +6056,6 @@ function prLabel(result) {
8117
6056
  function isDryRun(result) {
8118
6057
  return result.action === "would-create" || result.action === "would-update";
8119
6058
  }
8120
- /** The summary of a create, which an upsert may have turned into an update. */
8121
6059
  function formatPrCreate(result) {
8122
6060
  const opening = result.action === "created" || result.action === "would-create";
8123
6061
  if (isDryRun(result)) {
@@ -8137,11 +6075,6 @@ function formatPrCreate(result) {
8137
6075
  body: null
8138
6076
  };
8139
6077
  }
8140
- /**
8141
- * The summary of an update. `rewrites` says whether the caller passed a new title or body; without
8142
- * one the PR's text is left as authored, and the summary says so and names the options that would
8143
- * change it.
8144
- */
8145
6078
  function formatPrUpdate(result, rewrites, flags) {
8146
6079
  if (isDryRun(result)) return {
8147
6080
  lines: [`[dry-run] Would push ${prTarget(result)} to ${prLabel(result)}`],
@@ -8160,10 +6093,6 @@ function formatPrUpdate(result, rewrites, flags) {
8160
6093
  }
8161
6094
  //#endregion
8162
6095
  //#region ../forgejo/src/pr-host.ts
8163
- /**
8164
- * The Forgejo {@link PrHost}, over the API calls in `pr.ts`. Its content read, create and update
8165
- * take no abort signal, as those calls never have.
8166
- */
8167
6096
  function forgejoPrHost(fetcher, conn) {
8168
6097
  return {
8169
6098
  wipTitles: true,
@@ -8187,11 +6116,6 @@ function forgejoPrHost(fetcher, conn) {
8187
6116
  }
8188
6117
  };
8189
6118
  }
8190
- /**
8191
- * The Forgejo {@link PrEditHost}, over the API calls in `pr.ts` and `repo.ts`. Its `ready` is a
8192
- * title-only PATCH and never a body write: the body is the author's text, and the draft flag has
8193
- * no PATCH field of its own on this forge.
8194
- */
8195
6119
  function forgejoPrEditHost(fetcher, conn) {
8196
6120
  return {
8197
6121
  wipTitles: true,
@@ -8203,14 +6127,8 @@ function forgejoPrEditHost(fetcher, conn) {
8203
6127
  }
8204
6128
  //#endregion
8205
6129
  //#region src/git.ts
8206
- /** Exit codes reported when this module ends a child itself, following the shell's convention. */
8207
6130
  const TIMED_OUT_EXIT_CODE = 124;
8208
6131
  const ABORTED_EXIT_CODE = 143;
8209
- /**
8210
- * The one place a command runs git. Commands reach git only through this function, so a test
8211
- * mocks this module and no other. A nonzero exit is a result, never a throw: the caller decides
8212
- * what a failed `push` or a missing ref means. `env` adds to the inherited environment.
8213
- */
8214
6132
  function cliGit() {
8215
6133
  return { run: (args, { cwd, env, timeoutMs, signal }) => new Promise((resolve, reject) => {
8216
6134
  if (signal?.aborted === true) {
@@ -8297,11 +6215,6 @@ const FLAGS = {
8297
6215
  function warnClosingLinks(report) {
8298
6216
  for (const line of describeClosingLinks(report, FLAGS)) rootLogger.warn(line);
8299
6217
  }
8300
- /**
8301
- * The part of `pr:create` and `pr:update` that is the same: refuse a GitHub remote, run the shared
8302
- * upsert over this CLI's git and fetch seams, and reword its refusals and closing-link findings
8303
- * with `bst-fj`'s own command and flag names.
8304
- */
8305
6218
  async function runForgejoPr(githubRefusal, options) {
8306
6219
  const cwd = process.cwd();
8307
6220
  const conn = requireForgejoTarget(cwd, void 0, githubRefusal);
@@ -8396,22 +6309,13 @@ const prCreateCommand = defineCommand({
8396
6309
  });
8397
6310
  //#endregion
8398
6311
  //#region ../forgejo/src/pr-files.ts
8399
- /**
8400
- * A pull request's changed-files listing. Its own module, because `pr.ts` is at its fan-in
8401
- * ceiling and this read is about a diff, not a pull request's state.
8402
- */
8403
6312
  const DEFAULT_LIMIT = 100;
8404
6313
  const PrFileEntrySchema = z.object({
8405
6314
  filename: z.string(),
8406
- /** Forgejo's swagger does not enumerate this, so it stays an open string. */
8407
6315
  status: z.string().nullish(),
8408
6316
  additions: z.number().default(0),
8409
6317
  deletions: z.number().default(0)
8410
6318
  });
8411
- /**
8412
- * One page of the files a pull request changed, never a walk: a caller on a page load must not
8413
- * turn a navigation into an unbounded forge loop, and `truncated` says when more exist.
8414
- */
8415
6319
  async function listPrFiles(executor, conn, prNumber, options) {
8416
6320
  return readPrFilesPage(executor, conn, prNumber, 1, options?.limit ?? DEFAULT_LIMIT);
8417
6321
  }
@@ -8436,10 +6340,6 @@ async function readPrFilesPage(executor, conn, prNumber, page, limit) {
8436
6340
  }
8437
6341
  //#endregion
8438
6342
  //#region ../forgejo/src/pr-number.ts
8439
- /**
8440
- * The one parser for a pull request number given as text, shared by every bin's positional so a
8441
- * malformed number refuses the same way whether a person or a script typed it.
8442
- */
8443
6343
  function parsePrNumber(raw) {
8444
6344
  const value = Number(raw);
8445
6345
  if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) throw new FatalError(`Invalid pull request number "${raw}" (expected a positive integer).`);
@@ -8447,16 +6347,10 @@ function parsePrNumber(raw) {
8447
6347
  }
8448
6348
  //#endregion
8449
6349
  //#region src/pr-files.ts
8450
- /**
8451
- * `bst-fj pr:files`: the files a pull request changes, over `@bensandee/forgejo/pr-files`.
8452
- * Forgejo-only; `gh pr diff --name-only` already serves GitHub.
8453
- */
8454
- /** List the files a pull request changes. */
8455
6350
  function runPrFiles(fetcher, options) {
8456
6351
  const number = parsePrNumber(options.number);
8457
6352
  return listPrFiles(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:files is Forgejo-only -- gh pr diff --name-only already serves GitHub."), number);
8458
6353
  }
8459
- /** Human-readable rendering: `status filename` per file, then a note when more files exist. */
8460
6354
  function renderPrFiles(listing) {
8461
6355
  const lines = listing.files.map((file) => `${file.status ?? "unknown"} ${file.filename}`);
8462
6356
  if (lines.length === 0) lines.push("No changed files.");
@@ -8505,12 +6399,6 @@ const prFilesCommand = defineCommand({
8505
6399
  });
8506
6400
  //#endregion
8507
6401
  //#region src/pr-list.ts
8508
- /**
8509
- * `bst-fj pr:list` -- a read-only, Forgejo-only pull request listing over the shared `listPulls`
8510
- * pager (`@bensandee/forgejo/pr`), so a duplicate-work check needs no raw API call. GitHub-only
8511
- * PR listing already has a first-class tool (`gh pr list`), the same precedent `pr:checks`
8512
- * follows for the same structural reason.
8513
- */
8514
6402
  function parseState(state) {
8515
6403
  if (state === "open" || state === "closed" || state === "all") return state;
8516
6404
  throw new FatalError(`Invalid --state "${state}" (expected open, closed, or all).`);
@@ -8520,10 +6408,6 @@ function parseLimit(raw) {
8520
6408
  if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
8521
6409
  return limit;
8522
6410
  }
8523
- /**
8524
- * List a repository's pull requests, most recently updated first, sliced to `--limit`. Refuses a
8525
- * github.com target before any fetch -- `gh pr list` already serves GitHub, first-class.
8526
- */
8527
6411
  async function runPrList(fetcher, options) {
8528
6412
  const state = parseState(options.state);
8529
6413
  const limit = parseLimit(options.limit);
@@ -8547,7 +6431,6 @@ function statusLabel(pr) {
8547
6431
  if (pr.draft === true) return `${pr.state}/draft`;
8548
6432
  return pr.state;
8549
6433
  }
8550
- /** Human-readable rendering: one line per PR, most recently updated first. */
8551
6434
  function renderPrList(result) {
8552
6435
  if (result.pulls.length === 0) return "No pull requests.";
8553
6436
  return result.pulls.map((pr) => {
@@ -8603,16 +6486,6 @@ const prListCommand = defineCommand({
8603
6486
  });
8604
6487
  //#endregion
8605
6488
  //#region ../forgejo/src/pr-merge.ts
8606
- /**
8607
- * The one irreversible write a merge makes, and the two live reads that arm it. Split
8608
- * out of `pr.ts` on cohesion rather than fan-in: a merge, the check that the PR is actually
8609
- * mergeable, and the check that its head SHA is actually green are one concern, and one home for
8610
- * them beats a caller reaching two files for a decision it must make atomically.
8611
- *
8612
- * `readMergeGate`'s live `open`/`draft`/`mergeable` read exists so a merge re-reads the forge
8613
- * rather than trusting a label or a cached view it is about to act on.
8614
- */
8615
- /** Merge a pull request by number. */
8616
6489
  async function mergePr(executor, conn, prNumber, options) {
8617
6490
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
8618
6491
  const body = {
@@ -8644,12 +6517,6 @@ const MergeGateSchema = z.object({
8644
6517
  labels: z.array(z.object({ name: z.string() })).nullable(),
8645
6518
  updated_at: z.string().nullish()
8646
6519
  });
8647
- /**
8648
- * Read a PR's live open/draft/mergeable state and current head SHA in one `/pulls/<n>` GET --
8649
- * the gate's label-independent half. It confirms nothing has changed since an earlier judgment
8650
- * that the PR was ready, including an ordinary push to the branch, which moves the head SHA the
8651
- * status read below has to be scoped to.
8652
- */
8653
6520
  async function readMergeGate(executor, conn, prNumber) {
8654
6521
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
8655
6522
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
@@ -8671,15 +6538,6 @@ async function readMergeGate(executor, conn, prNumber) {
8671
6538
  updatedAt: parsed.data.updated_at ?? null
8672
6539
  };
8673
6540
  }
8674
- /**
8675
- * Read a PR's merge gate and, when mergeability is not yet confirmed, read it once more.
8676
- *
8677
- * Forgejo reports `mergeable: false` for a real conflict and for its own check still running, so a
8678
- * lone `false` (or a null, which {@link readMergeGate} folds to `false`) proves nothing. A first
8679
- * `true` is final. Otherwise the function waits `delayMs` and reads again: a second `false` at the
8680
- * same head and merge base is `conflicted`; a moved head or merge base means the two reads saw two
8681
- * different trees, so the result is `unconfirmed`.
8682
- */
8683
6541
  async function confirmMergeable(executor, conn, prNumber, options) {
8684
6542
  const firstGate = await readMergeGate(executor, conn, prNumber);
8685
6543
  if (firstGate.mergeable) return {
@@ -8731,45 +6589,52 @@ z.object({
8731
6589
  })
8732
6590
  });
8733
6591
  //#endregion
8734
- //#region src/pr-merge.ts
8735
- /**
8736
- * `bst-fj pr:merge`: merge a pull request, but only one a person would not want to look at first.
8737
- * Every refusal happens before the merge request, and the merge is pinned to the head SHA the
8738
- * confirming read saw, so a push that lands between the read and the merge makes the forge
8739
- * refuse it rather than merge a tree nobody checked. Forgejo's branch protection still applies.
8740
- */
8741
- const METHODS = [
6592
+ //#region ../forgejo/src/pr-merge-run.ts
6593
+ const MERGE_METHODS = [
8742
6594
  "merge",
8743
6595
  "rebase",
8744
6596
  "squash"
8745
6597
  ];
8746
- /** Long enough for Forgejo to finish its own mergeability check after a first `false`. */
8747
6598
  const RECHECK_DELAY_MS = 2e3;
8748
- function parseMethod(raw) {
8749
- if (raw === void 0) return "merge";
8750
- const method = METHODS.find((candidate) => candidate === raw);
8751
- if (method === void 0) throw new FatalError(`Invalid --method "${raw}" (expected one of: ${METHODS.join(", ")}).`);
8752
- return method;
6599
+ const MIN_SHA_PREFIX = 7;
6600
+ function sleepDefault(ms) {
6601
+ return new Promise((resolve) => {
6602
+ setTimeout(resolve, ms);
6603
+ });
8753
6604
  }
8754
- /** Confirm, check, and (unless `dryRun`) merge. Throws a `FatalError` naming the reason for every
8755
- * refusal, before any merge request. */
8756
- async function runPrMerge(fetcher, options) {
8757
- const number = parsePrNumber(options.number);
8758
- const method = parseMethod(options.method);
8759
- const conn = requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:merge is Forgejo-only -- use gh pr merge on GitHub.");
6605
+ function headMatches(expected, actual) {
6606
+ const want = expected.trim().toLowerCase();
6607
+ return want.length >= MIN_SHA_PREFIX && actual.toLowerCase().startsWith(want);
6608
+ }
6609
+ async function runPrMergeCore(fetcher, conn, number, options = {}) {
8760
6610
  const pr = `PR #${String(number)}`;
6611
+ const method = options.method ?? "merge";
6612
+ const expected = options.expectedHeadSha;
6613
+ const moved = (headSha) => expected !== void 0 && !headMatches(expected, headSha);
8761
6614
  const confirmation = await confirmMergeable(fetcher, conn, number, {
8762
6615
  delayMs: RECHECK_DELAY_MS,
8763
- sleep: options.sleep ?? sleep,
8764
- settleOnFirstRead: (gate) => !gate.open || gate.draft
6616
+ sleep: options.sleep ?? sleepDefault,
6617
+ settleOnFirstRead: (gate) => !gate.open || gate.draft || moved(gate.headSha)
8765
6618
  });
8766
6619
  const { gate, verdict } = confirmation;
8767
- if (!gate.open) throw new FatalError(`Refusing to merge ${pr}: it is closed.`);
8768
- if (gate.draft) throw new FatalError(`Refusing to merge ${pr}: it is a draft.`);
8769
- if (verdict === "conflicted") throw new FatalError(`Refusing to merge ${pr}: it has conflicts. ${confirmation.evidence ?? ""}`.trimEnd());
8770
- if (verdict === "unconfirmed") throw new FatalError(`Refusing to merge ${pr}: the forge could not confirm it is mergeable. ${confirmation.evidence ?? ""}`.trimEnd());
8771
- const checks = classifyBaseStatus(await readCommitChecks(fetcher, conn, gate.headSha));
8772
- if (checks !== "green" && !options.ignoreChecks) throw new FatalError(`Refusing to merge ${pr}: its checks at ${gate.headSha} are ${checks}, not green. Pass --ignore-checks to merge anyway.`);
6620
+ const refuse = (why) => ({
6621
+ kind: "refused",
6622
+ reason: `Refusing to merge ${pr}: ${why}`
6623
+ });
6624
+ if (!gate.open) return refuse("it is closed.");
6625
+ if (gate.draft) return refuse("it is a draft.");
6626
+ if (options.refuseEmpty === true && (gate.headSha === gate.mergeBase || gate.changedFiles === 0)) return refuse("its diff against its base is empty, so its branch is left alone.");
6627
+ if (expected !== void 0 && moved(gate.headSha)) return refuse(`its head is ${gate.headSha}, not the approved ${expected}.`);
6628
+ if (verdict === "conflicted") return refuse(`it has conflicts. ${confirmation.evidence ?? ""}`.trimEnd());
6629
+ if (verdict === "unconfirmed") return refuse(`the forge could not confirm it is mergeable. ${confirmation.evidence ?? ""}`.trimEnd());
6630
+ const read = await readCommitChecks(fetcher, conn, gate.headSha);
6631
+ const checks = classifyBaseStatus(read);
6632
+ const skippedOnly = read.parsed && read.contexts.length > 0 && read.contexts.every((context) => context.verdict === "skipped");
6633
+ if (!(checks === "green" || options.ignoreChecks === true || (checks === "no-status" || skippedOnly) && options.allowNoStatus === true)) return {
6634
+ kind: "refused",
6635
+ reason: `Refusing to merge ${pr}: its checks at ${gate.headSha} are ${checks}, not green.`,
6636
+ checks
6637
+ };
8773
6638
  const result = {
8774
6639
  number,
8775
6640
  merged: false,
@@ -8778,18 +6643,47 @@ async function runPrMerge(fetcher, options) {
8778
6643
  verdict,
8779
6644
  checks
8780
6645
  };
8781
- if (options.dryRun) return result;
6646
+ if (options.dryRun === true) return {
6647
+ kind: "ok",
6648
+ result
6649
+ };
8782
6650
  await mergePr(fetcher, conn, number, {
8783
6651
  method,
8784
- deleteBranch: options.deleteBranch,
6652
+ deleteBranch: options.deleteBranch ?? false,
8785
6653
  expectedHeadSha: gate.headSha
8786
6654
  });
8787
6655
  return {
8788
- ...result,
8789
- merged: true
6656
+ kind: "ok",
6657
+ result: {
6658
+ ...result,
6659
+ merged: true
6660
+ }
8790
6661
  };
8791
6662
  }
8792
- /** Human-readable rendering: one line saying what was merged, or what a dry run would merge. */
6663
+ //#endregion
6664
+ //#region src/pr-merge.ts
6665
+ function parseMethod(raw) {
6666
+ if (raw === void 0) return "merge";
6667
+ const method = MERGE_METHODS.find((candidate) => candidate === raw);
6668
+ if (method === void 0) throw new FatalError(`Invalid --method "${raw}" (expected one of: ${MERGE_METHODS.join(", ")}).`);
6669
+ return method;
6670
+ }
6671
+ async function runPrMerge(fetcher, options) {
6672
+ const number = parsePrNumber(options.number);
6673
+ const method = parseMethod(options.method);
6674
+ const outcome = await runPrMergeCore(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:merge is Forgejo-only -- use gh pr merge on GitHub."), number, {
6675
+ method,
6676
+ deleteBranch: options.deleteBranch,
6677
+ ignoreChecks: options.ignoreChecks,
6678
+ dryRun: options.dryRun,
6679
+ sleep: options.sleep ?? sleep
6680
+ });
6681
+ if (outcome.kind === "refused") {
6682
+ const hint = outcome.checks === void 0 ? "" : " Pass --ignore-checks to merge anyway.";
6683
+ throw new FatalError(`${outcome.reason}${hint}`);
6684
+ }
6685
+ return outcome.result;
6686
+ }
8793
6687
  function renderPrMerge(result) {
8794
6688
  const what = `PR #${String(result.number)} (${result.method}) at ${result.headSha.slice(0, 12)}, checks ${result.checks}`;
8795
6689
  return result.merged ? `Merged ${what}.` : `Dry run: would merge ${what}.`;
@@ -8853,19 +6747,7 @@ const prMergeCommand = defineCommand({
8853
6747
  });
8854
6748
  //#endregion
8855
6749
  //#region ../forgejo/src/pr-edit.ts
8856
- /**
8857
- * The two pull-request editing engines, forge-generic: what a base change and a ready write check,
8858
- * send, and confirm. Each one drives a {@link PrEditHost}, so the sequence is written once and only
8859
- * the writes differ per forge -- the refusals and the confirming re-read are the parts a person
8860
- * relies on, and they must not be re-derived on each side of the seam.
8861
- *
8862
- * Engines and renderers share this module because every importer of either is the same command: a
8863
- * renderer kept apart would be a second module for the same four callers.
8864
- */
8865
- /** The wait before the one mergeability re-read after a retarget; both bins pass it. */
8866
6750
  const MERGEABLE_RECHECK_DELAY_MS = 2e3;
8867
- /** What both forges call an editable pull request. One predicate, so the two engines cannot
8868
- * disagree about it and a caller cannot read "open" as its own looser thing. */
8869
6751
  function isOpen(live) {
8870
6752
  return live.state === "open" && live.merged === false;
8871
6753
  }
@@ -8873,16 +6755,6 @@ function notOpenMessage(prNumber, live) {
8873
6755
  const what = live.merged ? "merged" : live.state;
8874
6756
  return `Pull request #${String(prNumber)} is ${what}, so it has no base or draft state left to change. Reopen it first if this work is still live.`;
8875
6757
  }
8876
- /**
8877
- * Point an open pull request at a new base branch, then prove the forge took it.
8878
- *
8879
- * The re-read is the whole point of the command: a forge that does not implement the base field
8880
- * answers the PATCH with 200 and changes nothing, so a report built from the request would claim a
8881
- * move that never happened. `mergeable` rides along as advice only and never decides the outcome --
8882
- * it is tri-state on both forges and a pull request whose base just moved is being recomputed by
8883
- * definition -- so a `false` earns exactly one more read after a wait, and `null` and `true` are
8884
- * taken as final.
8885
- */
8886
6758
  async function runPrRetarget(host, options) {
8887
6759
  const live = await host.readLive(options.number);
8888
6760
  if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
@@ -8927,17 +6799,6 @@ async function runPrRetarget(host, options) {
8927
6799
  dryRun: false
8928
6800
  };
8929
6801
  }
8930
- /**
8931
- * Mark an open pull request ready, dropping its WIP marker or writing the title given.
8932
- *
8933
- * The draft is the forge's flag, never the title prefix: a prefix is how a title reads and a draft
8934
- * is what the forge refuses to merge, and the two disagree on both forges. So the title is the
8935
- * mechanism (it is the only lever Forgejo's PATCH has) and the flag is the question of whether
8936
- * there is anything to do -- which is also why a successful write is re-read rather than trusted.
8937
- * A draft whose title carries no marker this side recognises is refused rather than readied by
8938
- * guessing at one: on that forge only the title can clear the flag, and an invented title is a
8939
- * rewrite of someone's headline.
8940
- */
8941
6802
  async function runPrReady(host, options) {
8942
6803
  const live = await host.readLive(options.number);
8943
6804
  if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
@@ -8995,7 +6856,6 @@ function mergeabilityLine(mergeable) {
8995
6856
  if (mergeable === false) return "Mergeability: not mergeable -- advice, not a verdict.";
8996
6857
  return "Mergeability: unknown -- the forge has not computed it.";
8997
6858
  }
8998
- /** What happened to the base, and the mergeability reading as advice. */
8999
6859
  function renderPrRetarget(result) {
9000
6860
  const number = `#${String(result.number)}`;
9001
6861
  const lines = [];
@@ -9005,7 +6865,6 @@ function renderPrRetarget(result) {
9005
6865
  lines.push(mergeabilityLine(result.mergeable));
9006
6866
  return lines.join("\n");
9007
6867
  }
9008
- /** What happened to the draft state and the title. */
9009
6868
  function renderPrReady(result) {
9010
6869
  const number = `#${String(result.number)}`;
9011
6870
  if (result.action === "readied") return `Pull request ${number} is no longer a draft, reading as "${result.title}".`;