@thebattlearena/corner 0.8.0 → 0.9.1-beta.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +38 -5
  2. package/fonts/battle-arena-icons/BattleArenaIcons.ttf +0 -0
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/hooks/icons.generated.ts +2 -0
  6. package/plugin/hooks/pane.tsx +16 -2
  7. package/plugin/hooks/register.tsx +62 -33
  8. package/plugin/hooks/ring-io.ts +59 -1
  9. package/plugin/hooks/ring-pane.tsx +125 -35
  10. package/plugin/hooks/ring.ts +73 -38
  11. package/plugin/hooks/sparring.ts +139 -0
  12. package/plugin/icons/rudo-amber-2x1.png +0 -0
  13. package/plugin/icons/rudo-amber-4x2.png +0 -0
  14. package/plugin/icons/rudo-amber-5x2.png +0 -0
  15. package/plugin/icons/rudo-amber-6x2.png +0 -0
  16. package/plugin/icons/rudo-grey-2x1.png +0 -0
  17. package/plugin/icons/rudo-grey-4x2.png +0 -0
  18. package/plugin/icons/rudo-grey-5x2.png +0 -0
  19. package/plugin/icons/rudo-grey-6x2.png +0 -0
  20. package/plugin/icons/rudo-pink-2x1.png +0 -0
  21. package/plugin/icons/rudo-pink-4x2.png +0 -0
  22. package/plugin/icons/rudo-pink-5x2.png +0 -0
  23. package/plugin/icons/rudo-pink-6x2.png +0 -0
  24. package/plugin/icons/rudo-pink-dim-2x1.png +0 -0
  25. package/plugin/icons/rudo-pink-dim-4x2.png +0 -0
  26. package/plugin/icons/rudo-pink-dim-5x2.png +0 -0
  27. package/plugin/icons/rudo-pink-dim-6x2.png +0 -0
  28. package/plugin/icons/rudo-red-2x1.png +0 -0
  29. package/plugin/icons/rudo-red-4x2.png +0 -0
  30. package/plugin/icons/rudo-red-5x2.png +0 -0
  31. package/plugin/icons/rudo-red-6x2.png +0 -0
  32. package/plugin/tests/ring-pane.test.tsx +154 -0
  33. package/plugin/tests/ring.test.ts +13 -4
  34. package/plugin/tests/sparring.test.ts +204 -0
  35. package/plugin/types/index.d.ts +25 -1
  36. package/ring/.claude-plugin/plugin.json +1 -1
  37. package/ring/agents/rudo.md +95 -0
  38. package/ring/scripts/chronicle.mjs +24 -0
  39. package/ring/scripts/config.mjs +47 -1
  40. package/ring/scripts/fight.mjs +238 -0
  41. package/ring/scripts/judges-card.mjs +53 -9
  42. package/ring/scripts/live-hook.mjs +42 -15
  43. package/ring/scripts/live.mjs +96 -1
  44. package/ring/scripts/mask-guard.mjs +184 -6
  45. package/ring/scripts/monitors.mjs +2 -1
  46. package/ring/scripts/play-by-play.mjs +39 -9
  47. package/ring/scripts/round-bell.mjs +10 -6
  48. package/ring/scripts/sparring-chronicle.mjs +470 -0
  49. package/ring/scripts/sparring.mjs +1161 -0
  50. package/ring/skills/play-by-play/SKILL.md +49 -3
  51. package/ring/skills/play-by-play/templates/chronicle.html +7 -0
  52. package/ring/skills/referee/SKILL.md +46 -2
  53. package/ring/skills/sparring/SKILL.md +408 -0
  54. package/ring/skills/sparring/references/host-signal.md +86 -0
  55. package/ring/skills/sparring/references/lessons.md +50 -0
  56. package/ring/skills/sparring/templates/pr-body.md +34 -0
  57. package/ring/skills/sparring/templates/rudo-brief.md +47 -0
  58. package/ring/skills/sparring/templates/sparring-card.md +33 -0
  59. package/ring/skills/weigh-in/SKILL.md +21 -3
@@ -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) && !ctx.copilotRoundLeft(gh, prNumberOf(gh)),
1064
+ requestsCopilot(gh) &&
1065
+ ctx.isBoutPr(gh, prNumberOf(gh)) &&
1066
+ !ctx.copilotRoundLeft(gh, prNumberOf(gh)),
951
1067
  reason: () =>
952
- 'Requesting a Copilot review is blocked while a ring bout is active, unless /ring:title-defense set its flag for this PR 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.',
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) && !ctx.titleDefenseAllows(gh, prNumberOf(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 is blocked while a ring bout is active, unless /ring:title-defense set its flag for this PR. Never reply or resolve on the human's behalf otherwise.",
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
- return { ...loaded().gates, ...loaded().venueGates };
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
- function collect([pr, ...argv]) {
80
- if (!pr)
81
- throw new Error(
82
- 'usage: play-by-play.mjs <pr> [--repo <owner/name>] [--tz <zone>] [--extra <json|file>] [--no-gh]',
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
- const timezone =
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 = 'do-not-merge';
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
- /** Is the label already in the host repo? Then it is left as the repo has it (color, description). */
180
- export function labelExists(read) {
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
- LABEL,
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 === LABEL);
197
+ return labels.some((label) => label.name === name);
194
198
  }
195
199
 
196
200
  function open(argv) {