@kaynooo/forgejo-tools 0.5.1 → 0.7.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 +1 -1
  2. package/README.md +10 -7
  3. package/dist/cli.js +1698 -439
  4. package/package.json +6 -6
package/dist/cli.js CHANGED
@@ -2,17 +2,17 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import process8 from "process";
5
+ import process15 from "process";
6
6
 
7
7
  // ../kit/src/args.ts
8
- import process3 from "process";
8
+ import process4 from "process";
9
9
  import { parseArgs } from "util";
10
10
 
11
11
  // ../kit/src/format.ts
12
- import process2 from "process";
12
+ import process3 from "process";
13
13
 
14
14
  // ../kit/src/style.ts
15
- import process from "process";
15
+ import process2 from "process";
16
16
 
17
17
  // ../../node_modules/.bun/@kaynooo+utils@2.31.0+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-mtetydvc.js
18
18
  function x(s) {
@@ -276,12 +276,30 @@ function le(i = {}) {
276
276
  var t = le();
277
277
 
278
278
  // ../kit/src/style.ts
279
- function takesControlCodes(stream, env = process.env) {
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) {
280
286
  return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
281
287
  }
282
- function paint(stream, env = process.env) {
288
+ function paint(stream, env = process2.env) {
283
289
  return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
284
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
+ }
285
303
  var STATUSES = {
286
304
  passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
287
305
  failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
@@ -290,6 +308,16 @@ var STATUSES = {
290
308
  skipped: { symbol: "-", words: ["skipped", "cancelled", "canceled", "manual"], paint: (colors) => colors.dim }
291
309
  };
292
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
+ }
293
321
 
294
322
  // ../kit/src/format.ts
295
323
  var UNITS = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];
@@ -336,7 +364,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
336
364
  widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
337
365
  }
338
366
  const shown = fit === undefined ? cells : narrow(cells, widths, fit);
339
- const colors = paint(process2.stdout);
367
+ const colors = paint(process3.stdout);
340
368
  const lines = shown.map((row) => row.map((cell, index) => {
341
369
  const width = widths[index] ?? 0;
342
370
  const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
@@ -367,6 +395,26 @@ function printJson(value) {
367
395
  console.log(JSON.stringify(value, undefined, 2));
368
396
  }
369
397
 
398
+ // ../kit/src/text.ts
399
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
400
+ function distance(a, b) {
401
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
402
+ for (const [i, left] of [...a].entries()) {
403
+ const current = [i + 1];
404
+ for (const [j, right] of [...b].entries())
405
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
406
+ previous = current;
407
+ }
408
+ return previous[b.length] ?? 0;
409
+ }
410
+ function closest(name, names) {
411
+ const started = names.find((known) => known.startsWith(name));
412
+ if (started !== undefined)
413
+ return started;
414
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
415
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
416
+ }
417
+
370
418
  // ../kit/src/args.ts
371
419
  function jsonOption(holds) {
372
420
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -386,8 +434,8 @@ function fail(message, code = 2, hint) {
386
434
  }
387
435
 
388
436
  class UsageError extends Exit {
389
- constructor(message) {
390
- super(message, 2);
437
+ constructor(message, hint) {
438
+ super(message, 2, hint);
391
439
  }
392
440
  }
393
441
  function badUsage(message) {
@@ -409,24 +457,70 @@ class Completing {
409
457
  var completion = { active: false };
410
458
  var OPTIONAL = /\[[^\]]+\]/g;
411
459
  var USAGE_PREFIX = /^usage:\s*/i;
412
- function usageLine(usage, colors = paint(process3.stdout)) {
460
+ var FULL_STOP = /\.$/;
461
+ var DASHES = /^-+/;
462
+ function usageLine(usage, colors = paint(process4.stdout)) {
413
463
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
414
464
  }
415
- function helpText(usage, options, colors = paint(process3.stdout)) {
416
- return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors)].join(`
465
+ function helpText(usage, options, colors = paint(process4.stdout), { examples = [], replaces } = {}) {
466
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
417
467
  `);
418
468
  }
419
- function optionRows(options, colors = paint(process3.stdout)) {
469
+ function replacesRows(replaces, colors = paint(process4.stdout)) {
470
+ return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
471
+ }
472
+ function wantsHelpJson(env = process4.env) {
473
+ return env.KIT_HELP === "json";
474
+ }
475
+ function description(usage, { examples = [], replaces } = {}) {
476
+ const lines = usage.replace(USAGE_PREFIX, "").split(`
477
+ `);
478
+ const first = lines[0];
479
+ const colon = first.indexOf(": ");
480
+ const prose = lines[lines.indexOf("") + 1] ?? "";
481
+ const fallback = lines.includes("") ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, "") : "";
482
+ return {
483
+ synopsis: colon === -1 ? first : first.slice(0, colon),
484
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
485
+ ...replaces === undefined ? {} : { replaces },
486
+ examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
487
+ };
488
+ }
489
+ function exampleRows(examples, colors = paint(process4.stdout)) {
490
+ if (examples.length === 0)
491
+ return [];
492
+ return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
493
+ }
494
+ function optionRows(options, colors = paint(process4.stdout)) {
420
495
  const rows = Object.entries(options).map(([name, option]) => {
421
496
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
422
497
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
423
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
498
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
424
499
  });
425
500
  const width = Math.max(0, ...rows.map((row) => row.width));
426
- return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
501
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
502
+ }
503
+ var OUTPUT_OPTIONS = {
504
+ color: { type: "string", description: "auto (on a terminal alone), always or never" },
505
+ plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
506
+ };
507
+ function applyOutput(color, plain, env = process4.env) {
508
+ const when = plain === true ? "never" : color ?? "auto";
509
+ if (when === "auto")
510
+ return;
511
+ if (when !== "always" && when !== "never")
512
+ badUsage(`--color takes auto, always or never, not ${when}`);
513
+ if (when === "always") {
514
+ env.FORCE_COLOR = "1";
515
+ delete env.NO_COLOR;
516
+ } else {
517
+ env.NO_COLOR = "1";
518
+ delete env.FORCE_COLOR;
519
+ }
427
520
  }
428
521
  function parse(args, usage, options, settings = {}) {
429
- const all = { ...options, help: { type: "boolean", short: "h", description: "show this help" } };
522
+ const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
523
+ const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
430
524
  if (completion.active)
431
525
  throw new Completing(all);
432
526
  const separator = settings.rest === true ? args.indexOf("--") : -1;
@@ -434,12 +528,15 @@ function parse(args, usage, options, settings = {}) {
434
528
  const rest = separator === -1 ? undefined : args.slice(separator + 1);
435
529
  let parsed;
436
530
  try {
437
- parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
531
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
438
532
  } catch (error) {
439
- badUsage(parseError(error));
533
+ const message = parseError(error);
534
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
440
535
  }
441
- if (parsed.values.help === true) {
442
- console.log(helpText(usage, all));
536
+ const values = parsed.values;
537
+ applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
538
+ if (values.help === true) {
539
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
443
540
  throw new HelpShown;
444
541
  }
445
542
  if (settings.positionals !== undefined)
@@ -454,16 +551,52 @@ function checkPositionals(given, names) {
454
551
  if (!variadic && given.length > names.length)
455
552
  badUsage(`unexpected argument ${given[names.length]}`);
456
553
  }
554
+ function joinDashValues(args, options) {
555
+ const longOf = new Map(Object.entries(options).flatMap(([name, option]) => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]));
556
+ const isOption = (arg) => arg === "--" || longOf.has(arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg);
557
+ const joined = [];
558
+ for (let i = 0;i < args.length; i++) {
559
+ const arg = args[i] ?? "";
560
+ if (arg === "--")
561
+ return [...joined, ...args.slice(i)];
562
+ const name = longOf.get(arg);
563
+ const next = args[i + 1];
564
+ if (name !== undefined && options[name]?.type === "string" && next !== undefined && next.startsWith("-") && next !== "-" && !isOption(next)) {
565
+ joined.push(`--${name}=${next}`);
566
+ i++;
567
+ } else {
568
+ joined.push(arg);
569
+ }
570
+ }
571
+ return joined;
572
+ }
457
573
  function parseError(error) {
458
574
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
459
575
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
460
576
  }
577
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
578
+ function nearestOption(message, options, allowNegative) {
579
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
580
+ if (typed === undefined)
581
+ return;
582
+ const word = typed.replace(DASHES, "");
583
+ if (word.length < 2 && !typed.startsWith("--"))
584
+ return;
585
+ const known = Object.entries(options).flatMap(([name, option]) => [
586
+ ...option.short === undefined ? [] : [`-${option.short}`],
587
+ `--${name}`,
588
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
589
+ ]);
590
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
591
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
592
+ }
593
+ var WHOLE = /^\d+$/;
461
594
  function wholeNumber(value, name, fallback, min = 1) {
462
595
  if (value === undefined)
463
596
  return fallback;
464
597
  const number = Number(value);
465
- if (!Number.isInteger(number) || number < min)
466
- badUsage(`--${name} takes a whole number from ${min}, not ${value}`);
598
+ if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
599
+ badUsage(`${name.startsWith("<") ? name : `--${name}`} takes a whole number from ${min}, not ${value}`);
467
600
  return number;
468
601
  }
469
602
  var DURATION = /^(\d+(?:\.\d+)?)(ms|[smh])?$/;
@@ -479,7 +612,7 @@ function duration(value, name, fallback, unit = "s") {
479
612
  }
480
613
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
481
614
  if (path === "-") {
482
- if (process3.stdin.isTTY)
615
+ if (process4.stdin.isTTY)
483
616
  fail(noStdin);
484
617
  return Bun.stdin.text();
485
618
  }
@@ -488,75 +621,471 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
488
621
  fail(`no file ${path}`);
489
622
  return file.text();
490
623
  }
491
- // ../kit/src/cli.ts
492
- import process4 from "process";
624
+ // ../kit/src/progress.ts
625
+ import process6 from "process";
493
626
 
494
- // ../kit/src/text.ts
495
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
496
- function distance(a, b) {
497
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
498
- for (const [i, left] of [...a].entries()) {
499
- const current = [i + 1];
500
- for (const [j, right] of [...b].entries())
501
- current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
502
- previous = current;
627
+ // ../kit/src/process.ts
628
+ import { constants } from "os";
629
+ import process5 from "process";
630
+ var KILL_GRACE_MS = 2000;
631
+ var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
632
+ function exitCodeOf(code, signal) {
633
+ if (code !== null)
634
+ return code;
635
+ if (typeof signal === "number")
636
+ return 128 + signal;
637
+ return signal === null ? 129 : 128 + constants.signals[signal];
638
+ }
639
+ function holdSignals(cleanup) {
640
+ let received;
641
+ let child;
642
+ const held = {
643
+ get signal() {
644
+ return received;
645
+ },
646
+ forward: (to) => {
647
+ child = to;
648
+ },
649
+ release: () => {
650
+ for (const signal of SIGNALS)
651
+ process5.off(signal, onSignal);
652
+ }
653
+ };
654
+ function onSignal(signal) {
655
+ received = signal;
656
+ if (child !== undefined) {
657
+ child.kill(signal);
658
+ return;
659
+ }
660
+ if (cleanup === undefined)
661
+ return;
662
+ held.release();
663
+ cleanup(signal);
664
+ process5.kill(process5.pid, signal);
665
+ }
666
+ for (const signal of SIGNALS)
667
+ process5.on(signal, onSignal);
668
+ return held;
669
+ }
670
+ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
671
+ const chunks = [];
672
+ const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined;
673
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe", ...grouped ? { detached: true } : {} });
674
+ const state = { kept: 0, truncated: false, timedOut: false, failure: undefined, done: false };
675
+ const timers = [];
676
+ const killGroup = (killing) => {
677
+ if (state.done)
678
+ return;
679
+ try {
680
+ if (grouped)
681
+ process5.kill(-proc.pid, killing);
682
+ else
683
+ proc.kill(killing);
684
+ } catch {}
685
+ };
686
+ const stop = () => {
687
+ killGroup("SIGTERM");
688
+ timers.push(setTimeout(killGroup, KILL_GRACE_MS, "SIGKILL"));
689
+ };
690
+ const onAbort = () => stop();
691
+ if (timeoutMs !== undefined) {
692
+ timers.push(setTimeout(() => {
693
+ state.timedOut = true;
694
+ stop();
695
+ }, timeoutMs));
696
+ }
697
+ if (signal?.aborted === true)
698
+ onAbort();
699
+ else
700
+ signal?.addEventListener("abort", onAbort, { once: true });
701
+ onStart?.({ kill: (killing) => killGroup(killing) });
702
+ const drain = async (stream, name) => {
703
+ for await (const data of stream) {
704
+ if (state.failure !== undefined)
705
+ continue;
706
+ try {
707
+ onChunk?.({ stream: name, data });
708
+ } catch (error) {
709
+ state.failure = error;
710
+ stop();
711
+ continue;
712
+ }
713
+ if (state.kept + data.length > maxBytes) {
714
+ state.truncated = true;
715
+ continue;
716
+ }
717
+ state.kept += data.length;
718
+ chunks.push({ stream: name, data });
719
+ }
720
+ };
721
+ try {
722
+ await Promise.all([drain(proc.stdout, "stdout"), drain(proc.stderr, "stderr")]);
723
+ await proc.exited;
724
+ } finally {
725
+ state.done = true;
726
+ for (const timer of timers)
727
+ clearTimeout(timer);
728
+ signal?.removeEventListener("abort", onAbort);
729
+ }
730
+ if (state.failure !== undefined)
731
+ throw state.failure;
732
+ const joined = () => Buffer.concat(chunks.map(({ data }) => data));
733
+ let output;
734
+ let text;
735
+ return {
736
+ code: exitCodeOf(proc.exitCode, proc.signalCode),
737
+ chunks,
738
+ timedOut: state.timedOut,
739
+ truncated: state.truncated,
740
+ get output() {
741
+ return output ??= joined();
742
+ },
743
+ get text() {
744
+ return text ??= (output ??= joined()).toString();
745
+ }
746
+ };
747
+ }
748
+
749
+ // ../kit/src/progress.ts
750
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
751
+ var FRAME_MS = 80;
752
+ var CLEAR_LINE = "\r\x1B[2K";
753
+ var LINE_UP = "\x1B[1A\x1B[2K";
754
+ var ESC = String.fromCharCode(27);
755
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
756
+ var RESET_STYLE = `${ESC}[0m`;
757
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
758
+ var CONSOLE_METHODS = ["log", "info", "warn", "error", "debug", "dir", "dirxml", "table", "trace", "group", "groupCollapsed", "count", "timeEnd", "timeLog", "assert"];
759
+ function liveRegion(render, stream = process6.stderr, env = process6.env) {
760
+ if (!takesControlCodes(stream, env))
761
+ return { redraw: () => {
762
+ return;
763
+ }, print: (text) => void stream.write(`${text}
764
+ `), stop: () => {
765
+ return;
766
+ } };
767
+ const write = stream.write.bind(stream);
768
+ let frame = 0;
769
+ let drawn = 0;
770
+ const clear = () => {
771
+ if (drawn > 0)
772
+ write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`);
773
+ drawn = 0;
774
+ };
775
+ const draw = () => {
776
+ const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1;
777
+ const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows;
778
+ const lines = render(FRAMES[frame++ % FRAMES.length]).slice(0, Math.max(1, height - 1)).map((line) => cutToWidth(line, width));
779
+ if (lines.length === 0) {
780
+ clear();
781
+ return;
782
+ }
783
+ write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join(`
784
+ `)}`);
785
+ drawn = lines.length;
786
+ };
787
+ const timer = setInterval(draw, FRAME_MS);
788
+ const restore = interceptOutput(clear);
789
+ const onExit = () => clear();
790
+ const signals = holdSignals(stop);
791
+ function stop() {
792
+ clearInterval(timer);
793
+ clear();
794
+ restore();
795
+ process6.off("exit", onExit);
796
+ signals.release();
797
+ }
798
+ timer.unref();
799
+ process6.on("exit", onExit);
800
+ return {
801
+ redraw: draw,
802
+ print: (text) => {
803
+ clear();
804
+ write(`${text}
805
+ `);
806
+ draw();
807
+ },
808
+ stop
809
+ };
810
+ }
811
+ function cutToWidth(line, width) {
812
+ if (Bun.stringWidth(line) <= width)
813
+ return line;
814
+ let room = width;
815
+ let kept = "";
816
+ let index = 0;
817
+ let linked = false;
818
+ while (index < line.length) {
819
+ ESCAPE_CODE.lastIndex = index;
820
+ const escape = ESCAPE_CODE.exec(line);
821
+ if (escape !== null) {
822
+ kept += escape[0];
823
+ linked ||= escape[0].startsWith(`${ESC}]`);
824
+ index = ESCAPE_CODE.lastIndex;
825
+ continue;
826
+ }
827
+ const char = String.fromCodePoint(line.codePointAt(index));
828
+ const columns = Bun.stringWidth(char);
829
+ if (columns > room)
830
+ break;
831
+ kept += char;
832
+ room -= columns;
833
+ index += char.length;
834
+ }
835
+ return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ""}`;
836
+ }
837
+ function interceptOutput(clear) {
838
+ const targets = [...CONSOLE_METHODS.map((name) => [console, name]), [process6.stdout, "write"], [process6.stderr, "write"]];
839
+ const wrapped = targets.map(([target, name]) => {
840
+ const original = Reflect.get(target, name);
841
+ const wrapper = (...args) => {
842
+ clear();
843
+ return original.apply(target, args);
844
+ };
845
+ Reflect.set(target, name, wrapper);
846
+ return { target, name, original, wrapper };
847
+ });
848
+ return () => {
849
+ for (const { target, name, original, wrapper } of wrapped) {
850
+ if (Reflect.get(target, name) === wrapper)
851
+ Reflect.set(target, name, original);
852
+ }
853
+ };
854
+ }
855
+
856
+ // ../kit/src/tool.ts
857
+ import process7 from "process";
858
+ var PERMANENT = /\bHTTP (?:401|403|404)\b|unauthorized|forbidden|not found|couldn't be found|token is required/i;
859
+ function cwdGone() {
860
+ try {
861
+ process7.cwd();
862
+ return false;
863
+ } catch {
864
+ return true;
503
865
  }
504
- return previous[b.length] ?? 0;
505
866
  }
506
867
 
868
+ class SpawnError extends Error {
869
+ missing;
870
+ constructor(message, missing) {
871
+ super(message);
872
+ this.missing = missing;
873
+ }
874
+ }
875
+ function spawnFailure(command, error) {
876
+ const gone = cwdGone();
877
+ const missing = !gone && error?.code === "ENOENT";
878
+ return new SpawnError(`${command}: ${error instanceof Error ? error.message : String(error)}${gone ? " (the working directory was removed)" : ""}`, missing);
879
+ }
880
+ function isPermanentFailure(error) {
881
+ if (!(error instanceof Error))
882
+ return false;
883
+ return error instanceof SpawnError && error.missing || PERMANENT.test(error.message);
884
+ }
885
+ async function runTool(label, argv, { timeoutMs, cwd, onStart } = {}) {
886
+ let ran;
887
+ try {
888
+ ran = await runCommand(argv, { env: process7.env, ...timeoutMs === undefined ? {} : { timeoutMs }, ...cwd === undefined ? {} : { cwd }, ...onStart === undefined ? {} : { onStart } });
889
+ } catch (error) {
890
+ throw spawnFailure(label, error);
891
+ }
892
+ if (ran.timedOut)
893
+ throw new Error(`${label}: no answer in ${formatDuration(timeoutMs ?? 0)}`);
894
+ const decoder = new TextDecoder;
895
+ const streams = { stdout: "", stderr: "" };
896
+ for (const { stream, data } of ran.chunks)
897
+ streams[stream] += decoder.decode(data, { stream: true });
898
+ return { code: ran.code, ...streams };
899
+ }
900
+
901
+ // ../kit/src/ci.ts
902
+ var POLL = 15000;
903
+ async function waitForCi(wait) {
904
+ const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL } = wait;
905
+ const started = Date.now();
906
+ let seen = started;
907
+ let moving = started;
908
+ let followed = "";
909
+ let found;
910
+ const region = wait.lines === undefined ? undefined : liveRegion((spinner) => wait.lines?.(spinner, found, started) ?? []);
911
+ const suffix = () => found === undefined ? "" : `: ${wait.links(found)}`;
912
+ async function stallOf(current) {
913
+ try {
914
+ return await wait.stall?.(current);
915
+ } catch (error) {
916
+ if (error instanceof Exit || isPermanentFailure(error))
917
+ throw error;
918
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`);
919
+ return "unknown";
920
+ }
921
+ }
922
+ try {
923
+ for (;; ) {
924
+ if (Date.now() - started > giveUpMs)
925
+ fail(`CI has not finished on ${short} in ${formatDuration(giveUpMs)}${suffix()}`);
926
+ try {
927
+ found = await wait.covering();
928
+ } catch (error) {
929
+ if (error instanceof Exit)
930
+ throw error;
931
+ if (cwdGone())
932
+ fail(`the working directory was removed while waiting for CI on ${short}${suffix()}`);
933
+ if (isPermanentFailure(error))
934
+ fail(error instanceof Error ? error.message : String(error));
935
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`);
936
+ const latest = found === undefined ? undefined : await wait.settle(found);
937
+ if (latest !== undefined)
938
+ return latest;
939
+ await Bun.sleep(pollMs);
940
+ continue;
941
+ }
942
+ if (found !== undefined) {
943
+ seen = Date.now();
944
+ const key = wait.key(found);
945
+ if (key !== followed) {
946
+ followed = key;
947
+ moving = Date.now();
948
+ wait.announce(found);
949
+ }
950
+ if (wait.finished(found))
951
+ return found;
952
+ const stall = await stallOf(found);
953
+ if (stall === undefined)
954
+ moving = Date.now();
955
+ else if (stall !== "unknown" && (stall.certain === true || Date.now() - moving > stalledMs))
956
+ fail(stall.message);
957
+ } else if (Date.now() - seen > noneMs) {
958
+ fail(wait.noneMessage);
959
+ }
960
+ await Bun.sleep(pollMs);
961
+ }
962
+ } finally {
963
+ region?.stop();
964
+ }
965
+ }
507
966
  // ../kit/src/cli.ts
508
- function closest(name, names) {
509
- const started = names.find((known) => known.startsWith(name));
510
- if (started !== undefined)
511
- return started;
512
- const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
513
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
967
+ import process8 from "process";
968
+ var LEVEL_OPTIONS = {
969
+ ...OUTPUT_OPTIONS,
970
+ help: { type: "boolean", short: "h", description: "show this help" }
971
+ };
972
+ var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
973
+ async function commandRows(commands, colors) {
974
+ const width = Math.max(...Object.keys(commands).map((name) => name.length));
975
+ const groups = new Map([["Commands:", []]]);
976
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
977
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
978
+ const heading = group === undefined ? "Commands:" : `${group}:`;
979
+ const rows = groups.get(heading) ?? [];
980
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
981
+ groups.set(heading, rows);
982
+ }
983
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
984
+ }
985
+ function takeOutputFlags(argv) {
986
+ let index = 0;
987
+ for (;index < argv.length; index++) {
988
+ const word = argv[index];
989
+ if (word === "--plain")
990
+ applyOutput(undefined, true);
991
+ else if (word.startsWith("--color="))
992
+ applyOutput(word.slice("--color=".length), undefined);
993
+ else if (word === "--color" && argv[index + 1] !== undefined)
994
+ applyOutput(argv[++index], undefined);
995
+ else
996
+ break;
997
+ }
998
+ return argv.slice(index);
999
+ }
1000
+ async function resolveSummary(summary) {
1001
+ return typeof summary === "string" ? summary : summary();
1002
+ }
1003
+ function scopeOf(commands, extra, aliases) {
1004
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
1005
+ }
1006
+ async function expand(scope, words) {
1007
+ if (scope.lazy.length === 0)
1008
+ return scope;
1009
+ const commands = { ...scope.commands };
1010
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
1011
+ for (const [name, command] of Object.entries(added))
1012
+ commands[name] ??= command;
1013
+ }
1014
+ return { ...scope, commands, lazy: [] };
514
1015
  }
515
- async function help({ path, header, commands, defaultCommand }, stream) {
1016
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
516
1017
  const colors = paint(stream);
517
- const width = Math.max(...Object.keys(commands).map((name) => name.length));
518
1018
  const prefix = path.join(" ");
1019
+ const summary = await resolveSummary(lazySummary);
1020
+ if (wantsHelpJson())
1021
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
519
1022
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
520
1023
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
1024
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
521
1025
  return [
522
- header(colors),
1026
+ header(colors, summary),
523
1027
  "",
524
1028
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
1029
+ ...replacesRows(replaces, colors),
1030
+ "",
1031
+ ...await commandRows(commands, colors),
1032
+ ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
525
1033
  "",
526
- colors.bold("Commands:"),
527
- ...Object.entries(commands).map(([name, { summary }]) => ` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`),
528
- ...options === undefined || Object.keys(options).length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(options, colors)],
1034
+ colors.bold("Options:"),
1035
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
1036
+ ...exampleRows(examples ?? [], colors),
529
1037
  "",
530
1038
  `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
531
1039
  ].join(`
532
1040
  `);
533
1041
  }
534
1042
  function printError(prefix, message, hint, cause) {
535
- const colors = paint(process4.stderr);
536
- const styled = takesControlCodes(process4.stderr);
1043
+ const colors = paint(process8.stderr);
1044
+ const styled = takesControlCodes(process8.stderr);
537
1045
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
538
1046
  const under = (label, text, tint) => text === undefined ? "" : `
539
1047
  ${styled ? tint(label) : `${label}:`} ${text}`;
540
1048
  console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
541
1049
  }
542
- function reportThrown(prefix, error, redact = (text) => text) {
1050
+ function runHint(command, effect) {
1051
+ return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
1052
+ }
1053
+ function printHint(hint) {
1054
+ const colors = paint(process8.stderr);
1055
+ console.error(` ${takesControlCodes(process8.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
1056
+ }
1057
+ function printJsonError(message, hint, cause) {
1058
+ console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
1059
+ }
1060
+ function wantsJson(argv) {
1061
+ const end = argv.indexOf("--");
1062
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
1063
+ }
1064
+ function reportThrown(prefix, error, redact = (text) => text, json = false) {
543
1065
  if (error instanceof HelpShown)
544
1066
  return 0;
545
1067
  const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
546
1068
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
547
1069
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
548
- printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
1070
+ const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
1071
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
549
1072
  return error instanceof Exit ? error.code : 1;
550
1073
  }
551
- async function dispatch(level, argv, { version, redact }) {
552
- const [name, ...args] = argv;
1074
+ async function dispatch(level, typed, { version, redact }) {
553
1075
  const prefix = level.path.join(" ");
1076
+ let argv;
1077
+ try {
1078
+ argv = takeOutputFlags(typed);
1079
+ } catch (error) {
1080
+ return reportThrown(prefix, error);
1081
+ }
1082
+ const [name, ...args] = argv;
554
1083
  if (name === undefined && level.path.length > 1) {
555
- console.error(await help(level, process4.stderr));
1084
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stderr));
556
1085
  return 2;
557
1086
  }
558
1087
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
559
- console.log(await help(level, process4.stdout));
1088
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stdout));
560
1089
  return 0;
561
1090
  }
562
1091
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -565,6 +1094,10 @@ async function dispatch(level, argv, { version, redact }) {
565
1094
  }
566
1095
  let command = level.commands[level.aliases?.[name] ?? name];
567
1096
  let rest = args;
1097
+ if (command === undefined && level.lazy.length > 0) {
1098
+ level = await expand(level, [...level.path.slice(1), ...argv]);
1099
+ command = level.commands[level.aliases?.[name] ?? name];
1100
+ }
568
1101
  if (command === undefined && level.defaultCommand !== undefined) {
569
1102
  command = level.commands[level.defaultCommand];
570
1103
  rest = argv;
@@ -576,12 +1109,12 @@ async function dispatch(level, argv, { version, redact }) {
576
1109
  }
577
1110
  if ("commands" in command) {
578
1111
  const { summary } = command;
579
- return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined }, rest, { version, redact });
1112
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
580
1113
  }
581
1114
  try {
582
1115
  return await command.run(rest);
583
1116
  } catch (error) {
584
- return reportThrown(`${prefix} ${name}`, error, redact);
1117
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
585
1118
  }
586
1119
  }
587
1120
  var COMPLETE = "__complete";
@@ -601,31 +1134,96 @@ async function optionsOf(run) {
601
1134
  async function leafCandidates(run, words) {
602
1135
  if (!(words.at(-1) ?? "").startsWith("-"))
603
1136
  return [FILES];
604
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
1137
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
605
1138
  }
606
- async function candidates(commands, aliases, defaultCommand, words) {
1139
+ async function candidates(scope, defaultCommand, words, before = []) {
607
1140
  const [word = "", ...rest] = words;
608
- const command = commands[aliases?.[word] ?? word];
609
- if (rest.length === 0 && !word.startsWith("-"))
610
- return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
1141
+ const listing = rest.length === 0 && !word.startsWith("-");
1142
+ let { commands, aliases } = scope;
1143
+ let command = commands[aliases?.[word] ?? word];
1144
+ if (listing || command === undefined) {
1145
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
1146
+ command = commands[aliases?.[word] ?? word];
1147
+ }
1148
+ if (listing)
1149
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
611
1150
  if (command === undefined) {
612
1151
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
613
1152
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
614
1153
  }
615
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
1154
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
616
1155
  }
617
1156
  async function printCandidates(found) {
618
1157
  for (const line of await found)
619
1158
  console.log(line);
620
1159
  return 0;
621
1160
  }
622
- async function runCli(cli, argv = process4.argv.slice(2)) {
1161
+ async function runCli(cli, argv = process8.argv.slice(2)) {
623
1162
  if (argv[0] === COMPLETE)
624
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
625
- return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand }, argv, { version: cli.version, redact: cli.redact });
1163
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
1164
+ return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
1165
+ }
1166
+ // ../kit/src/git.ts
1167
+ import { basename, dirname } from "path";
1168
+ import process9 from "process";
1169
+ var ORIGIN_PREFIX = /^origin\//;
1170
+ var GIT_NETWORK_MS = 60000;
1171
+ async function git(args, timeoutMs) {
1172
+ const result = await runTool(`git ${args.join(" ")}`, ["git", ...args], { timeoutMs });
1173
+ if (result.code !== 0)
1174
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.trim() || `exit ${result.code}`}${cwdGone() ? " (the working directory was removed)" : ""}`);
1175
+ return result.stdout.trim();
1176
+ }
1177
+ async function gitOk(args) {
1178
+ return (await runTool(`git ${args.join(" ")}`, ["git", ...args])).code === 0;
626
1179
  }
1180
+ async function resolveCommit(ref) {
1181
+ const sha = (await runTool("git rev-parse", ["git", "rev-parse", "--verify", "-q", "--end-of-options", `${ref}^{commit}`])).stdout.trim();
1182
+ if (sha === "")
1183
+ fail(`no commit ${ref}`);
1184
+ return sha;
1185
+ }
1186
+ async function fetchBranch(branch) {
1187
+ if (branch.startsWith("-") || !await gitOk(["check-ref-format", "--branch", branch]))
1188
+ fail(`${branch} is not a branch name`);
1189
+ await git(["fetch", "-q", "origin", `refs/heads/${branch}`], GIT_NETWORK_MS);
1190
+ }
1191
+ async function defaultBranch() {
1192
+ const head = (await runTool("git symbolic-ref", ["git", "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"])).stdout.trim();
1193
+ return head === "" ? "main" : head.replace(ORIGIN_PREFIX, "");
1194
+ }
1195
+ async function leaveWorktree() {
1196
+ const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1197
+ if (basename(common) === ".git")
1198
+ process9.chdir(dirname(common));
1199
+ }
1200
+ // ../kit/src/limit.ts
1201
+ function limitOptions(fallback) {
1202
+ return {
1203
+ limit: { type: "string", description: `show at most this many rows, the rest counted (${fallback})` },
1204
+ full: { type: "boolean", description: "show every row" }
1205
+ };
1206
+ }
1207
+ function limitOf(values, fallback) {
1208
+ if (values.full === true && values.limit !== undefined)
1209
+ badUsage("--limit and --full exclude each other");
1210
+ if (values.full === true || values.json === true && values.limit === undefined)
1211
+ return Number.POSITIVE_INFINITY;
1212
+ return wholeNumber(values.limit, "limit", fallback);
1213
+ }
1214
+ function moreFooter(more) {
1215
+ return `${more} more, rerun with --full`;
1216
+ }
1217
+ function capped(rows, limit) {
1218
+ const more = rows.length - limit;
1219
+ return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
1220
+ }
1221
+ // ../kit/src/logs.ts
1222
+ var DAY_MS = 24 * 60 * 60 * 1000;
1223
+ // ../kit/src/secrets.ts
1224
+ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
627
1225
  // package.json
628
- var version = "0.5.1";
1226
+ var version = "0.7.0";
629
1227
 
630
1228
  // src/args.ts
631
1229
  var repoOption = {
@@ -637,27 +1235,41 @@ function issueNumber(value, usage) {
637
1235
  fail(`an issue number is needed
638
1236
 
639
1237
  ${usage}`);
640
- return wholeNumber(value.replace(ISSUE_HASH, ""), "issue", 0);
1238
+ return wholeNumber(value.replace(ISSUE_HASH, ""), "<issue>", 0);
641
1239
  }
642
1240
  function issueNumbers(values, usage) {
643
1241
  return values.length === 0 ? [issueNumber(undefined, usage)] : values.map((value) => issueNumber(value, usage));
644
1242
  }
645
1243
 
646
1244
  // src/checklist.ts
647
- var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*)\r?$/i;
648
- var FENCE = /^\s*(?:```|~~~)/;
1245
+ var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*?)\r?$/is;
1246
+ var FENCE = /^\s*(`{3,}|~{3,})/;
1247
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/;
1248
+ var INDENT_CODE = /^(?: {4}|\t)/;
649
1249
  function boxes(body) {
650
1250
  const found = [];
651
- let fenced = false;
1251
+ let fence;
1252
+ let inList = false;
652
1253
  for (const [line, text] of body.split(`
653
1254
  `).entries()) {
654
- if (FENCE.test(text)) {
655
- fenced = !fenced;
1255
+ const marker = FENCE.exec(text)?.[1];
1256
+ if (fence !== undefined) {
1257
+ if (marker?.startsWith(fence[0] ?? "") === true && marker.length >= fence.length && text.trim() === marker)
1258
+ fence = undefined;
1259
+ continue;
1260
+ }
1261
+ if (marker !== undefined) {
1262
+ fence = marker;
656
1263
  continue;
657
1264
  }
658
- const match = fenced ? null : BOX.exec(text);
1265
+ const blank = text.trim() === "";
1266
+ const match = INDENT_CODE.test(text) && !inList ? null : BOX.exec(text);
659
1267
  if (match !== null)
660
1268
  found.push({ number: found.length + 1, line, done: match[2] !== " ", text: match[4] ?? "" });
1269
+ if (LIST_ITEM.test(text))
1270
+ inList = true;
1271
+ else if (!blank && !text.startsWith(" ") && !text.startsWith("\t"))
1272
+ inList = false;
661
1273
  }
662
1274
  return found;
663
1275
  }
@@ -690,13 +1302,17 @@ function numberedBody(body) {
690
1302
  return lines.join(`
691
1303
  `).trim();
692
1304
  }
1305
+ var NUMBERED_PICK = /^(\d+):(.+)$/s;
1306
+ var WHOLE_PICK = /^\d+$/;
693
1307
  function setBoxes(body, picks, done) {
694
1308
  const all = boxes(body);
695
1309
  const chosen = new Map;
696
1310
  for (const pick of picks) {
697
- const number = Number(pick);
698
- const needle = pick.toLowerCase();
699
- const matches = Number.isInteger(number) && String(number) === pick ? all.filter((box) => box.number === number) : all.filter((box) => box.text.toLowerCase().includes(needle));
1311
+ if (pick.trim() === "")
1312
+ throw new Error("an empty box pick matches every box");
1313
+ const guarded = NUMBERED_PICK.exec(pick);
1314
+ const needle = (guarded?.[2] ?? pick).trim().toLowerCase();
1315
+ const matches = guarded === null && !WHOLE_PICK.test(pick) ? all.filter((box) => box.text.toLowerCase().includes(needle)) : all.filter((box) => box.number === Number(guarded?.[1] ?? pick) && (guarded === null || box.text.toLowerCase().startsWith(needle)));
700
1316
  if (matches.length === 0)
701
1317
  throw new Error(`no box matches ${pick}`);
702
1318
  if (matches.length > 1)
@@ -734,8 +1350,8 @@ function tickReferences(body, wanted) {
734
1350
  }
735
1351
 
736
1352
  // src/claims.ts
737
- var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/im;
738
- var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/im;
1353
+ var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/i;
1354
+ var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/i;
739
1355
  var LANE = /^lane-([^-]+)-/;
740
1356
  function takenComment(name) {
741
1357
  return `Taken by session ${name}.`;
@@ -743,9 +1359,12 @@ function takenComment(name) {
743
1359
  function releasedComment(name) {
744
1360
  return `Released by session ${name}.`;
745
1361
  }
746
- function latestClaim(comments) {
1362
+ function isClaimant(comment, author) {
1363
+ return author === undefined || comment.user === undefined || comment.user.login === author;
1364
+ }
1365
+ function latestClaim(comments, author) {
747
1366
  let claim;
748
- for (const comment of [...comments].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
1367
+ for (const comment of comments.filter((each) => isClaimant(each, author)).sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
749
1368
  const taken = TAKEN.exec(comment.body)?.[1];
750
1369
  if (taken !== undefined) {
751
1370
  claim = { holder: taken, at: comment.created_at };
@@ -772,80 +1391,15 @@ function holderSession(holder, sessions) {
772
1391
  }
773
1392
 
774
1393
  // src/tea.ts
775
- var {$: $2 } = globalThis.Bun;
776
-
777
- // src/git.ts
778
- import { basename, dirname } from "path";
779
- import process6 from "process";
780
- var {$ } = globalThis.Bun;
781
- var ORIGIN_PREFIX = /^origin\//;
782
- function spawnFailure(command, error) {
783
- return new Error(`${command}: ${error instanceof Error ? error.message : String(error)}${cwdGone() ? " (the working directory was removed)" : ""}`);
784
- }
785
- async function git(args) {
786
- let result;
787
- try {
788
- result = await $`git ${args}`.quiet().nothrow();
789
- } catch (error) {
790
- throw spawnFailure(`git ${args.join(" ")}`, error);
791
- }
792
- if (result.exitCode !== 0)
793
- throw new Error(`git ${args.join(" ")}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
794
- return result.stdout.toString().trim();
795
- }
796
- async function gitOk(args) {
797
- return (await $`git ${args}`.quiet().nothrow()).exitCode === 0;
798
- }
799
- async function resolveCommit(ref) {
800
- const sha = (await $`git rev-parse --verify -q ${`${ref}^{commit}`}`.quiet().nothrow().text()).trim();
801
- if (sha === "")
802
- fail(`no commit ${ref}`);
803
- return sha;
804
- }
805
- async function defaultBranch() {
806
- const head = (await $`git symbolic-ref --short -q refs/remotes/origin/HEAD`.quiet().nothrow().text()).trim();
807
- return head === "" ? "main" : head.replace(ORIGIN_PREFIX, "");
808
- }
809
- async function tagsAt(commit) {
810
- return (await git(["tag", "--points-at", commit])).split(`
811
- `).filter(Boolean);
812
- }
813
- async function commitsNaming(issue, ref) {
814
- const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
815
- return out.split(`
816
- `).filter(Boolean).map((line) => {
817
- const space = line.indexOf(" ");
818
- return { sha: line.slice(0, space), subject: line.slice(space + 1) };
819
- });
820
- }
821
- function cwdGone() {
822
- try {
823
- process6.cwd();
824
- return false;
825
- } catch {
826
- return true;
827
- }
828
- }
829
- async function leaveWorktree() {
830
- const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
831
- if (basename(common) === ".git")
832
- process6.chdir(dirname(common));
833
- }
834
-
835
- // src/tea.ts
1394
+ var TEA_MS = 60000;
836
1395
  function repoFlag(repo) {
837
1396
  return repo === undefined ? [] : ["--repo", repo];
838
1397
  }
839
1398
  async function request(path, repo) {
840
- let result;
841
- try {
842
- result = await $2`tea api ${repoFlag(repo)} ${path}`.quiet().nothrow();
843
- } catch (error) {
844
- throw spawnFailure(`tea api ${path}`, error);
845
- }
846
- if (result.exitCode !== 0)
847
- throw new Error(`tea api ${path}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
848
- return result.stdout.toString();
1399
+ const result = await runTool(`tea api ${path}`, ["tea", "api", ...repoFlag(repo), path], { timeoutMs: TEA_MS });
1400
+ if (result.code !== 0)
1401
+ throw new Error(`tea api ${path}: ${result.stderr.trim() || `exit ${result.code}`}`);
1402
+ return result.stdout;
849
1403
  }
850
1404
  function parseJson(path, text) {
851
1405
  let value;
@@ -855,29 +1409,49 @@ function parseJson(path, text) {
855
1409
  throw new Error(`tea api ${path}: ${upTo(text.trim(), `
856
1410
  `) || "empty answer"}`);
857
1411
  }
858
- if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
859
- throw new Error(`tea api ${path}: ${value.message}`);
1412
+ const failure = apiError(value);
1413
+ if (failure !== undefined)
1414
+ throw new Error(`tea api ${path}: ${failure}`);
860
1415
  return value;
861
1416
  }
1417
+ function apiError(value) {
1418
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
1419
+ return value.message;
1420
+ return;
1421
+ }
862
1422
  async function api(path, repo) {
863
1423
  return parseJson(path, await request(path, repo));
864
1424
  }
865
1425
  async function repoApi(path, repo) {
866
1426
  return api(`repos/{owner}/{repo}/${path}`, repo);
867
1427
  }
868
- async function repoApiText(path, repo) {
869
- return request(`repos/{owner}/{repo}/${path}`, repo);
1428
+ async function repoWrite(method, path, fields, repo) {
1429
+ const full = `repos/{owner}/{repo}/${path}`;
1430
+ const flags = Object.entries(fields).flatMap(([name, value]) => ["-f", `${name}=${value}`]);
1431
+ const result = await runTool(`tea api ${full}`, ["tea", "api", ...repoFlag(repo), "-X", method, ...flags, full], { timeoutMs: TEA_MS });
1432
+ if (result.code !== 0)
1433
+ throw new Error(`tea api ${full}: ${result.stderr.trim() || `exit ${result.code}`}`);
1434
+ return parseJson(full, result.stdout);
870
1435
  }
871
- async function tea(args, repo) {
872
- let result;
1436
+ async function repoApiText(path, repo) {
1437
+ const full = `repos/{owner}/{repo}/${path}`;
1438
+ const text = await request(full, repo);
1439
+ let value;
873
1440
  try {
874
- result = await $2`tea ${args} ${repoFlag(repo)}`.quiet().nothrow();
875
- } catch (error) {
876
- throw spawnFailure(`tea ${args[0] ?? ""}`, error);
1441
+ value = JSON.parse(text);
1442
+ } catch {
1443
+ return text;
877
1444
  }
878
- if (result.exitCode !== 0)
879
- throw new Error(`tea ${args[0] ?? ""}: ${result.stderr.toString().trim() || result.stdout.toString().trim() || `exit ${result.exitCode}`}`);
880
- return result.stdout.toString();
1445
+ const failure = apiError(value);
1446
+ if (failure !== undefined)
1447
+ throw new Error(`tea api ${full}: ${failure}`);
1448
+ return text;
1449
+ }
1450
+ async function tea(command, args, repo) {
1451
+ const result = await runTool(`tea ${command.join(" ")}`, ["tea", ...command, ...repoFlag(repo), ...args], { timeoutMs: TEA_MS });
1452
+ if (result.code !== 0)
1453
+ throw new Error(`tea ${command.join(" ")}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
1454
+ return result.stdout;
881
1455
  }
882
1456
 
883
1457
  // src/issues.ts
@@ -886,16 +1460,21 @@ var CONCURRENCY = 8;
886
1460
  async function getIssue(number, repo) {
887
1461
  return repoApi(`issues/${number}`, repo);
888
1462
  }
889
- async function openIssues(labels, repo) {
1463
+ async function listIssues(state, filter, repo) {
890
1464
  const issues = [];
891
- const filter = labels === undefined ? "" : `&labels=${encodeURIComponent(labels)}`;
892
1465
  for (let page = 1;; page++) {
893
- const batch = await repoApi(`issues?state=open&type=issues&page=${page}&limit=${PAGE}${filter}`, repo);
1466
+ const batch = await repoApi(`issues?state=${state}&type=issues&page=${page}&limit=${PAGE}${filter}`, repo);
894
1467
  issues.push(...batch);
895
1468
  if (batch.length < PAGE)
896
1469
  return issues;
897
1470
  }
898
1471
  }
1472
+ async function openIssues(labels, repo, milestone) {
1473
+ return listIssues("open", `${labels === undefined ? "" : `&labels=${encodeURIComponent(labels)}`}${milestone === undefined ? "" : `&milestones=${milestone}`}`, repo);
1474
+ }
1475
+ async function closedIssues(milestone, repo) {
1476
+ return listIssues("closed", `&milestones=${milestone}`, repo);
1477
+ }
899
1478
  async function commentsOf(number, repo) {
900
1479
  return repoApi(`issues/${number}/comments`, repo);
901
1480
  }
@@ -910,42 +1489,75 @@ async function commentsOfAll(issues, repo) {
910
1489
  return result;
911
1490
  }
912
1491
  async function editIssue(number, change, repo) {
913
- const args = ["issues", "edit", String(number)];
1492
+ const args = [String(number)];
914
1493
  if (change.title !== undefined)
915
1494
  args.push("-t", change.title);
916
1495
  if (change.body !== undefined)
917
1496
  args.push("-d", change.body);
918
- await tea(args, repo);
1497
+ if (change.milestone !== undefined)
1498
+ args.push("-m", change.milestone);
1499
+ await tea(["issues", "edit"], args, repo);
919
1500
  }
920
1501
  async function setBody(number, body, repo) {
921
1502
  await editIssue(number, { body }, repo);
922
1503
  }
923
- async function createIssue(title, body, labels, repo) {
924
- const args = ["issues", "create", "-t", title, "-d", body];
1504
+ async function createIssue(title, body, labels, milestone, repo) {
1505
+ const args = ["-t", title, "-d", body];
1506
+ const same = (found) => found.title === title && sameText(found.body, body);
1507
+ const known = new Set((await openIssues(undefined, repo)).filter(same).map((found) => found.number));
925
1508
  if (labels !== undefined)
926
1509
  args.push("-L", labels);
927
- await tea(args, repo);
928
- const created = (await openIssues(undefined, repo)).filter((found) => found.title === title).sort((a, b) => b.number - a.number)[0];
929
- if (created === undefined)
1510
+ if (milestone !== undefined)
1511
+ args.push("-m", milestone);
1512
+ await tea(["issues", "create"], args, repo);
1513
+ const created = (await openIssues(undefined, repo)).filter((found) => same(found) && !known.has(found.number));
1514
+ if (created.length > 1)
1515
+ throw new Error(`created "${title}" but ${created.map((found) => `#${found.number}`).join(" and ")} both match: another session created the same issue at once`);
1516
+ if (created[0] === undefined)
930
1517
  throw new Error(`created "${title}" but cannot find it among the open issues`);
931
- return created;
1518
+ return created[0];
932
1519
  }
1520
+ function sameText(a, b) {
1521
+ return a.replaceAll(`\r
1522
+ `, `
1523
+ `).trimEnd() === b.replaceAll(`\r
1524
+ `, `
1525
+ `).trimEnd();
1526
+ }
1527
+ var EDIT_TRIES = 3;
933
1528
  async function editBody(number, change, repo) {
934
- const { body } = await getIssue(number, repo);
935
- const next = change(body);
936
- if (next === body)
937
- return false;
938
- await setBody(number, next, repo);
939
- return true;
1529
+ let wrote = false;
1530
+ for (let attempt = 0;attempt < EDIT_TRIES; attempt++) {
1531
+ const { body } = await getIssue(number, repo);
1532
+ const next = change(body);
1533
+ if (next === body)
1534
+ return wrote;
1535
+ await setBody(number, next, repo);
1536
+ wrote = true;
1537
+ if (sameText((await getIssue(number, repo)).body, next))
1538
+ return true;
1539
+ }
1540
+ throw new Error(`#${number}: its description keeps changing under the edit, so it was not written reliably`);
1541
+ }
1542
+ var login;
1543
+ async function sessionAuthor() {
1544
+ if (login !== undefined)
1545
+ return login;
1546
+ try {
1547
+ login = (await api("user")).login;
1548
+ } catch {
1549
+ return;
1550
+ }
1551
+ return login;
940
1552
  }
941
1553
  async function comment(number, body, repo) {
942
- await tea(["comment", String(number), body], repo);
1554
+ await repoWrite("POST", `issues/${number}/comments`, { body }, repo);
943
1555
  }
944
1556
  async function close(number, repo) {
945
- await tea(["issues", "close", String(number)], repo);
1557
+ await tea(["issues", "close"], [String(number)], repo);
946
1558
  }
947
1559
  async function reopen(number, repo) {
948
- await tea(["issues", "reopen", String(number)], repo);
1560
+ await tea(["issues", "reopen"], [String(number)], repo);
949
1561
  }
950
1562
  async function repoLabels(repo) {
951
1563
  const names = [];
@@ -957,26 +1569,74 @@ async function repoLabels(repo) {
957
1569
  }
958
1570
  }
959
1571
  async function editLabels(number, add, remove, repo) {
960
- const args = ["issues", "edit", String(number)];
1572
+ const args = [String(number)];
961
1573
  if (add.length > 0)
962
1574
  args.push("--add-labels", add.join(","));
963
1575
  if (remove.length > 0)
964
1576
  args.push("--remove-labels", remove.join(","));
965
- await tea(args, repo);
1577
+ await tea(["issues", "edit"], args, repo);
1578
+ }
1579
+
1580
+ // src/milestones.ts
1581
+ var PAGE2 = 50;
1582
+ var DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
1583
+ var NO_DUE_YEAR = 9999;
1584
+ async function listMilestones(state, repo) {
1585
+ const found = [];
1586
+ for (let page = 1;; page++) {
1587
+ const batch = await repoApi(`milestones?state=${state}&page=${page}&limit=${PAGE2}`, repo);
1588
+ found.push(...batch);
1589
+ if (batch.length < PAGE2)
1590
+ return found;
1591
+ }
1592
+ }
1593
+ async function findMilestone(reference, repo) {
1594
+ const all = await listMilestones("all", repo);
1595
+ const found = all.find((each) => each.title === reference) ?? all.find((each) => String(each.id) === reference);
1596
+ if (found === undefined)
1597
+ 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"));
1598
+ return found;
1599
+ }
1600
+ function dueOn(value) {
1601
+ const match = DATE.exec(value);
1602
+ const date = new Date(`${value}T00:00:00Z`);
1603
+ if (match === null || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value || date.getUTCFullYear() < 2000)
1604
+ fail(`--due takes a date as YYYY-MM-DD, not ${value}`);
1605
+ return date.toISOString();
1606
+ }
1607
+ function dueDate(milestone) {
1608
+ if (milestone.due_on === null || new Date(milestone.due_on).getUTCFullYear() >= NO_DUE_YEAR)
1609
+ return "";
1610
+ return milestone.due_on.slice(0, 10);
1611
+ }
1612
+ function progress(milestone) {
1613
+ const total = milestone.open_issues + milestone.closed_issues;
1614
+ return total === 0 ? "" : `${Math.round(milestone.closed_issues / total * 100)}%`;
1615
+ }
1616
+ async function createMilestone(title, description, due, repo) {
1617
+ const fields = { title };
1618
+ if (description !== undefined)
1619
+ fields.description = description;
1620
+ if (due !== undefined)
1621
+ fields.due_on = due;
1622
+ return repoWrite("POST", "milestones", fields, repo);
1623
+ }
1624
+ async function closeMilestone(id, repo) {
1625
+ await repoWrite("PATCH", `milestones/${id}`, { state: "closed" }, repo);
966
1626
  }
967
1627
 
968
1628
  // src/sessions.ts
969
1629
  import { homedir } from "os";
970
1630
  import { join } from "path";
971
- import process7 from "process";
1631
+ import process11 from "process";
972
1632
  var {Glob } = globalThis.Bun;
973
1633
  var SESSION_FILES = new Glob("*.json");
974
1634
  var ACCOUNT_DIRS = new Glob("*/sessions");
975
1635
  async function sessionDirs() {
976
1636
  const home = homedir();
977
1637
  const dirs = new Set([join(home, ".claude", "sessions")]);
978
- if (process7.env.CLAUDE_CONFIG_DIR !== undefined)
979
- dirs.add(join(process7.env.CLAUDE_CONFIG_DIR, "sessions"));
1638
+ if (process11.env.CLAUDE_CONFIG_DIR !== undefined)
1639
+ dirs.add(join(process11.env.CLAUDE_CONFIG_DIR, "sessions"));
980
1640
  try {
981
1641
  for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
982
1642
  dirs.add(dir);
@@ -1022,7 +1682,7 @@ async function liveSessions() {
1022
1682
  }
1023
1683
  async function currentSession(sessions) {
1024
1684
  const byPid = new Map(sessions.map((session) => [session.pid, session]));
1025
- let pid = process7.ppid;
1685
+ let pid = process11.ppid;
1026
1686
  for (let depth = 0;depth < 32 && pid > 1; depth++) {
1027
1687
  const session = byPid.get(pid);
1028
1688
  if (session !== undefined)
@@ -1061,18 +1721,20 @@ function noteLine(comments, now) {
1061
1721
  `).map((line) => line.trim()).find((line) => line !== "");
1062
1722
  return note === undefined || first === undefined ? [] : [` ${note.user.login} ${formatAge(note.created_at, now)}: ${truncate(first, NOTE_WIDTH)}`];
1063
1723
  }
1064
- function renderBoard(rows, now = Date.now()) {
1724
+ function within(rows, limit, cells, decorate = (line) => [line]) {
1725
+ const { shown, footer } = capped(rows, limit);
1726
+ return { lines: [...table(shown.map(cells)).split(`
1727
+ `).flatMap(decorate), ...footer === undefined ? [] : [footer]] };
1728
+ }
1729
+ function renderBoard(rows, now = Date.now(), limit = Number.POSITIVE_INFINITY) {
1065
1730
  const available = rows.filter((row) => row.standing.state === "available");
1066
1731
  const claimed = (state) => rows.flatMap(({ issue, standing: row, comments }) => row.state === state ? [{ issue, row, comments }] : []);
1067
1732
  const taken = claimed("taken");
1068
1733
  const away = claimed("away");
1069
1734
  const sections = [
1070
- { heading: "available", count: available.length, lines: table(available.map(({ issue }) => [`#${issue.number}`, truncate(issue.title, TITLE_WIDTH), issue.labels.map((label) => label.name).join(", ")])).split(`
1071
- `) },
1072
- { heading: "taken", count: taken.length, lines: table(taken.map(({ issue, row }) => claimRow(issue, row, now))).split(`
1073
- `) },
1074
- { heading: "claimed, no live session here", count: away.length, lines: table(away.map(({ issue, row }) => claimRow(issue, row, now))).split(`
1075
- `).flatMap((line, index) => [line, ...noteLine(away[index]?.comments ?? [], now)]) }
1735
+ { heading: "available", count: available.length, ...within(available, limit, ({ issue }) => [`#${issue.number}`, truncate(issue.title, TITLE_WIDTH), issue.labels.map((label) => label.name).join(", ")]) },
1736
+ { heading: "taken", count: taken.length, ...within(taken, limit, ({ issue, row }) => claimRow(issue, row, now)) },
1737
+ { 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)]) }
1076
1738
  ].filter((section) => section.count > 0);
1077
1739
  return sections.length === 0 ? "no open issue" : sections.map(({ heading, count, lines }) => `${heading} (${count})
1078
1740
  ${lines.join(`
@@ -1080,15 +1742,46 @@ ${lines.join(`
1080
1742
 
1081
1743
  `);
1082
1744
  }
1745
+ async function boardRows(issues, sessions, repo) {
1746
+ const comments = await commentsOfAll(issues, repo);
1747
+ const author = await sessionAuthor();
1748
+ return issues.map((issue) => {
1749
+ const own = comments.get(issue.number) ?? [];
1750
+ return { issue, standing: standing(latestClaim(own, author), sessions), comments: own };
1751
+ });
1752
+ }
1753
+ function boardJson(rows) {
1754
+ return rows.map(({ issue, standing: { state, ...rest }, comments: own }) => {
1755
+ const list = boxes(issue.body);
1756
+ const note = latestNote(own);
1757
+ return {
1758
+ number: issue.number,
1759
+ title: issue.title,
1760
+ labels: issue.labels.map((label) => label.name),
1761
+ state,
1762
+ holder: "claim" in rest ? rest.claim.holder : undefined,
1763
+ since: "claim" in rest ? rest.claim.at : undefined,
1764
+ session: "session" in rest ? rest.session.name : undefined,
1765
+ boxesTotal: list.length,
1766
+ boxesLeft: list.filter((box) => !box.done).length,
1767
+ note: note === undefined ? undefined : { author: note.user.login, at: note.created_at, body: note.body }
1768
+ };
1769
+ });
1770
+ }
1083
1771
  var boardUsage = `ft board
1084
1772
 
1085
- Lists open issues 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.
1773
+ 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.
1086
1774
  A claim shows its boxes left, and one no live session holds also the latest comment that is not a claim.
1087
- A lane's claim (lane-<n>-<issue>) is held while the session whose name ends in -<n> runs.`;
1775
+ A lane's claim (lane-<n>-<issue>) is held while the session whose name ends in -<n> runs.
1776
+
1777
+ Exits 0 when it listed them, 1 when the issues cannot be read, 2 on a usage error.`;
1778
+ var BOARD_LIMIT = 30;
1088
1779
  var boardOptions = {
1089
1780
  labels: { type: "string", short: "L", description: "only issues with these labels, comma separated" },
1781
+ milestone: { type: "string", short: "m", description: "only issues of this milestone, a title or an id" },
1090
1782
  query: { type: "string", short: "q", description: "only issues whose title matches this, a case-insensitive regex such as dom|eager" },
1091
1783
  state: { type: "string", description: "only issues in this state: available, taken or away (claimed, no live session here)" },
1784
+ ...limitOptions(BOARD_LIMIT),
1092
1785
  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)"),
1093
1786
  ...repoOption
1094
1787
  };
@@ -1096,6 +1789,7 @@ async function board(args) {
1096
1789
  const parsed = parse(args, boardUsage, boardOptions);
1097
1790
  const { values } = parsed;
1098
1791
  const state = values.state;
1792
+ const limit = limitOf(values, BOARD_LIMIT);
1099
1793
  if (state !== undefined && !STATES.has(state))
1100
1794
  fail(`--state takes available, taken or away, not ${state}`);
1101
1795
  let query;
@@ -1104,33 +1798,15 @@ async function board(args) {
1104
1798
  } catch (error) {
1105
1799
  fail(`--query: ${error instanceof Error ? error.message : String(error)}`);
1106
1800
  }
1107
- const [open, sessions] = await Promise.all([openIssues(values.labels, values.repo), liveSessions()]);
1108
- const issues = query === undefined ? open : open.filter((issue) => query.test(issue.title));
1109
- const comments = await commentsOfAll(issues, values.repo);
1110
- const rows = issues.map((issue) => {
1111
- const own = comments.get(issue.number) ?? [];
1112
- return { issue, standing: standing(latestClaim(own), sessions), comments: own };
1113
- }).filter((row) => state === undefined || row.standing.state === state);
1801
+ const milestone = values.milestone === undefined ? undefined : (await findMilestone(values.milestone, values.repo)).id;
1802
+ const [open, sessions] = await Promise.all([openIssues(values.labels, values.repo, milestone), liveSessions()]);
1803
+ const issues = query === undefined ? open : open.filter((issue) => query.test(issue.title));
1804
+ const rows = (await boardRows(issues, sessions, values.repo)).filter((row) => state === undefined || row.standing.state === state);
1114
1805
  if (values.json === true) {
1115
- printJson(rows.map(({ issue, standing: { state, ...rest }, comments: own }) => {
1116
- const list = boxes(issue.body);
1117
- const note = latestNote(own);
1118
- return {
1119
- number: issue.number,
1120
- title: issue.title,
1121
- labels: issue.labels.map((label) => label.name),
1122
- state,
1123
- holder: "claim" in rest ? rest.claim.holder : undefined,
1124
- since: "claim" in rest ? rest.claim.at : undefined,
1125
- session: "session" in rest ? rest.session.name : undefined,
1126
- boxesTotal: list.length,
1127
- boxesLeft: list.filter((box) => !box.done).length,
1128
- note: note === undefined ? undefined : { author: note.user.login, at: note.created_at, body: note.body }
1129
- };
1130
- }));
1806
+ printJson(boardJson(rows));
1131
1807
  return 0;
1132
1808
  }
1133
- console.log(rows.length === 0 && (query !== undefined || state !== undefined) ? "no open issue matches" : renderBoard(rows));
1809
+ console.log(rows.length === 0 && (query !== undefined || state !== undefined) ? "no open issue matches" : renderBoard(rows, Date.now(), limit));
1134
1810
  return 0;
1135
1811
  }
1136
1812
  async function me(as, sessions) {
@@ -1144,7 +1820,9 @@ async function me(as, sessions) {
1144
1820
  var takeUsage = `ft take <n>...
1145
1821
 
1146
1822
  Claims each issue for this Claude Code session with a "Taken by session <name>." comment.
1147
- 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 and exits 1.`;
1823
+ 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.
1824
+
1825
+ Exits 0 when every issue is taken, 1 when one is refused, 2 on a usage error.`;
1148
1826
  var laneOption = {
1149
1827
  lane: { type: "boolean", description: "claim each as lane-<s>-<n>, <s> ending this session's name, so the claim stays live while it runs" }
1150
1828
  };
@@ -1172,6 +1850,8 @@ async function eachIssue(command, numbers, step) {
1172
1850
  if (!(error instanceof Exit) || numbers.length === 1)
1173
1851
  throw error;
1174
1852
  console.error(`ft ${command}: ${error.message}`);
1853
+ if (error.hint !== undefined)
1854
+ printHint(error.hint);
1175
1855
  code = Math.max(code, error.code);
1176
1856
  }
1177
1857
  }
@@ -1183,12 +1863,13 @@ async function take(args) {
1183
1863
  const numbers = issueNumbers(positionals, takeUsage);
1184
1864
  const sessions = await liveSessions();
1185
1865
  const nameFor = await claimant(values, sessions);
1866
+ const author = await sessionAuthor();
1186
1867
  return eachIssue("take", numbers, async (number) => {
1187
1868
  const name = nameFor(number);
1188
1869
  const [found, comments] = await Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)]);
1189
1870
  if (found.state === "closed")
1190
- fail(`#${number} is closed`, 1);
1191
- const claim = latestClaim(comments);
1871
+ fail(`#${number} is closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1872
+ const claim = latestClaim(comments, author);
1192
1873
  const current = standing(claim, sessions);
1193
1874
  if (claim?.holder === name) {
1194
1875
  console.log(`#${number} is already taken by ${name}`);
@@ -1197,14 +1878,19 @@ async function take(args) {
1197
1878
  if (current.state === "taken" && holderSession(name, sessions)?.pid !== current.session.pid)
1198
1879
  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);
1199
1880
  if (current.state === "away" && values["take-stale"] !== true)
1200
- fail(`#${number} was taken by ${current.claim.holder} ${formatAge(current.claim.at)}, which no session here answers to: it may run on another machine, so pass --take-stale to take it over`, 1);
1881
+ 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"));
1201
1882
  await comment(number, takenComment(name), values.repo);
1883
+ const standingNow = latestClaim(await commentsOf(number, values.repo), author);
1884
+ if (standingNow?.holder !== name)
1885
+ fail(`#${number} was taken by ${standingNow?.holder ?? "another session"} at the same moment`, 1);
1202
1886
  console.log(`took #${number} as ${name}: ${found.title}`);
1203
1887
  });
1204
1888
  }
1205
1889
  var dropUsage = `ft drop <n>...
1206
1890
 
1207
- Gives each issue back with a "Released by session <name>." comment, when this session holds it.`;
1891
+ Gives each issue back with a "Released by session <name>." comment, when this session holds it.
1892
+
1893
+ Exits 0 when every issue is given back, 1 when one is not held by this session, 2 on a usage error.`;
1208
1894
  var dropOptions = {
1209
1895
  as: { type: "string", description: "the name it was taken as, instead of this session's" },
1210
1896
  ...laneOption,
@@ -1215,9 +1901,10 @@ async function drop(args) {
1215
1901
  const { values, positionals } = parsed;
1216
1902
  const numbers = issueNumbers(positionals, dropUsage);
1217
1903
  const nameFor = await claimant(values, await liveSessions());
1904
+ const author = await sessionAuthor();
1218
1905
  return eachIssue("drop", numbers, async (number) => {
1219
1906
  const name = nameFor(number);
1220
- const claim = latestClaim(await commentsOf(number, values.repo));
1907
+ const claim = latestClaim(await commentsOf(number, values.repo), author);
1221
1908
  if (claim?.holder !== name)
1222
1909
  fail(`#${number} is ${claim === undefined ? "not taken" : `taken by ${claim.holder}, not ${name}`}`, 1);
1223
1910
  await comment(number, releasedComment(name), values.repo);
@@ -1225,6 +1912,23 @@ async function drop(args) {
1225
1912
  });
1226
1913
  }
1227
1914
 
1915
+ // src/commands/ci.ts
1916
+ import process12 from "process";
1917
+
1918
+ // src/git.ts
1919
+ async function tagsAt(commit) {
1920
+ return (await git(["tag", "--points-at", commit])).split(`
1921
+ `).filter(Boolean);
1922
+ }
1923
+ async function commitsNaming(issue, ref) {
1924
+ const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
1925
+ return out.split(`
1926
+ `).filter(Boolean).map((line) => {
1927
+ const space = line.indexOf(" ");
1928
+ return { sha: line.slice(0, space), subject: line.slice(space + 1) };
1929
+ });
1930
+ }
1931
+
1228
1932
  // src/log.ts
1229
1933
  var BOUNDARY = /^(?:\((?:pass|fail|skip|todo)\) |::(?:end)?group::|\S+\.test\.[jt]sx?:$)/;
1230
1934
  var DURATION2 = / \[[\d.]+m?s\]$/;
@@ -1280,12 +1984,32 @@ function runSeconds(run) {
1280
1984
  function startedAge(run, now = Date.now()) {
1281
1985
  return Date.parse(run.started) < EPOCH ? "-" : formatAge(run.started, now);
1282
1986
  }
1283
- async function recentRuns(limit, repo, workflow) {
1987
+ var RUNS_PAGE = 50;
1988
+ async function runsListing(limit, repo, workflow) {
1284
1989
  const filter = workflow === undefined ? "" : `&workflow_id=${encodeURIComponent(workflow)}`;
1285
- return (await repoApi(`actions/runs?page=1&limit=${limit}${filter}`, repo)).workflow_runs ?? [];
1990
+ const runs = [];
1991
+ let total = 0;
1992
+ for (let page = 1;runs.length < limit; page++) {
1993
+ const found = await repoApi(`actions/runs?page=${page}&limit=${RUNS_PAGE}${filter}`, repo);
1994
+ const list = found.workflow_runs ?? [];
1995
+ runs.push(...list);
1996
+ total = found.total_count ?? 0;
1997
+ if (list.length < RUNS_PAGE)
1998
+ break;
1999
+ }
2000
+ return { runs: runs.slice(0, limit), total: Math.max(total, runs.length) };
2001
+ }
2002
+ async function recentRuns(limit, repo, workflow) {
2003
+ return (await runsListing(limit, repo, workflow)).runs;
1286
2004
  }
1287
2005
  async function runsOn(sha, repo) {
1288
- return (await repoApi(`actions/runs?head_sha=${sha}`, repo)).workflow_runs ?? [];
2006
+ const runs = [];
2007
+ for (let page = 1;; page++) {
2008
+ const list = (await repoApi(`actions/runs?head_sha=${sha}&page=${page}&limit=${RUNS_PAGE}`, repo)).workflow_runs ?? [];
2009
+ runs.push(...list);
2010
+ if (list.length < RUNS_PAGE)
2011
+ return runs;
2012
+ }
1289
2013
  }
1290
2014
  async function runById(id, repo) {
1291
2015
  return repoApi(`actions/runs/${id}`, repo);
@@ -1371,9 +2095,9 @@ var options = {
1371
2095
  job: { type: "string", short: "j", description: "a job whose skipping means the run left its work to a newer push" },
1372
2096
  timeout: { type: "string", short: "t", description: "how long before giving up (30m), a bare number in minutes" }
1373
2097
  };
1374
- var POLL = 15000;
1375
2098
  var NO_RUN = 3 * 60000;
1376
2099
  var STALLED = 3 * 60000;
2100
+ var MAX_LOOKUPS = 20;
1377
2101
  function judge(runs, jobSkipped, tagged) {
1378
2102
  if (runs.length === 0)
1379
2103
  return { kind: "none" };
@@ -1385,7 +2109,13 @@ function judge(runs, jobSkipped, tagged) {
1385
2109
  return { kind: "runs", runs };
1386
2110
  return tagged ? { kind: "runs", runs: runs.map((run) => ({ ...run, status: "skipped" })) } : { kind: "passed-over" };
1387
2111
  }
1388
- async function ci(args) {
2112
+ function commitsToAsk(commits, passedOver, max = MAX_LOOKUPS) {
2113
+ return commits.filter((commit) => !passedOver.has(commit)).slice(0, max);
2114
+ }
2115
+ function settled(latest) {
2116
+ return latest !== undefined && latest.every((run) => FINISHED.has(run.status)) && judge(latest, false, false).kind === "runs" ? latest : undefined;
2117
+ }
2118
+ async function ci2(args) {
1389
2119
  const parsed = parse(args, usage, options);
1390
2120
  const { values, positionals } = parsed;
1391
2121
  const branch = values.branch ?? await defaultBranch();
@@ -1409,62 +2139,44 @@ async function ci(args) {
1409
2139
  return coverage;
1410
2140
  }
1411
2141
  async function covering() {
1412
- await git(["fetch", "-q", "origin", branch]);
2142
+ await fetchBranch(branch);
1413
2143
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`]))
1414
- fail(`${short} is not on origin/${branch}: push it first`);
2144
+ fail(`${short} is not on origin/${branch}`, 2, runHint(`git push origin ${branch}`));
1415
2145
  const later = (await git(["rev-list", "--reverse", "--first-parent", `${sha}..origin/${branch}`])).split(`
1416
2146
  `).filter(Boolean);
1417
- for (const commit of [sha, ...later]) {
2147
+ for (const commit of commitsToAsk([sha, ...later], passedOver)) {
1418
2148
  const coverage = await coverageOf(commit);
1419
2149
  if (coverage.kind === "runs")
1420
2150
  return coverage.runs;
1421
2151
  }
1422
2152
  return;
1423
2153
  }
1424
- const started = Date.now();
1425
- let seen = started;
1426
- let moving = started;
1427
- let followed = "";
1428
- let runs;
1429
- for (;; ) {
1430
- if (Date.now() - started > giveUp)
1431
- fail(`CI has not finished on ${short} in ${formatDuration(giveUp)}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1432
- try {
1433
- runs = await covering();
1434
- } catch (error) {
1435
- if (error instanceof Exit)
1436
- throw error;
1437
- if (cwdGone())
1438
- fail(`the working directory was removed while waiting for CI on ${short}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1439
- console.warn(`ft ci: ${error instanceof Error ? error.message : String(error)}, polling again`);
1440
- const latest = runs === undefined ? undefined : await refreshed(runs);
1441
- if (latest?.every((run) => FINISHED.has(run.status)) === true) {
1442
- runs = latest;
1443
- break;
1444
- }
1445
- await Bun.sleep(POLL);
1446
- continue;
1447
- }
1448
- if (runs !== undefined) {
1449
- seen = Date.now();
1450
- const ids = runs.map((run) => run.id).join(",");
1451
- if (ids !== followed) {
1452
- followed = ids;
1453
- for (const run of runs)
1454
- 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}`);
1455
- }
1456
- if (runs.every((run) => FINISHED.has(run.status)))
1457
- break;
1458
- const stall = await stallReason(runs);
1459
- if (stall === undefined)
1460
- moving = Date.now();
1461
- else if (stall.certain || Date.now() - moving > STALLED)
1462
- fail(`runs queued on ${short} are not moving: ${stall.reason}`);
1463
- } else if (Date.now() - seen > NO_RUN) {
1464
- fail(`no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`);
1465
- }
1466
- await Bun.sleep(POLL);
1467
- }
2154
+ const { dim } = paint(process12.stderr);
2155
+ const runs = await waitForCi({
2156
+ tool: "ft ci",
2157
+ short,
2158
+ giveUpMs: giveUp,
2159
+ noneMs: NO_RUN,
2160
+ stalledMs: STALLED,
2161
+ noneMessage: `no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`,
2162
+ covering,
2163
+ finished: (found) => found.every((run) => FINISHED.has(run.status)),
2164
+ key: (found) => found.map((run) => run.id).join(","),
2165
+ announce: (found) => {
2166
+ for (const run of found)
2167
+ 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}`);
2168
+ },
2169
+ links: (found) => found.map((run) => run.html_url).join(" "),
2170
+ settle: async (found) => settled(await refreshed(found)),
2171
+ stall: async (found) => {
2172
+ const stall = await stallReason(found);
2173
+ return stall === undefined ? undefined : { message: `runs queued on ${short} are not moving: ${stall.reason}`, certain: stall.certain };
2174
+ },
2175
+ lines: (spinner, found, started) => [
2176
+ `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
2177
+ ...(found ?? []).map((run) => ` ${badge(run.status, process12.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
2178
+ ]
2179
+ });
1468
2180
  const status = overall(runs);
1469
2181
  if (status !== "failure") {
1470
2182
  console.log(`CI ${status === "success" ? "passed" : "skipped its tests, a release tag's workflow testing them"} on ${short}`);
@@ -1480,6 +2192,129 @@ run ${run.id} failed${run.commit_sha === sha ? "" : `, testing every commit from
1480
2192
  return 1;
1481
2193
  }
1482
2194
 
2195
+ // src/commands/digest.ts
2196
+ import process13 from "process";
2197
+ var RUNS_SCANNED = 50;
2198
+ var TITLE_WIDTH2 = 70;
2199
+ var SHOWN = 8;
2200
+ var LINE_START2 = /^/gm;
2201
+ function claimed(issue, claim) {
2202
+ const list = boxes(issue.body);
2203
+ return { number: issue.number, title: issue.title, holder: claim.holder, since: claim.at, boxesLeft: list.filter((box) => !box.done).length, boxesTotal: list.length };
2204
+ }
2205
+ function claims(rows, sessions, me) {
2206
+ const held = [];
2207
+ const away = [];
2208
+ for (const { issue, claim } of rows) {
2209
+ const row = standing(claim, sessions);
2210
+ if (row.state === "taken" && me !== undefined && row.session.pid === me.pid)
2211
+ held.push(claimed(issue, row.claim));
2212
+ else if (row.state === "away")
2213
+ away.push(claimed(issue, row.claim));
2214
+ }
2215
+ return { held, away };
2216
+ }
2217
+ function unsettled(runs, branch) {
2218
+ 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 }));
2219
+ }
2220
+ function dirtyFiles(porcelain) {
2221
+ return porcelain.split(`
2222
+ `).flatMap((line) => {
2223
+ const trimmed = line.trim();
2224
+ const space = trimmed.indexOf(" ");
2225
+ return space === -1 ? [] : [{ status: trimmed.slice(0, space), path: trimmed.slice(space + 1).trim() }];
2226
+ });
2227
+ }
2228
+ async function unpushedCommits() {
2229
+ const base = await gitOk(["rev-parse", "--verify", "-q", "@{upstream}"]) ? "@{upstream}" : `origin/${await defaultBranch()}`;
2230
+ if (!await gitOk(["rev-parse", "--verify", "-q", base]))
2231
+ return [];
2232
+ return (await git(["log", `${base}..HEAD`, "--format=%h %s"])).split(`
2233
+ `).filter(Boolean);
2234
+ }
2235
+ async function dirtyTree() {
2236
+ return dirtyFiles(await git(["status", "--porcelain"]));
2237
+ }
2238
+ async function quietly(read) {
2239
+ try {
2240
+ return await read();
2241
+ } catch {
2242
+ return [];
2243
+ }
2244
+ }
2245
+ async function staleTodos() {
2246
+ const bin = Bun.which("todo-scan", { PATH: process13.env.PATH ?? "" });
2247
+ if (bin === null)
2248
+ return [];
2249
+ let found;
2250
+ try {
2251
+ const ran = await runCommand([bin, "--stale", "--json"]);
2252
+ found = JSON.parse(Buffer.concat(ran.chunks.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data)).toString());
2253
+ } catch {
2254
+ return [];
2255
+ }
2256
+ const byIssue = new Map;
2257
+ for (const mark of found) {
2258
+ for (const ref of mark.refs.filter((each) => each.state === "closed")) {
2259
+ const key = `${ref.repo}#${ref.number}`;
2260
+ const entry = byIssue.get(key) ?? { issue: key, comments: [] };
2261
+ entry.comments.push({ path: mark.path, line: mark.line });
2262
+ byIssue.set(key, entry);
2263
+ }
2264
+ }
2265
+ return [...byIssue.values()];
2266
+ }
2267
+ function claimLine(row, now) {
2268
+ return [`#${row.number}`, row.holder, formatAge(row.since, now), row.boxesTotal === 0 ? "" : `${row.boxesLeft}/${row.boxesTotal} left`, truncate(row.title, TITLE_WIDTH2)];
2269
+ }
2270
+ function capped2(lines) {
2271
+ return lines.length > SHOWN ? [...lines.slice(0, SHOWN), `... ${lines.length - SHOWN} more`] : lines;
2272
+ }
2273
+ function renderDigest(digest, now = Date.now()) {
2274
+ const sections = [
2275
+ { heading: "held by this session", count: digest.held.length, lines: table(digest.held.map((row) => claimLine(row, now))).split(`
2276
+ `) },
2277
+ { heading: "claimed, no live session here", count: digest.away.length, lines: table(digest.away.map((row) => claimLine(row, now))).split(`
2278
+ `) },
2279
+ { 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, `
2280
+ `), TITLE_WIDTH2)}`) },
2281
+ { heading: "unpushed commits", count: digest.unpushed.length, lines: capped2(digest.unpushed) },
2282
+ { heading: "dirty files", count: digest.dirty.length, lines: capped2(digest.dirty.map((file) => `${file.status} ${file.path}`)) },
2283
+ { 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(", ")}`)) }
2284
+ ].filter((section) => section.count > 0);
2285
+ return sections.length === 0 ? "nothing to pick up" : sections.map(({ heading, count, lines }) => `${heading} (${count})
2286
+ ${lines.join(`
2287
+ `).replace(LINE_START2, " ")}`).join(`
2288
+
2289
+ `);
2290
+ }
2291
+ var usage2 = `ft digest
2292
+
2293
+ 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.
2294
+ A section with nothing in it is left out, and the stale TODO comments need todo-scan on the PATH.
2295
+
2296
+ Exits 0 when it summarised the repository, 1 when Forgejo cannot be read, 2 on a usage error.`;
2297
+ var options2 = {
2298
+ 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)"),
2299
+ ...repoOption
2300
+ };
2301
+ async function digest(args) {
2302
+ const parsed = parse(args, usage2, options2);
2303
+ const { values } = parsed;
2304
+ const [issues, sessions, branch, runs] = await Promise.all([openIssues(undefined, values.repo), liveSessions(), defaultBranch(), recentRuns(RUNS_SCANNED, values.repo)]);
2305
+ const comments = await commentsOfAll(issues, values.repo);
2306
+ const me = await currentSession(sessions);
2307
+ const author = await sessionAuthor();
2308
+ const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? [], author) }));
2309
+ const [unpushed, dirty, stale] = await Promise.all([quietly(unpushedCommits), quietly(dirtyTree), staleTodos()]);
2310
+ const summary = { ...claims(rows, sessions, me), ci: unsettled(runs, branch), unpushed, dirty, stale };
2311
+ if (values.json === true)
2312
+ printJson(summary);
2313
+ else
2314
+ console.log(renderDigest(summary));
2315
+ return 0;
2316
+ }
2317
+
1483
2318
  // src/commands/triage.ts
1484
2319
  async function checkLabels(names, repo) {
1485
2320
  if (names.length === 0)
@@ -1513,13 +2348,17 @@ async function setState(args, usage, wanted) {
1513
2348
  }
1514
2349
  var closeUsage = `ft close <n>... [-m <reason>]
1515
2350
 
1516
- Closes issues without a fix, a duplicate or one dropped, posting the -m reason first. \`ft fixed\` closes one a commit fixed.`;
2351
+ Closes issues without a fix, a duplicate or one dropped, posting the -m reason first. \`ft fixed\` closes one a commit fixed.
2352
+
2353
+ Exits 0 when every issue is closed, 1 when Forgejo refuses, 2 on a usage error.`;
1517
2354
  async function closeIssues(args) {
1518
2355
  return setState(args, closeUsage, "closed");
1519
2356
  }
1520
2357
  var reopenUsage = `ft reopen <n>... [-m <reason>]
1521
2358
 
1522
- Reopens closed issues, posting the -m reason first.`;
2359
+ Reopens closed issues, posting the -m reason first.
2360
+
2361
+ Exits 0 when every issue is open, 1 when Forgejo refuses, 2 on a usage error.`;
1523
2362
  async function reopenIssues(args) {
1524
2363
  return setState(args, reopenUsage, "open");
1525
2364
  }
@@ -1544,7 +2383,9 @@ function labelChanges(args) {
1544
2383
  }
1545
2384
  var labelUsage = `ft label <n> [+]<label>... -<label>...
1546
2385
 
1547
- Adds the labels named bare or with +, removes those named with -, then prints the issue's labels. A label the repository lacks is refused.`;
2386
+ Adds the labels named bare or with +, removes those named with -, then prints the issue's labels. A label the repository lacks is refused.
2387
+
2388
+ Exits 0 when it changed the labels, 1 when Forgejo refuses, 2 on a usage error or a label the repository lacks.`;
1548
2389
  async function label(args) {
1549
2390
  const changes = labelChanges(args);
1550
2391
  const parsed = parse(changes.rest, labelUsage, repoOption);
@@ -1570,7 +2411,9 @@ ${labelUsage}`);
1570
2411
  var issueUsage = `ft issue <n>...
1571
2412
 
1572
2413
  Prints each issue: its title, state, boxes left and claim, then its description with each checklist box numbered as \`ft tick\` takes it.
1573
- With --body, prints only the raw description, to edit and give back to \`ft edit -F\`.`;
2414
+ With --body, prints only the raw description, to edit and give back to \`ft edit -F\`.
2415
+
2416
+ Exits 0 when it printed them, 1 when an issue cannot be read, 2 on a usage error.`;
1574
2417
  var issueOptions = {
1575
2418
  todo: { type: "boolean", short: "t", description: "only the header and the boxes left to tick" },
1576
2419
  comments: { type: "boolean", short: "c", description: "add the comments, each with its author and age" },
@@ -1579,7 +2422,7 @@ var issueOptions = {
1579
2422
  ...repoOption
1580
2423
  };
1581
2424
  function renderIssue(found, comments, view, now = Date.now()) {
1582
- const claim = latestClaim(comments);
2425
+ const claim = latestClaim(comments, view.author);
1583
2426
  const list = boxes(found.body);
1584
2427
  const left = list.filter((box) => !box.done);
1585
2428
  const facts = [found.state];
@@ -1607,14 +2450,14 @@ ${each.body.replaceAll(`\r
1607
2450
 
1608
2451
  `);
1609
2452
  }
1610
- function issueJson(found, comments) {
2453
+ function issueJson(found, comments, author) {
1611
2454
  return {
1612
2455
  number: found.number,
1613
2456
  title: found.title,
1614
2457
  state: found.state,
1615
2458
  url: found.html_url,
1616
2459
  labels: found.labels.map((label) => label.name),
1617
- claim: latestClaim(comments) ?? null,
2460
+ claim: latestClaim(comments, author) ?? null,
1618
2461
  boxes: boxes(found.body).map(({ number, done, text }) => ({ number, done, text })),
1619
2462
  body: found.body,
1620
2463
  comments: comments.map((each) => ({ author: each.user.login, at: each.created_at, body: each.body }))
@@ -1634,12 +2477,13 @@ async function issue(args) {
1634
2477
  return 0;
1635
2478
  }
1636
2479
  const read = await Promise.all(numbers.map(async (number) => Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)])));
2480
+ const author = await sessionAuthor();
1637
2481
  if (values.json === true) {
1638
- const json = read.map(([found, comments]) => issueJson(found, comments));
2482
+ const json = read.map(([found, comments]) => issueJson(found, comments, author));
1639
2483
  printJson(json.length === 1 ? json[0] : json);
1640
2484
  return 0;
1641
2485
  }
1642
- const view = { todo: values.todo === true, comments: values.comments === true };
2486
+ const view = { todo: values.todo === true, comments: values.comments === true, author };
1643
2487
  console.log(read.map(([found, comments]) => renderIssue(found, comments, view)).join(view.todo ? `
1644
2488
 
1645
2489
  ` : `
@@ -1658,11 +2502,14 @@ async function noteText(values) {
1658
2502
  const text = values.file === undefined ? values.message : await readInput(values.file);
1659
2503
  return text?.trim() === "" ? undefined : text?.trim();
1660
2504
  }
2505
+ var NOT_FOUND = /not exist|not found|couldn't be found/i;
1661
2506
  async function isClosed(number, repo) {
1662
2507
  try {
1663
2508
  return (await getIssue(number, repo)).state === "closed";
1664
- } catch {
1665
- return false;
2509
+ } catch (error) {
2510
+ if (error instanceof Error && NOT_FOUND.test(error.message))
2511
+ return false;
2512
+ throw error;
1666
2513
  }
1667
2514
  }
1668
2515
  async function closedReferences(body, repo) {
@@ -1670,15 +2517,23 @@ async function closedReferences(body, repo) {
1670
2517
  const states = await Promise.all(listed.map(async (issue) => isClosed(issue, repo)));
1671
2518
  return new Set(listed.filter((_, at) => states[at] === true));
1672
2519
  }
1673
- var newUsage = `ft new -t <title> [-F <file>]
2520
+ var newUsage = `ft new -t <title> [-F <file>] [-L a,b] [-m <milestone>]
1674
2521
 
1675
- 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 the repository lacks is refused before anything is created.`;
2522
+ 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.
2523
+
2524
+ Exits 0 when it created the issue, 1 when Forgejo refuses, 2 on a usage error or a label or milestone the repository lacks.`;
1676
2525
  var newOptions = {
1677
2526
  title: { type: "string", short: "t", description: "the issue title" },
1678
2527
  file: { type: "string", short: "F", description: "the description: a file, - for stdin" },
1679
2528
  labels: { type: "string", short: "L", description: "comma-separated labels" },
2529
+ milestone: { type: "string", short: "m", description: "the milestone, a title or an id; the repository must have it" },
1680
2530
  ...repoOption
1681
2531
  };
2532
+ async function milestoneTitle(reference, repo) {
2533
+ if (reference === undefined)
2534
+ return;
2535
+ return reference.trim() === "" ? "" : (await findMilestone(reference.trim(), repo)).title;
2536
+ }
1682
2537
  async function newIssue(args) {
1683
2538
  const parsed = parse(args, newUsage, newOptions);
1684
2539
  const { values } = parsed;
@@ -1688,20 +2543,25 @@ async function newIssue(args) {
1688
2543
  ${newUsage}`);
1689
2544
  const body = await noteText({ file: values.file });
1690
2545
  await checkLabels((values.labels ?? "").split(",").map((name) => name.trim()).filter((name) => name !== ""), values.repo);
1691
- const created = await createIssue(values.title.trim(), body ?? "", values.labels, values.repo);
2546
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2547
+ const created = await createIssue(values.title.trim(), body ?? "", values.labels, milestone === "" ? undefined : milestone, values.repo);
1692
2548
  console.log(`#${created.number} ${created.html_url}`);
1693
2549
  return 0;
1694
2550
  }
1695
- var editUsage = `ft edit <n> [-t <title>] [-F <file> [--append]] [--add-box <text>]...
2551
+ var editUsage = `ft edit <n> [-t <title>] [-F <file> [--append]] [--add-box <text>]... [-m <milestone>]
1696
2552
 
1697
2553
  Replaces the title and/or the description of issue <n>, the description from -F <file> or from stdin with -F -.
2554
+ -m moves it to a milestone given by title or id, and \`-m ''\` takes it out of its milestone.
1698
2555
  --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.
1699
- \`ft issue <n> --body\` prints the description to edit.`;
2556
+ \`ft issue <n> --body\` prints the description to edit.
2557
+
2558
+ Exits 0 when it edited the issue, 1 when Forgejo refuses, 2 on a usage error.`;
1700
2559
  var editOptions = {
1701
2560
  title: { type: "string", short: "t", description: "the new title" },
1702
2561
  file: { type: "string", short: "F", description: "the new description: a file, - for stdin" },
1703
2562
  append: { type: "boolean", short: "a", description: "add the -F text after the description instead of replacing it" },
1704
2563
  "add-box": { type: "string", multiple: true, description: "add an unticked box after the last one, repeatable" },
2564
+ 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" },
1705
2565
  ...repoOption
1706
2566
  };
1707
2567
  async function edit(args) {
@@ -1714,35 +2574,51 @@ async function edit(args) {
1714
2574
  fail("the description file is empty");
1715
2575
  if (values.append === true && text === undefined)
1716
2576
  fail("--append adds the -F text, give -F");
1717
- if (values.title === undefined && text === undefined && added.length === 0)
1718
- fail(`nothing to change: give -t, -F or --add-box
2577
+ if (values.title === undefined && text === undefined && added.length === 0 && values.milestone === undefined)
2578
+ fail(`nothing to change: give -t, -F, --add-box or -m
1719
2579
 
1720
2580
  ${editUsage}`);
1721
2581
  const change = values.title === undefined ? {} : { title: values.title.trim() };
1722
- if (text !== undefined && values.append !== true)
2582
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2583
+ if (milestone !== undefined)
2584
+ change.milestone = milestone;
2585
+ const grows = text !== undefined && values.append === true || added.length > 0;
2586
+ if (text !== undefined && !grows)
1723
2587
  change.body = text;
1724
- else if (text !== undefined || added.length > 0)
1725
- change.body = (await getIssue(number, values.repo)).body;
1726
- if (text !== undefined && values.append === true)
1727
- change.body = `${(change.body ?? "").trimEnd()}
1728
-
1729
- ${text}`;
1730
- if (added.length > 0)
1731
- change.body = appendBoxes(change.body ?? "", added);
1732
- await editIssue(number, change, values.repo);
2588
+ if (Object.keys(change).length > 0)
2589
+ await editIssue(number, change, values.repo);
2590
+ if (grows) {
2591
+ await editBody(number, (body) => {
2592
+ const appended = text !== undefined && values.append === true && !body.trimEnd().endsWith(text) ? `${body.trimEnd()}
2593
+
2594
+ ${text}` : body;
2595
+ const present = new Set(boxes(appended).map((box) => box.text));
2596
+ const missing = added.filter((box) => !present.has(box));
2597
+ return missing.length > 0 ? appendBoxes(appended, missing) : appended;
2598
+ }, values.repo);
2599
+ }
1733
2600
  console.log(`edited #${number}`);
1734
2601
  return 0;
1735
2602
  }
1736
2603
  var tickUsage = `ft tick <n> <box>...
1737
2604
 
1738
2605
  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.
1739
- With --closed, ticks instead every box opening with \`#<issue>\` whose issue is closed, as a release plan lists them.`;
2606
+ With --closed, ticks instead every box opening with \`#<issue>\` whose issue is closed, as a release plan lists them.
2607
+
2608
+ Exits 0 when it ticked the boxes, 1 when Forgejo refuses, 2 on a usage error or a box matching none or several.`;
1740
2609
  var tickOptions = {
1741
2610
  undo: { type: "boolean", short: "u", description: "untick them instead" },
1742
2611
  closed: { type: "boolean", description: "tick the boxes listing a closed issue, no box to name" },
1743
2612
  ...noteOptions,
1744
2613
  ...repoOption
1745
2614
  };
2615
+ function tickedOrFail(number, run) {
2616
+ try {
2617
+ return run();
2618
+ } catch (error) {
2619
+ fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
2620
+ }
2621
+ }
1746
2622
  async function tick(args) {
1747
2623
  const parsed = parse(args, tickUsage, tickOptions);
1748
2624
  const { values, positionals } = parsed;
@@ -1760,17 +2636,13 @@ ${tickUsage}`);
1760
2636
  const found = await getIssue(number, values.repo);
1761
2637
  const done = values.undo !== true;
1762
2638
  const closed = values.closed === true ? await closedReferences(found.body, values.repo) : undefined;
1763
- let result;
1764
- try {
1765
- result = closed === undefined ? setBoxes(found.body, picks, done) : tickReferences(found.body, (issue) => closed.has(issue));
1766
- } catch (error) {
1767
- fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
1768
- }
2639
+ const apply = (body) => closed === undefined ? setBoxes(body, picks, done) : tickReferences(body, (issue) => closed.has(issue));
2640
+ let result = tickedOrFail(number, () => apply(found.body));
1769
2641
  if (result.changed.length > 0) {
1770
- if (closed === undefined)
1771
- await setBody(number, result.body, values.repo);
1772
- else
1773
- await editBody(number, (body) => tickReferences(body, (issue) => closed.has(issue)).body, values.repo);
2642
+ await editBody(number, (body) => {
2643
+ result = tickedOrFail(number, () => apply(body));
2644
+ return result.body;
2645
+ }, values.repo);
1774
2646
  }
1775
2647
  for (const box of result.changed)
1776
2648
  console.log(`${done ? "ticked" : "unticked"} ${box.number}: ${box.text}`);
@@ -1784,7 +2656,9 @@ ${tickUsage}`);
1784
2656
  }
1785
2657
  var commentUsage = `ft comment <n> [text]
1786
2658
 
1787
- Posts a comment on issue <n>: the text given, -F <file>, or stdin when neither is, so a heredoc needs no escaping.`;
2659
+ Posts a comment on issue <n>: the text given, -F <file>, or stdin when neither is, so a heredoc needs no escaping.
2660
+
2661
+ Exits 0 when it posted the comment, 1 when Forgejo refuses, 2 on a usage error.`;
1788
2662
  var commentOptions = {
1789
2663
  file: noteOptions.file,
1790
2664
  ...repoOption
@@ -1807,7 +2681,9 @@ var fixedUsage = `ft fixed <n> [commit]...
1807
2681
 
1808
2682
  Comments "Fixed in <sha>: <message>" on issue <n> and closes it, naming each commit given as "Fixed in <a>, <b> and <c>".
1809
2683
  Without a commit it takes those on origin's default branch whose message names #<n>, and says which, or HEAD when none does.
1810
- 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.`;
2684
+ 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.
2685
+
2686
+ 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.`;
1811
2687
  var fixedOptions = {
1812
2688
  message: { type: "string", short: "m", description: 'the line after "Fixed in <sha>:"' },
1813
2689
  tick: { type: "boolean", description: "tick the boxes left before closing" },
@@ -1854,18 +2730,20 @@ async function fixed(args) {
1854
2730
  const commits = await fixingCommits(number, positionals.slice(1));
1855
2731
  const found = await getIssue(number, values.repo);
1856
2732
  if (found.state === "closed")
1857
- fail(`#${number} is already closed`);
2733
+ fail(`#${number} is already closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1858
2734
  const left = boxes(found.body).filter((box) => !box.done);
1859
2735
  if (left.length > 0 && values.tick === true) {
1860
- await setBody(number, setBoxes(found.body, left.map((box) => String(box.number)), true).body, values.repo);
2736
+ await editBody(number, (body) => setBoxes(body, boxes(body).filter((box) => !box.done).map((box) => String(box.number)), true).body, values.repo);
1861
2737
  console.log(`ticked ${left.length} box${left.length === 1 ? "" : "es"} of #${number}`);
1862
2738
  } else if (left.length > 0 && values.force !== true) {
1863
- fail(`#${number} has ${left.length} boxes left, tick them, pass --tick or pass --force:
2739
+ fail(`#${number} has ${left.length} boxes left:
1864
2740
  ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
1865
- `)}`);
2741
+ `)}`, 1, `${runHint(`ft fixed ${number} --tick`, "tick them and close")}, or ${runHint(`ft fixed ${number} --force`, "close with them left")}`);
1866
2742
  }
1867
2743
  const shas = joinShas(commits.map((each) => each.sha.slice(0, 7)));
1868
- await comment(number, `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`, values.repo);
2744
+ const line = `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`;
2745
+ if ((await commentsOf(number, values.repo)).at(-1)?.body.trim() !== line.trim())
2746
+ await comment(number, line, values.repo);
1869
2747
  await close(number, values.repo);
1870
2748
  console.log(`closed #${number}: fixed in ${shas}`);
1871
2749
  await tickListings(number, values.repo);
@@ -1873,44 +2751,182 @@ ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
1873
2751
  }
1874
2752
 
1875
2753
  // src/commands/logs.ts
1876
- var usage2 = `ft logs <run id>
2754
+ var usage3 = `ft logs <run id>
1877
2755
 
1878
2756
  Prints what the failed jobs of a run say went wrong: each failed bun test's output and the end of each failed step.
1879
- With no failed job it prints every job's last lines; --full prints whole logs.`;
1880
- var options2 = {
1881
- job: { type: "string", short: "j", description: "only this job, by name" },
2757
+ With no failed job it prints every job's last lines; --full prints whole logs.
2758
+
2759
+ Exits 0 when it printed the logs, 1 when Forgejo cannot be read, 2 on a usage error or a job the run lacks.`;
2760
+ var options3 = {
2761
+ job: { type: "string", short: "j", description: "only this job, by name or id" },
1882
2762
  full: { type: "boolean", short: "f", description: "the whole log, timestamps stripped" },
1883
2763
  ...repoOption
1884
2764
  };
1885
- function pickJobs(jobs, name) {
1886
- if (name !== undefined)
1887
- return jobs.filter((job) => job.name === name);
2765
+ function pickJobs(jobs, wanted) {
2766
+ if (wanted !== undefined)
2767
+ return jobs.filter((job) => job.name === wanted || String(job.id) === wanted);
1888
2768
  const failed = jobs.filter((job) => job.status === "failure");
1889
2769
  return failed.length > 0 ? failed : jobs;
1890
2770
  }
1891
- async function logs(args) {
1892
- const parsed = parse(args, usage2, options2);
2771
+ async function logs2(args) {
2772
+ const parsed = parse(args, usage3, options3);
1893
2773
  const { values, positionals } = parsed;
1894
2774
  if (positionals[0] === undefined)
1895
- fail(`a run id is needed, as \`ft runs\` lists them
1896
-
1897
- ${usage2}`);
1898
- const run = await runById(wholeNumber(positionals[0], "run id", 0), values.repo);
2775
+ fail("a run id is needed", 2, runHint("ft runs", "list them"));
2776
+ const run = await runById(wholeNumber(positionals[0], "<run id>", 0), values.repo);
1899
2777
  const jobs = await jobsOf(run.id, values.repo);
1900
2778
  const picked = pickJobs(jobs, values.job);
1901
2779
  if (picked.length === 0)
1902
- fail(`run ${run.id} has no job ${values.job ?? ""}: ${jobs.map((job) => job.name).join(", ")}`);
1903
- console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref} (${run.commit_sha.slice(0, 7)}): ${run.status}, ${run.html_url}`);
2780
+ 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"));
2781
+ 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}`);
1904
2782
  for (const job of picked) {
1905
2783
  const log = await jobLog(job.id, values.repo);
1906
2784
  console.log(`
1907
- job ${job.name}: ${job.status}
2785
+ job ${job.name}: ${badge(job.status, process.stdout)}
1908
2786
  ${values.full === true ? logLines(log).join(`
1909
2787
  `) : excerpt(log)}`);
1910
2788
  }
1911
2789
  return 0;
1912
2790
  }
1913
2791
 
2792
+ // src/commands/milestone.ts
2793
+ var TITLE_WIDTH3 = 60;
2794
+ var LIST_LIMIT = 30;
2795
+ function counts(milestone) {
2796
+ return `${milestone.open_issues} open, ${milestone.closed_issues} closed`;
2797
+ }
2798
+ var listUsage = `ft milestones
2799
+
2800
+ 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.
2801
+
2802
+ Exits 0 when it listed them, 1 when Forgejo refuses, 2 on a usage error.`;
2803
+ var listOptions = {
2804
+ state: { type: "string", description: "open (the default), closed or all" },
2805
+ ...limitOptions(LIST_LIMIT),
2806
+ json: jsonOption("id, title, state, description, open, closed, due (YYYY-MM-DD or null)"),
2807
+ ...repoOption
2808
+ };
2809
+ async function milestones(args) {
2810
+ const parsed = parse(args, listUsage, listOptions);
2811
+ const { values } = parsed;
2812
+ const state = values.state ?? "open";
2813
+ if (state !== "open" && state !== "closed" && state !== "all")
2814
+ fail(`--state takes open, closed or all, not ${state}`);
2815
+ const limit = limitOf(values, LIST_LIMIT);
2816
+ const found = await listMilestones(state, values.repo);
2817
+ if (values.json === true) {
2818
+ 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 })));
2819
+ return 0;
2820
+ }
2821
+ if (found.length === 0) {
2822
+ console.log(`no ${state === "all" ? "" : `${state} `}milestone`);
2823
+ return 0;
2824
+ }
2825
+ const { shown, footer } = capped(found, limit);
2826
+ const showState = state === "all";
2827
+ 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] : []]);
2828
+ console.log(table(rows));
2829
+ if (footer !== undefined)
2830
+ console.log(footer);
2831
+ return 0;
2832
+ }
2833
+ var newUsage2 = `ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2834
+
2835
+ Creates a milestone through the API and prints its id. Without --due it has no due date.
2836
+
2837
+ Exits 0 when it created the milestone, 1 when Forgejo refuses, 2 on a usage error.`;
2838
+ var newOptions2 = {
2839
+ description: { type: "string", short: "d", description: "the milestone description" },
2840
+ due: { type: "string", description: "the due date, YYYY-MM-DD" },
2841
+ ...repoOption
2842
+ };
2843
+ async function create(args) {
2844
+ const parsed = parse(args, newUsage2, newOptions2);
2845
+ const { values, positionals } = parsed;
2846
+ const title = positionals.join(" ").trim();
2847
+ if (title === "")
2848
+ fail(`give the milestone a title
2849
+
2850
+ ${newUsage2}`);
2851
+ const due = values.due === undefined ? undefined : dueOn(values.due);
2852
+ const created = await createMilestone(title, values.description, due, values.repo);
2853
+ console.log(`milestone ${created.id} ${created.title}${dueDate(created) === "" ? "" : `, due ${dueDate(created)}`}`);
2854
+ return 0;
2855
+ }
2856
+ var closeUsage2 = `ft milestone close <title|id> [--force]
2857
+
2858
+ Closes a milestone, refused while issues are open in it, which it names. --force closes it with them left.
2859
+
2860
+ Exits 0 when it closed the milestone, 1 when issues are open in it or Forgejo refuses, 2 on a usage error.`;
2861
+ var closeOptions = {
2862
+ force: { type: "boolean", description: "close it with issues left open" },
2863
+ ...repoOption
2864
+ };
2865
+ async function closeOne(args) {
2866
+ const parsed = parse(args, closeUsage2, closeOptions);
2867
+ const { values, positionals } = parsed;
2868
+ const reference = positionals.join(" ").trim();
2869
+ if (reference === "")
2870
+ fail(`name the milestone to close
2871
+
2872
+ ${closeUsage2}`);
2873
+ const found = await findMilestone(reference, values.repo);
2874
+ if (found.state === "closed") {
2875
+ console.log(`milestone ${found.title} is already closed`);
2876
+ return 0;
2877
+ }
2878
+ const open = await openIssues(undefined, values.repo, found.id);
2879
+ if (open.length > 0 && values.force !== true)
2880
+ fail(`milestone ${found.title} has ${open.length} open issue${open.length === 1 ? "" : "s"}:
2881
+ ${open.map((issue) => ` #${issue.number} ${truncate(issue.title, TITLE_WIDTH3)}`).join(`
2882
+ `)}`, 1, `${runHint(`ft milestone close ${found.title} --force`, "close it with them open")}, or move them with \`ft edit <n> -m <milestone>\``);
2883
+ await closeMilestone(found.id, values.repo);
2884
+ console.log(`closed milestone ${found.title}`);
2885
+ return 0;
2886
+ }
2887
+ var showUsage = `ft milestone <title|id>
2888
+ ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2889
+ ft milestone close <title|id> [--force]
2890
+
2891
+ 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).
2892
+ \`ft milestone new\` and \`ft milestone close\` have their own --help; a milestone titled new or close is named by its id.
2893
+
2894
+ Exits 0 when it showed the milestone, 1 when it cannot be read, 2 on a usage error or an unknown milestone.`;
2895
+ var showOptions = {
2896
+ ...limitOptions(LIST_LIMIT),
2897
+ json: jsonOption("the milestone (id, title, state, description, open, closed, due) and its open issues as `ft board --json` lists them"),
2898
+ ...repoOption
2899
+ };
2900
+ async function show(args) {
2901
+ const parsed = parse(args, showUsage, showOptions);
2902
+ const { values, positionals } = parsed;
2903
+ const reference = positionals.join(" ").trim();
2904
+ if (reference === "")
2905
+ fail(`name the milestone, or see them all with \`ft milestones\`
2906
+
2907
+ ${showUsage}`);
2908
+ const found = await findMilestone(reference, values.repo);
2909
+ const [issues, sessions] = await Promise.all([openIssues(undefined, values.repo, found.id), liveSessions()]);
2910
+ const rows = await boardRows(issues, sessions, values.repo);
2911
+ if (values.json === true) {
2912
+ 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) });
2913
+ return 0;
2914
+ }
2915
+ const facts = [found.state, counts(found), progress(found), dueDate(found) === "" ? "" : `due ${dueDate(found)}`].filter((fact) => fact !== "");
2916
+ console.log(`milestone ${found.title} (${facts.join(", ")})`);
2917
+ if (found.description.trim() !== "")
2918
+ console.log(found.description.trim());
2919
+ console.log(`
2920
+ ${renderBoard(rows, Date.now(), limitOf(values, LIST_LIMIT))}`);
2921
+ return 0;
2922
+ }
2923
+ async function milestone(args) {
2924
+ const [first, ...rest] = args;
2925
+ if (first === "new")
2926
+ return create(rest);
2927
+ return first === "close" ? closeOne(rest) : show(args);
2928
+ }
2929
+
1914
2930
  // src/plan.ts
1915
2931
  var MARKUP = /^[#*_\s]+|[*_\s]+$/g;
1916
2932
  function heading(line) {
@@ -1969,13 +2985,17 @@ var addUsage = `ft plan add <plan> <n>... [--section "Bugs:"]
1969
2985
 
1970
2986
  Lists each issue <n> as \`- [ ] #<n> <title>\` in the checklist of issue <plan>, skipping those already listed.
1971
2987
  Lines go under --section, created at the end of the body when missing, or else after the checklist's last box.
1972
- The body is read again just before it is written, so an edit made meanwhile is kept.`;
2988
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2989
+
2990
+ Exits 0 when it listed the issues, 1 when Forgejo refuses, 2 on a usage error.`;
1973
2991
  var carryUsage = `ft plan carry <from> <to> [--section "Carried from 1.71.0:"]
1974
2992
 
1975
2993
  Copies the unticked \`#<n>\` lines of issue <from> into issue <to>, skipping those <to> already lists.
1976
2994
  Lines go under --section, created at the end of the body when missing, or else after the checklist's last box.
1977
- The body is read again just before it is written, so an edit made meanwhile is kept.`;
1978
- var options3 = {
2995
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2996
+
2997
+ Exits 0 when it copied the lines, 1 when Forgejo refuses, 2 on a usage error.`;
2998
+ var options4 = {
1979
2999
  section: { type: "string", short: "s", description: 'the heading the lines go under, e.g. "Bugs:"' },
1980
3000
  ...repoOption
1981
3001
  };
@@ -1987,7 +3007,7 @@ var plan = {
1987
3007
  }
1988
3008
  };
1989
3009
  async function add(args) {
1990
- const parsed = parse(args, addUsage, options3);
3010
+ const parsed = parse(args, addUsage, options4);
1991
3011
  const { section, repo } = parsed.values;
1992
3012
  const [target, ...listed] = parsed.positionals;
1993
3013
  const planNumber = issueNumber(target, addUsage);
@@ -2008,7 +3028,7 @@ ${addUsage}`);
2008
3028
  return 0;
2009
3029
  }
2010
3030
  async function carry(args) {
2011
- const parsed = parse(args, carryUsage, options3);
3031
+ const parsed = parse(args, carryUsage, options4);
2012
3032
  const { section, repo } = parsed.values;
2013
3033
  const from = issueNumber(parsed.positionals[0], carryUsage);
2014
3034
  const to = issueNumber(parsed.positionals[1], carryUsage);
@@ -2029,15 +3049,16 @@ function report(plan, added, skipped) {
2029
3049
  }
2030
3050
 
2031
3051
  // src/commands/release.ts
2032
- import { basename as basename2, join as join2, resolve } from "path";
2033
- var {$: $3 } = globalThis.Bun;
3052
+ import { basename as basename2, join as join2, relative, resolve } from "path";
3053
+ import process14 from "process";
3054
+ var {$ } = globalThis.Bun;
2034
3055
 
2035
3056
  // src/commands/watch.ts
2036
- var usage3 = `ft watch [--after <id>]
3057
+ var usage4 = `ft watch [--after <id>]
2037
3058
 
2038
3059
  Waits for runs to finish: those newer than run <id>, or else the ones in progress now.
2039
3060
  Exits 0 when every one passed or was skipped, 1 when one failed or was cancelled, 2 on a timeout or a stalled queue.`;
2040
- var options4 = {
3061
+ var options5 = {
2041
3062
  after: { type: "string", short: "a", description: "watch the runs newer than this run id, as `ft runs --json` gives it" },
2042
3063
  timeout: { type: "string", short: "t", description: "how long before giving up (15m), a bare number in minutes" },
2043
3064
  ...repoOption
@@ -2048,21 +3069,18 @@ var STALLED2 = 3 * 60000;
2048
3069
  async function watchRuns(pick, timeout, repo, idle) {
2049
3070
  const started = Date.now();
2050
3071
  const reported = new Map;
3072
+ const followed = new Set;
3073
+ const failed = new Set;
2051
3074
  let moving = started;
2052
- for (;; ) {
2053
- await Bun.sleep(timing.poll);
2054
- if (Date.now() - started > timeout)
2055
- fail(`the runs did not finish in ${formatDuration(timeout)}`);
2056
- let runs;
2057
- try {
2058
- runs = pick(await recentRuns(LIMIT, repo));
2059
- } catch (error) {
2060
- console.warn(`ft watch: ${error instanceof Error ? error.message : String(error)}, polling again`);
2061
- continue;
2062
- }
3075
+ async function poll() {
3076
+ const newest = await recentRuns(LIMIT, repo);
3077
+ const gone = await Promise.all([...followed].filter((id) => !newest.some((run) => run.id === id)).map(async (id) => runById(id, repo)));
3078
+ const runs = [...pick(newest), ...gone];
3079
+ for (const run of runs)
3080
+ followed.add(run.id);
2063
3081
  for (const run of runs) {
2064
3082
  if (reported.get(run.id) !== run.status)
2065
- console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${run.status}`);
3083
+ console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${badge(run.status, process.stdout)}`);
2066
3084
  reported.set(run.id, run.status);
2067
3085
  }
2068
3086
  if (runs.length === 0) {
@@ -2070,30 +3088,48 @@ async function watchRuns(pick, timeout, repo, idle) {
2070
3088
  console.log("no run started, so nothing was waited for");
2071
3089
  return 0;
2072
3090
  }
2073
- continue;
3091
+ return;
2074
3092
  }
2075
- const status = overall(runs);
2076
- if (status === "failure" || status === "cancelled") {
2077
- for (const run of runs) {
2078
- if (run.status === "failure")
2079
- console.error(`
3093
+ for (const run of runs) {
3094
+ if (run.status === "failure" && !failed.has(run.id)) {
3095
+ console.error(`
2080
3096
  ${await failureReport(run, repo)}
2081
3097
 
2082
3098
  ${run.html_url}`);
3099
+ failed.add(run.id);
2083
3100
  }
2084
- return 1;
2085
3101
  }
2086
- if (runs.every((run) => FINISHED.has(run.status)))
2087
- return 0;
3102
+ if (runs.every((run) => FINISHED.has(run.status))) {
3103
+ const status = overall(runs);
3104
+ return status === "failure" || status === "cancelled" ? 1 : 0;
3105
+ }
2088
3106
  const stall = await stallReason(runs, repo);
2089
3107
  if (stall === undefined)
2090
3108
  moving = Date.now();
2091
3109
  else if (stall.certain || Date.now() - moving > STALLED2)
2092
3110
  fail(`queued runs are not moving: ${stall.reason}`);
3111
+ return;
3112
+ }
3113
+ for (;; ) {
3114
+ await Bun.sleep(timing.poll);
3115
+ if (Date.now() - started > timeout)
3116
+ fail(`the runs did not finish in ${formatDuration(timeout)}`);
3117
+ try {
3118
+ const code = await poll();
3119
+ if (code !== undefined)
3120
+ return code;
3121
+ } catch (error) {
3122
+ if (error instanceof Exit)
3123
+ throw error;
3124
+ const message = error instanceof Error ? error.message : String(error);
3125
+ if (isPermanentFailure(error))
3126
+ fail(message);
3127
+ console.warn(`ft watch: ${message}, polling again`);
3128
+ }
2093
3129
  }
2094
3130
  }
2095
3131
  async function watch(args) {
2096
- const parsed = parse(args, usage3, options4);
3132
+ const parsed = parse(args, usage4, options5);
2097
3133
  const { values } = parsed;
2098
3134
  const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
2099
3135
  if (values.after !== undefined) {
@@ -2109,28 +3145,62 @@ async function watch(args) {
2109
3145
  }
2110
3146
 
2111
3147
  // src/commands/release.ts
2112
- var usage4 = `ft release <patch|minor|major|x.y.z>
3148
+ var usage5 = `ft release <patch|minor|major|x.y.z>
2113
3149
  ft release --tagged
2114
3150
 
2115
3151
  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.
2116
3152
  --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.
2117
3153
  --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).
2118
- A refused push drops the bump commit and its tag, so a retry starts clean.`;
2119
- var options5 = {
3154
+ A refused or interrupted push drops the bump commit and its tag, so a retry starts clean; with --tagged the commit and tag are yours and stay.
3155
+ 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.
3156
+ --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.
3157
+ 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.`;
3158
+ var options6 = {
2120
3159
  tagged: { type: "boolean", description: "push the version commit and tag already at HEAD instead of bumping" },
2121
3160
  package: { type: "string", short: "p", description: "workspace folder to release, a path or a name under packages/" },
2122
3161
  "no-registry": { type: "boolean", description: "do not wait for the registry to serve the new version" },
3162
+ "dry-run": { type: "boolean", description: "print what the release would do, changing nothing" },
2123
3163
  timeout: { type: "string", short: "t", description: "how long to wait for the runs (15m), a bare number in minutes" }
2124
3164
  };
2125
3165
  var LEVELS = new Set(["patch", "minor", "major", "prepatch", "preminor", "premajor", "prerelease"]);
2126
3166
  var VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
3167
+ var PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
3168
+ var LAST_NUMBER = /\d+$/;
2127
3169
  var REGISTRY_TRIES = 12;
2128
3170
  var REGISTRY_POLL = 5000;
3171
+ var limits = { pushMs: 5 * 60000 };
3172
+ var REGISTRY_INFO_MS = 30000;
2129
3173
  var NO_RUN2 = 3 * 60000;
2130
3174
  function taggedVersion(tag, prefix) {
2131
3175
  const version = tag.slice(prefix.length);
2132
3176
  return tag.startsWith(prefix) && VERSION.test(version) ? version : undefined;
2133
3177
  }
3178
+ function nextVersion(current, level) {
3179
+ if (VERSION.test(level))
3180
+ return level;
3181
+ const [, major = "0", minor = "0", patch = "0", pre] = PARTS.exec(current) ?? [];
3182
+ const [x, y, z] = [Number(major), Number(minor), Number(patch)];
3183
+ switch (level) {
3184
+ case "major":
3185
+ return pre !== undefined && y === 0 && z === 0 ? `${x}.0.0` : `${x + 1}.0.0`;
3186
+ case "minor":
3187
+ return pre !== undefined && z === 0 ? `${x}.${y}.0` : `${x}.${y + 1}.0`;
3188
+ case "patch":
3189
+ return pre === undefined ? `${x}.${y}.${z + 1}` : `${x}.${y}.${z}`;
3190
+ case "premajor":
3191
+ return `${x + 1}.0.0-0`;
3192
+ case "preminor":
3193
+ return `${x}.${y + 1}.0-0`;
3194
+ case "prepatch":
3195
+ return `${x}.${y}.${z + 1}-0`;
3196
+ default: {
3197
+ if (pre === undefined)
3198
+ return `${x}.${y}.${z + 1}-0`;
3199
+ const numbered = LAST_NUMBER.exec(pre);
3200
+ return `${x}.${y}.${z}-${numbered === null ? `${pre}.0` : `${pre.slice(0, numbered.index)}${Number(numbered[0]) + 1}`}`;
3201
+ }
3202
+ }
3203
+ }
2134
3204
  async function packageDir(value) {
2135
3205
  for (const dir of [resolve(value), join2(await git(["rev-parse", "--show-toplevel"]), "packages", value)]) {
2136
3206
  if (await Bun.file(join2(dir, "package.json")).exists())
@@ -2142,7 +3212,7 @@ async function commitPackageBump(dir, prefix) {
2142
3212
  const { version } = await Bun.file(join2(dir, "package.json")).json();
2143
3213
  const root = await git(["rev-parse", "--show-toplevel"]);
2144
3214
  const tag = `${prefix}${version}`;
2145
- const lock = await $3`bun install --lockfile-only`.cwd(root).quiet().nothrow();
3215
+ const lock = await $`bun install --lockfile-only`.cwd(root).quiet().nothrow();
2146
3216
  if (lock.exitCode !== 0)
2147
3217
  fail(`bun install --lockfile-only after the bump: ${lock.stderr.toString().trim()}`);
2148
3218
  await git(["add", join2(dir, "package.json"), join2(root, "bun.lock")]);
@@ -2151,8 +3221,14 @@ async function commitPackageBump(dir, prefix) {
2151
3221
  }
2152
3222
  async function waitForRegistry(name, version) {
2153
3223
  for (let attempt = 0;attempt < REGISTRY_TRIES; attempt++) {
2154
- const served = (await $3`bun info ${name} version --no-cache`.quiet().nothrow().text()).trim().split(`
3224
+ let served;
3225
+ try {
3226
+ served = (await runTool("bun info", ["bun", "info", name, "version", "--no-cache"], { timeoutMs: REGISTRY_INFO_MS })).stdout.trim().split(`
2155
3227
  `).at(-1);
3228
+ } catch (error) {
3229
+ if (isPermanentFailure(error))
3230
+ throw error;
3231
+ }
2156
3232
  if (served === version)
2157
3233
  return true;
2158
3234
  await Bun.sleep(REGISTRY_POLL);
@@ -2161,23 +3237,77 @@ async function waitForRegistry(name, version) {
2161
3237
  }
2162
3238
  async function testsPass(sha, branch, timeout) {
2163
3239
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`])) {
2164
- const push = await $3`git push origin ${`${sha}:refs/heads/${branch}`}`.quiet().nothrow();
2165
- if (push.exitCode !== 0)
3240
+ const held = holdSignals();
3241
+ let push;
3242
+ try {
3243
+ push = await runTool("git push", ["git", "push", "origin", `${sha}:refs/heads/${branch}`], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3244
+ } finally {
3245
+ held.release();
3246
+ }
3247
+ if (held.signal !== undefined)
3248
+ fail(`interrupted by ${held.signal} while pushing ${sha.slice(0, 7)}, so nothing was bumped`, exitCodeOf(null, held.signal));
3249
+ if (push.code !== 0)
2166
3250
  fail(`origin refused the push of ${sha.slice(0, 7)}, so nothing was bumped:
2167
- ${push.stderr.toString().trim()}`, 1);
3251
+ ${push.stderr.trim()}`, 1);
2168
3252
  console.log(`pushed ${branch} at ${sha.slice(0, 7)}, waiting for its runs before the bump`);
2169
3253
  }
2170
3254
  return await watchRuns((runs) => newestPerWorkflow(runs.filter((run) => run.commit_sha === sha)), timeout, undefined, NO_RUN2) === 0;
2171
3255
  }
3256
+ async function printPlan({ level, dir, prefix, branch, base, timeout, registry }) {
3257
+ const file = Bun.file(join2(dir ?? ".", "package.json"));
3258
+ const pkg = await file.exists() ? await file.json() : undefined;
3259
+ const name = pkg?.name ?? basename2(dir ?? process14.cwd());
3260
+ const where = dir === undefined ? "" : ` in ${relative(process14.cwd(), dir) || "."}`;
3261
+ const sha = base.slice(0, 7);
3262
+ let version;
3263
+ let bump;
3264
+ if (level === undefined) {
3265
+ const tag = (await tagsAt("HEAD")).find((each) => taggedVersion(each, prefix) !== undefined);
3266
+ if (tag === undefined)
3267
+ fail(`HEAD carries no ${prefix}* tag`);
3268
+ version = tag.slice(prefix.length);
3269
+ bump = `take the version commit at HEAD, tagged ${tag}`;
3270
+ console.log(`would release ${name} ${version}, tagged ${tag}`);
3271
+ } else {
3272
+ if (pkg?.version === undefined)
3273
+ fail(`no version in ${join2(dir ?? ".", "package.json")}`);
3274
+ version = nextVersion(pkg.version, level);
3275
+ 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}`;
3276
+ console.log(`would release ${name} ${pkg.version} -> ${version}, tagged ${prefix}${version}`);
3277
+ }
3278
+ const pushed = await gitOk(["merge-base", "--is-ancestor", base, `origin/${branch}`]);
3279
+ 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"}`;
3280
+ const steps = [
3281
+ pushed ? `wait for the runs on ${sha}, already on origin/${branch}` : `push ${branch} at ${sha}, which origin lacks, then wait for its runs`,
3282
+ bump,
3283
+ `push ${branch} and ${prefix}${version} in one atomic push, then wait for the runs it starts (${formatDuration(timeout)} at most)`,
3284
+ registryStep
3285
+ ];
3286
+ console.log(steps.map((step, i) => ` ${i + 1}. ${step}`).join(`
3287
+ `));
3288
+ console.log("dry run: nothing was pushed, committed or tagged");
3289
+ return 0;
3290
+ }
3291
+ async function rollBack(preBump, tagsBefore) {
3292
+ try {
3293
+ for (const name of await tagsAt("HEAD")) {
3294
+ if (!tagsBefore.has(name))
3295
+ await git(["tag", "-d", name]);
3296
+ }
3297
+ await git(["reset", "--keep", preBump]);
3298
+ } catch (error) {
3299
+ warn(`could not undo the bump, back to ${preBump.slice(0, 7)} by hand`, error instanceof Error ? error.message : String(error));
3300
+ }
3301
+ }
2172
3302
  async function release(args) {
2173
- const parsed = parse(args, usage4, options5);
3303
+ const parsed = parse(args, usage5, options6);
2174
3304
  const { values, positionals } = parsed;
2175
3305
  const level = positionals[0];
2176
3306
  const tagged = values.tagged === true;
2177
3307
  if (tagged === (level !== undefined))
2178
3308
  fail(`give a level or --tagged
2179
3309
 
2180
- ${usage4}`);
3310
+ ${usage5}`);
2181
3311
  if (level !== undefined && !LEVELS.has(level) && !VERSION.test(level))
2182
3312
  fail(`${level} is no level nor x.y.z version`);
2183
3313
  const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
@@ -2186,35 +3316,64 @@ ${usage4}`);
2186
3316
  const branch = await defaultBranch();
2187
3317
  const current = await git(["branch", "--show-current"]);
2188
3318
  if (current !== branch)
2189
- fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`);
3319
+ fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`, 2, runHint(`git switch ${branch}`));
2190
3320
  if (await git(["status", "--porcelain"]) !== "")
2191
- fail("the tree has uncommitted changes: commit or stash them first");
2192
- await git(["fetch", "-q", "origin", branch]);
2193
- if ((await $3`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
2194
- fail(`origin/${branch} has commits HEAD lacks: pull first`);
3321
+ fail("the tree has uncommitted changes", 2, runHint("gst", "see them, then commit them with gc"));
3322
+ await fetchBranch(branch);
3323
+ if ((await $`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
3324
+ fail(`origin/${branch} has commits HEAD lacks`, 2, runHint(`git pull --rebase origin ${branch}`));
2195
3325
  const base = await git(["rev-parse", tagged ? "HEAD~1" : "HEAD"]);
3326
+ if (values["dry-run"] === true)
3327
+ return printPlan({ level, dir, prefix, branch, base, timeout, registry: values["no-registry"] !== true });
2196
3328
  if (!await testsPass(base, branch, timeout))
2197
- fail(`the runs on ${base.slice(0, 7)} failed, so nothing was bumped or tagged: fix them and release again`, 1);
2198
- if (level !== undefined) {
2199
- 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();
2200
- if (bump.exitCode !== 0)
2201
- fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
2202
- if (dir !== undefined)
2203
- await commitPackageBump(dir, prefix);
2204
- }
2205
- const tag = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
2206
- if (tag === undefined)
2207
- fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3329
+ 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"));
3330
+ const preBump = await git(["rev-parse", "HEAD"]);
3331
+ const tagsBefore = new Set(await tagsAt("HEAD"));
2208
3332
  const before = (await recentRuns(1))[0]?.id ?? 0;
2209
- const push = await $3`git push --atomic origin ${`HEAD:refs/heads/${branch}`} ${tag}`.quiet().nothrow();
2210
- if (push.exitCode !== 0) {
2211
- await git(["tag", "-d", tag]);
2212
- await git(["reset", "--keep", "HEAD~1"]);
2213
- fail(`origin refused the push, so ${tag} and its commit were dropped for a clean retry:
2214
- ${push.stderr.toString().trim()}`, 1);
3333
+ const held = holdSignals();
3334
+ const signalOf = () => held.signal;
3335
+ let tag = "";
3336
+ let head = "";
3337
+ let pushed = false;
3338
+ let isPushUnknown = false;
3339
+ try {
3340
+ if (level !== undefined) {
3341
+ const bump = dir === undefined ? await $`bun pm version ${level}`.quiet().nothrow() : await $`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
3342
+ if (bump.exitCode !== 0)
3343
+ fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
3344
+ if (dir !== undefined)
3345
+ await commitPackageBump(dir, prefix);
3346
+ }
3347
+ const found = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
3348
+ if (found === undefined)
3349
+ fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3350
+ tag = found;
3351
+ head = await git(["rev-parse", "HEAD"]);
3352
+ if (held.signal !== undefined)
3353
+ fail(`interrupted by ${held.signal} before the push`, exitCodeOf(null, held.signal));
3354
+ let push;
3355
+ try {
3356
+ push = await runTool("git push", ["git", "push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3357
+ } catch (error) {
3358
+ isPushUnknown = true;
3359
+ fail(`${error instanceof Error ? error.message : String(error)}, so ${tag} and its commit are kept: the push may have landed`, 2, `git ls-remote origin refs/tags/${tag} tells; if it is there, ft release --tagged waits for its runs`);
3360
+ }
3361
+ if (push.code !== 0) {
3362
+ const signal = signalOf();
3363
+ fail(`origin refused the push${signal === undefined ? "" : `, or ${signal} interrupted it`}, so ${level === undefined ? `${tag} and its commit are kept for a retry` : `${tag} and its commit were dropped for a clean retry`}:
3364
+ ${push.stderr.trim()}`, signal === undefined ? 1 : exitCodeOf(null, signal));
3365
+ }
3366
+ pushed = true;
3367
+ } finally {
3368
+ held.release();
3369
+ if (!pushed && !isPushUnknown && level !== undefined)
3370
+ await rollBack(preBump, tagsBefore);
2215
3371
  }
3372
+ const late = signalOf();
3373
+ if (late !== undefined)
3374
+ fail(`interrupted by ${late} after ${tag} was pushed: its runs are not waited for`, exitCodeOf(null, late));
2216
3375
  console.log(`pushed ${branch} and ${tag}, waiting for their runs`);
2217
- const code = await watchRuns((runs) => runs.filter((run) => run.id > before), timeout);
3376
+ const code = await watchRuns((runs) => runs.filter((run) => run.id > before && run.commit_sha === head), timeout, undefined, NO_RUN2);
2218
3377
  if (code !== 0)
2219
3378
  return code;
2220
3379
  const file = Bun.file(join2(dir ?? ".", "package.json"));
@@ -2222,29 +3381,93 @@ ${push.stderr.toString().trim()}`, 1);
2222
3381
  const version = tag.slice(prefix.length);
2223
3382
  if (values["no-registry"] === true || pkg?.name === undefined || pkg.private === true) {
2224
3383
  console.log(`released ${tag}`);
3384
+ await settleMilestone(version, tag);
2225
3385
  return 0;
2226
3386
  }
2227
3387
  if (!await waitForRegistry(pkg.name, version))
2228
3388
  fail(`the runs passed but the registry does not serve ${pkg.name}@${version}: read the publish job with \`ft logs\``, 1);
2229
3389
  console.log(`released ${pkg.name}@${version}`);
3390
+ await settleMilestone(version, tag);
2230
3391
  return 0;
2231
3392
  }
3393
+ async function settleMilestone(version, tag) {
3394
+ try {
3395
+ const names = new Set([version, `v${version}`, tag]);
3396
+ const found = (await listMilestones("open")).find((each) => names.has(each.title));
3397
+ if (found === undefined)
3398
+ return;
3399
+ const [done, left] = await Promise.all([closedIssues(found.id), openIssues(undefined, undefined, found.id)]);
3400
+ console.log(`milestone ${found.title}: ${done.length} closed issue${done.length === 1 ? "" : "s"}${done.length === 0 ? "" : `
3401
+ ${done.map((issue) => ` #${issue.number} ${issue.title}`).join(`
3402
+ `)}`}`);
3403
+ if (left.length > 0)
3404
+ warn(`${left.length} issue${left.length === 1 ? " is" : "s are"} still open in it`, left.map((issue) => `#${issue.number}`).join(" "));
3405
+ await closeMilestone(found.id);
3406
+ console.log(`closed milestone ${found.title}`);
3407
+ } catch (error) {
3408
+ warn(`released, but the milestone ${version} could not be settled`, error instanceof Error ? error.message : String(error));
3409
+ }
3410
+ }
2232
3411
 
2233
3412
  // src/commands/remote.ts
2234
3413
  import { lookup } from "dns/promises";
2235
- var {$: $4 } = globalThis.Bun;
3414
+ import { homedir as homedir3 } from "os";
3415
+ var {$: $2 } = globalThis.Bun;
3416
+
3417
+ // src/config.ts
3418
+ import { homedir as homedir2 } from "os";
3419
+ import { join as join3 } from "path";
3420
+ function settingsPath(home = homedir2()) {
3421
+ return join3(home, ".config", "ft", "config.json");
3422
+ }
3423
+ async function loadSettings(home = homedir2()) {
3424
+ const path = settingsPath(home);
3425
+ const file = Bun.file(path);
3426
+ if (!await file.exists())
3427
+ return {};
3428
+ let raw;
3429
+ try {
3430
+ raw = await file.json();
3431
+ } catch (error) {
3432
+ warn(`${path} is not valid JSON, so its settings are ignored`, error instanceof Error ? error.message : String(error));
3433
+ return {};
3434
+ }
3435
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3436
+ warn(`${path} is not a JSON object, so its settings are ignored`);
3437
+ return {};
3438
+ }
3439
+ const host = "remoteHost" in raw ? raw.remoteHost : undefined;
3440
+ if (host !== undefined && (typeof host !== "string" || host === ""))
3441
+ warn(`remoteHost in ${path} must be a non-empty string, so it is ignored`);
3442
+ return typeof host === "string" && host !== "" ? { remoteHost: host } : {};
3443
+ }
2236
3444
 
2237
3445
  // src/remote.ts
2238
3446
  var SCP_LIKE = /^(?:([^@/]+)@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/;
2239
- var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/;
3447
+ var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::(\d+))?\/(.+?)(?:\.git)?\/?$/;
3448
+ var USERINFO = /^([a-z][a-z\d+.-]*:\/\/)[^@/]*@/i;
3449
+ var DEFAULT_PORT = "22";
2240
3450
  function parseSshRemote(url) {
2241
- const match = SSH_URL.exec(url) ?? (url.includes("://") ? null : SCP_LIKE.exec(url));
2242
- return match === null ? undefined : { user: match[1] ?? "git", host: match[2] ?? "", path: match[3] ?? "" };
3451
+ const ssh = SSH_URL.exec(url);
3452
+ const scp = ssh === null && !url.includes("://") ? SCP_LIKE.exec(url) : null;
3453
+ const match = ssh ?? scp;
3454
+ if (match === null || match[2]?.startsWith("-") === true)
3455
+ return;
3456
+ const login = match[1] ?? "git";
3457
+ const user = login.includes(":") ? login.slice(0, login.indexOf(":")) : login;
3458
+ if (ssh === null)
3459
+ return { user, host: match[2] ?? "", path: match[3] ?? "" };
3460
+ return { user, host: match[2] ?? "", path: match[4] ?? "", ...ssh[3] === undefined ? {} : { port: ssh[3] } };
3461
+ }
3462
+ function redactUrl(url) {
3463
+ return url.replace(USERINFO, "$1");
2243
3464
  }
2244
3465
  function canonicalRemote(url, host, reaches) {
2245
3466
  const remote = parseSshRemote(url);
2246
3467
  if (remote === undefined || remote.host === host || !reaches(remote.host))
2247
3468
  return;
3469
+ if (remote.port !== undefined && remote.port !== DEFAULT_PORT)
3470
+ return `ssh://${remote.user}@${host}:${remote.port}/${remote.path}.git`;
2248
3471
  return `${remote.user}@${host}:${remote.path}.git`;
2249
3472
  }
2250
3473
  async function reachesHost(hostname, host, hostAddresses, addressesOf) {
@@ -2254,17 +3477,20 @@ async function reachesHost(hostname, host, hostAddresses, addressesOf) {
2254
3477
  }
2255
3478
 
2256
3479
  // src/commands/remote.ts
2257
- var usage5 = `ft remote [--fix]
3480
+ var usage6 = `ft remote [--fix]
2258
3481
 
2259
3482
  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\`.
2260
- --fix switches every alias URL to \`git@<host>:<owner>/<repo>.git\` and proves it with \`git ls-remote\`.`;
2261
- var options6 = {
2262
- host: { type: "string", description: "the Forgejo ssh host (git.kevyn.fr)" },
3483
+ --fix switches every alias URL to \`git@<host>:<owner>/<repo>.git\` and proves it with \`git ls-remote\`.
3484
+ The host comes from --host, else \`remoteHost\` in ~/.config/ft/config.json; with neither, it checks nothing.
3485
+
3486
+ 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.`;
3487
+ var options7 = {
3488
+ host: { type: "string", description: "the Forgejo ssh host, instead of remoteHost in ~/.config/ft/config.json" },
2263
3489
  fix: { type: "boolean", description: "rewrite the alias URLs" }
2264
3490
  };
2265
3491
  var HOSTNAME = /^hostname (.+)$/m;
2266
3492
  async function sshHostname(alias) {
2267
- return HOSTNAME.exec(await $4`ssh -G ${alias}`.quiet().nothrow().text())?.[1];
3493
+ return HOSTNAME.exec(await $2`ssh -G -- ${alias}`.quiet().nothrow().text())?.[1];
2268
3494
  }
2269
3495
  async function addresses(name) {
2270
3496
  try {
@@ -2273,15 +3499,26 @@ async function addresses(name) {
2273
3499
  return [];
2274
3500
  }
2275
3501
  }
3502
+ async function restoreOrigin(fetchUrl, pushUrls, changes) {
3503
+ if (changes.some(([from]) => from === fetchUrl))
3504
+ await git(["remote", "set-url", "origin", fetchUrl]);
3505
+ for (const [from, to] of changes) {
3506
+ if (pushUrls.includes(from))
3507
+ await git(["remote", "set-url", "--push", "origin", from, to]);
3508
+ }
3509
+ }
2276
3510
  function originHost(url) {
2277
3511
  return parseSshRemote(url)?.host ?? (URL.canParse(url) ? new URL(url).hostname : undefined);
2278
3512
  }
2279
- async function remote(args) {
2280
- const parsed = parse(args, usage5, options6);
2281
- const { values } = parsed;
2282
- const host = values.host ?? "git.kevyn.fr";
3513
+ async function remote(args, home = homedir3()) {
3514
+ const { values } = parse(args, usage6, options7);
3515
+ const host = values.host ?? (await loadSettings(home)).remoteHost;
3516
+ if (host === undefined) {
3517
+ note(`no Forgejo host to check origin against: pass --host, or set "remoteHost" in ${settingsPath(home)}`);
3518
+ return 0;
3519
+ }
2283
3520
  const fetchUrl = await git(["remote", "get-url", "origin"]);
2284
- const pushUrls = (await $4`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3521
+ const pushUrls = (await $2`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
2285
3522
  `).filter(Boolean);
2286
3523
  const hostAddresses = new Set(await addresses(host));
2287
3524
  const hostnames = new Map;
@@ -2299,15 +3536,15 @@ async function remote(args) {
2299
3536
  if (changes.length === 0) {
2300
3537
  const named = originHost(fetchUrl);
2301
3538
  if (named === host) {
2302
- console.log(`origin already names ${host}: ${fetchUrl}`);
3539
+ console.log(`origin already names ${host}: ${redactUrl(fetchUrl)}`);
2303
3540
  return 0;
2304
3541
  }
2305
3542
  const hostname = named === undefined ? undefined : hostnames.get(named);
2306
- console.log(`origin ${fetchUrl} does not reach ${host}${hostname === undefined || hostname === named ? "" : `: ssh connects ${named} to ${hostname}`}, pass --host if Forgejo lives elsewhere`);
3543
+ console.log(`origin ${redactUrl(fetchUrl)} does not reach ${host}${hostname === undefined || hostname === named ? "" : `: ssh connects ${named} to ${hostname}`}, pass --host if Forgejo lives elsewhere`);
2307
3544
  return 1;
2308
3545
  }
2309
3546
  for (const [from, to] of changes)
2310
- console.log(`${from} -> ${to}`);
3547
+ console.log(`${redactUrl(from)} -> ${redactUrl(to)}`);
2311
3548
  if (values.fix !== true) {
2312
3549
  console.log("run `ft remote --fix` to switch them");
2313
3550
  return 1;
@@ -2319,29 +3556,44 @@ async function remote(args) {
2319
3556
  if (pushUrls.includes(from))
2320
3557
  await git(["remote", "set-url", "--push", "origin", to, from]);
2321
3558
  }
2322
- await git(["ls-remote", "origin", "HEAD"]);
3559
+ try {
3560
+ await git(["ls-remote", "origin", "HEAD"]);
3561
+ } catch (error) {
3562
+ await restoreOrigin(fetchUrl, pushUrls, changes);
3563
+ return fail(`the switched origin does not answer, so the old URLs were put back: ${redactUrl(error instanceof Error ? error.message : String(error))}`, 1);
3564
+ }
2323
3565
  console.log("origin switched, and git ls-remote reaches it");
2324
3566
  return 0;
2325
3567
  }
2326
3568
 
2327
3569
  // src/commands/runs.ts
2328
- var usage6 = `ft runs [--sha <ref>]
3570
+ var usage7 = `ft runs [--sha <ref>]
2329
3571
 
2330
- Lists recent runs with what tea's own listing leaves empty: workflow, ref, duration and age.`;
2331
- var options7 = {
2332
- limit: { type: "string", short: "n", description: "how many runs (10)" },
3572
+ 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).
3573
+
3574
+ Exits 0 when it listed them, 1 when Forgejo or git cannot be read, 2 on a usage error.`;
3575
+ var DEFAULT_LIMIT = 10;
3576
+ var options8 = {
3577
+ limit: { ...limitOptions(DEFAULT_LIMIT).limit, short: "n" },
3578
+ full: limitOptions(DEFAULT_LIMIT).full,
2333
3579
  sha: { type: "string", short: "s", description: "only the runs of this commit" },
2334
3580
  workflow: { type: "string", short: "w", description: "only this workflow file, e.g. test.yml" },
2335
3581
  json: jsonOption(),
2336
3582
  ...repoOption
2337
3583
  };
2338
- var TITLE_WIDTH2 = 70;
3584
+ var TITLE_WIDTH4 = 70;
3585
+ async function onCommit(sha, repo) {
3586
+ const found = await runsOn(await resolveCommit(sha), repo);
3587
+ return { runs: found, total: found.length };
3588
+ }
2339
3589
  async function runs(args) {
2340
- const parsed = parse(args, usage6, options7);
3590
+ const parsed = parse(args, usage7, options8);
2341
3591
  const { values } = parsed;
2342
- const limit = wholeNumber(values.limit, "limit", 10);
2343
- const found = values.sha === undefined ? await recentRuns(limit, values.repo, values.workflow) : await runsOn(await resolveCommit(values.sha), values.repo);
2344
- const list = found.filter((run) => values.workflow === undefined || run.workflow_id === values.workflow).slice(0, limit);
3592
+ const limit = limitOf(values, DEFAULT_LIMIT);
3593
+ const { runs: found, total } = values.sha === undefined ? await runsListing(limit, values.repo, values.workflow) : await onCommit(values.sha, values.repo);
3594
+ const matching = found.filter((run) => values.workflow === undefined || run.workflow_id === values.workflow);
3595
+ const list = matching.slice(0, limit);
3596
+ const more = values.sha === undefined ? total - list.length : matching.length - list.length;
2345
3597
  if (values.json === true) {
2346
3598
  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 })));
2347
3599
  return 0;
@@ -2352,24 +3604,29 @@ async function runs(args) {
2352
3604
  }
2353
3605
  console.log(table([
2354
3606
  ["ID", "STATUS", "WORKFLOW", "REF", "EVENT", "TOOK", "STARTED", "TITLE"],
2355
- ...list.map((run) => [String(run.id), run.status, run.workflow_id, run.prettyref, run.event, run.duration > 0 ? formatSeconds(runSeconds(run)) : "-", startedAge(run), truncate(upTo(run.title, `
2356
- `), TITLE_WIDTH2)])
2357
- ]));
3607
+ ...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, `
3608
+ `), TITLE_WIDTH4)])
3609
+ ], { header: true }));
3610
+ if (more > 0)
3611
+ console.log(moreFooter(more));
2358
3612
  return 0;
2359
3613
  }
2360
3614
 
2361
3615
  // src/cli.ts
2362
- process8.exitCode = await runCli({
3616
+ process15.exitCode = await runCli({
2363
3617
  name: "ft",
2364
3618
  version,
2365
3619
  about: "Forgejo tools over tea, run from a repository whose origin is on Forgejo",
3620
+ replaces: "tea api calls piped through jq, and sleep loops waiting on CI",
3621
+ 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"]],
2366
3622
  commands: {
2367
3623
  runs: { run: runs, summary: "recent runs with workflow, ref, duration and age" },
2368
- ci: { run: ci, summary: "wait for the runs covering a commit, printing what failed" },
3624
+ ci: { run: ci2, summary: "wait for the runs covering a commit, printing what failed" },
2369
3625
  watch: { run: watch, summary: "wait for runs newer than an id, or those in progress" },
2370
- logs: { run: logs, summary: "what the failed jobs of a run say went wrong" },
3626
+ logs: { run: logs2, summary: "what the failed jobs of a run say went wrong" },
2371
3627
  release: { run: release, summary: "bump, push branch and tag atomically, wait for runs and registry" },
2372
3628
  board: { run: board, summary: "open issues: available, taken by a live session, or claimed by one gone" },
3629
+ digest: { run: digest, summary: "repo state for a cold start: held and abandoned claims, red CI, unpushed commits, dirty files, stale TODOs" },
2373
3630
  take: { run: take, summary: "claim an issue for this Claude Code session" },
2374
3631
  drop: { run: drop, summary: "give back an issue this session took" },
2375
3632
  issue: { run: issue, summary: "an issue's claim, description and numbered boxes, -c comments" },
@@ -2382,6 +3639,8 @@ process8.exitCode = await runCli({
2382
3639
  reopen: { run: reopenIssues, summary: "reopen closed issues, -m posting the reason" },
2383
3640
  label: { run: label, summary: "add +name and remove -name labels of an issue" },
2384
3641
  plan,
3642
+ milestones: { run: milestones, summary: "milestones with their open and closed issues, progress and due date" },
3643
+ milestone: { run: milestone, summary: "show a milestone's issues like the board, `new` creates one, `close` closes it" },
2385
3644
  remote: { run: remote, summary: "check or fix an origin using an ssh alias tea cannot match" }
2386
3645
  },
2387
3646
  aliases: { show: "issue", view: "issue" }