@kaynooo/forgejo-tools 0.0.0-stage → 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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/cli.js +3268 -0
  4. package/package.json +31 -4
package/dist/cli.js ADDED
@@ -0,0 +1,3268 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/cli.ts
5
+ import process14 from "process";
6
+
7
+ // ../kit/src/args.ts
8
+ import process4 from "process";
9
+ import { parseArgs } from "util";
10
+
11
+ // ../kit/src/format.ts
12
+ import process3 from "process";
13
+
14
+ // ../kit/src/style.ts
15
+ import process2 from "process";
16
+
17
+ // ../../node_modules/.bun/@kaynooo+utils@2.31.0+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-mtetydvc.js
18
+ function x(s) {
19
+ let r = s.startsWith("#") ? s.slice(1).toLowerCase() : s.toLowerCase();
20
+ if (!/^[a-f0-9]+$/.test(r))
21
+ return [0, 0, 0];
22
+ let t = r.length, n = (c) => Number.parseInt(c, 16);
23
+ if (t === 3 || t === 4) {
24
+ let c = n(r[0] + r[0]), u = n(r[1] + r[1]), h = n(r[2] + r[2]), g = t === 4 ? n(r[3] + r[3]) : undefined;
25
+ return g !== undefined ? [c, u, h, g] : [c, u, h];
26
+ }
27
+ if (t !== 6 && t !== 8)
28
+ throw Error(`Invalid hex color length: ${t}`);
29
+ let a = n(r.slice(0, 2)), b = n(r.slice(2, 4)), o = n(r.slice(4, 6)), y = t === 8 ? n(r.slice(6, 8)) : undefined;
30
+ if ([a, b, o].some(Number.isNaN) || y !== undefined && Number.isNaN(y))
31
+ throw Error(`Invalid hex color value: ${s}`);
32
+ return y !== undefined ? [a, b, o, y] : [a, b, o];
33
+ }
34
+ function A(s) {
35
+ let [r, t, n, a] = s;
36
+ if (a)
37
+ return `#${(r << 24 | t << 16 | n << 8 | Math.round((a ?? 1) * 255)).toString(16).padStart(8, "0").toUpperCase()}`;
38
+ return `#${(r << 16 | t << 8 | n).toString(16).padStart(6, "0").toUpperCase()}`;
39
+ }
40
+ function R(s, r, t) {
41
+ s = (s % 360 + 360) % 360, t /= 100;
42
+ let n = r * Math.min(t, 1 - t) / 100, a = (b) => {
43
+ let o = (b + s / 30) % 12, y = t - n * Math.max(Math.min(o - 3, 9 - o, 1), -1);
44
+ return Math.round(255 * y);
45
+ };
46
+ return [a(0), a(8), a(4)];
47
+ }
48
+ function k(s) {
49
+ let [r, t, n] = s;
50
+ r /= 255, t /= 255, n /= 255;
51
+ let a = Math.max(r, t, n), b = Math.min(r, t, n), o = 0, y = 0, c = (a + b) / 2;
52
+ if (a !== b) {
53
+ let u = a - b;
54
+ if (y = c > 0 && c < 1 ? u / (1 - Math.abs(2 * c - 1)) : 0, a === r)
55
+ o = (t - n) / u + (t < n ? 6 : 0);
56
+ else if (a === t)
57
+ o = (n - r) / u + 2;
58
+ else
59
+ o = (r - t) / u + 4;
60
+ o /= 6;
61
+ }
62
+ return [Math.round(o * 360), Math.round(y * 100), Math.round(c * 100)];
63
+ }
64
+ function H(s, r, t) {
65
+ s = (s % 360 + 360) % 360, r /= 100, t /= 100;
66
+ let n = (a) => {
67
+ let b = (a + s / 60) % 6, o = t - t * r * Math.max(Math.min(b, 4 - b, 1), 0);
68
+ return Math.round(255 * o);
69
+ };
70
+ return [n(5), n(3), n(1)];
71
+ }
72
+ function f(s) {
73
+ let [r, t, n] = s;
74
+ r /= 255, t /= 255, n /= 255;
75
+ let a = Math.max(r, t, n), b = Math.min(r, t, n), o = a - b, y = 0, c = a === 0 ? 0 : o / a, u = a;
76
+ if (o !== 0) {
77
+ if (a === r)
78
+ y = (t - n) / o + (t < n ? 6 : 0);
79
+ else if (a === t)
80
+ y = (n - r) / o + 2;
81
+ else
82
+ y = (r - t) / o + 4;
83
+ y /= 6;
84
+ }
85
+ return [Math.round(y * 360), Math.round(c * 100), Math.round(u * 100)];
86
+ }
87
+ function O(s) {
88
+ let r = s[0] / 255, t = s[1] / 255, n = s[2] / 255, a = 1 - Math.max(r, t, n);
89
+ if (a === 1)
90
+ return [0, 0, 0, 100];
91
+ let b = (1 - r - a) / (1 - a), o = (1 - t - a) / (1 - a), y = (1 - n - a) / (1 - a);
92
+ return [Math.round(b * 100), Math.round(o * 100), Math.round(y * 100), Math.round(a * 100)];
93
+ }
94
+ function v(s, r, t, n) {
95
+ return s /= 100, r /= 100, t /= 100, n /= 100, [Math.round(255 * (1 - s) * (1 - n)), Math.round(255 * (1 - r) * (1 - n)), Math.round(255 * (1 - t) * (1 - n))];
96
+ }
97
+ var m = (s) => s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
98
+ var p = (s) => s <= 0.0031308 ? 12.92 * s : 1.055 * s ** 0.4166666666666667 - 0.055;
99
+ var i = (s, r) => Math.round(s * 10 ** r) / 10 ** r;
100
+ function T(s) {
101
+ let r = m(s[0] / 255), t = m(s[1] / 255), n = m(s[2] / 255), a = Math.cbrt(0.4122214708 * r + 0.5363325363 * t + 0.0514459929 * n), b = Math.cbrt(0.2119034982 * r + 0.6806995451 * t + 0.1073969566 * n), o = Math.cbrt(0.0883024619 * r + 0.2817188376 * t + 0.6299787005 * n);
102
+ return [i(0.2104542553 * a + 0.793617785 * b - 0.0040720468 * o, 4), i(1.9779984951 * a - 2.428592205 * b + 0.4505937099 * o, 4), i(0.0259040371 * a + 0.7827717662 * b - 0.808675766 * o, 4)];
103
+ }
104
+ function M(s, r, t) {
105
+ let n = (s + 0.3963377774 * r + 0.2158037573 * t) ** 3, a = (s - 0.1055613458 * r - 0.0638541728 * t) ** 3, b = (s - 0.0894841775 * r - 1.291485548 * t) ** 3, o = p(4.0767416621 * n - 3.3077115913 * a + 0.2309699292 * b), y = p(-1.2684380046 * n + 2.6097574011 * a - 0.3413193965 * b), c = p(-0.0041960863 * n - 0.7034186147 * a + 1.707614701 * b), u = (h) => Math.min(255, Math.max(0, Math.round(h * 255)));
106
+ return [u(o), u(y), u(c)];
107
+ }
108
+ function d(s) {
109
+ let [r, t, n] = T(s), a = Math.sqrt(t * t + n * n), b = (Math.atan2(n, t) * 180 / Math.PI + 360) % 360;
110
+ return [i(r, 4), i(a, 4), i(b, 2)];
111
+ }
112
+ function C(s, r, t) {
113
+ let n = t * Math.PI / 180;
114
+ return M(s, r * Math.cos(n), r * Math.sin(n));
115
+ }
116
+ function l(s) {
117
+ let r = s / 255;
118
+ return r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
119
+ }
120
+
121
+ class e {
122
+ r;
123
+ g;
124
+ b;
125
+ a;
126
+ constructor(s, r, t, n = 1) {
127
+ this.r = s, this.g = r, this.b = t, this.a = n;
128
+ }
129
+ static from(s, r) {
130
+ if (s === "hex") {
131
+ let [t, n, a, b] = x(r);
132
+ return new e(t, n, a, b);
133
+ }
134
+ if (s === "rgb") {
135
+ if (Array.isArray(r)) {
136
+ let [o, y, c, u] = r;
137
+ return new e(o, y, c, u);
138
+ }
139
+ let { r: t, g: n, b: a, a: b } = r;
140
+ return new e(t, n, a, b);
141
+ }
142
+ if (s === "hsl") {
143
+ let t, n, a, b;
144
+ if (Array.isArray(r))
145
+ [t, n, a, b] = r;
146
+ else
147
+ ({ h: t, s: n, l: a, a: b } = r);
148
+ let [o, y, c] = R(t, n, a);
149
+ return new e(o, y, c, b);
150
+ }
151
+ if (s === "hsv") {
152
+ let t, n, a, b;
153
+ if (Array.isArray(r))
154
+ [t, n, a, b] = r;
155
+ else
156
+ ({ h: t, s: n, v: a, a: b } = r);
157
+ let [o, y, c] = H(t, n, a);
158
+ return new e(o, y, c, b);
159
+ }
160
+ if (s === "cmyk") {
161
+ let t, n, a, b, o;
162
+ if (Array.isArray(r))
163
+ [t, n, a, b, o] = r;
164
+ else
165
+ ({ c: t, m: n, y: a, k: b, a: o } = r);
166
+ let [y, c, u] = v(t, n, a, b);
167
+ return new e(y, c, u, o);
168
+ }
169
+ if (s === "oklch") {
170
+ let t, n, a, b;
171
+ if (Array.isArray(r))
172
+ [t, n, a, b] = r;
173
+ else
174
+ ({ l: t, c: n, h: a, a: b } = r);
175
+ let [o, y, c] = C(t, n, a);
176
+ return new e(o, y, c, b);
177
+ }
178
+ if (s === "oklab") {
179
+ let t, n, a, b;
180
+ if (Array.isArray(r))
181
+ [t, n, a, b] = r;
182
+ else
183
+ ({ l: t, a: n, b: a, alpha: b } = r);
184
+ let [o, y, c] = M(t, n, a);
185
+ return new e(o, y, c, b);
186
+ }
187
+ throw Error("Invalid input");
188
+ }
189
+ to(s) {
190
+ switch (s) {
191
+ case "rgb":
192
+ return [this.r, this.g, this.b];
193
+ case "rgba":
194
+ return [this.r, this.g, this.b, this.a];
195
+ case "hex":
196
+ return A([this.r, this.g, this.b]);
197
+ case "hexa":
198
+ return A([this.r, this.g, this.b, this.a]);
199
+ case "hsl":
200
+ return k([this.r, this.g, this.b]);
201
+ case "hsla":
202
+ return [...k([this.r, this.g, this.b]), this.a];
203
+ case "hsv":
204
+ return f([this.r, this.g, this.b]);
205
+ case "hsva":
206
+ return [...f([this.r, this.g, this.b]), this.a];
207
+ case "cmyk":
208
+ return O([this.r, this.g, this.b]);
209
+ case "cmyka":
210
+ return [...O([this.r, this.g, this.b]), this.a];
211
+ case "oklch":
212
+ return d([this.r, this.g, this.b]);
213
+ case "oklcha":
214
+ return [...d([this.r, this.g, this.b]), this.a];
215
+ case "oklab":
216
+ return T([this.r, this.g, this.b]);
217
+ case "oklaba":
218
+ return [...T([this.r, this.g, this.b]), this.a];
219
+ default:
220
+ return [this.r, this.g, this.b];
221
+ }
222
+ }
223
+ getLuminance() {
224
+ let s = l(this.r), r = l(this.g), t = l(this.b);
225
+ return 0.2126 * s + 0.7152 * r + 0.0722 * t;
226
+ }
227
+ contrastRatio(s) {
228
+ let r = this.getLuminance(), t = s.getLuminance(), [n, a] = r >= t ? [r, t] : [t, r];
229
+ return (n + 0.05) / (a + 0.05);
230
+ }
231
+ bestContrastForeground() {
232
+ let s = new e(255, 255, 255, 1), r = new e(0, 0, 0, 1), t = this.contrastRatio(s), n = this.contrastRatio(r);
233
+ return t >= n ? s : r;
234
+ }
235
+ }
236
+
237
+ // ../../node_modules/.bun/@kaynooo+utils@2.31.0+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-y1msbssb.js
238
+ var r = 39;
239
+ var o = 49;
240
+ var f2 = "\x1B[";
241
+ var C2 = { reset: [0, 0], bold: [1, 22], dim: [2, 22], italic: [3, 23], underline: [4, 24], inverse: [7, 27], hidden: [8, 28], strikethrough: [9, 29], black: [30, r], red: [31, r], green: [32, r], yellow: [33, r], blue: [34, r], magenta: [35, r], cyan: [36, r], white: [37, r], gray: [90, r], grey: [90, r], blackBright: [90, r], redBright: [91, r], greenBright: [92, r], yellowBright: [93, r], blueBright: [94, r], magentaBright: [95, r], cyanBright: [96, r], whiteBright: [97, r], bgBlack: [40, o], bgRed: [41, o], bgGreen: [42, o], bgYellow: [43, o], bgBlue: [44, o], bgMagenta: [45, o], bgCyan: [46, o], bgWhite: [47, o], bgBlackBright: [100, o], bgRedBright: [101, o], bgGreenBright: [102, o], bgYellowBright: [103, o], bgBlueBright: [104, o], bgMagentaBright: [105, o], bgCyanBright: [106, o], bgWhiteBright: [107, o], rgb: (i, n, a, l = "fg") => [`${l === "fg" ? 38 : 48};2;${i};${n};${a}`, l === "fg" ? r : o], hex: (i, n = "fg") => {
242
+ let [a, l, s] = e.from("hex", i).to("rgb");
243
+ return [`${n === "fg" ? 38 : 48};2;${a};${l};${s}`, n === "fg" ? r : o];
244
+ }, ansi256: (i, n = "fg") => [`${n === "fg" ? 38 : 48};5;${i}`, n === "fg" ? r : o] };
245
+ var b = new Set(["", "1", "2", "3", "true"]);
246
+ function h(i, n = globalThis.process?.env ?? {}) {
247
+ if (n.FORCE_COLOR !== undefined)
248
+ return b.has(n.FORCE_COLOR);
249
+ if (n.NO_COLOR !== undefined || n.NODE_DISABLE_COLORS !== undefined || n.TERM === "dumb")
250
+ return false;
251
+ return i?.isTTY === true;
252
+ }
253
+ function d2(i, n, a) {
254
+ if (!h(a.stream ?? globalThis.process?.stdout, a.env))
255
+ return n;
256
+ let l = "", s = "";
257
+ for (let g = 0, y = i.length;g < y; g++) {
258
+ let [c, p] = i[g];
259
+ l += `${f2}${c}m`, s = `${f2}${p}m${s}`;
260
+ }
261
+ return l + n + s;
262
+ }
263
+ function u(i, n = []) {
264
+ return new Proxy((l) => d2(n, l, i), { get(l, s) {
265
+ let g = C2[s];
266
+ if (Array.isArray(g))
267
+ return u(i, [...n, g]);
268
+ if (typeof g === "function")
269
+ return (...y) => u(i, [...n, g(...y)]);
270
+ throw Error(`Unknown style: ${String(s)}`);
271
+ } });
272
+ }
273
+ function le(i = {}) {
274
+ return u(i);
275
+ }
276
+ var t = le();
277
+
278
+ // ../kit/src/style.ts
279
+ var MARKS = {
280
+ ok: { symbol: "\u2713", word: "ok:", paint: (colors) => colors.green },
281
+ warn: { symbol: "!", word: "warning:", paint: (colors) => colors.yellow },
282
+ problem: { symbol: "\u2717", word: "error:", paint: (colors) => colors.red },
283
+ note: { symbol: "\xB7", word: "note:", paint: (colors) => colors.dim }
284
+ };
285
+ function takesControlCodes(stream, env = process2.env) {
286
+ return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
287
+ }
288
+ function paint(stream, env = process2.env) {
289
+ return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
290
+ }
291
+ function marked(mark, text, stream, detail, env = process2.env) {
292
+ const colors = paint(stream, env);
293
+ const { symbol, word, paint: tint } = MARKS[mark];
294
+ const head = takesControlCodes(stream, env) ? tint(colors)(symbol) : word;
295
+ return `${head} ${text}${detail === undefined ? "" : ` ${colors.dim(detail)}`}`;
296
+ }
297
+ function note(text, detail) {
298
+ console.log(marked("note", text, process2.stdout, detail));
299
+ }
300
+ function warn(text, detail) {
301
+ console.error(marked("warn", text, process2.stderr, detail));
302
+ }
303
+ var STATUSES = {
304
+ passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
305
+ failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
306
+ running: { symbol: "\u25CF", words: ["running", "in_progress", "started"], paint: (colors) => colors.cyan },
307
+ queued: { symbol: "\u25CB", words: ["queued", "waiting", "pending", "created", "blocked", "scheduled"], paint: (colors) => colors.yellow },
308
+ skipped: { symbol: "-", words: ["skipped", "cancelled", "canceled", "manual"], paint: (colors) => colors.dim }
309
+ };
310
+ var STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map((word) => [word, status])));
311
+ function statusOf(word) {
312
+ return STATUS_OF.get(word.toLowerCase());
313
+ }
314
+ function badge(word, stream, env = process2.env) {
315
+ const status = statusOf(word);
316
+ if (status === undefined || !takesControlCodes(stream, env))
317
+ return word;
318
+ const { symbol, paint: tint } = STATUSES[status];
319
+ return tint(paint(stream, env))(`${symbol} ${word}`);
320
+ }
321
+
322
+ // ../kit/src/format.ts
323
+ var UNITS = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];
324
+ function formatSeconds(total) {
325
+ let rest = Math.max(0, Math.round(total));
326
+ const amounts = UNITS.map(([unit, size]) => {
327
+ const amount = Math.floor(rest / size);
328
+ rest -= amount * size;
329
+ return `${amount}${unit}`;
330
+ });
331
+ const first = amounts.findIndex((amount) => !amount.startsWith("0"));
332
+ if (first === -1)
333
+ return "0s";
334
+ const next = amounts[first + 1];
335
+ return next === undefined || next.startsWith("0") ? amounts[first] ?? "0s" : `${amounts[first]} ${next}`;
336
+ }
337
+ function formatDuration(ms) {
338
+ return ms < 60000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : formatSeconds(ms / 1000);
339
+ }
340
+ function upTo(text, separator) {
341
+ const end = text.indexOf(separator);
342
+ return end === -1 ? text : text.slice(0, end);
343
+ }
344
+ function formatAge(date, now = Date.now()) {
345
+ const at = typeof date === "number" ? date : Date.parse(date);
346
+ if (Number.isNaN(at))
347
+ return "-";
348
+ const seconds = (now - at) / 1000;
349
+ return seconds < 60 ? "just now" : `${upTo(formatSeconds(seconds), " ")} ago`;
350
+ }
351
+ function truncate(text, width) {
352
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}\u2026`;
353
+ }
354
+ var LINE_BREAKS = /\s*\n\s*/g;
355
+ var ESCAPE = String.fromCharCode(27);
356
+ function textWidth(text) {
357
+ return Bun.stringWidth(text);
358
+ }
359
+ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
360
+ const cells = cellWidth === undefined ? rows : rows.map((row) => row.map((cell) => truncate(cell.replaceAll(LINE_BREAKS, " "), cellWidth)));
361
+ const widths = [];
362
+ for (const row of cells) {
363
+ for (const [index, cell] of row.entries())
364
+ widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
365
+ }
366
+ const shown = fit === undefined ? cells : narrow(cells, widths, fit);
367
+ const colors = paint(process3.stdout);
368
+ const lines = shown.map((row) => row.map((cell, index) => {
369
+ const width = widths[index] ?? 0;
370
+ const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
371
+ if (align[index] === "right")
372
+ return `${gap}${cell}`;
373
+ return index === row.length - 1 ? cell : `${cell}${gap}`;
374
+ }).join(" ").trimEnd());
375
+ return lines.map((line, index) => header && index === 0 ? colors.bold(line) : line).join(`
376
+ `);
377
+ }
378
+ function narrow(rows, widths, limit) {
379
+ const used = () => widths.reduce((sum, width) => sum + width, 0) + 2 * Math.max(0, widths.length - 1);
380
+ const cuttable = (index) => rows.every((row) => !(row[index] ?? "").includes(ESCAPE));
381
+ const floor = 8;
382
+ while (used() > limit) {
383
+ let widest = -1;
384
+ for (const [index, width] of widths.entries()) {
385
+ if (cuttable(index) && width > floor && width > (widths[widest] ?? 0))
386
+ widest = index;
387
+ }
388
+ if (widest === -1)
389
+ break;
390
+ widths[widest] = Math.max(floor, (widths[widest] ?? 0) - (used() - limit));
391
+ }
392
+ return rows.map((row) => row.map((cell, index) => textWidth(cell) > (widths[index] ?? 0) ? truncate(cell, widths[index] ?? 0) : cell));
393
+ }
394
+ function printJson(value) {
395
+ console.log(JSON.stringify(value, undefined, 2));
396
+ }
397
+
398
+ // ../kit/src/args.ts
399
+ function jsonOption(holds) {
400
+ return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
401
+ }
402
+
403
+ class Exit extends Error {
404
+ code;
405
+ hint;
406
+ constructor(message, code, hint) {
407
+ super(message);
408
+ this.code = code;
409
+ this.hint = hint;
410
+ }
411
+ }
412
+ function fail(message, code = 2, hint) {
413
+ throw new Exit(message, code, hint);
414
+ }
415
+
416
+ class UsageError extends Exit {
417
+ constructor(message) {
418
+ super(message, 2);
419
+ }
420
+ }
421
+ function badUsage(message) {
422
+ throw new UsageError(message);
423
+ }
424
+
425
+ class HelpShown extends Exit {
426
+ constructor() {
427
+ super("help shown", 0);
428
+ }
429
+ }
430
+
431
+ class Completing {
432
+ options;
433
+ constructor(options) {
434
+ this.options = options;
435
+ }
436
+ }
437
+ var completion = { active: false };
438
+ var OPTIONAL = /\[[^\]]+\]/g;
439
+ var USAGE_PREFIX = /^usage:\s*/i;
440
+ var FULL_STOP = /\.$/;
441
+ function usageLine(usage, colors = paint(process4.stdout)) {
442
+ return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
443
+ }
444
+ function helpText(usage, options, colors = paint(process4.stdout), { examples = [], replaces } = {}) {
445
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
446
+ `);
447
+ }
448
+ function replacesRows(replaces, colors = paint(process4.stdout)) {
449
+ return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
450
+ }
451
+ function wantsHelpJson(env = process4.env) {
452
+ return env.KIT_HELP === "json";
453
+ }
454
+ function description(usage, { examples = [], replaces } = {}) {
455
+ const lines = usage.replace(USAGE_PREFIX, "").split(`
456
+ `);
457
+ const first = lines[0];
458
+ const colon = first.indexOf(": ");
459
+ const prose = lines[lines.indexOf("") + 1] ?? "";
460
+ const fallback = lines.includes("") ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, "") : "";
461
+ return {
462
+ synopsis: colon === -1 ? first : first.slice(0, colon),
463
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
464
+ ...replaces === undefined ? {} : { replaces },
465
+ examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
466
+ };
467
+ }
468
+ function exampleRows(examples, colors = paint(process4.stdout)) {
469
+ if (examples.length === 0)
470
+ return [];
471
+ return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
472
+ }
473
+ function optionRows(options, colors = paint(process4.stdout)) {
474
+ const rows = Object.entries(options).map(([name, option]) => {
475
+ const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
476
+ const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
477
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
478
+ });
479
+ const width = Math.max(0, ...rows.map((row) => row.width));
480
+ return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
481
+ }
482
+ var OUTPUT_OPTIONS = {
483
+ color: { type: "string", description: "auto (on a terminal alone), always or never" },
484
+ plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
485
+ };
486
+ function applyOutput(color, plain, env = process4.env) {
487
+ const when = plain === true ? "never" : color ?? "auto";
488
+ if (when === "auto")
489
+ return;
490
+ if (when !== "always" && when !== "never")
491
+ badUsage(`--color takes auto, always or never, not ${when}`);
492
+ if (when === "always") {
493
+ env.FORCE_COLOR = "1";
494
+ delete env.NO_COLOR;
495
+ } else {
496
+ env.NO_COLOR = "1";
497
+ delete env.FORCE_COLOR;
498
+ }
499
+ }
500
+ function parse(args, usage, options, settings = {}) {
501
+ const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
502
+ const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
503
+ if (completion.active)
504
+ throw new Completing(all);
505
+ const separator = settings.rest === true ? args.indexOf("--") : -1;
506
+ const own = separator === -1 ? args : args.slice(0, separator);
507
+ const rest = separator === -1 ? undefined : args.slice(separator + 1);
508
+ let parsed;
509
+ try {
510
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
511
+ } catch (error) {
512
+ badUsage(parseError(error));
513
+ }
514
+ const values = parsed.values;
515
+ applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
516
+ if (values.help === true) {
517
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
518
+ throw new HelpShown;
519
+ }
520
+ if (settings.positionals !== undefined)
521
+ checkPositionals(parsed.positionals, settings.positionals);
522
+ return { ...parsed, positionals: parsed.positionals, rest };
523
+ }
524
+ function checkPositionals(given, names) {
525
+ const variadic = names.at(-1)?.replace("]", "").endsWith("...") === true;
526
+ const required = names.filter((name) => !name.startsWith("[")).length;
527
+ if (given.length < required)
528
+ badUsage(`missing ${names[given.length]}`);
529
+ if (!variadic && given.length > names.length)
530
+ badUsage(`unexpected argument ${given[names.length]}`);
531
+ }
532
+ function joinDashValues(args, options) {
533
+ const longOf = new Map(Object.entries(options).flatMap(([name, option]) => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]));
534
+ const isOption = (arg) => arg === "--" || longOf.has(arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg);
535
+ const joined = [];
536
+ for (let i = 0;i < args.length; i++) {
537
+ const arg = args[i] ?? "";
538
+ if (arg === "--")
539
+ return [...joined, ...args.slice(i)];
540
+ const name = longOf.get(arg);
541
+ const next = args[i + 1];
542
+ if (name !== undefined && options[name]?.type === "string" && next !== undefined && next.startsWith("-") && next !== "-" && !isOption(next)) {
543
+ joined.push(`--${name}=${next}`);
544
+ i++;
545
+ } else {
546
+ joined.push(arg);
547
+ }
548
+ }
549
+ return joined;
550
+ }
551
+ function parseError(error) {
552
+ const message = upTo(error instanceof Error ? error.message : String(error), ". ");
553
+ return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
554
+ }
555
+ function wholeNumber(value, name, fallback, min = 1) {
556
+ if (value === undefined)
557
+ return fallback;
558
+ const number = Number(value);
559
+ if (!Number.isInteger(number) || number < min)
560
+ badUsage(`--${name} takes a whole number from ${min}, not ${value}`);
561
+ return number;
562
+ }
563
+ var DURATION = /^(\d+(?:\.\d+)?)(ms|[smh])?$/;
564
+ var UNIT_MS = { ms: 1, s: 1000, m: 60000, h: 3600000 };
565
+ function duration(value, name, fallback, unit = "s") {
566
+ if (value === undefined)
567
+ return fallback;
568
+ const match = DURATION.exec(value.trim());
569
+ const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[match[2] ?? unit];
570
+ if (ms < 1)
571
+ badUsage(`--${name} takes a duration such as 30s, 5m or 1h, not ${value}`);
572
+ return ms;
573
+ }
574
+ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
575
+ if (path === "-") {
576
+ if (process4.stdin.isTTY)
577
+ fail(noStdin);
578
+ return Bun.stdin.text();
579
+ }
580
+ const file = Bun.file(path);
581
+ if (!await file.exists())
582
+ fail(`no file ${path}`);
583
+ return file.text();
584
+ }
585
+ // ../kit/src/cli.ts
586
+ import process5 from "process";
587
+
588
+ // ../kit/src/text.ts
589
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
590
+ function distance(a, b) {
591
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
592
+ for (const [i, left] of [...a].entries()) {
593
+ const current = [i + 1];
594
+ for (const [j, right] of [...b].entries())
595
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
596
+ previous = current;
597
+ }
598
+ return previous[b.length] ?? 0;
599
+ }
600
+
601
+ // ../kit/src/cli.ts
602
+ var LEVEL_OPTIONS = {
603
+ ...OUTPUT_OPTIONS,
604
+ help: { type: "boolean", short: "h", description: "show this help" }
605
+ };
606
+ var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
607
+ function commandRows(commands, colors) {
608
+ const width = Math.max(...Object.keys(commands).map((name) => name.length));
609
+ const groups = new Map([["Commands:", []]]);
610
+ for (const [name, { summary, group }] of Object.entries(commands)) {
611
+ const heading = group === undefined ? "Commands:" : `${group}:`;
612
+ const rows = groups.get(heading) ?? [];
613
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
614
+ groups.set(heading, rows);
615
+ }
616
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
617
+ }
618
+ function takeOutputFlags(argv) {
619
+ let index = 0;
620
+ for (;index < argv.length; index++) {
621
+ const word = argv[index];
622
+ if (word === "--plain")
623
+ applyOutput(undefined, true);
624
+ else if (word.startsWith("--color="))
625
+ applyOutput(word.slice("--color=".length), undefined);
626
+ else if (word === "--color" && argv[index + 1] !== undefined)
627
+ applyOutput(argv[++index], undefined);
628
+ else
629
+ break;
630
+ }
631
+ return argv.slice(index);
632
+ }
633
+ function closest(name, names) {
634
+ const started = names.find((known) => known.startsWith(name));
635
+ if (started !== undefined)
636
+ return started;
637
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
638
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
639
+ }
640
+ async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
641
+ const colors = paint(stream);
642
+ const prefix = path.join(" ");
643
+ if (wantsHelpJson())
644
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
645
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
646
+ const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
647
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
648
+ return [
649
+ header(colors),
650
+ "",
651
+ usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
652
+ ...replacesRows(replaces, colors),
653
+ "",
654
+ ...commandRows(commands, colors),
655
+ ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
656
+ "",
657
+ colors.bold("Options:"),
658
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
659
+ ...exampleRows(examples ?? [], colors),
660
+ "",
661
+ `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
662
+ ].join(`
663
+ `);
664
+ }
665
+ function printError(prefix, message, hint, cause) {
666
+ const colors = paint(process5.stderr);
667
+ const styled = takesControlCodes(process5.stderr);
668
+ const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
669
+ const under = (label, text, tint) => text === undefined ? "" : `
670
+ ${styled ? tint(label) : `${label}:`} ${text}`;
671
+ console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
672
+ }
673
+ function runHint(command, effect) {
674
+ return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
675
+ }
676
+ function printHint(hint) {
677
+ const colors = paint(process5.stderr);
678
+ console.error(` ${takesControlCodes(process5.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
679
+ }
680
+ function printJsonError(message, hint, cause) {
681
+ console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
682
+ }
683
+ function wantsJson(argv) {
684
+ const end = argv.indexOf("--");
685
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
686
+ }
687
+ function reportThrown(prefix, error, redact = (text) => text, json = false) {
688
+ if (error instanceof HelpShown)
689
+ return 0;
690
+ const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
691
+ const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
692
+ const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
693
+ const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
694
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
695
+ return error instanceof Exit ? error.code : 1;
696
+ }
697
+ async function dispatch(level, typed, { version, redact }) {
698
+ const prefix = level.path.join(" ");
699
+ let argv;
700
+ try {
701
+ argv = takeOutputFlags(typed);
702
+ } catch (error) {
703
+ return reportThrown(prefix, error);
704
+ }
705
+ const [name, ...args] = argv;
706
+ if (name === undefined && level.path.length > 1) {
707
+ console.error(await help(level, process5.stderr));
708
+ return 2;
709
+ }
710
+ if (name === undefined || name === "help" || name === "--help" || name === "-h") {
711
+ console.log(await help(level, process5.stdout));
712
+ return 0;
713
+ }
714
+ if (level.path.length === 1 && (name === "--version" || name === "-v")) {
715
+ console.log(version);
716
+ return 0;
717
+ }
718
+ let command = level.commands[level.aliases?.[name] ?? name];
719
+ let rest = args;
720
+ if (command === undefined && level.defaultCommand !== undefined) {
721
+ command = level.commands[level.defaultCommand];
722
+ rest = argv;
723
+ }
724
+ if (command === undefined) {
725
+ const meant = closest(name, Object.keys(level.commands));
726
+ printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`);
727
+ return 2;
728
+ }
729
+ if ("commands" in command) {
730
+ const { summary } = command;
731
+ return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
732
+ }
733
+ try {
734
+ return await command.run(rest);
735
+ } catch (error) {
736
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
737
+ }
738
+ }
739
+ var COMPLETE = "__complete";
740
+ var FILES = "__files__";
741
+ async function optionsOf(run) {
742
+ completion.active = true;
743
+ try {
744
+ await run([]);
745
+ } catch (error) {
746
+ if (error instanceof Completing)
747
+ return error.options;
748
+ } finally {
749
+ completion.active = false;
750
+ }
751
+ return {};
752
+ }
753
+ async function leafCandidates(run, words) {
754
+ if (!(words.at(-1) ?? "").startsWith("-"))
755
+ return [FILES];
756
+ return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
757
+ }
758
+ async function candidates(commands, aliases, defaultCommand, words) {
759
+ const [word = "", ...rest] = words;
760
+ const command = commands[aliases?.[word] ?? word];
761
+ if (rest.length === 0 && !word.startsWith("-"))
762
+ return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
763
+ if (command === undefined) {
764
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
765
+ return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
766
+ }
767
+ return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
768
+ }
769
+ async function printCandidates(found) {
770
+ for (const line of await found)
771
+ console.log(line);
772
+ return 0;
773
+ }
774
+ async function runCli(cli, argv = process5.argv.slice(2)) {
775
+ if (argv[0] === COMPLETE)
776
+ return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
777
+ return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
778
+ }
779
+ // ../kit/src/limit.ts
780
+ function limitOptions(fallback) {
781
+ return {
782
+ limit: { type: "string", description: `show at most this many rows, the rest counted (${fallback})` },
783
+ full: { type: "boolean", description: "show every row" }
784
+ };
785
+ }
786
+ function limitOf(values, fallback) {
787
+ if (values.full === true && values.limit !== undefined)
788
+ badUsage("--limit and --full exclude each other");
789
+ if (values.full === true || values.json === true && values.limit === undefined)
790
+ return Number.POSITIVE_INFINITY;
791
+ return wholeNumber(values.limit, "limit", fallback);
792
+ }
793
+ function moreFooter(more) {
794
+ return `${more} more, rerun with --full`;
795
+ }
796
+ function capped(rows, limit) {
797
+ const more = rows.length - limit;
798
+ return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
799
+ }
800
+ // ../kit/src/process.ts
801
+ import { constants } from "os";
802
+ import process6 from "process";
803
+ var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
804
+ async function collect(stream, name, chunks) {
805
+ for await (const data of stream)
806
+ chunks.push({ stream: name, data });
807
+ }
808
+ function exitCodeOf(code, signal) {
809
+ if (code !== null)
810
+ return code;
811
+ if (typeof signal === "number")
812
+ return 128 + signal;
813
+ return signal === null ? 129 : 128 + constants.signals[signal];
814
+ }
815
+ function holdSignals(cleanup) {
816
+ let received;
817
+ let child;
818
+ const held = {
819
+ get signal() {
820
+ return received;
821
+ },
822
+ forward: (to) => {
823
+ child = to;
824
+ },
825
+ release: () => {
826
+ for (const signal of SIGNALS)
827
+ process6.off(signal, onSignal);
828
+ }
829
+ };
830
+ function onSignal(signal) {
831
+ received = signal;
832
+ if (child !== undefined) {
833
+ child.kill(signal);
834
+ return;
835
+ }
836
+ if (cleanup === undefined)
837
+ return;
838
+ held.release();
839
+ cleanup(signal);
840
+ process6.kill(process6.pid, signal);
841
+ }
842
+ for (const signal of SIGNALS)
843
+ process6.on(signal, onSignal);
844
+ return held;
845
+ }
846
+ async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
847
+ const chunks = [];
848
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe" });
849
+ await Promise.all([collect(proc.stdout, "stdout", chunks), collect(proc.stderr, "stderr", chunks)]);
850
+ await proc.exited;
851
+ const output = Buffer.concat(chunks.map(({ data }) => data));
852
+ return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
853
+ }
854
+ // package.json
855
+ var version = "0.6.0";
856
+
857
+ // src/args.ts
858
+ var repoOption = {
859
+ repo: { type: "string", short: "r", description: "owner/name of the repository, instead of the one origin points at" }
860
+ };
861
+ var ISSUE_HASH = /^#/;
862
+ function issueNumber(value, usage) {
863
+ if (value === undefined)
864
+ fail(`an issue number is needed
865
+
866
+ ${usage}`);
867
+ return wholeNumber(value.replace(ISSUE_HASH, ""), "issue", 0);
868
+ }
869
+ function issueNumbers(values, usage) {
870
+ return values.length === 0 ? [issueNumber(undefined, usage)] : values.map((value) => issueNumber(value, usage));
871
+ }
872
+
873
+ // src/checklist.ts
874
+ var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*)\r?$/i;
875
+ var FENCE = /^\s*(?:```|~~~)/;
876
+ function boxes(body) {
877
+ const found = [];
878
+ let fenced = false;
879
+ for (const [line, text] of body.split(`
880
+ `).entries()) {
881
+ if (FENCE.test(text)) {
882
+ fenced = !fenced;
883
+ continue;
884
+ }
885
+ const match = fenced ? null : BOX.exec(text);
886
+ if (match !== null)
887
+ found.push({ number: found.length + 1, line, done: match[2] !== " ", text: match[4] ?? "" });
888
+ }
889
+ return found;
890
+ }
891
+ var INDENT = /^\s*/;
892
+ function appendBoxes(body, texts) {
893
+ const end = body.includes(`\r
894
+ `) ? "\r" : "";
895
+ const last = boxes(body).at(-1);
896
+ const lines = body.split(`
897
+ `);
898
+ const indent = last === undefined ? "" : INDENT.exec(lines[last.line] ?? "")?.[0] ?? "";
899
+ const added = texts.map((text) => `${indent}- [ ] ${text}${end}`);
900
+ if (last === undefined)
901
+ return body.trim() === "" ? added.join(`
902
+ `) : `${body.trimEnd()}${end}
903
+ ${end}
904
+ ${added.join(`
905
+ `)}`;
906
+ lines.splice(last.line + 1, 0, ...added);
907
+ return lines.join(`
908
+ `);
909
+ }
910
+ function numberedBody(body) {
911
+ const lines = body.replaceAll(`\r
912
+ `, `
913
+ `).split(`
914
+ `);
915
+ for (const box of boxes(body))
916
+ lines[box.line] = `${INDENT.exec(lines[box.line] ?? "")?.[0] ?? ""}${box.number} [${box.done ? "x" : " "}] ${box.text}`;
917
+ return lines.join(`
918
+ `).trim();
919
+ }
920
+ function setBoxes(body, picks, done) {
921
+ const all = boxes(body);
922
+ const chosen = new Map;
923
+ for (const pick of picks) {
924
+ const number = Number(pick);
925
+ const needle = pick.toLowerCase();
926
+ const matches = Number.isInteger(number) && String(number) === pick ? all.filter((box) => box.number === number) : all.filter((box) => box.text.toLowerCase().includes(needle));
927
+ if (matches.length === 0)
928
+ throw new Error(`no box matches ${pick}`);
929
+ if (matches.length > 1)
930
+ throw new Error(`${pick} matches ${matches.length} boxes: ${matches.map((box) => box.number).join(", ")}`);
931
+ const [box] = matches;
932
+ if (box !== undefined)
933
+ chosen.set(box.number, box);
934
+ }
935
+ const lines = body.split(`
936
+ `);
937
+ const changed = [];
938
+ for (const box of chosen.values()) {
939
+ if (box.done === done)
940
+ continue;
941
+ const line = lines[box.line] ?? "";
942
+ lines[box.line] = line.replace(BOX, `$1${done ? "x" : " "}$3$4`) + (line.endsWith("\r") ? "\r" : "");
943
+ changed.push({ ...box, done });
944
+ }
945
+ return { body: lines.join(`
946
+ `), changed };
947
+ }
948
+ var REFERENCE = /^#(\d+)(?!\d)/;
949
+ function referencedBoxes(body) {
950
+ const found = [];
951
+ for (const box of boxes(body)) {
952
+ const match = REFERENCE.exec(box.text);
953
+ if (match?.[1] !== undefined)
954
+ found.push({ box, issue: Number(match[1]) });
955
+ }
956
+ return found;
957
+ }
958
+ function tickReferences(body, wanted) {
959
+ const picks = referencedBoxes(body).filter(({ box, issue }) => !box.done && wanted(issue)).map(({ box }) => String(box.number));
960
+ return setBoxes(body, picks, true);
961
+ }
962
+
963
+ // src/claims.ts
964
+ var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/im;
965
+ var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/im;
966
+ var LANE = /^lane-([^-]+)-/;
967
+ function takenComment(name) {
968
+ return `Taken by session ${name}.`;
969
+ }
970
+ function releasedComment(name) {
971
+ return `Released by session ${name}.`;
972
+ }
973
+ function latestClaim(comments) {
974
+ let claim;
975
+ for (const comment of [...comments].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
976
+ const taken = TAKEN.exec(comment.body)?.[1];
977
+ if (taken !== undefined) {
978
+ claim = { holder: taken, at: comment.created_at };
979
+ continue;
980
+ }
981
+ if (claim !== undefined && RELEASED.exec(comment.body)?.[1] === claim.holder)
982
+ claim = undefined;
983
+ }
984
+ return claim;
985
+ }
986
+ function laneSuffix(session) {
987
+ const suffix = session.slice(session.lastIndexOf("-") + 1);
988
+ return session.includes("-") && suffix !== "" ? suffix : undefined;
989
+ }
990
+ function latestNote(comments) {
991
+ return comments.filter((comment) => !TAKEN.test(comment.body) && !RELEASED.test(comment.body)).sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)).at(-1);
992
+ }
993
+ function holderSession(holder, sessions) {
994
+ const own = sessions.find((session) => session.name === holder);
995
+ if (own !== undefined)
996
+ return own;
997
+ const lane = LANE.exec(holder)?.[1];
998
+ return lane === undefined ? undefined : sessions.find((session) => session.name.endsWith(`-${lane}`));
999
+ }
1000
+
1001
+ // src/tea.ts
1002
+ var {$: $2 } = globalThis.Bun;
1003
+
1004
+ // src/git.ts
1005
+ import { basename, dirname } from "path";
1006
+ import process8 from "process";
1007
+ var {$ } = globalThis.Bun;
1008
+ var ORIGIN_PREFIX = /^origin\//;
1009
+ function spawnFailure(command, error) {
1010
+ return new Error(`${command}: ${error instanceof Error ? error.message : String(error)}${cwdGone() ? " (the working directory was removed)" : ""}`);
1011
+ }
1012
+ async function git(args) {
1013
+ let result;
1014
+ try {
1015
+ result = await $`git ${args}`.quiet().nothrow();
1016
+ } catch (error) {
1017
+ throw spawnFailure(`git ${args.join(" ")}`, error);
1018
+ }
1019
+ if (result.exitCode !== 0)
1020
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1021
+ return result.stdout.toString().trim();
1022
+ }
1023
+ async function gitOk(args) {
1024
+ return (await $`git ${args}`.quiet().nothrow()).exitCode === 0;
1025
+ }
1026
+ async function resolveCommit(ref) {
1027
+ const sha = (await $`git rev-parse --verify -q ${`${ref}^{commit}`}`.quiet().nothrow().text()).trim();
1028
+ if (sha === "")
1029
+ fail(`no commit ${ref}`);
1030
+ return sha;
1031
+ }
1032
+ async function defaultBranch() {
1033
+ const head = (await $`git symbolic-ref --short -q refs/remotes/origin/HEAD`.quiet().nothrow().text()).trim();
1034
+ return head === "" ? "main" : head.replace(ORIGIN_PREFIX, "");
1035
+ }
1036
+ async function tagsAt(commit) {
1037
+ return (await git(["tag", "--points-at", commit])).split(`
1038
+ `).filter(Boolean);
1039
+ }
1040
+ async function commitsNaming(issue, ref) {
1041
+ const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
1042
+ return out.split(`
1043
+ `).filter(Boolean).map((line) => {
1044
+ const space = line.indexOf(" ");
1045
+ return { sha: line.slice(0, space), subject: line.slice(space + 1) };
1046
+ });
1047
+ }
1048
+ function cwdGone() {
1049
+ try {
1050
+ process8.cwd();
1051
+ return false;
1052
+ } catch {
1053
+ return true;
1054
+ }
1055
+ }
1056
+ async function leaveWorktree() {
1057
+ const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1058
+ if (basename(common) === ".git")
1059
+ process8.chdir(dirname(common));
1060
+ }
1061
+
1062
+ // src/tea.ts
1063
+ function repoFlag(repo) {
1064
+ return repo === undefined ? [] : ["--repo", repo];
1065
+ }
1066
+ async function request(path, repo) {
1067
+ let result;
1068
+ try {
1069
+ result = await $2`tea api ${repoFlag(repo)} ${path}`.quiet().nothrow();
1070
+ } catch (error) {
1071
+ throw spawnFailure(`tea api ${path}`, error);
1072
+ }
1073
+ if (result.exitCode !== 0)
1074
+ throw new Error(`tea api ${path}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1075
+ return result.stdout.toString();
1076
+ }
1077
+ function parseJson(path, text) {
1078
+ let value;
1079
+ try {
1080
+ value = JSON.parse(text);
1081
+ } catch {
1082
+ throw new Error(`tea api ${path}: ${upTo(text.trim(), `
1083
+ `) || "empty answer"}`);
1084
+ }
1085
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
1086
+ throw new Error(`tea api ${path}: ${value.message}`);
1087
+ return value;
1088
+ }
1089
+ async function api(path, repo) {
1090
+ return parseJson(path, await request(path, repo));
1091
+ }
1092
+ async function repoApi(path, repo) {
1093
+ return api(`repos/{owner}/{repo}/${path}`, repo);
1094
+ }
1095
+ async function repoWrite(method, path, fields, repo) {
1096
+ const full = `repos/{owner}/{repo}/${path}`;
1097
+ const flags = Object.entries(fields).flatMap(([name, value]) => ["-f", `${name}=${value}`]);
1098
+ let result;
1099
+ try {
1100
+ result = await $2`tea api ${repoFlag(repo)} -X ${method} ${flags} ${full}`.quiet().nothrow();
1101
+ } catch (error) {
1102
+ throw spawnFailure(`tea api ${full}`, error);
1103
+ }
1104
+ if (result.exitCode !== 0)
1105
+ throw new Error(`tea api ${full}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1106
+ return parseJson(full, result.stdout.toString());
1107
+ }
1108
+ async function repoApiText(path, repo) {
1109
+ return request(`repos/{owner}/{repo}/${path}`, repo);
1110
+ }
1111
+ async function tea(args, repo) {
1112
+ let result;
1113
+ try {
1114
+ result = await $2`tea ${args} ${repoFlag(repo)}`.quiet().nothrow();
1115
+ } catch (error) {
1116
+ throw spawnFailure(`tea ${args[0] ?? ""}`, error);
1117
+ }
1118
+ if (result.exitCode !== 0)
1119
+ throw new Error(`tea ${args[0] ?? ""}: ${result.stderr.toString().trim() || result.stdout.toString().trim() || `exit ${result.exitCode}`}`);
1120
+ return result.stdout.toString();
1121
+ }
1122
+
1123
+ // src/issues.ts
1124
+ var PAGE = 50;
1125
+ var CONCURRENCY = 8;
1126
+ async function getIssue(number, repo) {
1127
+ return repoApi(`issues/${number}`, repo);
1128
+ }
1129
+ async function listIssues(state, filter, repo) {
1130
+ const issues = [];
1131
+ for (let page = 1;; page++) {
1132
+ const batch = await repoApi(`issues?state=${state}&type=issues&page=${page}&limit=${PAGE}${filter}`, repo);
1133
+ issues.push(...batch);
1134
+ if (batch.length < PAGE)
1135
+ return issues;
1136
+ }
1137
+ }
1138
+ async function openIssues(labels, repo, milestone) {
1139
+ return listIssues("open", `${labels === undefined ? "" : `&labels=${encodeURIComponent(labels)}`}${milestone === undefined ? "" : `&milestones=${milestone}`}`, repo);
1140
+ }
1141
+ async function closedIssues(milestone, repo) {
1142
+ return listIssues("closed", `&milestones=${milestone}`, repo);
1143
+ }
1144
+ async function commentsOf(number, repo) {
1145
+ return repoApi(`issues/${number}/comments`, repo);
1146
+ }
1147
+ async function commentsOfAll(issues, repo) {
1148
+ const result = new Map;
1149
+ const queue = issues.filter((issue) => issue.comments > 0);
1150
+ async function worker() {
1151
+ for (let issue = queue.shift();issue !== undefined; issue = queue.shift())
1152
+ result.set(issue.number, await commentsOf(issue.number, repo));
1153
+ }
1154
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
1155
+ return result;
1156
+ }
1157
+ async function editIssue(number, change, repo) {
1158
+ const args = ["issues", "edit", String(number)];
1159
+ if (change.title !== undefined)
1160
+ args.push("-t", change.title);
1161
+ if (change.body !== undefined)
1162
+ args.push("-d", change.body);
1163
+ if (change.milestone !== undefined)
1164
+ args.push("-m", change.milestone);
1165
+ await tea(args, repo);
1166
+ }
1167
+ async function setBody(number, body, repo) {
1168
+ await editIssue(number, { body }, repo);
1169
+ }
1170
+ async function createIssue(title, body, labels, milestone, repo) {
1171
+ const args = ["issues", "create", "-t", title, "-d", body];
1172
+ if (labels !== undefined)
1173
+ args.push("-L", labels);
1174
+ if (milestone !== undefined)
1175
+ args.push("-m", milestone);
1176
+ await tea(args, repo);
1177
+ const created = (await openIssues(undefined, repo)).filter((found) => found.title === title).sort((a, b) => b.number - a.number)[0];
1178
+ if (created === undefined)
1179
+ throw new Error(`created "${title}" but cannot find it among the open issues`);
1180
+ return created;
1181
+ }
1182
+ async function editBody(number, change, repo) {
1183
+ const { body } = await getIssue(number, repo);
1184
+ const next = change(body);
1185
+ if (next === body)
1186
+ return false;
1187
+ await setBody(number, next, repo);
1188
+ return true;
1189
+ }
1190
+ async function comment(number, body, repo) {
1191
+ await tea(["comment", String(number), body], repo);
1192
+ }
1193
+ async function close(number, repo) {
1194
+ await tea(["issues", "close", String(number)], repo);
1195
+ }
1196
+ async function reopen(number, repo) {
1197
+ await tea(["issues", "reopen", String(number)], repo);
1198
+ }
1199
+ async function repoLabels(repo) {
1200
+ const names = [];
1201
+ for (let page = 1;; page++) {
1202
+ const batch = await repoApi(`labels?page=${page}&limit=${PAGE}`, repo);
1203
+ names.push(...batch.map((each) => each.name));
1204
+ if (batch.length < PAGE)
1205
+ return names;
1206
+ }
1207
+ }
1208
+ async function editLabels(number, add, remove, repo) {
1209
+ const args = ["issues", "edit", String(number)];
1210
+ if (add.length > 0)
1211
+ args.push("--add-labels", add.join(","));
1212
+ if (remove.length > 0)
1213
+ args.push("--remove-labels", remove.join(","));
1214
+ await tea(args, repo);
1215
+ }
1216
+
1217
+ // src/milestones.ts
1218
+ var PAGE2 = 50;
1219
+ var DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
1220
+ var NO_DUE_YEAR = 9999;
1221
+ async function listMilestones(state, repo) {
1222
+ const found = [];
1223
+ for (let page = 1;; page++) {
1224
+ const batch = await repoApi(`milestones?state=${state}&page=${page}&limit=${PAGE2}`, repo);
1225
+ found.push(...batch);
1226
+ if (batch.length < PAGE2)
1227
+ return found;
1228
+ }
1229
+ }
1230
+ async function findMilestone(reference, repo) {
1231
+ const all = await listMilestones("all", repo);
1232
+ const found = all.find((each) => each.title === reference) ?? all.find((each) => String(each.id) === reference);
1233
+ if (found === undefined)
1234
+ fail(`no milestone ${reference}, ${all.length === 0 ? "the repository has none" : `it has: ${all.map((each) => each.title).join(", ")}`}`, 2, runHint(`ft milestone new ${reference}`, "create it"));
1235
+ return found;
1236
+ }
1237
+ function dueOn(value) {
1238
+ const match = DATE.exec(value);
1239
+ const date = new Date(`${value}T00:00:00Z`);
1240
+ if (match === null || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value || date.getUTCFullYear() < 2000)
1241
+ fail(`--due takes a date as YYYY-MM-DD, not ${value}`);
1242
+ return date.toISOString();
1243
+ }
1244
+ function dueDate(milestone) {
1245
+ if (milestone.due_on === null || new Date(milestone.due_on).getUTCFullYear() >= NO_DUE_YEAR)
1246
+ return "";
1247
+ return milestone.due_on.slice(0, 10);
1248
+ }
1249
+ function progress(milestone) {
1250
+ const total = milestone.open_issues + milestone.closed_issues;
1251
+ return total === 0 ? "" : `${Math.round(milestone.closed_issues / total * 100)}%`;
1252
+ }
1253
+ async function createMilestone(title, description, due, repo) {
1254
+ const fields = { title };
1255
+ if (description !== undefined)
1256
+ fields.description = description;
1257
+ if (due !== undefined)
1258
+ fields.due_on = due;
1259
+ return repoWrite("POST", "milestones", fields, repo);
1260
+ }
1261
+ async function closeMilestone(id, repo) {
1262
+ await repoWrite("PATCH", `milestones/${id}`, { state: "closed" }, repo);
1263
+ }
1264
+
1265
+ // src/sessions.ts
1266
+ import { homedir } from "os";
1267
+ import { join } from "path";
1268
+ import process9 from "process";
1269
+ var {Glob } = globalThis.Bun;
1270
+ var SESSION_FILES = new Glob("*.json");
1271
+ var ACCOUNT_DIRS = new Glob("*/sessions");
1272
+ async function sessionDirs() {
1273
+ const home = homedir();
1274
+ const dirs = new Set([join(home, ".claude", "sessions")]);
1275
+ if (process9.env.CLAUDE_CONFIG_DIR !== undefined)
1276
+ dirs.add(join(process9.env.CLAUDE_CONFIG_DIR, "sessions"));
1277
+ try {
1278
+ for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
1279
+ dirs.add(dir);
1280
+ } catch {}
1281
+ return [...dirs];
1282
+ }
1283
+ async function procStat(pid) {
1284
+ try {
1285
+ const stat = await Bun.file(`/proc/${pid}/stat`).text();
1286
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ");
1287
+ } catch {
1288
+ return;
1289
+ }
1290
+ }
1291
+ async function isAlive(pid, procStart) {
1292
+ const stat = await procStat(pid);
1293
+ if (stat === undefined)
1294
+ return false;
1295
+ return procStart === undefined || stat[19] === procStart;
1296
+ }
1297
+ async function liveSessions() {
1298
+ const sessions = new Map;
1299
+ for (const dir of await sessionDirs()) {
1300
+ let files;
1301
+ try {
1302
+ files = await Array.fromAsync(SESSION_FILES.scan({ cwd: dir, absolute: true }));
1303
+ } catch {
1304
+ continue;
1305
+ }
1306
+ for (const file of files) {
1307
+ let entry;
1308
+ try {
1309
+ entry = await Bun.file(file).json();
1310
+ } catch {
1311
+ continue;
1312
+ }
1313
+ if (entry.name === undefined || entry.pid === undefined || sessions.has(entry.pid) || !await isAlive(entry.pid, entry.procStart))
1314
+ continue;
1315
+ sessions.set(entry.pid, { name: entry.name, pid: entry.pid, status: entry.status ?? "unknown", cwd: entry.cwd ?? "" });
1316
+ }
1317
+ }
1318
+ return [...sessions.values()];
1319
+ }
1320
+ async function currentSession(sessions) {
1321
+ const byPid = new Map(sessions.map((session) => [session.pid, session]));
1322
+ let pid = process9.ppid;
1323
+ for (let depth = 0;depth < 32 && pid > 1; depth++) {
1324
+ const session = byPid.get(pid);
1325
+ if (session !== undefined)
1326
+ return session;
1327
+ const parent = Number((await procStat(pid))?.[1]);
1328
+ if (!Number.isInteger(parent))
1329
+ return;
1330
+ pid = parent;
1331
+ }
1332
+ return;
1333
+ }
1334
+
1335
+ // src/commands/board.ts
1336
+ var LINE_START = /^/gm;
1337
+ var TITLE_WIDTH = 80;
1338
+ function standing(claim, sessions) {
1339
+ if (claim === undefined)
1340
+ return { state: "available" };
1341
+ const session = holderSession(claim.holder, sessions);
1342
+ return session === undefined ? { state: "away", claim } : { state: "taken", claim, session };
1343
+ }
1344
+ var NOTE_WIDTH = 100;
1345
+ var STATES = new Set(["available", "taken", "away"]);
1346
+ function boxesLeft(issue) {
1347
+ const list = boxes(issue.body);
1348
+ return list.length === 0 ? "" : `${list.filter((box) => !box.done).length}/${list.length} left`;
1349
+ }
1350
+ function claimRow(issue, row, now) {
1351
+ const holder = row.state === "taken" && row.session.name !== row.claim.holder ? `${row.claim.holder} via ${row.session.name}` : row.claim.holder;
1352
+ const status = row.state === "taken" ? ` (${row.session.status})` : "";
1353
+ return [`#${issue.number}`, `${holder}${status}`, formatAge(row.claim.at, now), boxesLeft(issue), truncate(issue.title, TITLE_WIDTH)];
1354
+ }
1355
+ function noteLine(comments, now) {
1356
+ const note = latestNote(comments);
1357
+ const first = note?.body.split(`
1358
+ `).map((line) => line.trim()).find((line) => line !== "");
1359
+ return note === undefined || first === undefined ? [] : [` ${note.user.login} ${formatAge(note.created_at, now)}: ${truncate(first, NOTE_WIDTH)}`];
1360
+ }
1361
+ function within(rows, limit, cells, decorate = (line) => [line]) {
1362
+ const { shown, footer } = capped(rows, limit);
1363
+ return { lines: [...table(shown.map(cells)).split(`
1364
+ `).flatMap(decorate), ...footer === undefined ? [] : [footer]] };
1365
+ }
1366
+ function renderBoard(rows, now = Date.now(), limit = Number.POSITIVE_INFINITY) {
1367
+ const available = rows.filter((row) => row.standing.state === "available");
1368
+ const claimed = (state) => rows.flatMap(({ issue, standing: row, comments }) => row.state === state ? [{ issue, row, comments }] : []);
1369
+ const taken = claimed("taken");
1370
+ const away = claimed("away");
1371
+ const sections = [
1372
+ { heading: "available", count: available.length, ...within(available, limit, ({ issue }) => [`#${issue.number}`, truncate(issue.title, TITLE_WIDTH), issue.labels.map((label) => label.name).join(", ")]) },
1373
+ { heading: "taken", count: taken.length, ...within(taken, limit, ({ issue, row }) => claimRow(issue, row, now)) },
1374
+ { heading: "claimed, no live session here", count: away.length, ...within(away, limit, ({ issue, row }) => claimRow(issue, row, now), (line, index) => [line, ...noteLine(away[index]?.comments ?? [], now)]) }
1375
+ ].filter((section) => section.count > 0);
1376
+ return sections.length === 0 ? "no open issue" : sections.map(({ heading, count, lines }) => `${heading} (${count})
1377
+ ${lines.join(`
1378
+ `).replace(LINE_START, " ")}`).join(`
1379
+
1380
+ `);
1381
+ }
1382
+ async function boardRows(issues, sessions, repo) {
1383
+ const comments = await commentsOfAll(issues, repo);
1384
+ return issues.map((issue) => {
1385
+ const own = comments.get(issue.number) ?? [];
1386
+ return { issue, standing: standing(latestClaim(own), sessions), comments: own };
1387
+ });
1388
+ }
1389
+ function boardJson(rows) {
1390
+ return rows.map(({ issue, standing: { state, ...rest }, comments: own }) => {
1391
+ const list = boxes(issue.body);
1392
+ const note = latestNote(own);
1393
+ return {
1394
+ number: issue.number,
1395
+ title: issue.title,
1396
+ labels: issue.labels.map((label) => label.name),
1397
+ state,
1398
+ holder: "claim" in rest ? rest.claim.holder : undefined,
1399
+ since: "claim" in rest ? rest.claim.at : undefined,
1400
+ session: "session" in rest ? rest.session.name : undefined,
1401
+ boxesTotal: list.length,
1402
+ boxesLeft: list.filter((box) => !box.done).length,
1403
+ note: note === undefined ? undefined : { author: note.user.login, at: note.created_at, body: note.body }
1404
+ };
1405
+ });
1406
+ }
1407
+ var boardUsage = `ft board
1408
+
1409
+ Lists open issues, at most 30 of each kind (--full for all), as available, taken by a session running here, or claimed by a session no session here answers to, from their latest "Taken by session <name>." comment.
1410
+ A claim shows its boxes left, and one no live session holds also the latest comment that is not a claim.
1411
+ A lane's claim (lane-<n>-<issue>) is held while the session whose name ends in -<n> runs.
1412
+
1413
+ Exits 0 when it listed them, 1 when the issues cannot be read, 2 on a usage error.`;
1414
+ var BOARD_LIMIT = 30;
1415
+ var boardOptions = {
1416
+ labels: { type: "string", short: "L", description: "only issues with these labels, comma separated" },
1417
+ milestone: { type: "string", short: "m", description: "only issues of this milestone, a title or an id" },
1418
+ query: { type: "string", short: "q", description: "only issues whose title matches this, a case-insensitive regex such as dom|eager" },
1419
+ state: { type: "string", description: "only issues in this state: available, taken or away (claimed, no live session here)" },
1420
+ ...limitOptions(BOARD_LIMIT),
1421
+ json: jsonOption("number, title, labels, state, holder, since, session, boxesTotal, boxesLeft and note (author, at, body of the latest comment that is not a claim)"),
1422
+ ...repoOption
1423
+ };
1424
+ async function board(args) {
1425
+ const parsed = parse(args, boardUsage, boardOptions);
1426
+ const { values } = parsed;
1427
+ const state = values.state;
1428
+ const limit = limitOf(values, BOARD_LIMIT);
1429
+ if (state !== undefined && !STATES.has(state))
1430
+ fail(`--state takes available, taken or away, not ${state}`);
1431
+ let query;
1432
+ try {
1433
+ query = values.query === undefined ? undefined : new RegExp(values.query, "i");
1434
+ } catch (error) {
1435
+ fail(`--query: ${error instanceof Error ? error.message : String(error)}`);
1436
+ }
1437
+ const milestone = values.milestone === undefined ? undefined : (await findMilestone(values.milestone, values.repo)).id;
1438
+ const [open, sessions] = await Promise.all([openIssues(values.labels, values.repo, milestone), liveSessions()]);
1439
+ const issues = query === undefined ? open : open.filter((issue) => query.test(issue.title));
1440
+ const rows = (await boardRows(issues, sessions, values.repo)).filter((row) => state === undefined || row.standing.state === state);
1441
+ if (values.json === true) {
1442
+ printJson(boardJson(rows));
1443
+ return 0;
1444
+ }
1445
+ console.log(rows.length === 0 && (query !== undefined || state !== undefined) ? "no open issue matches" : renderBoard(rows, Date.now(), limit));
1446
+ return 0;
1447
+ }
1448
+ async function me(as, sessions) {
1449
+ if (as !== undefined)
1450
+ return as;
1451
+ const session = await currentSession(sessions);
1452
+ if (session === undefined)
1453
+ fail("not run from a Claude Code session: pass --as <name>");
1454
+ return session.name;
1455
+ }
1456
+ var takeUsage = `ft take <n>...
1457
+
1458
+ Claims each issue for this Claude Code session with a "Taken by session <name>." comment.
1459
+ An issue another live session holds is refused, one this session or its lanes hold passes to the new name, and one claimed by a session no session here answers to needs --take-stale; a refusal leaves the other issues taken.
1460
+
1461
+ Exits 0 when every issue is taken, 1 when one is refused, 2 on a usage error.`;
1462
+ var laneOption = {
1463
+ lane: { type: "boolean", description: "claim each as lane-<s>-<n>, <s> ending this session's name, so the claim stays live while it runs" }
1464
+ };
1465
+ var takeOptions = {
1466
+ as: { type: "string", description: "the name to claim as, instead of this session's" },
1467
+ ...laneOption,
1468
+ "take-stale": { type: "boolean", description: "take it over from a claim no live session here answers to" },
1469
+ ...repoOption
1470
+ };
1471
+ async function claimant(values, sessions) {
1472
+ const name = await me(values.as, sessions);
1473
+ if (values.lane !== true)
1474
+ return () => name;
1475
+ const suffix = laneSuffix(name);
1476
+ if (suffix === undefined)
1477
+ fail(`a lane is named after a session whose name ends in -<n>, not ${name}: pass --as`);
1478
+ return (number) => `lane-${suffix}-${number}`;
1479
+ }
1480
+ async function eachIssue(command, numbers, step) {
1481
+ let code = 0;
1482
+ for (const number of numbers) {
1483
+ try {
1484
+ await step(number);
1485
+ } catch (error) {
1486
+ if (!(error instanceof Exit) || numbers.length === 1)
1487
+ throw error;
1488
+ console.error(`ft ${command}: ${error.message}`);
1489
+ if (error.hint !== undefined)
1490
+ printHint(error.hint);
1491
+ code = Math.max(code, error.code);
1492
+ }
1493
+ }
1494
+ return code;
1495
+ }
1496
+ async function take(args) {
1497
+ const parsed = parse(args, takeUsage, takeOptions);
1498
+ const { values, positionals } = parsed;
1499
+ const numbers = issueNumbers(positionals, takeUsage);
1500
+ const sessions = await liveSessions();
1501
+ const nameFor = await claimant(values, sessions);
1502
+ return eachIssue("take", numbers, async (number) => {
1503
+ const name = nameFor(number);
1504
+ const [found, comments] = await Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)]);
1505
+ if (found.state === "closed")
1506
+ fail(`#${number} is closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1507
+ const claim = latestClaim(comments);
1508
+ const current = standing(claim, sessions);
1509
+ if (claim?.holder === name) {
1510
+ console.log(`#${number} is already taken by ${name}`);
1511
+ return;
1512
+ }
1513
+ if (current.state === "taken" && holderSession(name, sessions)?.pid !== current.session.pid)
1514
+ fail(`#${number} is taken by ${current.claim.holder}${current.session.name === current.claim.holder ? "" : ` in ${current.session.name}`}, which still runs (${current.session.status})`, 1);
1515
+ if (current.state === "away" && values["take-stale"] !== true)
1516
+ fail(`#${number} was taken by ${current.claim.holder} ${formatAge(current.claim.at)}, which no session here answers to: it may run on another machine`, 1, runHint(`ft take ${number} --take-stale`, "take it over"));
1517
+ await comment(number, takenComment(name), values.repo);
1518
+ console.log(`took #${number} as ${name}: ${found.title}`);
1519
+ });
1520
+ }
1521
+ var dropUsage = `ft drop <n>...
1522
+
1523
+ Gives each issue back with a "Released by session <name>." comment, when this session holds it.
1524
+
1525
+ Exits 0 when every issue is given back, 1 when one is not held by this session, 2 on a usage error.`;
1526
+ var dropOptions = {
1527
+ as: { type: "string", description: "the name it was taken as, instead of this session's" },
1528
+ ...laneOption,
1529
+ ...repoOption
1530
+ };
1531
+ async function drop(args) {
1532
+ const parsed = parse(args, dropUsage, dropOptions);
1533
+ const { values, positionals } = parsed;
1534
+ const numbers = issueNumbers(positionals, dropUsage);
1535
+ const nameFor = await claimant(values, await liveSessions());
1536
+ return eachIssue("drop", numbers, async (number) => {
1537
+ const name = nameFor(number);
1538
+ const claim = latestClaim(await commentsOf(number, values.repo));
1539
+ if (claim?.holder !== name)
1540
+ fail(`#${number} is ${claim === undefined ? "not taken" : `taken by ${claim.holder}, not ${name}`}`, 1);
1541
+ await comment(number, releasedComment(name), values.repo);
1542
+ console.log(`released #${number}`);
1543
+ });
1544
+ }
1545
+
1546
+ // src/commands/ci.ts
1547
+ import process11 from "process";
1548
+
1549
+ // ../kit/src/progress.ts
1550
+ import process10 from "process";
1551
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1552
+ var FRAME_MS = 80;
1553
+ var CLEAR_LINE = "\r\x1B[2K";
1554
+ var LINE_UP = "\x1B[1A\x1B[2K";
1555
+ var ESC = String.fromCharCode(27);
1556
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
1557
+ var RESET_STYLE = `${ESC}[0m`;
1558
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
1559
+ var CONSOLE_METHODS = ["log", "info", "warn", "error", "debug", "dir", "dirxml", "table", "trace", "group", "groupCollapsed", "count", "timeEnd", "timeLog", "assert"];
1560
+ function liveRegion(render, stream = process10.stderr, env = process10.env) {
1561
+ if (!takesControlCodes(stream, env))
1562
+ return { redraw: () => {
1563
+ return;
1564
+ }, print: (text) => void stream.write(`${text}
1565
+ `), stop: () => {
1566
+ return;
1567
+ } };
1568
+ const write = stream.write.bind(stream);
1569
+ let frame = 0;
1570
+ let drawn = 0;
1571
+ const clear = () => {
1572
+ if (drawn > 0)
1573
+ write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`);
1574
+ drawn = 0;
1575
+ };
1576
+ const draw = () => {
1577
+ const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1;
1578
+ const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows;
1579
+ const lines = render(FRAMES[frame++ % FRAMES.length]).slice(0, Math.max(1, height - 1)).map((line) => cutToWidth(line, width));
1580
+ if (lines.length === 0) {
1581
+ clear();
1582
+ return;
1583
+ }
1584
+ write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join(`
1585
+ `)}`);
1586
+ drawn = lines.length;
1587
+ };
1588
+ const timer = setInterval(draw, FRAME_MS);
1589
+ const restore = interceptOutput(clear);
1590
+ const onExit = () => clear();
1591
+ const signals = holdSignals(stop);
1592
+ function stop() {
1593
+ clearInterval(timer);
1594
+ clear();
1595
+ restore();
1596
+ process10.off("exit", onExit);
1597
+ signals.release();
1598
+ }
1599
+ timer.unref();
1600
+ process10.on("exit", onExit);
1601
+ return {
1602
+ redraw: draw,
1603
+ print: (text) => {
1604
+ clear();
1605
+ write(`${text}
1606
+ `);
1607
+ draw();
1608
+ },
1609
+ stop
1610
+ };
1611
+ }
1612
+ function cutToWidth(line, width) {
1613
+ if (Bun.stringWidth(line) <= width)
1614
+ return line;
1615
+ let room = width;
1616
+ let kept = "";
1617
+ let index = 0;
1618
+ let linked = false;
1619
+ while (index < line.length) {
1620
+ ESCAPE_CODE.lastIndex = index;
1621
+ const escape = ESCAPE_CODE.exec(line);
1622
+ if (escape !== null) {
1623
+ kept += escape[0];
1624
+ linked ||= escape[0].startsWith(`${ESC}]`);
1625
+ index = ESCAPE_CODE.lastIndex;
1626
+ continue;
1627
+ }
1628
+ const char = String.fromCodePoint(line.codePointAt(index));
1629
+ const columns = Bun.stringWidth(char);
1630
+ if (columns > room)
1631
+ break;
1632
+ kept += char;
1633
+ room -= columns;
1634
+ index += char.length;
1635
+ }
1636
+ return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ""}`;
1637
+ }
1638
+ function interceptOutput(clear) {
1639
+ const targets = [...CONSOLE_METHODS.map((name) => [console, name]), [process10.stdout, "write"], [process10.stderr, "write"]];
1640
+ const wrapped = targets.map(([target, name]) => {
1641
+ const original = Reflect.get(target, name);
1642
+ const wrapper = (...args) => {
1643
+ clear();
1644
+ return original.apply(target, args);
1645
+ };
1646
+ Reflect.set(target, name, wrapper);
1647
+ return { target, name, original, wrapper };
1648
+ });
1649
+ return () => {
1650
+ for (const { target, name, original, wrapper } of wrapped) {
1651
+ if (Reflect.get(target, name) === wrapper)
1652
+ Reflect.set(target, name, original);
1653
+ }
1654
+ };
1655
+ }
1656
+
1657
+ // src/log.ts
1658
+ var BOUNDARY = /^(?:\((?:pass|fail|skip|todo)\) |::(?:end)?group::|\S+\.test\.[jt]sx?:$)/;
1659
+ var DURATION2 = / \[[\d.]+m?s\]$/;
1660
+ var FAIL = "(fail) ";
1661
+ var PASS = "(pass) ";
1662
+ var MAX_TESTS = 10;
1663
+ var STAMP = /^\S+Z /;
1664
+ var FAILED_STEP = /\u274C\s+Failure - Main /;
1665
+ var STEP_TAIL = 30;
1666
+ var TAIL = 40;
1667
+ function logLines(log) {
1668
+ return log.split(`
1669
+ `).map((line) => line.replace(STAMP, ""));
1670
+ }
1671
+ function failedTests(lines) {
1672
+ const seen = new Set;
1673
+ const blocks = [];
1674
+ for (const [index, line] of lines.entries()) {
1675
+ const name = line.replace(DURATION2, "");
1676
+ if (!line.startsWith(FAIL) || seen.has(name))
1677
+ continue;
1678
+ seen.add(name);
1679
+ if (blocks.length < MAX_TESTS)
1680
+ blocks.push(lines.slice(lines.findLastIndex((text, at) => at < index && BOUNDARY.test(text)) + 1, index + 1).filter((text) => !text.startsWith("::")).join(`
1681
+ `).trim());
1682
+ }
1683
+ if (seen.size > MAX_TESTS)
1684
+ blocks.push(`and ${seen.size - MAX_TESTS} more failed tests`);
1685
+ return blocks;
1686
+ }
1687
+ function excerpt(log) {
1688
+ const lines = logLines(log);
1689
+ const parts = failedTests(lines);
1690
+ for (const [index, line] of lines.entries()) {
1691
+ if (FAILED_STEP.test(line))
1692
+ parts.push(lines.slice(Math.max(0, index - STEP_TAIL), index + 1).filter((text) => text.trim() !== "" && !text.startsWith(PASS) && !text.startsWith("::")).join(`
1693
+ `));
1694
+ }
1695
+ return parts.length === 0 ? lines.slice(-TAIL).join(`
1696
+ `).trim() : parts.join(`
1697
+
1698
+ `);
1699
+ }
1700
+
1701
+ // src/runs.ts
1702
+ var FINISHED = new Set(["success", "failure", "skipped", "cancelled"]);
1703
+ var QUEUED = new Set(["waiting", "blocked"]);
1704
+ var NANOSECONDS = 1e9;
1705
+ var EPOCH = Date.parse("2000-01-01T00:00:00Z");
1706
+ function runSeconds(run) {
1707
+ return run.duration / NANOSECONDS;
1708
+ }
1709
+ function startedAge(run, now = Date.now()) {
1710
+ return Date.parse(run.started) < EPOCH ? "-" : formatAge(run.started, now);
1711
+ }
1712
+ var RUNS_PAGE = 50;
1713
+ async function runsListing(limit, repo, workflow) {
1714
+ const filter = workflow === undefined ? "" : `&workflow_id=${encodeURIComponent(workflow)}`;
1715
+ const runs = [];
1716
+ let total = 0;
1717
+ for (let page = 1;runs.length < limit; page++) {
1718
+ const found = await repoApi(`actions/runs?page=${page}&limit=${RUNS_PAGE}${filter}`, repo);
1719
+ const list = found.workflow_runs ?? [];
1720
+ runs.push(...list);
1721
+ total = found.total_count ?? 0;
1722
+ if (list.length < RUNS_PAGE)
1723
+ break;
1724
+ }
1725
+ return { runs: runs.slice(0, limit), total: Math.max(total, runs.length) };
1726
+ }
1727
+ async function recentRuns(limit, repo, workflow) {
1728
+ return (await runsListing(limit, repo, workflow)).runs;
1729
+ }
1730
+ async function runsOn(sha, repo) {
1731
+ return (await repoApi(`actions/runs?head_sha=${sha}`, repo)).workflow_runs ?? [];
1732
+ }
1733
+ async function runById(id, repo) {
1734
+ return repoApi(`actions/runs/${id}`, repo);
1735
+ }
1736
+ async function jobsOf(run, repo) {
1737
+ return repoApi(`actions/runs/${run}/jobs`, repo);
1738
+ }
1739
+ async function jobLog(job, repo) {
1740
+ return repoApiText(`actions/jobs/${job}/logs`, repo);
1741
+ }
1742
+ async function refreshed(runs, repo) {
1743
+ try {
1744
+ return await Promise.all(runs.map(async (run) => runById(run.id, repo)));
1745
+ } catch {
1746
+ return;
1747
+ }
1748
+ }
1749
+ function newestPerWorkflow(runs, workflow) {
1750
+ const newest = new Map;
1751
+ for (const run of runs) {
1752
+ if (run.event !== "push" || workflow !== undefined && run.workflow_id !== workflow)
1753
+ continue;
1754
+ const kept = newest.get(run.workflow_id);
1755
+ if (kept === undefined || run.id > kept.id)
1756
+ newest.set(run.workflow_id, run);
1757
+ }
1758
+ return [...newest.values()].sort((a, b) => a.workflow_id.localeCompare(b.workflow_id));
1759
+ }
1760
+ function overall(runs) {
1761
+ const statuses = new Set(runs.map((run) => run.status));
1762
+ for (const status of ["failure", "cancelled"]) {
1763
+ if (statuses.has(status))
1764
+ return status;
1765
+ }
1766
+ const pending = runs.find((run) => !FINISHED.has(run.status));
1767
+ if (pending !== undefined)
1768
+ return pending.status;
1769
+ return statuses.has("success") ? "success" : "skipped";
1770
+ }
1771
+ async function stallReason(runs, repo) {
1772
+ const queued = runs.filter((run) => QUEUED.has(run.status));
1773
+ if (queued.length === 0)
1774
+ return;
1775
+ let runners;
1776
+ try {
1777
+ runners = await api("admin/actions/runners");
1778
+ } catch {
1779
+ return;
1780
+ }
1781
+ const online = runners.filter((runner) => runner.status !== "offline");
1782
+ if (online.length === 0)
1783
+ return { reason: runners.length === 0 ? "no runner is registered" : `every runner is offline (${runners.map((runner) => runner.name).join(", ")})`, certain: false };
1784
+ for (const run of queued) {
1785
+ for (const job of await jobsOf(run.id, repo)) {
1786
+ const labels = job.runs_on ?? [];
1787
+ if (QUEUED.has(job.status) && !online.some((runner) => labels.every((label) => (runner.labels ?? []).includes(label))))
1788
+ return { reason: `job ${job.name} of ${run.workflow_id} runs on ${labels.join(", ")}, which no online runner carries (${online.map((runner) => `${runner.name}: ${(runner.labels ?? []).join(", ")}`).join("; ")})`, certain: true };
1789
+ }
1790
+ }
1791
+ return online.some((runner) => runner.status === "active") ? undefined : { reason: "no runner is working on anything: is it up?", certain: false };
1792
+ }
1793
+ async function failureReport(run, repo) {
1794
+ const parts = [];
1795
+ for (const job of await jobsOf(run.id, repo)) {
1796
+ if (job.status === "failure")
1797
+ parts.push(`job ${job.name} of ${run.workflow_id} failed:
1798
+ ${excerpt(await jobLog(job.id, repo))}`);
1799
+ }
1800
+ return parts.join(`
1801
+
1802
+ `);
1803
+ }
1804
+
1805
+ // src/commands/ci.ts
1806
+ var usage = `ft ci [ref]
1807
+
1808
+ Waits for the push runs covering a commit on the branch, by default origin's tip, and prints what each failed job's log says.
1809
+ A push's run tests every commit since the previous push, so a commit whose own run was cancelled, or whose --job was skipped, is covered by the next commit's runs.
1810
+ Exits 0 when they pass, 1 when one fails, 2 on a timeout, a stalled queue or a commit not on the branch.`;
1811
+ var options = {
1812
+ branch: { type: "string", short: "b", description: "branch the commit is on, by default the one origin/HEAD names" },
1813
+ workflow: { type: "string", short: "w", description: "only this workflow file, e.g. test.yml; by default every workflow a push starts" },
1814
+ job: { type: "string", short: "j", description: "a job whose skipping means the run left its work to a newer push" },
1815
+ timeout: { type: "string", short: "t", description: "how long before giving up (30m), a bare number in minutes" }
1816
+ };
1817
+ var POLL = 15000;
1818
+ var NO_RUN = 3 * 60000;
1819
+ var STALLED = 3 * 60000;
1820
+ function judge(runs, jobSkipped, tagged) {
1821
+ if (runs.length === 0)
1822
+ return { kind: "none" };
1823
+ if (runs.some((run) => !FINISHED.has(run.status)))
1824
+ return { kind: "runs", runs };
1825
+ if (runs.some((run) => run.status === "cancelled"))
1826
+ return { kind: "passed-over" };
1827
+ if (!jobSkipped)
1828
+ return { kind: "runs", runs };
1829
+ return tagged ? { kind: "runs", runs: runs.map((run) => ({ ...run, status: "skipped" })) } : { kind: "passed-over" };
1830
+ }
1831
+ async function ci(args) {
1832
+ const parsed = parse(args, usage, options);
1833
+ const { values, positionals } = parsed;
1834
+ const branch = values.branch ?? await defaultBranch();
1835
+ const giveUp = duration(values.timeout, "timeout", 30 * 60000, "m");
1836
+ const sha = await resolveCommit(positionals[0] ?? `origin/${branch}`);
1837
+ const short = sha.slice(0, 7);
1838
+ await leaveWorktree();
1839
+ const passedOver = new Set;
1840
+ async function coverageOf(commit) {
1841
+ if (passedOver.has(commit))
1842
+ return { kind: "passed-over" };
1843
+ const runs = newestPerWorkflow(await runsOn(commit), values.workflow);
1844
+ let jobSkipped = false;
1845
+ if (values.job !== undefined && runs.every((run) => FINISHED.has(run.status))) {
1846
+ for (const run of runs)
1847
+ jobSkipped ||= (await jobsOf(run.id)).some((job) => job.name === values.job && job.status === "skipped");
1848
+ }
1849
+ const coverage = judge(runs, jobSkipped, jobSkipped && (await tagsAt(commit)).length > 0);
1850
+ if (coverage.kind === "passed-over")
1851
+ passedOver.add(commit);
1852
+ return coverage;
1853
+ }
1854
+ async function covering() {
1855
+ await git(["fetch", "-q", "origin", branch]);
1856
+ if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`]))
1857
+ fail(`${short} is not on origin/${branch}`, 2, runHint(`git push origin ${branch}`));
1858
+ const later = (await git(["rev-list", "--reverse", "--first-parent", `${sha}..origin/${branch}`])).split(`
1859
+ `).filter(Boolean);
1860
+ for (const commit of [sha, ...later]) {
1861
+ const coverage = await coverageOf(commit);
1862
+ if (coverage.kind === "runs")
1863
+ return coverage.runs;
1864
+ }
1865
+ return;
1866
+ }
1867
+ const started = Date.now();
1868
+ let seen = started;
1869
+ let moving = started;
1870
+ let followed = "";
1871
+ let runs;
1872
+ const { dim } = paint(process11.stderr);
1873
+ const region = liveRegion((spinner) => [
1874
+ `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
1875
+ ...(runs ?? []).map((run) => ` ${badge(run.status, process11.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
1876
+ ]);
1877
+ try {
1878
+ for (;; ) {
1879
+ if (Date.now() - started > giveUp)
1880
+ fail(`CI has not finished on ${short} in ${formatDuration(giveUp)}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1881
+ try {
1882
+ runs = await covering();
1883
+ } catch (error) {
1884
+ if (error instanceof Exit)
1885
+ throw error;
1886
+ if (cwdGone())
1887
+ fail(`the working directory was removed while waiting for CI on ${short}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1888
+ console.warn(`ft ci: ${error instanceof Error ? error.message : String(error)}, polling again`);
1889
+ const latest = runs === undefined ? undefined : await refreshed(runs);
1890
+ if (latest?.every((run) => FINISHED.has(run.status)) === true) {
1891
+ runs = latest;
1892
+ break;
1893
+ }
1894
+ await Bun.sleep(POLL);
1895
+ continue;
1896
+ }
1897
+ if (runs !== undefined) {
1898
+ seen = Date.now();
1899
+ const ids = runs.map((run) => run.id).join(",");
1900
+ if (ids !== followed) {
1901
+ followed = ids;
1902
+ for (const run of runs)
1903
+ console.log(`following ${run.workflow_id} run ${run.id} on ${run.commit_sha.slice(0, 7)}${run.commit_sha === sha ? "" : `, which covers ${short}`}: ${run.html_url}`);
1904
+ }
1905
+ if (runs.every((run) => FINISHED.has(run.status)))
1906
+ break;
1907
+ const stall = await stallReason(runs);
1908
+ if (stall === undefined)
1909
+ moving = Date.now();
1910
+ else if (stall.certain || Date.now() - moving > STALLED)
1911
+ fail(`runs queued on ${short} are not moving: ${stall.reason}`);
1912
+ } else if (Date.now() - seen > NO_RUN) {
1913
+ fail(`no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`);
1914
+ }
1915
+ await Bun.sleep(POLL);
1916
+ }
1917
+ } finally {
1918
+ region.stop();
1919
+ }
1920
+ const status = overall(runs);
1921
+ if (status !== "failure") {
1922
+ console.log(`CI ${status === "success" ? "passed" : "skipped its tests, a release tag's workflow testing them"} on ${short}`);
1923
+ return 0;
1924
+ }
1925
+ for (const run of runs) {
1926
+ if (run.status === "failure")
1927
+ console.error(`
1928
+ ${await failureReport(run)}
1929
+
1930
+ run ${run.id} failed${run.commit_sha === sha ? "" : `, testing every commit from ${short} to ${run.commit_sha.slice(0, 7)}`}: ${run.html_url}`);
1931
+ }
1932
+ return 1;
1933
+ }
1934
+
1935
+ // src/commands/digest.ts
1936
+ import process12 from "process";
1937
+ var RUNS_SCANNED = 50;
1938
+ var TITLE_WIDTH2 = 70;
1939
+ var SHOWN = 8;
1940
+ var LINE_START2 = /^/gm;
1941
+ function claimed(issue, claim) {
1942
+ const list = boxes(issue.body);
1943
+ return { number: issue.number, title: issue.title, holder: claim.holder, since: claim.at, boxesLeft: list.filter((box) => !box.done).length, boxesTotal: list.length };
1944
+ }
1945
+ function claims(rows, sessions, me) {
1946
+ const held = [];
1947
+ const away = [];
1948
+ for (const { issue, claim } of rows) {
1949
+ const row = standing(claim, sessions);
1950
+ if (row.state === "taken" && me !== undefined && row.session.pid === me.pid)
1951
+ held.push(claimed(issue, row.claim));
1952
+ else if (row.state === "away")
1953
+ away.push(claimed(issue, row.claim));
1954
+ }
1955
+ return { held, away };
1956
+ }
1957
+ function unsettled(runs, branch) {
1958
+ return newestPerWorkflow(runs.filter((run) => run.prettyref === branch)).filter((run) => run.status === "failure" || !FINISHED.has(run.status)).map((run) => ({ id: run.id, status: run.status, workflow: run.workflow_id, started: run.started, title: run.title }));
1959
+ }
1960
+ function dirtyFiles(porcelain) {
1961
+ return porcelain.split(`
1962
+ `).flatMap((line) => {
1963
+ const trimmed = line.trim();
1964
+ const space = trimmed.indexOf(" ");
1965
+ return space === -1 ? [] : [{ status: trimmed.slice(0, space), path: trimmed.slice(space + 1).trim() }];
1966
+ });
1967
+ }
1968
+ async function unpushedCommits() {
1969
+ const base = await gitOk(["rev-parse", "--verify", "-q", "@{upstream}"]) ? "@{upstream}" : `origin/${await defaultBranch()}`;
1970
+ if (!await gitOk(["rev-parse", "--verify", "-q", base]))
1971
+ return [];
1972
+ return (await git(["log", `${base}..HEAD`, "--format=%h %s"])).split(`
1973
+ `).filter(Boolean);
1974
+ }
1975
+ async function dirtyTree() {
1976
+ return dirtyFiles(await git(["status", "--porcelain"]));
1977
+ }
1978
+ async function quietly(read) {
1979
+ try {
1980
+ return await read();
1981
+ } catch {
1982
+ return [];
1983
+ }
1984
+ }
1985
+ async function staleTodos() {
1986
+ const bin = Bun.which("todo-scan", { PATH: process12.env.PATH ?? "" });
1987
+ if (bin === null)
1988
+ return [];
1989
+ let found;
1990
+ try {
1991
+ const ran = await runCommand([bin, "--stale", "--json"]);
1992
+ found = JSON.parse(Buffer.concat(ran.chunks.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data)).toString());
1993
+ } catch {
1994
+ return [];
1995
+ }
1996
+ const byIssue = new Map;
1997
+ for (const mark of found) {
1998
+ for (const ref of mark.refs.filter((each) => each.state === "closed")) {
1999
+ const key = `${ref.repo}#${ref.number}`;
2000
+ const entry = byIssue.get(key) ?? { issue: key, comments: [] };
2001
+ entry.comments.push({ path: mark.path, line: mark.line });
2002
+ byIssue.set(key, entry);
2003
+ }
2004
+ }
2005
+ return [...byIssue.values()];
2006
+ }
2007
+ function claimLine(row, now) {
2008
+ return [`#${row.number}`, row.holder, formatAge(row.since, now), row.boxesTotal === 0 ? "" : `${row.boxesLeft}/${row.boxesTotal} left`, truncate(row.title, TITLE_WIDTH2)];
2009
+ }
2010
+ function capped2(lines) {
2011
+ return lines.length > SHOWN ? [...lines.slice(0, SHOWN), `... ${lines.length - SHOWN} more`] : lines;
2012
+ }
2013
+ function renderDigest(digest, now = Date.now()) {
2014
+ const sections = [
2015
+ { heading: "held by this session", count: digest.held.length, lines: table(digest.held.map((row) => claimLine(row, now))).split(`
2016
+ `) },
2017
+ { heading: "claimed, no live session here", count: digest.away.length, lines: table(digest.away.map((row) => claimLine(row, now))).split(`
2018
+ `) },
2019
+ { heading: "CI on the default branch", count: digest.ci.length, lines: digest.ci.map((run) => `${run.workflow} ${run.status} (run ${run.id}) ${truncate(upTo(run.title, `
2020
+ `), TITLE_WIDTH2)}`) },
2021
+ { heading: "unpushed commits", count: digest.unpushed.length, lines: capped2(digest.unpushed) },
2022
+ { heading: "dirty files", count: digest.dirty.length, lines: capped2(digest.dirty.map((file) => `${file.status} ${file.path}`)) },
2023
+ { heading: "stale TODO comments", count: digest.stale.length, lines: capped2(digest.stale.map((todo) => `${todo.issue} closed: ${todo.comments.map((each) => `${each.path}:${each.line}`).join(", ")}`)) }
2024
+ ].filter((section) => section.count > 0);
2025
+ return sections.length === 0 ? "nothing to pick up" : sections.map(({ heading, count, lines }) => `${heading} (${count})
2026
+ ${lines.join(`
2027
+ `).replace(LINE_START2, " ")}`).join(`
2028
+
2029
+ `);
2030
+ }
2031
+ var usage2 = `ft digest
2032
+
2033
+ Summarises the repository for a session starting cold: the issues this session holds with their boxes left, the claims no live session here answers to, red or running CI on the default branch, unpushed commits, dirty files and TODO comments naming a closed issue.
2034
+ A section with nothing in it is left out, and the stale TODO comments need todo-scan on the PATH.
2035
+
2036
+ Exits 0 when it summarised the repository, 1 when Forgejo cannot be read, 2 on a usage error.`;
2037
+ var options2 = {
2038
+ json: jsonOption("held and away (number, title, holder, since, boxesLeft, boxesTotal), ci (id, status, workflow, started, title), unpushed, dirty (status, path) and stale (issue, comments: path, line)"),
2039
+ ...repoOption
2040
+ };
2041
+ async function digest(args) {
2042
+ const parsed = parse(args, usage2, options2);
2043
+ const { values } = parsed;
2044
+ const [issues, sessions, branch, runs] = await Promise.all([openIssues(undefined, values.repo), liveSessions(), defaultBranch(), recentRuns(RUNS_SCANNED, values.repo)]);
2045
+ const comments = await commentsOfAll(issues, values.repo);
2046
+ const me = await currentSession(sessions);
2047
+ const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? []) }));
2048
+ const [unpushed, dirty, stale] = await Promise.all([quietly(unpushedCommits), quietly(dirtyTree), staleTodos()]);
2049
+ const summary = { ...claims(rows, sessions, me), ci: unsettled(runs, branch), unpushed, dirty, stale };
2050
+ if (values.json === true)
2051
+ printJson(summary);
2052
+ else
2053
+ console.log(renderDigest(summary));
2054
+ return 0;
2055
+ }
2056
+
2057
+ // src/commands/triage.ts
2058
+ async function checkLabels(names, repo) {
2059
+ if (names.length === 0)
2060
+ return;
2061
+ const known = await repoLabels(repo);
2062
+ const missing = names.filter((name) => !known.includes(name));
2063
+ if (missing.length > 0)
2064
+ fail(`no label ${missing.join(", ")} in this repository, ${known.length === 0 ? "which has none" : `it has: ${known.join(", ")}`}`);
2065
+ }
2066
+ var stateOptions = {
2067
+ message: { type: "string", short: "m", description: "a comment posted first, saying why" },
2068
+ ...repoOption
2069
+ };
2070
+ async function setState(args, usage, wanted) {
2071
+ const parsed = parse(args, usage, stateOptions);
2072
+ const { values, positionals } = parsed;
2073
+ for (const number of issueNumbers(positionals, usage)) {
2074
+ if ((await getIssue(number, values.repo)).state === wanted) {
2075
+ console.log(`#${number} is already ${wanted}`);
2076
+ continue;
2077
+ }
2078
+ if (values.message !== undefined && values.message.trim() !== "")
2079
+ await comment(number, values.message.trim(), values.repo);
2080
+ if (wanted === "closed")
2081
+ await close(number, values.repo);
2082
+ else
2083
+ await reopen(number, values.repo);
2084
+ console.log(`${wanted === "closed" ? "closed" : "reopened"} #${number}`);
2085
+ }
2086
+ return 0;
2087
+ }
2088
+ var closeUsage = `ft close <n>... [-m <reason>]
2089
+
2090
+ Closes issues without a fix, a duplicate or one dropped, posting the -m reason first. \`ft fixed\` closes one a commit fixed.
2091
+
2092
+ Exits 0 when every issue is closed, 1 when Forgejo refuses, 2 on a usage error.`;
2093
+ async function closeIssues(args) {
2094
+ return setState(args, closeUsage, "closed");
2095
+ }
2096
+ var reopenUsage = `ft reopen <n>... [-m <reason>]
2097
+
2098
+ Reopens closed issues, posting the -m reason first.
2099
+
2100
+ Exits 0 when every issue is open, 1 when Forgejo refuses, 2 on a usage error.`;
2101
+ async function reopenIssues(args) {
2102
+ return setState(args, reopenUsage, "open");
2103
+ }
2104
+ var LABEL_FLAGS = new Set(["-h", "--help"]);
2105
+ var REPO_FLAGS = new Set(["-r", "--repo"]);
2106
+ function labelChanges(args) {
2107
+ const rest = [];
2108
+ const add = [];
2109
+ const remove = [];
2110
+ for (let at = 0;at < args.length; at++) {
2111
+ const arg = args[at] ?? "";
2112
+ if (REPO_FLAGS.has(arg) && at + 1 < args.length)
2113
+ rest.push(arg, args[++at] ?? "");
2114
+ else if (arg.startsWith("+") && arg.length > 1)
2115
+ add.push(arg.slice(1));
2116
+ else if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1 && !LABEL_FLAGS.has(arg) && !REPO_FLAGS.has(arg))
2117
+ remove.push(arg.slice(1));
2118
+ else
2119
+ rest.push(arg);
2120
+ }
2121
+ return { rest, add, remove };
2122
+ }
2123
+ var labelUsage = `ft label <n> [+]<label>... -<label>...
2124
+
2125
+ Adds the labels named bare or with +, removes those named with -, then prints the issue's labels. A label the repository lacks is refused.
2126
+
2127
+ Exits 0 when it changed the labels, 1 when Forgejo refuses, 2 on a usage error or a label the repository lacks.`;
2128
+ async function label(args) {
2129
+ const changes = labelChanges(args);
2130
+ const parsed = parse(changes.rest, labelUsage, repoOption);
2131
+ const { values, positionals } = parsed;
2132
+ const number = issueNumber(positionals[0], labelUsage);
2133
+ const add = [...positionals.slice(1), ...changes.add];
2134
+ const { remove } = changes;
2135
+ if (add.length === 0 && remove.length === 0)
2136
+ fail(`name labels to add or remove
2137
+
2138
+ ${labelUsage}`);
2139
+ const both = add.filter((name) => remove.includes(name));
2140
+ if (both.length > 0)
2141
+ fail(`${both.join(", ")} is both added and removed`);
2142
+ await checkLabels(add, values.repo);
2143
+ await editLabels(number, add, remove, values.repo);
2144
+ const labels = (await getIssue(number, values.repo)).labels.map((each) => each.name);
2145
+ console.log(`#${number} labels: ${labels.length === 0 ? "none" : labels.join(", ")}`);
2146
+ return 0;
2147
+ }
2148
+
2149
+ // src/commands/issue.ts
2150
+ var issueUsage = `ft issue <n>...
2151
+
2152
+ Prints each issue: its title, state, boxes left and claim, then its description with each checklist box numbered as \`ft tick\` takes it.
2153
+ With --body, prints only the raw description, to edit and give back to \`ft edit -F\`.
2154
+
2155
+ Exits 0 when it printed them, 1 when an issue cannot be read, 2 on a usage error.`;
2156
+ var issueOptions = {
2157
+ todo: { type: "boolean", short: "t", description: "only the header and the boxes left to tick" },
2158
+ comments: { type: "boolean", short: "c", description: "add the comments, each with its author and age" },
2159
+ body: { type: "boolean", short: "b", description: "only the raw description, without numbered boxes" },
2160
+ json: jsonOption("number, title, state, url, labels, claim, boxes, body and comments, an array for several issues"),
2161
+ ...repoOption
2162
+ };
2163
+ function renderIssue(found, comments, view, now = Date.now()) {
2164
+ const claim = latestClaim(comments);
2165
+ const list = boxes(found.body);
2166
+ const left = list.filter((box) => !box.done);
2167
+ const facts = [found.state];
2168
+ if (list.length > 0)
2169
+ facts.push(`${left.length} of ${list.length} boxes left`);
2170
+ if (claim !== undefined)
2171
+ facts.push(`taken by ${claim.holder} ${formatAge(claim.at, now)}`);
2172
+ const header = `#${found.number} ${found.title} (${facts.join(", ")})`;
2173
+ if (view.todo)
2174
+ return [header, ...left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`)].join(`
2175
+ `);
2176
+ const parts = [`${header}
2177
+ ${found.html_url}`];
2178
+ const body = numberedBody(found.body);
2179
+ if (body !== "")
2180
+ parts.push(body);
2181
+ if (view.comments) {
2182
+ for (const each of comments)
2183
+ parts.push(`--- ${each.user.login}, ${formatAge(each.created_at, now)}
2184
+ ${each.body.replaceAll(`\r
2185
+ `, `
2186
+ `).trim()}`);
2187
+ }
2188
+ return parts.join(`
2189
+
2190
+ `);
2191
+ }
2192
+ function issueJson(found, comments) {
2193
+ return {
2194
+ number: found.number,
2195
+ title: found.title,
2196
+ state: found.state,
2197
+ url: found.html_url,
2198
+ labels: found.labels.map((label) => label.name),
2199
+ claim: latestClaim(comments) ?? null,
2200
+ boxes: boxes(found.body).map(({ number, done, text }) => ({ number, done, text })),
2201
+ body: found.body,
2202
+ comments: comments.map((each) => ({ author: each.user.login, at: each.created_at, body: each.body }))
2203
+ };
2204
+ }
2205
+ async function issue(args) {
2206
+ const parsed = parse(args, issueUsage, issueOptions);
2207
+ const { values, positionals } = parsed;
2208
+ const numbers = issueNumbers(positionals, issueUsage);
2209
+ if (values.body === true) {
2210
+ const bodies = await Promise.all(numbers.map(async (number) => (await getIssue(number, values.repo)).body.replaceAll(`\r
2211
+ `, `
2212
+ `)));
2213
+ console.log(bodies.join(`
2214
+
2215
+ `));
2216
+ return 0;
2217
+ }
2218
+ const read = await Promise.all(numbers.map(async (number) => Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)])));
2219
+ if (values.json === true) {
2220
+ const json = read.map(([found, comments]) => issueJson(found, comments));
2221
+ printJson(json.length === 1 ? json[0] : json);
2222
+ return 0;
2223
+ }
2224
+ const view = { todo: values.todo === true, comments: values.comments === true };
2225
+ console.log(read.map(([found, comments]) => renderIssue(found, comments, view)).join(view.todo ? `
2226
+
2227
+ ` : `
2228
+
2229
+
2230
+ `));
2231
+ return 0;
2232
+ }
2233
+ var noteOptions = {
2234
+ message: { type: "string", short: "m", description: "comment this text on the issue" },
2235
+ file: { type: "string", short: "F", description: "comment the text of this file, - for stdin" }
2236
+ };
2237
+ async function noteText(values) {
2238
+ if (values.message !== undefined && values.file !== undefined)
2239
+ fail("give the comment once, as text or with -F");
2240
+ const text = values.file === undefined ? values.message : await readInput(values.file);
2241
+ return text?.trim() === "" ? undefined : text?.trim();
2242
+ }
2243
+ async function isClosed(number, repo) {
2244
+ try {
2245
+ return (await getIssue(number, repo)).state === "closed";
2246
+ } catch {
2247
+ return false;
2248
+ }
2249
+ }
2250
+ async function closedReferences(body, repo) {
2251
+ const listed = [...new Set(referencedBoxes(body).filter(({ box }) => !box.done).map(({ issue }) => issue))];
2252
+ const states = await Promise.all(listed.map(async (issue) => isClosed(issue, repo)));
2253
+ return new Set(listed.filter((_, at) => states[at] === true));
2254
+ }
2255
+ var newUsage = `ft new -t <title> [-F <file>] [-L a,b] [-m <milestone>]
2256
+
2257
+ Creates an issue and prints its number and url. The description is read from -F <file>, or from stdin with -F -, and is empty without it. A label or milestone the repository lacks is refused before anything is created.
2258
+
2259
+ Exits 0 when it created the issue, 1 when Forgejo refuses, 2 on a usage error or a label or milestone the repository lacks.`;
2260
+ var newOptions = {
2261
+ title: { type: "string", short: "t", description: "the issue title" },
2262
+ file: { type: "string", short: "F", description: "the description: a file, - for stdin" },
2263
+ labels: { type: "string", short: "L", description: "comma-separated labels" },
2264
+ milestone: { type: "string", short: "m", description: "the milestone, a title or an id; the repository must have it" },
2265
+ ...repoOption
2266
+ };
2267
+ async function milestoneTitle(reference, repo) {
2268
+ if (reference === undefined)
2269
+ return;
2270
+ return reference.trim() === "" ? "" : (await findMilestone(reference.trim(), repo)).title;
2271
+ }
2272
+ async function newIssue(args) {
2273
+ const parsed = parse(args, newUsage, newOptions);
2274
+ const { values } = parsed;
2275
+ if (values.title === undefined || values.title.trim() === "")
2276
+ fail(`give the issue a title with -t
2277
+
2278
+ ${newUsage}`);
2279
+ const body = await noteText({ file: values.file });
2280
+ await checkLabels((values.labels ?? "").split(",").map((name) => name.trim()).filter((name) => name !== ""), values.repo);
2281
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2282
+ const created = await createIssue(values.title.trim(), body ?? "", values.labels, milestone === "" ? undefined : milestone, values.repo);
2283
+ console.log(`#${created.number} ${created.html_url}`);
2284
+ return 0;
2285
+ }
2286
+ var editUsage = `ft edit <n> [-t <title>] [-F <file> [--append]] [--add-box <text>]... [-m <milestone>]
2287
+
2288
+ Replaces the title and/or the description of issue <n>, the description from -F <file> or from stdin with -F -.
2289
+ -m moves it to a milestone given by title or id, and \`-m ''\` takes it out of its milestone.
2290
+ --append adds the -F text after the description instead, and each --add-box an unticked box after the last one, the rest of the description untouched.
2291
+ \`ft issue <n> --body\` prints the description to edit.
2292
+
2293
+ Exits 0 when it edited the issue, 1 when Forgejo refuses, 2 on a usage error.`;
2294
+ var editOptions = {
2295
+ title: { type: "string", short: "t", description: "the new title" },
2296
+ file: { type: "string", short: "F", description: "the new description: a file, - for stdin" },
2297
+ append: { type: "boolean", short: "a", description: "add the -F text after the description instead of replacing it" },
2298
+ "add-box": { type: "string", multiple: true, description: "add an unticked box after the last one, repeatable" },
2299
+ milestone: { type: "string", short: "m", description: "move the issue to this milestone, a title or an id; an empty value takes it out of its milestone" },
2300
+ ...repoOption
2301
+ };
2302
+ async function edit(args) {
2303
+ const parsed = parse(args, editUsage, editOptions);
2304
+ const { values, positionals } = parsed;
2305
+ const number = issueNumber(positionals[0], editUsage);
2306
+ const text = await noteText({ file: values.file });
2307
+ const added = values["add-box"] ?? [];
2308
+ if (values.file !== undefined && text === undefined)
2309
+ fail("the description file is empty");
2310
+ if (values.append === true && text === undefined)
2311
+ fail("--append adds the -F text, give -F");
2312
+ if (values.title === undefined && text === undefined && added.length === 0 && values.milestone === undefined)
2313
+ fail(`nothing to change: give -t, -F, --add-box or -m
2314
+
2315
+ ${editUsage}`);
2316
+ const change = values.title === undefined ? {} : { title: values.title.trim() };
2317
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2318
+ if (milestone !== undefined)
2319
+ change.milestone = milestone;
2320
+ if (text !== undefined && values.append !== true)
2321
+ change.body = text;
2322
+ else if (text !== undefined || added.length > 0)
2323
+ change.body = (await getIssue(number, values.repo)).body;
2324
+ if (text !== undefined && values.append === true)
2325
+ change.body = `${(change.body ?? "").trimEnd()}
2326
+
2327
+ ${text}`;
2328
+ if (added.length > 0)
2329
+ change.body = appendBoxes(change.body ?? "", added);
2330
+ await editIssue(number, change, values.repo);
2331
+ console.log(`edited #${number}`);
2332
+ return 0;
2333
+ }
2334
+ var tickUsage = `ft tick <n> <box>...
2335
+
2336
+ Ticks checklist boxes of issue <n>, each given by its number from \`ft issue\` or by text matching exactly one box, then posts the -m or -F comment.
2337
+ With --closed, ticks instead every box opening with \`#<issue>\` whose issue is closed, as a release plan lists them.
2338
+
2339
+ Exits 0 when it ticked the boxes, 1 when Forgejo refuses, 2 on a usage error or a box matching none or several.`;
2340
+ var tickOptions = {
2341
+ undo: { type: "boolean", short: "u", description: "untick them instead" },
2342
+ closed: { type: "boolean", description: "tick the boxes listing a closed issue, no box to name" },
2343
+ ...noteOptions,
2344
+ ...repoOption
2345
+ };
2346
+ async function tick(args) {
2347
+ const parsed = parse(args, tickUsage, tickOptions);
2348
+ const { values, positionals } = parsed;
2349
+ const number = issueNumber(positionals[0], tickUsage);
2350
+ const picks = positionals.slice(1);
2351
+ if (values.closed === true && (picks.length > 0 || values.undo === true))
2352
+ fail(`--closed takes no boxes and no --undo
2353
+
2354
+ ${tickUsage}`);
2355
+ if (picks.length === 0 && values.closed !== true)
2356
+ fail(`name the boxes to tick
2357
+
2358
+ ${tickUsage}`);
2359
+ const note = await noteText(values);
2360
+ const found = await getIssue(number, values.repo);
2361
+ const done = values.undo !== true;
2362
+ const closed = values.closed === true ? await closedReferences(found.body, values.repo) : undefined;
2363
+ let result;
2364
+ try {
2365
+ result = closed === undefined ? setBoxes(found.body, picks, done) : tickReferences(found.body, (issue) => closed.has(issue));
2366
+ } catch (error) {
2367
+ fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
2368
+ }
2369
+ if (result.changed.length > 0) {
2370
+ if (closed === undefined)
2371
+ await setBody(number, result.body, values.repo);
2372
+ else
2373
+ await editBody(number, (body) => tickReferences(body, (issue) => closed.has(issue)).body, values.repo);
2374
+ }
2375
+ for (const box of result.changed)
2376
+ console.log(`${done ? "ticked" : "unticked"} ${box.number}: ${box.text}`);
2377
+ const left = boxes(result.body).filter((box) => !box.done).length;
2378
+ console.log(`#${number}: ${result.changed.length === 0 ? "nothing to change, " : ""}${left} box${left === 1 ? "" : "es"} left`);
2379
+ if (note !== undefined) {
2380
+ await comment(number, note, values.repo);
2381
+ console.log(`commented on #${number}`);
2382
+ }
2383
+ return 0;
2384
+ }
2385
+ var commentUsage = `ft comment <n> [text]
2386
+
2387
+ Posts a comment on issue <n>: the text given, -F <file>, or stdin when neither is, so a heredoc needs no escaping.
2388
+
2389
+ Exits 0 when it posted the comment, 1 when Forgejo refuses, 2 on a usage error.`;
2390
+ var commentOptions = {
2391
+ file: noteOptions.file,
2392
+ ...repoOption
2393
+ };
2394
+ async function commentOn(args) {
2395
+ const parsed = parse(args, commentUsage, commentOptions);
2396
+ const { values, positionals } = parsed;
2397
+ const number = issueNumber(positionals[0], commentUsage);
2398
+ const given = positionals.slice(1).join(" ") || undefined;
2399
+ const text = await noteText({ message: given, file: values.file ?? (given === undefined ? "-" : undefined) });
2400
+ if (text === undefined)
2401
+ fail(`nothing to comment
2402
+
2403
+ ${commentUsage}`);
2404
+ await comment(number, text, values.repo);
2405
+ console.log(`commented on #${number}`);
2406
+ return 0;
2407
+ }
2408
+ var fixedUsage = `ft fixed <n> [commit]...
2409
+
2410
+ Comments "Fixed in <sha>: <message>" on issue <n> and closes it, naming each commit given as "Fixed in <a>, <b> and <c>".
2411
+ Without a commit it takes those on origin's default branch whose message names #<n>, and says which, or HEAD when none does.
2412
+ The message defaults to the subject of the first commit. Every open issue listing \`- [ ] #<n>\` in its checklist gets that line ticked. An issue with boxes left is refused, as a partly done issue stays open, unless --tick ticks them first.
2413
+
2414
+ Exits 0 when it closed the issue, 1 when the issue is already closed, has boxes left or Forgejo refuses, 2 on a usage error.`;
2415
+ var fixedOptions = {
2416
+ message: { type: "string", short: "m", description: 'the line after "Fixed in <sha>:"' },
2417
+ tick: { type: "boolean", description: "tick the boxes left before closing" },
2418
+ force: { type: "boolean", description: "close even with boxes left" },
2419
+ ...repoOption
2420
+ };
2421
+ function joinShas(shas) {
2422
+ const last = shas.at(-1) ?? "";
2423
+ return shas.length < 2 ? last : `${shas.slice(0, -1).join(", ")} and ${last}`;
2424
+ }
2425
+ async function fixingCommits(number, given) {
2426
+ if (given.length > 0) {
2427
+ const named = [];
2428
+ for (const ref of given) {
2429
+ const sha = await resolveCommit(ref);
2430
+ named.push({ sha, subject: await git(["log", "-1", "--format=%s", sha]) });
2431
+ }
2432
+ return named;
2433
+ }
2434
+ const ref = `origin/${await defaultBranch()}`;
2435
+ const found = await commitsNaming(number, ref);
2436
+ if (found.length > 0) {
2437
+ console.log(`commits on ${ref} naming #${number}:
2438
+ ${found.map((each) => ` ${each.sha.slice(0, 7)} ${each.subject}`).join(`
2439
+ `)}`);
2440
+ return found;
2441
+ }
2442
+ const sha = await resolveCommit("HEAD");
2443
+ console.log(`no commit on ${ref} names #${number}, using HEAD`);
2444
+ return [{ sha, subject: await git(["log", "-1", "--format=%s", sha]) }];
2445
+ }
2446
+ async function tickListings(closed, repo) {
2447
+ for (const listing of await openIssues(undefined, repo)) {
2448
+ if (!referencedBoxes(listing.body).some(({ box, issue }) => issue === closed && !box.done))
2449
+ continue;
2450
+ if (await editBody(listing.number, (body) => tickReferences(body, (issue) => issue === closed).body, repo))
2451
+ console.log(`ticked #${closed} in #${listing.number}`);
2452
+ }
2453
+ }
2454
+ async function fixed(args) {
2455
+ const parsed = parse(args, fixedUsage, fixedOptions);
2456
+ const { values, positionals } = parsed;
2457
+ const number = issueNumber(positionals[0], fixedUsage);
2458
+ const commits = await fixingCommits(number, positionals.slice(1));
2459
+ const found = await getIssue(number, values.repo);
2460
+ if (found.state === "closed")
2461
+ fail(`#${number} is already closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
2462
+ const left = boxes(found.body).filter((box) => !box.done);
2463
+ if (left.length > 0 && values.tick === true) {
2464
+ await setBody(number, setBoxes(found.body, left.map((box) => String(box.number)), true).body, values.repo);
2465
+ console.log(`ticked ${left.length} box${left.length === 1 ? "" : "es"} of #${number}`);
2466
+ } else if (left.length > 0 && values.force !== true) {
2467
+ fail(`#${number} has ${left.length} boxes left:
2468
+ ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
2469
+ `)}`, 1, `${runHint(`ft fixed ${number} --tick`, "tick them and close")}, or ${runHint(`ft fixed ${number} --force`, "close with them left")}`);
2470
+ }
2471
+ const shas = joinShas(commits.map((each) => each.sha.slice(0, 7)));
2472
+ await comment(number, `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`, values.repo);
2473
+ await close(number, values.repo);
2474
+ console.log(`closed #${number}: fixed in ${shas}`);
2475
+ await tickListings(number, values.repo);
2476
+ return 0;
2477
+ }
2478
+
2479
+ // src/commands/logs.ts
2480
+ var usage3 = `ft logs <run id>
2481
+
2482
+ Prints what the failed jobs of a run say went wrong: each failed bun test's output and the end of each failed step.
2483
+ With no failed job it prints every job's last lines; --full prints whole logs.
2484
+
2485
+ Exits 0 when it printed the logs, 1 when Forgejo cannot be read, 2 on a usage error or a job the run lacks.`;
2486
+ var options3 = {
2487
+ job: { type: "string", short: "j", description: "only this job, by name" },
2488
+ full: { type: "boolean", short: "f", description: "the whole log, timestamps stripped" },
2489
+ ...repoOption
2490
+ };
2491
+ function pickJobs(jobs, name) {
2492
+ if (name !== undefined)
2493
+ return jobs.filter((job) => job.name === name);
2494
+ const failed = jobs.filter((job) => job.status === "failure");
2495
+ return failed.length > 0 ? failed : jobs;
2496
+ }
2497
+ async function logs(args) {
2498
+ const parsed = parse(args, usage3, options3);
2499
+ const { values, positionals } = parsed;
2500
+ if (positionals[0] === undefined)
2501
+ fail("a run id is needed", 2, runHint("ft runs", "list them"));
2502
+ const run = await runById(wholeNumber(positionals[0], "run id", 0), values.repo);
2503
+ const jobs = await jobsOf(run.id, values.repo);
2504
+ const picked = pickJobs(jobs, values.job);
2505
+ if (picked.length === 0)
2506
+ fail(`run ${run.id} has no job ${values.job ?? ""}: ${jobs.map((job) => job.name).join(", ")}`, 2, runHint(`ft logs ${run.id}`, "read every failed job"));
2507
+ console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref} (${run.commit_sha.slice(0, 7)}): ${badge(run.status, process.stdout)}, ${run.html_url}`);
2508
+ for (const job of picked) {
2509
+ const log = await jobLog(job.id, values.repo);
2510
+ console.log(`
2511
+ job ${job.name}: ${badge(job.status, process.stdout)}
2512
+ ${values.full === true ? logLines(log).join(`
2513
+ `) : excerpt(log)}`);
2514
+ }
2515
+ return 0;
2516
+ }
2517
+
2518
+ // src/commands/milestone.ts
2519
+ var TITLE_WIDTH3 = 60;
2520
+ var LIST_LIMIT = 30;
2521
+ function counts(milestone) {
2522
+ return `${milestone.open_issues} open, ${milestone.closed_issues} closed`;
2523
+ }
2524
+ var listUsage = `ft milestones
2525
+
2526
+ Lists the repository's milestones, the open ones unless --state says otherwise, with the issues open and closed in each, the progress and the due date.
2527
+
2528
+ Exits 0 when it listed them, 1 when Forgejo refuses, 2 on a usage error.`;
2529
+ var listOptions = {
2530
+ state: { type: "string", description: "open (the default), closed or all" },
2531
+ ...limitOptions(LIST_LIMIT),
2532
+ json: jsonOption("id, title, state, description, open, closed, due (YYYY-MM-DD or null)"),
2533
+ ...repoOption
2534
+ };
2535
+ async function milestones(args) {
2536
+ const parsed = parse(args, listUsage, listOptions);
2537
+ const { values } = parsed;
2538
+ const state = values.state ?? "open";
2539
+ if (state !== "open" && state !== "closed" && state !== "all")
2540
+ fail(`--state takes open, closed or all, not ${state}`);
2541
+ const limit = limitOf(values, LIST_LIMIT);
2542
+ const found = await listMilestones(state, values.repo);
2543
+ if (values.json === true) {
2544
+ printJson(found.map((each) => ({ id: each.id, title: each.title, state: each.state, description: each.description, open: each.open_issues, closed: each.closed_issues, due: dueDate(each) || null })));
2545
+ return 0;
2546
+ }
2547
+ if (found.length === 0) {
2548
+ console.log(`no ${state === "all" ? "" : `${state} `}milestone`);
2549
+ return 0;
2550
+ }
2551
+ const { shown, footer } = capped(found, limit);
2552
+ const showState = state === "all";
2553
+ const rows = shown.map((each) => [String(each.id), truncate(each.title, TITLE_WIDTH3), `${each.open_issues} open`, `${each.closed_issues} closed`, progress(each), dueDate(each) === "" ? "" : `due ${dueDate(each)}`, ...showState ? [each.state] : []]);
2554
+ console.log(table(rows));
2555
+ if (footer !== undefined)
2556
+ console.log(footer);
2557
+ return 0;
2558
+ }
2559
+ var newUsage2 = `ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2560
+
2561
+ Creates a milestone through the API and prints its id. Without --due it has no due date.
2562
+
2563
+ Exits 0 when it created the milestone, 1 when Forgejo refuses, 2 on a usage error.`;
2564
+ var newOptions2 = {
2565
+ description: { type: "string", short: "d", description: "the milestone description" },
2566
+ due: { type: "string", description: "the due date, YYYY-MM-DD" },
2567
+ ...repoOption
2568
+ };
2569
+ async function create(args) {
2570
+ const parsed = parse(args, newUsage2, newOptions2);
2571
+ const { values, positionals } = parsed;
2572
+ const title = positionals.join(" ").trim();
2573
+ if (title === "")
2574
+ fail(`give the milestone a title
2575
+
2576
+ ${newUsage2}`);
2577
+ const due = values.due === undefined ? undefined : dueOn(values.due);
2578
+ const created = await createMilestone(title, values.description, due, values.repo);
2579
+ console.log(`milestone ${created.id} ${created.title}${dueDate(created) === "" ? "" : `, due ${dueDate(created)}`}`);
2580
+ return 0;
2581
+ }
2582
+ var closeUsage2 = `ft milestone close <title|id> [--force]
2583
+
2584
+ Closes a milestone, refused while issues are open in it, which it names. --force closes it with them left.
2585
+
2586
+ Exits 0 when it closed the milestone, 1 when issues are open in it or Forgejo refuses, 2 on a usage error.`;
2587
+ var closeOptions = {
2588
+ force: { type: "boolean", description: "close it with issues left open" },
2589
+ ...repoOption
2590
+ };
2591
+ async function closeOne(args) {
2592
+ const parsed = parse(args, closeUsage2, closeOptions);
2593
+ const { values, positionals } = parsed;
2594
+ const reference = positionals.join(" ").trim();
2595
+ if (reference === "")
2596
+ fail(`name the milestone to close
2597
+
2598
+ ${closeUsage2}`);
2599
+ const found = await findMilestone(reference, values.repo);
2600
+ if (found.state === "closed") {
2601
+ console.log(`milestone ${found.title} is already closed`);
2602
+ return 0;
2603
+ }
2604
+ const open = await openIssues(undefined, values.repo, found.id);
2605
+ if (open.length > 0 && values.force !== true)
2606
+ fail(`milestone ${found.title} has ${open.length} open issue${open.length === 1 ? "" : "s"}:
2607
+ ${open.map((issue) => ` #${issue.number} ${truncate(issue.title, TITLE_WIDTH3)}`).join(`
2608
+ `)}`, 1, `${runHint(`ft milestone close ${found.title} --force`, "close it with them open")}, or move them with \`ft edit <n> -m <milestone>\``);
2609
+ await closeMilestone(found.id, values.repo);
2610
+ console.log(`closed milestone ${found.title}`);
2611
+ return 0;
2612
+ }
2613
+ var showUsage = `ft milestone <title|id>
2614
+ ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2615
+ ft milestone close <title|id> [--force]
2616
+
2617
+ Shows a milestone with its open issues as \`ft board\` does: available, taken by a session running here, or claimed by one gone, each with its boxes left, at most 30 of each kind (--full for all).
2618
+ \`ft milestone new\` and \`ft milestone close\` have their own --help; a milestone titled new or close is named by its id.
2619
+
2620
+ Exits 0 when it showed the milestone, 1 when it cannot be read, 2 on a usage error or an unknown milestone.`;
2621
+ var showOptions = {
2622
+ ...limitOptions(LIST_LIMIT),
2623
+ json: jsonOption("the milestone (id, title, state, description, open, closed, due) and its open issues as `ft board --json` lists them"),
2624
+ ...repoOption
2625
+ };
2626
+ async function show(args) {
2627
+ const parsed = parse(args, showUsage, showOptions);
2628
+ const { values, positionals } = parsed;
2629
+ const reference = positionals.join(" ").trim();
2630
+ if (reference === "")
2631
+ fail(`name the milestone, or see them all with \`ft milestones\`
2632
+
2633
+ ${showUsage}`);
2634
+ const found = await findMilestone(reference, values.repo);
2635
+ const [issues, sessions] = await Promise.all([openIssues(undefined, values.repo, found.id), liveSessions()]);
2636
+ const rows = await boardRows(issues, sessions, values.repo);
2637
+ if (values.json === true) {
2638
+ printJson({ id: found.id, title: found.title, state: found.state, description: found.description, open: found.open_issues, closed: found.closed_issues, due: dueDate(found) || null, issues: boardJson(rows) });
2639
+ return 0;
2640
+ }
2641
+ const facts = [found.state, counts(found), progress(found), dueDate(found) === "" ? "" : `due ${dueDate(found)}`].filter((fact) => fact !== "");
2642
+ console.log(`milestone ${found.title} (${facts.join(", ")})`);
2643
+ if (found.description.trim() !== "")
2644
+ console.log(found.description.trim());
2645
+ console.log(`
2646
+ ${renderBoard(rows, Date.now(), limitOf(values, LIST_LIMIT))}`);
2647
+ return 0;
2648
+ }
2649
+ async function milestone(args) {
2650
+ const [first, ...rest] = args;
2651
+ if (first === "new")
2652
+ return create(rest);
2653
+ return first === "close" ? closeOne(rest) : show(args);
2654
+ }
2655
+
2656
+ // src/plan.ts
2657
+ var MARKUP = /^[#*_\s]+|[*_\s]+$/g;
2658
+ function heading(line) {
2659
+ return line.replaceAll(MARKUP, "").toLowerCase();
2660
+ }
2661
+ function insertionPoint(lines, body, section) {
2662
+ const all = boxes(body);
2663
+ if (section === undefined)
2664
+ return all.length === 0 ? undefined : (all.at(-1)?.line ?? 0) + 1;
2665
+ const title = heading(section);
2666
+ const at = lines.findIndex((line) => heading(line) === title);
2667
+ if (at === -1)
2668
+ return;
2669
+ const lineOfBox = new Set(all.map((box) => box.line));
2670
+ let end = at + 1;
2671
+ for (let next = at + 1;next < lines.length; next++) {
2672
+ if (lineOfBox.has(next))
2673
+ end = next + 1;
2674
+ else if (lines[next]?.trim() !== "")
2675
+ break;
2676
+ }
2677
+ return end;
2678
+ }
2679
+ function addLines(body, added, section) {
2680
+ if (added.length === 0)
2681
+ return body;
2682
+ const eol = body.includes(`\r
2683
+ `) ? "\r" : "";
2684
+ const lines = body.split(`
2685
+ `);
2686
+ const at = insertionPoint(lines, body, section);
2687
+ const fresh = added.map((line) => `${line}${eol}`);
2688
+ if (at !== undefined) {
2689
+ lines.splice(at, 0, ...fresh);
2690
+ return lines.join(`
2691
+ `);
2692
+ }
2693
+ const text = body.trimEnd();
2694
+ return `${text}${text === "" ? "" : `${eol}
2695
+ ${eol}
2696
+ `}${section === undefined ? "" : `${section}${eol}
2697
+ `}${fresh.join(`
2698
+ `)}${body.endsWith(`
2699
+ `) ? `
2700
+ ` : ""}`;
2701
+ }
2702
+ function listedIssues(body) {
2703
+ return new Set(referencedBoxes(body).map(({ issue }) => issue));
2704
+ }
2705
+ function openLines(body) {
2706
+ return referencedBoxes(body).filter(({ box }) => !box.done).map(({ box, issue }) => ({ issue, line: `- [ ] ${box.text}` }));
2707
+ }
2708
+
2709
+ // src/commands/plan.ts
2710
+ var addUsage = `ft plan add <plan> <n>... [--section "Bugs:"]
2711
+
2712
+ Lists each issue <n> as \`- [ ] #<n> <title>\` in the checklist of issue <plan>, skipping those already listed.
2713
+ Lines go under --section, created at the end of the body when missing, or else after the checklist's last box.
2714
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2715
+
2716
+ Exits 0 when it listed the issues, 1 when Forgejo refuses, 2 on a usage error.`;
2717
+ var carryUsage = `ft plan carry <from> <to> [--section "Carried from 1.71.0:"]
2718
+
2719
+ Copies the unticked \`#<n>\` lines of issue <from> into issue <to>, skipping those <to> already lists.
2720
+ Lines go under --section, created at the end of the body when missing, or else after the checklist's last box.
2721
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2722
+
2723
+ Exits 0 when it copied the lines, 1 when Forgejo refuses, 2 on a usage error.`;
2724
+ var options4 = {
2725
+ section: { type: "string", short: "s", description: 'the heading the lines go under, e.g. "Bugs:"' },
2726
+ ...repoOption
2727
+ };
2728
+ var plan = {
2729
+ summary: "add issues to a release plan's checklist, or carry its unticked lines to the next plan",
2730
+ commands: {
2731
+ add: { run: add, summary: "list issues in a plan's checklist" },
2732
+ carry: { run: carry, summary: "copy a plan's unticked lines into the next plan" }
2733
+ }
2734
+ };
2735
+ async function add(args) {
2736
+ const parsed = parse(args, addUsage, options4);
2737
+ const { section, repo } = parsed.values;
2738
+ const [target, ...listed] = parsed.positionals;
2739
+ const planNumber = issueNumber(target, addUsage);
2740
+ if (listed.length === 0)
2741
+ fail(`name the issues to add
2742
+
2743
+ ${addUsage}`);
2744
+ const numbers = issueNumbers(listed, addUsage);
2745
+ const titled = await Promise.all(numbers.map(async (number) => ({ number, title: (await getIssue(number, repo)).title })));
2746
+ let added = [];
2747
+ await editBody(planNumber, (body) => {
2748
+ const known = listedIssues(body);
2749
+ const fresh = titled.filter(({ number }) => !known.has(number));
2750
+ added = fresh.map(({ number, title }) => `- [ ] #${number} ${title}`);
2751
+ return addLines(body, added, section);
2752
+ }, repo);
2753
+ report(planNumber, added, titled.length - added.length);
2754
+ return 0;
2755
+ }
2756
+ async function carry(args) {
2757
+ const parsed = parse(args, carryUsage, options4);
2758
+ const { section, repo } = parsed.values;
2759
+ const from = issueNumber(parsed.positionals[0], carryUsage);
2760
+ const to = issueNumber(parsed.positionals[1], carryUsage);
2761
+ const source = openLines((await getIssue(from, repo)).body);
2762
+ let added = [];
2763
+ await editBody(to, (body) => {
2764
+ const known = listedIssues(body);
2765
+ added = source.filter(({ issue }) => !known.has(issue)).map(({ line }) => line);
2766
+ return addLines(body, added, section);
2767
+ }, repo);
2768
+ report(to, added, source.length - added.length);
2769
+ return 0;
2770
+ }
2771
+ function report(plan, added, skipped) {
2772
+ for (const line of added)
2773
+ console.log(`#${plan}: ${line}`);
2774
+ console.log(`#${plan}: ${added.length} added${skipped === 0 ? "" : `, ${skipped} already listed`}`);
2775
+ }
2776
+
2777
+ // src/commands/release.ts
2778
+ import { basename as basename2, join as join2, relative, resolve } from "path";
2779
+ import process13 from "process";
2780
+ var {$: $3 } = globalThis.Bun;
2781
+
2782
+ // src/commands/watch.ts
2783
+ var usage4 = `ft watch [--after <id>]
2784
+
2785
+ Waits for runs to finish: those newer than run <id>, or else the ones in progress now.
2786
+ Exits 0 when every one passed or was skipped, 1 when one failed or was cancelled, 2 on a timeout or a stalled queue.`;
2787
+ var options5 = {
2788
+ after: { type: "string", short: "a", description: "watch the runs newer than this run id, as `ft runs --json` gives it" },
2789
+ timeout: { type: "string", short: "t", description: "how long before giving up (15m), a bare number in minutes" },
2790
+ ...repoOption
2791
+ };
2792
+ var timing = { poll: 1e4 };
2793
+ var LIMIT = 20;
2794
+ var STALLED2 = 3 * 60000;
2795
+ async function watchRuns(pick, timeout, repo, idle) {
2796
+ const started = Date.now();
2797
+ const reported = new Map;
2798
+ let moving = started;
2799
+ for (;; ) {
2800
+ await Bun.sleep(timing.poll);
2801
+ if (Date.now() - started > timeout)
2802
+ fail(`the runs did not finish in ${formatDuration(timeout)}`);
2803
+ let runs;
2804
+ try {
2805
+ runs = pick(await recentRuns(LIMIT, repo));
2806
+ } catch (error) {
2807
+ console.warn(`ft watch: ${error instanceof Error ? error.message : String(error)}, polling again`);
2808
+ continue;
2809
+ }
2810
+ for (const run of runs) {
2811
+ if (reported.get(run.id) !== run.status)
2812
+ console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${badge(run.status, process.stdout)}`);
2813
+ reported.set(run.id, run.status);
2814
+ }
2815
+ if (runs.length === 0) {
2816
+ if (idle !== undefined && Date.now() - started > idle) {
2817
+ console.log("no run started, so nothing was waited for");
2818
+ return 0;
2819
+ }
2820
+ continue;
2821
+ }
2822
+ const status = overall(runs);
2823
+ if (status === "failure" || status === "cancelled") {
2824
+ for (const run of runs) {
2825
+ if (run.status === "failure")
2826
+ console.error(`
2827
+ ${await failureReport(run, repo)}
2828
+
2829
+ ${run.html_url}`);
2830
+ }
2831
+ return 1;
2832
+ }
2833
+ if (runs.every((run) => FINISHED.has(run.status)))
2834
+ return 0;
2835
+ const stall = await stallReason(runs, repo);
2836
+ if (stall === undefined)
2837
+ moving = Date.now();
2838
+ else if (stall.certain || Date.now() - moving > STALLED2)
2839
+ fail(`queued runs are not moving: ${stall.reason}`);
2840
+ }
2841
+ }
2842
+ async function watch(args) {
2843
+ const parsed = parse(args, usage4, options5);
2844
+ const { values } = parsed;
2845
+ const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
2846
+ if (values.after !== undefined) {
2847
+ const after = wholeNumber(values.after, "after", 0, 0);
2848
+ return watchRuns((runs) => runs.filter((run) => run.id > after), timeout, values.repo);
2849
+ }
2850
+ const active = new Set((await recentRuns(LIMIT, values.repo)).filter((run) => !FINISHED.has(run.status)).map((run) => run.id));
2851
+ if (active.size === 0) {
2852
+ console.log("no run in progress");
2853
+ return 0;
2854
+ }
2855
+ return watchRuns((runs) => runs.filter((run) => active.has(run.id)), timeout, values.repo);
2856
+ }
2857
+
2858
+ // src/commands/release.ts
2859
+ var usage5 = `ft release <patch|minor|major|x.y.z>
2860
+ ft release --tagged
2861
+
2862
+ Cuts a release from the default branch: first the commit it sits on is pushed and its runs waited for, so failing tests stop it before anything is bumped or tagged. Then \`bun pm version\` commits and tags the bump, one atomic push sends the branch and its tag, and it waits for the runs the push started and for the registry to serve the version.
2863
+ --tagged releases a version commit already tagged at HEAD instead, for repos bumping with their own script; the runs waited for first are then those of the commit below it.
2864
+ --package releases one workspace package of a monorepo on its own, tagged \`<folder>-v<version>\` (\`ft release patch -p ft\` tags packages/ft as ft-v0.3.0).
2865
+ A refused push drops the bump commit and its tag, so a retry starts clean.
2866
+ Once released, an open milestone titled like the version (\`1.2.0\`, \`v1.2.0\` or the tag) has its closed issues listed and is closed; a failure there only warns.
2867
+ --dry-run checks the same and prints the plan: the version bump, the tag, the pushes and the runs and registry it would wait for, changing nothing.
2868
+ Exits 0 once released or planned, 1 when a run fails, origin refuses a push or the registry never serves the version, 2 on a timeout, a stalled queue, a refusal before anything ran (another branch, uncommitted changes, origin ahead) or a usage error.`;
2869
+ var options6 = {
2870
+ tagged: { type: "boolean", description: "push the version commit and tag already at HEAD instead of bumping" },
2871
+ package: { type: "string", short: "p", description: "workspace folder to release, a path or a name under packages/" },
2872
+ "no-registry": { type: "boolean", description: "do not wait for the registry to serve the new version" },
2873
+ "dry-run": { type: "boolean", description: "print what the release would do, changing nothing" },
2874
+ timeout: { type: "string", short: "t", description: "how long to wait for the runs (15m), a bare number in minutes" }
2875
+ };
2876
+ var LEVELS = new Set(["patch", "minor", "major", "prepatch", "preminor", "premajor", "prerelease"]);
2877
+ var VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
2878
+ var PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
2879
+ var LAST_NUMBER = /\d+$/;
2880
+ var REGISTRY_TRIES = 12;
2881
+ var REGISTRY_POLL = 5000;
2882
+ var NO_RUN2 = 3 * 60000;
2883
+ function taggedVersion(tag, prefix) {
2884
+ const version = tag.slice(prefix.length);
2885
+ return tag.startsWith(prefix) && VERSION.test(version) ? version : undefined;
2886
+ }
2887
+ function nextVersion(current, level) {
2888
+ if (VERSION.test(level))
2889
+ return level;
2890
+ const [, major = "0", minor = "0", patch = "0", pre] = PARTS.exec(current) ?? [];
2891
+ const [x, y, z] = [Number(major), Number(minor), Number(patch)];
2892
+ switch (level) {
2893
+ case "major":
2894
+ return pre !== undefined && y === 0 && z === 0 ? `${x}.0.0` : `${x + 1}.0.0`;
2895
+ case "minor":
2896
+ return pre !== undefined && z === 0 ? `${x}.${y}.0` : `${x}.${y + 1}.0`;
2897
+ case "patch":
2898
+ return pre === undefined ? `${x}.${y}.${z + 1}` : `${x}.${y}.${z}`;
2899
+ case "premajor":
2900
+ return `${x + 1}.0.0-0`;
2901
+ case "preminor":
2902
+ return `${x}.${y + 1}.0-0`;
2903
+ case "prepatch":
2904
+ return `${x}.${y}.${z + 1}-0`;
2905
+ default: {
2906
+ if (pre === undefined)
2907
+ return `${x}.${y}.${z + 1}-0`;
2908
+ const numbered = LAST_NUMBER.exec(pre);
2909
+ return `${x}.${y}.${z}-${numbered === null ? `${pre}.0` : `${pre.slice(0, numbered.index)}${Number(numbered[0]) + 1}`}`;
2910
+ }
2911
+ }
2912
+ }
2913
+ async function packageDir(value) {
2914
+ for (const dir of [resolve(value), join2(await git(["rev-parse", "--show-toplevel"]), "packages", value)]) {
2915
+ if (await Bun.file(join2(dir, "package.json")).exists())
2916
+ return dir;
2917
+ }
2918
+ fail(`no package.json in ${value} nor in packages/${value}`);
2919
+ }
2920
+ async function commitPackageBump(dir, prefix) {
2921
+ const { version } = await Bun.file(join2(dir, "package.json")).json();
2922
+ const root = await git(["rev-parse", "--show-toplevel"]);
2923
+ const tag = `${prefix}${version}`;
2924
+ const lock = await $3`bun install --lockfile-only`.cwd(root).quiet().nothrow();
2925
+ if (lock.exitCode !== 0)
2926
+ fail(`bun install --lockfile-only after the bump: ${lock.stderr.toString().trim()}`);
2927
+ await git(["add", join2(dir, "package.json"), join2(root, "bun.lock")]);
2928
+ await git(["commit", "-q", "-m", tag]);
2929
+ await git(["tag", "-a", tag, "-m", tag]);
2930
+ }
2931
+ async function waitForRegistry(name, version) {
2932
+ for (let attempt = 0;attempt < REGISTRY_TRIES; attempt++) {
2933
+ const served = (await $3`bun info ${name} version --no-cache`.quiet().nothrow().text()).trim().split(`
2934
+ `).at(-1);
2935
+ if (served === version)
2936
+ return true;
2937
+ await Bun.sleep(REGISTRY_POLL);
2938
+ }
2939
+ return false;
2940
+ }
2941
+ async function testsPass(sha, branch, timeout) {
2942
+ if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`])) {
2943
+ const push = await $3`git push origin ${`${sha}:refs/heads/${branch}`}`.quiet().nothrow();
2944
+ if (push.exitCode !== 0)
2945
+ fail(`origin refused the push of ${sha.slice(0, 7)}, so nothing was bumped:
2946
+ ${push.stderr.toString().trim()}`, 1);
2947
+ console.log(`pushed ${branch} at ${sha.slice(0, 7)}, waiting for its runs before the bump`);
2948
+ }
2949
+ return await watchRuns((runs) => newestPerWorkflow(runs.filter((run) => run.commit_sha === sha)), timeout, undefined, NO_RUN2) === 0;
2950
+ }
2951
+ async function printPlan({ level, dir, prefix, branch, base, timeout, registry }) {
2952
+ const file = Bun.file(join2(dir ?? ".", "package.json"));
2953
+ const pkg = await file.exists() ? await file.json() : undefined;
2954
+ const name = pkg?.name ?? basename2(dir ?? process13.cwd());
2955
+ const where = dir === undefined ? "" : ` in ${relative(process13.cwd(), dir) || "."}`;
2956
+ const sha = base.slice(0, 7);
2957
+ let version;
2958
+ let bump;
2959
+ if (level === undefined) {
2960
+ const tag = (await tagsAt("HEAD")).find((each) => taggedVersion(each, prefix) !== undefined);
2961
+ if (tag === undefined)
2962
+ fail(`HEAD carries no ${prefix}* tag`);
2963
+ version = tag.slice(prefix.length);
2964
+ bump = `take the version commit at HEAD, tagged ${tag}`;
2965
+ console.log(`would release ${name} ${version}, tagged ${tag}`);
2966
+ } else {
2967
+ if (pkg?.version === undefined)
2968
+ fail(`no version in ${join2(dir ?? ".", "package.json")}`);
2969
+ version = nextVersion(pkg.version, level);
2970
+ bump = dir === undefined ? `bun pm version ${level}, which commits and tags ${prefix}${version}` : `bun pm version ${level} --no-git-tag-version${where}, then commit it with bun.lock refreshed and tag ${prefix}${version}`;
2971
+ console.log(`would release ${name} ${pkg.version} -> ${version}, tagged ${prefix}${version}`);
2972
+ }
2973
+ const pushed = await gitOk(["merge-base", "--is-ancestor", base, `origin/${branch}`]);
2974
+ const registryStep = registry && pkg?.name !== undefined && pkg.private !== true ? `wait for the registry to serve ${pkg.name}@${version}` : `skip the registry: ${registry ? "the package is private or unnamed" : "--no-registry"}`;
2975
+ const steps = [
2976
+ pushed ? `wait for the runs on ${sha}, already on origin/${branch}` : `push ${branch} at ${sha}, which origin lacks, then wait for its runs`,
2977
+ bump,
2978
+ `push ${branch} and ${prefix}${version} in one atomic push, then wait for the runs it starts (${formatDuration(timeout)} at most)`,
2979
+ registryStep
2980
+ ];
2981
+ console.log(steps.map((step, i) => ` ${i + 1}. ${step}`).join(`
2982
+ `));
2983
+ console.log("dry run: nothing was pushed, committed or tagged");
2984
+ return 0;
2985
+ }
2986
+ async function release(args) {
2987
+ const parsed = parse(args, usage5, options6);
2988
+ const { values, positionals } = parsed;
2989
+ const level = positionals[0];
2990
+ const tagged = values.tagged === true;
2991
+ if (tagged === (level !== undefined))
2992
+ fail(`give a level or --tagged
2993
+
2994
+ ${usage5}`);
2995
+ if (level !== undefined && !LEVELS.has(level) && !VERSION.test(level))
2996
+ fail(`${level} is no level nor x.y.z version`);
2997
+ const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
2998
+ const dir = values.package === undefined ? undefined : await packageDir(values.package);
2999
+ const prefix = dir === undefined ? "v" : `${basename2(dir)}-v`;
3000
+ const branch = await defaultBranch();
3001
+ const current = await git(["branch", "--show-current"]);
3002
+ if (current !== branch)
3003
+ fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`, 2, runHint(`git switch ${branch}`));
3004
+ if (await git(["status", "--porcelain"]) !== "")
3005
+ fail("the tree has uncommitted changes", 2, runHint("gst", "see them, then commit them with gc"));
3006
+ await git(["fetch", "-q", "origin", branch]);
3007
+ if ((await $3`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
3008
+ fail(`origin/${branch} has commits HEAD lacks`, 2, runHint(`git pull --rebase origin ${branch}`));
3009
+ const base = await git(["rev-parse", tagged ? "HEAD~1" : "HEAD"]);
3010
+ if (values["dry-run"] === true)
3011
+ return printPlan({ level, dir, prefix, branch, base, timeout, registry: values["no-registry"] !== true });
3012
+ if (!await testsPass(base, branch, timeout))
3013
+ fail(`the runs on ${base.slice(0, 7)} failed, so nothing was bumped or tagged`, 1, runHint(`ft runs --sha ${base.slice(0, 7)}`, "list the runs, then ft logs <id> reads the failure"));
3014
+ if (level !== undefined) {
3015
+ const bump = dir === undefined ? await $3`bun pm version ${level}`.quiet().nothrow() : await $3`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
3016
+ if (bump.exitCode !== 0)
3017
+ fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
3018
+ if (dir !== undefined)
3019
+ await commitPackageBump(dir, prefix);
3020
+ }
3021
+ const tag = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
3022
+ if (tag === undefined)
3023
+ fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3024
+ const before = (await recentRuns(1))[0]?.id ?? 0;
3025
+ const push = await $3`git push --atomic origin ${`HEAD:refs/heads/${branch}`} ${tag}`.quiet().nothrow();
3026
+ if (push.exitCode !== 0) {
3027
+ await git(["tag", "-d", tag]);
3028
+ await git(["reset", "--keep", "HEAD~1"]);
3029
+ fail(`origin refused the push, so ${tag} and its commit were dropped for a clean retry:
3030
+ ${push.stderr.toString().trim()}`, 1);
3031
+ }
3032
+ console.log(`pushed ${branch} and ${tag}, waiting for their runs`);
3033
+ const code = await watchRuns((runs) => runs.filter((run) => run.id > before), timeout);
3034
+ if (code !== 0)
3035
+ return code;
3036
+ const file = Bun.file(join2(dir ?? ".", "package.json"));
3037
+ const pkg = await file.exists() ? await file.json() : undefined;
3038
+ const version = tag.slice(prefix.length);
3039
+ if (values["no-registry"] === true || pkg?.name === undefined || pkg.private === true) {
3040
+ console.log(`released ${tag}`);
3041
+ await settleMilestone(version, tag);
3042
+ return 0;
3043
+ }
3044
+ if (!await waitForRegistry(pkg.name, version))
3045
+ fail(`the runs passed but the registry does not serve ${pkg.name}@${version}: read the publish job with \`ft logs\``, 1);
3046
+ console.log(`released ${pkg.name}@${version}`);
3047
+ await settleMilestone(version, tag);
3048
+ return 0;
3049
+ }
3050
+ async function settleMilestone(version, tag) {
3051
+ try {
3052
+ const names = new Set([version, `v${version}`, tag]);
3053
+ const found = (await listMilestones("open")).find((each) => names.has(each.title));
3054
+ if (found === undefined)
3055
+ return;
3056
+ const [done, left] = await Promise.all([closedIssues(found.id), openIssues(undefined, undefined, found.id)]);
3057
+ console.log(`milestone ${found.title}: ${done.length} closed issue${done.length === 1 ? "" : "s"}${done.length === 0 ? "" : `
3058
+ ${done.map((issue) => ` #${issue.number} ${issue.title}`).join(`
3059
+ `)}`}`);
3060
+ if (left.length > 0)
3061
+ warn(`${left.length} issue${left.length === 1 ? " is" : "s are"} still open in it`, left.map((issue) => `#${issue.number}`).join(" "));
3062
+ await closeMilestone(found.id);
3063
+ console.log(`closed milestone ${found.title}`);
3064
+ } catch (error) {
3065
+ warn(`released, but the milestone ${version} could not be settled`, error instanceof Error ? error.message : String(error));
3066
+ }
3067
+ }
3068
+
3069
+ // src/commands/remote.ts
3070
+ import { lookup } from "dns/promises";
3071
+ import { homedir as homedir3 } from "os";
3072
+ var {$: $4 } = globalThis.Bun;
3073
+
3074
+ // src/config.ts
3075
+ import { homedir as homedir2 } from "os";
3076
+ import { join as join3 } from "path";
3077
+ function settingsPath(home = homedir2()) {
3078
+ return join3(home, ".config", "ft", "config.json");
3079
+ }
3080
+ async function loadSettings(home = homedir2()) {
3081
+ const file = Bun.file(settingsPath(home));
3082
+ if (!await file.exists())
3083
+ return {};
3084
+ let raw;
3085
+ try {
3086
+ raw = await file.json();
3087
+ } catch {
3088
+ return {};
3089
+ }
3090
+ const host = typeof raw === "object" && raw !== null && "remoteHost" in raw ? raw.remoteHost : undefined;
3091
+ return typeof host === "string" && host !== "" ? { remoteHost: host } : {};
3092
+ }
3093
+
3094
+ // src/remote.ts
3095
+ var SCP_LIKE = /^(?:([^@/]+)@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/;
3096
+ var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/;
3097
+ function parseSshRemote(url) {
3098
+ const match = SSH_URL.exec(url) ?? (url.includes("://") ? null : SCP_LIKE.exec(url));
3099
+ return match === null ? undefined : { user: match[1] ?? "git", host: match[2] ?? "", path: match[3] ?? "" };
3100
+ }
3101
+ function canonicalRemote(url, host, reaches) {
3102
+ const remote = parseSshRemote(url);
3103
+ if (remote === undefined || remote.host === host || !reaches(remote.host))
3104
+ return;
3105
+ return `${remote.user}@${host}:${remote.path}.git`;
3106
+ }
3107
+ async function reachesHost(hostname, host, hostAddresses, addressesOf) {
3108
+ if (hostname === undefined)
3109
+ return false;
3110
+ return hostname === host || (await addressesOf(hostname)).some((address) => hostAddresses.has(address));
3111
+ }
3112
+
3113
+ // src/commands/remote.ts
3114
+ var usage6 = `ft remote [--fix]
3115
+
3116
+ Checks that origin's URLs name the Forgejo host itself: tea only finds the repository when origin's host is the login's ssh_host, never through an ssh alias such as \`forgejo\` or \`gitea\`.
3117
+ --fix switches every alias URL to \`git@<host>:<owner>/<repo>.git\` and proves it with \`git ls-remote\`.
3118
+ The host comes from --host, else \`remoteHost\` in ~/.config/ft/config.json; with neither, it checks nothing.
3119
+
3120
+ Exits 0 when origin names the Forgejo host (or was switched) or no host is set, 1 when it does not or a switch is still to run, 2 on a usage error.`;
3121
+ var options7 = {
3122
+ host: { type: "string", description: "the Forgejo ssh host, instead of remoteHost in ~/.config/ft/config.json" },
3123
+ fix: { type: "boolean", description: "rewrite the alias URLs" }
3124
+ };
3125
+ var HOSTNAME = /^hostname (.+)$/m;
3126
+ async function sshHostname(alias) {
3127
+ return HOSTNAME.exec(await $4`ssh -G ${alias}`.quiet().nothrow().text())?.[1];
3128
+ }
3129
+ async function addresses(name) {
3130
+ try {
3131
+ return (await lookup(name, { all: true })).map((entry) => entry.address);
3132
+ } catch {
3133
+ return [];
3134
+ }
3135
+ }
3136
+ function originHost(url) {
3137
+ return parseSshRemote(url)?.host ?? (URL.canParse(url) ? new URL(url).hostname : undefined);
3138
+ }
3139
+ async function remote(args, home = homedir3()) {
3140
+ const { values } = parse(args, usage6, options7);
3141
+ const host = values.host ?? (await loadSettings(home)).remoteHost;
3142
+ if (host === undefined) {
3143
+ note(`no Forgejo host to check origin against: pass --host, or set "remoteHost" in ${settingsPath(home)}`);
3144
+ return 0;
3145
+ }
3146
+ const fetchUrl = await git(["remote", "get-url", "origin"]);
3147
+ const pushUrls = (await $4`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3148
+ `).filter(Boolean);
3149
+ const hostAddresses = new Set(await addresses(host));
3150
+ const hostnames = new Map;
3151
+ const reaching = new Set;
3152
+ for (const url of [fetchUrl, ...pushUrls]) {
3153
+ const alias = parseSshRemote(url)?.host;
3154
+ if (alias === undefined || hostnames.has(alias))
3155
+ continue;
3156
+ const hostname = await sshHostname(alias);
3157
+ hostnames.set(alias, hostname);
3158
+ if (await reachesHost(hostname, host, hostAddresses, addresses))
3159
+ reaching.add(alias);
3160
+ }
3161
+ const changes = [fetchUrl, ...pushUrls].map((url) => [url, canonicalRemote(url, host, (alias) => reaching.has(alias))]).filter((change) => change[1] !== undefined);
3162
+ if (changes.length === 0) {
3163
+ const named = originHost(fetchUrl);
3164
+ if (named === host) {
3165
+ console.log(`origin already names ${host}: ${fetchUrl}`);
3166
+ return 0;
3167
+ }
3168
+ const hostname = named === undefined ? undefined : hostnames.get(named);
3169
+ console.log(`origin ${fetchUrl} does not reach ${host}${hostname === undefined || hostname === named ? "" : `: ssh connects ${named} to ${hostname}`}, pass --host if Forgejo lives elsewhere`);
3170
+ return 1;
3171
+ }
3172
+ for (const [from, to] of changes)
3173
+ console.log(`${from} -> ${to}`);
3174
+ if (values.fix !== true) {
3175
+ console.log("run `ft remote --fix` to switch them");
3176
+ return 1;
3177
+ }
3178
+ const fetchChange = changes.find(([from]) => from === fetchUrl);
3179
+ if (fetchChange !== undefined)
3180
+ await git(["remote", "set-url", "origin", fetchChange[1]]);
3181
+ for (const [from, to] of changes) {
3182
+ if (pushUrls.includes(from))
3183
+ await git(["remote", "set-url", "--push", "origin", to, from]);
3184
+ }
3185
+ await git(["ls-remote", "origin", "HEAD"]);
3186
+ console.log("origin switched, and git ls-remote reaches it");
3187
+ return 0;
3188
+ }
3189
+
3190
+ // src/commands/runs.ts
3191
+ var usage7 = `ft runs [--sha <ref>]
3192
+
3193
+ Lists recent runs with what tea's own listing leaves empty: workflow, ref, duration and age, ten by default with the count of the rest (--full for all of them; --json is never cut unless --limit is given).
3194
+
3195
+ Exits 0 when it listed them, 1 when Forgejo or git cannot be read, 2 on a usage error.`;
3196
+ var DEFAULT_LIMIT = 10;
3197
+ var options8 = {
3198
+ limit: { ...limitOptions(DEFAULT_LIMIT).limit, short: "n" },
3199
+ full: limitOptions(DEFAULT_LIMIT).full,
3200
+ sha: { type: "string", short: "s", description: "only the runs of this commit" },
3201
+ workflow: { type: "string", short: "w", description: "only this workflow file, e.g. test.yml" },
3202
+ json: jsonOption(),
3203
+ ...repoOption
3204
+ };
3205
+ var TITLE_WIDTH4 = 70;
3206
+ async function onCommit(sha, repo) {
3207
+ const found = await runsOn(await resolveCommit(sha), repo);
3208
+ return { runs: found, total: found.length };
3209
+ }
3210
+ async function runs(args) {
3211
+ const parsed = parse(args, usage7, options8);
3212
+ const { values } = parsed;
3213
+ const limit = limitOf(values, DEFAULT_LIMIT);
3214
+ const { runs: found, total } = values.sha === undefined ? await runsListing(limit, values.repo, values.workflow) : await onCommit(values.sha, values.repo);
3215
+ const matching = found.filter((run) => values.workflow === undefined || run.workflow_id === values.workflow);
3216
+ const list = matching.slice(0, limit);
3217
+ const more = values.sha === undefined ? total - list.length : matching.length - list.length;
3218
+ if (values.json === true) {
3219
+ printJson(list.map((run) => ({ id: run.id, number: run.index_in_repo, status: run.status, workflow: run.workflow_id, ref: run.prettyref, event: run.event, sha: run.commit_sha, seconds: runSeconds(run), started: run.started, title: run.title, url: run.html_url })));
3220
+ return 0;
3221
+ }
3222
+ if (list.length === 0) {
3223
+ console.log("no run");
3224
+ return 0;
3225
+ }
3226
+ console.log(table([
3227
+ ["ID", "STATUS", "WORKFLOW", "REF", "EVENT", "TOOK", "STARTED", "TITLE"],
3228
+ ...list.map((run) => [String(run.id), badge(run.status, process.stdout), run.workflow_id, run.prettyref, run.event, run.duration > 0 ? formatSeconds(runSeconds(run)) : "-", startedAge(run), truncate(upTo(run.title, `
3229
+ `), TITLE_WIDTH4)])
3230
+ ], { header: true }));
3231
+ if (more > 0)
3232
+ console.log(moreFooter(more));
3233
+ return 0;
3234
+ }
3235
+
3236
+ // src/cli.ts
3237
+ process14.exitCode = await runCli({
3238
+ name: "ft",
3239
+ version,
3240
+ about: "Forgejo tools over tea, run from a repository whose origin is on Forgejo",
3241
+ replaces: "tea api calls piped through jq, and sleep loops waiting on CI",
3242
+ examples: [["ft ci", "wait for the runs of origin's tip, failures printed"], ["ft issue 12 -c", "an issue with its boxes numbered and its comments"]],
3243
+ commands: {
3244
+ runs: { run: runs, summary: "recent runs with workflow, ref, duration and age" },
3245
+ ci: { run: ci, summary: "wait for the runs covering a commit, printing what failed" },
3246
+ watch: { run: watch, summary: "wait for runs newer than an id, or those in progress" },
3247
+ logs: { run: logs, summary: "what the failed jobs of a run say went wrong" },
3248
+ release: { run: release, summary: "bump, push branch and tag atomically, wait for runs and registry" },
3249
+ board: { run: board, summary: "open issues: available, taken by a live session, or claimed by one gone" },
3250
+ digest: { run: digest, summary: "repo state for a cold start: held and abandoned claims, red CI, unpushed commits, dirty files, stale TODOs" },
3251
+ take: { run: take, summary: "claim an issue for this Claude Code session" },
3252
+ drop: { run: drop, summary: "give back an issue this session took" },
3253
+ issue: { run: issue, summary: "an issue's claim, description and numbered boxes, -c comments" },
3254
+ new: { run: newIssue, summary: "create an issue from -t and a description file or stdin" },
3255
+ edit: { run: edit, summary: "replace an issue's title or description, from a file or stdin" },
3256
+ tick: { run: tick, summary: "tick checklist boxes of an issue, -m to comment too" },
3257
+ comment: { run: commentOn, summary: "comment on an issue, from stdin when no text is given" },
3258
+ fixed: { run: fixed, summary: 'comment "Fixed in <sha>" and close an issue' },
3259
+ close: { run: closeIssues, summary: "close issues without a fix, -m posting the reason" },
3260
+ reopen: { run: reopenIssues, summary: "reopen closed issues, -m posting the reason" },
3261
+ label: { run: label, summary: "add +name and remove -name labels of an issue" },
3262
+ plan,
3263
+ milestones: { run: milestones, summary: "milestones with their open and closed issues, progress and due date" },
3264
+ milestone: { run: milestone, summary: "show a milestone's issues like the board, `new` creates one, `close` closes it" },
3265
+ remote: { run: remote, summary: "check or fix an origin using an ssh alias tea cannot match" }
3266
+ },
3267
+ aliases: { show: "issue", view: "issue" }
3268
+ });