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