@bensandee/forgejo-cli 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/bin.mjs +784 -106
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -55,6 +55,8 @@ command takes `--json`, which prints exactly one JSON document, failures include
55
55
  | `pr:files` | List the files a pull request changes |
56
56
  | `pr:merge` | Merge an open, non-draft, mergeable PR pinned to the head it read; refuses closed, draft, conflicted, unconfirmed or non-green checks (zero statuses included) unless `--ignore-checks`; `--delete-branch` is opt-in |
57
57
  | `workflow:dispatch` | Dispatch a workflow with `--workflow`, `--ref` and repeatable `--input`; takes `--repo owner/repo`, not a URL |
58
+ | `ci:runs` | List a repository's workflow runs with their jobs, filtered by workflow, ref, pull request, head SHA or status |
59
+ | `ci:log` | Print one workflow job's whole log, or with `--grep` only the lines whose text matches |
58
60
 
59
61
  Zero posted statuses reads as `no-status`, never as green. Every command is Forgejo-only: on
60
62
  GitHub, use `gh`. The `issue:*`, `pr:create` and `pr:update` commands take the same flags and print the
package/dist/bin.mjs CHANGED
@@ -245,7 +245,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
245
245
  //#region src/version.ts
246
246
  const parsed = z.object({ version: z.string() }).safeParse({
247
247
  name: "@bensandee/forgejo-cli",
248
- version: "0.2.0",
248
+ version: "0.3.0",
249
249
  description: "bst-fj: Forgejo API commands without the rest of bst",
250
250
  license: "MIT",
251
251
  repository: {
@@ -1039,11 +1039,17 @@ function jsonDocumentWritten() {
1039
1039
  //#endregion
1040
1040
  //#region ../cli-kit/src/path-probe.ts
1041
1041
  /** The real {@link PathKind} probe the pre-run argument check uses. A path that exists but is not
1042
- * a directory (a file, a socket) reads as `"file"`. */
1042
+ * a directory (a file, a socket) reads as `"file"`. A path stat cannot resolve at all -- a symlink
1043
+ * loop, an unsearchable parent -- reads as `"missing"`: `throwIfNoEntry` covers ENOENT and ENOTDIR
1044
+ * only, and a stat failure here would surface as a crash where the check owes a usage error. */
1043
1045
  function probeCliPath(value) {
1044
- const stat = statSync(value, { throwIfNoEntry: false });
1045
- if (stat === void 0) return "missing";
1046
- return stat.isDirectory() ? "directory" : "file";
1046
+ try {
1047
+ const stat = statSync(value, { throwIfNoEntry: false });
1048
+ if (stat === void 0) return "missing";
1049
+ return stat.isDirectory() ? "directory" : "file";
1050
+ } catch {
1051
+ return "missing";
1052
+ }
1047
1053
  }
1048
1054
  //#endregion
1049
1055
  //#region ../cli-kit/src/run.ts
@@ -2325,7 +2331,7 @@ const commitStatusCommand = defineCommand({
2325
2331
  },
2326
2332
  json: {
2327
2333
  type: "boolean",
2328
- description: "Emit the machine-readable report on stdout. Keys: sha, ref, repository, url, status (green | red | pending | no-status | unparseable | timeout), settled, combinedState, waited, elapsedMs, checks (each: context, name, state, verdict, description, targetUrl, updatedAt; description, targetUrl and updatedAt can be null), detail? (unparseable only). no-status means no check was posted and is never green"
2334
+ description: "Emit one JSON document: sha, ref, repository, url, status (green | red | pending | no-status | unparseable | timeout), settled, combinedState, waited, elapsedMs, checks ([{context, name, state, verdict, description (or null), targetUrl (or null), updatedAt (or null)}]), detail? (unparseable only). no-status means no check was posted and is never green."
2329
2335
  },
2330
2336
  verbose: {
2331
2337
  type: "boolean",
@@ -3080,7 +3086,7 @@ const listCommand$1 = defineCommand({
3080
3086
  },
3081
3087
  json: {
3082
3088
  type: "boolean",
3083
- description: "Emit results as JSON instead of a human-readable list"
3089
+ description: "Emit one JSON document: repository, labels ([{id, name, color?, description?, exclusive?}])."
3084
3090
  }
3085
3091
  },
3086
3092
  async run({ args, rawArgs }) {
@@ -3134,7 +3140,7 @@ const syncCommand = defineCommand({
3134
3140
  },
3135
3141
  json: {
3136
3142
  type: "boolean",
3137
- description: "Emit results as JSON instead of a human-readable summary. Keys: set, mode (apply | check | dry-run), repos (each: repository, created, updated, unchanged, omitted, stale), failures (each: repository, error), drifted (boolean; sets the exit code only under --check)"
3143
+ description: "Emit one JSON document: set, mode (apply | check | dry-run), repos ([{repository, created, updated, unchanged, omitted, stale}], every field but repository a list of label names), failures ([{repository, error}]; always present, filled only under --fleet), drifted. drifted is true when any label is missing or drifted, and sets the exit code only under --check."
3138
3144
  }
3139
3145
  },
3140
3146
  async run({ args, rawArgs }) {
@@ -3174,7 +3180,7 @@ const labelsCommand = defineCommand({
3174
3180
  },
3175
3181
  args: { json: {
3176
3182
  type: "boolean",
3177
- description: "Emit results as JSON instead of a human-readable summary"
3183
+ description: "Emit one JSON document; the keys are the subcommand's (see labels list --help and labels sync --help)."
3178
3184
  } },
3179
3185
  subCommands: {
3180
3186
  list: listCommand$1,
@@ -3504,7 +3510,7 @@ const releaseCreateCommand = defineCommand({
3504
3510
  },
3505
3511
  json: {
3506
3512
  type: "boolean",
3507
- description: "Emit the release result as JSON. Keys: status (created | exists | race | repaired), tag, target, prerelease (boolean), release ({ id, url }, where url can be null)"
3513
+ description: "Emit one JSON document: status (created | exists | race | repaired), tag, target, prerelease, release ({id, url (or null)})."
3508
3514
  }
3509
3515
  },
3510
3516
  async run({ args }) {
@@ -3697,7 +3703,7 @@ const setCommand = defineCommand({
3697
3703
  },
3698
3704
  json: {
3699
3705
  type: "boolean",
3700
- description: "Emit results as JSON instead of a human-readable summary. Keys: status (set | would-set), name, repository. The secret value is never included"
3706
+ description: "Emit one JSON document: status (set | would-set), name, repository. The secret value is never included."
3701
3707
  }
3702
3708
  },
3703
3709
  async run({ args }) {
@@ -3726,7 +3732,7 @@ const listCommand = defineCommand({
3726
3732
  },
3727
3733
  json: {
3728
3734
  type: "boolean",
3729
- description: "Emit results as JSON instead of a human-readable summary. Keys: repository, secrets (an array of secret names). Values are never returned"
3735
+ description: "Emit one JSON document: repository, secrets (secret names). Values are never returned."
3730
3736
  }
3731
3737
  },
3732
3738
  async run({ args }) {
@@ -3760,7 +3766,7 @@ const deleteCommand = defineCommand({
3760
3766
  },
3761
3767
  json: {
3762
3768
  type: "boolean",
3763
- description: "Emit results as JSON instead of a human-readable summary. Keys: status (deleted | would-delete), name, repository"
3769
+ description: "Emit one JSON document: status (deleted | would-delete), name, repository."
3764
3770
  }
3765
3771
  },
3766
3772
  async run({ args }) {
@@ -3781,7 +3787,7 @@ const secretsCommand = defineCommand({
3781
3787
  },
3782
3788
  args: { json: {
3783
3789
  type: "boolean",
3784
- description: "Emit results as JSON instead of a human-readable summary"
3790
+ description: "Emit one JSON document; the keys are the subcommand's (see secrets set --help, secrets list --help and secrets delete --help)."
3785
3791
  } },
3786
3792
  subCommands: {
3787
3793
  set: setCommand,
@@ -3986,7 +3992,7 @@ const webhooksCommand = defineCommand({
3986
3992
  },
3987
3993
  args: { json: {
3988
3994
  type: "boolean",
3989
- description: "Emit results as JSON instead of a human-readable summary"
3995
+ description: "Emit one JSON document: user, isAdmin, groups ([{label, hooks ([{id, type, active, config?}])}]), totalCount."
3990
3996
  } },
3991
3997
  async run({ args }) {
3992
3998
  const conn = requireForgejoTarget(process.cwd(), void 0, "bst-fj webhooks requires a Forgejo repository");
@@ -3999,48 +4005,241 @@ const webhooksCommand = defineCommand({
3999
4005
  }
4000
4006
  });
4001
4007
  //#endregion
4002
- //#region ../cli-kit/src/workflow-inputs.ts
4003
- const INPUT_FLAG = "--input";
4004
- const INPUT_FLAG_EQ = "--input=";
4008
+ //#region ../forgejo/src/actions.ts
4005
4009
  /**
4006
- * Collect every `--input` value from a command's raw CLI args.
4010
+ * The Actions reads: the one place in this package that talks to Forgejo's Actions surface, beside
4011
+ * `secrets.ts` which already speaks to `/actions/`. What used to be one composed read is now the
4012
+ * pieces that compose it -- {@link getActionsRunByNumber}, {@link listActionsRunJobs},
4013
+ * {@link fetchActionsJobLogById} -- with {@link fetchActionsJobLog} kept as the composed read its
4014
+ * existing callers use, and {@link listActionsRuns} beside them as the paged listing.
4007
4015
  *
4008
- * citty@0.2.2 keeps only the LAST occurrence of a repeated named flag, so a
4009
- * declared `input` arg cannot capture `--input a=1 --input b=2` -- it silently
4010
- * yields just `b=2`. Scanning the raw argv directly recovers all occurrences.
4011
- * Both `--input k=v` (two tokens) and `--input=k=v` (one token) forms are
4012
- * supported. Throws {@link FatalError} when `--input` has no following value.
4016
+ * **Checked against a live instance (2026-09-29).** A status entry's `targetUrl` carries
4017
+ * `{run, index}` and `run` there is the repo's
4018
+ * run **number**, not the run id the API's `/actions/runs/{id}` paths take -- passing the number
4019
+ * as an id silently reads an unrelated, older run. So the resolution is three reads: the run by
4020
+ * `?run_number=`, its jobs (a bare array, in the web UI's `/jobs/<index>` order), then the job's
4021
+ * log. It is **not** derived from `GET .../actions/tasks`, which was measured returning an
4022
+ * unrelated job's log for the same input. Every caller of {@link fetchActionsJobLog} must fail open on any error --
4023
+ * this module throws on one rather than swallowing it, exactly like every other read in this
4024
+ * package, so the caller's fail-open decision is made once, in one place, rather than half-hidden
4025
+ * in here.
4026
+ */
4027
+ /**
4028
+ * A forge timestamp as the instance wrote it, or `null`. Nullish and empty are `null`, and so is
4029
+ * any time at or before the Unix epoch: the instance writes the epoch (in its own zone, such as
4030
+ * `1969-12-31T18:00:00-06:00`) for a run that has not started, and Go's zero time (`0001-...`)
4031
+ * elsewhere. Passing either through would make every duration derived from it meaningless.
4032
+ */
4033
+ function forgeTimestamp(value) {
4034
+ if (value === null || value === void 0 || value === "") return null;
4035
+ const ms = Date.parse(value);
4036
+ if (!Number.isFinite(ms) || ms <= 0) return null;
4037
+ return value;
4038
+ }
4039
+ const ActionsRunRecordSchema = z.object({
4040
+ id: z.number(),
4041
+ index_in_repo: z.number().nullish(),
4042
+ workflow_id: z.string().nullish(),
4043
+ title: z.string().nullish(),
4044
+ prettyref: z.string().nullish(),
4045
+ commit_sha: z.string().nullish(),
4046
+ event: z.string().nullish(),
4047
+ status: z.string().nullish(),
4048
+ started: z.string().nullish(),
4049
+ stopped: z.string().nullish(),
4050
+ /** Nanoseconds (a Go `time.Duration`); absent until the run finishes. */
4051
+ duration: z.number().nullish(),
4052
+ html_url: z.string().nullish()
4053
+ });
4054
+ const RunLookupResponseSchema = z.object({ workflow_runs: z.array(ActionsRunRecordSchema) });
4055
+ const ActionsJobRecordSchema = z.object({
4056
+ id: z.number(),
4057
+ name: z.string(),
4058
+ status: z.string()
4059
+ });
4060
+ const RunJobsResponseSchema = z.array(ActionsJobRecordSchema);
4061
+ function toActionsRun(record) {
4062
+ const duration = record.duration ?? null;
4063
+ return {
4064
+ id: record.id,
4065
+ number: record.index_in_repo ?? null,
4066
+ workflow: record.workflow_id ?? null,
4067
+ title: record.title ?? null,
4068
+ ref: record.prettyref ?? null,
4069
+ headSha: record.commit_sha ?? null,
4070
+ event: record.event ?? null,
4071
+ status: record.status ?? null,
4072
+ startedAt: forgeTimestamp(record.started),
4073
+ completedAt: forgeTimestamp(record.stopped),
4074
+ durationMs: duration === null || duration <= 0 ? null : Math.round(duration / 1e6),
4075
+ url: record.html_url ?? null
4076
+ };
4077
+ }
4078
+ /** A ceiling for a reader of the whole log, passed as `{ head: MAX_WHOLE_LOG_CHARS }`: well over
4079
+ * the 5 MB logs measured on this repo, and low enough that no reader receives an unbounded body. */
4080
+ const MAX_WHOLE_LOG_CHARS = 67108864;
4081
+ /**
4082
+ * Look a run up by the number the web UI and every `targetUrl` use, and report the whole record
4083
+ * rather than only its id. Sends `page` and `limit` with the filter -- without them the instance
4084
+ * answers a `run_number` query with every run it holds.
4085
+ *
4086
+ * No run with that number is a {@link FatalError}: the caller named something that does not
4087
+ * exist. A record whose number is not the one asked for is an {@link UnexpectedError}, because
4088
+ * an instance that ignored the filter would otherwise hand back its newest run under the
4089
+ * caller's number. Also throws on a non-2xx or an unparseable response.
4090
+ */
4091
+ async function getActionsRunByNumber(executor, conn, runNumber) {
4092
+ const params = new URLSearchParams({
4093
+ run_number: runNumber,
4094
+ page: "1",
4095
+ limit: "1"
4096
+ });
4097
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/runs?${params.toString()}`;
4098
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
4099
+ if (!res.ok) await throwForgejoFailure(res, {
4100
+ verb: "look up run",
4101
+ resource: runNumber
4102
+ });
4103
+ const parsed = RunLookupResponseSchema.safeParse(await res.json());
4104
+ if (!parsed.success) throw new UnexpectedError(`Unexpected run lookup response for run ${runNumber}: ${parsed.error.message}`);
4105
+ const [run] = parsed.data.workflow_runs;
4106
+ if (run === void 0) throw new FatalError(`No run numbered ${runNumber} in ${conn.repository}`);
4107
+ const found = toActionsRun(run);
4108
+ if (found.number !== null && String(found.number) !== runNumber) throw new UnexpectedError(`Asked ${conn.repository} for run ${runNumber} and got run ${String(found.number)}: the forge ignored the run_number filter`);
4109
+ return found;
4110
+ }
4111
+ /**
4112
+ * A run's jobs, as a bare array, with `index` set to array position -- the same order the web UI's
4113
+ * `/jobs/<index>` links use. Throws on a non-2xx, an unparseable response, or a job record without
4114
+ * the three fields this module requires.
4013
4115
  */
4014
- function collectInputArgs(rawArgs) {
4015
- const values = [];
4016
- for (let i = 0; i < rawArgs.length; i += 1) {
4017
- const arg = rawArgs[i];
4018
- if (arg === void 0) continue;
4019
- if (arg === INPUT_FLAG) {
4020
- const next = rawArgs[i + 1];
4021
- if (next === void 0) throw new FatalError("Missing value for --input: expected `--input key=value`");
4022
- values.push(next);
4023
- i += 1;
4024
- } else if (arg.startsWith(INPUT_FLAG_EQ)) values.push(arg.slice(8));
4025
- }
4026
- return values;
4116
+ async function listActionsRunJobs(executor, conn, runId) {
4117
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/runs/${String(runId)}/jobs`;
4118
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
4119
+ if (!res.ok) await throwForgejoFailure(res, {
4120
+ verb: "list run jobs",
4121
+ resource: String(runId)
4122
+ });
4123
+ const parsed = RunJobsResponseSchema.safeParse(await res.json());
4124
+ if (!parsed.success) throw new UnexpectedError(`Unexpected run-jobs response for run ${String(runId)}: ${parsed.error.message}`);
4125
+ const jobs = [];
4126
+ for (const [index, job] of parsed.data.entries()) jobs.push({
4127
+ index,
4128
+ id: job.id,
4129
+ name: job.name,
4130
+ status: job.status
4131
+ });
4132
+ return jobs;
4027
4133
  }
4028
4134
  /**
4029
- * Parse `key=value` workflow-input strings (as collected by
4030
- * {@link collectInputArgs}) into an inputs object. Throws {@link FatalError} on
4031
- * a malformed entry -- no `=`, or an empty key.
4135
+ * Read one job's log by the numeric id the log endpoint wants, bounded by `bound`. Never throws on
4136
+ * a long log: the bound is what keeps a 68 MB-class response from reaching a caller, and
4137
+ * {@link ActionsJobLogText.truncated} says when it cut something. Throws on a non-2xx.
4032
4138
  */
4033
- function parseInputs(raw) {
4034
- const inputs = {};
4035
- for (const value of raw) {
4036
- const eq = value.indexOf("=");
4037
- if (eq <= 0) throw new FatalError(`Invalid --input "${value}": expected key=value`);
4038
- inputs[value.slice(0, eq)] = value.slice(eq + 1);
4139
+ async function fetchActionsJobLogById(executor, conn, jobId, bound) {
4140
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/jobs/${String(jobId)}/logs`;
4141
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
4142
+ if (!res.ok) await throwForgejoFailure(res, {
4143
+ verb: "read job log",
4144
+ resource: String(jobId)
4145
+ });
4146
+ const text = await res.text();
4147
+ if ("head" in bound) return {
4148
+ text: text.slice(0, bound.head),
4149
+ truncated: text.length > bound.head
4150
+ };
4151
+ const tail = bound.tail;
4152
+ if (text.length <= tail) return {
4153
+ text,
4154
+ truncated: false
4155
+ };
4156
+ return {
4157
+ text: text.slice(text.length - tail),
4158
+ truncated: true
4159
+ };
4160
+ }
4161
+ const RunsListResponseSchema = z.object({ workflow_runs: z.array(ActionsRunRecordSchema) });
4162
+ /**
4163
+ * The repository's workflow runs, newest first, paged **always** (spec decision 9). Every request
4164
+ * carries `page` and `limit`: measured against a live instance (2026-09-30), a runs query without
4165
+ * `page` ignores `limit` and answers with the whole table -- 3443 runs, 68 MB -- so paging is
4166
+ * structural here rather than a caller's obligation.
4167
+ *
4168
+ * The walk stops on an empty page or once `limit` rows are in hand. A page **shorter** than asked
4169
+ * for does not stop it: the server may clamp `limit` to its own page size, and reading a short page
4170
+ * as the end of the table would silently answer about part of it. Hitting the page cap with rows
4171
+ * still arriving warns, because a truncated listing otherwise reads as absence.
4172
+ */
4173
+ async function listActionsRuns(executor, conn, options) {
4174
+ const headers = { Authorization: `token ${conn.token}` };
4175
+ const pageSize = Math.min(options.limit, 50);
4176
+ const runs = [];
4177
+ for (let page = 1; page <= 20; page++) {
4178
+ const params = new URLSearchParams({
4179
+ page: String(page),
4180
+ limit: String(pageSize)
4181
+ });
4182
+ if (options.workflow !== void 0) params.set("workflow_id", options.workflow);
4183
+ if (options.ref !== void 0) params.set("ref", options.ref);
4184
+ if (options.headSha !== void 0) params.set("head_sha", options.headSha);
4185
+ if (options.status !== void 0) params.set("status", options.status);
4186
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/runs?${params.toString()}`;
4187
+ const res = await executor.fetch(url, { headers });
4188
+ if (!res.ok) await throwForgejoFailure(res, {
4189
+ verb: "list workflow runs",
4190
+ resource: conn.repository
4191
+ });
4192
+ const parsed = RunsListResponseSchema.safeParse(await res.json());
4193
+ if (!parsed.success) throw new UnexpectedError(`Unexpected workflow-runs response for ${conn.repository}: ${parsed.error.message}`);
4194
+ const pageRuns = parsed.data.workflow_runs.map(toActionsRun);
4195
+ runs.push(...pageRuns);
4196
+ if (pageRuns.length === 0) break;
4197
+ if (runs.length >= options.limit) break;
4198
+ if (page === 20) {
4199
+ log.warn(`Workflow run listing on ${conn.repository} hit the ${String(20)}-page cap without an empty page; results may be truncated`);
4200
+ options.onPageCap?.();
4201
+ }
4039
4202
  }
4040
- return inputs;
4203
+ return runs.slice(0, options.limit);
4204
+ }
4205
+ /** A `targetUrl` path of the shape `/actions/runs/{run}/jobs/{index}`, tolerating a scheme and
4206
+ * host in front of it and a trailing path segment, query, or fragment after it. */
4207
+ const ACTIONS_JOB_TARGET_URL = /\/actions\/runs\/(\d+)\/jobs\/(\d+)(?:[/?#]|$)/u;
4208
+ /**
4209
+ * Parse a status entry's `targetUrl` into the `{run, index}` the job-log read needs. A `targetUrl`
4210
+ * of any other shape -- including `null` -- yields `null`, full stop: **the id is never derived
4211
+ * from the `/actions/tasks` listing**, which was measured (against a live instance, in an earlier
4212
+ * investigation) returning an unrelated job's log for the same input. This module's job-log read
4213
+ * accepts nothing but this parser's output for exactly that reason.
4214
+ *
4215
+ * The parser lives here rather than beside the incident sweep that first needed it (spec decision
4216
+ * 15) because a caller holding a pasted `targetUrl` must be able to read the same object, the URL
4217
+ * shape is this package's domain, and a second copy in a command-line tool would be a forked
4218
+ * declaration.
4219
+ */
4220
+ function parseActionsJobTargetUrl(targetUrl) {
4221
+ if (targetUrl === null) return null;
4222
+ const match = ACTIONS_JOB_TARGET_URL.exec(targetUrl);
4223
+ if (match === null) return null;
4224
+ return {
4225
+ run: match[1],
4226
+ index: Number(match[2])
4227
+ };
4228
+ }
4229
+ /**
4230
+ * The repository half of the same URL: everything before the first `/actions/runs/` is a
4231
+ * repository URL when it carries a scheme, so a pasted `targetUrl` can name the repository it came
4232
+ * from instead of leaving that to a separate option. A bare path carries no repository and yields
4233
+ * `undefined`; a prefixed value that is not a repository URL is refused by
4234
+ * {@link parseRepoTarget}, which is the answer a caller who pasted the wrong thing needs.
4235
+ */
4236
+ function parseActionsJobTargetRepo(targetUrl) {
4237
+ const boundary = targetUrl.indexOf("/actions/runs/");
4238
+ if (boundary === -1) return void 0;
4239
+ const prefix = targetUrl.slice(0, boundary);
4240
+ if (!prefix.startsWith("http://") && !prefix.startsWith("https://")) return void 0;
4241
+ return parseRepoTarget(prefix);
4041
4242
  }
4042
- z.object({ workflow_runs: z.array(z.object({ id: z.number() })) });
4043
- z.array(z.object({ id: z.number() }));
4044
4243
  /**
4045
4244
  * Trigger a `workflow_dispatch` event. Any 2xx is success (the instance's swagger lists 201 and
4046
4245
  * 204). A failure is a {@link FatalError} carrying {@link forgejoErrorMessage}'s text for every
@@ -4070,79 +4269,551 @@ async function dispatchWorkflow(executor, conn, options) {
4070
4269
  };
4071
4270
  }
4072
4271
  //#endregion
4073
- //#region src/workflow-dispatch.ts
4272
+ //#region ../forgejo/src/actions-log.ts
4273
+ /** A CSI sequence: `ESC [` , parameter bytes, intermediate bytes, one final byte. Removed whole --
4274
+ * dropping only the escape byte would leave the parameters behind as text. */
4275
+ const CSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
4276
+ /** An OSC sequence: `ESC ]` up to a BEL or a string terminator (`ESC \`). */
4277
+ const OSC_SEQUENCE = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu;
4278
+ /** An escape byte that began neither of those. */
4279
+ const LONE_ESCAPE = /\u001b/gu;
4280
+ /** The C0 controls that may still be left, tab excepted, plus DEL. */
4281
+ const CONTROL_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f]/gu;
4282
+ /** The first token of a line, when it is shaped like the timestamp the runner writes. */
4283
+ const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\S+$/u;
4284
+ /** Remove every control sequence and control character, whole. */
4285
+ function stripControlSequences(text) {
4286
+ return text.replace(CSI_SEQUENCE, "").replace(OSC_SEQUENCE, "").replace(LONE_ESCAPE, "").replace(CONTROL_CHARS, "");
4287
+ }
4288
+ /**
4289
+ * Split one line into its timestamp and its text. The first whitespace-delimited token is the
4290
+ * timestamp only when it is shaped like one **and** a clock accepts it -- a job printing a
4291
+ * date-shaped word keeps it in its body rather than losing it to a `null`.
4292
+ */
4293
+ function splitLogLine(line) {
4294
+ const spaceAt = line.indexOf(" ");
4295
+ const head = spaceAt === -1 ? line : line.slice(0, spaceAt);
4296
+ if (LINE_TIMESTAMP.test(head) && Number.isFinite(Date.parse(head))) return {
4297
+ at: head,
4298
+ text: stripControlSequences(spaceAt === -1 ? "" : line.slice(spaceAt + 1))
4299
+ };
4300
+ return {
4301
+ at: null,
4302
+ text: stripControlSequences(line)
4303
+ };
4304
+ }
4305
+ function spanBetween(start, end) {
4306
+ if (start === null || end === null) return null;
4307
+ const startMs = Date.parse(start);
4308
+ const endMs = Date.parse(end);
4309
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
4310
+ return endMs - startMs;
4311
+ }
4074
4312
  /**
4075
- * `bst-fj workflow:dispatch`: a thin command over `@bensandee/forgejo/actions`. Forgejo-only;
4076
- * `gh workflow run` already serves GitHub.
4313
+ * Read a job log as lines. The carriage return of a CRLF pair and the empty line a trailing
4314
+ * newline leaves are both dropped.
4077
4315
  */
4078
- /** Dispatch a `workflow_dispatch` event on the current repository or on `options.repo`. */
4079
- async function runWorkflowDispatch(fetcher, options) {
4080
- const dispatched = await dispatchWorkflow(fetcher, requireForgejoTarget(options.cwd, void 0, "workflow:dispatch is Forgejo-only -- use gh workflow run on GitHub."), {
4081
- ...options.repo === void 0 ? {} : { repository: options.repo },
4082
- workflow: options.workflow,
4083
- ref: options.ref,
4084
- inputs: options.inputs
4085
- });
4316
+ function readActionsJobLog(text) {
4317
+ const raw = text.split("\n");
4318
+ if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
4319
+ const lines = raw.map((entry) => splitLogLine(entry.endsWith("\r") ? entry.slice(0, -1) : entry));
4320
+ const firstLineAt = lines.find((line) => line.at !== null)?.at ?? null;
4321
+ let lastLineAt = null;
4322
+ for (let i = lines.length - 1; i >= 0 && lastLineAt === null; i--) lastLineAt = lines[i]?.at ?? null;
4086
4323
  return {
4087
- status: "dispatched",
4088
- host: "forgejo",
4089
- repo: dispatched.repository,
4090
- workflow: dispatched.workflow,
4091
- ref: dispatched.ref,
4092
- inputs: dispatched.inputs
4324
+ firstLineAt,
4325
+ lastLineAt,
4326
+ spanMs: spanBetween(firstLineAt, lastLineAt),
4327
+ lines
4093
4328
  };
4094
4329
  }
4095
4330
  //#endregion
4096
- //#region src/registry-actions.ts
4097
- const actionsSubCommands = { "workflow:dispatch": defineCommand({
4331
+ //#region src/ci-log.ts
4332
+ /**
4333
+ * `bst-fj ci:log` -- one CI job's log, whole, or the lines of it a `--grep` matches. GitHub has
4334
+ * `gh run view --log` for this, so the command is Forgejo-only, like every other command in this
4335
+ * bin.
4336
+ *
4337
+ * The positional is a run number, a run URL (what `ci:runs` prints), or a status `target_url` (what
4338
+ * `pr:checks` and `commit:status` print beside a red check), so a caller pastes rather than
4339
+ * transcribes.
4340
+ *
4341
+ * The log is read **whole** up to the library's ceiling, not as the 2 MiB tail the incident body
4342
+ * keeps: a `--grep` over a tail would answer "not mentioned" about lines it never saw. A log cut
4343
+ * by the ceiling says so in `log.truncated` and in a notice.
4344
+ *
4345
+ * There is no step table. The runner on the instance this was built against writes a step marker
4346
+ * for its first main step only, so a step split read off the log text was wrong for every later
4347
+ * step (see `@bensandee/forgejo/actions-log`).
4348
+ *
4349
+ * Under `--json` the payload carries `lines` only with `--grep`: a whole log is megabytes, and a
4350
+ * reader that wants it all reads the text output.
4351
+ */
4352
+ /** A run page URL: `/actions/runs/{run}`, with nothing after it but a slash, query or fragment. */
4353
+ const ACTIONS_RUN_URL = /\/actions\/runs\/(\d+)\/?(?:[?#]|$)/u;
4354
+ /**
4355
+ * The positional: all digits is a run number; a job URL gives the run and the job index; a run
4356
+ * URL gives the run alone. A URL carrying a host and `owner/repo` also names the repository,
4357
+ * which then stands in for `--repo`.
4358
+ */
4359
+ function parseCiLogTarget(target) {
4360
+ if (/^\d+$/.test(target)) return {
4361
+ run: target,
4362
+ index: null,
4363
+ repoTarget: void 0
4364
+ };
4365
+ const location = parseActionsJobTargetUrl(target);
4366
+ if (location !== null) return {
4367
+ run: location.run,
4368
+ index: location.index,
4369
+ repoTarget: parseActionsJobTargetRepo(target)
4370
+ };
4371
+ const runOnly = ACTIONS_RUN_URL.exec(target);
4372
+ if (runOnly !== null) return {
4373
+ run: runOnly[1],
4374
+ index: null,
4375
+ repoTarget: parseActionsJobTargetRepo(target)
4376
+ };
4377
+ throw new FatalError(`Not a run number or an Actions run or job URL: "${target}" (expected e.g. 6693, https://host/owner/repo/actions/runs/6693 or .../actions/runs/6693/jobs/0).`);
4378
+ }
4379
+ /** What the run's jobs are, in the shape an error message lists them in for a caller to read. */
4380
+ function jobTable(jobs) {
4381
+ return jobs.map((candidate) => ` ${String(candidate.index)} ${candidate.name} ${candidate.status}`).join("\n");
4382
+ }
4383
+ function jobAtIndex(jobs, index, label) {
4384
+ const found = jobs.find((candidate) => candidate.index === index);
4385
+ if (found === void 0) throw new FatalError(`${label} ${String(index)} is not one of this run's jobs. Jobs in this run:\n${jobTable(jobs)}`);
4386
+ return found;
4387
+ }
4388
+ /**
4389
+ * Which job's log to read.
4390
+ *
4391
+ * A `--job` of all digits is an index -- the same index a `target_url` carries -- and anything else
4392
+ * is an exact, case-insensitive job name. When the URL already named an index, a `--job` that
4393
+ * disagrees with it is a refusal naming both, because either could be what the caller meant.
4394
+ *
4395
+ * With neither and several jobs, this refuses rather than defaulting to index 0: the answer would
4396
+ * be about the wrong job, confidently, with nothing said.
4397
+ */
4398
+ function chooseJob(jobs, job, urlIndex) {
4399
+ if (job !== void 0) {
4400
+ const chosen = /^\d+$/.test(job) ? jobAtIndex(jobs, Number(job), `--job ${job}`) : jobs.find((candidate) => candidate.name.toLowerCase() === job.toLowerCase());
4401
+ if (chosen === void 0) throw new FatalError(`No job is named "${job}". Jobs in this run:\n${jobTable(jobs)}`);
4402
+ if (urlIndex !== null && chosen.index !== urlIndex) throw new FatalError(`--job "${job}" is job ${String(chosen.index)}, but the URL names job ${String(urlIndex)} -- give one of them.`);
4403
+ return chosen;
4404
+ }
4405
+ if (urlIndex !== null) return jobAtIndex(jobs, urlIndex, "The URL's job index");
4406
+ const only = jobs.length === 1 ? jobs[0] : void 0;
4407
+ if (only !== void 0) return only;
4408
+ throw new FatalError(`This run has ${String(jobs.length)} jobs, so --job must say which one (a name or the index).\nJobs in this run:\n${jobTable(jobs)}`);
4409
+ }
4410
+ /**
4411
+ * Read one job's log: run by number, its jobs, the chosen job, the whole log. A refused target, a
4412
+ * missing run or job, and a non-2xx from the forge all throw; a successful read of a failed job
4413
+ * does not, because this is a readout and not a verdict.
4414
+ */
4415
+ async function runCiLog(fetcher, options) {
4416
+ const target = parseCiLogTarget(options.target);
4417
+ let grep;
4418
+ if (options.grep !== void 0) try {
4419
+ grep = new RegExp(options.grep);
4420
+ } catch {
4421
+ throw new FatalError(`--grep is not a valid JavaScript regular expression: "${options.grep}"`);
4422
+ }
4423
+ const conn = requireForgejoTarget(options.cwd, resolveIssueTarget(target.repoTarget, options.repo), "ci:log is Forgejo-only -- gh run view --log already serves GitHub, first-class. Use that instead.");
4424
+ rootLogger.debug(`ci:log repository=${conn.repository} run=${target.run}`);
4425
+ const run = await getActionsRunByNumber(fetcher, conn, target.run);
4426
+ const chosen = chooseJob(await listActionsRunJobs(fetcher, conn, run.id), options.job, target.index);
4427
+ const jobLog = await fetchActionsJobLogById(fetcher, conn, chosen.id, { head: MAX_WHOLE_LOG_CHARS });
4428
+ const read = readActionsJobLog(jobLog.text);
4429
+ const notices = [];
4430
+ if (jobLog.truncated) notices.push("The job log is longer than the whole-log ceiling, so only its head was read: later lines are missing.");
4431
+ return {
4432
+ repository: conn.repository,
4433
+ run: {
4434
+ number: run.number ?? Number.parseInt(target.run, 10),
4435
+ id: run.id,
4436
+ workflow: run.workflow,
4437
+ ref: run.ref,
4438
+ headSha: run.headSha,
4439
+ status: run.status
4440
+ },
4441
+ job: {
4442
+ index: chosen.index,
4443
+ id: chosen.id,
4444
+ name: chosen.name,
4445
+ status: chosen.status
4446
+ },
4447
+ log: {
4448
+ firstLineAt: read.firstLineAt,
4449
+ lastLineAt: read.lastLineAt,
4450
+ spanMs: read.spanMs,
4451
+ lineCount: read.lines.length,
4452
+ truncated: jobLog.truncated
4453
+ },
4454
+ lines: grep === void 0 ? read.lines : read.lines.filter((line) => grep.test(line.text)),
4455
+ grep: grep !== void 0,
4456
+ notices
4457
+ };
4458
+ }
4459
+ /** A two-line header naming the run and job, then each line kept with its timestamp. */
4460
+ function renderCiLog(result) {
4461
+ const lines = [`run ${String(result.run.number)} ${result.run.status ?? "?"} ${result.run.workflow ?? "?"}`, `job ${String(result.job.index)} ${result.job.name} (${result.job.status})`];
4462
+ for (const line of result.lines) lines.push(line.at === null ? line.text : `${line.at} ${line.text}`);
4463
+ return lines.join("\n");
4464
+ }
4465
+ //#endregion
4466
+ //#region src/commands/ci-log.ts
4467
+ const ciLogCommand = defineCommand({
4098
4468
  meta: {
4099
- name: "workflow:dispatch",
4100
- description: "Trigger a workflow_dispatch event for a workflow in any repo on the same Forgejo host (Forgejo-only; use gh workflow run on GitHub). The token must have rights on --repo when it differs from the current repo."
4469
+ name: "ci:log",
4470
+ description: "Show one workflow job's log, or the lines matching --grep (Forgejo-only; use gh run view --log on GitHub)"
4101
4471
  },
4102
4472
  args: {
4473
+ target: {
4474
+ type: "positional",
4475
+ required: true,
4476
+ description: "Run number, run URL (actions/runs/<run>), or a status target_url (actions/runs/<run>/jobs/<index>)"
4477
+ },
4478
+ job: {
4479
+ type: "string",
4480
+ description: "Job name (case-insensitive) or zero-based index (refused when a URL names one)"
4481
+ },
4482
+ grep: {
4483
+ type: "string",
4484
+ description: "JavaScript regular expression over each line's text; prints only matching lines"
4485
+ },
4103
4486
  repo: {
4104
4487
  type: "string",
4105
- description: "Target repository as owner/repo, not a URL (default: the current repo)",
4106
- required: false
4488
+ description: "Repository URL (default: this repo)"
4107
4489
  },
4490
+ json: {
4491
+ type: "boolean",
4492
+ description: "Emit one JSON document: {repository, run: {number, id, workflow, ref, headSha, status}, job: {index, id, name, status}, log: {firstLineAt, lastLineAt, spanMs, lineCount, truncated}, lines ([{at, text}], with --grep only)}."
4493
+ },
4494
+ verbose: {
4495
+ type: "boolean",
4496
+ description: "Emit detailed debug output"
4497
+ }
4498
+ },
4499
+ async run({ args }) {
4500
+ const out = createCliOutput(args.json === true);
4501
+ const target = args.target ?? "";
4502
+ const result = await runCiLog(cliFetcher(), {
4503
+ cwd: process.cwd(),
4504
+ target,
4505
+ ...args.job === void 0 ? {} : { job: args.job },
4506
+ ...args.grep === void 0 ? {} : { grep: args.grep },
4507
+ ...args.repo === void 0 ? {} : { repo: args.repo }
4508
+ });
4509
+ for (const notice of result.notices) out.notice(notice);
4510
+ out.json({
4511
+ repository: result.repository,
4512
+ run: result.run,
4513
+ job: result.job,
4514
+ log: result.log,
4515
+ ...result.grep ? { lines: result.lines } : {}
4516
+ });
4517
+ out.text(renderCiLog(result));
4518
+ }
4519
+ });
4520
+ //#endregion
4521
+ //#region src/ci-runs.ts
4522
+ /**
4523
+ * `bst-fj ci:runs` -- a read-only, Forgejo-only listing of a repository's workflow runs and their
4524
+ * jobs, over the shared paged read in `@bensandee/forgejo/actions`. GitHub run listing already has
4525
+ * a first-class tool (`gh run list`), the same precedent `pr:list` follows for the same structural
4526
+ * reason (spec decision 2 and 13's target form: `--repo` is a repository URL, like every `pr:*`
4527
+ * and `issue:*` read).
4528
+ *
4529
+ * `--ref` and `--pr` are one filter spelled two ways (spec decision 6): a pull-request run carries
4530
+ * `refs/pull/<n>/head`, which no caller should have to know, and the branch's own push runs stay
4531
+ * one `--ref <branch>` away.
4532
+ */
4533
+ function parseLimit$2(raw) {
4534
+ const limit = Number(raw);
4535
+ if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
4536
+ return limit;
4537
+ }
4538
+ /**
4539
+ * The `ref` filter, from whichever of `--ref` and `--pr` was given. A bare `--ref main` names a
4540
+ * branch, so it becomes `refs/heads/main`; a value that is already a full ref is sent as is;
4541
+ * `--pr` becomes `refs/pull/<n>/head` (spec decision 6) and carries the repository off a PR URL so
4542
+ * the URL can stand in for `--repo`.
4543
+ *
4544
+ * Both together is a refusal rather than a precedence: they are the same filter, and quietly
4545
+ * ignoring one answers about a different set of runs than the caller asked for.
4546
+ */
4547
+ function resolveRunsRef(ref, pr) {
4548
+ if (ref !== void 0 && pr !== void 0) throw new FatalError("--ref and --pr are the same filter -- give one of them (--pr 273 for a pull request's own runs, --ref main for a branch's).");
4549
+ if (pr !== void 0) {
4550
+ const parsed = parseIssueRef(pr);
4551
+ if (parsed.number === void 0) throw new FatalError(`Not a pull request number or URL: "${pr}"`);
4552
+ return {
4553
+ ref: `refs/pull/${String(parsed.number)}/head`,
4554
+ target: parsed.target
4555
+ };
4556
+ }
4557
+ if (ref === void 0) return {
4558
+ ref: void 0,
4559
+ target: void 0
4560
+ };
4561
+ if (ref.startsWith("refs/")) return {
4562
+ ref,
4563
+ target: void 0
4564
+ };
4565
+ return {
4566
+ ref: `refs/heads/${ref}`,
4567
+ target: void 0
4568
+ };
4569
+ }
4570
+ /**
4571
+ * List a repository's workflow runs, newest first, each with its jobs. Paging is never optional on
4572
+ * this read (spec decision 9): an unpaged runs query answers with the whole table. Refuses a
4573
+ * github.com target before any fetch.
4574
+ */
4575
+ async function runCiRuns(fetcher, options) {
4576
+ const limit = parseLimit$2(options.limit);
4577
+ const resolved = resolveRunsRef(options.ref, options.pr);
4578
+ const conn = requireForgejoTarget(options.cwd, resolveIssueTarget(resolved.target, options.repo), "ci:runs is Forgejo-only -- gh run list already serves GitHub, first-class. Use that instead.");
4579
+ rootLogger.debug(`ci:runs repository=${conn.repository} limit=${String(limit)}`);
4580
+ const notices = [];
4581
+ const runs = await listActionsRuns(fetcher, conn, {
4582
+ limit,
4583
+ workflow: options.workflow,
4584
+ ref: resolved.ref,
4585
+ headSha: options.sha,
4586
+ status: options.status,
4587
+ onPageCap: () => {
4588
+ notices.push("Workflow run listing hit the page cap before an empty page; results may be truncated.");
4589
+ }
4590
+ });
4591
+ const fetched = await Promise.all(runs.map(async (run) => ({
4592
+ run,
4593
+ jobs: await listActionsRunJobs(fetcher, conn, run.id)
4594
+ })));
4595
+ const rows = [];
4596
+ for (const { run, jobs } of fetched) rows.push({
4597
+ ...run,
4598
+ jobs
4599
+ });
4600
+ return {
4601
+ repository: conn.repository,
4602
+ runs: rows,
4603
+ notices
4604
+ };
4605
+ }
4606
+ /** A duration, or the dash that says the forge reported none (the run has not finished). */
4607
+ function durationText(durationMs) {
4608
+ return durationMs === null ? "-" : `${String(durationMs)}ms`;
4609
+ }
4610
+ /**
4611
+ * One line per run, then one indented line per job. The run number leads its line because it is
4612
+ * what `ci:log` takes as its positional.
4613
+ */
4614
+ function renderCiRuns(result) {
4615
+ if (result.runs.length === 0) return `No workflow runs in ${result.repository}.`;
4616
+ const lines = [];
4617
+ for (const run of result.runs) {
4618
+ lines.push([
4619
+ run.number === null ? "?" : String(run.number),
4620
+ run.status ?? "?",
4621
+ run.workflow ?? "?",
4622
+ run.event ?? "?",
4623
+ run.ref ?? "?",
4624
+ run.headSha ?? "?",
4625
+ durationText(run.durationMs),
4626
+ run.url ?? "?"
4627
+ ].join(" "));
4628
+ for (const job of run.jobs) lines.push(` ${String(job.index)} ${job.status} ${job.name}`);
4629
+ }
4630
+ return lines.join("\n");
4631
+ }
4632
+ //#endregion
4633
+ //#region src/commands/ci-runs.ts
4634
+ const ciRunsCommand = defineCommand({
4635
+ meta: {
4636
+ name: "ci:runs",
4637
+ description: "List a repository's workflow runs with their jobs, newest first (Forgejo-only; use gh run list on GitHub)"
4638
+ },
4639
+ args: {
4108
4640
  workflow: {
4109
4641
  type: "string",
4110
- description: "Workflow filename (e.g. deploy.yml) or numeric ID",
4111
- required: true
4642
+ description: "Workflow file name, e.g. check.yml (the forge's workflow_id)"
4112
4643
  },
4113
4644
  ref: {
4114
4645
  type: "string",
4115
- description: "Git ref to dispatch on (default: main)",
4116
- required: false
4646
+ description: "Branch or full ref; a bare name means refs/heads/<name> (refuses --pr)"
4117
4647
  },
4118
- input: {
4648
+ pr: {
4119
4649
  type: "string",
4120
- description: "Workflow input as key=value (repeatable)",
4121
- required: false
4650
+ description: "Pull request number or URL: its own runs (refs/pull/<n>/head)"
4651
+ },
4652
+ sha: {
4653
+ type: "string",
4654
+ description: "Head SHA (the forge's head_sha)"
4655
+ },
4656
+ status: {
4657
+ type: "string",
4658
+ description: "Run status: pending | queued | in_progress | failure | success | skipped (the forge interprets the string)"
4659
+ },
4660
+ limit: {
4661
+ type: "string",
4662
+ default: "20",
4663
+ description: "Maximum runs to return"
4664
+ },
4665
+ repo: {
4666
+ type: "string",
4667
+ description: "Repository URL (default: this repo)"
4122
4668
  },
4123
4669
  json: {
4124
4670
  type: "boolean",
4125
- description: "Emit the dispatch result as one JSON document. Keys: status (always dispatched), host (always forgejo), repo (owner/repo), workflow, ref, inputs."
4671
+ description: "Emit one JSON document: {repository, runs: [{number, id, workflow, title, ref (main, or #<pr> for a PR run), headSha, event, status, startedAt, completedAt, durationMs, url (each nullable but id), jobs: [{index, id, name, status}]}]}."
4126
4672
  },
4127
4673
  verbose: {
4128
4674
  type: "boolean",
4129
4675
  description: "Emit detailed debug output"
4130
4676
  }
4131
4677
  },
4132
- async run({ args, rawArgs }) {
4678
+ async run({ args }) {
4133
4679
  const out = createCliOutput(args.json === true);
4134
- if (!args.workflow) throw new FatalError("--workflow is required");
4135
- const result = await runWorkflowDispatch(cliFetcher(), {
4680
+ const result = await runCiRuns(cliFetcher(), {
4136
4681
  cwd: process.cwd(),
4137
- repo: args.repo ?? void 0,
4138
- workflow: args.workflow,
4139
- ref: args.ref ?? "main",
4140
- inputs: parseInputs(collectInputArgs(rawArgs))
4682
+ workflow: args.workflow ?? void 0,
4683
+ ref: args.ref ?? void 0,
4684
+ pr: args.pr ?? void 0,
4685
+ sha: args.sha ?? void 0,
4686
+ status: args.status ?? void 0,
4687
+ limit: args.limit,
4688
+ repo: args.repo ?? void 0
4141
4689
  });
4142
- out.json(result);
4143
- out.text(`Dispatched ${result.workflow} on ${result.repo} (ref: ${result.ref})`);
4690
+ for (const notice of result.notices) out.notice(notice);
4691
+ out.json({
4692
+ repository: result.repository,
4693
+ runs: result.runs
4694
+ });
4695
+ out.text(renderCiRuns(result));
4144
4696
  }
4145
- }) };
4697
+ });
4698
+ //#endregion
4699
+ //#region ../cli-kit/src/workflow-inputs.ts
4700
+ const INPUT_FLAG = "--input";
4701
+ const INPUT_FLAG_EQ = "--input=";
4702
+ /**
4703
+ * Collect every `--input` value from a command's raw CLI args.
4704
+ *
4705
+ * citty@0.2.2 keeps only the LAST occurrence of a repeated named flag, so a
4706
+ * declared `input` arg cannot capture `--input a=1 --input b=2` -- it silently
4707
+ * yields just `b=2`. Scanning the raw argv directly recovers all occurrences.
4708
+ * Both `--input k=v` (two tokens) and `--input=k=v` (one token) forms are
4709
+ * supported. Throws {@link FatalError} when `--input` has no following value.
4710
+ */
4711
+ function collectInputArgs(rawArgs) {
4712
+ const values = [];
4713
+ for (let i = 0; i < rawArgs.length; i += 1) {
4714
+ const arg = rawArgs[i];
4715
+ if (arg === void 0) continue;
4716
+ if (arg === INPUT_FLAG) {
4717
+ const next = rawArgs[i + 1];
4718
+ if (next === void 0) throw new FatalError("Missing value for --input: expected `--input key=value`");
4719
+ values.push(next);
4720
+ i += 1;
4721
+ } else if (arg.startsWith(INPUT_FLAG_EQ)) values.push(arg.slice(8));
4722
+ }
4723
+ return values;
4724
+ }
4725
+ /**
4726
+ * Parse `key=value` workflow-input strings (as collected by
4727
+ * {@link collectInputArgs}) into an inputs object. Throws {@link FatalError} on
4728
+ * a malformed entry -- no `=`, or an empty key.
4729
+ */
4730
+ function parseInputs(raw) {
4731
+ const inputs = {};
4732
+ for (const value of raw) {
4733
+ const eq = value.indexOf("=");
4734
+ if (eq <= 0) throw new FatalError(`Invalid --input "${value}": expected key=value`);
4735
+ inputs[value.slice(0, eq)] = value.slice(eq + 1);
4736
+ }
4737
+ return inputs;
4738
+ }
4739
+ //#endregion
4740
+ //#region src/workflow-dispatch.ts
4741
+ /**
4742
+ * `bst-fj workflow:dispatch`: a thin command over `@bensandee/forgejo/actions`. Forgejo-only;
4743
+ * `gh workflow run` already serves GitHub.
4744
+ */
4745
+ /** Dispatch a `workflow_dispatch` event on the current repository or on `options.repo`. */
4746
+ async function runWorkflowDispatch(fetcher, options) {
4747
+ const dispatched = await dispatchWorkflow(fetcher, requireForgejoTarget(options.cwd, void 0, "workflow:dispatch is Forgejo-only -- use gh workflow run on GitHub."), {
4748
+ ...options.repo === void 0 ? {} : { repository: options.repo },
4749
+ workflow: options.workflow,
4750
+ ref: options.ref,
4751
+ inputs: options.inputs
4752
+ });
4753
+ return {
4754
+ status: "dispatched",
4755
+ host: "forgejo",
4756
+ repo: dispatched.repository,
4757
+ workflow: dispatched.workflow,
4758
+ ref: dispatched.ref,
4759
+ inputs: dispatched.inputs
4760
+ };
4761
+ }
4762
+ //#endregion
4763
+ //#region src/registry-actions.ts
4764
+ const actionsSubCommands = {
4765
+ "ci:log": ciLogCommand,
4766
+ "ci:runs": ciRunsCommand,
4767
+ "workflow:dispatch": defineCommand({
4768
+ meta: {
4769
+ name: "workflow:dispatch",
4770
+ description: "Trigger a workflow_dispatch event for a workflow in any repo on the same Forgejo host (Forgejo-only; use gh workflow run on GitHub). The token must have rights on --repo when it differs from the current repo."
4771
+ },
4772
+ args: {
4773
+ repo: {
4774
+ type: "string",
4775
+ description: "Target repository as owner/repo, not a URL (default: the current repo)",
4776
+ required: false
4777
+ },
4778
+ workflow: {
4779
+ type: "string",
4780
+ description: "Workflow filename (e.g. deploy.yml) or numeric ID",
4781
+ required: true
4782
+ },
4783
+ ref: {
4784
+ type: "string",
4785
+ description: "Git ref to dispatch on (default: main)",
4786
+ required: false
4787
+ },
4788
+ input: {
4789
+ type: "string",
4790
+ description: "Workflow input as key=value (repeatable)",
4791
+ required: false
4792
+ },
4793
+ json: {
4794
+ type: "boolean",
4795
+ description: "Emit one JSON document: status (dispatched), host (forgejo), repo (owner/repo), workflow, ref, inputs."
4796
+ },
4797
+ verbose: {
4798
+ type: "boolean",
4799
+ description: "Emit detailed debug output"
4800
+ }
4801
+ },
4802
+ async run({ args, rawArgs }) {
4803
+ const out = createCliOutput(args.json === true);
4804
+ if (!args.workflow) throw new FatalError("--workflow is required");
4805
+ const result = await runWorkflowDispatch(cliFetcher(), {
4806
+ cwd: process.cwd(),
4807
+ repo: args.repo ?? void 0,
4808
+ workflow: args.workflow,
4809
+ ref: args.ref ?? "main",
4810
+ inputs: parseInputs(collectInputArgs(rawArgs))
4811
+ });
4812
+ out.json(result);
4813
+ out.text(`Dispatched ${result.workflow} on ${result.repo} (ref: ${result.ref})`);
4814
+ }
4815
+ })
4816
+ };
4146
4817
  z.object({
4147
4818
  content: z.string(),
4148
4819
  encoding: z.literal("base64")
@@ -4328,7 +4999,7 @@ const branchSubCommands = {
4328
4999
  },
4329
5000
  json: {
4330
5001
  type: "boolean",
4331
- description: "Emit JSON on stdout"
5002
+ description: "Emit one JSON document: deleted."
4332
5003
  },
4333
5004
  verbose: {
4334
5005
  type: "boolean",
@@ -4358,7 +5029,7 @@ const branchSubCommands = {
4358
5029
  },
4359
5030
  json: {
4360
5031
  type: "boolean",
4361
- description: "Emit JSON on stdout"
5032
+ description: "Emit one JSON document: branches ([{name, protected, commitSha (or null), commitDate (or null)}]), truncated."
4362
5033
  },
4363
5034
  verbose: {
4364
5035
  type: "boolean",
@@ -4914,7 +5585,12 @@ function forgejoIssueApi(fetcher, conn) {
4914
5585
  const ISSUE_LIST_JSON_HELP = "Emit one JSON document: issues ([{number, title, state, url, author, labels, createdAt, updatedAt, closedAt (null while open), isPullRequest, assignees, attachments ([{id, name, size, createdAt, downloadUrl}])}]), truncated.";
4915
5586
  const ISSUE_CREATE_JSON_HELP = "Emit one JSON document: number, url, assignees. A dry run reports number 0 and an empty url.";
4916
5587
  const ISSUE_STATE_JSON_HELP = "Emit one JSON document: number, url, action (close | reopen), state ({previous, current}), changed, comment? ({posted}; present only when a comment was given).";
4917
- const ISSUE_VIEW_JSON_HELP = "Emit JSON on stdout: the issue's own fields (number, title, state, url, author, labels, createdAt, updatedAt, closedAt, body, isPullRequest, assignees, attachments), comments (each with id, author, createdAt, updatedAt?, body, url?), commentsTruncated (true when the comment read hit its cap of about 1000), and, on a pull request only, either pr (head and base, each with ref, sha and repo, which is null for a deleted fork; draft; merged; mergeable, true, false or null; state) or prUnavailable (reason)";
5588
+ const ISSUE_COMMENT_JSON_HELP = "Emit one JSON document: number, url.";
5589
+ const ISSUE_UPDATE_JSON_HELP = "Emit one JSON document: number, url, previous ({title, body}), current ({title, body}), changed ([title | body]). A dry run reports current as it would be and writes nothing.";
5590
+ const ISSUE_DOWNLOAD_JSON_HELP = "Emit one JSON document: dir, files ([{name, size, path}]). A dry run lists the files it would write and writes nothing.";
5591
+ const ISSUE_LABEL_JSON_HELP = "Emit one JSON document: number, label, action (add | remove), url.";
5592
+ const ISSUE_SET_LABELS_JSON_HELP = "Emit one JSON document: number, labels, previous, added, removed, url. A dry run reports the change it would make and writes nothing.";
5593
+ const ISSUE_VIEW_JSON_HELP = "Emit one JSON document: number, title, state, url, author, labels, createdAt, updatedAt, closedAt, body, isPullRequest, assignees, attachments, comments ([{id, author, createdAt, updatedAt?, body, url?}]), commentsTruncated, pr? ({head ({ref, sha, repo (or null)}), base ({ref, sha, repo (or null)}), draft, merged, mergeable (true | false | null), state}), prUnavailable? ({reason}). On a pull request exactly one of pr and prUnavailable is present, and neither is otherwise; repo is null for a deleted fork; commentsTruncated is true when the comment read hit its cap of about 1000.";
4918
5594
  /**
4919
5595
  * The refusal for an {@link IssueNumberRequiredError}. `example` is the program's own complete
4920
5596
  * invocation, for instance the program name, the command and a sample number, so every program
@@ -5361,7 +6037,7 @@ const issueCommentCommand = defineCommand({
5361
6037
  },
5362
6038
  json: {
5363
6039
  type: "boolean",
5364
- description: "Emit JSON on stdout"
6040
+ description: ISSUE_COMMENT_JSON_HELP
5365
6041
  },
5366
6042
  "dry-run": {
5367
6043
  type: "boolean",
@@ -5618,7 +6294,7 @@ const issueDownloadCommand = defineCommand({
5618
6294
  },
5619
6295
  json: {
5620
6296
  type: "boolean",
5621
- description: "Emit JSON on stdout"
6297
+ description: ISSUE_DOWNLOAD_JSON_HELP
5622
6298
  },
5623
6299
  "dry-run": {
5624
6300
  type: "boolean",
@@ -5858,7 +6534,7 @@ function defineIssueLabelCommand(action) {
5858
6534
  },
5859
6535
  json: {
5860
6536
  type: "boolean",
5861
- description: "Emit JSON on stdout"
6537
+ description: ISSUE_LABEL_JSON_HELP
5862
6538
  },
5863
6539
  "dry-run": {
5864
6540
  type: "boolean",
@@ -5920,7 +6596,7 @@ const issueSetLabelsCommand = defineCommand({
5920
6596
  },
5921
6597
  json: {
5922
6598
  type: "boolean",
5923
- description: "Emit JSON on stdout"
6599
+ description: ISSUE_SET_LABELS_JSON_HELP
5924
6600
  },
5925
6601
  "dry-run": {
5926
6602
  type: "boolean",
@@ -6202,7 +6878,7 @@ const issueUpdateCommand = defineCommand({
6202
6878
  },
6203
6879
  json: {
6204
6880
  type: "boolean",
6205
- description: "Emit JSON on stdout"
6881
+ description: ISSUE_UPDATE_JSON_HELP
6206
6882
  },
6207
6883
  "dry-run": {
6208
6884
  type: "boolean",
@@ -6628,7 +7304,7 @@ const prChecksCommand = defineCommand({
6628
7304
  },
6629
7305
  json: {
6630
7306
  type: "boolean",
6631
- description: "Emit the machine-readable report on stdout. Keys: pr, repository, url, state, headSha, baseRef, status (green | red | pending | no-status | unparseable | head-moved | timeout), settled, combinedState, waited, elapsedMs, checks (each: context, name, state, verdict, description, targetUrl, updatedAt, preexisting; description, targetUrl and updatedAt can be null), baseline (ref, sha?, status, reason?, redNames), detail? (unparseable only), previousHeadSha? (head-moved only). no-status means no check was posted and is never green"
7307
+ description: "Emit one JSON document: pr, repository, url, state, headSha, baseRef, status (green | red | pending | no-status | unparseable | head-moved | timeout), settled, combinedState, waited, elapsedMs, checks ([{context, name, state, verdict, description (or null), targetUrl (or null), updatedAt (or null), preexisting}]), baseline ({ref, sha?, status, reason?, redNames}), detail? (unparseable only), previousHeadSha? (head-moved only). no-status means no check was posted and is never green."
6632
7308
  },
6633
7309
  verbose: {
6634
7310
  type: "boolean",
@@ -7588,7 +8264,7 @@ const prCreateCommand = defineCommand({
7588
8264
  },
7589
8265
  json: {
7590
8266
  type: "boolean",
7591
- description: "Emit the result as JSON: action (created | updated | would-create | would-update), number, url, branch, base, title, body, droppedRefs? (array of {text, issue}; a create or would-create carries it), dryRun. On a dry run, number and url are null."
8267
+ description: "Emit one JSON document: action (created | updated | would-create | would-update), number (or null), url (or null), branch, base, title, body, droppedRefs? ([{text, issue}]), dryRun. A create or would-create carries droppedRefs; on a dry run, number and url are null."
7592
8268
  },
7593
8269
  verbose: {
7594
8270
  type: "boolean",
@@ -7696,7 +8372,7 @@ const prFilesCommand = defineCommand({
7696
8372
  },
7697
8373
  json: {
7698
8374
  type: "boolean",
7699
- description: "Emit JSON on stdout"
8375
+ description: "Emit one JSON document: files ([{filename, status (or null), additions, deletions}]), truncated."
7700
8376
  },
7701
8377
  verbose: {
7702
8378
  type: "boolean",
@@ -7855,7 +8531,8 @@ const MergeGateSchema = z.object({
7855
8531
  merge_base: z.string(),
7856
8532
  changed_files: z.number().nullish(),
7857
8533
  base: z.object({ ref: z.string() }),
7858
- labels: z.array(z.object({ name: z.string() })).nullable()
8534
+ labels: z.array(z.object({ name: z.string() })).nullable(),
8535
+ updated_at: z.string().nullish()
7859
8536
  });
7860
8537
  /**
7861
8538
  * Read a PR's live open/draft/mergeable state and current head SHA in one `/pulls/<n>` GET --
@@ -7880,7 +8557,8 @@ async function readMergeGate(executor, conn, prNumber) {
7880
8557
  mergeBase: parsed.data.merge_base,
7881
8558
  changedFiles: parsed.data.changed_files ?? null,
7882
8559
  baseRef: parsed.data.base.ref,
7883
- labels: (parsed.data.labels ?? []).map((label) => label.name)
8560
+ labels: (parsed.data.labels ?? []).map((label) => label.name),
8561
+ updatedAt: parsed.data.updated_at ?? null
7884
8562
  };
7885
8563
  }
7886
8564
  /**
@@ -8046,7 +8724,7 @@ const prSubCommands = {
8046
8724
  },
8047
8725
  json: {
8048
8726
  type: "boolean",
8049
- description: "Emit JSON on stdout"
8727
+ description: "Emit one JSON document: number, merged, method (merge | rebase | squash), headSha, verdict (mergeable), checks (green | red | pending | no-status | unparseable). merged is false on a dry run; a refusal is an error, never this document."
8050
8728
  },
8051
8729
  verbose: {
8052
8730
  type: "boolean",
@@ -8100,7 +8778,7 @@ const prSubCommands = {
8100
8778
  },
8101
8779
  json: {
8102
8780
  type: "boolean",
8103
- description: "Emit the result as JSON: action (updated | would-update), number, url, branch, base, title, body, dryRun, rewrites. On a dry run, number and url are null."
8781
+ description: "Emit one JSON document: action (updated | would-update), number (or null), url (or null), branch, base, title, body, dryRun, rewrites. On a dry run, number and url are null."
8104
8782
  },
8105
8783
  verbose: {
8106
8784
  type: "boolean",
@@ -8155,7 +8833,7 @@ setForgejoLogger(rootLogger);
8155
8833
  await runCli({
8156
8834
  name: "bst-fj",
8157
8835
  version: BST_FJ_VERSION,
8158
- description: "Forgejo API commands: labels, secrets, webhooks, releases, issues, PRs, branches, workflows and commit checks",
8836
+ description: "Forgejo API commands: labels, secrets, webhooks, releases, issues, PRs, branches, workflows, CI runs and logs, and commit checks",
8159
8837
  banner: void 0,
8160
8838
  subCommands: bstFjSubCommands,
8161
8839
  describeError: describeForgeError
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/forgejo-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "bst-fj: Forgejo API commands without the rest of bst",
5
5
  "license": "MIT",
6
6
  "repository": {