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