@bensandee/forgejo-cli 0.5.5 → 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 +263 -183
  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.5",
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;
@@ -1664,6 +1688,16 @@ const commitStatusCommand = defineCommand({
1664
1688
  }
1665
1689
  });
1666
1690
  //#endregion
1691
+ //#region ../forgejo/src/fetcher.ts
1692
+ const LONG_FETCH_TIMEOUT_MS = 6e5;
1693
+ function morePagesFollow(res, pageLength, received, pageSize) {
1694
+ if (pageLength === 0) return false;
1695
+ const raw = res.headers.get("x-total-count")?.trim() ?? "";
1696
+ const total = raw === "" ? NaN : Number(raw);
1697
+ if (!Number.isSafeInteger(total) || total < 0) return pageLength >= pageSize;
1698
+ return received < total;
1699
+ }
1700
+ //#endregion
1667
1701
  //#region ../forgejo/src/label-provisioning.ts
1668
1702
  const LabelSchema = z.object({
1669
1703
  id: z.number(),
@@ -1673,12 +1707,12 @@ const LabelSchema = z.object({
1673
1707
  exclusive: z.boolean().optional()
1674
1708
  });
1675
1709
  const LabelListSchema = z.array(LabelSchema);
1676
- const LABELS_PAGE_SIZE = 100;
1710
+ const LABELS_PAGE_SIZE = 50;
1677
1711
  const LABELS_MAX_PAGES = 20;
1678
1712
  async function fetchAllForgejoLabels(executor, conn) {
1679
1713
  const labels = [];
1680
1714
  for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
1681
- 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) });
1682
1716
  if (!res.ok) await throwForgejoFailure(res, {
1683
1717
  verb: "list labels",
1684
1718
  resource: conn.repository
@@ -1686,7 +1720,7 @@ async function fetchAllForgejoLabels(executor, conn) {
1686
1720
  const parsed = LabelListSchema.safeParse(await res.json());
1687
1721
  if (!parsed.success) throw new UnexpectedError(`Unexpected label-list response: ${parsed.error.message}`);
1688
1722
  labels.push(...parsed.data);
1689
- if (parsed.data.length < LABELS_PAGE_SIZE) break;
1723
+ if (!morePagesFollow(res, parsed.data.length, labels.length, LABELS_PAGE_SIZE)) break;
1690
1724
  }
1691
1725
  return labels;
1692
1726
  }
@@ -1702,6 +1736,17 @@ async function resolveLabelIds(executor, conn, names) {
1702
1736
  return match.id;
1703
1737
  });
1704
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
+ }
1705
1750
  const LabelSpecSchema = z.strictObject({
1706
1751
  name: z.string().min(1),
1707
1752
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
@@ -1751,13 +1796,14 @@ function labelScope(name) {
1751
1796
  const slash = name.indexOf("/");
1752
1797
  return slash === -1 ? null : name.slice(0, slash + 1);
1753
1798
  }
1754
- function staleLabelNames(specs, live) {
1799
+ function staleLabelNames(specs, live, retiring = []) {
1755
1800
  const owned = /* @__PURE__ */ new Set();
1756
1801
  for (const spec of specs) {
1757
1802
  const scope = labelScope(spec.name);
1758
1803
  if (scope !== null) owned.add(scope);
1759
1804
  }
1760
1805
  const wanted = new Set(specs.map((spec) => spec.name));
1806
+ for (const spec of retiring) wanted.add(spec.name);
1761
1807
  const stale = [];
1762
1808
  for (const name of live) {
1763
1809
  if (wanted.has(name)) continue;
@@ -1818,7 +1864,7 @@ async function ensureForgejoLabels(executor, conn, specs) {
1818
1864
  async function deleteForgejoLabel(executor, conn, id) {
1819
1865
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels/${String(id)}`, {
1820
1866
  method: "DELETE",
1821
- headers: authHeaders$2(conn)
1867
+ headers: authHeaders(conn)
1822
1868
  }, { idempotent: true });
1823
1869
  if (!res.ok) await throwForgejoFailure(res, {
1824
1870
  verb: "delete label",
@@ -1871,7 +1917,7 @@ function normalizeIssue(raw) {
1871
1917
  };
1872
1918
  }
1873
1919
  async function getForgejoIssue(executor, conn, number) {
1874
- 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) });
1875
1921
  if (!res.ok) await throwForgejoFailure(res, {
1876
1922
  verb: "get issue",
1877
1923
  resource: `#${String(number)}`
@@ -1895,12 +1941,15 @@ function issueListParams(type, query) {
1895
1941
  return params;
1896
1942
  }
1897
1943
  async function fetchIssueListPage(executor, conn, params) {
1898
- 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) });
1899
1945
  if (!res.ok) await throwForgejoFailure(res, {
1900
1946
  verb: "list issues",
1901
1947
  resource: conn.repository
1902
1948
  }, repoNotFoundMessage(conn));
1903
- return parseIssueListOrThrow(await res.json());
1949
+ return {
1950
+ raw: parseIssueListOrThrow(await res.json()),
1951
+ res
1952
+ };
1904
1953
  }
1905
1954
  function normalizeDetailedIssues(raw, type) {
1906
1955
  const normalized = raw.map(normalizeIssue);
@@ -1911,7 +1960,7 @@ const DETAILED_LIST_MAX_PAGES = 20;
1911
1960
  async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
1912
1961
  const issues = [];
1913
1962
  for (let page = 1; page <= DETAILED_LIST_MAX_PAGES; page++) {
1914
- const raw = await fetchIssueListPage(executor, conn, issueListParams(query.type, {
1963
+ const { raw } = await fetchIssueListPage(executor, conn, issueListParams(query.type, {
1915
1964
  ...query,
1916
1965
  limit: DETAILED_LIST_PAGE_SIZE,
1917
1966
  page
@@ -1926,6 +1975,29 @@ async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
1926
1975
  }
1927
1976
  return issues;
1928
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
+ }
1929
2001
  const USER_REPOS_PAGE_SIZE = 50;
1930
2002
  const USER_REPOS_MAX_PAGES = 20;
1931
2003
  const UserRepoSchema = z.object({
@@ -2046,24 +2118,77 @@ function outcomeFromAudit(conn, specs, audit, lists) {
2046
2118
  ...lists
2047
2119
  };
2048
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
+ }
2049
2139
  async function syncOneRepo(params) {
2050
- const { fetcher, conn, specs, mode, prune, allowedPruneNames, resolveTargetSpecs } = params;
2140
+ const { fetcher, conn, specs, mode, prune, allowedPruneNames, resolveTargetSpecs, retiring, today } = params;
2051
2141
  const target = resolveTargetSpecs === void 0 ? {
2052
2142
  specs,
2053
2143
  omitted: [],
2054
2144
  aliasNames: []
2055
2145
  } : await resolveTargetSpecs(conn, specs);
2056
- const audit = await auditForgejoLabels(fetcher, conn, target.specs);
2057
- const stale = staleLabelNames(specs, audit.labelIds.keys()).filter((name) => !target.aliasNames.includes(name));
2058
- 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
+ });
2059
2182
  const outcome = outcomeFromAudit(conn, target.specs, audit, {
2060
2183
  omitted: target.omitted,
2061
- pruned: pruneCandidates,
2062
- 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
2063
2188
  });
2064
2189
  if (mode === "apply") {
2065
- if (outcome.created.length > 0 || outcome.updated.length > 0) await ensureForgejoLabels(fetcher, conn, target.specs);
2066
- 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) {
2067
2192
  const id = audit.labelIds.get(name);
2068
2193
  if (id === void 0) throw new UnexpectedError(`Prune candidate "${name}" had no known id after the audit`);
2069
2194
  await deleteForgejoLabel(fetcher, conn, id);
@@ -2073,6 +2198,9 @@ async function syncOneRepo(params) {
2073
2198
  }
2074
2199
  async function syncLabelSet(params) {
2075
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.");
2076
2204
  const specList = [...specs];
2077
2205
  const repos = [];
2078
2206
  const failures = [];
@@ -2085,7 +2213,9 @@ async function syncLabelSet(params) {
2085
2213
  mode,
2086
2214
  prune: prune === true,
2087
2215
  allowedPruneNames,
2088
- resolveTargetSpecs: params.resolveTargetSpecs
2216
+ resolveTargetSpecs: params.resolveTargetSpecs,
2217
+ retiring,
2218
+ today
2089
2219
  };
2090
2220
  if (contained !== true) {
2091
2221
  repos.push(await syncOneRepo(syncOneRepoParams));
@@ -2118,6 +2248,11 @@ function syncSummaryLines(result) {
2118
2248
  if (repo.omitted.length > 0) actions.push(`omitted (repo declares its own priority vocabulary): ${repo.omitted.join(", ")}`);
2119
2249
  if (repo.pruned.length > 0) actions.push(`${verb}prune: ${repo.pruned.join(", ")}`);
2120
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)`);
2121
2256
  if (actions.length === 0) {
2122
2257
  repoLines.push(`${repo.repository}: up to date (${String(repo.unchanged.length)} unchanged)`);
2123
2258
  continue;
@@ -2257,7 +2392,7 @@ const syncCommand = defineCommand({
2257
2392
  },
2258
2393
  json: {
2259
2394
  type: "boolean",
2260
- 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."
2261
2396
  }
2262
2397
  },
2263
2398
  async run({ args, rawArgs }) {
@@ -2301,9 +2436,6 @@ const labelsCommand = defineCommand({
2301
2436
  }
2302
2437
  });
2303
2438
  //#endregion
2304
- //#region ../forgejo/src/fetcher.ts
2305
- const LONG_FETCH_TIMEOUT_MS = 6e5;
2306
- //#endregion
2307
2439
  //#region ../forgejo/src/releases.ts
2308
2440
  const SETTLE_ATTEMPTS = 4;
2309
2441
  const SETTLE_BASE_DELAY_MS = 2e3;
@@ -2814,7 +2946,7 @@ const AuthenticatedUserSchema = z.object({
2814
2946
  });
2815
2947
  async function getAuthenticatedUser(executor, conn, signal = null) {
2816
2948
  const res = await executor.fetch(`${conn.serverUrl}/api/v1/user`, {
2817
- headers: authHeaders$2(conn),
2949
+ headers: authHeaders(conn),
2818
2950
  signal
2819
2951
  });
2820
2952
  if (!res.ok) return {
@@ -2891,7 +3023,7 @@ async function paginate(executor, conn, endpoint, verb, extract) {
2891
3023
  }));
2892
3024
  const items = extract(await res.json());
2893
3025
  all.push(...items);
2894
- if (items.length < PAGE_LIMIT) break;
3026
+ if (!morePagesFollow(res, items.length, all.length, PAGE_LIMIT)) break;
2895
3027
  if (page === 101) throw new UnexpectedError(`${endpoint} still returns full pages after ${String(MAX_PAGES)} pages; refusing to report a prefix of it`);
2896
3028
  }
2897
3029
  return all;
@@ -3360,11 +3492,6 @@ const ciLogCommand = defineCommand({
3360
3492
  });
3361
3493
  //#endregion
3362
3494
  //#region src/ci-runs.ts
3363
- function parseLimit$2(raw) {
3364
- const limit = Number(raw);
3365
- if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
3366
- return limit;
3367
- }
3368
3495
  function resolveRunsRef(ref, pr) {
3369
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).");
3370
3497
  if (pr !== void 0) {
@@ -3389,7 +3516,7 @@ function resolveRunsRef(ref, pr) {
3389
3516
  };
3390
3517
  }
3391
3518
  async function runCiRuns(fetcher, options) {
3392
- const limit = parseLimit$2(options.limit);
3519
+ const limit = parseLimitFlag(options.limit);
3393
3520
  const resolved = resolveRunsRef(options.ref, options.pr);
3394
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.");
3395
3522
  rootLogger.debug(`ci:runs repository=${conn.repository} limit=${String(limit)}`);
@@ -3634,12 +3761,6 @@ const BranchEntrySchema = z.object({
3634
3761
  });
3635
3762
  const BRANCHES_PAGE_SIZE = 50;
3636
3763
  const DEFAULT_MAX_PAGES = 20;
3637
- function repoBase(conn) {
3638
- return `${conn.serverUrl}/api/v1/repos/${conn.repository}`;
3639
- }
3640
- function authHeaders(conn) {
3641
- return { Authorization: `token ${conn.token}` };
3642
- }
3643
3764
  async function listBranches(executor, conn, options = {}) {
3644
3765
  const signal = options.signal;
3645
3766
  const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
@@ -3869,14 +3990,14 @@ async function listForgejoComments(executor, conn, number, options = {}) {
3869
3990
  limit: String(COMMENTS_PAGE_SIZE),
3870
3991
  page: String(page)
3871
3992
  });
3872
- 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) });
3873
3994
  if (!res.ok) await throwForgejoFailure(res, {
3874
3995
  verb: "list comments",
3875
3996
  resource: `#${String(number)}`
3876
3997
  }, issueNotFoundMessage(conn, number));
3877
3998
  const parsed = parseCommentListOrThrow(await res.json());
3878
3999
  comments.push(...parsed.map(normalizeComment));
3879
- if (parsed.length < COMMENTS_PAGE_SIZE) break;
4000
+ if (!morePagesFollow(res, parsed.length, comments.length, COMMENTS_PAGE_SIZE)) break;
3880
4001
  if (page === COMMENTS_MAX_PAGES) {
3881
4002
  log.warn(`Comment listing for #${String(number)} hit the ${String(COMMENTS_MAX_PAGES)}-page cap; results may be truncated`);
3882
4003
  options.onPageCap?.();
@@ -4352,7 +4473,7 @@ function parseState$1(state) {
4352
4473
  if (state === "open" || state === "closed" || state === "all") return state;
4353
4474
  throw new InvalidListQueryError("state", state);
4354
4475
  }
4355
- function parseLimit$1(raw) {
4476
+ function parseLimit(raw) {
4356
4477
  const limit = Number(raw);
4357
4478
  if (!Number.isInteger(limit) || limit <= 0) throw new InvalidListQueryError("limit", raw);
4358
4479
  return limit;
@@ -4365,7 +4486,7 @@ async function listIssues(resolve, request) {
4365
4486
  const label = normalizeLabel(request.label);
4366
4487
  const query = {
4367
4488
  state: parseState$1(request.state),
4368
- limit: parseLimit$1(request.limit),
4489
+ limit: parseLimit(request.limit),
4369
4490
  ...label === void 0 ? {} : { label }
4370
4491
  };
4371
4492
  const { api } = resolve(parseRepoTarget(request.repo));
@@ -4847,7 +4968,7 @@ async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
4847
4968
  const id = await resolveLabelId(executor, conn, name, labelIds);
4848
4969
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
4849
4970
  method: "DELETE",
4850
- headers: authHeaders$2(conn)
4971
+ headers: authHeaders(conn)
4851
4972
  }, { idempotent: true });
4852
4973
  if (!res.ok) await throwForgejoFailure(res, {
4853
4974
  verb: "remove label",
@@ -5511,75 +5632,45 @@ function buildReport(detail, checks, ctx, extras = {}) {
5511
5632
  return report;
5512
5633
  }
5513
5634
  async function waitForSettledChecks(fetcher, target, baseline, notices, poll) {
5514
- const { timeoutMs, intervalMs, sleep, now } = poll;
5515
- const startedAt = now();
5516
5635
  let currentDetail = target.detail;
5517
- let consecutiveFailures = 0;
5518
5636
  let consecutiveDetailFailures = 0;
5519
- let lastFailureDetail;
5520
- let lastGoodChecks;
5521
- let previousSettledContexts = null;
5522
- for (;;) {
5523
- const sha = currentDetail.head.sha;
5524
- const { checks, failureDetail } = await readPollChecks(fetcher, target.conn, sha);
5525
- if (failureDetail !== void 0) lastFailureDetail = failureDetail;
5526
- if (checks === void 0) {
5527
- consecutiveFailures += 1;
5528
- if (consecutiveFailures >= 3) return buildReport(currentDetail, EMPTY_CHECKS, {
5529
- target,
5530
- baseline,
5531
- notices,
5532
- waited: true,
5533
- elapsedMs: now() - startedAt
5534
- }, {
5535
- statusOverride: "unparseable",
5536
- detailOverride: lastFailureDetail
5537
- });
5538
- } else {
5539
- consecutiveFailures = 0;
5540
- lastGoodChecks = checks;
5541
- pushDisagreementNotice(notices, sha, checks);
5542
- if (isSettled(checks)) {
5543
- const contexts = new Set(checks.contexts.map((check) => check.context));
5544
- if (previousSettledContexts !== null && previousSettledContexts.size === contexts.size && [...previousSettledContexts].every((context) => contexts.has(context))) return buildReport(currentDetail, checks, {
5545
- target,
5546
- baseline,
5547
- notices,
5548
- waited: true,
5549
- elapsedMs: now() - startedAt
5550
- });
5551
- previousSettledContexts = contexts;
5552
- } else previousSettledContexts = null;
5553
- }
5554
- if (now() - startedAt >= timeoutMs) return buildReport(currentDetail, lastGoodChecks ?? EMPTY_CHECKS, {
5555
- target,
5556
- baseline,
5557
- notices,
5558
- waited: true,
5559
- elapsedMs: now() - startedAt
5560
- }, { statusOverride: "timeout" });
5561
- await sleep(intervalMs);
5562
- let nextDetail;
5563
- try {
5564
- nextDetail = await getForgejoPrDetail(fetcher, target.conn, target.prNumber);
5565
- consecutiveDetailFailures = 0;
5566
- } catch (error) {
5567
- consecutiveDetailFailures += 1;
5568
- if (consecutiveDetailFailures >= 3) throw error;
5569
- 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";
5570
5672
  }
5571
- if (nextDetail.head.sha !== sha) return buildReport(nextDetail, EMPTY_CHECKS, {
5572
- target,
5573
- baseline,
5574
- notices,
5575
- waited: true,
5576
- elapsedMs: now() - startedAt
5577
- }, {
5578
- statusOverride: "head-moved",
5579
- previousHeadSha: sha
5580
- });
5581
- currentDetail = nextDetail;
5582
- }
5673
+ });
5583
5674
  }
5584
5675
  async function runPrChecks(fetcher, options) {
5585
5676
  const timings = options.wait === true ? resolvePollTimings(options) : void 0;
@@ -5612,13 +5703,7 @@ function baselineClause(report) {
5612
5703
  return `${String(redChecks.length)} red, ${String(preexistingCount)} of them already red on ` + report.baseline.ref;
5613
5704
  }
5614
5705
  function renderPrChecks(report) {
5615
- const lines = report.checks.map((check) => {
5616
- const parts = [check.state, check.context];
5617
- if (check.description !== null) parts.push(check.description);
5618
- let line = parts.join(" -- ");
5619
- if (check.verdict === "red" && check.targetUrl !== null) line += ` (${check.targetUrl})`;
5620
- return line;
5621
- });
5706
+ const lines = report.checks.map((check) => renderCheckLine(check));
5622
5707
  const summaryParts = [`head ${report.headSha.slice(0, 12)}: ${report.status}`];
5623
5708
  const clause = baselineClause(report);
5624
5709
  if (clause !== null) summaryParts.push(clause);
@@ -6533,14 +6618,9 @@ function parseState(state) {
6533
6618
  if (state === "open" || state === "closed" || state === "all") return state;
6534
6619
  throw new FatalError(`Invalid --state "${state}" (expected open, closed, or all).`);
6535
6620
  }
6536
- function parseLimit(raw) {
6537
- const limit = Number(raw);
6538
- if (!Number.isInteger(limit) || limit < 1) throw new FatalError(`Invalid --limit "${raw}" (expected a positive integer).`);
6539
- return limit;
6540
- }
6541
6621
  async function runPrList(fetcher, options) {
6542
6622
  const state = parseState(options.state);
6543
- const limit = parseLimit(options.limit);
6623
+ const limit = parseLimitFlag(options.limit);
6544
6624
  rootLogger.debug(`pr:list state=${state} limit=${String(limit)}`);
6545
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.");
6546
6626
  const notices = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/forgejo-cli",
3
- "version": "0.5.5",
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": {