@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.
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/config-import.d.ts +1 -1
- package/dist/lint/policy-import.d.ts +1 -1
- package/dist/notify-verify.js +76 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- package/dist/op/index.d.ts +4 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +134 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +14 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +30 -3
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +1 -1
- package/package.json +14 -2
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- package/src/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- package/src/lint/policy-import.ts +1 -1
- package/src/notify-plain-node.e2e.test.ts +68 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
- package/src/op/index.ts +18 -2
- package/src/op/op-waves-run.ts +454 -83
- package/src/op/op-waves.test.ts +191 -1
- package/src/op/op-waves.ts +29 -1
- package/src/pr-forge.test.ts +76 -1
- package/src/pr-forge.ts +52 -8
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write.e2e.test.ts +1 -1
- /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
package/src/op/op-waves.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/op/op-waves.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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.
|
|
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
|
|
651
|
-
* a chant-specific variable first, for posting to an instance other than
|
|
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: ["
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts}
RENAMED
|
File without changes
|
|
File without changes
|
/package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|