@intentius/chant 0.123.0 → 0.124.1

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 (62) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/config-import.d.ts +1 -1
  3. package/dist/lint/policy-import.d.ts +1 -1
  4. package/dist/notify-verify.js +76 -0
  5. package/dist/notify.js +307 -0
  6. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  7. package/dist/op/index.d.ts +4 -4
  8. package/dist/op/index.d.ts.map +1 -1
  9. package/dist/op/op-waves-run.d.ts +134 -2
  10. package/dist/op/op-waves-run.d.ts.map +1 -1
  11. package/dist/op/op-waves.d.ts +14 -0
  12. package/dist/op/op-waves.d.ts.map +1 -1
  13. package/dist/pr-forge.d.ts +30 -3
  14. package/dist/pr-forge.d.ts.map +1 -1
  15. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  16. package/dist/workspace/conformance/broker.d.ts +1 -1
  17. package/package.json +14 -2
  18. package/src/cli/commands/check-lexicon.ts +3 -1
  19. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  20. package/src/cli/commands/lint.test.ts +4 -109
  21. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  22. package/src/config-import.ts +1 -1
  23. package/src/discovery/sandbox/run.test.ts +1 -1
  24. package/src/lint/policy-import.ts +1 -1
  25. package/src/notify-plain-node.e2e.test.ts +68 -0
  26. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  27. package/src/op/index.ts +18 -2
  28. package/src/op/op-waves-run.ts +454 -83
  29. package/src/op/op-waves.test.ts +191 -1
  30. package/src/op/op-waves.ts +29 -1
  31. package/src/pr-forge.test.ts +76 -1
  32. package/src/pr-forge.ts +52 -8
  33. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  34. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  35. package/src/workspace/conformance/broker.ts +1 -1
  36. package/src/workspace/records-write.e2e.test.ts +1 -1
  37. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  38. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  39. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  40. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  41. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  42. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  43. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  44. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  45. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  46. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  47. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  48. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  49. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  50. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  51. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  52. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  53. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  54. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  55. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  56. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  57. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  58. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  59. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  60. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  61. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  62. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
@@ -3,7 +3,18 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { assertOpWavesSpec, opWaveJobs, opWaveShare, type OpWavesSpec } from "./op-waves";
6
- import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
6
+ import {
7
+ opWaveHooksAtBase,
8
+ readOpWaveHooksFile,
9
+ recordOpWaveHeadPlans,
10
+ runOpWave,
11
+ runOpWaveApplyHooks,
12
+ type OpWaveDecision,
13
+ type OpWaveExec,
14
+ type OpWaveHooksFile,
15
+ type OpWaveRunsFile,
16
+ type OpWaveShowAtBase,
17
+ } from "./op-waves-run";
7
18
  import { memoryGateLedgerPort } from "./gate";
8
19
  import { waveSetDigest } from "../gated-waves";
9
20
 
@@ -473,3 +484,182 @@ describe("spent approvals (#3784)", () => {
473
484
  h.done();
474
485
  });
475
486
  });
487
+
488
+ describe("apply-stage hooks run by the apply, under its lock (#3804)", () => {
489
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
490
+ const spec: OpWavesSpec = {
491
+ name: "migrations",
492
+ op: "migrate",
493
+ plan: ["tool", "plan", "{target}", "{plan}"],
494
+ apply: ["tool", "apply", "{target}", "{plan}"],
495
+ hooks: { apply: { runBy: "apply" }, before: { plan: [{ name: "lint", command: ["hook", "lint"] }] } },
496
+ waves: [
497
+ {
498
+ name: "prod",
499
+ runs: [{ target: "t1" }],
500
+ gate: "never",
501
+ hooks: {
502
+ before: { apply: [{ name: "lag", command: ["hook", "lag", "{target}"], perRun: true, onFailure: "approve" }] },
503
+ after: { apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
504
+ },
505
+ },
506
+ ],
507
+ };
508
+
509
+ /**
510
+ * A job whose apply command is a product that takes its lock and runs the
511
+ * hooks it was handed, as SQL Yodeler's apply would. `obey: false` is an
512
+ * apply that ignores the hooks file.
513
+ */
514
+ function harness(atBase: OpWavesSpec | null, opts: { fails?: string[]; obey?: boolean } = {}) {
515
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-by-apply-"));
516
+ const calls: string[] = [];
517
+ const files: OpWaveHooksFile[] = [];
518
+ const inner: OpWaveExec = (argv) => {
519
+ calls.push(` ${argv.join(" ")}`);
520
+ return (opts.fails ?? []).some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
521
+ };
522
+ const exec: OpWaveExec = (argv, dir, env) => {
523
+ calls.push(argv.join(" "));
524
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest("1") }));
525
+ if (argv[1] === "apply") {
526
+ const file = readOpWaveHooksFile(env?.CHANT_HOOKS, dir)!;
527
+ files.push(file);
528
+ if (opts.obey === false) return 0;
529
+ calls.push(" lock");
530
+ const before = runOpWaveApplyHooks(file, "before-apply", { cwd: dir, exec: inner, log: () => {} });
531
+ if (!before.proceed) return before.held.length > 0 ? 3 : 4;
532
+ calls.push(" migrate");
533
+ const after = runOpWaveApplyHooks(file, "after-apply", { cwd: dir, exec: inner, log: () => {} });
534
+ return after.proceed ? 0 : 1;
535
+ }
536
+ return (opts.fails ?? []).some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
537
+ };
538
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
539
+ const record = () => JSON.parse(readFileSync(join(cwd, ".chant/op-waves/migrations/wave-1.json"), "utf-8")) as OpWaveDecision;
540
+ return { cwd, calls, files, exec, show, record, done: () => rmSync(cwd, { recursive: true, force: true }) };
541
+ }
542
+ const set = waveSetDigest([{ member: "t1", planDigest: digest("1") }]);
543
+
544
+ test("the spec checks hooks.apply.runBy and keeps the rest as it was", () => {
545
+ const base = { name: "w", op: "o", plan: ["p", "{plan}"], waves: [{ name: "dev", runs: [{ target: "a" }] }] };
546
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: { runBy: "apply" } } })).not.toThrow();
547
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: { runBy: "later" } } } as unknown as OpWavesSpec)).toThrow(/hooks.apply.runBy is "later"/);
548
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: [{ command: ["x"] }] } } as unknown as OpWavesSpec)).toThrow(/hooks.apply must be/);
549
+ });
550
+
551
+ test("chant hands the apply-stage hooks to the apply, which runs them under its lock; the outcomes come back", async () => {
552
+ const h = harness(spec);
553
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
554
+ expect(result).toMatchObject({ exitCode: 0, applied: ["t1"], decision: { applyHooksRunBy: "apply" } });
555
+ expect(h.calls).toEqual([
556
+ "hook lint",
557
+ "tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
558
+ "tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
559
+ " lock",
560
+ " hook lag t1",
561
+ " migrate",
562
+ " hook smoke prod",
563
+ ]);
564
+ // The apply sees the outcomes of the hooks chant ran before it, and the hooks it runs itself.
565
+ expect(h.files[0]).toMatchObject({ runBy: "apply", approved: false, digest: set, outcomes: [{ moment: "before-plan", name: "lint", outcome: "passed" }] });
566
+ expect(Object.keys(h.files[0]!.hooks)).toEqual(["before-apply", "after-apply"]);
567
+ expect(result.hooks.map((o) => `${o.moment} ${o.name} ${o.outcome}`)).toEqual(["before-plan lint passed", "before-apply lag passed", "after-apply smoke passed"]);
568
+ expect(h.record().outcomes).toHaveLength(3);
569
+ h.done();
570
+ });
571
+
572
+ test("a hold inside the apply applies nothing and waits; with an approval of the digest the apply is called again and goes on", async () => {
573
+ const h = harness(spec, { fails: ["hook lag"] });
574
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
575
+ expect(waiting).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", heldBy: ["before-apply hook lag (t1)"] } });
576
+ expect(h.calls).not.toContain(" migrate");
577
+ h.done();
578
+
579
+ const again = harness(spec, { fails: ["hook lag"] });
580
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "migrations", gate: "migrations-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
581
+ const applied = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: again.cwd, exec: again.exec, show: again.show, gates });
582
+ expect(applied).toMatchObject({ exitCode: 0, applied: ["t1"], decision: { status: "approved", approvedBy: "alice" } });
583
+ expect(again.calls.filter((c) => c === " lock")).toHaveLength(2);
584
+ expect(again.calls.filter((c) => c === " migrate")).toHaveLength(1);
585
+ // The second call knows the approval stands, and which hook held the wave.
586
+ expect(again.files[1]).toMatchObject({ approved: true, heldBy: ["before-apply hook lag (t1)"] });
587
+ expect(applied.hooks.filter((o) => o.name === "lag").map((o) => o.outcome)).toEqual(["held", "held"]);
588
+ again.done();
589
+ });
590
+
591
+ test("an after-apply failure inside the apply counts the run applied and fails the wave", async () => {
592
+ const h = harness(spec, { fails: ["hook smoke"] });
593
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
594
+ expect(result).toMatchObject({ exitCode: 1, applied: ["t1"], failed: [] });
595
+ expect(result.hooks.at(-1)).toMatchObject({ moment: "after-apply", name: "smoke", outcome: "failed" });
596
+ h.done();
597
+ });
598
+
599
+ test("an apply that ignores the hooks it was handed fails the wave", async () => {
600
+ const h = harness(spec, { obey: false });
601
+ const logs: string[] = [];
602
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
603
+ expect(result).toMatchObject({ exitCode: 1, applied: ["t1"] });
604
+ expect(logs.join("\n")).toMatch(/without running its before-apply and after-apply hooks/);
605
+ h.done();
606
+ });
607
+
608
+ test("runBy is read at base: a change that sets it still has chant run the hooks, and the apply gets its outcomes", async () => {
609
+ const atBase: OpWavesSpec = { ...spec, hooks: { before: spec.hooks!.before } };
610
+ const h = harness(atBase);
611
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
612
+ expect(result.exitCode).toBe(0);
613
+ expect(h.calls.slice(0, 4)).toEqual(["hook lint", "tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json", "hook lag t1", "tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json"]);
614
+ expect(h.files[0]).toMatchObject({ runBy: "chant", hooks: {} });
615
+ expect(h.files[0]!.outcomes.map((o) => o.name)).toEqual(["lint", "lag"]);
616
+ h.done();
617
+ });
618
+
619
+ test("an apply that skips both moments for an empty run exits the wave ok, and the skip is recorded (#3811)", async () => {
620
+ const h = harness(spec);
621
+ const exec: OpWaveExec = (argv, dir, env) => {
622
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest("1") }));
623
+ if (argv[1] === "apply") {
624
+ const file = readOpWaveHooksFile(env?.CHANT_HOOKS, dir)!;
625
+ runOpWaveApplyHooks(file, "before-apply", { cwd: dir, skip: "nothing to apply", log: () => {} });
626
+ runOpWaveApplyHooks(file, "after-apply", { cwd: dir, skip: "nothing to apply", log: () => {} });
627
+ }
628
+ return 0;
629
+ };
630
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
631
+ expect(result).toMatchObject({ exitCode: 0, applied: ["t1"] });
632
+ const skipped = [
633
+ { moment: "before-apply", name: "skip", exit: 0, outcome: "skipped", reason: "nothing to apply" },
634
+ { moment: "after-apply", name: "skip", exit: 0, outcome: "skipped", reason: "nothing to apply" },
635
+ ];
636
+ expect(result.hooks.filter((o) => o.outcome === "skipped")).toEqual(skipped);
637
+ expect(h.record().outcomes).toEqual(expect.arrayContaining(skipped));
638
+ h.done();
639
+ });
640
+
641
+ test("a skip with no reason is refused", () => {
642
+ const h = harness(spec);
643
+ writeFileSync(join(h.cwd, "waves.json"), JSON.stringify(spec));
644
+ const file = opWaveHooksAtBase({ specFile: "waves.json", wave: "prod", cwd: h.cwd, show: h.show });
645
+ expect(() => runOpWaveApplyHooks(file, "before-apply", { cwd: h.cwd, skip: " ", log: () => {} })).toThrow(/needs a reason/);
646
+ h.done();
647
+ });
648
+
649
+ test("an apply outside the waves reads the same hooks at base and runs them itself; a hold stops it", () => {
650
+ const h = harness(spec);
651
+ writeFileSync(join(h.cwd, "waves.json"), JSON.stringify({ ...spec, hooks: undefined, waves: [{ ...spec.waves[0]!, hooks: undefined }] }));
652
+ const file = opWaveHooksAtBase({ specFile: "waves.json", wave: "prod", cwd: h.cwd, show: h.show, base: "HEAD^1" });
653
+ expect(file).toMatchObject({ opWaves: "migrations", wave: "prod", number: 1, approved: false, runBy: "apply", runs: [{ target: "t1", op: "migrate" }] });
654
+ expect(Object.keys(file.hooks)).toEqual(["before-plan", "before-apply", "after-apply"]);
655
+ const ran: string[] = [];
656
+ const exec: OpWaveExec = (argv) => (ran.push(argv.join(" ")), argv[1] === "lag" ? 1 : 0);
657
+ const before = runOpWaveApplyHooks(file, "before-apply", { cwd: h.cwd, exec, log: () => {} });
658
+ expect(before).toMatchObject({ proceed: false, held: ["before-apply hook lag (t1)"], outcomes: [{ name: "lag", target: "t1", exit: 1, outcome: "held" }] });
659
+ expect(runOpWaveApplyHooks(file, "after-plan", { cwd: h.cwd, exec, log: () => {} })).toMatchObject({ proceed: true, outcomes: [] });
660
+ expect(ran).toEqual(["hook lag t1"]);
661
+ const absent = opWaveHooksAtBase({ specFile: "waves.json", wave: "prod", cwd: h.cwd, show: () => ({ sha: "c".repeat(40), text: null }) });
662
+ expect(absent.hooks).toEqual({});
663
+ h.done();
664
+ });
665
+ });
@@ -107,10 +107,22 @@ export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "
107
107
 
108
108
  export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "before-apply", "after-apply"];
109
109
 
110
+ /**
111
+ * Who runs the `before.apply` and `after.apply` hooks (#3804). `chant`: the
112
+ * wave job, around the apply command. `apply`: the apply command itself,
113
+ * under its own lock, through `runOpWaveApplyHooks` and the file named by
114
+ * `CHANT_HOOKS`; it writes the outcomes back to `CHANT_HOOKS_RESULT`.
115
+ */
116
+ export type OpWaveApplyHooksRunner = "chant" | "apply";
117
+
118
+ export const OP_WAVE_APPLY_HOOKS_RUNNERS: readonly OpWaveApplyHooksRunner[] = ["chant", "apply"];
119
+
110
120
  /** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
111
121
  export interface OpWaveHooks {
112
122
  before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
113
123
  after?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
124
+ /** Who runs the apply-stage hooks (#3804). Default `{ runBy: "chant" }`. A wave's own setting wins over the spec's. */
125
+ apply?: { runBy?: OpWaveApplyHooksRunner };
114
126
  }
115
127
 
116
128
  /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
@@ -367,7 +379,17 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
367
379
  throw new Error(`${where}: \`hooks\` must be { "before"?: { "plan"?, "apply"? }, "after"?: { "plan"?, "apply"? } }.`);
368
380
  }
369
381
  for (const [side, stages] of Object.entries(hooks as Record<string, unknown>)) {
370
- if (side !== "before" && side !== "after") throw new Error(`${where}: hooks.${side} is not a moment; use hooks.before or hooks.after.`);
382
+ if (side === "apply") {
383
+ const runBy = (stages as { runBy?: unknown } | null)?.runBy;
384
+ if (stages === null || typeof stages !== "object" || Array.isArray(stages) || Object.keys(stages).some((key) => key !== "runBy")) {
385
+ throw new Error(`${where}: hooks.apply must be { "runBy"?: "chant" | "apply" }.`);
386
+ }
387
+ if (runBy !== undefined && !OP_WAVE_APPLY_HOOKS_RUNNERS.includes(runBy as OpWaveApplyHooksRunner)) {
388
+ throw new Error(`${where}: hooks.apply.runBy is ${JSON.stringify(runBy)}; use ${OP_WAVE_APPLY_HOOKS_RUNNERS.map((r) => `"${r}"`).join(" or ")}.`);
389
+ }
390
+ continue;
391
+ }
392
+ if (side !== "before" && side !== "after") throw new Error(`${where}: hooks.${side} is not a moment; use hooks.before or hooks.after (or hooks.apply.runBy).`);
371
393
  if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
372
394
  for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
373
395
  const at = `hooks.${side}.${stage}`;
@@ -405,6 +427,12 @@ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name:
405
427
  return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
406
428
  }
407
429
 
430
+ /** Who runs wave `name`'s apply-stage hooks (#3804): the wave's `hooks.apply.runBy`, else the spec's, else `chant`. */
431
+ export function opWaveApplyHooksRunBy(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): OpWaveApplyHooksRunner {
432
+ const wave = spec.waves.find((w) => w.name === name);
433
+ return wave?.hooks?.apply?.runBy ?? spec.hooks?.apply?.runBy ?? "chant";
434
+ }
435
+
408
436
  /**
409
437
  * The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
410
438
  * by target, cut into contiguous slices whose sizes differ by at most one.
@@ -361,6 +361,52 @@ describe("upsertComment (#3759)", () => {
361
361
  });
362
362
  });
363
363
 
364
+ describe("findComment (#3802)", () => {
365
+ const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
366
+
367
+ test("reads back the comment upsertComment would update, author honoured, oldest first", async () => {
368
+ const { fetch, calls } = fakeFetch({
369
+ "GET /repos/acme/infra/issues/12/comments": [
370
+ { id: 1, body: "<!-- y -->\nsubject: planted", user: { login: "mallory" } },
371
+ { id: 5, body: "<!-- y -->\nsubject: newer", user: { login: "yodel-bot" }, html_url: "https://h/5" },
372
+ { id: 3, body: "<!-- y -->\nsubject: abc", user: { login: "Yodel-Bot" }, html_url: "https://h/3" },
373
+ ],
374
+ });
375
+ const forge = githubForge({ ...gh, fetch });
376
+ expect(await forge.findComment(12, "<!-- y -->", { author: "yodel-bot" })).toEqual({ id: 3, body: "<!-- y -->\nsubject: abc", url: "https://h/3" });
377
+ expect((await forge.findComment(12, "<!-- y -->"))?.id).toBe(1);
378
+ expect(await forge.findComment(12, "<!-- other -->")).toBeUndefined();
379
+ expect(calls.every((c) => c.method === "GET")).toBe(true);
380
+ });
381
+
382
+ test("Forgejo pages with limit, and anywhere finds a marker inside the body", async () => {
383
+ const { fetch, calls } = fakeFetch({
384
+ "GET /api/v1/repos/acme/infra/issues/4/comments": [{ id: 7, body: "plan\n<!-- y -->", user: { login: "bot" }, html_url: "https://cb/7" }],
385
+ });
386
+ const forge = forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch });
387
+ expect(await forge.findComment(4, "<!-- y -->", { match: "anywhere" })).toEqual({ id: 7, body: "plan\n<!-- y -->", url: "https://cb/7" });
388
+ expect(await forge.findComment(4, "<!-- y -->")).toBeUndefined();
389
+ expect(new URL(calls[0].url).searchParams.get("limit")).toBe("50");
390
+ });
391
+
392
+ test("GitLab skips system notes and reads the note without a URL", async () => {
393
+ const { fetch } = fakeFetch({
394
+ "GET /api/v4/projects/42/merge_requests/5/notes": [
395
+ { id: 30, body: "<!-- y -->\nsys", system: true },
396
+ { id: 31, body: "<!-- y -->\nmine", author: { username: "bot" } },
397
+ ],
398
+ });
399
+ const forge = gitlabForge({ apiBase: "https://gitlab.example.com/api/v4", project: "42", token: "glpat", fetch });
400
+ expect(await forge.findComment(5, "<!-- y -->", { author: "bot" })).toEqual({ id: 31, body: "<!-- y -->\nmine" });
401
+ });
402
+
403
+ test("an empty marker is refused", async () => {
404
+ const { fetch, calls } = fakeFetch({});
405
+ await expect(githubForge({ ...gh, fetch }).findComment(12, "")).rejects.toThrow(/needs a marker/);
406
+ expect(calls).toEqual([]);
407
+ });
408
+ });
409
+
364
410
  describe("trackIssue (#3759)", () => {
365
411
  const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
366
412
  const open = [
@@ -408,6 +454,27 @@ describe("trackIssue (#3759)", () => {
408
454
  expect(await githubForge({ ...gh, fetch: none.fetch }).trackIssue("<!-- d -->", { title: "t", body: "<!-- d -->\nb" })).toEqual({ action: "opened", number: 10, url: "https://h/issues/10" });
409
455
  });
410
456
 
457
+ test("sameWhen counts a body that differs only in run-specific text as unchanged (#3802)", async () => {
458
+ const issues = [{ number: 8, title: "Drift: prod", body: "<!-- d -->\nfingerprint f1\nrun 100", html_url: "https://h/issues/8" }];
459
+ const next = { title: "Drift: prod", body: "<!-- d -->\nfingerprint f1\nrun 101" };
460
+ const same = fakeFetch({ "GET /repos/acme/infra/issues": issues });
461
+ expect(await githubForge({ ...gh, fetch: same.fetch }).trackIssue("<!-- d -->", next, { sameWhen: "fingerprint f1" })).toEqual({
462
+ action: "unchanged",
463
+ number: 8,
464
+ url: "https://h/issues/8",
465
+ });
466
+ expect(same.calls.map((c) => c.method)).toEqual(["GET"]);
467
+ const strip = (b: string) => b.replace(/\nrun \d+/, "");
468
+ const fn = fakeFetch({ "GET /repos/acme/infra/issues": issues });
469
+ expect(
470
+ (await githubForge({ ...gh, fetch: fn.fetch }).trackIssue("<!-- d -->", next, { sameWhen: (o, f) => o.title === f.title && strip(o.body) === strip(f.body) })).action,
471
+ ).toBe("unchanged");
472
+ const retitled = fakeFetch({ "GET /repos/acme/infra/issues": issues, "PATCH /repos/acme/infra/issues/8": { number: 8 } });
473
+ expect((await githubForge({ ...gh, fetch: retitled.fetch }).trackIssue("<!-- d -->", { ...next, title: "Drift: prod (2)" }, { sameWhen: "fingerprint f1" })).action).toBe("updated");
474
+ const moved = fakeFetch({ "GET /repos/acme/infra/issues": issues, "PATCH /repos/acme/infra/issues/8": { number: 8 } });
475
+ expect((await githubForge({ ...gh, fetch: moved.fetch }).trackIssue("<!-- d -->", { ...next, body: "<!-- d -->\nfingerprint f2" }, { sameWhen: "fingerprint f2" })).action).toBe("updated");
476
+ });
477
+
411
478
  test("GitLab searches open issues by description, writes description, and closes with a note", async () => {
412
479
  const issues = [{ iid: 3, title: "Drift", description: "<!-- d -->\nold", web_url: "https://gl/i/3" }];
413
480
  const { fetch, calls } = fakeFetch({
@@ -522,6 +589,14 @@ describe("forgeTargetFromEnv (#3759)", () => {
522
589
  expect(calls[0].headers["PRIVATE-TOKEN"]).toBeUndefined();
523
590
  });
524
591
 
592
+ test("the default token order: CHANT_GITHUB_TOKEN on GitHub, FORGEJO_TOKEN before GITHUB_TOKEN on Forgejo (#3802)", () => {
593
+ const base = { GITHUB_REPOSITORY: "a/b", GITHUB_SERVER_URL: "https://codeberg.org" };
594
+ expect(forgeTargetFromEnv({ GITHUB_ACTIONS: "true", ...base, CHANT_FORGEJO_TOKEN: "fj", GITHUB_TOKEN: "gh" }).tokenSource).toBe("GITHUB_TOKEN");
595
+ expect(forgeTargetFromEnv({ GITHUB_ACTIONS: "true", ...base, CHANT_GITHUB_TOKEN: "c", GH_TOKEN: "g" }).tokenSource).toBe("CHANT_GITHUB_TOKEN");
596
+ expect(forgeTargetFromEnv({ FORGEJO_ACTIONS: "true", ...base, FORGEJO_TOKEN: "f", GITHUB_TOKEN: "g" }).tokenSource).toBe("FORGEJO_TOKEN");
597
+ expect(forgeTargetFromEnv({ FORGEJO_ACTIONS: "true", ...base, CHANT_FORGEJO_TOKEN: "c", FORGEJO_TOKEN: "f" }).tokenSource).toBe("CHANT_FORGEJO_TOKEN");
598
+ });
599
+
525
600
  test("says every missing piece, and when the forge is unknown", () => {
526
601
  let err: unknown;
527
602
  try {
@@ -533,7 +608,7 @@ describe("forgeTargetFromEnv (#3759)", () => {
533
608
  expect((err as ForgeEnvironmentError).missing).toEqual([
534
609
  "GITHUB_REPOSITORY",
535
610
  "a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)",
536
- "a token (CHANT_FORGEJO_TOKEN, GH_TOKEN, GITHUB_TOKEN)",
611
+ "a token (CHANT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN)",
537
612
  ]);
538
613
  expect(() => forgeTargetFromEnv({})).toThrow(/forge is not known/);
539
614
  });
package/src/pr-forge.ts CHANGED
@@ -76,6 +76,15 @@ export interface CommentResult {
76
76
  url?: string;
77
77
  }
78
78
 
79
+ /** A comment or note {@link PrForge.findComment} read back. */
80
+ export interface FoundComment {
81
+ /** The comment's or note's id. */
82
+ id: number;
83
+ body: string;
84
+ /** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
85
+ url?: string;
86
+ }
87
+
79
88
  /** A finding to keep in a tracking issue. `body` carries the marker. */
80
89
  export interface IssueFinding {
81
90
  title: string;
@@ -92,6 +101,15 @@ export interface TrackIssueOptions extends MarkerMatch {
92
101
  closeOnClear?: boolean;
93
102
  /** Posted on the issue before it is closed. */
94
103
  clearComment?: string;
104
+ /**
105
+ * When the open issue counts as already current, in place of an equal
106
+ * title and body, for a body that names something that changes on every
107
+ * run (the run's URL, a timestamp). A string: unchanged when the titles
108
+ * are equal and the open issue's body carries it, such as the finding's
109
+ * fingerprint. A function: unchanged when it returns true for the open
110
+ * issue and the finding. An unchanged issue is not edited.
111
+ */
112
+ sameWhen?: string | ((open: IssueFinding, finding: IssueFinding) => boolean);
95
113
  }
96
114
 
97
115
  /** What {@link PrForge.trackIssue} did. */
@@ -129,6 +147,14 @@ export interface PrForge {
129
147
  * comment's web URL. The pull-request loop's form.
130
148
  */
131
149
  upsertNote(pr: number, marker: string, body: string): Promise<string | undefined>;
150
+ /**
151
+ * The comment on pull request `pr` (the note on merge request `pr`) that
152
+ * {@link upsertComment} would update for `marker`: the oldest one that
153
+ * {@link MarkerMatch} finds, `author` honoured, so a pull request's author
154
+ * cannot plant one. Undefined when there is none. Every page is read, and
155
+ * GitLab's system notes are skipped.
156
+ */
157
+ findComment(pr: number, marker: string, opts?: MarkerMatch): Promise<FoundComment | undefined>;
132
158
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
133
159
  /**
134
160
  * Keep one OPEN tracking issue for `marker` in line with `finding`: open
@@ -250,9 +276,15 @@ function checkMarker(what: string, marker: string, body: string, match: MarkerMa
250
276
  }
251
277
  }
252
278
 
279
+ /** The oldest comment on `pr` that `opts` takes for the caller's own. */
280
+ async function ownComment(t: Threads, pr: number, marker: string, opts: MarkerMatch): Promise<Thread | undefined> {
281
+ if (!marker) throw new Error("the comment needs a marker to be found");
282
+ return (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
283
+ }
284
+
253
285
  async function upsertCommentWith(t: Threads, pr: number, marker: string, body: string, opts: MarkerMatch, check: boolean): Promise<CommentResult> {
254
286
  if (check) checkMarker("comment", marker, body, opts.match);
255
- const mine = (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
287
+ const mine = await ownComment(t, pr, marker, opts);
256
288
  if (!mine) {
257
289
  const made = await t.addComment(pr, body);
258
290
  return { action: "created", id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
@@ -274,14 +306,24 @@ async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding
274
306
  return { action: "closed", ...at(mine) };
275
307
  }
276
308
  if (!mine) return { action: "opened", ...at(await t.openIssue(finding.title, finding.body)) };
277
- if (mine.body === finding.body && mine.title === finding.title) return { action: "unchanged", ...at(mine) };
309
+ if (sameIssue({ title: mine.title ?? "", body: mine.body }, finding, opts.sameWhen)) return { action: "unchanged", ...at(mine) };
278
310
  const edited = await t.editIssue(mine.id, finding.title, finding.body);
279
311
  return { action: "updated", ...at(mine), ...(edited?.url ? { url: edited.url } : {}) };
280
312
  }
281
313
 
314
+ function sameIssue(open: IssueFinding, finding: IssueFinding, sameWhen: TrackIssueOptions["sameWhen"]): boolean {
315
+ if (typeof sameWhen === "function") return sameWhen(open, finding);
316
+ if (open.title !== finding.title) return false;
317
+ return typeof sameWhen === "string" && sameWhen !== "" ? open.body.includes(sameWhen) : open.body === finding.body;
318
+ }
319
+
282
320
  /** The recipes every client shares, over its own {@link Threads}. */
283
- function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "trackIssue" | "upsertIssue"> {
321
+ function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "trackIssue" | "upsertIssue"> {
284
322
  return {
323
+ async findComment(pr, marker, opts = {}) {
324
+ const c = await ownComment(t, pr, marker, opts);
325
+ return c ? { id: c.id, body: c.body, ...(c.url ? { url: c.url } : {}) } : undefined;
326
+ },
285
327
  upsertComment: (pr, marker, body, opts = {}) => upsertCommentWith(t, pr, marker, body, opts, true),
286
328
  upsertNote: async (pr, marker, body) => (await upsertCommentWith(t, pr, marker, body, {}, false)).url,
287
329
  trackIssue: (marker, finding, opts = {}) => trackIssueWith(t, marker, finding, opts, true),
@@ -647,13 +689,15 @@ export interface ForgeTargetOptions {
647
689
  }
648
690
 
649
691
  /**
650
- * The token order guide/ops documents for the finding modes (#2291, #2256):
651
- * a chant-specific variable first, for posting to an instance other than the
652
- * one the job runs on, then what the job already carries.
692
+ * The token order {@link forgeTargetFromEnv} reads, as guide/ops documents
693
+ * it: a chant-specific variable first, for posting to an instance other than
694
+ * the one the job runs on, then what the job already carries (#3802). A
695
+ * Forgejo job reads `FORGEJO_TOKEN` before the `GITHUB_*` names, and the
696
+ * GitHub row has its own chant variable rather than the Forgejo one.
653
697
  */
654
698
  export const FORGE_TOKEN_ORDER: Record<ForgeKind, readonly string[]> = {
655
- github: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
656
- forgejo: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
699
+ github: ["CHANT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
700
+ forgejo: ["CHANT_FORGEJO_TOKEN", "FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
657
701
  gitlab: ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN"],
658
702
  };
659
703
 
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // A copy of docs/design/decisions/decision.kind.mjs in the chant repo, with
6
6
  // decision.schema.json beside it, so a workspace made from this one reads its
7
- // decisions without the chant repo. chant's test/reference-workspace.test.ts fails
7
+ // decisions without the chant repo. chant's test/reference-workspace.e2e.test.ts fails
8
8
  // when this kind's data or the schema drifts from chant's.
9
9
  export const recordKind = {
10
10
  name: "decision",
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * It is a test fixture and the package does not export it: chant never runs
10
10
  * a broker or listens on a port (ws-052, ws-086), so it lives under
11
- * `__fixtures__`, which test/no-listener.test.ts leaves out like a test file.
11
+ * `__fixtures__`, which test/no-listener.e2e.test.ts leaves out like a test file.
12
12
  * `startReferenceBroker(env)` has the shape of the suite's `start`.
13
13
  */
14
14
 
@@ -187,7 +187,7 @@ export type BrokerHttpHandler = (req: IncomingMessage, res: ServerResponse) => v
187
187
  /**
188
188
  * How the suite serves its upstreams: serve `handler` on a loopback port and
189
189
  * return its URL and how to stop it. The caller passes it because chant
190
- * itself never listens on a port (ws-052, ws-086, test/no-listener.test.ts);
190
+ * itself never listens on a port (ws-052, ws-086, test/no-listener.e2e.test.ts);
191
191
  * the test that runs the suite does.
192
192
  */
193
193
  export type BrokerListen = (handler: BrokerHttpHandler) => Promise<{ url: string; close(): Promise<void> | void }>;
@@ -3,7 +3,7 @@
3
3
  * line: standard input, exit codes and the refusal document, from the CLI a
4
4
  * user runs. Each test spawns the CLI several times, which put them near 20s
5
5
  * on CI, over the unit shards' per-test budget, so they run in the test-e2e
6
- * job (#2817). The commands' own behaviour is covered in records-write.test.ts.
6
+ * job (#2817). The commands' own behaviour is covered in records-write-commands.e2e.test.ts.
7
7
  */
8
8
 
9
9
  import { execFileSync } from "node:child_process";
File without changes