@thebattlearena/corner 0.8.0 → 0.9.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/README.md +38 -5
- package/fonts/battle-arena-icons/BattleArenaIcons.ttf +0 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/icons.generated.ts +2 -0
- package/plugin/hooks/pane.tsx +16 -2
- package/plugin/hooks/register.tsx +62 -33
- package/plugin/hooks/ring-io.ts +59 -1
- package/plugin/hooks/ring-pane.tsx +125 -35
- package/plugin/hooks/ring.ts +73 -38
- package/plugin/hooks/sparring.ts +139 -0
- package/plugin/icons/rudo-amber-2x1.png +0 -0
- package/plugin/icons/rudo-amber-4x2.png +0 -0
- package/plugin/icons/rudo-amber-5x2.png +0 -0
- package/plugin/icons/rudo-amber-6x2.png +0 -0
- package/plugin/icons/rudo-grey-2x1.png +0 -0
- package/plugin/icons/rudo-grey-4x2.png +0 -0
- package/plugin/icons/rudo-grey-5x2.png +0 -0
- package/plugin/icons/rudo-grey-6x2.png +0 -0
- package/plugin/icons/rudo-pink-2x1.png +0 -0
- package/plugin/icons/rudo-pink-4x2.png +0 -0
- package/plugin/icons/rudo-pink-5x2.png +0 -0
- package/plugin/icons/rudo-pink-6x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-2x1.png +0 -0
- package/plugin/icons/rudo-pink-dim-4x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-5x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-6x2.png +0 -0
- package/plugin/icons/rudo-red-2x1.png +0 -0
- package/plugin/icons/rudo-red-4x2.png +0 -0
- package/plugin/icons/rudo-red-5x2.png +0 -0
- package/plugin/icons/rudo-red-6x2.png +0 -0
- package/plugin/tests/ring-pane.test.tsx +154 -0
- package/plugin/tests/ring.test.ts +13 -4
- package/plugin/tests/sparring.test.ts +204 -0
- package/plugin/types/index.d.ts +25 -1
- package/ring/.claude-plugin/plugin.json +1 -1
- package/ring/agents/rudo.md +95 -0
- package/ring/scripts/chronicle.mjs +24 -0
- package/ring/scripts/config.mjs +47 -1
- package/ring/scripts/fight.mjs +238 -0
- package/ring/scripts/judges-card.mjs +53 -9
- package/ring/scripts/live-hook.mjs +42 -15
- package/ring/scripts/live.mjs +96 -1
- package/ring/scripts/mask-guard.mjs +184 -6
- package/ring/scripts/monitors.mjs +2 -1
- package/ring/scripts/play-by-play.mjs +39 -9
- package/ring/scripts/round-bell.mjs +10 -6
- package/ring/scripts/sparring-chronicle.mjs +470 -0
- package/ring/scripts/sparring.mjs +1161 -0
- package/ring/skills/play-by-play/SKILL.md +49 -3
- package/ring/skills/play-by-play/templates/chronicle.html +7 -0
- package/ring/skills/referee/SKILL.md +46 -2
- package/ring/skills/sparring/SKILL.md +408 -0
- package/ring/skills/sparring/references/host-signal.md +86 -0
- package/ring/skills/sparring/references/lessons.md +50 -0
- package/ring/skills/sparring/templates/pr-body.md +34 -0
- package/ring/skills/sparring/templates/rudo-brief.md +47 -0
- package/ring/skills/sparring/templates/sparring-card.md +33 -0
- package/ring/skills/weigh-in/SKILL.md +21 -3
package/ring/scripts/live.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The live feed of a bout: `<bout dir>/live.jsonl`, one JSON event per line, the last ones only.
|
|
3
|
-
// Ringside reads it every couple of seconds to show who is acting right now.
|
|
3
|
+
// Ringside reads it every couple of seconds to show who is acting right now. A sparring keeps the
|
|
4
|
+
// same feed in `<ring home>/sparrings/<slug>/live.jsonl` (árbitro, rudo and juez only).
|
|
4
5
|
// Zero-dependency. Every writer is BEST EFFORT: it never throws, never blocks and never prints
|
|
5
6
|
// anything to the user; a feed that cannot be written is simply not written.
|
|
6
7
|
//
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
closeSync,
|
|
31
32
|
existsSync,
|
|
32
33
|
openSync,
|
|
34
|
+
readdirSync,
|
|
33
35
|
readFileSync,
|
|
34
36
|
renameSync,
|
|
35
37
|
rmSync,
|
|
@@ -441,3 +443,96 @@ export function sessionRegistered(dir, sessionId) {
|
|
|
441
443
|
return false;
|
|
442
444
|
}
|
|
443
445
|
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------- the sparrings' sessions
|
|
448
|
+
|
|
449
|
+
/** Where the sparrings live: `<ring home>/sparrings/<slug>/sparring.json` (+ its live.jsonl). */
|
|
450
|
+
export const SPARRINGS_DIR = 'sparrings';
|
|
451
|
+
export const SPARRING_FILE = 'sparring.json';
|
|
452
|
+
/** Verdicts that end a sparring: after them nobody writes its feed and Ringside stops showing it. */
|
|
453
|
+
export const FINAL_VERDICTS = ['ko', 'discard'];
|
|
454
|
+
|
|
455
|
+
/** Is the sparring still being fought (no verdict, or a rematch)? PURE. */
|
|
456
|
+
export const isOpenSparring = (state) =>
|
|
457
|
+
!!state && !FINAL_VERDICTS.includes(state.verdict?.call);
|
|
458
|
+
|
|
459
|
+
export const sparringsHome = (env = process.env) =>
|
|
460
|
+
join(ringHome(env), SPARRINGS_DIR);
|
|
461
|
+
|
|
462
|
+
const readSparringFile = (dir) =>
|
|
463
|
+
JSON.parse(readFileSync(join(dir, SPARRING_FILE), 'utf8'));
|
|
464
|
+
|
|
465
|
+
/** Every sparring on this machine as { dir, state }; an unreadable one is skipped. Never throws. */
|
|
466
|
+
export function sparringsOf(env = process.env) {
|
|
467
|
+
try {
|
|
468
|
+
const home = sparringsHome(env);
|
|
469
|
+
return readdirSync(home)
|
|
470
|
+
.map((slug) => join(home, slug))
|
|
471
|
+
.flatMap((dir) => {
|
|
472
|
+
try {
|
|
473
|
+
return [{ dir, state: readSparringFile(dir) }];
|
|
474
|
+
} catch {
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
} catch {
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const sparringRecency = (dir) =>
|
|
484
|
+
Math.max(
|
|
485
|
+
mtime(join(dir, LIVE_LOG)),
|
|
486
|
+
mtime(join(dir, SPARRING_FILE)),
|
|
487
|
+
mtime(join(dir, 'card.md')),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The open sparring this session registered, for an event with `hint` (the Agent description: the
|
|
492
|
+
* rudo's says «Rudo: <slug> <repo>»). PURE over the sparrings list ({ dir, state }): the one the
|
|
493
|
+
* hint names, else the most recently touched (`recencyOf`). Null when the session registered none.
|
|
494
|
+
*/
|
|
495
|
+
export function pickSparring(
|
|
496
|
+
sparrings,
|
|
497
|
+
{ sessionId, hint },
|
|
498
|
+
recencyOf = sparringRecency,
|
|
499
|
+
) {
|
|
500
|
+
const own = sparrings.filter(
|
|
501
|
+
({ state }) => isOpenSparring(state) && isRegistered(state, sessionId),
|
|
502
|
+
);
|
|
503
|
+
const words = new Set(
|
|
504
|
+
String(hint ?? '')
|
|
505
|
+
.toLowerCase()
|
|
506
|
+
.split(/[^a-z0-9-]+/),
|
|
507
|
+
);
|
|
508
|
+
const named = own.find(({ state }) => words.has(state.slug));
|
|
509
|
+
if (named) return named.dir;
|
|
510
|
+
return (
|
|
511
|
+
[...own].sort((a, b) => recencyOf(b.dir) - recencyOf(a.dir))[0]?.dir ?? null
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The sparring directory of this session for an event, or null. Never throws. */
|
|
516
|
+
export function findSparring({ sessionId, hint } = {}, env = process.env) {
|
|
517
|
+
if (!sessionId) return null;
|
|
518
|
+
return pickSparring(sparringsOf(env), { sessionId, hint });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Registers a session in a sparring's sparring.json (`sessions`, like a bout's). Never throws;
|
|
523
|
+
* true when it is registered. Only the file is rewritten: the card does not list sessions.
|
|
524
|
+
*/
|
|
525
|
+
export function registerSparringSession(dir, sessionId, role = 'referee') {
|
|
526
|
+
try {
|
|
527
|
+
const state = readSparringFile(dir);
|
|
528
|
+
const next = withSession(state, sessionId, role);
|
|
529
|
+
if (next !== state)
|
|
530
|
+
writeFileSync(
|
|
531
|
+
join(dir, SPARRING_FILE),
|
|
532
|
+
JSON.stringify(next, null, 2) + '\n',
|
|
533
|
+
);
|
|
534
|
+
return isRegistered(next, sessionId);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
@@ -26,8 +26,11 @@ import {
|
|
|
26
26
|
boutDir,
|
|
27
27
|
chronicleStale,
|
|
28
28
|
DEFAULTS,
|
|
29
|
+
DO_NOT_MERGE,
|
|
29
30
|
isMain,
|
|
31
|
+
isSparringBranch,
|
|
30
32
|
loadConfig,
|
|
33
|
+
OPTIONAL_GATES,
|
|
31
34
|
projectDir,
|
|
32
35
|
repoSlug,
|
|
33
36
|
slugFromRemote,
|
|
@@ -642,7 +645,117 @@ function pullAvoidsMerge(git) {
|
|
|
642
645
|
return git.configs['pull.ff'] === 'only';
|
|
643
646
|
}
|
|
644
647
|
|
|
648
|
+
// ---------------------------------------------------------------- sparring drafts
|
|
649
|
+
|
|
650
|
+
/** The current branch of `dir`, or null when git cannot say: a sparring rule never fails a command. */
|
|
651
|
+
function branchOrNull(ctx, dir) {
|
|
652
|
+
try {
|
|
653
|
+
return ctx.currentBranch(dir);
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Does a `.env.sparring` (anywhere in the repo) go into this commit? */
|
|
660
|
+
const commitsSparringEnv = (git, ctx) =>
|
|
661
|
+
ctx
|
|
662
|
+
.committedFiles(git, commitsWorkingTree(git))
|
|
663
|
+
.some((path) => basename(path) === '.env.sparring');
|
|
664
|
+
|
|
665
|
+
/** Does a push take a sparring branch (by name, or HEAD on one) to a branch that is not one? */
|
|
666
|
+
function pushesSparringOut(git, ctx) {
|
|
667
|
+
const positional = git.args.filter((arg) => !arg.startsWith('-'));
|
|
668
|
+
const refspecs = positional.slice(1);
|
|
669
|
+
const current = () => branchOrNull(ctx, git.dir);
|
|
670
|
+
if (!refspecs.length) return false;
|
|
671
|
+
return refspecs.some((refspec) => {
|
|
672
|
+
const plain = refspec.replace(/^\+/, '');
|
|
673
|
+
if (!plain.includes(':')) return false;
|
|
674
|
+
const [source, target] = plain.split(':');
|
|
675
|
+
if (!source || isSparringBranch(target)) return false;
|
|
676
|
+
const from = source === 'HEAD' ? current() : source;
|
|
677
|
+
return isSparringBranch(from);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The value of `--name <v>`, `--name=<v>` or `-x <v>` in gh args, every time it appears. PURE. */
|
|
682
|
+
function optionValues(args, ...names) {
|
|
683
|
+
const values = [];
|
|
684
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
685
|
+
const inline = names.find((name) => args[i].startsWith(`${name}=`));
|
|
686
|
+
if (inline) values.push(args[i].slice(inline.length + 1));
|
|
687
|
+
if (names.includes(args[i]) && i + 1 < args.length)
|
|
688
|
+
values.push(args[i + 1]);
|
|
689
|
+
}
|
|
690
|
+
return values;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** The head branch a `gh pr create` opens from: --head, else the current branch. */
|
|
694
|
+
const createHead = (gh, ctx) =>
|
|
695
|
+
optionValues(gh.args, '--head', '-H')[0] ?? branchOrNull(ctx, null);
|
|
696
|
+
|
|
697
|
+
/** A `gh pr create` from a sparring branch that is not a draft with the do-not-merge label. */
|
|
698
|
+
function sparringCreateUnsafe(gh, ctx) {
|
|
699
|
+
if (gh.group !== 'pr' || gh.action !== 'create') return false;
|
|
700
|
+
if (!isSparringBranch(createHead(gh, ctx))) return false;
|
|
701
|
+
const labels = optionValues(gh.args, '--label', '-l').flatMap((value) =>
|
|
702
|
+
value.split(','),
|
|
703
|
+
);
|
|
704
|
+
return !hasFlag(gh.args, '--draft', '-d') || !labels.includes(DO_NOT_MERGE);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Does `gh pr ready|merge` target a sparring PR (by branch, number/URL, or the current branch)? */
|
|
708
|
+
function targetsSparringPr(gh, ctx) {
|
|
709
|
+
if (gh.group !== 'pr' || !['ready', 'merge'].includes(gh.action))
|
|
710
|
+
return false;
|
|
711
|
+
if (gh.action === 'ready' && gh.args.includes('--undo')) return false;
|
|
712
|
+
const target = gh.args.find((arg) => !arg.startsWith('-'));
|
|
713
|
+
if (!target) return isSparringBranch(branchOrNull(ctx, null));
|
|
714
|
+
if (isSparringBranch(target)) return true;
|
|
715
|
+
const number = /^(\d+)$|\/pull\/(\d+)/.exec(target);
|
|
716
|
+
if (!number) return false;
|
|
717
|
+
return isSparringBranch(ctx.prHead(gh, Number(number[1] ?? number[2])));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const sparringGitRules = [
|
|
721
|
+
{
|
|
722
|
+
always: true,
|
|
723
|
+
when: (git, ctx) => git.sub === 'commit' && commitsSparringEnv(git, ctx),
|
|
724
|
+
reason: () =>
|
|
725
|
+
'`.env.sparring` holds the credentials of a sparring and is never committed. Unstage it (`git restore --staged .env.sparring`) and commit again.',
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
always: true,
|
|
729
|
+
when: (git) =>
|
|
730
|
+
git.sub === 'merge' && git.args.some((arg) => isSparringBranch(arg)),
|
|
731
|
+
reason: () =>
|
|
732
|
+
'A sparring branch is never merged: it only proves value. A KO is promoted with `node "$RING/scripts/sparring.mjs" promote <slug>` (mainevent/<slug> from its tip, into release/<slug>), and a ring bout cleans the main event.',
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
always: true,
|
|
736
|
+
when: (git, ctx) => git.sub === 'push' && pushesSparringOut(git, ctx),
|
|
737
|
+
reason: () =>
|
|
738
|
+
"A sparring branch is pushed only to `sparring/*`. Its code leaves only through a KO: `node \"$RING/scripts/sparring.mjs\" promote <slug>`, which refuses unless the verdict is ko.",
|
|
739
|
+
},
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
const sparringGhRules = [
|
|
743
|
+
{
|
|
744
|
+
always: true,
|
|
745
|
+
when: (gh, ctx) => sparringCreateUnsafe(gh, ctx),
|
|
746
|
+
reason: () =>
|
|
747
|
+
`A PR from a sparring branch is opened as a draft with the ${DO_NOT_MERGE} label: use \`node "$RING/scripts/sparring.mjs" pr <slug>\`, which adds both.`,
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
always: true,
|
|
751
|
+
when: (gh, ctx) => targetsSparringPr(gh, ctx),
|
|
752
|
+
reason: () =>
|
|
753
|
+
'A sparring PR is never taken out of draft nor merged: it only proves value. On a KO, `sparring.mjs promote <slug>` opens the deliverable PR (mainevent → release).',
|
|
754
|
+
},
|
|
755
|
+
];
|
|
756
|
+
|
|
645
757
|
const gitRules = [
|
|
758
|
+
...sparringGitRules,
|
|
646
759
|
{
|
|
647
760
|
always: true,
|
|
648
761
|
when: (git) =>
|
|
@@ -921,6 +1034,7 @@ export function prNumberOf(gh) {
|
|
|
921
1034
|
}
|
|
922
1035
|
|
|
923
1036
|
const ghRules = [
|
|
1037
|
+
...sparringGhRules,
|
|
924
1038
|
{
|
|
925
1039
|
when: (gh) =>
|
|
926
1040
|
gh.group === 'pr' &&
|
|
@@ -947,15 +1061,20 @@ const ghRules = [
|
|
|
947
1061
|
},
|
|
948
1062
|
{
|
|
949
1063
|
when: (gh, ctx) =>
|
|
950
|
-
requestsCopilot(gh) &&
|
|
1064
|
+
requestsCopilot(gh) &&
|
|
1065
|
+
ctx.isBoutPr(gh, prNumberOf(gh)) &&
|
|
1066
|
+
!ctx.copilotRoundLeft(gh, prNumberOf(gh)),
|
|
951
1067
|
reason: () =>
|
|
952
|
-
'Requesting a Copilot review
|
|
1068
|
+
'Requesting a Copilot review on the PR of an active ring bout is blocked unless /ring:title-defense set its flag for it and it has rounds left (at most --until-round requests after the flag was set): the human reviews first, and every re-review costs the org. Other PRs of the repo are free.',
|
|
953
1069
|
},
|
|
954
1070
|
{
|
|
955
|
-
when: (gh, ctx) =>
|
|
956
|
-
touchesReviewThread(gh)
|
|
1071
|
+
when: (gh, ctx) => {
|
|
1072
|
+
if (!touchesReviewThread(gh)) return false;
|
|
1073
|
+
const pr = prNumberOf(gh) ?? ctx.prOfThread(gh);
|
|
1074
|
+
return ctx.isBoutPr(gh, pr) && !ctx.titleDefenseAllows(gh, pr);
|
|
1075
|
+
},
|
|
957
1076
|
reason: () =>
|
|
958
|
-
"Replying to or resolving review threads
|
|
1077
|
+
"Replying to or resolving review threads on the PR of an active ring bout is blocked unless /ring:title-defense set its flag for it. Never reply or resolve on the human's behalf otherwise; other PRs of the repo are free.",
|
|
959
1078
|
},
|
|
960
1079
|
{
|
|
961
1080
|
when: (gh) => dismissesReview(gh),
|
|
@@ -1169,6 +1288,9 @@ function judgeSegment(segment, words, cwd, ctx) {
|
|
|
1169
1288
|
(path) => ctx.committedHash(commit, path, wide),
|
|
1170
1289
|
);
|
|
1171
1290
|
},
|
|
1291
|
+
// Only the PR of an active bout is guarded; a PR the guard cannot name counts as one (fail closed).
|
|
1292
|
+
isBoutPr: (subject, pr) =>
|
|
1293
|
+
pr === null || ctx.boutPrs({ git, gh: subject, dir: cwd }).includes(pr),
|
|
1172
1294
|
// The flag expires: once its PR got `untilRound` Copilot requests, no more (PR ids: every flag).
|
|
1173
1295
|
copilotRoundLeft: (subject, pr) => {
|
|
1174
1296
|
const where = { git, gh: subject, dir: cwd };
|
|
@@ -1332,7 +1454,11 @@ function realContext(cwd) {
|
|
|
1332
1454
|
return loaded().heavyCommands ?? [];
|
|
1333
1455
|
},
|
|
1334
1456
|
get gates() {
|
|
1335
|
-
|
|
1457
|
+
// The optional gates (`build`) are the sparring vestidor's, not a bout's: no heavy lock.
|
|
1458
|
+
const gates = Object.entries(loaded().gates).filter(
|
|
1459
|
+
([gate]) => !OPTIONAL_GATES.includes(gate),
|
|
1460
|
+
);
|
|
1461
|
+
return { ...Object.fromEntries(gates), ...loaded().venueGates };
|
|
1336
1462
|
},
|
|
1337
1463
|
home: homedir(),
|
|
1338
1464
|
env: process.env,
|
|
@@ -1453,6 +1579,36 @@ function realContext(cwd) {
|
|
|
1453
1579
|
return contentHash(null);
|
|
1454
1580
|
}
|
|
1455
1581
|
},
|
|
1582
|
+
// The PRs of the repo's active bouts: only they are guarded against Copilot requests and replies.
|
|
1583
|
+
boutPrs: (where) => {
|
|
1584
|
+
const slug = slugWhere(where);
|
|
1585
|
+
if (!slug) return [];
|
|
1586
|
+
return boutsOf(slug).map(({ state }) => state.pr);
|
|
1587
|
+
},
|
|
1588
|
+
// The PR a GraphQL review-thread mutation acts on, from its thread id (PRRT_…); null when unknown.
|
|
1589
|
+
prOfThread: (gh) => {
|
|
1590
|
+
const thread = /\bPRRT_[A-Za-z0-9_-]+/.exec(gh.args.join(' '))?.[0];
|
|
1591
|
+
if (!thread) return null;
|
|
1592
|
+
try {
|
|
1593
|
+
const out = execFileSync(
|
|
1594
|
+
'gh',
|
|
1595
|
+
[
|
|
1596
|
+
'api',
|
|
1597
|
+
'graphql',
|
|
1598
|
+
'-f',
|
|
1599
|
+
'query=query($t:ID!){node(id:$t){... on PullRequestReviewThread{pullRequest{number}}}}',
|
|
1600
|
+
'-F',
|
|
1601
|
+
`t=${thread}`,
|
|
1602
|
+
'-q',
|
|
1603
|
+
'.data.node.pullRequest.number',
|
|
1604
|
+
],
|
|
1605
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
1606
|
+
).trim();
|
|
1607
|
+
return /^\d+$/.test(out) ? Number(out) : null;
|
|
1608
|
+
} catch {
|
|
1609
|
+
return null;
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1456
1612
|
// PRs whose own bout carries the title-defense flag (set by /ring:title-defense).
|
|
1457
1613
|
titleDefensePrs: (where) => {
|
|
1458
1614
|
const slug = slugWhere(where);
|
|
@@ -1512,6 +1668,28 @@ function realContext(cwd) {
|
|
|
1512
1668
|
git(dirOf(dir), ['status', '--porcelain', '--untracked-files=no']) !== '',
|
|
1513
1669
|
currentBranch: (dir) =>
|
|
1514
1670
|
git(dirOf(dir), ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
1671
|
+
// The head branch of a PR, or null when gh cannot say: outside a bout an unreachable GitHub
|
|
1672
|
+
// must not block every `gh pr ready`, and during one the bout rules still block it.
|
|
1673
|
+
prHead: (gh, pr) => {
|
|
1674
|
+
try {
|
|
1675
|
+
return execFileSync(
|
|
1676
|
+
'gh',
|
|
1677
|
+
[
|
|
1678
|
+
'pr',
|
|
1679
|
+
'view',
|
|
1680
|
+
String(pr),
|
|
1681
|
+
...(gh.repo ? ['-R', gh.repo] : []),
|
|
1682
|
+
'--json',
|
|
1683
|
+
'headRefName',
|
|
1684
|
+
'-q',
|
|
1685
|
+
'.headRefName',
|
|
1686
|
+
],
|
|
1687
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
1688
|
+
).trim();
|
|
1689
|
+
} catch {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1515
1693
|
readFile: (file, dir) => {
|
|
1516
1694
|
const path = isAbsolute(file) ? file : resolve(dirOf(dir), file);
|
|
1517
1695
|
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// runs that slash command (another mod's, say, that opens its own pane), `{ "pane": "<id>" }`
|
|
17
17
|
// names a pane the person opens themselves (one mod cannot open another mod's pane).
|
|
18
18
|
|
|
19
|
-
/** The ring's own actors. A monitor can never take one of these ids. */
|
|
19
|
+
/** The ring's own actors (the rudo writes sparrings). A monitor can never take one of these ids. */
|
|
20
20
|
export const CORE_ACTORS = [
|
|
21
21
|
'arbitro',
|
|
22
22
|
'luchador',
|
|
@@ -24,6 +24,7 @@ export const CORE_ACTORS = [
|
|
|
24
24
|
'campana',
|
|
25
25
|
'copilot',
|
|
26
26
|
'juez',
|
|
27
|
+
'rudo',
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const ID = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// Deterministic and read-only: the bout dir, git and GitHub. Zero-dependency.
|
|
4
4
|
//
|
|
5
5
|
// node play-by-play.mjs <pr> [--repo <owner/name>] [--tz <Area/City>] [--extra <json|file>] [--no-gh]
|
|
6
|
+
// node play-by-play.mjs --sparring <slug> [--tz <Area/City>] [--extra <json|file>] [--no-gh]
|
|
7
|
+
//
|
|
8
|
+
// A fight that starts at a sparring is one chronicle (fight.mjs): `--sparring <slug>`, or a bout
|
|
9
|
+
// opened `--from-sparring <slug>`, prints the sparring's falls and every bout of it as the third.
|
|
6
10
|
//
|
|
7
11
|
// Output: { pr, repo, boutDir, generatedAt, timezone, copilotOnHead, rounds, events, score, gaps,
|
|
8
12
|
// notes }. Every event carries its real time (`at`, plus `local` HH:MM in --tz) and its `source`.
|
|
@@ -11,7 +15,7 @@
|
|
|
11
15
|
// { at, type, title, detail, ref }, inline or in a file), always marked `provided`.
|
|
12
16
|
import { execFileSync } from 'node:child_process';
|
|
13
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
15
19
|
|
|
16
20
|
import {
|
|
17
21
|
checkEvents,
|
|
@@ -34,6 +38,7 @@ import {
|
|
|
34
38
|
repoSlug,
|
|
35
39
|
slugFromRemote,
|
|
36
40
|
} from './config.mjs';
|
|
41
|
+
import { collectFight } from './fight.mjs';
|
|
37
42
|
|
|
38
43
|
const run = (bin, args, cwd) =>
|
|
39
44
|
execFileSync(bin, args, {
|
|
@@ -76,18 +81,27 @@ export function readExtra(value) {
|
|
|
76
81
|
return extraEvents(items, inline ? 'inline' : value);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const USAGE =
|
|
85
|
+
'usage: play-by-play.mjs <pr> [--repo <owner/name>] | --sparring <slug>; [--tz <zone>] [--extra <json|file>] [--no-gh]';
|
|
86
|
+
|
|
87
|
+
const timezoneOf = (argv) =>
|
|
88
|
+
option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
89
|
+
|
|
90
|
+
/** The bout directory of `pr`: in `--repo`, else in this checkout's repo. */
|
|
91
|
+
const boutDirOf = (pr, argv) => {
|
|
84
92
|
const repoFlag = option(argv, 'repo');
|
|
85
|
-
|
|
86
|
-
option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
87
|
-
const dir = boutDir(
|
|
93
|
+
return boutDir(
|
|
88
94
|
repoFlag ? slugFromRemote(repoFlag) : repoSlug(projectDir()),
|
|
89
95
|
pr,
|
|
90
96
|
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** One bout's chronicle: its ledgers, git and GitHub. */
|
|
100
|
+
export function collectBout([pr, ...argv]) {
|
|
101
|
+
if (!pr) throw new Error(USAGE);
|
|
102
|
+
const repoFlag = option(argv, 'repo');
|
|
103
|
+
const timezone = timezoneOf(argv);
|
|
104
|
+
const dir = boutDirOf(pr, argv);
|
|
91
105
|
if (!existsSync(join(dir, 'state.json')))
|
|
92
106
|
throw new Error(`no bout for #${pr} in ${dir}`);
|
|
93
107
|
const state = readState(dir);
|
|
@@ -242,6 +256,22 @@ function collect([pr, ...argv]) {
|
|
|
242
256
|
};
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The chronicle a command asks for: `--sparring <slug>` is the whole fight from that sparring; a
|
|
261
|
+
* bout opened `--from-sparring` is too (one fight, one page); any other bout is its own chronicle.
|
|
262
|
+
*/
|
|
263
|
+
function collect(argv) {
|
|
264
|
+
const slug = option(argv, 'sparring');
|
|
265
|
+
if (slug) return collectFight(slug, argv, { collectBout, readExtra });
|
|
266
|
+
const [pr, ...rest] = argv;
|
|
267
|
+
if (!pr) throw new Error(USAGE);
|
|
268
|
+
const file = join(boutDirOf(pr, rest), 'state.json');
|
|
269
|
+
const from = existsSync(file) ? readState(dirname(file)).fromSparring : null;
|
|
270
|
+
return from
|
|
271
|
+
? collectFight(from, rest, { collectBout, readExtra })
|
|
272
|
+
: collectBout(argv);
|
|
273
|
+
}
|
|
274
|
+
|
|
245
275
|
if (isMain(import.meta.url)) {
|
|
246
276
|
try {
|
|
247
277
|
console.log(JSON.stringify(collect(process.argv.slice(2)), null, 2));
|
|
@@ -21,6 +21,7 @@ import { join } from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
boutDir,
|
|
24
|
+
DO_NOT_MERGE,
|
|
24
25
|
isMain,
|
|
25
26
|
projectDir,
|
|
26
27
|
readState,
|
|
@@ -39,7 +40,7 @@ import {
|
|
|
39
40
|
import { render, TEMPLATES } from './judges-card.mjs';
|
|
40
41
|
import { record } from './live.mjs';
|
|
41
42
|
|
|
42
|
-
export const LABEL =
|
|
43
|
+
export const LABEL = DO_NOT_MERGE;
|
|
43
44
|
|
|
44
45
|
/** Paths that are tests in the usual backend layouts; excluded from the production line count. */
|
|
45
46
|
export const TEST_EXCLUDES = [
|
|
@@ -176,21 +177,24 @@ function isAncestor(read, ancestor, sha) {
|
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
/**
|
|
180
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Is the label already in the host repo? Then it is left as the repo has it (color, description).
|
|
182
|
+
* `gh label list --json` prints nothing at all (not `[]`) when the search finds no label.
|
|
183
|
+
*/
|
|
184
|
+
export function labelExists(read, name = LABEL) {
|
|
181
185
|
const labels = JSON.parse(
|
|
182
186
|
read('gh', [
|
|
183
187
|
'label',
|
|
184
188
|
'list',
|
|
185
189
|
'--search',
|
|
186
|
-
|
|
190
|
+
name,
|
|
187
191
|
'--json',
|
|
188
192
|
'name',
|
|
189
193
|
'--limit',
|
|
190
194
|
'100',
|
|
191
|
-
]),
|
|
195
|
+
]) || '[]',
|
|
192
196
|
);
|
|
193
|
-
return labels.some((label) => label.name ===
|
|
197
|
+
return labels.some((label) => label.name === name);
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function open(argv) {
|