@bensandee/forgejo-cli 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +1 -2212
- 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.
|
|
166
|
+
version: "0.4.3",
|
|
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,14 +2333,6 @@ 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 = {
|
|
@@ -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;
|
|
@@ -3499,17 +2490,12 @@ async function recreateTagViaApi(executor, conn, tag, opts) {
|
|
|
3499
2490
|
}
|
|
3500
2491
|
return null;
|
|
3501
2492
|
}
|
|
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
2493
|
function releaseRef(release) {
|
|
3507
2494
|
return {
|
|
3508
2495
|
id: release.id,
|
|
3509
2496
|
url: release.html_url ?? null
|
|
3510
2497
|
};
|
|
3511
2498
|
}
|
|
3512
|
-
/** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
|
|
3513
2499
|
async function settleBackoff(attempt, baseDelayMs) {
|
|
3514
2500
|
if (baseDelayMs <= 0) return;
|
|
3515
2501
|
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
|
|
@@ -3572,25 +2558,11 @@ const ActionSecretListSchema = z.object({
|
|
|
3572
2558
|
total_count: z.number()
|
|
3573
2559
|
});
|
|
3574
2560
|
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
2561
|
const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3585
2562
|
const FORBIDDEN_PREFIX_PATTERN = /^(FORGEJO_|GITEA_|GITHUB_|[0-9])/i;
|
|
3586
2563
|
function isValidSecretName(name) {
|
|
3587
2564
|
return NAME_PATTERN.test(name) && !FORBIDDEN_PREFIX_PATTERN.test(name);
|
|
3588
2565
|
}
|
|
3589
|
-
/**
|
|
3590
|
-
* Set (or overwrite) a Forgejo Actions secret on a repository.
|
|
3591
|
-
*
|
|
3592
|
-
* Uses PUT which is idempotent -- creates or overwrites.
|
|
3593
|
-
*/
|
|
3594
2566
|
async function setActionSecret(executor, conn, secretName, secretValue) {
|
|
3595
2567
|
const [owner, repo] = conn.repository.split("/");
|
|
3596
2568
|
const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
|
|
@@ -3607,12 +2579,6 @@ async function setActionSecret(executor, conn, secretName, secretValue) {
|
|
|
3607
2579
|
resource: secretName
|
|
3608
2580
|
}));
|
|
3609
2581
|
}
|
|
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
2582
|
async function listActionSecrets(executor, conn) {
|
|
3617
2583
|
const [owner, repo] = conn.repository.split("/");
|
|
3618
2584
|
const names = [];
|
|
@@ -3633,11 +2599,6 @@ async function listActionSecrets(executor, conn) {
|
|
|
3633
2599
|
}
|
|
3634
2600
|
return names;
|
|
3635
2601
|
}
|
|
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
2602
|
async function deleteActionSecret(executor, conn, secretName) {
|
|
3642
2603
|
const [owner, repo] = conn.repository.split("/");
|
|
3643
2604
|
const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
|
|
@@ -3695,7 +2656,6 @@ async function runSecretsDelete(fetcher, config) {
|
|
|
3695
2656
|
}
|
|
3696
2657
|
//#endregion
|
|
3697
2658
|
//#region src/commands/secrets.ts
|
|
3698
|
-
/** Read secret value from --body flag or stdin. */
|
|
3699
2659
|
function readSecretValue(body) {
|
|
3700
2660
|
if (body) return body;
|
|
3701
2661
|
try {
|
|
@@ -3826,15 +2786,10 @@ const secretsCommand = defineCommand({
|
|
|
3826
2786
|
});
|
|
3827
2787
|
//#endregion
|
|
3828
2788
|
//#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
2789
|
const AuthenticatedUserSchema = z.object({
|
|
3831
2790
|
login: z.string(),
|
|
3832
2791
|
email: z.string().nullish()
|
|
3833
2792
|
});
|
|
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
2793
|
async function getAuthenticatedUser(executor, conn, signal = null) {
|
|
3839
2794
|
const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, {
|
|
3840
2795
|
headers: authHeaders$2(conn),
|
|
@@ -3862,11 +2817,6 @@ const WebhookSchema = z.object({
|
|
|
3862
2817
|
config: z.record(z.string(), z.string()).optional()
|
|
3863
2818
|
});
|
|
3864
2819
|
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
2820
|
async function fetchHooks(executor, conn, endpoint) {
|
|
3871
2821
|
const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
|
|
3872
2822
|
if (res.status === 401 || res.status === 403) return {
|
|
@@ -3888,28 +2838,18 @@ async function fetchHooks(executor, conn, endpoint) {
|
|
|
3888
2838
|
hooks: parsed.data
|
|
3889
2839
|
};
|
|
3890
2840
|
}
|
|
3891
|
-
/** List system-level webhooks. Requires admin token (read:admin scope). */
|
|
3892
2841
|
async function listSystemHooks(executor, conn) {
|
|
3893
2842
|
return fetchHooks(executor, conn, "/admin/hooks");
|
|
3894
2843
|
}
|
|
3895
|
-
/** List webhooks owned by the authenticated user. Requires read:user scope. */
|
|
3896
2844
|
async function listUserHooks(executor, conn) {
|
|
3897
2845
|
return fetchHooks(executor, conn, "/user/hooks");
|
|
3898
2846
|
}
|
|
3899
|
-
/** List webhooks for an organization. Requires read:organization scope. */
|
|
3900
2847
|
async function listOrgHooks(executor, conn, org) {
|
|
3901
2848
|
return fetchHooks(executor, conn, `/orgs/${encodeURIComponent(org)}/hooks`);
|
|
3902
2849
|
}
|
|
3903
|
-
/** List webhooks for a repository. Requires read:repository + repo admin. */
|
|
3904
2850
|
async function listRepoHooks(executor, conn, repository) {
|
|
3905
2851
|
return fetchHooks(executor, conn, `/repos/${repository}/hooks`);
|
|
3906
2852
|
}
|
|
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
2853
|
async function hasAdminAccess(executor, conn) {
|
|
3914
2854
|
return (await executor.fetch(`${conn.serverUrl}/api/v1/admin/users?limit=1`, { headers: { Authorization: `token ${conn.token}` } })).ok;
|
|
3915
2855
|
}
|
|
@@ -3934,10 +2874,6 @@ async function paginate(executor, conn, endpoint, verb, extract) {
|
|
|
3934
2874
|
}
|
|
3935
2875
|
return all;
|
|
3936
2876
|
}
|
|
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
2877
|
async function listOrgs(executor, conn, mode) {
|
|
3942
2878
|
const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
|
|
3943
2879
|
return (await paginate(executor, conn, endpoint, "list orgs", (raw) => {
|
|
@@ -3947,7 +2883,6 @@ async function listOrgs(executor, conn, mode) {
|
|
|
3947
2883
|
})).map((o) => o.username);
|
|
3948
2884
|
}
|
|
3949
2885
|
const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
|
|
3950
|
-
/** List repositories visible to the token via `/repos/search`. */
|
|
3951
2886
|
async function searchRepos(executor, conn) {
|
|
3952
2887
|
return (await paginate(executor, conn, "/repos/search", "list repos", (raw) => {
|
|
3953
2888
|
const parsed = RepoSearchSchema.safeParse(raw);
|
|
@@ -3957,7 +2892,6 @@ async function searchRepos(executor, conn) {
|
|
|
3957
2892
|
}
|
|
3958
2893
|
//#endregion
|
|
3959
2894
|
//#region src/commands/webhooks.ts
|
|
3960
|
-
/** Render a WebhookFetchStatus, logging warnings for unauthorized/error cases. */
|
|
3961
2895
|
function collectGroup(label, scopeHint, status) {
|
|
3962
2896
|
if (status.status === "ok") return status.hooks.length > 0 ? {
|
|
3963
2897
|
label,
|
|
@@ -4035,30 +2969,6 @@ const webhooksCommand = defineCommand({
|
|
|
4035
2969
|
});
|
|
4036
2970
|
//#endregion
|
|
4037
2971
|
//#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
2972
|
function forgeTimestamp(value) {
|
|
4063
2973
|
if (value === null || value === void 0 || value === "") return null;
|
|
4064
2974
|
const ms = Date.parse(value);
|
|
@@ -4076,7 +2986,6 @@ const ActionsRunRecordSchema = z.object({
|
|
|
4076
2986
|
status: z.string().nullish(),
|
|
4077
2987
|
started: z.string().nullish(),
|
|
4078
2988
|
stopped: z.string().nullish(),
|
|
4079
|
-
/** Nanoseconds (a Go `time.Duration`); absent until the run finishes. */
|
|
4080
2989
|
duration: z.number().nullish(),
|
|
4081
2990
|
html_url: z.string().nullish()
|
|
4082
2991
|
});
|
|
@@ -4104,19 +3013,7 @@ function toActionsRun(record) {
|
|
|
4104
3013
|
url: record.html_url ?? null
|
|
4105
3014
|
};
|
|
4106
3015
|
}
|
|
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
3016
|
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
3017
|
async function getActionsRunByNumber(executor, conn, runNumber) {
|
|
4121
3018
|
const params = new URLSearchParams({
|
|
4122
3019
|
run_number: runNumber,
|
|
@@ -4137,11 +3034,6 @@ async function getActionsRunByNumber(executor, conn, runNumber) {
|
|
|
4137
3034
|
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
3035
|
return found;
|
|
4139
3036
|
}
|
|
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
3037
|
async function listActionsRunJobs(executor, conn, runId) {
|
|
4146
3038
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/runs/${String(runId)}/jobs`;
|
|
4147
3039
|
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
@@ -4160,11 +3052,6 @@ async function listActionsRunJobs(executor, conn, runId) {
|
|
|
4160
3052
|
});
|
|
4161
3053
|
return jobs;
|
|
4162
3054
|
}
|
|
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
3055
|
async function fetchActionsJobLogById(executor, conn, jobId, bound) {
|
|
4169
3056
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/jobs/${String(jobId)}/logs`;
|
|
4170
3057
|
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
@@ -4188,17 +3075,6 @@ async function fetchActionsJobLogById(executor, conn, jobId, bound) {
|
|
|
4188
3075
|
};
|
|
4189
3076
|
}
|
|
4190
3077
|
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
3078
|
async function listActionsRuns(executor, conn, options) {
|
|
4203
3079
|
const headers = { Authorization: `token ${conn.token}` };
|
|
4204
3080
|
const pageSize = Math.min(options.limit, 50);
|
|
@@ -4231,21 +3107,7 @@ async function listActionsRuns(executor, conn, options) {
|
|
|
4231
3107
|
}
|
|
4232
3108
|
return runs.slice(0, options.limit);
|
|
4233
3109
|
}
|
|
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
3110
|
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
3111
|
function parseActionsJobTargetUrl(targetUrl) {
|
|
4250
3112
|
if (targetUrl === null) return null;
|
|
4251
3113
|
const match = ACTIONS_JOB_TARGET_URL.exec(targetUrl);
|
|
@@ -4255,13 +3117,6 @@ function parseActionsJobTargetUrl(targetUrl) {
|
|
|
4255
3117
|
index: Number(match[2])
|
|
4256
3118
|
};
|
|
4257
3119
|
}
|
|
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
3120
|
function parseActionsJobTargetRepo(targetUrl) {
|
|
4266
3121
|
const boundary = targetUrl.indexOf("/actions/runs/");
|
|
4267
3122
|
if (boundary === -1) return void 0;
|
|
@@ -4269,12 +3124,6 @@ function parseActionsJobTargetRepo(targetUrl) {
|
|
|
4269
3124
|
if (!prefix.startsWith("http://") && !prefix.startsWith("https://")) return void 0;
|
|
4270
3125
|
return parseRepoTarget(prefix);
|
|
4271
3126
|
}
|
|
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
3127
|
async function dispatchWorkflow(executor, conn, options) {
|
|
4279
3128
|
const repository = options.repository ?? conn.repository;
|
|
4280
3129
|
const url = `${conn.serverUrl}/api/v1/repos/${repository}/actions/workflows/${encodeURIComponent(options.workflow)}/dispatches`;
|
|
@@ -4299,26 +3148,14 @@ async function dispatchWorkflow(executor, conn, options) {
|
|
|
4299
3148
|
}
|
|
4300
3149
|
//#endregion
|
|
4301
3150
|
//#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
3151
|
const CSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
|
|
4305
|
-
/** An OSC sequence: `ESC ]` up to a BEL or a string terminator (`ESC \`). */
|
|
4306
3152
|
const OSC_SEQUENCE = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu;
|
|
4307
|
-
/** An escape byte that began neither of those. */
|
|
4308
3153
|
const LONE_ESCAPE = /\u001b/gu;
|
|
4309
|
-
/** The C0 controls that may still be left, tab excepted, plus DEL. */
|
|
4310
3154
|
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
3155
|
const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\S+$/u;
|
|
4313
|
-
/** Remove every control sequence and control character, whole. */
|
|
4314
3156
|
function stripControlSequences(text) {
|
|
4315
3157
|
return text.replace(CSI_SEQUENCE, "").replace(OSC_SEQUENCE, "").replace(LONE_ESCAPE, "").replace(CONTROL_CHARS, "");
|
|
4316
3158
|
}
|
|
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
3159
|
function splitLogLine(line) {
|
|
4323
3160
|
const spaceAt = line.indexOf(" ");
|
|
4324
3161
|
const head = spaceAt === -1 ? line : line.slice(0, spaceAt);
|
|
@@ -4338,10 +3175,6 @@ function spanBetween(start, end) {
|
|
|
4338
3175
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
4339
3176
|
return endMs - startMs;
|
|
4340
3177
|
}
|
|
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
3178
|
function readActionsJobLog(text) {
|
|
4346
3179
|
const raw = text.split("\n");
|
|
4347
3180
|
if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
|
|
@@ -4358,33 +3191,7 @@ function readActionsJobLog(text) {
|
|
|
4358
3191
|
}
|
|
4359
3192
|
//#endregion
|
|
4360
3193
|
//#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
3194
|
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
3195
|
function parseCiLogTarget(target) {
|
|
4389
3196
|
if (/^\d+$/.test(target)) return {
|
|
4390
3197
|
run: target,
|
|
@@ -4405,7 +3212,6 @@ function parseCiLogTarget(target) {
|
|
|
4405
3212
|
};
|
|
4406
3213
|
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
3214
|
}
|
|
4408
|
-
/** What the run's jobs are, in the shape an error message lists them in for a caller to read. */
|
|
4409
3215
|
function jobTable(jobs) {
|
|
4410
3216
|
return jobs.map((candidate) => ` ${String(candidate.index)} ${candidate.name} ${candidate.status}`).join("\n");
|
|
4411
3217
|
}
|
|
@@ -4414,16 +3220,6 @@ function jobAtIndex(jobs, index, label) {
|
|
|
4414
3220
|
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
3221
|
return found;
|
|
4416
3222
|
}
|
|
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
3223
|
function chooseJob(jobs, job, urlIndex) {
|
|
4428
3224
|
if (job !== void 0) {
|
|
4429
3225
|
const chosen = /^\d+$/.test(job) ? jobAtIndex(jobs, Number(job), `--job ${job}`) : jobs.find((candidate) => candidate.name.toLowerCase() === job.toLowerCase());
|
|
@@ -4436,11 +3232,6 @@ function chooseJob(jobs, job, urlIndex) {
|
|
|
4436
3232
|
if (only !== void 0) return only;
|
|
4437
3233
|
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
3234
|
}
|
|
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
3235
|
async function runCiLog(fetcher, options) {
|
|
4445
3236
|
const target = parseCiLogTarget(options.target);
|
|
4446
3237
|
let grep;
|
|
@@ -4485,7 +3276,6 @@ async function runCiLog(fetcher, options) {
|
|
|
4485
3276
|
notices
|
|
4486
3277
|
};
|
|
4487
3278
|
}
|
|
4488
|
-
/** A two-line header naming the run and job, then each line kept with its timestamp. */
|
|
4489
3279
|
function renderCiLog(result) {
|
|
4490
3280
|
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
3281
|
for (const line of result.lines) lines.push(line.at === null ? line.text : `${line.at} ${line.text}`);
|
|
@@ -4548,31 +3338,11 @@ const ciLogCommand = defineCommand({
|
|
|
4548
3338
|
});
|
|
4549
3339
|
//#endregion
|
|
4550
3340
|
//#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
3341
|
function parseLimit$2(raw) {
|
|
4563
3342
|
const limit = Number(raw);
|
|
4564
3343
|
if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
|
|
4565
3344
|
return limit;
|
|
4566
3345
|
}
|
|
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
3346
|
function resolveRunsRef(ref, pr) {
|
|
4577
3347
|
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
3348
|
if (pr !== void 0) {
|
|
@@ -4596,11 +3366,6 @@ function resolveRunsRef(ref, pr) {
|
|
|
4596
3366
|
target: void 0
|
|
4597
3367
|
};
|
|
4598
3368
|
}
|
|
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
3369
|
async function runCiRuns(fetcher, options) {
|
|
4605
3370
|
const limit = parseLimit$2(options.limit);
|
|
4606
3371
|
const resolved = resolveRunsRef(options.ref, options.pr);
|
|
@@ -4632,14 +3397,9 @@ async function runCiRuns(fetcher, options) {
|
|
|
4632
3397
|
notices
|
|
4633
3398
|
};
|
|
4634
3399
|
}
|
|
4635
|
-
/** A duration, or the dash that says the forge reported none (the run has not finished). */
|
|
4636
3400
|
function durationText(durationMs) {
|
|
4637
3401
|
return durationMs === null ? "-" : `${String(durationMs)}ms`;
|
|
4638
3402
|
}
|
|
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
3403
|
function renderCiRuns(result) {
|
|
4644
3404
|
if (result.runs.length === 0) return `No workflow runs in ${result.repository}.`;
|
|
4645
3405
|
const lines = [];
|
|
@@ -4728,15 +3488,6 @@ const ciRunsCommand = defineCommand({
|
|
|
4728
3488
|
//#region ../cli-kit/src/workflow-inputs.ts
|
|
4729
3489
|
const INPUT_FLAG = "--input";
|
|
4730
3490
|
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
3491
|
function collectInputArgs(rawArgs) {
|
|
4741
3492
|
const values = [];
|
|
4742
3493
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
@@ -4751,11 +3502,6 @@ function collectInputArgs(rawArgs) {
|
|
|
4751
3502
|
}
|
|
4752
3503
|
return values;
|
|
4753
3504
|
}
|
|
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
3505
|
function parseInputs(raw) {
|
|
4760
3506
|
const inputs = {};
|
|
4761
3507
|
for (const value of raw) {
|
|
@@ -4767,11 +3513,6 @@ function parseInputs(raw) {
|
|
|
4767
3513
|
}
|
|
4768
3514
|
//#endregion
|
|
4769
3515
|
//#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
3516
|
async function runWorkflowDispatch(fetcher, options) {
|
|
4776
3517
|
const dispatched = await dispatchWorkflow(fetcher, requireForgejoTarget(options.cwd, void 0, "workflow:dispatch is Forgejo-only -- use gh workflow run on GitHub."), {
|
|
4777
3518
|
...options.repo === void 0 ? {} : { repository: options.repo },
|
|
@@ -4847,31 +3588,11 @@ z.object({
|
|
|
4847
3588
|
content: z.string(),
|
|
4848
3589
|
encoding: z.literal("base64")
|
|
4849
3590
|
});
|
|
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
3591
|
function encodeRepoPath(filePath) {
|
|
4866
3592
|
const segments = filePath.split("/");
|
|
4867
3593
|
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new UnexpectedError(`Refusing a repository path with a relative segment: ${filePath}`);
|
|
4868
3594
|
return segments.map(encodeURIComponent).join("/");
|
|
4869
3595
|
}
|
|
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
3596
|
const DirEntrySchema = z.looseObject({
|
|
4876
3597
|
name: z.string(),
|
|
4877
3598
|
sha: z.string(),
|
|
@@ -4880,10 +3601,6 @@ const DirEntrySchema = z.looseObject({
|
|
|
4880
3601
|
z.array(DirEntrySchema);
|
|
4881
3602
|
//#endregion
|
|
4882
3603
|
//#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
3604
|
const BranchEntrySchema = z.object({
|
|
4888
3605
|
name: z.string(),
|
|
4889
3606
|
protected: z.boolean().optional(),
|
|
@@ -4892,10 +3609,7 @@ const BranchEntrySchema = z.object({
|
|
|
4892
3609
|
timestamp: z.string().optional()
|
|
4893
3610
|
}).optional()
|
|
4894
3611
|
});
|
|
4895
|
-
/** Requested page size. Forgejo clamps it, so a short page is NOT an end signal -- only an empty
|
|
4896
|
-
* one is. */
|
|
4897
3612
|
const BRANCHES_PAGE_SIZE = 50;
|
|
4898
|
-
/** Default cap on pages, so a misbehaving server cannot spin a caller forever. */
|
|
4899
3613
|
const DEFAULT_MAX_PAGES = 20;
|
|
4900
3614
|
function repoBase(conn) {
|
|
4901
3615
|
return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
|
|
@@ -4903,7 +3617,6 @@ function repoBase(conn) {
|
|
|
4903
3617
|
function authHeaders(conn) {
|
|
4904
3618
|
return { Authorization: `token ${conn.token}` };
|
|
4905
3619
|
}
|
|
4906
|
-
/** List a repository's branches, paging until an empty page or `maxPages`. */
|
|
4907
3620
|
async function listBranches(executor, conn, options = {}) {
|
|
4908
3621
|
const signal = options.signal;
|
|
4909
3622
|
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
@@ -4946,7 +3659,6 @@ const DELETE_REFUSALS = {
|
|
|
4946
3659
|
404: "not-found"
|
|
4947
3660
|
};
|
|
4948
3661
|
const ErrorBodySchema = z.object({ message: z.string() });
|
|
4949
|
-
/** The `message` field of a JSON error body, else the body text itself. */
|
|
4950
3662
|
async function refusalMessage(res) {
|
|
4951
3663
|
const text = await responseBodyText(res);
|
|
4952
3664
|
try {
|
|
@@ -4955,7 +3667,6 @@ async function refusalMessage(res) {
|
|
|
4955
3667
|
} catch (_error) {}
|
|
4956
3668
|
return text;
|
|
4957
3669
|
}
|
|
4958
|
-
/** Delete one branch. The name is encoded per segment so `feat/a/b` keeps its slashes. */
|
|
4959
3670
|
async function deleteBranch(executor, conn, branch) {
|
|
4960
3671
|
const res = await executor.fetch(`${repoBase(conn)}/branches/${encodeRepoPath(branch)}`, {
|
|
4961
3672
|
method: "DELETE",
|
|
@@ -4976,20 +3687,14 @@ async function deleteBranch(executor, conn, branch) {
|
|
|
4976
3687
|
}
|
|
4977
3688
|
//#endregion
|
|
4978
3689
|
//#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
3690
|
const REFUSAL_WORDS = {
|
|
4984
3691
|
forbidden: "forbidden",
|
|
4985
3692
|
locked: "locked",
|
|
4986
3693
|
"not-found": "not found"
|
|
4987
3694
|
};
|
|
4988
|
-
/** List a repository's branches. */
|
|
4989
3695
|
function runBranchList(fetcher, options) {
|
|
4990
3696
|
return listBranches(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "branch:list is Forgejo-only -- use git branch -r on GitHub."));
|
|
4991
3697
|
}
|
|
4992
|
-
/** Delete one branch; a refusal is a runtime error naming the reason. */
|
|
4993
3698
|
async function runBranchDelete(fetcher, options) {
|
|
4994
3699
|
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
3700
|
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 +3706,6 @@ function renderBranch(branch) {
|
|
|
5001
3706
|
if (branch.commitSha !== null) parts.push(branch.commitSha.slice(0, 7));
|
|
5002
3707
|
return parts.join(" ");
|
|
5003
3708
|
}
|
|
5004
|
-
/** Human-readable rendering: one line per branch, then a note when the walk was cut short. */
|
|
5005
3709
|
function renderBranchList(listing) {
|
|
5006
3710
|
const lines = listing.branches.map(renderBranch);
|
|
5007
3711
|
if (lines.length === 0) lines.push("No branches.");
|
|
@@ -5081,7 +3785,6 @@ const branchSubCommands = {
|
|
|
5081
3785
|
};
|
|
5082
3786
|
//#endregion
|
|
5083
3787
|
//#region ../cli-kit/src/body-source.ts
|
|
5084
|
-
/** Run `read`, converting any raw fs error (ENOENT, EISDIR, EACCES, ...) into a FatalError. */
|
|
5085
3788
|
function tryRead(read, label) {
|
|
5086
3789
|
try {
|
|
5087
3790
|
return read();
|
|
@@ -5089,20 +3792,6 @@ function tryRead(read, label) {
|
|
|
5089
3792
|
throw new FatalError(`Could not read ${label}: ${describeError(error)}`);
|
|
5090
3793
|
}
|
|
5091
3794
|
}
|
|
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
3795
|
function readBodySource(body, bodyFile, options = {}) {
|
|
5107
3796
|
const flags = options.flags ?? {
|
|
5108
3797
|
text: "--body",
|
|
@@ -5148,33 +3837,8 @@ function normalizeComment(raw) {
|
|
|
5148
3837
|
attachments: (raw.assets ?? []).map(normalizeAttachment)
|
|
5149
3838
|
};
|
|
5150
3839
|
}
|
|
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
3840
|
const COMMENTS_PAGE_SIZE = 50;
|
|
5158
|
-
/** Hard cap on pages fetched, so a misbehaving server can't spin this forever. */
|
|
5159
3841
|
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
3842
|
async function listForgejoComments(executor, conn, number, options = {}) {
|
|
5179
3843
|
const comments = [];
|
|
5180
3844
|
for (let page = 1; page <= COMMENTS_MAX_PAGES; page++) {
|
|
@@ -5203,7 +3867,6 @@ const RepoCommentResponseSchema = CommentResponseSchema.extend({
|
|
|
5203
3867
|
updated_at: z.string().nullish()
|
|
5204
3868
|
});
|
|
5205
3869
|
z.array(RepoCommentResponseSchema);
|
|
5206
|
-
/** Post a new comment on an issue. */
|
|
5207
3870
|
async function addForgejoComment(executor, conn, number, body) {
|
|
5208
3871
|
const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/comments`, {
|
|
5209
3872
|
method: "POST",
|
|
@@ -5218,18 +3881,7 @@ async function addForgejoComment(executor, conn, number, body) {
|
|
|
5218
3881
|
}
|
|
5219
3882
|
//#endregion
|
|
5220
3883
|
//#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
3884
|
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
3885
|
function hasWipPrefix(title) {
|
|
5234
3886
|
return matchedWipPrefix(title) !== void 0;
|
|
5235
3887
|
}
|
|
@@ -5237,13 +3889,6 @@ function matchedWipPrefix(title) {
|
|
|
5237
3889
|
const lower = title.toLowerCase();
|
|
5238
3890
|
return WIP_PREFIXES.find((prefix) => lower.startsWith(prefix.toLowerCase()));
|
|
5239
3891
|
}
|
|
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
3892
|
function stripWipPrefix(title) {
|
|
5248
3893
|
const prefix = matchedWipPrefix(title);
|
|
5249
3894
|
if (prefix === void 0) return title;
|
|
@@ -5255,32 +3900,11 @@ const CreatedPrSchema = z.object({
|
|
|
5255
3900
|
number: z.number(),
|
|
5256
3901
|
html_url: z.string().optional()
|
|
5257
3902
|
});
|
|
5258
|
-
/** Compose a PR's web URL from the connection coordinates. */
|
|
5259
3903
|
function prWebUrl(conn, prNumber) {
|
|
5260
3904
|
return `${conn.serverUrl}/${conn.repository}/pulls/${String(prNumber)}`;
|
|
5261
3905
|
}
|
|
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
3906
|
const PULLS_PAGE_SIZE = 50;
|
|
5265
|
-
/** Hard cap on pages, so a misbehaving server cannot spin a caller forever. */
|
|
5266
3907
|
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
3908
|
async function findOpenPrEntry(executor, conn, head, signal = null) {
|
|
5285
3909
|
let capped = false;
|
|
5286
3910
|
const match = (await listPulls(executor, conn, {
|
|
@@ -5301,10 +3925,6 @@ async function findOpenPrEntry(executor, conn, head, signal = null) {
|
|
|
5301
3925
|
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
3926
|
return null;
|
|
5303
3927
|
}
|
|
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
3928
|
async function findOpenPr(executor, conn, head, signal = null) {
|
|
5309
3929
|
return (await findOpenPrEntry(executor, conn, head, signal))?.number ?? null;
|
|
5310
3930
|
}
|
|
@@ -5325,23 +3945,6 @@ const PullListItemSchema = z.object({
|
|
|
5325
3945
|
function updatedBefore(since) {
|
|
5326
3946
|
return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
|
|
5327
3947
|
}
|
|
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
3948
|
async function listPulls(executor, conn, options = {}) {
|
|
5346
3949
|
const headers = { Authorization: `token ${conn.token}` };
|
|
5347
3950
|
const pulls = [];
|
|
@@ -5392,7 +3995,6 @@ async function listPulls(executor, conn, options = {}) {
|
|
|
5392
3995
|
}
|
|
5393
3996
|
return pulls;
|
|
5394
3997
|
}
|
|
5395
|
-
/** Create a new pull request. */
|
|
5396
3998
|
async function createPr(executor, conn, options) {
|
|
5397
3999
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls`;
|
|
5398
4000
|
const payload = {
|
|
@@ -5421,20 +4023,6 @@ async function createPr(executor, conn, options) {
|
|
|
5421
4023
|
};
|
|
5422
4024
|
}
|
|
5423
4025
|
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
4026
|
async function readPrField(executor, conn, prNumber, schema, what) {
|
|
5439
4027
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
|
|
5440
4028
|
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
@@ -5464,11 +4052,6 @@ const PrDetailSchema = z.object({
|
|
|
5464
4052
|
mergeable: z.boolean().nullish(),
|
|
5465
4053
|
state: z.string()
|
|
5466
4054
|
});
|
|
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
4055
|
function normalizePrDetail(data) {
|
|
5473
4056
|
return {
|
|
5474
4057
|
head: {
|
|
@@ -5487,14 +4070,6 @@ function normalizePrDetail(data) {
|
|
|
5487
4070
|
state: data.state
|
|
5488
4071
|
};
|
|
5489
4072
|
}
|
|
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
4073
|
async function getForgejoPrDetail(executor, conn, prNumber) {
|
|
5499
4074
|
return normalizePrDetail(await readPrField(executor, conn, prNumber, PrDetailSchema, "detail"));
|
|
5500
4075
|
}
|
|
@@ -5508,15 +4083,6 @@ const PrEditStateSchema = z.object({
|
|
|
5508
4083
|
mergeable: z.boolean().nullish(),
|
|
5509
4084
|
html_url: z.string().optional()
|
|
5510
4085
|
});
|
|
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
4086
|
async function getPrEditState(executor, conn, prNumber) {
|
|
5521
4087
|
const parsed = await readPrField(executor, conn, prNumber, PrEditStateSchema, "edit state");
|
|
5522
4088
|
return {
|
|
@@ -5530,16 +4096,6 @@ async function getPrEditState(executor, conn, prNumber) {
|
|
|
5530
4096
|
url: parsed.html_url ?? prWebUrl(conn, prNumber)
|
|
5531
4097
|
};
|
|
5532
4098
|
}
|
|
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
4099
|
async function retargetPr(executor, conn, prNumber, base) {
|
|
5544
4100
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
|
|
5545
4101
|
const res = await executor.fetch(url, {
|
|
@@ -5559,20 +4115,6 @@ const PrContentSchema = z.object({
|
|
|
5559
4115
|
title: z.string(),
|
|
5560
4116
|
body: z.string().nullish()
|
|
5561
4117
|
});
|
|
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
4118
|
async function getPrContent(executor, conn, prNumber) {
|
|
5577
4119
|
const parsed = await readPrField(executor, conn, prNumber, PrContentSchema, "content");
|
|
5578
4120
|
return {
|
|
@@ -5584,12 +4126,6 @@ z.object({
|
|
|
5584
4126
|
title: z.string(),
|
|
5585
4127
|
draft: z.boolean()
|
|
5586
4128
|
});
|
|
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
4129
|
async function updatePr(executor, conn, prNumber, options) {
|
|
5594
4130
|
if (options.title === void 0 && options.body === void 0) return;
|
|
5595
4131
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
|
|
@@ -5628,16 +4164,11 @@ const PullReviewCommentSchema = z.object({
|
|
|
5628
4164
|
z.array(PullReviewCommentSchema);
|
|
5629
4165
|
//#endregion
|
|
5630
4166
|
//#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
4167
|
var IssueNumberRequiredError = class extends FatalError {
|
|
5636
4168
|
constructor() {
|
|
5637
4169
|
super("An issue number or issue URL is required.");
|
|
5638
4170
|
}
|
|
5639
4171
|
};
|
|
5640
|
-
/** The issue number of `ref`, or an {@link IssueNumberRequiredError} when it has none. */
|
|
5641
4172
|
function requireIssueNumber(ref) {
|
|
5642
4173
|
if (ref.number === void 0) throw new IssueNumberRequiredError();
|
|
5643
4174
|
return ref.number;
|
|
@@ -5661,10 +4192,6 @@ function forgejoIssueApi(fetcher, conn) {
|
|
|
5661
4192
|
}
|
|
5662
4193
|
//#endregion
|
|
5663
4194
|
//#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
4195
|
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
4196
|
const ISSUE_CREATE_JSON_HELP = "Emit one JSON document: number, url, assignees. A dry run reports number 0 and an empty url.";
|
|
5670
4197
|
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 +4201,39 @@ const ISSUE_DOWNLOAD_JSON_HELP = "Emit one JSON document: dir, files ([{name, si
|
|
|
5674
4201
|
const ISSUE_LABEL_JSON_HELP = "Emit one JSON document: number, label, action (add | remove), url.";
|
|
5675
4202
|
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
4203
|
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
4204
|
function describeIssueNumberRequired(example) {
|
|
5683
4205
|
return `An issue number or issue URL is required, e.g. \`${example}\`.`;
|
|
5684
4206
|
}
|
|
5685
|
-
/** The refusal for an {@link InvalidListQueryError}, naming the program's own options. */
|
|
5686
4207
|
function describeInvalidListQuery(error, flags) {
|
|
5687
4208
|
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
4209
|
}
|
|
5689
|
-
/** The refusal for a NothingToUpdateError: an update that names no field to change. */
|
|
5690
4210
|
function describeNothingToUpdate(flags) {
|
|
5691
4211
|
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
4212
|
}
|
|
5693
|
-
/** The refusal for an EmptyCommentError, naming the program's own options. */
|
|
5694
4213
|
function describeEmptyComment(flags) {
|
|
5695
4214
|
return `${flags.text} / ${flags.file} resolved to empty text -- pass a comment or omit the flag.`;
|
|
5696
4215
|
}
|
|
5697
|
-
/** The refusal for a {@link PullRequestStateError}: a state write aimed at a pull request. */
|
|
5698
4216
|
function describePullRequestState(error, names) {
|
|
5699
4217
|
return `#${String(error.number)} is a pull request, not an issue -- ${names.command} refuses to write pull-request state. ${names.advice}`;
|
|
5700
4218
|
}
|
|
5701
|
-
/** The refusal for a {@link NoLabelsGivenError}, naming the program's own clear option. */
|
|
5702
4219
|
function describeNoLabelsGiven(error, clearFlag) {
|
|
5703
4220
|
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
4221
|
}
|
|
5705
|
-
/** The refusal for an {@link AttachmentExistsError}, naming the program's own force option. */
|
|
5706
4222
|
function describeAttachmentExists(error, forceFlag) {
|
|
5707
4223
|
return `"${error.filePath}" already exists -- pass ${forceFlag} to overwrite it.`;
|
|
5708
4224
|
}
|
|
5709
4225
|
//#endregion
|
|
5710
4226
|
//#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
4227
|
var NothingToUpdateError = class extends FatalError {
|
|
5713
4228
|
constructor() {
|
|
5714
4229
|
super("Nothing to change: neither a title nor a body was given.");
|
|
5715
4230
|
}
|
|
5716
4231
|
};
|
|
5717
|
-
/** Raised when a create has an empty title. */
|
|
5718
4232
|
var IssueTitleRequiredError = class extends FatalError {
|
|
5719
4233
|
constructor() {
|
|
5720
4234
|
super("An issue title is required.");
|
|
5721
4235
|
}
|
|
5722
4236
|
};
|
|
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
4237
|
var InvalidListQueryError = class extends FatalError {
|
|
5728
4238
|
field;
|
|
5729
4239
|
value;
|
|
@@ -5733,10 +4243,6 @@ var InvalidListQueryError = class extends FatalError {
|
|
|
5733
4243
|
this.value = value;
|
|
5734
4244
|
}
|
|
5735
4245
|
};
|
|
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
4246
|
async function viewIssue(resolve, request) {
|
|
5741
4247
|
const ref = parseIssueRef(request.ref);
|
|
5742
4248
|
const number = requireIssueNumber(ref);
|
|
@@ -5767,26 +4273,11 @@ async function viewIssue(resolve, request) {
|
|
|
5767
4273
|
};
|
|
5768
4274
|
}
|
|
5769
4275
|
}
|
|
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
4276
|
function formatPrHead(pr) {
|
|
5777
4277
|
if (pr.head.repo === null) return `${pr.head.ref} (head repo gone)`;
|
|
5778
4278
|
if (pr.head.repo === pr.base.repo) return pr.head.ref;
|
|
5779
4279
|
return `${pr.head.repo}:${pr.head.ref}`;
|
|
5780
4280
|
}
|
|
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
4281
|
function formatMergeable(mergeable) {
|
|
5791
4282
|
if (mergeable === null) return "mergeable: unknown";
|
|
5792
4283
|
return mergeable ? "mergeable" : "not mergeable";
|
|
@@ -5799,11 +4290,6 @@ function formatPrLine(pr) {
|
|
|
5799
4290
|
line += ` ${formatMergeable(pr.mergeable)}`;
|
|
5800
4291
|
return line;
|
|
5801
4292
|
}
|
|
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
4293
|
function formatPrUnavailableLine(unavailable) {
|
|
5808
4294
|
const breakAt = unavailable.reason.indexOf("\n");
|
|
5809
4295
|
return `pr: unavailable -- ${breakAt === -1 ? unavailable.reason : unavailable.reason.slice(0, breakAt)}`;
|
|
@@ -5843,24 +4329,15 @@ function parseState$1(state) {
|
|
|
5843
4329
|
if (state === "open" || state === "closed" || state === "all") return state;
|
|
5844
4330
|
throw new InvalidListQueryError("state", state);
|
|
5845
4331
|
}
|
|
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
4332
|
function parseLimit$1(raw) {
|
|
5854
4333
|
const limit = Number(raw);
|
|
5855
4334
|
if (!Number.isInteger(limit) || limit <= 0) throw new InvalidListQueryError("limit", raw);
|
|
5856
4335
|
return limit;
|
|
5857
4336
|
}
|
|
5858
|
-
/** An absent or whitespace-only label means "no filter" -- never send `labels=`. */
|
|
5859
4337
|
function normalizeLabel(label) {
|
|
5860
4338
|
if (label === void 0 || label.trim() === "") return void 0;
|
|
5861
4339
|
return label;
|
|
5862
4340
|
}
|
|
5863
|
-
/** List issues, filtered by state and label, up to a limit. */
|
|
5864
4341
|
async function listIssues(resolve, request) {
|
|
5865
4342
|
const label = normalizeLabel(request.label);
|
|
5866
4343
|
const query = {
|
|
@@ -5877,17 +4354,6 @@ async function listIssues(resolve, request) {
|
|
|
5877
4354
|
truncated
|
|
5878
4355
|
};
|
|
5879
4356
|
}
|
|
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
4357
|
async function commentOnIssue(resolve, request) {
|
|
5892
4358
|
const ref = parseIssueRef(request.ref);
|
|
5893
4359
|
const number = requireIssueNumber(ref);
|
|
@@ -5903,30 +4369,9 @@ async function commentOnIssue(resolve, request) {
|
|
|
5903
4369
|
url: issueUrl
|
|
5904
4370
|
};
|
|
5905
4371
|
}
|
|
5906
|
-
/** Human-readable summary of {@link commentOnIssue}'s result. */
|
|
5907
4372
|
function formatIssueComment(result, dryRun) {
|
|
5908
4373
|
return `${dryRun ? "[dry-run] Would comment" : "Commented"} on issue #${String(result.number)}`;
|
|
5909
4374
|
}
|
|
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
4375
|
async function updateIssue(resolve, request) {
|
|
5931
4376
|
const ref = parseIssueRef(request.ref);
|
|
5932
4377
|
const number = requireIssueNumber(ref);
|
|
@@ -5965,14 +4410,6 @@ async function updateIssue(resolve, request) {
|
|
|
5965
4410
|
changed
|
|
5966
4411
|
};
|
|
5967
4412
|
}
|
|
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
4413
|
function formatIssueUpdate(result, dryRun) {
|
|
5977
4414
|
const lines = [`${dryRun ? "Would update" : "Updated"} issue #${String(result.number)} (${result.url})`];
|
|
5978
4415
|
const untouched = ["title", "body"].filter((f) => !result.changed.includes(f));
|
|
@@ -5994,26 +4431,10 @@ function formatIssueUpdate(result, dryRun) {
|
|
|
5994
4431
|
if (untouched.length > 0) lines.push(` ${untouched.join(" and ")} left as authored`);
|
|
5995
4432
|
return lines.join("\n");
|
|
5996
4433
|
}
|
|
5997
|
-
/** A requested assignee absent from the case-insensitive lowercased set of what the forge
|
|
5998
|
-
* actually returned. */
|
|
5999
4434
|
function droppedAssignees(requested, returned) {
|
|
6000
4435
|
const returnedLower = new Set(returned.map((name) => name.toLowerCase()));
|
|
6001
4436
|
return requested.filter((name) => !returnedLower.has(name.toLowerCase()));
|
|
6002
4437
|
}
|
|
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
4438
|
async function createIssue(resolve, request) {
|
|
6018
4439
|
const title = request.title.trim();
|
|
6019
4440
|
if (title === "") throw new IssueTitleRequiredError();
|
|
@@ -6039,10 +4460,6 @@ async function createIssue(resolve, request) {
|
|
|
6039
4460
|
assignees: request.assignees
|
|
6040
4461
|
};
|
|
6041
4462
|
}
|
|
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
4463
|
function formatIssueCreate(result, title, dryRun) {
|
|
6047
4464
|
if (dryRun) return [`[dry-run] Would open an issue titled "${title}"${result.assignees.length > 0 ? ` assigned to ${result.assignees.join(", ")}` : ""}`];
|
|
6048
4465
|
const lines = [`Opened issue #${String(result.number)}`];
|
|
@@ -6064,15 +4481,9 @@ function formatIssueList(result) {
|
|
|
6064
4481
|
}
|
|
6065
4482
|
//#endregion
|
|
6066
4483
|
//#region src/issue-transport.ts
|
|
6067
|
-
/** The sentence for a github.com repository, which owes the reader the alternatives. */
|
|
6068
4484
|
function githubRefusal(command) {
|
|
6069
4485
|
return `bst-fj is Forgejo-only -- use \`gh\` or \`bst ${command}\` for a github.com repository.`;
|
|
6070
4486
|
}
|
|
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
4487
|
function forgejoIssueTarget(cwd, target, command) {
|
|
6077
4488
|
const conn = requireForgejoTarget(cwd, target, githubRefusal(command));
|
|
6078
4489
|
return {
|
|
@@ -6085,7 +4496,6 @@ function forgejoIssueTarget(cwd, target, command) {
|
|
|
6085
4496
|
}
|
|
6086
4497
|
};
|
|
6087
4498
|
}
|
|
6088
|
-
/** {@link forgejoIssueTarget}, with the issue API over its connection. */
|
|
6089
4499
|
function forgejoIssueTransport(cwd, target, command) {
|
|
6090
4500
|
const resolved = forgejoIssueTarget(cwd, target, command);
|
|
6091
4501
|
return {
|
|
@@ -6228,10 +4638,6 @@ const issueCreateCommand = defineCommand({
|
|
|
6228
4638
|
});
|
|
6229
4639
|
//#endregion
|
|
6230
4640
|
//#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
4641
|
function isReadableFile(filePath) {
|
|
6236
4642
|
try {
|
|
6237
4643
|
readFileSync(filePath);
|
|
@@ -6247,7 +4653,6 @@ const diskFiles = {
|
|
|
6247
4653
|
writeFileSync(filePath, content);
|
|
6248
4654
|
}
|
|
6249
4655
|
};
|
|
6250
|
-
/** Raised when a download would overwrite a file and `force` is off. */
|
|
6251
4656
|
var AttachmentExistsError = class extends FatalError {
|
|
6252
4657
|
filePath;
|
|
6253
4658
|
constructor(filePath) {
|
|
@@ -6255,13 +4660,6 @@ var AttachmentExistsError = class extends FatalError {
|
|
|
6255
4660
|
this.filePath = filePath;
|
|
6256
4661
|
}
|
|
6257
4662
|
};
|
|
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
4663
|
function sanitizeAttachmentName(name) {
|
|
6266
4664
|
const stripped = Array.from(name).filter((ch) => {
|
|
6267
4665
|
const code = ch.codePointAt(0) ?? 0;
|
|
@@ -6271,7 +4669,6 @@ function sanitizeAttachmentName(name) {
|
|
|
6271
4669
|
if (base === "" || base === "." || base === "..") throw new FatalError(`Refusing to write an attachment with an unusable filename: "${name}"`);
|
|
6272
4670
|
return base;
|
|
6273
4671
|
}
|
|
6274
|
-
/** Give same-named attachments distinct paths (`plan.md`, `plan-2.md`) instead of clobbering. */
|
|
6275
4672
|
function resolveTargetPath(outDir, name, taken) {
|
|
6276
4673
|
const safe = sanitizeAttachmentName(name);
|
|
6277
4674
|
const ext = path.extname(safe);
|
|
@@ -6288,20 +4685,6 @@ function resolveTargetPath(outDir, name, taken) {
|
|
|
6288
4685
|
if (target !== root && !target.startsWith(root + path.sep)) throw new FatalError(`Refusing to write outside ${outDir}: "${name}"`);
|
|
6289
4686
|
return target;
|
|
6290
4687
|
}
|
|
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
4688
|
async function downloadAttachment(fetcher, token, attachment) {
|
|
6306
4689
|
const res = await fetcher.fetch(attachment.downloadUrl, { headers: { Authorization: `token ${token}` } }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
|
|
6307
4690
|
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 +4694,6 @@ async function downloadAttachment(fetcher, token, attachment) {
|
|
|
6311
4694
|
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
4695
|
return body;
|
|
6313
4696
|
}
|
|
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
4697
|
async function downloadIssueAttachments(resolve, request) {
|
|
6321
4698
|
const ref = parseIssueRef(request.ref);
|
|
6322
4699
|
const number = requireIssueNumber(ref);
|
|
@@ -6346,7 +4723,6 @@ async function downloadIssueAttachments(resolve, request) {
|
|
|
6346
4723
|
files: downloaded
|
|
6347
4724
|
};
|
|
6348
4725
|
}
|
|
6349
|
-
/** Human-readable summary of a download. */
|
|
6350
4726
|
function formatDownload(result, dryRun) {
|
|
6351
4727
|
const lines = [];
|
|
6352
4728
|
const verb = dryRun ? "[dry-run] Would download" : "Downloaded";
|
|
@@ -6416,12 +4792,6 @@ const issueDownloadCommand = defineCommand({
|
|
|
6416
4792
|
});
|
|
6417
4793
|
//#endregion
|
|
6418
4794
|
//#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
4795
|
const TimelineEntrySchema = z.object({
|
|
6426
4796
|
id: z.number(),
|
|
6427
4797
|
type: z.string(),
|
|
@@ -6431,13 +4801,6 @@ const TimelineEntrySchema = z.object({
|
|
|
6431
4801
|
user: z.object({ login: z.string() }).nullish()
|
|
6432
4802
|
});
|
|
6433
4803
|
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
4804
|
async function resolveLabelId(executor, conn, name, labelIds) {
|
|
6442
4805
|
const cached = labelIds?.get(name);
|
|
6443
4806
|
if (cached !== void 0) return cached;
|
|
@@ -6445,10 +4808,6 @@ async function resolveLabelId(executor, conn, name, labelIds) {
|
|
|
6445
4808
|
if (id === void 0) throw new UnexpectedError(`Label id resolution returned nothing for "${name}"`);
|
|
6446
4809
|
return id;
|
|
6447
4810
|
}
|
|
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
4811
|
async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
|
|
6453
4812
|
const id = await resolveLabelId(executor, conn, name, labelIds);
|
|
6454
4813
|
const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
|
|
@@ -6461,11 +4820,6 @@ async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
|
|
|
6461
4820
|
resource: `#${String(number)}`
|
|
6462
4821
|
}, issueNotFoundMessage(conn, number));
|
|
6463
4822
|
}
|
|
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
4823
|
async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
|
|
6470
4824
|
const id = await resolveLabelId(executor, conn, name, labelIds);
|
|
6471
4825
|
const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
|
|
@@ -6477,12 +4831,6 @@ async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
|
|
|
6477
4831
|
resource: `#${String(number)}`
|
|
6478
4832
|
}, issueNotFoundMessage(conn, number));
|
|
6479
4833
|
}
|
|
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
4834
|
async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
|
|
6487
4835
|
const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
|
|
6488
4836
|
method: "PUT",
|
|
@@ -6496,12 +4844,6 @@ async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
|
|
|
6496
4844
|
}
|
|
6497
4845
|
//#endregion
|
|
6498
4846
|
//#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
4847
|
async function labelIssue(resolve, request) {
|
|
6506
4848
|
const ref = parseIssueRef(request.ref);
|
|
6507
4849
|
const number = requireIssueNumber(ref);
|
|
@@ -6522,7 +4864,6 @@ async function labelIssue(resolve, request) {
|
|
|
6522
4864
|
url
|
|
6523
4865
|
};
|
|
6524
4866
|
}
|
|
6525
|
-
/** Human-readable summary of a label add or remove. */
|
|
6526
4867
|
function formatIssueLabel(result, dryRun) {
|
|
6527
4868
|
const adding = result.action === "add";
|
|
6528
4869
|
let verb;
|
|
@@ -6531,7 +4872,6 @@ function formatIssueLabel(result, dryRun) {
|
|
|
6531
4872
|
const prep = adding ? "to" : "from";
|
|
6532
4873
|
return `${verb} label "${result.label}" ${prep} #${String(result.number)} (${result.url})`;
|
|
6533
4874
|
}
|
|
6534
|
-
/** Raised when an empty label set arrives without `clear`. Carries the issue it would have stripped. */
|
|
6535
4875
|
var NoLabelsGivenError = class extends FatalError {
|
|
6536
4876
|
number;
|
|
6537
4877
|
constructor(number) {
|
|
@@ -6539,22 +4879,6 @@ var NoLabelsGivenError = class extends FatalError {
|
|
|
6539
4879
|
this.number = number;
|
|
6540
4880
|
}
|
|
6541
4881
|
};
|
|
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
4882
|
async function setIssueLabels(resolve, request) {
|
|
6559
4883
|
const ref = parseIssueRef(request.ref);
|
|
6560
4884
|
const number = requireIssueNumber(ref);
|
|
@@ -6572,7 +4896,6 @@ async function setIssueLabels(resolve, request) {
|
|
|
6572
4896
|
...diff
|
|
6573
4897
|
};
|
|
6574
4898
|
}
|
|
6575
|
-
/** Set difference both ways, order-preserving so the report reads in the order the caller sees. */
|
|
6576
4899
|
function diffLabels(previous, next) {
|
|
6577
4900
|
const before = new Set(previous);
|
|
6578
4901
|
const after = new Set(next);
|
|
@@ -6581,13 +4904,6 @@ function diffLabels(previous, next) {
|
|
|
6581
4904
|
removed: previous.filter((name) => !after.has(name))
|
|
6582
4905
|
};
|
|
6583
4906
|
}
|
|
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
4907
|
function formatIssueSetLabels(result, dryRun) {
|
|
6592
4908
|
const head = `${dryRun ? "Would set" : "Set"} labels [${result.labels.length === 0 ? "(none)" : result.labels.join(", ")}] on #${String(result.number)} (${result.url})`;
|
|
6593
4909
|
if (result.added.length === 0 && result.removed.length === 0) return `${head}\n no change (the issue already carries exactly these)`;
|
|
@@ -6719,13 +5035,11 @@ const issueSetLabelsCommand = defineCommand({
|
|
|
6719
5035
|
});
|
|
6720
5036
|
//#endregion
|
|
6721
5037
|
//#region ../forgejo/src/issue-run-state.ts
|
|
6722
|
-
/** Raised when a comment was given but is empty or only whitespace. */
|
|
6723
5038
|
var EmptyCommentError = class extends FatalError {
|
|
6724
5039
|
constructor() {
|
|
6725
5040
|
super("The comment resolved to empty text.");
|
|
6726
5041
|
}
|
|
6727
5042
|
};
|
|
6728
|
-
/** Raised when a state write targets a pull request, whose state is not written here. */
|
|
6729
5043
|
var PullRequestStateError = class extends FatalError {
|
|
6730
5044
|
number;
|
|
6731
5045
|
action;
|
|
@@ -6739,16 +5053,6 @@ const TARGET_STATE = {
|
|
|
6739
5053
|
close: "closed",
|
|
6740
5054
|
reopen: "open"
|
|
6741
5055
|
};
|
|
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
5056
|
async function setIssueState(resolve, request) {
|
|
6753
5057
|
const ref = parseIssueRef(request.ref);
|
|
6754
5058
|
const number = requireIssueNumber(ref);
|
|
@@ -6785,7 +5089,6 @@ const PAST_TENSE = {
|
|
|
6785
5089
|
close: "Closed",
|
|
6786
5090
|
reopen: "Reopened"
|
|
6787
5091
|
};
|
|
6788
|
-
/** Human-readable summary of a close or reopen. */
|
|
6789
5092
|
function formatIssueState(result, dryRun) {
|
|
6790
5093
|
const lines = [];
|
|
6791
5094
|
if (!result.changed) lines.push(`#${String(result.number)} is already ${result.state.current} -- nothing to ${result.action}. (${result.url})`);
|
|
@@ -6932,11 +5235,6 @@ const issueListCommand = defineCommand({
|
|
|
6932
5235
|
});
|
|
6933
5236
|
//#endregion
|
|
6934
5237
|
//#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
5238
|
const issueUpdateCommand = defineCommand({
|
|
6941
5239
|
meta: {
|
|
6942
5240
|
name: "issue:update",
|
|
@@ -7063,31 +5361,6 @@ const issueSubCommands = {
|
|
|
7063
5361
|
};
|
|
7064
5362
|
//#endregion
|
|
7065
5363
|
//#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
5364
|
async function resolvePrChecksTarget(fetcher, options) {
|
|
7092
5365
|
const ref = parseIssueRef(options.pr);
|
|
7093
5366
|
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 +5386,6 @@ async function resolvePrChecksTarget(fetcher, options) {
|
|
|
7113
5386
|
detail
|
|
7114
5387
|
};
|
|
7115
5388
|
}
|
|
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
5389
|
async function readBaseline(fetcher, conn, baseRef, enabled) {
|
|
7133
5390
|
if (!enabled) return {
|
|
7134
5391
|
ref: baseRef,
|
|
@@ -7169,12 +5426,6 @@ async function readBaseline(fetcher, conn, baseRef, enabled) {
|
|
|
7169
5426
|
redNames
|
|
7170
5427
|
};
|
|
7171
5428
|
}
|
|
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
5429
|
function buildReport(detail, checks, ctx, extras = {}) {
|
|
7179
5430
|
const { target, baseline, notices, waited, elapsedMs } = ctx;
|
|
7180
5431
|
const preexistingNames = new Set(baseline.status === "read" ? baseline.redNames : []);
|
|
@@ -7203,21 +5454,6 @@ function buildReport(detail, checks, ctx, extras = {}) {
|
|
|
7203
5454
|
if (extras.previousHeadSha !== void 0) report.previousHeadSha = extras.previousHeadSha;
|
|
7204
5455
|
return report;
|
|
7205
5456
|
}
|
|
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
5457
|
async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
|
|
7222
5458
|
const { timeoutMs, intervalMs, sleep, now } = poll;
|
|
7223
5459
|
const startedAt = now();
|
|
@@ -7289,13 +5525,6 @@ async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
|
|
|
7289
5525
|
currentDetail = nextDetail;
|
|
7290
5526
|
}
|
|
7291
5527
|
}
|
|
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
5528
|
async function runPrChecks(fetcher, options) {
|
|
7300
5529
|
const timings = options.wait === true ? resolvePollTimings(options) : void 0;
|
|
7301
5530
|
const target = await resolvePrChecksTarget(fetcher, options);
|
|
@@ -7319,12 +5548,6 @@ async function runPrChecks(fetcher, options) {
|
|
|
7319
5548
|
elapsedMs: 0
|
|
7320
5549
|
});
|
|
7321
5550
|
}
|
|
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
5551
|
function baselineClause(report) {
|
|
7329
5552
|
if (report.baseline.status !== "read") return null;
|
|
7330
5553
|
const redChecks = report.checks.filter((check) => check.verdict === "red");
|
|
@@ -7332,12 +5555,6 @@ function baselineClause(report) {
|
|
|
7332
5555
|
const preexistingCount = redChecks.filter((check) => check.preexisting).length;
|
|
7333
5556
|
return `${String(redChecks.length)} red, ${String(preexistingCount)} of them already red on ` + report.baseline.ref;
|
|
7334
5557
|
}
|
|
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
5558
|
function renderPrChecks(report) {
|
|
7342
5559
|
const lines = report.checks.map((check) => {
|
|
7343
5560
|
const parts = [check.state, check.context];
|
|
@@ -7420,34 +5637,10 @@ const prChecksCommand = defineCommand({
|
|
|
7420
5637
|
});
|
|
7421
5638
|
//#endregion
|
|
7422
5639
|
//#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
5640
|
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
5641
|
function capitalize(word) {
|
|
7441
5642
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
7442
5643
|
}
|
|
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
5644
|
function extractClosingRefs(messages) {
|
|
7452
5645
|
const byIssue = /* @__PURE__ */ new Map();
|
|
7453
5646
|
for (const message of messages) for (const [, keyword, issue] of message.matchAll(CLOSING_REF)) {
|
|
@@ -7460,44 +5653,13 @@ function extractClosingRefs(messages) {
|
|
|
7460
5653
|
}
|
|
7461
5654
|
return [...byIssue.values()];
|
|
7462
5655
|
}
|
|
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
5656
|
function closedIssues(text) {
|
|
7471
5657
|
return new Set(extractClosingRefs([text]).map((ref) => ref.issue.toLowerCase()));
|
|
7472
5658
|
}
|
|
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
5659
|
function droppedClosingRefs(messages, body) {
|
|
7483
5660
|
const closed = closedIssues(body);
|
|
7484
5661
|
return extractClosingRefs(messages).filter((ref) => !closed.has(ref.issue.toLowerCase()));
|
|
7485
5662
|
}
|
|
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
5663
|
function carryForwardClosingRefs(source, target) {
|
|
7502
5664
|
const missing = droppedClosingRefs([source], target);
|
|
7503
5665
|
if (missing.length === 0) return target;
|
|
@@ -7506,18 +5668,8 @@ function carryForwardClosingRefs(source, target) {
|
|
|
7506
5668
|
}
|
|
7507
5669
|
//#endregion
|
|
7508
5670
|
//#region ../forgejo/src/git.ts
|
|
7509
|
-
/** A git child that touches no network: `rev-parse`, `log`, `status`, `symbolic-ref`. */
|
|
7510
5671
|
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
5672
|
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
5673
|
function pickDefaultBranch(override, originHead) {
|
|
7522
5674
|
if (override !== void 0 && override !== "") return override;
|
|
7523
5675
|
if (originHead !== void 0) {
|
|
@@ -7526,7 +5678,6 @@ function pickDefaultBranch(override, originHead) {
|
|
|
7526
5678
|
}
|
|
7527
5679
|
return "main";
|
|
7528
5680
|
}
|
|
7529
|
-
/** `origin/HEAD`'s branch name (`origin/<name>`), or undefined when git cannot say. */
|
|
7530
5681
|
async function resolveOriginHead(git, cwd, signal = null) {
|
|
7531
5682
|
const result = await git.run([
|
|
7532
5683
|
"symbolic-ref",
|
|
@@ -7541,7 +5692,6 @@ async function resolveOriginHead(git, cwd, signal = null) {
|
|
|
7541
5692
|
const out = result.stdout.trim();
|
|
7542
5693
|
return out === "" ? void 0 : out;
|
|
7543
5694
|
}
|
|
7544
|
-
/** The checked-out branch. Throws when there is none: not a repository, or a detached HEAD. */
|
|
7545
5695
|
async function currentBranch(git, cwd, signal = null) {
|
|
7546
5696
|
const result = await git.run([
|
|
7547
5697
|
"rev-parse",
|
|
@@ -7557,11 +5707,6 @@ async function currentBranch(git, cwd, signal = null) {
|
|
|
7557
5707
|
if (branch === "HEAD") throw new FatalError("HEAD is detached. Check out a feature branch first.");
|
|
7558
5708
|
return branch;
|
|
7559
5709
|
}
|
|
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
5710
|
async function baseRef(git, cwd, base, signal = null) {
|
|
7566
5711
|
const remote = `origin/${base}`;
|
|
7567
5712
|
const result = await git.run([
|
|
@@ -7576,7 +5721,6 @@ async function baseRef(git, cwd, base, signal = null) {
|
|
|
7576
5721
|
});
|
|
7577
5722
|
return result.exitCode === 0 && result.stdout.trim() !== "" ? remote : base;
|
|
7578
5723
|
}
|
|
7579
|
-
/** The subjects of the commits on `ref..HEAD`, newest first, as `git log` prints them. */
|
|
7580
5724
|
async function commitSubjects(git, cwd, ref, signal = null) {
|
|
7581
5725
|
return (await git.run([
|
|
7582
5726
|
"log",
|
|
@@ -7588,18 +5732,6 @@ async function commitSubjects(git, cwd, ref, signal = null) {
|
|
|
7588
5732
|
signal
|
|
7589
5733
|
})).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
7590
5734
|
}
|
|
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
5735
|
async function commitMessages(git, cwd, ref, signal = null) {
|
|
7604
5736
|
const result = await git.run([
|
|
7605
5737
|
"log",
|
|
@@ -7614,7 +5746,6 @@ async function commitMessages(git, cwd, ref, signal = null) {
|
|
|
7614
5746
|
if (result.exitCode !== 0) throw new FatalError(`Failed to read commit messages: ${result.stderr || result.stdout}`);
|
|
7615
5747
|
return result.stdout.split("\0").map((message) => message.trim()).filter((message) => message !== "");
|
|
7616
5748
|
}
|
|
7617
|
-
/** Whether `git status --porcelain` lists anything. */
|
|
7618
5749
|
async function hasUncommittedChanges(git, cwd, signal = null) {
|
|
7619
5750
|
return (await git.run(["status", "--porcelain"], {
|
|
7620
5751
|
cwd,
|
|
@@ -7622,19 +5753,6 @@ async function hasUncommittedChanges(git, cwd, signal = null) {
|
|
|
7622
5753
|
signal
|
|
7623
5754
|
})).stdout.trim() !== "";
|
|
7624
5755
|
}
|
|
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
5756
|
async function pushBranch(git, cwd, branch, env, forceWithLease = false) {
|
|
7639
5757
|
const pushArgs = forceWithLease ? [
|
|
7640
5758
|
"push",
|
|
@@ -7662,7 +5780,6 @@ async function pushBranch(git, cwd, branch, env, forceWithLease = false) {
|
|
|
7662
5780
|
}
|
|
7663
5781
|
//#endregion
|
|
7664
5782
|
//#region ../forgejo/src/pr-upsert.ts
|
|
7665
|
-
/** Conventional-commit types recognized as a branch-name prefix. */
|
|
7666
5783
|
const COMMIT_TYPES = /* @__PURE__ */ new Set([
|
|
7667
5784
|
"feat",
|
|
7668
5785
|
"fix",
|
|
@@ -7676,16 +5793,9 @@ const COMMIT_TYPES = /* @__PURE__ */ new Set([
|
|
|
7676
5793
|
"style",
|
|
7677
5794
|
"revert"
|
|
7678
5795
|
]);
|
|
7679
|
-
/** Convert a segment to words by replacing dashes and underscores with spaces. */
|
|
7680
5796
|
function words(segment) {
|
|
7681
5797
|
return segment.replaceAll("-", " ").replaceAll("_", " ");
|
|
7682
5798
|
}
|
|
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
5799
|
function humanizeBranch(branch) {
|
|
7690
5800
|
const slash = branch.indexOf("/");
|
|
7691
5801
|
if (slash === -1) return words(branch);
|
|
@@ -7694,33 +5804,9 @@ function humanizeBranch(branch) {
|
|
|
7694
5804
|
if (COMMIT_TYPES.has(type)) return `${type}: ${words(rest)}`;
|
|
7695
5805
|
return `${words(type)} ${words(rest.replaceAll("/", " "))}`;
|
|
7696
5806
|
}
|
|
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
5807
|
function withWipPrefix(title, draft) {
|
|
7705
5808
|
return draft && !hasWipPrefix(title) ? `WIP: ${title}` : title;
|
|
7706
5809
|
}
|
|
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
5810
|
function derivePrContent(input) {
|
|
7725
5811
|
const [only] = input.subjects;
|
|
7726
5812
|
const derivedTitle = input.subjects.length === 1 && only !== void 0 ? only : humanizeBranch(input.branch);
|
|
@@ -7739,14 +5825,9 @@ function derivePrContent(input) {
|
|
|
7739
5825
|
droppedRefs: []
|
|
7740
5826
|
};
|
|
7741
5827
|
}
|
|
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
5828
|
var PrModeError = class extends FatalError {
|
|
7747
5829
|
reason;
|
|
7748
5830
|
branch;
|
|
7749
|
-
/** The open PR's number, or null when there is none. */
|
|
7750
5831
|
existing;
|
|
7751
5832
|
constructor(reason, branch, existing) {
|
|
7752
5833
|
super(reason === "already-open" ? `${branch} already has an open PR (#${String(existing)}).` : `${branch} has no open PR to update.`);
|
|
@@ -7755,61 +5836,13 @@ var PrModeError = class extends FatalError {
|
|
|
7755
5836
|
this.existing = existing;
|
|
7756
5837
|
}
|
|
7757
5838
|
};
|
|
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
5839
|
function assertModeAllows(mode, existing, branch) {
|
|
7764
5840
|
if (mode === "create" && existing !== null) throw new PrModeError("already-open", branch, existing);
|
|
7765
5841
|
if (mode === "update" && existing === null) throw new PrModeError("none-open", branch, null);
|
|
7766
5842
|
}
|
|
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
5843
|
function prUpdateNeedsLive(request) {
|
|
7784
5844
|
return request.title === void 0 || request.body === void 0 || request.draft === void 0 || request.managedBodyMarkers !== void 0;
|
|
7785
5845
|
}
|
|
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
5846
|
function planPrUpdate(request, derived, live, wipTitles) {
|
|
7814
5847
|
if (request.managedBodyMarkers !== void 0 && live !== null) return planManagedUpdate(request, derived, live, wipTitles);
|
|
7815
5848
|
const retitle = request.title === void 0 ? void 0 : derived.title;
|
|
@@ -7826,27 +5859,6 @@ function planPrUpdate(request, derived, live, wipTitles) {
|
|
|
7826
5859
|
previousBody: live?.body ?? null
|
|
7827
5860
|
};
|
|
7828
5861
|
}
|
|
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
5862
|
function planManagedUpdate(request, derived, live, wipTitles) {
|
|
7851
5863
|
const managed = (request.managedBodyMarkers ?? []).some((marker) => live.body.startsWith(marker));
|
|
7852
5864
|
const rewriteTitle = managed && request.title !== void 0;
|
|
@@ -7868,17 +5880,6 @@ function planManagedUpdate(request, derived, live, wipTitles) {
|
|
|
7868
5880
|
previousBody: live.body
|
|
7869
5881
|
};
|
|
7870
5882
|
}
|
|
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
5883
|
function reconcileWipTitle(wipTitles, draft, liveTitle) {
|
|
7883
5884
|
if (!wipTitles || draft === void 0) return liveTitle;
|
|
7884
5885
|
if (draft) return hasWipPrefix(liveTitle) ? liveTitle : `WIP: ${liveTitle}`;
|
|
@@ -7889,26 +5890,9 @@ function reconcileWipTitle(wipTitles, draft, liveTitle) {
|
|
|
7889
5890
|
}
|
|
7890
5891
|
return stripped;
|
|
7891
5892
|
}
|
|
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
5893
|
function keepWipPrefix(live, next) {
|
|
7904
5894
|
return hasWipPrefix(live) && !hasWipPrefix(next) ? `WIP: ${next}` : next;
|
|
7905
5895
|
}
|
|
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
5896
|
var LostClosingRefsError = class extends FatalError {
|
|
7913
5897
|
lost;
|
|
7914
5898
|
fromCommits;
|
|
@@ -7918,44 +5902,6 @@ var LostClosingRefsError = class extends FatalError {
|
|
|
7918
5902
|
this.fromCommits = fromCommits;
|
|
7919
5903
|
}
|
|
7920
5904
|
};
|
|
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
5905
|
function checkClosingLinks(messages, plan, allowUnlink) {
|
|
7960
5906
|
const fromCommits = droppedClosingRefs(messages, plan.effective.body);
|
|
7961
5907
|
if (plan.previousBody === null) return {
|
|
@@ -7970,16 +5916,6 @@ function checkClosingLinks(messages, plan, allowUnlink) {
|
|
|
7970
5916
|
};
|
|
7971
5917
|
}
|
|
7972
5918
|
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
5919
|
async function runPrUpsert(git, host, options) {
|
|
7984
5920
|
const { cwd } = options;
|
|
7985
5921
|
const mode = options.mode ?? "upsert";
|
|
@@ -8053,12 +5989,6 @@ async function runPrUpsert(git, host, options) {
|
|
|
8053
5989
|
}
|
|
8054
5990
|
//#endregion
|
|
8055
5991
|
//#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
5992
|
function describePrModeError(error, commands) {
|
|
8063
5993
|
if (error.reason === "already-open") {
|
|
8064
5994
|
const createVerb = commands.create.split(" ").at(-1) ?? commands.create;
|
|
@@ -8066,11 +5996,6 @@ function describePrModeError(error, commands) {
|
|
|
8066
5996
|
}
|
|
8067
5997
|
return `${error.branch} has no open PR to update -- use \`${commands.create}\` to open one.`;
|
|
8068
5998
|
}
|
|
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
5999
|
function describeClosingLinks(report, flags) {
|
|
8075
6000
|
const lines = [];
|
|
8076
6001
|
if (report.fromCommits.length > 0) {
|
|
@@ -8083,19 +6008,9 @@ function describeClosingLinks(report, flags) {
|
|
|
8083
6008
|
}
|
|
8084
6009
|
return lines;
|
|
8085
6010
|
}
|
|
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
6011
|
function describeLostClosingRefs(error, flags) {
|
|
8091
6012
|
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
6013
|
}
|
|
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
6014
|
function rewordPrUpsertError(error, options) {
|
|
8100
6015
|
if (error instanceof PrModeError) return new FatalError(describePrModeError(error, options.commands));
|
|
8101
6016
|
if (error instanceof LostClosingRefsError) {
|
|
@@ -8117,7 +6032,6 @@ function prLabel(result) {
|
|
|
8117
6032
|
function isDryRun(result) {
|
|
8118
6033
|
return result.action === "would-create" || result.action === "would-update";
|
|
8119
6034
|
}
|
|
8120
|
-
/** The summary of a create, which an upsert may have turned into an update. */
|
|
8121
6035
|
function formatPrCreate(result) {
|
|
8122
6036
|
const opening = result.action === "created" || result.action === "would-create";
|
|
8123
6037
|
if (isDryRun(result)) {
|
|
@@ -8137,11 +6051,6 @@ function formatPrCreate(result) {
|
|
|
8137
6051
|
body: null
|
|
8138
6052
|
};
|
|
8139
6053
|
}
|
|
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
6054
|
function formatPrUpdate(result, rewrites, flags) {
|
|
8146
6055
|
if (isDryRun(result)) return {
|
|
8147
6056
|
lines: [`[dry-run] Would push ${prTarget(result)} to ${prLabel(result)}`],
|
|
@@ -8160,10 +6069,6 @@ function formatPrUpdate(result, rewrites, flags) {
|
|
|
8160
6069
|
}
|
|
8161
6070
|
//#endregion
|
|
8162
6071
|
//#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
6072
|
function forgejoPrHost(fetcher, conn) {
|
|
8168
6073
|
return {
|
|
8169
6074
|
wipTitles: true,
|
|
@@ -8187,11 +6092,6 @@ function forgejoPrHost(fetcher, conn) {
|
|
|
8187
6092
|
}
|
|
8188
6093
|
};
|
|
8189
6094
|
}
|
|
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
6095
|
function forgejoPrEditHost(fetcher, conn) {
|
|
8196
6096
|
return {
|
|
8197
6097
|
wipTitles: true,
|
|
@@ -8203,14 +6103,8 @@ function forgejoPrEditHost(fetcher, conn) {
|
|
|
8203
6103
|
}
|
|
8204
6104
|
//#endregion
|
|
8205
6105
|
//#region src/git.ts
|
|
8206
|
-
/** Exit codes reported when this module ends a child itself, following the shell's convention. */
|
|
8207
6106
|
const TIMED_OUT_EXIT_CODE = 124;
|
|
8208
6107
|
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
6108
|
function cliGit() {
|
|
8215
6109
|
return { run: (args, { cwd, env, timeoutMs, signal }) => new Promise((resolve, reject) => {
|
|
8216
6110
|
if (signal?.aborted === true) {
|
|
@@ -8297,11 +6191,6 @@ const FLAGS = {
|
|
|
8297
6191
|
function warnClosingLinks(report) {
|
|
8298
6192
|
for (const line of describeClosingLinks(report, FLAGS)) rootLogger.warn(line);
|
|
8299
6193
|
}
|
|
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
6194
|
async function runForgejoPr(githubRefusal, options) {
|
|
8306
6195
|
const cwd = process.cwd();
|
|
8307
6196
|
const conn = requireForgejoTarget(cwd, void 0, githubRefusal);
|
|
@@ -8396,22 +6285,13 @@ const prCreateCommand = defineCommand({
|
|
|
8396
6285
|
});
|
|
8397
6286
|
//#endregion
|
|
8398
6287
|
//#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
6288
|
const DEFAULT_LIMIT = 100;
|
|
8404
6289
|
const PrFileEntrySchema = z.object({
|
|
8405
6290
|
filename: z.string(),
|
|
8406
|
-
/** Forgejo's swagger does not enumerate this, so it stays an open string. */
|
|
8407
6291
|
status: z.string().nullish(),
|
|
8408
6292
|
additions: z.number().default(0),
|
|
8409
6293
|
deletions: z.number().default(0)
|
|
8410
6294
|
});
|
|
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
6295
|
async function listPrFiles(executor, conn, prNumber, options) {
|
|
8416
6296
|
return readPrFilesPage(executor, conn, prNumber, 1, options?.limit ?? DEFAULT_LIMIT);
|
|
8417
6297
|
}
|
|
@@ -8436,10 +6316,6 @@ async function readPrFilesPage(executor, conn, prNumber, page, limit) {
|
|
|
8436
6316
|
}
|
|
8437
6317
|
//#endregion
|
|
8438
6318
|
//#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
6319
|
function parsePrNumber(raw) {
|
|
8444
6320
|
const value = Number(raw);
|
|
8445
6321
|
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) throw new FatalError(`Invalid pull request number "${raw}" (expected a positive integer).`);
|
|
@@ -8447,16 +6323,10 @@ function parsePrNumber(raw) {
|
|
|
8447
6323
|
}
|
|
8448
6324
|
//#endregion
|
|
8449
6325
|
//#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
6326
|
function runPrFiles(fetcher, options) {
|
|
8456
6327
|
const number = parsePrNumber(options.number);
|
|
8457
6328
|
return listPrFiles(fetcher, requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:files is Forgejo-only -- gh pr diff --name-only already serves GitHub."), number);
|
|
8458
6329
|
}
|
|
8459
|
-
/** Human-readable rendering: `status filename` per file, then a note when more files exist. */
|
|
8460
6330
|
function renderPrFiles(listing) {
|
|
8461
6331
|
const lines = listing.files.map((file) => `${file.status ?? "unknown"} ${file.filename}`);
|
|
8462
6332
|
if (lines.length === 0) lines.push("No changed files.");
|
|
@@ -8505,12 +6375,6 @@ const prFilesCommand = defineCommand({
|
|
|
8505
6375
|
});
|
|
8506
6376
|
//#endregion
|
|
8507
6377
|
//#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
6378
|
function parseState(state) {
|
|
8515
6379
|
if (state === "open" || state === "closed" || state === "all") return state;
|
|
8516
6380
|
throw new FatalError(`Invalid --state "${state}" (expected open, closed, or all).`);
|
|
@@ -8520,10 +6384,6 @@ function parseLimit(raw) {
|
|
|
8520
6384
|
if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
|
|
8521
6385
|
return limit;
|
|
8522
6386
|
}
|
|
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
6387
|
async function runPrList(fetcher, options) {
|
|
8528
6388
|
const state = parseState(options.state);
|
|
8529
6389
|
const limit = parseLimit(options.limit);
|
|
@@ -8547,7 +6407,6 @@ function statusLabel(pr) {
|
|
|
8547
6407
|
if (pr.draft === true) return `${pr.state}/draft`;
|
|
8548
6408
|
return pr.state;
|
|
8549
6409
|
}
|
|
8550
|
-
/** Human-readable rendering: one line per PR, most recently updated first. */
|
|
8551
6410
|
function renderPrList(result) {
|
|
8552
6411
|
if (result.pulls.length === 0) return "No pull requests.";
|
|
8553
6412
|
return result.pulls.map((pr) => {
|
|
@@ -8603,16 +6462,6 @@ const prListCommand = defineCommand({
|
|
|
8603
6462
|
});
|
|
8604
6463
|
//#endregion
|
|
8605
6464
|
//#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
6465
|
async function mergePr(executor, conn, prNumber, options) {
|
|
8617
6466
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
|
|
8618
6467
|
const body = {
|
|
@@ -8644,12 +6493,6 @@ const MergeGateSchema = z.object({
|
|
|
8644
6493
|
labels: z.array(z.object({ name: z.string() })).nullable(),
|
|
8645
6494
|
updated_at: z.string().nullish()
|
|
8646
6495
|
});
|
|
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
6496
|
async function readMergeGate(executor, conn, prNumber) {
|
|
8654
6497
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
|
|
8655
6498
|
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
@@ -8671,15 +6514,6 @@ async function readMergeGate(executor, conn, prNumber) {
|
|
|
8671
6514
|
updatedAt: parsed.data.updated_at ?? null
|
|
8672
6515
|
};
|
|
8673
6516
|
}
|
|
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
6517
|
async function confirmMergeable(executor, conn, prNumber, options) {
|
|
8684
6518
|
const firstGate = await readMergeGate(executor, conn, prNumber);
|
|
8685
6519
|
if (firstGate.mergeable) return {
|
|
@@ -8732,18 +6566,11 @@ z.object({
|
|
|
8732
6566
|
});
|
|
8733
6567
|
//#endregion
|
|
8734
6568
|
//#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
6569
|
const METHODS = [
|
|
8742
6570
|
"merge",
|
|
8743
6571
|
"rebase",
|
|
8744
6572
|
"squash"
|
|
8745
6573
|
];
|
|
8746
|
-
/** Long enough for Forgejo to finish its own mergeability check after a first `false`. */
|
|
8747
6574
|
const RECHECK_DELAY_MS = 2e3;
|
|
8748
6575
|
function parseMethod(raw) {
|
|
8749
6576
|
if (raw === void 0) return "merge";
|
|
@@ -8751,8 +6578,6 @@ function parseMethod(raw) {
|
|
|
8751
6578
|
if (method === void 0) throw new FatalError(`Invalid --method "${raw}" (expected one of: ${METHODS.join(", ")}).`);
|
|
8752
6579
|
return method;
|
|
8753
6580
|
}
|
|
8754
|
-
/** Confirm, check, and (unless `dryRun`) merge. Throws a `FatalError` naming the reason for every
|
|
8755
|
-
* refusal, before any merge request. */
|
|
8756
6581
|
async function runPrMerge(fetcher, options) {
|
|
8757
6582
|
const number = parsePrNumber(options.number);
|
|
8758
6583
|
const method = parseMethod(options.method);
|
|
@@ -8789,7 +6614,6 @@ async function runPrMerge(fetcher, options) {
|
|
|
8789
6614
|
merged: true
|
|
8790
6615
|
};
|
|
8791
6616
|
}
|
|
8792
|
-
/** Human-readable rendering: one line saying what was merged, or what a dry run would merge. */
|
|
8793
6617
|
function renderPrMerge(result) {
|
|
8794
6618
|
const what = `PR #${String(result.number)} (${result.method}) at ${result.headSha.slice(0, 12)}, checks ${result.checks}`;
|
|
8795
6619
|
return result.merged ? `Merged ${what}.` : `Dry run: would merge ${what}.`;
|
|
@@ -8853,19 +6677,7 @@ const prMergeCommand = defineCommand({
|
|
|
8853
6677
|
});
|
|
8854
6678
|
//#endregion
|
|
8855
6679
|
//#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
6680
|
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
6681
|
function isOpen(live) {
|
|
8870
6682
|
return live.state === "open" && live.merged === false;
|
|
8871
6683
|
}
|
|
@@ -8873,16 +6685,6 @@ function notOpenMessage(prNumber, live) {
|
|
|
8873
6685
|
const what = live.merged ? "merged" : live.state;
|
|
8874
6686
|
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
6687
|
}
|
|
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
6688
|
async function runPrRetarget(host, options) {
|
|
8887
6689
|
const live = await host.readLive(options.number);
|
|
8888
6690
|
if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
|
|
@@ -8927,17 +6729,6 @@ async function runPrRetarget(host, options) {
|
|
|
8927
6729
|
dryRun: false
|
|
8928
6730
|
};
|
|
8929
6731
|
}
|
|
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
6732
|
async function runPrReady(host, options) {
|
|
8942
6733
|
const live = await host.readLive(options.number);
|
|
8943
6734
|
if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
|
|
@@ -8995,7 +6786,6 @@ function mergeabilityLine(mergeable) {
|
|
|
8995
6786
|
if (mergeable === false) return "Mergeability: not mergeable -- advice, not a verdict.";
|
|
8996
6787
|
return "Mergeability: unknown -- the forge has not computed it.";
|
|
8997
6788
|
}
|
|
8998
|
-
/** What happened to the base, and the mergeability reading as advice. */
|
|
8999
6789
|
function renderPrRetarget(result) {
|
|
9000
6790
|
const number = `#${String(result.number)}`;
|
|
9001
6791
|
const lines = [];
|
|
@@ -9005,7 +6795,6 @@ function renderPrRetarget(result) {
|
|
|
9005
6795
|
lines.push(mergeabilityLine(result.mergeable));
|
|
9006
6796
|
return lines.join("\n");
|
|
9007
6797
|
}
|
|
9008
|
-
/** What happened to the draft state and the title. */
|
|
9009
6798
|
function renderPrReady(result) {
|
|
9010
6799
|
const number = `#${String(result.number)}`;
|
|
9011
6800
|
if (result.action === "readied") return `Pull request ${number} is no longer a draft, reading as "${result.title}".`;
|