@bensandee/forgejo-cli 0.5.6 → 0.6.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 (2) hide show
  1. package/dist/bin.mjs +244 -174
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -162,7 +162,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
162
162
  //#region src/version.ts
163
163
  const parsed = z.object({ version: z.string() }).safeParse({
164
164
  name: "@bensandee/forgejo-cli",
165
- version: "0.5.6",
165
+ version: "0.6.0",
166
166
  description: "bst-fj: Forgejo API commands without the rest of bst",
167
167
  license: "MIT",
168
168
  repository: {
@@ -643,6 +643,11 @@ function listFlagPresent(rawArgs, flag) {
643
643
  const long = `--${flag}`;
644
644
  return rawArgs.some((token) => token === long || token.startsWith(`${long}=`));
645
645
  }
646
+ function parseLimitFlag(raw) {
647
+ const value = Number(raw);
648
+ if (!Number.isInteger(value) || value < 1) throw new FatalError(`--limit must be a positive integer, got "${raw}"`);
649
+ return value;
650
+ }
646
651
  //#endregion
647
652
  //#region ../cli-kit/src/output.ts
648
653
  const stdout = (msg) => console.log(msg);
@@ -1073,20 +1078,23 @@ function cliFetcher() {
1073
1078
  //#endregion
1074
1079
  //#region ../forgejo/src/errors.ts
1075
1080
  var ForgeAccessError = class extends FatalError {};
1076
- function authHeaders$2(conn) {
1081
+ function authHeaders(conn) {
1077
1082
  return { Authorization: `token ${conn.token}` };
1078
1083
  }
1079
1084
  function jsonHeaders(conn) {
1080
1085
  return {
1081
- ...authHeaders$2(conn),
1086
+ ...authHeaders(conn),
1082
1087
  "Content-Type": "application/json"
1083
1088
  };
1084
1089
  }
1085
1090
  function repoNotFoundMessage(conn) {
1086
1091
  return `Repository ${conn.repository} not found (or the token cannot see it)`;
1087
1092
  }
1093
+ function repoBase(conn) {
1094
+ return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
1095
+ }
1088
1096
  function issuesBase(conn) {
1089
- return `${conn.serverUrl}/api/v1/repos/${conn.repository}/issues`;
1097
+ return `${repoBase(conn)}/issues`;
1090
1098
  }
1091
1099
  function issueNotFoundMessage(conn, number) {
1092
1100
  return `Issue #${String(number)} not found in ${conn.repository}`;
@@ -1286,18 +1294,12 @@ z.object({
1286
1294
  created: z.string()
1287
1295
  });
1288
1296
  z.array(z.unknown());
1289
- function repoBase$1(conn) {
1290
- return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
1291
- }
1292
- function authHeaders$1(conn) {
1293
- return { Authorization: `token ${conn.token}` };
1294
- }
1295
1297
  function branchNotFoundMessage(conn, branch) {
1296
1298
  return `Branch ${branch} not found in ${conn.repository}`;
1297
1299
  }
1298
1300
  async function getBranchHeadSha(executor, conn, branch) {
1299
- const url = `${repoBase$1(conn)}/branches/${encodeURIComponent(branch)}`;
1300
- const res = await executor.fetch(url, { headers: authHeaders$1(conn) });
1301
+ const url = `${repoBase(conn)}/branches/${encodeURIComponent(branch)}`;
1302
+ const res = await executor.fetch(url, { headers: authHeaders(conn) });
1301
1303
  if (!res.ok) await throwForgejoFailure(res, {
1302
1304
  verb: "read branch",
1303
1305
  resource: branch
@@ -1307,8 +1309,8 @@ async function getBranchHeadSha(executor, conn, branch) {
1307
1309
  return parsed.data.commit.id;
1308
1310
  }
1309
1311
  async function branchExists(executor, conn, branch) {
1310
- const url = `${repoBase$1(conn)}/branches/${encodeURIComponent(branch)}`;
1311
- const res = await executor.fetch(url, { headers: authHeaders$1(conn) });
1312
+ const url = `${repoBase(conn)}/branches/${encodeURIComponent(branch)}`;
1313
+ const res = await executor.fetch(url, { headers: authHeaders(conn) });
1312
1314
  if (res.status === 404) return false;
1313
1315
  if (!res.ok) await throwForgejoFailure(res, {
1314
1316
  verb: "check branch",
@@ -1441,17 +1443,69 @@ async function readPollChecks(fetcher, conn, sha) {
1441
1443
  return { failureDetail: describeError(error) };
1442
1444
  }
1443
1445
  }
1446
+ async function settleChecks(fetcher, conn, poll, round) {
1447
+ const { timeoutMs, intervalMs, sleep, now } = poll;
1448
+ const startedAt = now();
1449
+ const elapsed = () => now() - startedAt;
1450
+ let consecutiveFailures = 0;
1451
+ let lastFailureDetail;
1452
+ let lastGoodChecks;
1453
+ let previousSettledContexts = null;
1454
+ for (;;) {
1455
+ const sha = round.sha();
1456
+ const { checks, failureDetail } = await readPollChecks(fetcher, conn, sha);
1457
+ if (failureDetail !== void 0) lastFailureDetail = failureDetail;
1458
+ if (checks === void 0) {
1459
+ consecutiveFailures += 1;
1460
+ if (consecutiveFailures >= 3) return round.report(EMPTY_CHECKS, {
1461
+ exit: "retry-budget",
1462
+ elapsed,
1463
+ failureDetail: lastFailureDetail
1464
+ });
1465
+ } else {
1466
+ consecutiveFailures = 0;
1467
+ lastGoodChecks = checks;
1468
+ pushDisagreementNotice(round.notices, sha, checks);
1469
+ if (isSettled(checks)) {
1470
+ const contexts = new Set(checks.contexts.map((check) => check.context));
1471
+ if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return round.report(checks, {
1472
+ exit: "settled",
1473
+ elapsed
1474
+ });
1475
+ previousSettledContexts = contexts;
1476
+ } else previousSettledContexts = null;
1477
+ }
1478
+ if (elapsed() >= timeoutMs) return round.report(lastGoodChecks ?? EMPTY_CHECKS, {
1479
+ exit: "timeout",
1480
+ elapsed
1481
+ });
1482
+ await sleep(intervalMs);
1483
+ if (round.afterSleep !== void 0) {
1484
+ const after = await round.afterSleep(sha, elapsed);
1485
+ if (after !== "retry" && after !== "next") return after.done;
1486
+ }
1487
+ }
1488
+ }
1489
+ function renderCheckLine(check) {
1490
+ const parts = [check.state, check.context];
1491
+ if (check.description !== null) parts.push(check.description);
1492
+ let line = parts.join(" -- ");
1493
+ if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
1494
+ return line;
1495
+ }
1444
1496
  //#endregion
1445
- //#region src/poll-timings.ts
1446
- const DEFAULT_TIMEOUT_MS = 12e5;
1447
- const DEFAULT_INTERVAL_MS = 15e3;
1448
- const MIN_INTERVAL_MS = 5e3;
1497
+ //#region ../cli-kit/src/env-override.ts
1449
1498
  function envMsOverride(raw, productionMs) {
1450
1499
  if (raw === void 0) return productionMs;
1451
1500
  const parsed = Number(raw.trim());
1452
1501
  if (!Number.isSafeInteger(parsed) || parsed <= 0) return productionMs;
1453
1502
  return Math.min(parsed, productionMs);
1454
1503
  }
1504
+ //#endregion
1505
+ //#region src/poll-timings.ts
1506
+ const DEFAULT_TIMEOUT_MS = 12e5;
1507
+ const DEFAULT_INTERVAL_MS = 15e3;
1508
+ const MIN_INTERVAL_MS = 5e3;
1455
1509
  function resolvePollTimings(options) {
1456
1510
  const timeoutMs = options.timeout !== void 0 ? parseDuration(options.timeout, "--timeout") : DEFAULT_TIMEOUT_MS;
1457
1511
  const requestedIntervalMs = options.interval !== void 0 ? parseDuration(options.interval, "--interval") : DEFAULT_INTERVAL_MS;
@@ -1528,47 +1582,23 @@ function buildReport$1(target, checks, ctx, extras = {}) {
1528
1582
  return report;
1529
1583
  }
1530
1584
  async function waitForSettledChecks$1(fetcher, target, notices, poll) {
1531
- const { timeoutMs, intervalMs, sleep, now } = poll;
1532
- const startedAt = now();
1533
- let consecutiveFailures = 0;
1534
- let lastFailureDetail;
1535
- let lastGoodChecks;
1536
- let previousSettledContexts = null;
1537
- for (;;) {
1538
- const sha = target.sha;
1539
- const { checks, failureDetail } = await readPollChecks(fetcher, target.conn, sha);
1540
- if (failureDetail !== void 0) lastFailureDetail = failureDetail;
1541
- if (checks === void 0) {
1542
- consecutiveFailures += 1;
1543
- if (consecutiveFailures >= 3) return buildReport$1(target, EMPTY_CHECKS, {
1585
+ return settleChecks(fetcher, target.conn, poll, {
1586
+ sha: () => target.sha,
1587
+ notices,
1588
+ report: (checks, extras) => {
1589
+ const extrasForReport = {};
1590
+ if (extras.exit === "timeout") extrasForReport.statusOverride = "timeout";
1591
+ else if (extras.exit === "retry-budget") {
1592
+ extrasForReport.statusOverride = "unparseable";
1593
+ if (extras.failureDetail !== void 0) extrasForReport.detailOverride = extras.failureDetail;
1594
+ }
1595
+ return buildReport$1(target, checks, {
1544
1596
  notices,
1545
1597
  waited: true,
1546
- elapsedMs: now() - startedAt
1547
- }, {
1548
- statusOverride: "unparseable",
1549
- detailOverride: lastFailureDetail
1550
- });
1551
- } else {
1552
- consecutiveFailures = 0;
1553
- lastGoodChecks = checks;
1554
- pushDisagreementNotice(notices, sha, checks);
1555
- if (isSettled(checks)) {
1556
- const contexts = new Set(checks.contexts.map((check) => check.context));
1557
- if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return buildReport$1(target, checks, {
1558
- notices,
1559
- waited: true,
1560
- elapsedMs: now() - startedAt
1561
- });
1562
- previousSettledContexts = contexts;
1563
- } else previousSettledContexts = null;
1598
+ elapsedMs: extras.elapsed()
1599
+ }, extrasForReport);
1564
1600
  }
1565
- if (now() - startedAt >= timeoutMs) return buildReport$1(target, lastGoodChecks ?? EMPTY_CHECKS, {
1566
- notices,
1567
- waited: true,
1568
- elapsedMs: now() - startedAt
1569
- }, { statusOverride: "timeout" });
1570
- await sleep(intervalMs);
1571
- }
1601
+ });
1572
1602
  }
1573
1603
  async function runCommitStatus(fetcher, options) {
1574
1604
  const timings = options.wait === true ? resolvePollTimings(options) : void 0;
@@ -1588,13 +1618,7 @@ async function runCommitStatus(fetcher, options) {
1588
1618
  });
1589
1619
  }
1590
1620
  function renderCommitStatus(report) {
1591
- const lines = report.checks.map((check) => {
1592
- const parts = [check.state, check.context];
1593
- if (check.description !== null) parts.push(check.description);
1594
- let line = parts.join(" -- ");
1595
- if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
1596
- return line;
1597
- });
1621
+ const lines = report.checks.map(renderCheckLine);
1598
1622
  const shortSha = FULL_SHA.test(report.sha) ? report.sha.slice(0, 12) : report.sha;
1599
1623
  const redCount = report.checks.filter((c) => c.verdict === "red").length;
1600
1624
  const pendingCount = report.checks.filter((c) => c.verdict === "pending").length;
@@ -1688,7 +1712,7 @@ const LABELS_MAX_PAGES = 20;
1688
1712
  async function fetchAllForgejoLabels(executor, conn) {
1689
1713
  const labels = [];
1690
1714
  for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
1691
- const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels?limit=${String(LABELS_PAGE_SIZE)}&page=${String(page)}`, { headers: authHeaders$2(conn) });
1715
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels?limit=${String(LABELS_PAGE_SIZE)}&page=${String(page)}`, { headers: authHeaders(conn) });
1692
1716
  if (!res.ok) await throwForgejoFailure(res, {
1693
1717
  verb: "list labels",
1694
1718
  resource: conn.repository
@@ -1712,6 +1736,17 @@ async function resolveLabelIds(executor, conn, names) {
1712
1736
  return match.id;
1713
1737
  });
1714
1738
  }
1739
+ function isRetiringExpired(spec, today) {
1740
+ return today > spec.until;
1741
+ }
1742
+ function retiringAsLabelSpec(spec) {
1743
+ return {
1744
+ name: spec.name,
1745
+ color: spec.color,
1746
+ description: spec.description,
1747
+ exclusive: false
1748
+ };
1749
+ }
1715
1750
  const LabelSpecSchema = z.strictObject({
1716
1751
  name: z.string().min(1),
1717
1752
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
@@ -1761,13 +1796,14 @@ function labelScope(name) {
1761
1796
  const slash = name.indexOf("/");
1762
1797
  return slash === -1 ? null : name.slice(0, slash + 1);
1763
1798
  }
1764
- function staleLabelNames(specs, live) {
1799
+ function staleLabelNames(specs, live, retiring = []) {
1765
1800
  const owned = /* @__PURE__ */ new Set();
1766
1801
  for (const spec of specs) {
1767
1802
  const scope = labelScope(spec.name);
1768
1803
  if (scope !== null) owned.add(scope);
1769
1804
  }
1770
1805
  const wanted = new Set(specs.map((spec) => spec.name));
1806
+ for (const spec of retiring) wanted.add(spec.name);
1771
1807
  const stale = [];
1772
1808
  for (const name of live) {
1773
1809
  if (wanted.has(name)) continue;
@@ -1828,7 +1864,7 @@ async function ensureForgejoLabels(executor, conn, specs) {
1828
1864
  async function deleteForgejoLabel(executor, conn, id) {
1829
1865
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels/${String(id)}`, {
1830
1866
  method: "DELETE",
1831
- headers: authHeaders$2(conn)
1867
+ headers: authHeaders(conn)
1832
1868
  }, { idempotent: true });
1833
1869
  if (!res.ok) await throwForgejoFailure(res, {
1834
1870
  verb: "delete label",
@@ -1881,7 +1917,7 @@ function normalizeIssue(raw) {
1881
1917
  };
1882
1918
  }
1883
1919
  async function getForgejoIssue(executor, conn, number) {
1884
- const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}`, { headers: authHeaders$2(conn) });
1920
+ const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}`, { headers: authHeaders(conn) });
1885
1921
  if (!res.ok) await throwForgejoFailure(res, {
1886
1922
  verb: "get issue",
1887
1923
  resource: `#${String(number)}`
@@ -1905,7 +1941,7 @@ function issueListParams(type, query) {
1905
1941
  return params;
1906
1942
  }
1907
1943
  async function fetchIssueListPage(executor, conn, params) {
1908
- const res = await executor.fetch(`${issuesBase(conn)}?${params.toString()}`, { headers: authHeaders$2(conn) });
1944
+ const res = await executor.fetch(`${issuesBase(conn)}?${params.toString()}`, { headers: authHeaders(conn) });
1909
1945
  if (!res.ok) await throwForgejoFailure(res, {
1910
1946
  verb: "list issues",
1911
1947
  resource: conn.repository
@@ -1939,6 +1975,29 @@ async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
1939
1975
  }
1940
1976
  return issues;
1941
1977
  }
1978
+ async function listOpenObjectsWithLabel(executor, conn, label) {
1979
+ const objects = [];
1980
+ for (let page = 1; page <= DETAILED_LIST_MAX_PAGES; page++) {
1981
+ const { raw } = await fetchIssueListPage(executor, conn, issueListParams("all", {
1982
+ state: "open",
1983
+ label,
1984
+ limit: DETAILED_LIST_PAGE_SIZE,
1985
+ page
1986
+ }));
1987
+ for (const issue of raw.map(normalizeIssue)) {
1988
+ if (!issue.labels.includes(label)) continue;
1989
+ objects.push({
1990
+ number: issue.number,
1991
+ kind: issue.isPullRequest ? "pull" : "issue",
1992
+ title: issue.title,
1993
+ url: issue.url
1994
+ });
1995
+ }
1996
+ if (raw.length === 0) break;
1997
+ if (page === DETAILED_LIST_MAX_PAGES) log.warn(`Open-object listing on ${conn.repository} hit the ${String(DETAILED_LIST_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
1998
+ }
1999
+ return objects;
2000
+ }
1942
2001
  const USER_REPOS_PAGE_SIZE = 50;
1943
2002
  const USER_REPOS_MAX_PAGES = 20;
1944
2003
  const UserRepoSchema = z.object({
@@ -2059,24 +2118,77 @@ function outcomeFromAudit(conn, specs, audit, lists) {
2059
2118
  ...lists
2060
2119
  };
2061
2120
  }
2121
+ async function checkPruneCandidate(fetcher, conn, name) {
2122
+ let objects;
2123
+ try {
2124
+ objects = await listOpenObjectsWithLabel(fetcher, conn, name);
2125
+ } catch (error) {
2126
+ return {
2127
+ ok: false,
2128
+ reason: `could not check open objects: ${describeError(error)}`,
2129
+ objects: []
2130
+ };
2131
+ }
2132
+ if (objects.length === 0) return { ok: true };
2133
+ return {
2134
+ ok: false,
2135
+ reason: `on ${String(objects.length)} open object(s)`,
2136
+ objects
2137
+ };
2138
+ }
2062
2139
  async function syncOneRepo(params) {
2063
- const { fetcher, conn, specs, mode, prune, allowedPruneNames, resolveTargetSpecs } = params;
2140
+ const { fetcher, conn, specs, mode, prune, allowedPruneNames, resolveTargetSpecs, retiring, today } = params;
2064
2141
  const target = resolveTargetSpecs === void 0 ? {
2065
2142
  specs,
2066
2143
  omitted: [],
2067
2144
  aliasNames: []
2068
2145
  } : await resolveTargetSpecs(conn, specs);
2069
- const audit = await auditForgejoLabels(fetcher, conn, target.specs);
2070
- const stale = staleLabelNames(specs, audit.labelIds.keys()).filter((name) => !target.aliasNames.includes(name));
2071
- const pruneCandidates = prune ? [...target.omitted, ...stale].filter((name) => audit.labelIds.has(name) && (allowedPruneNames === void 0 || allowedPruneNames.includes(name))) : [];
2146
+ const retiringNames = new Set(retiring.map((spec) => spec.name));
2147
+ const fullAudit = await auditForgejoLabels(fetcher, conn, [...target.specs, ...retiring.map(retiringAsLabelSpec)]);
2148
+ const presentRetiring = retiring.filter((spec) => fullAudit.labelIds.has(spec.name));
2149
+ const audit = {
2150
+ ...fullAudit,
2151
+ missing: fullAudit.missing.filter((name) => !retiringNames.has(name))
2152
+ };
2153
+ const stale = staleLabelNames(specs, audit.labelIds.keys(), retiring).filter((name) => !target.aliasNames.includes(name));
2154
+ const expiredRetiring = presentRetiring.filter((spec) => isRetiringExpired(spec, today)).map((spec) => spec.name);
2155
+ const pruneCandidates = prune ? [
2156
+ ...target.omitted,
2157
+ ...stale,
2158
+ ...expiredRetiring
2159
+ ].filter((name) => audit.labelIds.has(name) && (allowedPruneNames === void 0 || allowedPruneNames.includes(name))) : [];
2160
+ const pruned = [];
2161
+ const refused = [];
2162
+ for (const name of pruneCandidates) {
2163
+ const verdict = await checkPruneCandidate(fetcher, conn, name);
2164
+ if (verdict.ok) {
2165
+ pruned.push(name);
2166
+ continue;
2167
+ }
2168
+ refused.push({
2169
+ name,
2170
+ reason: verdict.reason,
2171
+ objects: verdict.objects
2172
+ });
2173
+ }
2174
+ const retiringReports = [];
2175
+ for (const spec of presentRetiring) retiringReports.push({
2176
+ name: spec.name,
2177
+ replacement: spec.replacement,
2178
+ until: spec.until,
2179
+ expired: isRetiringExpired(spec, today),
2180
+ openObjects: await listOpenObjectsWithLabel(fetcher, conn, spec.name)
2181
+ });
2072
2182
  const outcome = outcomeFromAudit(conn, target.specs, audit, {
2073
2183
  omitted: target.omitted,
2074
- pruned: pruneCandidates,
2075
- stale: prune ? stale.filter((name) => !pruneCandidates.includes(name)) : stale
2184
+ pruned,
2185
+ stale: prune ? stale.filter((name) => !pruneCandidates.includes(name)) : stale,
2186
+ retiring: retiringReports,
2187
+ refused
2076
2188
  });
2077
2189
  if (mode === "apply") {
2078
- if (outcome.created.length > 0 || outcome.updated.length > 0) await ensureForgejoLabels(fetcher, conn, target.specs);
2079
- for (const name of pruneCandidates) {
2190
+ if (outcome.created.length > 0 || outcome.updated.length > 0) await ensureForgejoLabels(fetcher, conn, [...target.specs, ...presentRetiring.map(retiringAsLabelSpec)]);
2191
+ for (const name of pruned) {
2080
2192
  const id = audit.labelIds.get(name);
2081
2193
  if (id === void 0) throw new UnexpectedError(`Prune candidate "${name}" had no known id after the audit`);
2082
2194
  await deleteForgejoLabel(fetcher, conn, id);
@@ -2086,6 +2198,9 @@ async function syncOneRepo(params) {
2086
2198
  }
2087
2199
  async function syncLabelSet(params) {
2088
2200
  const { fetcher, targets, specs, setName, mode, contained, prune, pruneAllowlist } = params;
2201
+ const retiring = params.retiring ?? [];
2202
+ const today = params.today ?? "";
2203
+ if (retiring.length > 0 && params.today === void 0) throw new UnexpectedError("syncLabelSet was given retiring label specs but no `today` to compare their expiry against: an expired entry is a prune candidate, and a guessed date could delete a name whose retirement window is still open.");
2089
2204
  const specList = [...specs];
2090
2205
  const repos = [];
2091
2206
  const failures = [];
@@ -2098,7 +2213,9 @@ async function syncLabelSet(params) {
2098
2213
  mode,
2099
2214
  prune: prune === true,
2100
2215
  allowedPruneNames,
2101
- resolveTargetSpecs: params.resolveTargetSpecs
2216
+ resolveTargetSpecs: params.resolveTargetSpecs,
2217
+ retiring,
2218
+ today
2102
2219
  };
2103
2220
  if (contained !== true) {
2104
2221
  repos.push(await syncOneRepo(syncOneRepoParams));
@@ -2131,6 +2248,11 @@ function syncSummaryLines(result) {
2131
2248
  if (repo.omitted.length > 0) actions.push(`omitted (repo declares its own priority vocabulary): ${repo.omitted.join(", ")}`);
2132
2249
  if (repo.pruned.length > 0) actions.push(`${verb}prune: ${repo.pruned.join(", ")}`);
2133
2250
  if (repo.stale.length > 0) actions.push(`stale (present, not in this set): ${repo.stale.join(", ")}`);
2251
+ for (const refusal of repo.refused) {
2252
+ const objects = refusal.objects.map((object) => `#${String(object.number)} (${object.kind}) ${object.title}`).join(", ");
2253
+ actions.push(`refused to prune ${refusal.name}: ${refusal.reason}${objects === "" ? "" : ` (${objects})`}`);
2254
+ }
2255
+ for (const retiring of repo.retiring) actions.push(`retiring ${retiring.name}: replaced by ${retiring.replacement}, prunable after ${retiring.until}${retiring.expired ? " (expired)" : ""}; ${String(retiring.openObjects.length)} open object(s)`);
2134
2256
  if (actions.length === 0) {
2135
2257
  repoLines.push(`${repo.repository}: up to date (${String(repo.unchanged.length)} unchanged)`);
2136
2258
  continue;
@@ -2270,7 +2392,7 @@ const syncCommand = defineCommand({
2270
2392
  },
2271
2393
  json: {
2272
2394
  type: "boolean",
2273
- 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."
2395
+ description: "Emit one JSON document: set, mode (apply | check | dry-run), repos ([{repository, created, updated, unchanged, omitted, stale, retiring, refused}], every field but repository and those two a list of label names; retiring and refused are always empty here, since this command never prunes), 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."
2274
2396
  }
2275
2397
  },
2276
2398
  async run({ args, rawArgs }) {
@@ -2824,7 +2946,7 @@ const AuthenticatedUserSchema = z.object({
2824
2946
  });
2825
2947
  async function getAuthenticatedUser(executor, conn, signal = null) {
2826
2948
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, {
2827
- headers: authHeaders$2(conn),
2949
+ headers: authHeaders(conn),
2828
2950
  signal
2829
2951
  });
2830
2952
  if (!res.ok) return {
@@ -3370,11 +3492,6 @@ const ciLogCommand = defineCommand({
3370
3492
  });
3371
3493
  //#endregion
3372
3494
  //#region src/ci-runs.ts
3373
- function parseLimit$2(raw) {
3374
- const limit = Number(raw);
3375
- if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
3376
- return limit;
3377
- }
3378
3495
  function resolveRunsRef(ref, pr) {
3379
3496
  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).");
3380
3497
  if (pr !== void 0) {
@@ -3399,7 +3516,7 @@ function resolveRunsRef(ref, pr) {
3399
3516
  };
3400
3517
  }
3401
3518
  async function runCiRuns(fetcher, options) {
3402
- const limit = parseLimit$2(options.limit);
3519
+ const limit = parseLimitFlag(options.limit);
3403
3520
  const resolved = resolveRunsRef(options.ref, options.pr);
3404
3521
  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.");
3405
3522
  rootLogger.debug(`ci:runs repository=${conn.repository} limit=${String(limit)}`);
@@ -3644,12 +3761,6 @@ const BranchEntrySchema = z.object({
3644
3761
  });
3645
3762
  const BRANCHES_PAGE_SIZE = 50;
3646
3763
  const DEFAULT_MAX_PAGES = 20;
3647
- function repoBase(conn) {
3648
- return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
3649
- }
3650
- function authHeaders(conn) {
3651
- return { Authorization: `token ${conn.token}` };
3652
- }
3653
3764
  async function listBranches(executor, conn, options = {}) {
3654
3765
  const signal = options.signal;
3655
3766
  const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
@@ -3879,7 +3990,7 @@ async function listForgejoComments(executor, conn, number, options = {}) {
3879
3990
  limit: String(COMMENTS_PAGE_SIZE),
3880
3991
  page: String(page)
3881
3992
  });
3882
- const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/comments?${params.toString()}`, { headers: authHeaders$2(conn) });
3993
+ const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/comments?${params.toString()}`, { headers: authHeaders(conn) });
3883
3994
  if (!res.ok) await throwForgejoFailure(res, {
3884
3995
  verb: "list comments",
3885
3996
  resource: `#${String(number)}`
@@ -4362,7 +4473,7 @@ function parseState$1(state) {
4362
4473
  if (state === "open" || state === "closed" || state === "all") return state;
4363
4474
  throw new InvalidListQueryError("state", state);
4364
4475
  }
4365
- function parseLimit$1(raw) {
4476
+ function parseLimit(raw) {
4366
4477
  const limit = Number(raw);
4367
4478
  if (!Number.isInteger(limit) || limit <= 0) throw new InvalidListQueryError("limit", raw);
4368
4479
  return limit;
@@ -4375,7 +4486,7 @@ async function listIssues(resolve, request) {
4375
4486
  const label = normalizeLabel(request.label);
4376
4487
  const query = {
4377
4488
  state: parseState$1(request.state),
4378
- limit: parseLimit$1(request.limit),
4489
+ limit: parseLimit(request.limit),
4379
4490
  ...label === void 0 ? {} : { label }
4380
4491
  };
4381
4492
  const { api } = resolve(parseRepoTarget(request.repo));
@@ -4857,7 +4968,7 @@ async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
4857
4968
  const id = await resolveLabelId(executor, conn, name, labelIds);
4858
4969
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
4859
4970
  method: "DELETE",
4860
- headers: authHeaders$2(conn)
4971
+ headers: authHeaders(conn)
4861
4972
  }, { idempotent: true });
4862
4973
  if (!res.ok) await throwForgejoFailure(res, {
4863
4974
  verb: "remove label",
@@ -5521,75 +5632,45 @@ function buildReport(detail, checks, ctx, extras = {}) {
5521
5632
  return report;
5522
5633
  }
5523
5634
  async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
5524
- const { timeoutMs, intervalMs, sleep, now } = poll;
5525
- const startedAt = now();
5526
5635
  let currentDetail = target.detail;
5527
- let consecutiveFailures = 0;
5528
5636
  let consecutiveDetailFailures = 0;
5529
- let lastFailureDetail;
5530
- let lastGoodChecks;
5531
- let previousSettledContexts = null;
5532
- for (;;) {
5533
- const sha = currentDetail.head.sha;
5534
- const { checks, failureDetail } = await readPollChecks(fetcher, target.conn, sha);
5535
- if (failureDetail !== void 0) lastFailureDetail = failureDetail;
5536
- if (checks === void 0) {
5537
- consecutiveFailures += 1;
5538
- if (consecutiveFailures >= 3) return buildReport(currentDetail, EMPTY_CHECKS, {
5539
- target,
5540
- baseline,
5541
- notices,
5542
- waited: true,
5543
- elapsedMs: now() - startedAt
5544
- }, {
5545
- statusOverride: "unparseable",
5546
- detailOverride: lastFailureDetail
5547
- });
5548
- } else {
5549
- consecutiveFailures = 0;
5550
- lastGoodChecks = checks;
5551
- pushDisagreementNotice(notices, sha, checks);
5552
- if (isSettled(checks)) {
5553
- const contexts = new Set(checks.contexts.map((check) => check.context));
5554
- if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return buildReport(currentDetail, checks, {
5555
- target,
5556
- baseline,
5557
- notices,
5558
- waited: true,
5559
- elapsedMs: now() - startedAt
5560
- });
5561
- previousSettledContexts = contexts;
5562
- } else previousSettledContexts = null;
5563
- }
5564
- if (now() - startedAt >= timeoutMs) return buildReport(currentDetail, lastGoodChecks ?? EMPTY_CHECKS, {
5565
- target,
5566
- baseline,
5567
- notices,
5568
- waited: true,
5569
- elapsedMs: now() - startedAt
5570
- }, { statusOverride: "timeout" });
5571
- await sleep(intervalMs);
5572
- let nextDetail;
5573
- try {
5574
- nextDetail = await getForgejoPrDetail(fetcher, target.conn, target.prNumber);
5575
- consecutiveDetailFailures = 0;
5576
- } catch (error) {
5577
- consecutiveDetailFailures += 1;
5578
- if (consecutiveDetailFailures >= 3) throw error;
5579
- continue;
5637
+ const reportFor = (detail, checks, elapsedMs, extras = {}) => buildReport(detail, checks, {
5638
+ target,
5639
+ baseline,
5640
+ notices,
5641
+ waited: true,
5642
+ elapsedMs
5643
+ }, extras);
5644
+ return settleChecks(fetcher, target.conn, poll, {
5645
+ sha: () => currentDetail.head.sha,
5646
+ notices,
5647
+ report: (checks, extras) => {
5648
+ const reportExtras = {};
5649
+ if (extras.exit === "timeout") reportExtras.statusOverride = "timeout";
5650
+ else if (extras.exit === "retry-budget") {
5651
+ reportExtras.statusOverride = "unparseable";
5652
+ if (extras.failureDetail !== void 0) reportExtras.detailOverride = extras.failureDetail;
5653
+ }
5654
+ return reportFor(currentDetail, checks, extras.elapsed(), reportExtras);
5655
+ },
5656
+ afterSleep: async (sha, elapsed) => {
5657
+ let nextDetail;
5658
+ try {
5659
+ nextDetail = await getForgejoPrDetail(fetcher, target.conn, target.prNumber);
5660
+ consecutiveDetailFailures = 0;
5661
+ } catch (error) {
5662
+ consecutiveDetailFailures += 1;
5663
+ if (consecutiveDetailFailures >= 3) throw error;
5664
+ return "retry";
5665
+ }
5666
+ if (nextDetail.head.sha !== sha) return { done: reportFor(nextDetail, EMPTY_CHECKS, elapsed(), {
5667
+ statusOverride: "head-moved",
5668
+ previousHeadSha: sha
5669
+ }) };
5670
+ currentDetail = nextDetail;
5671
+ return "next";
5580
5672
  }
5581
- if (nextDetail.head.sha !== sha) return buildReport(nextDetail, EMPTY_CHECKS, {
5582
- target,
5583
- baseline,
5584
- notices,
5585
- waited: true,
5586
- elapsedMs: now() - startedAt
5587
- }, {
5588
- statusOverride: "head-moved",
5589
- previousHeadSha: sha
5590
- });
5591
- currentDetail = nextDetail;
5592
- }
5673
+ });
5593
5674
  }
5594
5675
  async function runPrChecks(fetcher, options) {
5595
5676
  const timings = options.wait === true ? resolvePollTimings(options) : void 0;
@@ -5622,13 +5703,7 @@ function baselineClause(report) {
5622
5703
  return `${String(redChecks.length)} red, ${String(preexistingCount)} of them already red on ` + report.baseline.ref;
5623
5704
  }
5624
5705
  function renderPrChecks(report) {
5625
- const lines = report.checks.map((check) => {
5626
- const parts = [check.state, check.context];
5627
- if (check.description !== null) parts.push(check.description);
5628
- let line = parts.join(" -- ");
5629
- if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
5630
- return line;
5631
- });
5706
+ const lines = report.checks.map((check) => renderCheckLine(check));
5632
5707
  const summaryParts = [`head ${report.headSha.slice(0, 12)}: ${report.status}`];
5633
5708
  const clause = baselineClause(report);
5634
5709
  if (clause !== null) summaryParts.push(clause);
@@ -6543,14 +6618,9 @@ function parseState(state) {
6543
6618
  if (state === "open" || state === "closed" || state === "all") return state;
6544
6619
  throw new FatalError(`Invalid --state "${state}" (expected open, closed, or all).`);
6545
6620
  }
6546
- function parseLimit(raw) {
6547
- const limit = Number(raw);
6548
- if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
6549
- return limit;
6550
- }
6551
6621
  async function runPrList(fetcher, options) {
6552
6622
  const state = parseState(options.state);
6553
- const limit = parseLimit(options.limit);
6623
+ const limit = parseLimitFlag(options.limit);
6554
6624
  rootLogger.debug(`pr:list state=${state} limit=${String(limit)}`);
6555
6625
  const conn = requireForgejoTarget(options.cwd, parseRepoTarget(options.repo), "pr:list is Forgejo-only -- gh pr list already serves GitHub, first-class. Use that instead.");
6556
6626
  const notices = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/forgejo-cli",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "bst-fj: Forgejo API commands without the rest of bst",
5
5
  "license": "MIT",
6
6
  "repository": {