@kaynooo/forgejo-tools 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +9 -6
  3. package/dist/cli.js +1067 -187
  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 process14 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)));
@@ -409,14 +437,40 @@ class Completing {
409
437
  var completion = { active: false };
410
438
  var OPTIONAL = /\[[^\]]+\]/g;
411
439
  var USAGE_PREFIX = /^usage:\s*/i;
412
- function usageLine(usage, colors = paint(process3.stdout)) {
440
+ var FULL_STOP = /\.$/;
441
+ function usageLine(usage, colors = paint(process4.stdout)) {
413
442
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
414
443
  }
415
- function helpText(usage, options, colors = paint(process3.stdout)) {
416
- return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors)].join(`
444
+ function helpText(usage, options, colors = paint(process4.stdout), { examples = [], replaces } = {}) {
445
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
417
446
  `);
418
447
  }
419
- function optionRows(options, colors = paint(process3.stdout)) {
448
+ function replacesRows(replaces, colors = paint(process4.stdout)) {
449
+ return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
450
+ }
451
+ function wantsHelpJson(env = process4.env) {
452
+ return env.KIT_HELP === "json";
453
+ }
454
+ function description(usage, { examples = [], replaces } = {}) {
455
+ const lines = usage.replace(USAGE_PREFIX, "").split(`
456
+ `);
457
+ const first = lines[0];
458
+ const colon = first.indexOf(": ");
459
+ const prose = lines[lines.indexOf("") + 1] ?? "";
460
+ const fallback = lines.includes("") ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, "") : "";
461
+ return {
462
+ synopsis: colon === -1 ? first : first.slice(0, colon),
463
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
464
+ ...replaces === undefined ? {} : { replaces },
465
+ examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
466
+ };
467
+ }
468
+ function exampleRows(examples, colors = paint(process4.stdout)) {
469
+ if (examples.length === 0)
470
+ return [];
471
+ return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
472
+ }
473
+ function optionRows(options, colors = paint(process4.stdout)) {
420
474
  const rows = Object.entries(options).map(([name, option]) => {
421
475
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
422
476
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
@@ -425,8 +479,27 @@ function optionRows(options, colors = paint(process3.stdout)) {
425
479
  const width = Math.max(0, ...rows.map((row) => row.width));
426
480
  return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
427
481
  }
482
+ var OUTPUT_OPTIONS = {
483
+ color: { type: "string", description: "auto (on a terminal alone), always or never" },
484
+ plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
485
+ };
486
+ function applyOutput(color, plain, env = process4.env) {
487
+ const when = plain === true ? "never" : color ?? "auto";
488
+ if (when === "auto")
489
+ return;
490
+ if (when !== "always" && when !== "never")
491
+ badUsage(`--color takes auto, always or never, not ${when}`);
492
+ if (when === "always") {
493
+ env.FORCE_COLOR = "1";
494
+ delete env.NO_COLOR;
495
+ } else {
496
+ env.NO_COLOR = "1";
497
+ delete env.FORCE_COLOR;
498
+ }
499
+ }
428
500
  function parse(args, usage, options, settings = {}) {
429
- const all = { ...options, help: { type: "boolean", short: "h", description: "show this help" } };
501
+ const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
502
+ const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
430
503
  if (completion.active)
431
504
  throw new Completing(all);
432
505
  const separator = settings.rest === true ? args.indexOf("--") : -1;
@@ -434,12 +507,14 @@ function parse(args, usage, options, settings = {}) {
434
507
  const rest = separator === -1 ? undefined : args.slice(separator + 1);
435
508
  let parsed;
436
509
  try {
437
- parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
510
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
438
511
  } catch (error) {
439
512
  badUsage(parseError(error));
440
513
  }
441
- if (parsed.values.help === true) {
442
- console.log(helpText(usage, all));
514
+ const values = parsed.values;
515
+ applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
516
+ if (values.help === true) {
517
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
443
518
  throw new HelpShown;
444
519
  }
445
520
  if (settings.positionals !== undefined)
@@ -454,6 +529,25 @@ function checkPositionals(given, names) {
454
529
  if (!variadic && given.length > names.length)
455
530
  badUsage(`unexpected argument ${given[names.length]}`);
456
531
  }
532
+ function joinDashValues(args, options) {
533
+ const longOf = new Map(Object.entries(options).flatMap(([name, option]) => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]));
534
+ const isOption = (arg) => arg === "--" || longOf.has(arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg);
535
+ const joined = [];
536
+ for (let i = 0;i < args.length; i++) {
537
+ const arg = args[i] ?? "";
538
+ if (arg === "--")
539
+ return [...joined, ...args.slice(i)];
540
+ const name = longOf.get(arg);
541
+ const next = args[i + 1];
542
+ if (name !== undefined && options[name]?.type === "string" && next !== undefined && next.startsWith("-") && next !== "-" && !isOption(next)) {
543
+ joined.push(`--${name}=${next}`);
544
+ i++;
545
+ } else {
546
+ joined.push(arg);
547
+ }
548
+ }
549
+ return joined;
550
+ }
457
551
  function parseError(error) {
458
552
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
459
553
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
@@ -479,7 +573,7 @@ function duration(value, name, fallback, unit = "s") {
479
573
  }
480
574
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
481
575
  if (path === "-") {
482
- if (process3.stdin.isTTY)
576
+ if (process4.stdin.isTTY)
483
577
  fail(noStdin);
484
578
  return Bun.stdin.text();
485
579
  }
@@ -489,7 +583,7 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
489
583
  return file.text();
490
584
  }
491
585
  // ../kit/src/cli.ts
492
- import process4 from "process";
586
+ import process5 from "process";
493
587
 
494
588
  // ../kit/src/text.ts
495
589
  var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
@@ -505,6 +599,37 @@ function distance(a, b) {
505
599
  }
506
600
 
507
601
  // ../kit/src/cli.ts
602
+ var LEVEL_OPTIONS = {
603
+ ...OUTPUT_OPTIONS,
604
+ help: { type: "boolean", short: "h", description: "show this help" }
605
+ };
606
+ var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
607
+ function commandRows(commands, colors) {
608
+ const width = Math.max(...Object.keys(commands).map((name) => name.length));
609
+ const groups = new Map([["Commands:", []]]);
610
+ for (const [name, { summary, group }] of Object.entries(commands)) {
611
+ const heading = group === undefined ? "Commands:" : `${group}:`;
612
+ const rows = groups.get(heading) ?? [];
613
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
614
+ groups.set(heading, rows);
615
+ }
616
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
617
+ }
618
+ function takeOutputFlags(argv) {
619
+ let index = 0;
620
+ for (;index < argv.length; index++) {
621
+ const word = argv[index];
622
+ if (word === "--plain")
623
+ applyOutput(undefined, true);
624
+ else if (word.startsWith("--color="))
625
+ applyOutput(word.slice("--color=".length), undefined);
626
+ else if (word === "--color" && argv[index + 1] !== undefined)
627
+ applyOutput(argv[++index], undefined);
628
+ else
629
+ break;
630
+ }
631
+ return argv.slice(index);
632
+ }
508
633
  function closest(name, names) {
509
634
  const started = names.find((known) => known.startsWith(name));
510
635
  if (started !== undefined)
@@ -512,51 +637,78 @@ function closest(name, names) {
512
637
  const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
513
638
  return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
514
639
  }
515
- async function help({ path, header, commands, defaultCommand }, stream) {
640
+ async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
516
641
  const colors = paint(stream);
517
- const width = Math.max(...Object.keys(commands).map((name) => name.length));
518
642
  const prefix = path.join(" ");
643
+ if (wantsHelpJson())
644
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
519
645
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
520
646
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
647
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
521
648
  return [
522
649
  header(colors),
523
650
  "",
524
651
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
652
+ ...replacesRows(replaces, colors),
525
653
  "",
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)],
654
+ ...commandRows(commands, colors),
655
+ ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
656
+ "",
657
+ colors.bold("Options:"),
658
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
659
+ ...exampleRows(examples ?? [], colors),
529
660
  "",
530
661
  `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
531
662
  ].join(`
532
663
  `);
533
664
  }
534
665
  function printError(prefix, message, hint, cause) {
535
- const colors = paint(process4.stderr);
536
- const styled = takesControlCodes(process4.stderr);
666
+ const colors = paint(process5.stderr);
667
+ const styled = takesControlCodes(process5.stderr);
537
668
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
538
669
  const under = (label, text, tint) => text === undefined ? "" : `
539
670
  ${styled ? tint(label) : `${label}:`} ${text}`;
540
671
  console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
541
672
  }
542
- function reportThrown(prefix, error, redact = (text) => text) {
673
+ function runHint(command, effect) {
674
+ return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
675
+ }
676
+ function printHint(hint) {
677
+ const colors = paint(process5.stderr);
678
+ console.error(` ${takesControlCodes(process5.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
679
+ }
680
+ function printJsonError(message, hint, cause) {
681
+ console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
682
+ }
683
+ function wantsJson(argv) {
684
+ const end = argv.indexOf("--");
685
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
686
+ }
687
+ function reportThrown(prefix, error, redact = (text) => text, json = false) {
543
688
  if (error instanceof HelpShown)
544
689
  return 0;
545
690
  const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
546
691
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
547
692
  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);
693
+ const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
694
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
549
695
  return error instanceof Exit ? error.code : 1;
550
696
  }
551
- async function dispatch(level, argv, { version, redact }) {
552
- const [name, ...args] = argv;
697
+ async function dispatch(level, typed, { version, redact }) {
553
698
  const prefix = level.path.join(" ");
699
+ let argv;
700
+ try {
701
+ argv = takeOutputFlags(typed);
702
+ } catch (error) {
703
+ return reportThrown(prefix, error);
704
+ }
705
+ const [name, ...args] = argv;
554
706
  if (name === undefined && level.path.length > 1) {
555
- console.error(await help(level, process4.stderr));
707
+ console.error(await help(level, process5.stderr));
556
708
  return 2;
557
709
  }
558
710
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
559
- console.log(await help(level, process4.stdout));
711
+ console.log(await help(level, process5.stdout));
560
712
  return 0;
561
713
  }
562
714
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -576,12 +728,12 @@ async function dispatch(level, argv, { version, redact }) {
576
728
  }
577
729
  if ("commands" in command) {
578
730
  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 });
731
+ return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
580
732
  }
581
733
  try {
582
734
  return await command.run(rest);
583
735
  } catch (error) {
584
- return reportThrown(`${prefix} ${name}`, error, redact);
736
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
585
737
  }
586
738
  }
587
739
  var COMPLETE = "__complete";
@@ -619,13 +771,88 @@ async function printCandidates(found) {
619
771
  console.log(line);
620
772
  return 0;
621
773
  }
622
- async function runCli(cli, argv = process4.argv.slice(2)) {
774
+ async function runCli(cli, argv = process5.argv.slice(2)) {
623
775
  if (argv[0] === COMPLETE)
624
776
  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 });
777
+ return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
778
+ }
779
+ // ../kit/src/limit.ts
780
+ function limitOptions(fallback) {
781
+ return {
782
+ limit: { type: "string", description: `show at most this many rows, the rest counted (${fallback})` },
783
+ full: { type: "boolean", description: "show every row" }
784
+ };
785
+ }
786
+ function limitOf(values, fallback) {
787
+ if (values.full === true && values.limit !== undefined)
788
+ badUsage("--limit and --full exclude each other");
789
+ if (values.full === true || values.json === true && values.limit === undefined)
790
+ return Number.POSITIVE_INFINITY;
791
+ return wholeNumber(values.limit, "limit", fallback);
792
+ }
793
+ function moreFooter(more) {
794
+ return `${more} more, rerun with --full`;
795
+ }
796
+ function capped(rows, limit) {
797
+ const more = rows.length - limit;
798
+ return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
799
+ }
800
+ // ../kit/src/process.ts
801
+ import { constants } from "os";
802
+ import process6 from "process";
803
+ var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
804
+ async function collect(stream, name, chunks) {
805
+ for await (const data of stream)
806
+ chunks.push({ stream: name, data });
807
+ }
808
+ function exitCodeOf(code, signal) {
809
+ if (code !== null)
810
+ return code;
811
+ if (typeof signal === "number")
812
+ return 128 + signal;
813
+ return signal === null ? 129 : 128 + constants.signals[signal];
814
+ }
815
+ function holdSignals(cleanup) {
816
+ let received;
817
+ let child;
818
+ const held = {
819
+ get signal() {
820
+ return received;
821
+ },
822
+ forward: (to) => {
823
+ child = to;
824
+ },
825
+ release: () => {
826
+ for (const signal of SIGNALS)
827
+ process6.off(signal, onSignal);
828
+ }
829
+ };
830
+ function onSignal(signal) {
831
+ received = signal;
832
+ if (child !== undefined) {
833
+ child.kill(signal);
834
+ return;
835
+ }
836
+ if (cleanup === undefined)
837
+ return;
838
+ held.release();
839
+ cleanup(signal);
840
+ process6.kill(process6.pid, signal);
841
+ }
842
+ for (const signal of SIGNALS)
843
+ process6.on(signal, onSignal);
844
+ return held;
845
+ }
846
+ async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
847
+ const chunks = [];
848
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe" });
849
+ await Promise.all([collect(proc.stdout, "stdout", chunks), collect(proc.stderr, "stderr", chunks)]);
850
+ await proc.exited;
851
+ const output = Buffer.concat(chunks.map(({ data }) => data));
852
+ return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
626
853
  }
627
854
  // package.json
628
- var version = "0.5.1";
855
+ var version = "0.6.0";
629
856
 
630
857
  // src/args.ts
631
858
  var repoOption = {
@@ -776,7 +1003,7 @@ var {$: $2 } = globalThis.Bun;
776
1003
 
777
1004
  // src/git.ts
778
1005
  import { basename, dirname } from "path";
779
- import process6 from "process";
1006
+ import process8 from "process";
780
1007
  var {$ } = globalThis.Bun;
781
1008
  var ORIGIN_PREFIX = /^origin\//;
782
1009
  function spawnFailure(command, error) {
@@ -820,7 +1047,7 @@ async function commitsNaming(issue, ref) {
820
1047
  }
821
1048
  function cwdGone() {
822
1049
  try {
823
- process6.cwd();
1050
+ process8.cwd();
824
1051
  return false;
825
1052
  } catch {
826
1053
  return true;
@@ -829,7 +1056,7 @@ function cwdGone() {
829
1056
  async function leaveWorktree() {
830
1057
  const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
831
1058
  if (basename(common) === ".git")
832
- process6.chdir(dirname(common));
1059
+ process8.chdir(dirname(common));
833
1060
  }
834
1061
 
835
1062
  // src/tea.ts
@@ -865,6 +1092,19 @@ async function api(path, repo) {
865
1092
  async function repoApi(path, repo) {
866
1093
  return api(`repos/{owner}/{repo}/${path}`, repo);
867
1094
  }
1095
+ async function repoWrite(method, path, fields, repo) {
1096
+ const full = `repos/{owner}/{repo}/${path}`;
1097
+ const flags = Object.entries(fields).flatMap(([name, value]) => ["-f", `${name}=${value}`]);
1098
+ let result;
1099
+ try {
1100
+ result = await $2`tea api ${repoFlag(repo)} -X ${method} ${flags} ${full}`.quiet().nothrow();
1101
+ } catch (error) {
1102
+ throw spawnFailure(`tea api ${full}`, error);
1103
+ }
1104
+ if (result.exitCode !== 0)
1105
+ throw new Error(`tea api ${full}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1106
+ return parseJson(full, result.stdout.toString());
1107
+ }
868
1108
  async function repoApiText(path, repo) {
869
1109
  return request(`repos/{owner}/{repo}/${path}`, repo);
870
1110
  }
@@ -886,16 +1126,21 @@ var CONCURRENCY = 8;
886
1126
  async function getIssue(number, repo) {
887
1127
  return repoApi(`issues/${number}`, repo);
888
1128
  }
889
- async function openIssues(labels, repo) {
1129
+ async function listIssues(state, filter, repo) {
890
1130
  const issues = [];
891
- const filter = labels === undefined ? "" : `&labels=${encodeURIComponent(labels)}`;
892
1131
  for (let page = 1;; page++) {
893
- const batch = await repoApi(`issues?state=open&type=issues&page=${page}&limit=${PAGE}${filter}`, repo);
1132
+ const batch = await repoApi(`issues?state=${state}&type=issues&page=${page}&limit=${PAGE}${filter}`, repo);
894
1133
  issues.push(...batch);
895
1134
  if (batch.length < PAGE)
896
1135
  return issues;
897
1136
  }
898
1137
  }
1138
+ async function openIssues(labels, repo, milestone) {
1139
+ return listIssues("open", `${labels === undefined ? "" : `&labels=${encodeURIComponent(labels)}`}${milestone === undefined ? "" : `&milestones=${milestone}`}`, repo);
1140
+ }
1141
+ async function closedIssues(milestone, repo) {
1142
+ return listIssues("closed", `&milestones=${milestone}`, repo);
1143
+ }
899
1144
  async function commentsOf(number, repo) {
900
1145
  return repoApi(`issues/${number}/comments`, repo);
901
1146
  }
@@ -915,15 +1160,19 @@ async function editIssue(number, change, repo) {
915
1160
  args.push("-t", change.title);
916
1161
  if (change.body !== undefined)
917
1162
  args.push("-d", change.body);
1163
+ if (change.milestone !== undefined)
1164
+ args.push("-m", change.milestone);
918
1165
  await tea(args, repo);
919
1166
  }
920
1167
  async function setBody(number, body, repo) {
921
1168
  await editIssue(number, { body }, repo);
922
1169
  }
923
- async function createIssue(title, body, labels, repo) {
1170
+ async function createIssue(title, body, labels, milestone, repo) {
924
1171
  const args = ["issues", "create", "-t", title, "-d", body];
925
1172
  if (labels !== undefined)
926
1173
  args.push("-L", labels);
1174
+ if (milestone !== undefined)
1175
+ args.push("-m", milestone);
927
1176
  await tea(args, repo);
928
1177
  const created = (await openIssues(undefined, repo)).filter((found) => found.title === title).sort((a, b) => b.number - a.number)[0];
929
1178
  if (created === undefined)
@@ -965,18 +1214,66 @@ async function editLabels(number, add, remove, repo) {
965
1214
  await tea(args, repo);
966
1215
  }
967
1216
 
1217
+ // src/milestones.ts
1218
+ var PAGE2 = 50;
1219
+ var DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
1220
+ var NO_DUE_YEAR = 9999;
1221
+ async function listMilestones(state, repo) {
1222
+ const found = [];
1223
+ for (let page = 1;; page++) {
1224
+ const batch = await repoApi(`milestones?state=${state}&page=${page}&limit=${PAGE2}`, repo);
1225
+ found.push(...batch);
1226
+ if (batch.length < PAGE2)
1227
+ return found;
1228
+ }
1229
+ }
1230
+ async function findMilestone(reference, repo) {
1231
+ const all = await listMilestones("all", repo);
1232
+ const found = all.find((each) => each.title === reference) ?? all.find((each) => String(each.id) === reference);
1233
+ if (found === undefined)
1234
+ fail(`no milestone ${reference}, ${all.length === 0 ? "the repository has none" : `it has: ${all.map((each) => each.title).join(", ")}`}`, 2, runHint(`ft milestone new ${reference}`, "create it"));
1235
+ return found;
1236
+ }
1237
+ function dueOn(value) {
1238
+ const match = DATE.exec(value);
1239
+ const date = new Date(`${value}T00:00:00Z`);
1240
+ if (match === null || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value || date.getUTCFullYear() < 2000)
1241
+ fail(`--due takes a date as YYYY-MM-DD, not ${value}`);
1242
+ return date.toISOString();
1243
+ }
1244
+ function dueDate(milestone) {
1245
+ if (milestone.due_on === null || new Date(milestone.due_on).getUTCFullYear() >= NO_DUE_YEAR)
1246
+ return "";
1247
+ return milestone.due_on.slice(0, 10);
1248
+ }
1249
+ function progress(milestone) {
1250
+ const total = milestone.open_issues + milestone.closed_issues;
1251
+ return total === 0 ? "" : `${Math.round(milestone.closed_issues / total * 100)}%`;
1252
+ }
1253
+ async function createMilestone(title, description, due, repo) {
1254
+ const fields = { title };
1255
+ if (description !== undefined)
1256
+ fields.description = description;
1257
+ if (due !== undefined)
1258
+ fields.due_on = due;
1259
+ return repoWrite("POST", "milestones", fields, repo);
1260
+ }
1261
+ async function closeMilestone(id, repo) {
1262
+ await repoWrite("PATCH", `milestones/${id}`, { state: "closed" }, repo);
1263
+ }
1264
+
968
1265
  // src/sessions.ts
969
1266
  import { homedir } from "os";
970
1267
  import { join } from "path";
971
- import process7 from "process";
1268
+ import process9 from "process";
972
1269
  var {Glob } = globalThis.Bun;
973
1270
  var SESSION_FILES = new Glob("*.json");
974
1271
  var ACCOUNT_DIRS = new Glob("*/sessions");
975
1272
  async function sessionDirs() {
976
1273
  const home = homedir();
977
1274
  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"));
1275
+ if (process9.env.CLAUDE_CONFIG_DIR !== undefined)
1276
+ dirs.add(join(process9.env.CLAUDE_CONFIG_DIR, "sessions"));
980
1277
  try {
981
1278
  for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
982
1279
  dirs.add(dir);
@@ -1022,7 +1319,7 @@ async function liveSessions() {
1022
1319
  }
1023
1320
  async function currentSession(sessions) {
1024
1321
  const byPid = new Map(sessions.map((session) => [session.pid, session]));
1025
- let pid = process7.ppid;
1322
+ let pid = process9.ppid;
1026
1323
  for (let depth = 0;depth < 32 && pid > 1; depth++) {
1027
1324
  const session = byPid.get(pid);
1028
1325
  if (session !== undefined)
@@ -1061,18 +1358,20 @@ function noteLine(comments, now) {
1061
1358
  `).map((line) => line.trim()).find((line) => line !== "");
1062
1359
  return note === undefined || first === undefined ? [] : [` ${note.user.login} ${formatAge(note.created_at, now)}: ${truncate(first, NOTE_WIDTH)}`];
1063
1360
  }
1064
- function renderBoard(rows, now = Date.now()) {
1361
+ function within(rows, limit, cells, decorate = (line) => [line]) {
1362
+ const { shown, footer } = capped(rows, limit);
1363
+ return { lines: [...table(shown.map(cells)).split(`
1364
+ `).flatMap(decorate), ...footer === undefined ? [] : [footer]] };
1365
+ }
1366
+ function renderBoard(rows, now = Date.now(), limit = Number.POSITIVE_INFINITY) {
1065
1367
  const available = rows.filter((row) => row.standing.state === "available");
1066
1368
  const claimed = (state) => rows.flatMap(({ issue, standing: row, comments }) => row.state === state ? [{ issue, row, comments }] : []);
1067
1369
  const taken = claimed("taken");
1068
1370
  const away = claimed("away");
1069
1371
  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)]) }
1372
+ { heading: "available", count: available.length, ...within(available, limit, ({ issue }) => [`#${issue.number}`, truncate(issue.title, TITLE_WIDTH), issue.labels.map((label) => label.name).join(", ")]) },
1373
+ { heading: "taken", count: taken.length, ...within(taken, limit, ({ issue, row }) => claimRow(issue, row, now)) },
1374
+ { heading: "claimed, no live session here", count: away.length, ...within(away, limit, ({ issue, row }) => claimRow(issue, row, now), (line, index) => [line, ...noteLine(away[index]?.comments ?? [], now)]) }
1076
1375
  ].filter((section) => section.count > 0);
1077
1376
  return sections.length === 0 ? "no open issue" : sections.map(({ heading, count, lines }) => `${heading} (${count})
1078
1377
  ${lines.join(`
@@ -1080,15 +1379,45 @@ ${lines.join(`
1080
1379
 
1081
1380
  `);
1082
1381
  }
1382
+ async function boardRows(issues, sessions, repo) {
1383
+ const comments = await commentsOfAll(issues, repo);
1384
+ return issues.map((issue) => {
1385
+ const own = comments.get(issue.number) ?? [];
1386
+ return { issue, standing: standing(latestClaim(own), sessions), comments: own };
1387
+ });
1388
+ }
1389
+ function boardJson(rows) {
1390
+ return rows.map(({ issue, standing: { state, ...rest }, comments: own }) => {
1391
+ const list = boxes(issue.body);
1392
+ const note = latestNote(own);
1393
+ return {
1394
+ number: issue.number,
1395
+ title: issue.title,
1396
+ labels: issue.labels.map((label) => label.name),
1397
+ state,
1398
+ holder: "claim" in rest ? rest.claim.holder : undefined,
1399
+ since: "claim" in rest ? rest.claim.at : undefined,
1400
+ session: "session" in rest ? rest.session.name : undefined,
1401
+ boxesTotal: list.length,
1402
+ boxesLeft: list.filter((box) => !box.done).length,
1403
+ note: note === undefined ? undefined : { author: note.user.login, at: note.created_at, body: note.body }
1404
+ };
1405
+ });
1406
+ }
1083
1407
  var boardUsage = `ft board
1084
1408
 
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.
1409
+ Lists open issues, at most 30 of each kind (--full for all), as available, taken by a session running here, or claimed by a session no session here answers to, from their latest "Taken by session <name>." comment.
1086
1410
  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.`;
1411
+ A lane's claim (lane-<n>-<issue>) is held while the session whose name ends in -<n> runs.
1412
+
1413
+ Exits 0 when it listed them, 1 when the issues cannot be read, 2 on a usage error.`;
1414
+ var BOARD_LIMIT = 30;
1088
1415
  var boardOptions = {
1089
1416
  labels: { type: "string", short: "L", description: "only issues with these labels, comma separated" },
1417
+ milestone: { type: "string", short: "m", description: "only issues of this milestone, a title or an id" },
1090
1418
  query: { type: "string", short: "q", description: "only issues whose title matches this, a case-insensitive regex such as dom|eager" },
1091
1419
  state: { type: "string", description: "only issues in this state: available, taken or away (claimed, no live session here)" },
1420
+ ...limitOptions(BOARD_LIMIT),
1092
1421
  json: jsonOption("number, title, labels, state, holder, since, session, boxesTotal, boxesLeft and note (author, at, body of the latest comment that is not a claim)"),
1093
1422
  ...repoOption
1094
1423
  };
@@ -1096,6 +1425,7 @@ async function board(args) {
1096
1425
  const parsed = parse(args, boardUsage, boardOptions);
1097
1426
  const { values } = parsed;
1098
1427
  const state = values.state;
1428
+ const limit = limitOf(values, BOARD_LIMIT);
1099
1429
  if (state !== undefined && !STATES.has(state))
1100
1430
  fail(`--state takes available, taken or away, not ${state}`);
1101
1431
  let query;
@@ -1104,33 +1434,15 @@ async function board(args) {
1104
1434
  } catch (error) {
1105
1435
  fail(`--query: ${error instanceof Error ? error.message : String(error)}`);
1106
1436
  }
1107
- const [open, sessions] = await Promise.all([openIssues(values.labels, values.repo), liveSessions()]);
1437
+ const milestone = values.milestone === undefined ? undefined : (await findMilestone(values.milestone, values.repo)).id;
1438
+ const [open, sessions] = await Promise.all([openIssues(values.labels, values.repo, milestone), liveSessions()]);
1108
1439
  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);
1440
+ const rows = (await boardRows(issues, sessions, values.repo)).filter((row) => state === undefined || row.standing.state === state);
1114
1441
  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
- }));
1442
+ printJson(boardJson(rows));
1131
1443
  return 0;
1132
1444
  }
1133
- console.log(rows.length === 0 && (query !== undefined || state !== undefined) ? "no open issue matches" : renderBoard(rows));
1445
+ console.log(rows.length === 0 && (query !== undefined || state !== undefined) ? "no open issue matches" : renderBoard(rows, Date.now(), limit));
1134
1446
  return 0;
1135
1447
  }
1136
1448
  async function me(as, sessions) {
@@ -1144,7 +1456,9 @@ async function me(as, sessions) {
1144
1456
  var takeUsage = `ft take <n>...
1145
1457
 
1146
1458
  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.`;
1459
+ An issue another live session holds is refused, one this session or its lanes hold passes to the new name, and one claimed by a session no session here answers to needs --take-stale; a refusal leaves the other issues taken.
1460
+
1461
+ Exits 0 when every issue is taken, 1 when one is refused, 2 on a usage error.`;
1148
1462
  var laneOption = {
1149
1463
  lane: { type: "boolean", description: "claim each as lane-<s>-<n>, <s> ending this session's name, so the claim stays live while it runs" }
1150
1464
  };
@@ -1172,6 +1486,8 @@ async function eachIssue(command, numbers, step) {
1172
1486
  if (!(error instanceof Exit) || numbers.length === 1)
1173
1487
  throw error;
1174
1488
  console.error(`ft ${command}: ${error.message}`);
1489
+ if (error.hint !== undefined)
1490
+ printHint(error.hint);
1175
1491
  code = Math.max(code, error.code);
1176
1492
  }
1177
1493
  }
@@ -1187,7 +1503,7 @@ async function take(args) {
1187
1503
  const name = nameFor(number);
1188
1504
  const [found, comments] = await Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)]);
1189
1505
  if (found.state === "closed")
1190
- fail(`#${number} is closed`, 1);
1506
+ fail(`#${number} is closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1191
1507
  const claim = latestClaim(comments);
1192
1508
  const current = standing(claim, sessions);
1193
1509
  if (claim?.holder === name) {
@@ -1197,14 +1513,16 @@ async function take(args) {
1197
1513
  if (current.state === "taken" && holderSession(name, sessions)?.pid !== current.session.pid)
1198
1514
  fail(`#${number} is taken by ${current.claim.holder}${current.session.name === current.claim.holder ? "" : ` in ${current.session.name}`}, which still runs (${current.session.status})`, 1);
1199
1515
  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);
1516
+ fail(`#${number} was taken by ${current.claim.holder} ${formatAge(current.claim.at)}, which no session here answers to: it may run on another machine`, 1, runHint(`ft take ${number} --take-stale`, "take it over"));
1201
1517
  await comment(number, takenComment(name), values.repo);
1202
1518
  console.log(`took #${number} as ${name}: ${found.title}`);
1203
1519
  });
1204
1520
  }
1205
1521
  var dropUsage = `ft drop <n>...
1206
1522
 
1207
- Gives each issue back with a "Released by session <name>." comment, when this session holds it.`;
1523
+ Gives each issue back with a "Released by session <name>." comment, when this session holds it.
1524
+
1525
+ Exits 0 when every issue is given back, 1 when one is not held by this session, 2 on a usage error.`;
1208
1526
  var dropOptions = {
1209
1527
  as: { type: "string", description: "the name it was taken as, instead of this session's" },
1210
1528
  ...laneOption,
@@ -1225,6 +1543,117 @@ async function drop(args) {
1225
1543
  });
1226
1544
  }
1227
1545
 
1546
+ // src/commands/ci.ts
1547
+ import process11 from "process";
1548
+
1549
+ // ../kit/src/progress.ts
1550
+ import process10 from "process";
1551
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1552
+ var FRAME_MS = 80;
1553
+ var CLEAR_LINE = "\r\x1B[2K";
1554
+ var LINE_UP = "\x1B[1A\x1B[2K";
1555
+ var ESC = String.fromCharCode(27);
1556
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
1557
+ var RESET_STYLE = `${ESC}[0m`;
1558
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
1559
+ var CONSOLE_METHODS = ["log", "info", "warn", "error", "debug", "dir", "dirxml", "table", "trace", "group", "groupCollapsed", "count", "timeEnd", "timeLog", "assert"];
1560
+ function liveRegion(render, stream = process10.stderr, env = process10.env) {
1561
+ if (!takesControlCodes(stream, env))
1562
+ return { redraw: () => {
1563
+ return;
1564
+ }, print: (text) => void stream.write(`${text}
1565
+ `), stop: () => {
1566
+ return;
1567
+ } };
1568
+ const write = stream.write.bind(stream);
1569
+ let frame = 0;
1570
+ let drawn = 0;
1571
+ const clear = () => {
1572
+ if (drawn > 0)
1573
+ write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`);
1574
+ drawn = 0;
1575
+ };
1576
+ const draw = () => {
1577
+ const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1;
1578
+ const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows;
1579
+ const lines = render(FRAMES[frame++ % FRAMES.length]).slice(0, Math.max(1, height - 1)).map((line) => cutToWidth(line, width));
1580
+ if (lines.length === 0) {
1581
+ clear();
1582
+ return;
1583
+ }
1584
+ write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join(`
1585
+ `)}`);
1586
+ drawn = lines.length;
1587
+ };
1588
+ const timer = setInterval(draw, FRAME_MS);
1589
+ const restore = interceptOutput(clear);
1590
+ const onExit = () => clear();
1591
+ const signals = holdSignals(stop);
1592
+ function stop() {
1593
+ clearInterval(timer);
1594
+ clear();
1595
+ restore();
1596
+ process10.off("exit", onExit);
1597
+ signals.release();
1598
+ }
1599
+ timer.unref();
1600
+ process10.on("exit", onExit);
1601
+ return {
1602
+ redraw: draw,
1603
+ print: (text) => {
1604
+ clear();
1605
+ write(`${text}
1606
+ `);
1607
+ draw();
1608
+ },
1609
+ stop
1610
+ };
1611
+ }
1612
+ function cutToWidth(line, width) {
1613
+ if (Bun.stringWidth(line) <= width)
1614
+ return line;
1615
+ let room = width;
1616
+ let kept = "";
1617
+ let index = 0;
1618
+ let linked = false;
1619
+ while (index < line.length) {
1620
+ ESCAPE_CODE.lastIndex = index;
1621
+ const escape = ESCAPE_CODE.exec(line);
1622
+ if (escape !== null) {
1623
+ kept += escape[0];
1624
+ linked ||= escape[0].startsWith(`${ESC}]`);
1625
+ index = ESCAPE_CODE.lastIndex;
1626
+ continue;
1627
+ }
1628
+ const char = String.fromCodePoint(line.codePointAt(index));
1629
+ const columns = Bun.stringWidth(char);
1630
+ if (columns > room)
1631
+ break;
1632
+ kept += char;
1633
+ room -= columns;
1634
+ index += char.length;
1635
+ }
1636
+ return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ""}`;
1637
+ }
1638
+ function interceptOutput(clear) {
1639
+ const targets = [...CONSOLE_METHODS.map((name) => [console, name]), [process10.stdout, "write"], [process10.stderr, "write"]];
1640
+ const wrapped = targets.map(([target, name]) => {
1641
+ const original = Reflect.get(target, name);
1642
+ const wrapper = (...args) => {
1643
+ clear();
1644
+ return original.apply(target, args);
1645
+ };
1646
+ Reflect.set(target, name, wrapper);
1647
+ return { target, name, original, wrapper };
1648
+ });
1649
+ return () => {
1650
+ for (const { target, name, original, wrapper } of wrapped) {
1651
+ if (Reflect.get(target, name) === wrapper)
1652
+ Reflect.set(target, name, original);
1653
+ }
1654
+ };
1655
+ }
1656
+
1228
1657
  // src/log.ts
1229
1658
  var BOUNDARY = /^(?:\((?:pass|fail|skip|todo)\) |::(?:end)?group::|\S+\.test\.[jt]sx?:$)/;
1230
1659
  var DURATION2 = / \[[\d.]+m?s\]$/;
@@ -1280,9 +1709,23 @@ function runSeconds(run) {
1280
1709
  function startedAge(run, now = Date.now()) {
1281
1710
  return Date.parse(run.started) < EPOCH ? "-" : formatAge(run.started, now);
1282
1711
  }
1283
- async function recentRuns(limit, repo, workflow) {
1712
+ var RUNS_PAGE = 50;
1713
+ async function runsListing(limit, repo, workflow) {
1284
1714
  const filter = workflow === undefined ? "" : `&workflow_id=${encodeURIComponent(workflow)}`;
1285
- return (await repoApi(`actions/runs?page=1&limit=${limit}${filter}`, repo)).workflow_runs ?? [];
1715
+ const runs = [];
1716
+ let total = 0;
1717
+ for (let page = 1;runs.length < limit; page++) {
1718
+ const found = await repoApi(`actions/runs?page=${page}&limit=${RUNS_PAGE}${filter}`, repo);
1719
+ const list = found.workflow_runs ?? [];
1720
+ runs.push(...list);
1721
+ total = found.total_count ?? 0;
1722
+ if (list.length < RUNS_PAGE)
1723
+ break;
1724
+ }
1725
+ return { runs: runs.slice(0, limit), total: Math.max(total, runs.length) };
1726
+ }
1727
+ async function recentRuns(limit, repo, workflow) {
1728
+ return (await runsListing(limit, repo, workflow)).runs;
1286
1729
  }
1287
1730
  async function runsOn(sha, repo) {
1288
1731
  return (await repoApi(`actions/runs?head_sha=${sha}`, repo)).workflow_runs ?? [];
@@ -1411,7 +1854,7 @@ async function ci(args) {
1411
1854
  async function covering() {
1412
1855
  await git(["fetch", "-q", "origin", branch]);
1413
1856
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`]))
1414
- fail(`${short} is not on origin/${branch}: push it first`);
1857
+ fail(`${short} is not on origin/${branch}`, 2, runHint(`git push origin ${branch}`));
1415
1858
  const later = (await git(["rev-list", "--reverse", "--first-parent", `${sha}..origin/${branch}`])).split(`
1416
1859
  `).filter(Boolean);
1417
1860
  for (const commit of [sha, ...later]) {
@@ -1426,44 +1869,53 @@ async function ci(args) {
1426
1869
  let moving = started;
1427
1870
  let followed = "";
1428
1871
  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;
1872
+ const { dim } = paint(process11.stderr);
1873
+ const region = liveRegion((spinner) => [
1874
+ `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
1875
+ ...(runs ?? []).map((run) => ` ${badge(run.status, process11.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
1876
+ ]);
1877
+ try {
1878
+ for (;; ) {
1879
+ if (Date.now() - started > giveUp)
1880
+ fail(`CI has not finished on ${short} in ${formatDuration(giveUp)}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1881
+ try {
1882
+ runs = await covering();
1883
+ } catch (error) {
1884
+ if (error instanceof Exit)
1885
+ throw error;
1886
+ if (cwdGone())
1887
+ fail(`the working directory was removed while waiting for CI on ${short}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1888
+ console.warn(`ft ci: ${error instanceof Error ? error.message : String(error)}, polling again`);
1889
+ const latest = runs === undefined ? undefined : await refreshed(runs);
1890
+ if (latest?.every((run) => FINISHED.has(run.status)) === true) {
1891
+ runs = latest;
1892
+ break;
1893
+ }
1894
+ await Bun.sleep(POLL);
1895
+ continue;
1444
1896
  }
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}`);
1897
+ if (runs !== undefined) {
1898
+ seen = Date.now();
1899
+ const ids = runs.map((run) => run.id).join(",");
1900
+ if (ids !== followed) {
1901
+ followed = ids;
1902
+ for (const run of runs)
1903
+ console.log(`following ${run.workflow_id} run ${run.id} on ${run.commit_sha.slice(0, 7)}${run.commit_sha === sha ? "" : `, which covers ${short}`}: ${run.html_url}`);
1904
+ }
1905
+ if (runs.every((run) => FINISHED.has(run.status)))
1906
+ break;
1907
+ const stall = await stallReason(runs);
1908
+ if (stall === undefined)
1909
+ moving = Date.now();
1910
+ else if (stall.certain || Date.now() - moving > STALLED)
1911
+ fail(`runs queued on ${short} are not moving: ${stall.reason}`);
1912
+ } else if (Date.now() - seen > NO_RUN) {
1913
+ fail(`no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`);
1455
1914
  }
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`);
1915
+ await Bun.sleep(POLL);
1465
1916
  }
1466
- await Bun.sleep(POLL);
1917
+ } finally {
1918
+ region.stop();
1467
1919
  }
1468
1920
  const status = overall(runs);
1469
1921
  if (status !== "failure") {
@@ -1480,6 +1932,128 @@ run ${run.id} failed${run.commit_sha === sha ? "" : `, testing every commit from
1480
1932
  return 1;
1481
1933
  }
1482
1934
 
1935
+ // src/commands/digest.ts
1936
+ import process12 from "process";
1937
+ var RUNS_SCANNED = 50;
1938
+ var TITLE_WIDTH2 = 70;
1939
+ var SHOWN = 8;
1940
+ var LINE_START2 = /^/gm;
1941
+ function claimed(issue, claim) {
1942
+ const list = boxes(issue.body);
1943
+ return { number: issue.number, title: issue.title, holder: claim.holder, since: claim.at, boxesLeft: list.filter((box) => !box.done).length, boxesTotal: list.length };
1944
+ }
1945
+ function claims(rows, sessions, me) {
1946
+ const held = [];
1947
+ const away = [];
1948
+ for (const { issue, claim } of rows) {
1949
+ const row = standing(claim, sessions);
1950
+ if (row.state === "taken" && me !== undefined && row.session.pid === me.pid)
1951
+ held.push(claimed(issue, row.claim));
1952
+ else if (row.state === "away")
1953
+ away.push(claimed(issue, row.claim));
1954
+ }
1955
+ return { held, away };
1956
+ }
1957
+ function unsettled(runs, branch) {
1958
+ return newestPerWorkflow(runs.filter((run) => run.prettyref === branch)).filter((run) => run.status === "failure" || !FINISHED.has(run.status)).map((run) => ({ id: run.id, status: run.status, workflow: run.workflow_id, started: run.started, title: run.title }));
1959
+ }
1960
+ function dirtyFiles(porcelain) {
1961
+ return porcelain.split(`
1962
+ `).flatMap((line) => {
1963
+ const trimmed = line.trim();
1964
+ const space = trimmed.indexOf(" ");
1965
+ return space === -1 ? [] : [{ status: trimmed.slice(0, space), path: trimmed.slice(space + 1).trim() }];
1966
+ });
1967
+ }
1968
+ async function unpushedCommits() {
1969
+ const base = await gitOk(["rev-parse", "--verify", "-q", "@{upstream}"]) ? "@{upstream}" : `origin/${await defaultBranch()}`;
1970
+ if (!await gitOk(["rev-parse", "--verify", "-q", base]))
1971
+ return [];
1972
+ return (await git(["log", `${base}..HEAD`, "--format=%h %s"])).split(`
1973
+ `).filter(Boolean);
1974
+ }
1975
+ async function dirtyTree() {
1976
+ return dirtyFiles(await git(["status", "--porcelain"]));
1977
+ }
1978
+ async function quietly(read) {
1979
+ try {
1980
+ return await read();
1981
+ } catch {
1982
+ return [];
1983
+ }
1984
+ }
1985
+ async function staleTodos() {
1986
+ const bin = Bun.which("todo-scan", { PATH: process12.env.PATH ?? "" });
1987
+ if (bin === null)
1988
+ return [];
1989
+ let found;
1990
+ try {
1991
+ const ran = await runCommand([bin, "--stale", "--json"]);
1992
+ found = JSON.parse(Buffer.concat(ran.chunks.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data)).toString());
1993
+ } catch {
1994
+ return [];
1995
+ }
1996
+ const byIssue = new Map;
1997
+ for (const mark of found) {
1998
+ for (const ref of mark.refs.filter((each) => each.state === "closed")) {
1999
+ const key = `${ref.repo}#${ref.number}`;
2000
+ const entry = byIssue.get(key) ?? { issue: key, comments: [] };
2001
+ entry.comments.push({ path: mark.path, line: mark.line });
2002
+ byIssue.set(key, entry);
2003
+ }
2004
+ }
2005
+ return [...byIssue.values()];
2006
+ }
2007
+ function claimLine(row, now) {
2008
+ return [`#${row.number}`, row.holder, formatAge(row.since, now), row.boxesTotal === 0 ? "" : `${row.boxesLeft}/${row.boxesTotal} left`, truncate(row.title, TITLE_WIDTH2)];
2009
+ }
2010
+ function capped2(lines) {
2011
+ return lines.length > SHOWN ? [...lines.slice(0, SHOWN), `... ${lines.length - SHOWN} more`] : lines;
2012
+ }
2013
+ function renderDigest(digest, now = Date.now()) {
2014
+ const sections = [
2015
+ { heading: "held by this session", count: digest.held.length, lines: table(digest.held.map((row) => claimLine(row, now))).split(`
2016
+ `) },
2017
+ { heading: "claimed, no live session here", count: digest.away.length, lines: table(digest.away.map((row) => claimLine(row, now))).split(`
2018
+ `) },
2019
+ { heading: "CI on the default branch", count: digest.ci.length, lines: digest.ci.map((run) => `${run.workflow} ${run.status} (run ${run.id}) ${truncate(upTo(run.title, `
2020
+ `), TITLE_WIDTH2)}`) },
2021
+ { heading: "unpushed commits", count: digest.unpushed.length, lines: capped2(digest.unpushed) },
2022
+ { heading: "dirty files", count: digest.dirty.length, lines: capped2(digest.dirty.map((file) => `${file.status} ${file.path}`)) },
2023
+ { heading: "stale TODO comments", count: digest.stale.length, lines: capped2(digest.stale.map((todo) => `${todo.issue} closed: ${todo.comments.map((each) => `${each.path}:${each.line}`).join(", ")}`)) }
2024
+ ].filter((section) => section.count > 0);
2025
+ return sections.length === 0 ? "nothing to pick up" : sections.map(({ heading, count, lines }) => `${heading} (${count})
2026
+ ${lines.join(`
2027
+ `).replace(LINE_START2, " ")}`).join(`
2028
+
2029
+ `);
2030
+ }
2031
+ var usage2 = `ft digest
2032
+
2033
+ Summarises the repository for a session starting cold: the issues this session holds with their boxes left, the claims no live session here answers to, red or running CI on the default branch, unpushed commits, dirty files and TODO comments naming a closed issue.
2034
+ A section with nothing in it is left out, and the stale TODO comments need todo-scan on the PATH.
2035
+
2036
+ Exits 0 when it summarised the repository, 1 when Forgejo cannot be read, 2 on a usage error.`;
2037
+ var options2 = {
2038
+ json: jsonOption("held and away (number, title, holder, since, boxesLeft, boxesTotal), ci (id, status, workflow, started, title), unpushed, dirty (status, path) and stale (issue, comments: path, line)"),
2039
+ ...repoOption
2040
+ };
2041
+ async function digest(args) {
2042
+ const parsed = parse(args, usage2, options2);
2043
+ const { values } = parsed;
2044
+ const [issues, sessions, branch, runs] = await Promise.all([openIssues(undefined, values.repo), liveSessions(), defaultBranch(), recentRuns(RUNS_SCANNED, values.repo)]);
2045
+ const comments = await commentsOfAll(issues, values.repo);
2046
+ const me = await currentSession(sessions);
2047
+ const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? []) }));
2048
+ const [unpushed, dirty, stale] = await Promise.all([quietly(unpushedCommits), quietly(dirtyTree), staleTodos()]);
2049
+ const summary = { ...claims(rows, sessions, me), ci: unsettled(runs, branch), unpushed, dirty, stale };
2050
+ if (values.json === true)
2051
+ printJson(summary);
2052
+ else
2053
+ console.log(renderDigest(summary));
2054
+ return 0;
2055
+ }
2056
+
1483
2057
  // src/commands/triage.ts
1484
2058
  async function checkLabels(names, repo) {
1485
2059
  if (names.length === 0)
@@ -1513,13 +2087,17 @@ async function setState(args, usage, wanted) {
1513
2087
  }
1514
2088
  var closeUsage = `ft close <n>... [-m <reason>]
1515
2089
 
1516
- Closes issues without a fix, a duplicate or one dropped, posting the -m reason first. \`ft fixed\` closes one a commit fixed.`;
2090
+ Closes issues without a fix, a duplicate or one dropped, posting the -m reason first. \`ft fixed\` closes one a commit fixed.
2091
+
2092
+ Exits 0 when every issue is closed, 1 when Forgejo refuses, 2 on a usage error.`;
1517
2093
  async function closeIssues(args) {
1518
2094
  return setState(args, closeUsage, "closed");
1519
2095
  }
1520
2096
  var reopenUsage = `ft reopen <n>... [-m <reason>]
1521
2097
 
1522
- Reopens closed issues, posting the -m reason first.`;
2098
+ Reopens closed issues, posting the -m reason first.
2099
+
2100
+ Exits 0 when every issue is open, 1 when Forgejo refuses, 2 on a usage error.`;
1523
2101
  async function reopenIssues(args) {
1524
2102
  return setState(args, reopenUsage, "open");
1525
2103
  }
@@ -1544,7 +2122,9 @@ function labelChanges(args) {
1544
2122
  }
1545
2123
  var labelUsage = `ft label <n> [+]<label>... -<label>...
1546
2124
 
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.`;
2125
+ Adds the labels named bare or with +, removes those named with -, then prints the issue's labels. A label the repository lacks is refused.
2126
+
2127
+ Exits 0 when it changed the labels, 1 when Forgejo refuses, 2 on a usage error or a label the repository lacks.`;
1548
2128
  async function label(args) {
1549
2129
  const changes = labelChanges(args);
1550
2130
  const parsed = parse(changes.rest, labelUsage, repoOption);
@@ -1570,7 +2150,9 @@ ${labelUsage}`);
1570
2150
  var issueUsage = `ft issue <n>...
1571
2151
 
1572
2152
  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\`.`;
2153
+ With --body, prints only the raw description, to edit and give back to \`ft edit -F\`.
2154
+
2155
+ Exits 0 when it printed them, 1 when an issue cannot be read, 2 on a usage error.`;
1574
2156
  var issueOptions = {
1575
2157
  todo: { type: "boolean", short: "t", description: "only the header and the boxes left to tick" },
1576
2158
  comments: { type: "boolean", short: "c", description: "add the comments, each with its author and age" },
@@ -1670,15 +2252,23 @@ async function closedReferences(body, repo) {
1670
2252
  const states = await Promise.all(listed.map(async (issue) => isClosed(issue, repo)));
1671
2253
  return new Set(listed.filter((_, at) => states[at] === true));
1672
2254
  }
1673
- var newUsage = `ft new -t <title> [-F <file>]
2255
+ var newUsage = `ft new -t <title> [-F <file>] [-L a,b] [-m <milestone>]
1674
2256
 
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.`;
2257
+ Creates an issue and prints its number and url. The description is read from -F <file>, or from stdin with -F -, and is empty without it. A label or milestone the repository lacks is refused before anything is created.
2258
+
2259
+ Exits 0 when it created the issue, 1 when Forgejo refuses, 2 on a usage error or a label or milestone the repository lacks.`;
1676
2260
  var newOptions = {
1677
2261
  title: { type: "string", short: "t", description: "the issue title" },
1678
2262
  file: { type: "string", short: "F", description: "the description: a file, - for stdin" },
1679
2263
  labels: { type: "string", short: "L", description: "comma-separated labels" },
2264
+ milestone: { type: "string", short: "m", description: "the milestone, a title or an id; the repository must have it" },
1680
2265
  ...repoOption
1681
2266
  };
2267
+ async function milestoneTitle(reference, repo) {
2268
+ if (reference === undefined)
2269
+ return;
2270
+ return reference.trim() === "" ? "" : (await findMilestone(reference.trim(), repo)).title;
2271
+ }
1682
2272
  async function newIssue(args) {
1683
2273
  const parsed = parse(args, newUsage, newOptions);
1684
2274
  const { values } = parsed;
@@ -1688,20 +2278,25 @@ async function newIssue(args) {
1688
2278
  ${newUsage}`);
1689
2279
  const body = await noteText({ file: values.file });
1690
2280
  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);
2281
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2282
+ const created = await createIssue(values.title.trim(), body ?? "", values.labels, milestone === "" ? undefined : milestone, values.repo);
1692
2283
  console.log(`#${created.number} ${created.html_url}`);
1693
2284
  return 0;
1694
2285
  }
1695
- var editUsage = `ft edit <n> [-t <title>] [-F <file> [--append]] [--add-box <text>]...
2286
+ var editUsage = `ft edit <n> [-t <title>] [-F <file> [--append]] [--add-box <text>]... [-m <milestone>]
1696
2287
 
1697
2288
  Replaces the title and/or the description of issue <n>, the description from -F <file> or from stdin with -F -.
2289
+ -m moves it to a milestone given by title or id, and \`-m ''\` takes it out of its milestone.
1698
2290
  --append adds the -F text after the description instead, and each --add-box an unticked box after the last one, the rest of the description untouched.
1699
- \`ft issue <n> --body\` prints the description to edit.`;
2291
+ \`ft issue <n> --body\` prints the description to edit.
2292
+
2293
+ Exits 0 when it edited the issue, 1 when Forgejo refuses, 2 on a usage error.`;
1700
2294
  var editOptions = {
1701
2295
  title: { type: "string", short: "t", description: "the new title" },
1702
2296
  file: { type: "string", short: "F", description: "the new description: a file, - for stdin" },
1703
2297
  append: { type: "boolean", short: "a", description: "add the -F text after the description instead of replacing it" },
1704
2298
  "add-box": { type: "string", multiple: true, description: "add an unticked box after the last one, repeatable" },
2299
+ milestone: { type: "string", short: "m", description: "move the issue to this milestone, a title or an id; an empty value takes it out of its milestone" },
1705
2300
  ...repoOption
1706
2301
  };
1707
2302
  async function edit(args) {
@@ -1714,11 +2309,14 @@ async function edit(args) {
1714
2309
  fail("the description file is empty");
1715
2310
  if (values.append === true && text === undefined)
1716
2311
  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
2312
+ if (values.title === undefined && text === undefined && added.length === 0 && values.milestone === undefined)
2313
+ fail(`nothing to change: give -t, -F, --add-box or -m
1719
2314
 
1720
2315
  ${editUsage}`);
1721
2316
  const change = values.title === undefined ? {} : { title: values.title.trim() };
2317
+ const milestone = await milestoneTitle(values.milestone, values.repo);
2318
+ if (milestone !== undefined)
2319
+ change.milestone = milestone;
1722
2320
  if (text !== undefined && values.append !== true)
1723
2321
  change.body = text;
1724
2322
  else if (text !== undefined || added.length > 0)
@@ -1736,7 +2334,9 @@ ${text}`;
1736
2334
  var tickUsage = `ft tick <n> <box>...
1737
2335
 
1738
2336
  Ticks checklist boxes of issue <n>, each given by its number from \`ft issue\` or by text matching exactly one box, then posts the -m or -F comment.
1739
- With --closed, ticks instead every box opening with \`#<issue>\` whose issue is closed, as a release plan lists them.`;
2337
+ With --closed, ticks instead every box opening with \`#<issue>\` whose issue is closed, as a release plan lists them.
2338
+
2339
+ Exits 0 when it ticked the boxes, 1 when Forgejo refuses, 2 on a usage error or a box matching none or several.`;
1740
2340
  var tickOptions = {
1741
2341
  undo: { type: "boolean", short: "u", description: "untick them instead" },
1742
2342
  closed: { type: "boolean", description: "tick the boxes listing a closed issue, no box to name" },
@@ -1784,7 +2384,9 @@ ${tickUsage}`);
1784
2384
  }
1785
2385
  var commentUsage = `ft comment <n> [text]
1786
2386
 
1787
- Posts a comment on issue <n>: the text given, -F <file>, or stdin when neither is, so a heredoc needs no escaping.`;
2387
+ Posts a comment on issue <n>: the text given, -F <file>, or stdin when neither is, so a heredoc needs no escaping.
2388
+
2389
+ Exits 0 when it posted the comment, 1 when Forgejo refuses, 2 on a usage error.`;
1788
2390
  var commentOptions = {
1789
2391
  file: noteOptions.file,
1790
2392
  ...repoOption
@@ -1807,7 +2409,9 @@ var fixedUsage = `ft fixed <n> [commit]...
1807
2409
 
1808
2410
  Comments "Fixed in <sha>: <message>" on issue <n> and closes it, naming each commit given as "Fixed in <a>, <b> and <c>".
1809
2411
  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.`;
2412
+ The message defaults to the subject of the first commit. Every open issue listing \`- [ ] #<n>\` in its checklist gets that line ticked. An issue with boxes left is refused, as a partly done issue stays open, unless --tick ticks them first.
2413
+
2414
+ Exits 0 when it closed the issue, 1 when the issue is already closed, has boxes left or Forgejo refuses, 2 on a usage error.`;
1811
2415
  var fixedOptions = {
1812
2416
  message: { type: "string", short: "m", description: 'the line after "Fixed in <sha>:"' },
1813
2417
  tick: { type: "boolean", description: "tick the boxes left before closing" },
@@ -1854,15 +2458,15 @@ async function fixed(args) {
1854
2458
  const commits = await fixingCommits(number, positionals.slice(1));
1855
2459
  const found = await getIssue(number, values.repo);
1856
2460
  if (found.state === "closed")
1857
- fail(`#${number} is already closed`);
2461
+ fail(`#${number} is already closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1858
2462
  const left = boxes(found.body).filter((box) => !box.done);
1859
2463
  if (left.length > 0 && values.tick === true) {
1860
2464
  await setBody(number, setBoxes(found.body, left.map((box) => String(box.number)), true).body, values.repo);
1861
2465
  console.log(`ticked ${left.length} box${left.length === 1 ? "" : "es"} of #${number}`);
1862
2466
  } else if (left.length > 0 && values.force !== true) {
1863
- fail(`#${number} has ${left.length} boxes left, tick them, pass --tick or pass --force:
2467
+ fail(`#${number} has ${left.length} boxes left:
1864
2468
  ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
1865
- `)}`);
2469
+ `)}`, 1, `${runHint(`ft fixed ${number} --tick`, "tick them and close")}, or ${runHint(`ft fixed ${number} --force`, "close with them left")}`);
1866
2470
  }
1867
2471
  const shas = joinShas(commits.map((each) => each.sha.slice(0, 7)));
1868
2472
  await comment(number, `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`, values.repo);
@@ -1873,11 +2477,13 @@ ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
1873
2477
  }
1874
2478
 
1875
2479
  // src/commands/logs.ts
1876
- var usage2 = `ft logs <run id>
2480
+ var usage3 = `ft logs <run id>
1877
2481
 
1878
2482
  Prints what the failed jobs of a run say went wrong: each failed bun test's output and the end of each failed step.
1879
- With no failed job it prints every job's last lines; --full prints whole logs.`;
1880
- var options2 = {
2483
+ With no failed job it prints every job's last lines; --full prints whole logs.
2484
+
2485
+ Exits 0 when it printed the logs, 1 when Forgejo cannot be read, 2 on a usage error or a job the run lacks.`;
2486
+ var options3 = {
1881
2487
  job: { type: "string", short: "j", description: "only this job, by name" },
1882
2488
  full: { type: "boolean", short: "f", description: "the whole log, timestamps stripped" },
1883
2489
  ...repoOption
@@ -1889,28 +2495,164 @@ function pickJobs(jobs, name) {
1889
2495
  return failed.length > 0 ? failed : jobs;
1890
2496
  }
1891
2497
  async function logs(args) {
1892
- const parsed = parse(args, usage2, options2);
2498
+ const parsed = parse(args, usage3, options3);
1893
2499
  const { values, positionals } = parsed;
1894
2500
  if (positionals[0] === undefined)
1895
- fail(`a run id is needed, as \`ft runs\` lists them
1896
-
1897
- ${usage2}`);
2501
+ fail("a run id is needed", 2, runHint("ft runs", "list them"));
1898
2502
  const run = await runById(wholeNumber(positionals[0], "run id", 0), values.repo);
1899
2503
  const jobs = await jobsOf(run.id, values.repo);
1900
2504
  const picked = pickJobs(jobs, values.job);
1901
2505
  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}`);
2506
+ fail(`run ${run.id} has no job ${values.job ?? ""}: ${jobs.map((job) => job.name).join(", ")}`, 2, runHint(`ft logs ${run.id}`, "read every failed job"));
2507
+ console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref} (${run.commit_sha.slice(0, 7)}): ${badge(run.status, process.stdout)}, ${run.html_url}`);
1904
2508
  for (const job of picked) {
1905
2509
  const log = await jobLog(job.id, values.repo);
1906
2510
  console.log(`
1907
- job ${job.name}: ${job.status}
2511
+ job ${job.name}: ${badge(job.status, process.stdout)}
1908
2512
  ${values.full === true ? logLines(log).join(`
1909
2513
  `) : excerpt(log)}`);
1910
2514
  }
1911
2515
  return 0;
1912
2516
  }
1913
2517
 
2518
+ // src/commands/milestone.ts
2519
+ var TITLE_WIDTH3 = 60;
2520
+ var LIST_LIMIT = 30;
2521
+ function counts(milestone) {
2522
+ return `${milestone.open_issues} open, ${milestone.closed_issues} closed`;
2523
+ }
2524
+ var listUsage = `ft milestones
2525
+
2526
+ Lists the repository's milestones, the open ones unless --state says otherwise, with the issues open and closed in each, the progress and the due date.
2527
+
2528
+ Exits 0 when it listed them, 1 when Forgejo refuses, 2 on a usage error.`;
2529
+ var listOptions = {
2530
+ state: { type: "string", description: "open (the default), closed or all" },
2531
+ ...limitOptions(LIST_LIMIT),
2532
+ json: jsonOption("id, title, state, description, open, closed, due (YYYY-MM-DD or null)"),
2533
+ ...repoOption
2534
+ };
2535
+ async function milestones(args) {
2536
+ const parsed = parse(args, listUsage, listOptions);
2537
+ const { values } = parsed;
2538
+ const state = values.state ?? "open";
2539
+ if (state !== "open" && state !== "closed" && state !== "all")
2540
+ fail(`--state takes open, closed or all, not ${state}`);
2541
+ const limit = limitOf(values, LIST_LIMIT);
2542
+ const found = await listMilestones(state, values.repo);
2543
+ if (values.json === true) {
2544
+ printJson(found.map((each) => ({ id: each.id, title: each.title, state: each.state, description: each.description, open: each.open_issues, closed: each.closed_issues, due: dueDate(each) || null })));
2545
+ return 0;
2546
+ }
2547
+ if (found.length === 0) {
2548
+ console.log(`no ${state === "all" ? "" : `${state} `}milestone`);
2549
+ return 0;
2550
+ }
2551
+ const { shown, footer } = capped(found, limit);
2552
+ const showState = state === "all";
2553
+ const rows = shown.map((each) => [String(each.id), truncate(each.title, TITLE_WIDTH3), `${each.open_issues} open`, `${each.closed_issues} closed`, progress(each), dueDate(each) === "" ? "" : `due ${dueDate(each)}`, ...showState ? [each.state] : []]);
2554
+ console.log(table(rows));
2555
+ if (footer !== undefined)
2556
+ console.log(footer);
2557
+ return 0;
2558
+ }
2559
+ var newUsage2 = `ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2560
+
2561
+ Creates a milestone through the API and prints its id. Without --due it has no due date.
2562
+
2563
+ Exits 0 when it created the milestone, 1 when Forgejo refuses, 2 on a usage error.`;
2564
+ var newOptions2 = {
2565
+ description: { type: "string", short: "d", description: "the milestone description" },
2566
+ due: { type: "string", description: "the due date, YYYY-MM-DD" },
2567
+ ...repoOption
2568
+ };
2569
+ async function create(args) {
2570
+ const parsed = parse(args, newUsage2, newOptions2);
2571
+ const { values, positionals } = parsed;
2572
+ const title = positionals.join(" ").trim();
2573
+ if (title === "")
2574
+ fail(`give the milestone a title
2575
+
2576
+ ${newUsage2}`);
2577
+ const due = values.due === undefined ? undefined : dueOn(values.due);
2578
+ const created = await createMilestone(title, values.description, due, values.repo);
2579
+ console.log(`milestone ${created.id} ${created.title}${dueDate(created) === "" ? "" : `, due ${dueDate(created)}`}`);
2580
+ return 0;
2581
+ }
2582
+ var closeUsage2 = `ft milestone close <title|id> [--force]
2583
+
2584
+ Closes a milestone, refused while issues are open in it, which it names. --force closes it with them left.
2585
+
2586
+ Exits 0 when it closed the milestone, 1 when issues are open in it or Forgejo refuses, 2 on a usage error.`;
2587
+ var closeOptions = {
2588
+ force: { type: "boolean", description: "close it with issues left open" },
2589
+ ...repoOption
2590
+ };
2591
+ async function closeOne(args) {
2592
+ const parsed = parse(args, closeUsage2, closeOptions);
2593
+ const { values, positionals } = parsed;
2594
+ const reference = positionals.join(" ").trim();
2595
+ if (reference === "")
2596
+ fail(`name the milestone to close
2597
+
2598
+ ${closeUsage2}`);
2599
+ const found = await findMilestone(reference, values.repo);
2600
+ if (found.state === "closed") {
2601
+ console.log(`milestone ${found.title} is already closed`);
2602
+ return 0;
2603
+ }
2604
+ const open = await openIssues(undefined, values.repo, found.id);
2605
+ if (open.length > 0 && values.force !== true)
2606
+ fail(`milestone ${found.title} has ${open.length} open issue${open.length === 1 ? "" : "s"}:
2607
+ ${open.map((issue) => ` #${issue.number} ${truncate(issue.title, TITLE_WIDTH3)}`).join(`
2608
+ `)}`, 1, `${runHint(`ft milestone close ${found.title} --force`, "close it with them open")}, or move them with \`ft edit <n> -m <milestone>\``);
2609
+ await closeMilestone(found.id, values.repo);
2610
+ console.log(`closed milestone ${found.title}`);
2611
+ return 0;
2612
+ }
2613
+ var showUsage = `ft milestone <title|id>
2614
+ ft milestone new <title> [-d <text>] [--due <YYYY-MM-DD>]
2615
+ ft milestone close <title|id> [--force]
2616
+
2617
+ Shows a milestone with its open issues as \`ft board\` does: available, taken by a session running here, or claimed by one gone, each with its boxes left, at most 30 of each kind (--full for all).
2618
+ \`ft milestone new\` and \`ft milestone close\` have their own --help; a milestone titled new or close is named by its id.
2619
+
2620
+ Exits 0 when it showed the milestone, 1 when it cannot be read, 2 on a usage error or an unknown milestone.`;
2621
+ var showOptions = {
2622
+ ...limitOptions(LIST_LIMIT),
2623
+ json: jsonOption("the milestone (id, title, state, description, open, closed, due) and its open issues as `ft board --json` lists them"),
2624
+ ...repoOption
2625
+ };
2626
+ async function show(args) {
2627
+ const parsed = parse(args, showUsage, showOptions);
2628
+ const { values, positionals } = parsed;
2629
+ const reference = positionals.join(" ").trim();
2630
+ if (reference === "")
2631
+ fail(`name the milestone, or see them all with \`ft milestones\`
2632
+
2633
+ ${showUsage}`);
2634
+ const found = await findMilestone(reference, values.repo);
2635
+ const [issues, sessions] = await Promise.all([openIssues(undefined, values.repo, found.id), liveSessions()]);
2636
+ const rows = await boardRows(issues, sessions, values.repo);
2637
+ if (values.json === true) {
2638
+ printJson({ id: found.id, title: found.title, state: found.state, description: found.description, open: found.open_issues, closed: found.closed_issues, due: dueDate(found) || null, issues: boardJson(rows) });
2639
+ return 0;
2640
+ }
2641
+ const facts = [found.state, counts(found), progress(found), dueDate(found) === "" ? "" : `due ${dueDate(found)}`].filter((fact) => fact !== "");
2642
+ console.log(`milestone ${found.title} (${facts.join(", ")})`);
2643
+ if (found.description.trim() !== "")
2644
+ console.log(found.description.trim());
2645
+ console.log(`
2646
+ ${renderBoard(rows, Date.now(), limitOf(values, LIST_LIMIT))}`);
2647
+ return 0;
2648
+ }
2649
+ async function milestone(args) {
2650
+ const [first, ...rest] = args;
2651
+ if (first === "new")
2652
+ return create(rest);
2653
+ return first === "close" ? closeOne(rest) : show(args);
2654
+ }
2655
+
1914
2656
  // src/plan.ts
1915
2657
  var MARKUP = /^[#*_\s]+|[*_\s]+$/g;
1916
2658
  function heading(line) {
@@ -1969,13 +2711,17 @@ var addUsage = `ft plan add <plan> <n>... [--section "Bugs:"]
1969
2711
 
1970
2712
  Lists each issue <n> as \`- [ ] #<n> <title>\` in the checklist of issue <plan>, skipping those already listed.
1971
2713
  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.`;
2714
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2715
+
2716
+ Exits 0 when it listed the issues, 1 when Forgejo refuses, 2 on a usage error.`;
1973
2717
  var carryUsage = `ft plan carry <from> <to> [--section "Carried from 1.71.0:"]
1974
2718
 
1975
2719
  Copies the unticked \`#<n>\` lines of issue <from> into issue <to>, skipping those <to> already lists.
1976
2720
  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 = {
2721
+ The body is read again just before it is written, so an edit made meanwhile is kept.
2722
+
2723
+ Exits 0 when it copied the lines, 1 when Forgejo refuses, 2 on a usage error.`;
2724
+ var options4 = {
1979
2725
  section: { type: "string", short: "s", description: 'the heading the lines go under, e.g. "Bugs:"' },
1980
2726
  ...repoOption
1981
2727
  };
@@ -1987,7 +2733,7 @@ var plan = {
1987
2733
  }
1988
2734
  };
1989
2735
  async function add(args) {
1990
- const parsed = parse(args, addUsage, options3);
2736
+ const parsed = parse(args, addUsage, options4);
1991
2737
  const { section, repo } = parsed.values;
1992
2738
  const [target, ...listed] = parsed.positionals;
1993
2739
  const planNumber = issueNumber(target, addUsage);
@@ -2008,7 +2754,7 @@ ${addUsage}`);
2008
2754
  return 0;
2009
2755
  }
2010
2756
  async function carry(args) {
2011
- const parsed = parse(args, carryUsage, options3);
2757
+ const parsed = parse(args, carryUsage, options4);
2012
2758
  const { section, repo } = parsed.values;
2013
2759
  const from = issueNumber(parsed.positionals[0], carryUsage);
2014
2760
  const to = issueNumber(parsed.positionals[1], carryUsage);
@@ -2029,15 +2775,16 @@ function report(plan, added, skipped) {
2029
2775
  }
2030
2776
 
2031
2777
  // src/commands/release.ts
2032
- import { basename as basename2, join as join2, resolve } from "path";
2778
+ import { basename as basename2, join as join2, relative, resolve } from "path";
2779
+ import process13 from "process";
2033
2780
  var {$: $3 } = globalThis.Bun;
2034
2781
 
2035
2782
  // src/commands/watch.ts
2036
- var usage3 = `ft watch [--after <id>]
2783
+ var usage4 = `ft watch [--after <id>]
2037
2784
 
2038
2785
  Waits for runs to finish: those newer than run <id>, or else the ones in progress now.
2039
2786
  Exits 0 when every one passed or was skipped, 1 when one failed or was cancelled, 2 on a timeout or a stalled queue.`;
2040
- var options4 = {
2787
+ var options5 = {
2041
2788
  after: { type: "string", short: "a", description: "watch the runs newer than this run id, as `ft runs --json` gives it" },
2042
2789
  timeout: { type: "string", short: "t", description: "how long before giving up (15m), a bare number in minutes" },
2043
2790
  ...repoOption
@@ -2062,7 +2809,7 @@ async function watchRuns(pick, timeout, repo, idle) {
2062
2809
  }
2063
2810
  for (const run of runs) {
2064
2811
  if (reported.get(run.id) !== run.status)
2065
- console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${run.status}`);
2812
+ console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${badge(run.status, process.stdout)}`);
2066
2813
  reported.set(run.id, run.status);
2067
2814
  }
2068
2815
  if (runs.length === 0) {
@@ -2093,7 +2840,7 @@ ${run.html_url}`);
2093
2840
  }
2094
2841
  }
2095
2842
  async function watch(args) {
2096
- const parsed = parse(args, usage3, options4);
2843
+ const parsed = parse(args, usage4, options5);
2097
2844
  const { values } = parsed;
2098
2845
  const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
2099
2846
  if (values.after !== undefined) {
@@ -2109,21 +2856,27 @@ async function watch(args) {
2109
2856
  }
2110
2857
 
2111
2858
  // src/commands/release.ts
2112
- var usage4 = `ft release <patch|minor|major|x.y.z>
2859
+ var usage5 = `ft release <patch|minor|major|x.y.z>
2113
2860
  ft release --tagged
2114
2861
 
2115
2862
  Cuts a release from the default branch: first the commit it sits on is pushed and its runs waited for, so failing tests stop it before anything is bumped or tagged. Then \`bun pm version\` commits and tags the bump, one atomic push sends the branch and its tag, and it waits for the runs the push started and for the registry to serve the version.
2116
2863
  --tagged releases a version commit already tagged at HEAD instead, for repos bumping with their own script; the runs waited for first are then those of the commit below it.
2117
2864
  --package releases one workspace package of a monorepo on its own, tagged \`<folder>-v<version>\` (\`ft release patch -p ft\` tags packages/ft as ft-v0.3.0).
2118
- A refused push drops the bump commit and its tag, so a retry starts clean.`;
2119
- var options5 = {
2865
+ A refused push drops the bump commit and its tag, so a retry starts clean.
2866
+ Once released, an open milestone titled like the version (\`1.2.0\`, \`v1.2.0\` or the tag) has its closed issues listed and is closed; a failure there only warns.
2867
+ --dry-run checks the same and prints the plan: the version bump, the tag, the pushes and the runs and registry it would wait for, changing nothing.
2868
+ Exits 0 once released or planned, 1 when a run fails, origin refuses a push or the registry never serves the version, 2 on a timeout, a stalled queue, a refusal before anything ran (another branch, uncommitted changes, origin ahead) or a usage error.`;
2869
+ var options6 = {
2120
2870
  tagged: { type: "boolean", description: "push the version commit and tag already at HEAD instead of bumping" },
2121
2871
  package: { type: "string", short: "p", description: "workspace folder to release, a path or a name under packages/" },
2122
2872
  "no-registry": { type: "boolean", description: "do not wait for the registry to serve the new version" },
2873
+ "dry-run": { type: "boolean", description: "print what the release would do, changing nothing" },
2123
2874
  timeout: { type: "string", short: "t", description: "how long to wait for the runs (15m), a bare number in minutes" }
2124
2875
  };
2125
2876
  var LEVELS = new Set(["patch", "minor", "major", "prepatch", "preminor", "premajor", "prerelease"]);
2126
2877
  var VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
2878
+ var PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
2879
+ var LAST_NUMBER = /\d+$/;
2127
2880
  var REGISTRY_TRIES = 12;
2128
2881
  var REGISTRY_POLL = 5000;
2129
2882
  var NO_RUN2 = 3 * 60000;
@@ -2131,6 +2884,32 @@ function taggedVersion(tag, prefix) {
2131
2884
  const version = tag.slice(prefix.length);
2132
2885
  return tag.startsWith(prefix) && VERSION.test(version) ? version : undefined;
2133
2886
  }
2887
+ function nextVersion(current, level) {
2888
+ if (VERSION.test(level))
2889
+ return level;
2890
+ const [, major = "0", minor = "0", patch = "0", pre] = PARTS.exec(current) ?? [];
2891
+ const [x, y, z] = [Number(major), Number(minor), Number(patch)];
2892
+ switch (level) {
2893
+ case "major":
2894
+ return pre !== undefined && y === 0 && z === 0 ? `${x}.0.0` : `${x + 1}.0.0`;
2895
+ case "minor":
2896
+ return pre !== undefined && z === 0 ? `${x}.${y}.0` : `${x}.${y + 1}.0`;
2897
+ case "patch":
2898
+ return pre === undefined ? `${x}.${y}.${z + 1}` : `${x}.${y}.${z}`;
2899
+ case "premajor":
2900
+ return `${x + 1}.0.0-0`;
2901
+ case "preminor":
2902
+ return `${x}.${y + 1}.0-0`;
2903
+ case "prepatch":
2904
+ return `${x}.${y}.${z + 1}-0`;
2905
+ default: {
2906
+ if (pre === undefined)
2907
+ return `${x}.${y}.${z + 1}-0`;
2908
+ const numbered = LAST_NUMBER.exec(pre);
2909
+ return `${x}.${y}.${z}-${numbered === null ? `${pre}.0` : `${pre.slice(0, numbered.index)}${Number(numbered[0]) + 1}`}`;
2910
+ }
2911
+ }
2912
+ }
2134
2913
  async function packageDir(value) {
2135
2914
  for (const dir of [resolve(value), join2(await git(["rev-parse", "--show-toplevel"]), "packages", value)]) {
2136
2915
  if (await Bun.file(join2(dir, "package.json")).exists())
@@ -2169,15 +2948,50 @@ ${push.stderr.toString().trim()}`, 1);
2169
2948
  }
2170
2949
  return await watchRuns((runs) => newestPerWorkflow(runs.filter((run) => run.commit_sha === sha)), timeout, undefined, NO_RUN2) === 0;
2171
2950
  }
2951
+ async function printPlan({ level, dir, prefix, branch, base, timeout, registry }) {
2952
+ const file = Bun.file(join2(dir ?? ".", "package.json"));
2953
+ const pkg = await file.exists() ? await file.json() : undefined;
2954
+ const name = pkg?.name ?? basename2(dir ?? process13.cwd());
2955
+ const where = dir === undefined ? "" : ` in ${relative(process13.cwd(), dir) || "."}`;
2956
+ const sha = base.slice(0, 7);
2957
+ let version;
2958
+ let bump;
2959
+ if (level === undefined) {
2960
+ const tag = (await tagsAt("HEAD")).find((each) => taggedVersion(each, prefix) !== undefined);
2961
+ if (tag === undefined)
2962
+ fail(`HEAD carries no ${prefix}* tag`);
2963
+ version = tag.slice(prefix.length);
2964
+ bump = `take the version commit at HEAD, tagged ${tag}`;
2965
+ console.log(`would release ${name} ${version}, tagged ${tag}`);
2966
+ } else {
2967
+ if (pkg?.version === undefined)
2968
+ fail(`no version in ${join2(dir ?? ".", "package.json")}`);
2969
+ version = nextVersion(pkg.version, level);
2970
+ bump = dir === undefined ? `bun pm version ${level}, which commits and tags ${prefix}${version}` : `bun pm version ${level} --no-git-tag-version${where}, then commit it with bun.lock refreshed and tag ${prefix}${version}`;
2971
+ console.log(`would release ${name} ${pkg.version} -> ${version}, tagged ${prefix}${version}`);
2972
+ }
2973
+ const pushed = await gitOk(["merge-base", "--is-ancestor", base, `origin/${branch}`]);
2974
+ const registryStep = registry && pkg?.name !== undefined && pkg.private !== true ? `wait for the registry to serve ${pkg.name}@${version}` : `skip the registry: ${registry ? "the package is private or unnamed" : "--no-registry"}`;
2975
+ const steps = [
2976
+ pushed ? `wait for the runs on ${sha}, already on origin/${branch}` : `push ${branch} at ${sha}, which origin lacks, then wait for its runs`,
2977
+ bump,
2978
+ `push ${branch} and ${prefix}${version} in one atomic push, then wait for the runs it starts (${formatDuration(timeout)} at most)`,
2979
+ registryStep
2980
+ ];
2981
+ console.log(steps.map((step, i) => ` ${i + 1}. ${step}`).join(`
2982
+ `));
2983
+ console.log("dry run: nothing was pushed, committed or tagged");
2984
+ return 0;
2985
+ }
2172
2986
  async function release(args) {
2173
- const parsed = parse(args, usage4, options5);
2987
+ const parsed = parse(args, usage5, options6);
2174
2988
  const { values, positionals } = parsed;
2175
2989
  const level = positionals[0];
2176
2990
  const tagged = values.tagged === true;
2177
2991
  if (tagged === (level !== undefined))
2178
2992
  fail(`give a level or --tagged
2179
2993
 
2180
- ${usage4}`);
2994
+ ${usage5}`);
2181
2995
  if (level !== undefined && !LEVELS.has(level) && !VERSION.test(level))
2182
2996
  fail(`${level} is no level nor x.y.z version`);
2183
2997
  const timeout = duration(values.timeout, "timeout", 15 * 60000, "m");
@@ -2186,15 +3000,17 @@ ${usage4}`);
2186
3000
  const branch = await defaultBranch();
2187
3001
  const current = await git(["branch", "--show-current"]);
2188
3002
  if (current !== branch)
2189
- fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`);
3003
+ fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`, 2, runHint(`git switch ${branch}`));
2190
3004
  if (await git(["status", "--porcelain"]) !== "")
2191
- fail("the tree has uncommitted changes: commit or stash them first");
3005
+ fail("the tree has uncommitted changes", 2, runHint("gst", "see them, then commit them with gc"));
2192
3006
  await git(["fetch", "-q", "origin", branch]);
2193
3007
  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`);
3008
+ fail(`origin/${branch} has commits HEAD lacks`, 2, runHint(`git pull --rebase origin ${branch}`));
2195
3009
  const base = await git(["rev-parse", tagged ? "HEAD~1" : "HEAD"]);
3010
+ if (values["dry-run"] === true)
3011
+ return printPlan({ level, dir, prefix, branch, base, timeout, registry: values["no-registry"] !== true });
2196
3012
  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);
3013
+ fail(`the runs on ${base.slice(0, 7)} failed, so nothing was bumped or tagged`, 1, runHint(`ft runs --sha ${base.slice(0, 7)}`, "list the runs, then ft logs <id> reads the failure"));
2198
3014
  if (level !== undefined) {
2199
3015
  const bump = dir === undefined ? await $3`bun pm version ${level}`.quiet().nothrow() : await $3`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
2200
3016
  if (bump.exitCode !== 0)
@@ -2222,18 +3038,59 @@ ${push.stderr.toString().trim()}`, 1);
2222
3038
  const version = tag.slice(prefix.length);
2223
3039
  if (values["no-registry"] === true || pkg?.name === undefined || pkg.private === true) {
2224
3040
  console.log(`released ${tag}`);
3041
+ await settleMilestone(version, tag);
2225
3042
  return 0;
2226
3043
  }
2227
3044
  if (!await waitForRegistry(pkg.name, version))
2228
3045
  fail(`the runs passed but the registry does not serve ${pkg.name}@${version}: read the publish job with \`ft logs\``, 1);
2229
3046
  console.log(`released ${pkg.name}@${version}`);
3047
+ await settleMilestone(version, tag);
2230
3048
  return 0;
2231
3049
  }
3050
+ async function settleMilestone(version, tag) {
3051
+ try {
3052
+ const names = new Set([version, `v${version}`, tag]);
3053
+ const found = (await listMilestones("open")).find((each) => names.has(each.title));
3054
+ if (found === undefined)
3055
+ return;
3056
+ const [done, left] = await Promise.all([closedIssues(found.id), openIssues(undefined, undefined, found.id)]);
3057
+ console.log(`milestone ${found.title}: ${done.length} closed issue${done.length === 1 ? "" : "s"}${done.length === 0 ? "" : `
3058
+ ${done.map((issue) => ` #${issue.number} ${issue.title}`).join(`
3059
+ `)}`}`);
3060
+ if (left.length > 0)
3061
+ warn(`${left.length} issue${left.length === 1 ? " is" : "s are"} still open in it`, left.map((issue) => `#${issue.number}`).join(" "));
3062
+ await closeMilestone(found.id);
3063
+ console.log(`closed milestone ${found.title}`);
3064
+ } catch (error) {
3065
+ warn(`released, but the milestone ${version} could not be settled`, error instanceof Error ? error.message : String(error));
3066
+ }
3067
+ }
2232
3068
 
2233
3069
  // src/commands/remote.ts
2234
3070
  import { lookup } from "dns/promises";
3071
+ import { homedir as homedir3 } from "os";
2235
3072
  var {$: $4 } = globalThis.Bun;
2236
3073
 
3074
+ // src/config.ts
3075
+ import { homedir as homedir2 } from "os";
3076
+ import { join as join3 } from "path";
3077
+ function settingsPath(home = homedir2()) {
3078
+ return join3(home, ".config", "ft", "config.json");
3079
+ }
3080
+ async function loadSettings(home = homedir2()) {
3081
+ const file = Bun.file(settingsPath(home));
3082
+ if (!await file.exists())
3083
+ return {};
3084
+ let raw;
3085
+ try {
3086
+ raw = await file.json();
3087
+ } catch {
3088
+ return {};
3089
+ }
3090
+ const host = typeof raw === "object" && raw !== null && "remoteHost" in raw ? raw.remoteHost : undefined;
3091
+ return typeof host === "string" && host !== "" ? { remoteHost: host } : {};
3092
+ }
3093
+
2237
3094
  // src/remote.ts
2238
3095
  var SCP_LIKE = /^(?:([^@/]+)@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/;
2239
3096
  var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/;
@@ -2254,12 +3111,15 @@ async function reachesHost(hostname, host, hostAddresses, addressesOf) {
2254
3111
  }
2255
3112
 
2256
3113
  // src/commands/remote.ts
2257
- var usage5 = `ft remote [--fix]
3114
+ var usage6 = `ft remote [--fix]
2258
3115
 
2259
3116
  Checks that origin's URLs name the Forgejo host itself: tea only finds the repository when origin's host is the login's ssh_host, never through an ssh alias such as \`forgejo\` or \`gitea\`.
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)" },
3117
+ --fix switches every alias URL to \`git@<host>:<owner>/<repo>.git\` and proves it with \`git ls-remote\`.
3118
+ The host comes from --host, else \`remoteHost\` in ~/.config/ft/config.json; with neither, it checks nothing.
3119
+
3120
+ Exits 0 when origin names the Forgejo host (or was switched) or no host is set, 1 when it does not or a switch is still to run, 2 on a usage error.`;
3121
+ var options7 = {
3122
+ host: { type: "string", description: "the Forgejo ssh host, instead of remoteHost in ~/.config/ft/config.json" },
2263
3123
  fix: { type: "boolean", description: "rewrite the alias URLs" }
2264
3124
  };
2265
3125
  var HOSTNAME = /^hostname (.+)$/m;
@@ -2276,10 +3136,13 @@ async function addresses(name) {
2276
3136
  function originHost(url) {
2277
3137
  return parseSshRemote(url)?.host ?? (URL.canParse(url) ? new URL(url).hostname : undefined);
2278
3138
  }
2279
- async function remote(args) {
2280
- const parsed = parse(args, usage5, options6);
2281
- const { values } = parsed;
2282
- const host = values.host ?? "git.kevyn.fr";
3139
+ async function remote(args, home = homedir3()) {
3140
+ const { values } = parse(args, usage6, options7);
3141
+ const host = values.host ?? (await loadSettings(home)).remoteHost;
3142
+ if (host === undefined) {
3143
+ note(`no Forgejo host to check origin against: pass --host, or set "remoteHost" in ${settingsPath(home)}`);
3144
+ return 0;
3145
+ }
2283
3146
  const fetchUrl = await git(["remote", "get-url", "origin"]);
2284
3147
  const pushUrls = (await $4`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
2285
3148
  `).filter(Boolean);
@@ -2325,23 +3188,33 @@ async function remote(args) {
2325
3188
  }
2326
3189
 
2327
3190
  // src/commands/runs.ts
2328
- var usage6 = `ft runs [--sha <ref>]
3191
+ var usage7 = `ft runs [--sha <ref>]
2329
3192
 
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)" },
3193
+ Lists recent runs with what tea's own listing leaves empty: workflow, ref, duration and age, ten by default with the count of the rest (--full for all of them; --json is never cut unless --limit is given).
3194
+
3195
+ Exits 0 when it listed them, 1 when Forgejo or git cannot be read, 2 on a usage error.`;
3196
+ var DEFAULT_LIMIT = 10;
3197
+ var options8 = {
3198
+ limit: { ...limitOptions(DEFAULT_LIMIT).limit, short: "n" },
3199
+ full: limitOptions(DEFAULT_LIMIT).full,
2333
3200
  sha: { type: "string", short: "s", description: "only the runs of this commit" },
2334
3201
  workflow: { type: "string", short: "w", description: "only this workflow file, e.g. test.yml" },
2335
3202
  json: jsonOption(),
2336
3203
  ...repoOption
2337
3204
  };
2338
- var TITLE_WIDTH2 = 70;
3205
+ var TITLE_WIDTH4 = 70;
3206
+ async function onCommit(sha, repo) {
3207
+ const found = await runsOn(await resolveCommit(sha), repo);
3208
+ return { runs: found, total: found.length };
3209
+ }
2339
3210
  async function runs(args) {
2340
- const parsed = parse(args, usage6, options7);
3211
+ const parsed = parse(args, usage7, options8);
2341
3212
  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);
3213
+ const limit = limitOf(values, DEFAULT_LIMIT);
3214
+ const { runs: found, total } = values.sha === undefined ? await runsListing(limit, values.repo, values.workflow) : await onCommit(values.sha, values.repo);
3215
+ const matching = found.filter((run) => values.workflow === undefined || run.workflow_id === values.workflow);
3216
+ const list = matching.slice(0, limit);
3217
+ const more = values.sha === undefined ? total - list.length : matching.length - list.length;
2345
3218
  if (values.json === true) {
2346
3219
  printJson(list.map((run) => ({ id: run.id, number: run.index_in_repo, status: run.status, workflow: run.workflow_id, ref: run.prettyref, event: run.event, sha: run.commit_sha, seconds: runSeconds(run), started: run.started, title: run.title, url: run.html_url })));
2347
3220
  return 0;
@@ -2352,17 +3225,21 @@ async function runs(args) {
2352
3225
  }
2353
3226
  console.log(table([
2354
3227
  ["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
- ]));
3228
+ ...list.map((run) => [String(run.id), badge(run.status, process.stdout), run.workflow_id, run.prettyref, run.event, run.duration > 0 ? formatSeconds(runSeconds(run)) : "-", startedAge(run), truncate(upTo(run.title, `
3229
+ `), TITLE_WIDTH4)])
3230
+ ], { header: true }));
3231
+ if (more > 0)
3232
+ console.log(moreFooter(more));
2358
3233
  return 0;
2359
3234
  }
2360
3235
 
2361
3236
  // src/cli.ts
2362
- process8.exitCode = await runCli({
3237
+ process14.exitCode = await runCli({
2363
3238
  name: "ft",
2364
3239
  version,
2365
3240
  about: "Forgejo tools over tea, run from a repository whose origin is on Forgejo",
3241
+ replaces: "tea api calls piped through jq, and sleep loops waiting on CI",
3242
+ examples: [["ft ci", "wait for the runs of origin's tip, failures printed"], ["ft issue 12 -c", "an issue with its boxes numbered and its comments"]],
2366
3243
  commands: {
2367
3244
  runs: { run: runs, summary: "recent runs with workflow, ref, duration and age" },
2368
3245
  ci: { run: ci, summary: "wait for the runs covering a commit, printing what failed" },
@@ -2370,6 +3247,7 @@ process8.exitCode = await runCli({
2370
3247
  logs: { run: logs, summary: "what the failed jobs of a run say went wrong" },
2371
3248
  release: { run: release, summary: "bump, push branch and tag atomically, wait for runs and registry" },
2372
3249
  board: { run: board, summary: "open issues: available, taken by a live session, or claimed by one gone" },
3250
+ digest: { run: digest, summary: "repo state for a cold start: held and abandoned claims, red CI, unpushed commits, dirty files, stale TODOs" },
2373
3251
  take: { run: take, summary: "claim an issue for this Claude Code session" },
2374
3252
  drop: { run: drop, summary: "give back an issue this session took" },
2375
3253
  issue: { run: issue, summary: "an issue's claim, description and numbered boxes, -c comments" },
@@ -2382,6 +3260,8 @@ process8.exitCode = await runCli({
2382
3260
  reopen: { run: reopenIssues, summary: "reopen closed issues, -m posting the reason" },
2383
3261
  label: { run: label, summary: "add +name and remove -name labels of an issue" },
2384
3262
  plan,
3263
+ milestones: { run: milestones, summary: "milestones with their open and closed issues, progress and due date" },
3264
+ milestone: { run: milestone, summary: "show a milestone's issues like the board, `new` creates one, `close` closes it" },
2385
3265
  remote: { run: remote, summary: "check or fix an origin using an ssh alias tea cannot match" }
2386
3266
  },
2387
3267
  aliases: { show: "issue", view: "issue" }