@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.
- package/LICENSE +1 -1
- package/README.md +9 -6
- package/dist/cli.js +1067 -187
- 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
|
|
5
|
+
import process14 from "process";
|
|
6
6
|
|
|
7
7
|
// ../kit/src/args.ts
|
|
8
|
-
import
|
|
8
|
+
import process4 from "process";
|
|
9
9
|
import { parseArgs } from "util";
|
|
10
10
|
|
|
11
11
|
// ../kit/src/format.ts
|
|
12
|
-
import
|
|
12
|
+
import process3 from "process";
|
|
13
13
|
|
|
14
14
|
// ../kit/src/style.ts
|
|
15
|
-
import
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
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 (
|
|
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
|
|
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
|
|
527
|
-
...
|
|
528
|
-
|
|
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(
|
|
536
|
-
const styled = takesControlCodes(
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
979
|
-
dirs.add(join(
|
|
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 =
|
|
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
|
|
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,
|
|
1071
|
-
|
|
1072
|
-
{ heading: "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
1712
|
+
var RUNS_PAGE = 50;
|
|
1713
|
+
async function runsListing(limit, repo, workflow) {
|
|
1284
1714
|
const filter = workflow === undefined ? "" : `&workflow_id=${encodeURIComponent(workflow)}`;
|
|
1285
|
-
|
|
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}
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
2498
|
+
const parsed = parse(args, usage3, options3);
|
|
1893
2499
|
const { values, positionals } = parsed;
|
|
1894
2500
|
if (positionals[0] === undefined)
|
|
1895
|
-
fail(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2262
|
-
|
|
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
|
|
2281
|
-
const
|
|
2282
|
-
|
|
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
|
|
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
|
-
|
|
2332
|
-
|
|
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
|
|
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,
|
|
3211
|
+
const parsed = parse(args, usage7, options8);
|
|
2341
3212
|
const { values } = parsed;
|
|
2342
|
-
const limit =
|
|
2343
|
-
const found = values.sha === undefined ? await
|
|
2344
|
-
const
|
|
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
|
-
`),
|
|
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
|
-
|
|
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" }
|