@intentius/chant 0.124.40 → 0.124.41
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/__fixtures__/oidc-emulator.d.ts +49 -0
- package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
- package/dist/__fixtures__/oidc-emulator.js +89 -0
- package/dist/ci-handover.d.ts +117 -0
- package/dist/ci-handover.d.ts.map +1 -0
- package/dist/ci-handover.js +217 -0
- package/dist/ci-identity.d.ts +190 -0
- package/dist/ci-identity.d.ts.map +1 -0
- package/dist/ci-identity.js +219 -0
- package/dist/cli/handlers/approve.d.ts +2 -1
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -6
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +15 -1
- package/dist/cli/handlers/fan-out.js +16 -3
- package/dist/cli/handlers/gate-resume.d.ts +3 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.js +15 -2
- package/dist/cli/handlers/lock.d.ts +8 -0
- package/dist/cli/handlers/lock.d.ts.map +1 -0
- package/dist/cli/handlers/lock.js +197 -0
- package/dist/cli/handlers/pr-env.d.ts +34 -0
- package/dist/cli/handlers/pr-env.d.ts.map +1 -0
- package/dist/cli/handlers/pr-env.js +436 -0
- package/dist/cli/handlers/pr.d.ts +74 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +503 -53
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +9 -0
- package/dist/cli/handlers/query.d.ts +6 -0
- package/dist/cli/handlers/query.d.ts.map +1 -0
- package/dist/cli/handlers/query.js +80 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +41 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -2
- package/dist/cli/registry.d.ts +12 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/fan-out-run.d.ts +28 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +27 -3
- package/dist/components/pr-environment.d.ts +298 -0
- package/dist/components/pr-environment.d.ts.map +1 -0
- package/dist/components/pr-environment.js +406 -0
- package/dist/components/pr-hooks.d.ts +66 -0
- package/dist/components/pr-hooks.d.ts.map +1 -0
- package/dist/components/pr-hooks.js +119 -0
- package/dist/components/pr-pipeline.d.ts +72 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +96 -5
- package/dist/components/pr-policy.d.ts +109 -0
- package/dist/components/pr-policy.d.ts.map +1 -0
- package/dist/components/pr-policy.js +150 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +5 -3
- package/dist/gate-decision.d.ts +15 -2
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +17 -2
- package/dist/gate-policy.d.ts +140 -0
- package/dist/gate-policy.d.ts.map +1 -0
- package/dist/gate-policy.js +242 -0
- package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
- package/dist/lifecycle/gate-checkpoint.js +1 -1
- package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
- package/dist/lifecycle/gate-seal.d.ts.map +1 -0
- package/dist/lifecycle/query.d.ts +68 -0
- package/dist/lifecycle/query.d.ts.map +1 -0
- package/dist/lifecycle/query.js +246 -0
- package/dist/lifecycle/run-ledger.d.ts +13 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +16 -0
- package/dist/lifecycle/target-lock.d.ts +97 -0
- package/dist/lifecycle/target-lock.d.ts.map +1 -0
- package/dist/lifecycle/target-lock.js +286 -0
- package/dist/op/activities/activity-contracts.d.ts +30 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +26 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +4 -0
- package/dist/op/activities/review-agent.d.ts +137 -0
- package/dist/op/activities/review-agent.d.ts.map +1 -0
- package/dist/op/activities/review-agent.js +266 -0
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +11 -0
- package/dist/op/cron.d.ts +8 -0
- package/dist/op/cron.d.ts.map +1 -1
- package/dist/op/cron.js +51 -0
- package/dist/op/gate-approval.d.ts +20 -13
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +13 -18
- package/dist/op/gate-quorum.d.ts +131 -0
- package/dist/op/gate-quorum.d.ts.map +1 -0
- package/dist/op/gate-quorum.js +183 -0
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-resume.js +5 -4
- package/dist/op/gate-review.js +1 -1
- package/dist/op/gate.d.ts +32 -4
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +40 -24
- package/dist/op/index.d.ts +6 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +4 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/op-waves-run.d.ts +62 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +190 -11
- package/dist/op/op-waves.d.ts +55 -3
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +63 -8
- package/dist/op/schedule-overdue.d.ts +57 -0
- package/dist/op/schedule-overdue.d.ts.map +1 -0
- package/dist/op/schedule-overdue.js +138 -0
- package/dist/override-gate.d.ts +2 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/override-gate.js +1 -1
- package/dist/pr-forge.d.ts +36 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +82 -0
- package/dist/pr-loop.d.ts +90 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +77 -3
- package/dist/pr-post.d.ts +72 -0
- package/dist/pr-post.d.ts.map +1 -0
- package/dist/pr-post.js +134 -0
- package/dist/wave-blocked.d.ts +79 -0
- package/dist/wave-blocked.d.ts.map +1 -0
- package/dist/wave-blocked.js +245 -0
- package/dist/wave-engine.d.ts +6 -0
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +2 -0
- package/dist/wave-runtime.mjs +242 -242
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
- package/dist/workspace/ci-branches.d.ts +51 -0
- package/dist/workspace/ci-branches.d.ts.map +1 -0
- package/dist/workspace/ci-branches.js +69 -0
- package/dist/workspace/ci-cloud-identity.d.ts +11 -0
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
- package/dist/workspace/ci-cloud-identity.js +24 -0
- package/dist/workspace/ci-locks.d.ts +12 -0
- package/dist/workspace/ci-locks.d.ts.map +1 -0
- package/dist/workspace/ci-locks.js +6 -0
- package/dist/workspace/ci-run.d.ts +17 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +64 -3
- package/dist/workspace/declaration.d.ts +63 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +147 -0
- package/dist/workspace/identity-gate-rule.d.ts +1 -1
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +1 -1
- package/dist/workspace/identity.d.ts +1 -1
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +8 -5
- package/dist/workspace/member-pipeline.d.ts +6 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +23 -0
- package/dist/workspace/pr-report.schema.json +73 -2
- package/dist/workspace/security.d.ts.map +1 -1
- package/dist/workspace/security.js +4 -0
- package/dist/workspace/status-gates.d.ts +3 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +8 -7
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +4 -1
- package/dist/workspace/trust/seal.d.ts +2 -2
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/oidc-emulator.ts +118 -0
- package/src/ci-handover.test.ts +209 -0
- package/src/ci-handover.ts +237 -0
- package/src/ci-identity.test.ts +201 -0
- package/src/ci-identity.ts +310 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/fan-out.ts +17 -4
- package/src/cli/handlers/gate-resume.ts +16 -2
- package/src/cli/handlers/lock.test.ts +98 -0
- package/src/cli/handlers/lock.ts +202 -0
- package/src/cli/handlers/operator.test.ts +3 -1
- package/src/cli/handlers/pr-env.ts +462 -0
- package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
- package/src/cli/handlers/pr.test.ts +184 -0
- package/src/cli/handlers/pr.ts +496 -59
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/main.ts +41 -2
- package/src/cli/parse-args.ts +23 -2
- package/src/cli/registry.ts +12 -2
- package/src/components/fan-out-run.ts +39 -2
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/pr-environment.test.ts +249 -0
- package/src/components/pr-environment.ts +538 -0
- package/src/components/pr-hooks.test.ts +118 -0
- package/src/components/pr-hooks.ts +157 -0
- package/src/components/pr-pipeline.ts +130 -5
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +5 -2
- package/src/gate-decision-bundle.test.ts +4 -3
- package/src/gate-decision.ts +39 -2
- package/src/gate-policy-bundle.test.ts +28 -0
- package/src/gate-policy.test.ts +162 -0
- package/src/gate-policy.ts +298 -0
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/op/activities/activity-contracts.ts +31 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activities/review-agent.test.ts +240 -0
- package/src/op/activities/review-agent.ts +355 -0
- package/src/op/builders.ts +13 -0
- package/src/op/cron.ts +44 -0
- package/src/op/gate-alias.test.ts +2 -2
- package/src/op/gate-approval.test.ts +30 -9
- package/src/op/gate-approval.ts +23 -26
- package/src/op/gate-quorum.test.ts +139 -0
- package/src/op/gate-quorum.ts +248 -0
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +1 -1
- package/src/op/gate.ts +64 -23
- package/src/op/index.ts +13 -1
- package/src/op/local-executor.ts +9 -2
- package/src/op/op-waves-hook-targets.test.ts +126 -0
- package/src/op/op-waves-policy-hold.test.ts +66 -0
- package/src/op/op-waves-quorum.test.ts +101 -0
- package/src/op/op-waves-run.ts +233 -11
- package/src/op/op-waves.ts +83 -10
- package/src/op/schedule-overdue.test.ts +220 -0
- package/src/op/schedule-overdue.ts +161 -0
- package/src/override-gate-bundle.test.ts +1 -1
- package/src/override-gate.ts +2 -2
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +33 -0
- package/src/pr-forge.ts +114 -0
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +146 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/wave-blocked-bundle.test.ts +25 -0
- package/src/wave-blocked.test.ts +146 -0
- package/src/wave-blocked.ts +271 -0
- package/src/wave-engine.ts +8 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
- package/src/workspace/ci-branches.test.ts +62 -0
- package/src/workspace/ci-branches.ts +79 -0
- package/src/workspace/ci-cloud-identity.test.ts +53 -0
- package/src/workspace/ci-cloud-identity.ts +23 -0
- package/src/workspace/ci-locks.ts +16 -0
- package/src/workspace/ci-run-index.test.ts +2 -1
- package/src/workspace/ci-run.test.ts +107 -8
- package/src/workspace/ci-run.ts +72 -4
- package/src/workspace/declaration.schema.json +147 -0
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +134 -2
- package/src/workspace/identity-gate-rule.ts +1 -1
- package/src/workspace/identity.ts +1 -1
- package/src/workspace/lineage-upgrade.ts +11 -8
- package/src/workspace/member-pipeline.test.ts +29 -0
- package/src/workspace/member-pipeline.ts +30 -0
- package/src/workspace/pr-report.schema.json +73 -2
- package/src/workspace/security.ts +4 -0
- package/src/workspace/status-gates.ts +9 -6
- package/src/workspace/status.ts +4 -1
- package/src/workspace/trust/gate-seal.test.ts +1 -1
- package/src/workspace/trust/seal.ts +2 -2
- package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
- /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
|
@@ -41,7 +41,7 @@ describe("the pull-request loop bundle", () => {
|
|
|
41
41
|
"packages/core/src/pr-forge.ts",
|
|
42
42
|
"packages/core/src/pr-loop.ts",
|
|
43
43
|
]);
|
|
44
|
-
// About
|
|
45
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
44
|
+
// About 76 KB today, after the hooks line (#4288), the blocked set (#4291) and the scheduled-run reads (#4289).
|
|
45
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(80 * 1024);
|
|
46
46
|
});
|
|
47
47
|
});
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -366,3 +366,61 @@ describe("a gate per wave on merge (#4003)", () => {
|
|
|
366
366
|
expect(prStatusDescription({ ...waiting, waves: [wave1], refusal: "earlier-wave" })).toBe("refused: an earlier wave has not applied");
|
|
367
367
|
});
|
|
368
368
|
});
|
|
369
|
+
|
|
370
|
+
describe("stage hooks in the report and the note (#4288)", () => {
|
|
371
|
+
const hooked: PrReport = {
|
|
372
|
+
...planReport,
|
|
373
|
+
status: "plan-failed",
|
|
374
|
+
message: "The before-plan hook lint exited 1, so nothing was planned or applied.",
|
|
375
|
+
hooks: [
|
|
376
|
+
{ moment: "before-observe", name: "init", target: "a", exit: 0, outcome: "passed" },
|
|
377
|
+
{ moment: "before-plan", name: "lint", exit: 1, outcome: "failed" },
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
test("a report with hooks validates", () => {
|
|
382
|
+
expectValid(hooked);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("the note counts the hooks that passed and names the one that failed", () => {
|
|
386
|
+
const note = renderPrNote(hooked);
|
|
387
|
+
expect(note).toContain("A hook failed, so nothing can apply.");
|
|
388
|
+
expect(note).toContain("Hooks: 1 passed; `lint` before-plan failed (exit 1).");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("the policy in the report and the note (#4294)", () => {
|
|
393
|
+
const policy = {
|
|
394
|
+
result: "denied" as const,
|
|
395
|
+
engine: "opa" as const,
|
|
396
|
+
base: "b".repeat(40),
|
|
397
|
+
denials: [{ rule: "main.deny_public", message: "bucket logs is public" }],
|
|
398
|
+
rules: ["main.deny_public"],
|
|
399
|
+
holds: [{ rule: "main.hold_iam", message: "an IAM role changes" }],
|
|
400
|
+
warnings: ["three deletes"],
|
|
401
|
+
overrideCommand: 'chant approve policy-override pr-7 --rule main.deny_public --reason "<why>"',
|
|
402
|
+
};
|
|
403
|
+
const denied: PrReport = { ...planReport, policy };
|
|
404
|
+
|
|
405
|
+
test("a report with a policy, and an apply the policy refused, validate", () => {
|
|
406
|
+
expectValid(denied);
|
|
407
|
+
expectValid({ ...denied, stage: "apply", status: "refused", refusal: "policy-denied", message: "The policy denied this plan." });
|
|
408
|
+
expectValid({ ...denied, policy: { ...policy, result: "error", denials: [], rules: undefined, holds: [], error: "opa did not run" }, stage: "apply", status: "refused", refusal: "policy-error" });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("the note names the denial, the hold and the override command", () => {
|
|
412
|
+
const note = renderPrNote(denied);
|
|
413
|
+
expect(note).toContain("Policy (opa, read at `bbbbbbbbbbbb`) denied this plan:");
|
|
414
|
+
expect(note).toContain("- `main.deny_public`: bucket logs is public");
|
|
415
|
+
expect(note).toContain("holds this plan for a person's approval of its digest:");
|
|
416
|
+
expect(note).toContain(policy.overrideCommand);
|
|
417
|
+
expect(note).toContain("Policy warnings: three deletes");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("a denied plan fails its status until an override counts; the apply's refusal says why", () => {
|
|
421
|
+
expect(prStatusState(denied)).toBe("failure");
|
|
422
|
+
expect(prStatusState({ ...denied, policy: { ...policy, override: { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "docs site", digest: "sha256:1" } } })).toBe("success");
|
|
423
|
+
expect(prStatusState({ ...denied, policy: { ...policy, result: "held", denials: [] } })).toBe("success");
|
|
424
|
+
expect(prStatusDescription({ ...denied, stage: "apply", status: "refused", refusal: "policy-denied" })).toBe("refused: the policy denied the plan (main.deny_public)");
|
|
425
|
+
});
|
|
426
|
+
});
|
package/src/pr-loop.ts
CHANGED
|
@@ -71,6 +71,15 @@ export const PR_REPORT_PLANNED_OUTPUTS_FLOOR = "0.124.32";
|
|
|
71
71
|
/** The chant that writes the `record` stage and the `recorded` status (#4168 step 1). */
|
|
72
72
|
export const PR_REPORT_RECORD_FLOOR = "0.124.33";
|
|
73
73
|
|
|
74
|
+
/** The first chant whose report can carry `hooks` (#4288). */
|
|
75
|
+
export const PR_REPORT_HOOKS_FLOOR = "0.124.41";
|
|
76
|
+
|
|
77
|
+
/** The first chant whose plan report can carry `overdue` (#4289). */
|
|
78
|
+
export const PR_REPORT_OVERDUE_FLOOR = "0.124.41";
|
|
79
|
+
|
|
80
|
+
/** The first chant whose report can carry `policy` and the `policy-denied` and `policy-error` refusals (#4294). */
|
|
81
|
+
export const PR_REPORT_POLICY_FLOOR = "0.124.41";
|
|
82
|
+
|
|
74
83
|
/** The gate a pull request's apply waits on, unless the pipeline names another. */
|
|
75
84
|
export const PR_APPLY_GATE = "pr-apply";
|
|
76
85
|
|
|
@@ -174,6 +183,8 @@ export interface PrMember {
|
|
|
174
183
|
round?: number;
|
|
175
184
|
/** Apply stage (#3999): why the first round's plan did not apply. */
|
|
176
185
|
held?: string;
|
|
186
|
+
/** A blocked member's (#4291): the component whose failure held it, past any component blocked in between. */
|
|
187
|
+
blockedBy?: string;
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
/** A round of plans an apply made after an upstream applied (#3999), the report's `rounds`. */
|
|
@@ -258,7 +269,13 @@ export type PrRefusal =
|
|
|
258
269
|
/** `--wave-gate`: an approval of the wave's gate stands for another digest, so its plan moved since. */
|
|
259
270
|
| "wave-plan-changed"
|
|
260
271
|
/** `--wave-gate` with `--wave <k>`: an earlier wave has work left, or no record says it applied. */
|
|
261
|
-
| "earlier-wave"
|
|
272
|
+
| "earlier-wave"
|
|
273
|
+
/** Under `ci.locks` (#4293): another open pull request holds a lock on a target this apply reaches. */
|
|
274
|
+
| "locked"
|
|
275
|
+
/** `ci.policy` at base denied the plan (#4294), and no override of it counts. */
|
|
276
|
+
| "policy-denied"
|
|
277
|
+
/** `ci.policy` at base could not be run, or gave no answer chant can read (#4294). */
|
|
278
|
+
| "policy-error";
|
|
262
279
|
|
|
263
280
|
/**
|
|
264
281
|
* One wave of an apply with `--wave-gate` (#4003): what it planned, where it
|
|
@@ -289,6 +306,54 @@ export interface PrWave {
|
|
|
289
306
|
moved?: string[];
|
|
290
307
|
}
|
|
291
308
|
|
|
309
|
+
/** A scheduled watch or reconcile Op that stopped running (#4289, `op/schedule-overdue.ts`). */
|
|
310
|
+
export interface PrOverdue {
|
|
311
|
+
op: string;
|
|
312
|
+
cron: string;
|
|
313
|
+
/** The job's last run, or the pipeline file's first commit when `neverRan` (ISO 8601). */
|
|
314
|
+
since: string;
|
|
315
|
+
neverRan?: boolean;
|
|
316
|
+
message: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* What `ci.policy` at base said about the change set (#4294). Rule ids are
|
|
321
|
+
* `<package>.<rule>`.
|
|
322
|
+
*/
|
|
323
|
+
export interface PrPolicyReport {
|
|
324
|
+
/** `denied` wins over `held`. `error`: the policy did not run or gave no readable answer. */
|
|
325
|
+
result: "passed" | "denied" | "held" | "error";
|
|
326
|
+
/** The result shape read: `conftest` or `opa`. */
|
|
327
|
+
engine: "conftest" | "opa";
|
|
328
|
+
/** The commit the settings and the policy were read at. */
|
|
329
|
+
base: string;
|
|
330
|
+
denials: Array<{ rule: string; message: string }>;
|
|
331
|
+
/** The ids of the rules that denied, sorted: what an override names. */
|
|
332
|
+
rules?: string[];
|
|
333
|
+
holds: Array<{ rule: string; message: string }>;
|
|
334
|
+
warnings: string[];
|
|
335
|
+
error?: string;
|
|
336
|
+
/** The override that lets the denied plan through. */
|
|
337
|
+
override?: { by: string; at: string; reason: string; digest: string };
|
|
338
|
+
/** While denied: the command that overrides the denial. */
|
|
339
|
+
overrideCommand?: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One stage hook a pull-request job ran (#4288), from `ci.hooks` in the
|
|
344
|
+
* workspace declaration at base. The shape of an Op waves hook outcome.
|
|
345
|
+
*/
|
|
346
|
+
export interface PrHookOutcome {
|
|
347
|
+
/** `before-observe`, `before-plan`, `after-plan`, `before-apply` or `after-apply`. */
|
|
348
|
+
moment: string;
|
|
349
|
+
name: string;
|
|
350
|
+
/** The component, for a `perRun` hook. */
|
|
351
|
+
target?: string;
|
|
352
|
+
exit: number;
|
|
353
|
+
/** `held`: an `onFailure: approve` hook failed, which the pull request's gate answers. */
|
|
354
|
+
outcome: "passed" | "failed" | "held";
|
|
355
|
+
}
|
|
356
|
+
|
|
292
357
|
/**
|
|
293
358
|
* The document `pr-plan` and `pr-apply` write (`pr-report.schema.json`).
|
|
294
359
|
* The terragucci plan report (#3349) reads it; fields are only ever added.
|
|
@@ -350,6 +415,14 @@ export interface PrReport {
|
|
|
350
415
|
waveGate?: string;
|
|
351
416
|
/** With `waveGate`: each wave reached so far, in order. */
|
|
352
417
|
waves?: PrWave[];
|
|
418
|
+
/**
|
|
419
|
+
* Plan stage (#4289): the scheduled watch and reconcile Ops whose cron has
|
|
420
|
+
* fired at least twice since their job last ran, or since their pipeline
|
|
421
|
+
* file was added when no run is on record. Absent when none is overdue or
|
|
422
|
+
* the forge was not asked. Added within contract 1 by chant
|
|
423
|
+
* {@link PR_REPORT_OVERDUE_FLOOR}.
|
|
424
|
+
*/
|
|
425
|
+
overdue?: PrOverdue[];
|
|
353
426
|
/** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
|
|
354
427
|
selection: {
|
|
355
428
|
changed: string[];
|
|
@@ -361,6 +434,17 @@ export interface PrReport {
|
|
|
361
434
|
};
|
|
362
435
|
members: PrMember[];
|
|
363
436
|
changeSet: ChangeSetDocument;
|
|
437
|
+
/**
|
|
438
|
+
* The stage hooks the job ran (#4288), in order. Absent when it ran none.
|
|
439
|
+
* Added within contract 1 by chant {@link PR_REPORT_HOOKS_FLOOR}.
|
|
440
|
+
*/
|
|
441
|
+
hooks?: PrHookOutcome[];
|
|
442
|
+
/**
|
|
443
|
+
* What `ci.policy` at base said about the change set (#4294). Absent when
|
|
444
|
+
* the base sets no policy, or the stage reached no plan to judge. Added
|
|
445
|
+
* within contract 1 by chant {@link PR_REPORT_POLICY_FLOOR}.
|
|
446
|
+
*/
|
|
447
|
+
policy?: PrPolicyReport;
|
|
364
448
|
}
|
|
365
449
|
|
|
366
450
|
/** The counts a member's entries add up to. */
|
|
@@ -421,11 +505,14 @@ function headline(report: PrReport): string {
|
|
|
421
505
|
case "recorded":
|
|
422
506
|
return `Recorded the plan of ${members} on merge. Nothing applied: a release that carries this commit deploys it.`;
|
|
423
507
|
case "plan-failed":
|
|
508
|
+
if (report.hooks?.some((h) => h.outcome === "failed")) return "A hook failed, so nothing can apply.";
|
|
424
509
|
return `Planning failed for ${report.members.filter((m) => m.status === "plan-failed").length} of ${members}, so nothing can apply.`;
|
|
425
510
|
case "applied":
|
|
426
511
|
return `Applied ${members}.`;
|
|
427
|
-
case "failed":
|
|
428
|
-
|
|
512
|
+
case "failed": {
|
|
513
|
+
const blocked = report.members.filter((m) => m.status === "blocked").length;
|
|
514
|
+
return `The apply failed: ${report.members.filter((m) => m.status === "failed").length} failed, ${blocked > 0 ? `${blocked} blocked, ` : ""}${report.members.filter((m) => m.status === "applied").length} applied.`;
|
|
515
|
+
}
|
|
429
516
|
case "wave-applied": {
|
|
430
517
|
const applied = (report.waves ?? []).filter((w) => w.status === "applied").map((w) => w.wave);
|
|
431
518
|
const total = report.selection.waves.length;
|
|
@@ -440,6 +527,43 @@ function headline(report: PrReport): string {
|
|
|
440
527
|
}
|
|
441
528
|
}
|
|
442
529
|
|
|
530
|
+
/**
|
|
531
|
+
* The blocked set (#4291): each component a failure held, grouped by the
|
|
532
|
+
* component that failed, and that the rest went on. Undefined when nothing
|
|
533
|
+
* is blocked.
|
|
534
|
+
*/
|
|
535
|
+
function blockedLine(members: readonly PrMember[]): string | undefined {
|
|
536
|
+
const byRoot = new Map<string, Set<string>>();
|
|
537
|
+
for (const m of members) {
|
|
538
|
+
if (m.status !== "blocked") continue;
|
|
539
|
+
const by = m.blockedBy ?? "a dependency";
|
|
540
|
+
byRoot.set(by, (byRoot.get(by) ?? new Set()).add(m.component));
|
|
541
|
+
}
|
|
542
|
+
if (byRoot.size === 0) return undefined;
|
|
543
|
+
const groups = [...byRoot]
|
|
544
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
545
|
+
.map(([by, names]) => `${[...names].sort().map(code).join(", ")} (blocked by ${by === "a dependency" ? by : code(by)})`);
|
|
546
|
+
return `Blocked, so not applied: ${groups.join("; ")}. A failure blocks only the members that depend on it, so the rest went on.`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The policy's lines in the note (#4294): what denied or held the plan, and how a denial is overridden. */
|
|
550
|
+
function policyLines(report: PrReport): string[] {
|
|
551
|
+
const p = report.policy;
|
|
552
|
+
if (!p) return [];
|
|
553
|
+
const at = `read at ${code(short(p.base))}`;
|
|
554
|
+
const each = (list: Array<{ rule: string; message: string }>): string[] => list.map((f) => `- ${code(f.rule)}: ${f.message}`);
|
|
555
|
+
const out: string[] = [""];
|
|
556
|
+
if (p.result === "error") out.push(`Policy (${p.engine}, ${at}) could not be checked, so nothing applies: ${p.error ?? "no answer"}.`);
|
|
557
|
+
else if (p.result === "passed") out.push(`Policy (${p.engine}, ${at}) passed.`);
|
|
558
|
+
if (p.denials.length > 0) {
|
|
559
|
+
out.push(p.override ? `Policy (${p.engine}, ${at}) denied this plan; ${code(p.override.by)} overrode it: ${p.override.reason}` : `Policy (${p.engine}, ${at}) denied this plan:`, ...each(p.denials));
|
|
560
|
+
if (!p.override && p.overrideCommand) out.push("", `A person the overriders file at base lists may override it once pr-apply has recorded the denial: ${code(p.overrideCommand)}`);
|
|
561
|
+
}
|
|
562
|
+
if (p.holds.length > 0) out.push(`Policy (${p.engine}, ${at}) holds this plan for a person's approval of its digest:`, ...each(p.holds));
|
|
563
|
+
if (p.warnings.length > 0) out.push(`Policy warnings: ${p.warnings.join("; ")}`);
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
443
567
|
/** Who a wave applied or waits under, as a table cell. */
|
|
444
568
|
function waveApproval(w: PrWave): string {
|
|
445
569
|
const whose = w.approval === "pull-request" ? "the pull request's" : "its own";
|
|
@@ -513,6 +637,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
|
|
|
513
637
|
`${headline(report)} Head ${code(short(report.head))}, measured from ${code(short(report.base))}.`,
|
|
514
638
|
];
|
|
515
639
|
if (report.message) out.push("", `> ${report.message.split("\n").join("\n> ")}`);
|
|
640
|
+
for (const o of report.overdue ?? []) out.push("", `Warning: ${o.message}`);
|
|
516
641
|
if (report.resumed && report.resumed.applied.length > 0) {
|
|
517
642
|
out.push("", `Resumed under the same approval: ${report.resumed.applied.map(code).join(", ")} applied in an earlier attempt and did not run again.`);
|
|
518
643
|
}
|
|
@@ -522,6 +647,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
|
|
|
522
647
|
const holes = memberHoleReasons(report, m.member);
|
|
523
648
|
const status =
|
|
524
649
|
STATUS_WORDS[m.status] +
|
|
650
|
+
(m.status === "blocked" && m.blockedBy ? ` by ${code(m.blockedBy)}` : "") +
|
|
525
651
|
(m.inputsMoved?.length ? `, inputs from ${m.inputsMoved.join(", ")} moved` : "") +
|
|
526
652
|
(holes.length > 0 ? `; ${holes.length > 2 ? `${holes[0]}; and ${holes.length - 1} more, listed under Holes` : holes.join("; ")}` : "");
|
|
527
653
|
// A member whose plan has holes and no entries says nothing about what
|
|
@@ -534,7 +660,17 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
|
|
|
534
660
|
out.push("", "A member whose inputs moved applied the plan it was approved with. The next run plans it against the new values.");
|
|
535
661
|
}
|
|
536
662
|
}
|
|
663
|
+
const blocked = blockedLine(report.members);
|
|
664
|
+
if (blocked) out.push("", blocked);
|
|
537
665
|
out.push(...waveLines(report, options.approver ?? "<you>"));
|
|
666
|
+
// #4288: the hooks that did not pass, by name; a passed hook is a count.
|
|
667
|
+
const hooks = report.hooks ?? [];
|
|
668
|
+
if (hooks.length > 0) {
|
|
669
|
+
const passed = hooks.filter((h) => h.outcome === "passed").length;
|
|
670
|
+
const other = hooks.filter((h) => h.outcome !== "passed").map((h) => `${code(h.name)} ${h.moment}${h.target ? ` for ${code(h.target)}` : ""} ${h.outcome === "held" ? "held" : "failed"} (exit ${h.exit})`);
|
|
671
|
+
out.push("", `Hooks: ${passed} passed${other.length > 0 ? `; ${other.join("; ")}` : ""}.`);
|
|
672
|
+
}
|
|
673
|
+
out.push(...policyLines(report));
|
|
538
674
|
if (report.selection.unclaimed.length > 0) {
|
|
539
675
|
out.push("", `Changed, and deployed by no component: ${report.selection.unclaimed.map(code).join(", ")}.`);
|
|
540
676
|
}
|
|
@@ -635,7 +771,11 @@ export function prStatusDescription(report: PrReport): string {
|
|
|
635
771
|
text = "refused: an earlier wave has not applied";
|
|
636
772
|
break;
|
|
637
773
|
}
|
|
638
|
-
|
|
774
|
+
if (report.refusal === "policy-denied" || report.refusal === "policy-error") {
|
|
775
|
+
text = report.refusal === "policy-denied" ? `refused: the policy denied the plan (${(report.policy?.rules ?? []).join(", ")})` : "refused: the policy could not be checked";
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
text = `refused: ${report.refusal === "plan-changed" ? "the plan changed after review" : report.refusal === "not-a-reviewer" ? "the approver has not approved the pull request" : report.refusal === "author-approved" ? "the approver wrote the change" : report.refusal === "no-pull-request" ? "no pull request merged this commit" : report.refusal === "locked" ? "another pull request holds a target lock" : "the plan is not approved"}`;
|
|
639
779
|
break;
|
|
640
780
|
}
|
|
641
781
|
}
|
|
@@ -646,6 +786,8 @@ export function prStatusDescription(report: PrReport): string {
|
|
|
646
786
|
export function prStatusState(report: PrReport): "success" | "failure" | "pending" {
|
|
647
787
|
// #4168 decision 1: a merge whose plan differs from the approved one is recorded, and its status fails.
|
|
648
788
|
if (report.status === "recorded") return report.approval.status === "changed" ? "failure" : "success";
|
|
789
|
+
// #4294: a plan the policy denied, with no override, or one it could not check, fails at the plan stage as it would at the apply.
|
|
790
|
+
if (report.status === "planned" && report.policy && (report.policy.result === "error" || (report.policy.result === "denied" && !report.policy.override))) return "failure";
|
|
649
791
|
if (report.status === "planned" || report.status === "applied" || report.status === "wave-applied" || report.status === "nothing") return "success";
|
|
650
792
|
if (report.status === "refused" && (report.refusal === "not-approved" || report.refusal === "wave-not-approved")) return "pending";
|
|
651
793
|
if (report.status === "waiting") return "pending";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4295: `chant components pr-post` posts the plan's note and `chant/plan`
|
|
3
|
+
* from a report the pull request's code wrote, so it reads it as data: the
|
|
4
|
+
* pull request, head, environment and member are the caller's, a report that
|
|
5
|
+
* names others is refused, and the note carries no hidden marker but its own.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { postPlanReport, readPlanReport } from "./pr-post";
|
|
13
|
+
import { prNoteMarker, type PrReport } from "./pr-loop";
|
|
14
|
+
import type { ForgeStatus, PrForge } from "./pr-forge";
|
|
15
|
+
|
|
16
|
+
const FIXTURE = join(import.meta.dirname, "__fixtures__", "pr-loop", "pr-plan.golden.json");
|
|
17
|
+
const REPORT = JSON.parse(readFileSync(FIXTURE, "utf-8")) as PrReport;
|
|
18
|
+
const HEAD = REPORT.head;
|
|
19
|
+
|
|
20
|
+
const dirs: string[] = [];
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function reportDir(report: unknown): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pr-post-"));
|
|
27
|
+
dirs.push(dir);
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
if (report !== undefined) writeFileSync(join(dir, "pr-plan.json"), typeof report === "string" ? report : JSON.stringify(report));
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fakeForge(refuse = false): { forge: PrForge; statuses: Array<{ sha: string; status: ForgeStatus }>; notes: Array<{ pr: number; marker: string; body: string }> } {
|
|
34
|
+
const statuses: Array<{ sha: string; status: ForgeStatus }> = [];
|
|
35
|
+
const notes: Array<{ pr: number; marker: string; body: string }> = [];
|
|
36
|
+
const forge = {
|
|
37
|
+
kind: "github",
|
|
38
|
+
host: "github.com",
|
|
39
|
+
runUrl: "https://github.com/acme/estate/actions/runs/1",
|
|
40
|
+
async setStatus(sha: string, status: ForgeStatus) {
|
|
41
|
+
if (refuse) throw new Error("403");
|
|
42
|
+
statuses.push({ sha, status });
|
|
43
|
+
},
|
|
44
|
+
async upsertNote(pr: number, marker: string, body: string) {
|
|
45
|
+
if (refuse) throw new Error("403");
|
|
46
|
+
notes.push({ pr, marker, body });
|
|
47
|
+
return "1";
|
|
48
|
+
},
|
|
49
|
+
} as unknown as PrForge;
|
|
50
|
+
return { forge, statuses, notes };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const at = { pr: REPORT.pr!, head: HEAD, env: REPORT.env };
|
|
54
|
+
|
|
55
|
+
describe("pr-post reads the plan report as data (#4295)", () => {
|
|
56
|
+
test("a report for this pull request, head and environment posts its status and its note", async () => {
|
|
57
|
+
const { forge, statuses, notes } = fakeForge();
|
|
58
|
+
const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at, result: "success" });
|
|
59
|
+
expect(r).toMatchObject({ state: "success", noted: true, warnings: [] });
|
|
60
|
+
expect(statuses).toEqual([{ sha: HEAD, status: expect.objectContaining({ context: "chant/plan", state: "success", url: "https://github.com/acme/estate/actions/runs/1" }) }]);
|
|
61
|
+
expect(notes).toHaveLength(1);
|
|
62
|
+
expect(notes[0]!.marker).toBe(prNoteMarker("prod"));
|
|
63
|
+
expect(notes[0]!.body.startsWith(prNoteMarker("prod") + "\n")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a marker the change wrote into its report is defused, so the note claims no other environment's", async () => {
|
|
67
|
+
const forged = { ...REPORT, message: `planned ${prNoteMarker("staging")} <!-- chant-pr:prod -->` };
|
|
68
|
+
const { forge, notes } = fakeForge();
|
|
69
|
+
await postPlanReport({ forge, dir: reportDir(forged), ...at });
|
|
70
|
+
const body = notes[0]!.body;
|
|
71
|
+
expect(body.split("<!--")).toHaveLength(2);
|
|
72
|
+
expect(body).toContain("<!-- chant-pr:staging -->");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test.each([
|
|
76
|
+
["another pull request", { pr: 13 }, /pull request 13, not 12/],
|
|
77
|
+
["another head", { head: "3".repeat(40) }, /for head/],
|
|
78
|
+
["another environment", { env: "staging" }, /environment "staging"/],
|
|
79
|
+
["a member", { member: "net" }, /member "net"/],
|
|
80
|
+
["the apply stage", { stage: "apply" }, /not a plan report/],
|
|
81
|
+
])("a report for %s is refused: a failed status and no note", async (_what, change, why) => {
|
|
82
|
+
const { forge, statuses, notes } = fakeForge();
|
|
83
|
+
const r = await postPlanReport({ forge, dir: reportDir({ ...REPORT, ...change }), ...at });
|
|
84
|
+
expect(r.refused).toMatch(why);
|
|
85
|
+
expect(r.state).toBe("failure");
|
|
86
|
+
expect(statuses[0]!.sha).toBe(HEAD);
|
|
87
|
+
expect(statuses[0]!.status.state).toBe("failure");
|
|
88
|
+
expect(notes).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("no report, or one that does not read, fails the status", async () => {
|
|
92
|
+
for (const report of [undefined, "{not json", "[]", JSON.stringify({ ...REPORT, members: "x" })]) {
|
|
93
|
+
const { forge, statuses, notes } = fakeForge();
|
|
94
|
+
const r = await postPlanReport({ forge, dir: reportDir(report), ...at });
|
|
95
|
+
expect(r.state).toBe("failure");
|
|
96
|
+
expect(statuses[0]!.status.state).toBe("failure");
|
|
97
|
+
expect(notes).toEqual([]);
|
|
98
|
+
}
|
|
99
|
+
expect(readPlanReport({ dir: reportDir(undefined), ...at })).toEqual({ refused: "the plan job wrote no report" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a plan job that failed fails the status, whatever its report says", async () => {
|
|
103
|
+
const { forge, statuses } = fakeForge();
|
|
104
|
+
const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at, result: "failure" });
|
|
105
|
+
expect(r.state).toBe("failure");
|
|
106
|
+
expect(statuses[0]!.status.description).toMatch(/^the plan job failed; /);
|
|
107
|
+
expect(statuses[0]!.status.description.length).toBeLessThanOrEqual(140);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a member's report posts under the member's context and marker, and none when the change does not reach it", async () => {
|
|
111
|
+
const { forge, statuses, notes } = fakeForge();
|
|
112
|
+
await postPlanReport({ forge, dir: reportDir({ ...REPORT, member: "net" }), ...at, member: "net" });
|
|
113
|
+
expect(statuses[0]!.status.context).toBe("chant/plan/net");
|
|
114
|
+
expect(notes[0]!.marker).toBe(prNoteMarker("prod", "net"));
|
|
115
|
+
const quiet = fakeForge();
|
|
116
|
+
await postPlanReport({ forge: quiet.forge, dir: reportDir({ ...REPORT, member: "net", status: "nothing" }), ...at, member: "net" });
|
|
117
|
+
expect(quiet.statuses).toHaveLength(1);
|
|
118
|
+
expect(quiet.notes).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a forge that refuses is a warning", async () => {
|
|
122
|
+
const { forge } = fakeForge(true);
|
|
123
|
+
const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at });
|
|
124
|
+
expect(r.warnings).toEqual([expect.stringMatching(/could not set the chant\/plan status: 403/), expect.stringMatching(/could not post the note on #12: 403/)]);
|
|
125
|
+
expect(r.noted).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("pr-post asks for overdue scheduled Ops itself (#4289, #4295)", () => {
|
|
130
|
+
test("the Ops it finds go in the note, and an overdue list the change wrote is dropped", async () => {
|
|
131
|
+
const forged = { ...REPORT, overdue: [{ op: "forged", cron: "* * * * *", message: "forged warning" }] };
|
|
132
|
+
const found = { op: "drift-watch", cron: "0 * * * *", message: "Op drift-watch has not run since its cron fired twice" };
|
|
133
|
+
const { forge, notes } = fakeForge();
|
|
134
|
+
await postPlanReport({ forge, dir: reportDir(forged), ...at, overdue: async () => [found as never] });
|
|
135
|
+
expect(notes[0]!.body).toContain("drift-watch has not run");
|
|
136
|
+
expect(notes[0]!.body).not.toContain("forged warning");
|
|
137
|
+
const quiet = fakeForge();
|
|
138
|
+
await postPlanReport({ forge: quiet.forge, dir: reportDir(forged), ...at, overdue: async () => [] });
|
|
139
|
+
expect(quiet.notes[0]!.body).not.toContain("forged warning");
|
|
140
|
+
});
|
|
141
|
+
});
|
package/src/pr-post.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post a pull request's plan note and its `chant/plan` status from the plan
|
|
3
|
+
* report (#4295), in a job or a process that runs none of the change's code.
|
|
4
|
+
*
|
|
5
|
+
* The plan job runs the pull request's code and holds no forge token
|
|
6
|
+
* (./ci-handover.ts), so `pr-plan --no-status` only writes its report. Then
|
|
7
|
+
* `chant components pr-post` in the `plan-note` job, or `chant ci run plan`
|
|
8
|
+
* once its steps settle, reads the report and posts.
|
|
9
|
+
*
|
|
10
|
+
* The report is the change's: its code could write anything into it. So it
|
|
11
|
+
* is read as data. The pull request, the head, the environment and the
|
|
12
|
+
* member come from the caller, never the report, and a report that names
|
|
13
|
+
* others is refused. The note is rendered again here from the report, and
|
|
14
|
+
* every hidden HTML comment after its first line is defused, so a forged
|
|
15
|
+
* marker cannot claim another environment's note. A plan job that failed
|
|
16
|
+
* fails the status whatever its report says.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { prNoteMarker, prStatusContext, prStatusDescription, prStatusState, renderPrNote, type PrOverdue, type PrReport } from "./pr-loop";
|
|
22
|
+
import { forgePrincipalOf, type PrForge } from "./pr-forge";
|
|
23
|
+
import { GITHUB_COMMENT_LIMIT, GITLAB_NOTE_LIMIT } from "./plan-summary";
|
|
24
|
+
|
|
25
|
+
/** The plan report's file under the report directory. */
|
|
26
|
+
export const PR_PLAN_REPORT_FILE = "pr-plan.json";
|
|
27
|
+
|
|
28
|
+
/** What {@link postPlanReport} posts for. */
|
|
29
|
+
export interface PlanPostInput {
|
|
30
|
+
forge: PrForge;
|
|
31
|
+
/** The report directory the plan job wrote, `.chant/pr`. */
|
|
32
|
+
dir: string;
|
|
33
|
+
/** The pull request, from the forge's event. */
|
|
34
|
+
pr: number;
|
|
35
|
+
/** The commit the plan job checked out, from the forge's event. */
|
|
36
|
+
head: string;
|
|
37
|
+
env: string;
|
|
38
|
+
member?: string;
|
|
39
|
+
/** The plan job's own result (`needs.plan.result`). `failure` fails the status. */
|
|
40
|
+
result?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The scheduled Ops that stopped running (#4289), asked here since the plan
|
|
43
|
+
* job held no token to ask with. It replaces any `overdue` in the report.
|
|
44
|
+
*/
|
|
45
|
+
overdue?: () => Promise<PrOverdue[]>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What {@link postPlanReport} did. */
|
|
49
|
+
export interface PlanPostResult {
|
|
50
|
+
state: "success" | "failure" | "pending";
|
|
51
|
+
description: string;
|
|
52
|
+
/** Whether a note was posted. A refused or missing report posts none. */
|
|
53
|
+
noted: boolean;
|
|
54
|
+
/** Why the report was not used, when it was not. */
|
|
55
|
+
refused?: string;
|
|
56
|
+
warnings: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the plan report under `dir` and check it is the one the caller ran:
|
|
63
|
+
* a plan report for the same pull request, head, environment and member.
|
|
64
|
+
* Returns the report, or why it is not used.
|
|
65
|
+
*/
|
|
66
|
+
export function readPlanReport(input: Pick<PlanPostInput, "dir" | "pr" | "head" | "env" | "member">): { report: PrReport } | { refused: string } {
|
|
67
|
+
const file = join(input.dir, PR_PLAN_REPORT_FILE);
|
|
68
|
+
if (!existsSync(file)) return { refused: "the plan job wrote no report" };
|
|
69
|
+
let doc: unknown;
|
|
70
|
+
try {
|
|
71
|
+
doc = JSON.parse(readFileSync(file, "utf-8"));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { refused: `the plan report does not read: ${err instanceof Error ? err.message : String(err)}` };
|
|
74
|
+
}
|
|
75
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return { refused: "the plan report is not an object" };
|
|
76
|
+
const r = doc as Record<string, unknown>;
|
|
77
|
+
if (r.stage !== "plan") return { refused: `the report is not a plan report (stage ${JSON.stringify(r.stage)})` };
|
|
78
|
+
if (r.pr !== input.pr) return { refused: `the report is for pull request ${JSON.stringify(r.pr)}, not ${input.pr}` };
|
|
79
|
+
if (typeof r.head !== "string" || !SHA.test(r.head) || r.head !== input.head) return { refused: `the report is for head ${JSON.stringify(r.head)}, not ${input.head}` };
|
|
80
|
+
if (r.env !== input.env) return { refused: `the report is for environment ${JSON.stringify(r.env)}, not ${input.env}` };
|
|
81
|
+
if ((r.member ?? undefined) !== input.member) return { refused: `the report is for member ${JSON.stringify(r.member ?? null)}, not ${JSON.stringify(input.member ?? null)}` };
|
|
82
|
+
if (!Array.isArray(r.members) || !r.changeSet || typeof r.changeSet !== "object" || !r.selection || typeof r.selection !== "object" || !r.approval || typeof r.approval !== "object") {
|
|
83
|
+
return { refused: "the plan report is missing its members, change set, selection or approval" };
|
|
84
|
+
}
|
|
85
|
+
return { report: doc as PrReport };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The note for `report`, rendered here, with every `<!--` after the marker
|
|
90
|
+
* line written as `<!--`, so the note carries no hidden marker but its own.
|
|
91
|
+
*/
|
|
92
|
+
export function planNoteFor(report: PrReport, forge: Pick<PrForge, "kind" | "host">, marker: string): string {
|
|
93
|
+
const note = renderPrNote(report, {
|
|
94
|
+
limit: forge.kind === "gitlab" ? GITLAB_NOTE_LIMIT : GITHUB_COMMENT_LIMIT,
|
|
95
|
+
approver: forgePrincipalOf(forge.kind, forge.host, "<your-login>"),
|
|
96
|
+
});
|
|
97
|
+
const at = note.indexOf("\n");
|
|
98
|
+
const rest = (at < 0 ? "" : note.slice(at + 1)).split("<!--").join("<!--");
|
|
99
|
+
return `${marker}\n${rest}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cut = (text: string, max = 140): string => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Post the status and the note for the plan report under `input.dir`. A
|
|
106
|
+
* forge that refuses a call is a warning. A report that is missing or not
|
|
107
|
+
* the caller's sets a failed status and posts no note.
|
|
108
|
+
*/
|
|
109
|
+
export async function postPlanReport(input: PlanPostInput): Promise<PlanPostResult> {
|
|
110
|
+
const warnings: string[] = [];
|
|
111
|
+
const context = prStatusContext("plan", input.member);
|
|
112
|
+
const read = readPlanReport(input);
|
|
113
|
+
const failedJob = input.result === "failure" || input.result === "cancelled";
|
|
114
|
+
let state: PlanPostResult["state"];
|
|
115
|
+
let description: string;
|
|
116
|
+
let note: string | undefined;
|
|
117
|
+
let refused: string | undefined;
|
|
118
|
+
if ("refused" in read) {
|
|
119
|
+
refused = read.refused;
|
|
120
|
+
state = "failure";
|
|
121
|
+
description = cut(read.refused);
|
|
122
|
+
} else {
|
|
123
|
+
let report = read.report;
|
|
124
|
+
if (input.overdue) {
|
|
125
|
+
const { overdue: _forged, ...rest } = report;
|
|
126
|
+
const found = await input.overdue().catch(() => [] as PrOverdue[]);
|
|
127
|
+
report = (found.length > 0 ? { ...rest, overdue: found } : rest) as PrReport;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
state = prStatusState(report);
|
|
131
|
+
description = prStatusDescription(report);
|
|
132
|
+
// A member the change does not reach has nothing to say on the pull request (as pr-plan's own publish).
|
|
133
|
+
if (!(input.member && report.status === "nothing" && !report.overdue?.length)) note = planNoteFor(report, input.forge, prNoteMarker(input.env, input.member));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
refused = `the plan report does not render: ${err instanceof Error ? err.message : String(err)}`;
|
|
136
|
+
state = "failure";
|
|
137
|
+
description = cut(refused);
|
|
138
|
+
note = undefined;
|
|
139
|
+
}
|
|
140
|
+
if (failedJob && state !== "failure") {
|
|
141
|
+
state = "failure";
|
|
142
|
+
description = cut(`the plan job failed; ${description}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await input.forge.setStatus(input.head, { context, state, description: cut(description), ...(input.forge.runUrl ? { url: input.forge.runUrl } : {}) });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
warnings.push(`could not set the ${context} status: ${err instanceof Error ? err.message : String(err)}`);
|
|
149
|
+
}
|
|
150
|
+
if (note !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
await input.forge.upsertNote(input.pr, prNoteMarker(input.env, input.member), note);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
warnings.push(`could not post the note on #${input.pr}: ${err instanceof Error ? err.message : String(err)}`);
|
|
155
|
+
note = undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { state, description: cut(description), noted: note !== undefined, ...(refused ? { refused } : {}), warnings };
|
|
159
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blocked-member rules bundle on their own (#4291): terragucci calls them
|
|
3
|
+
* from its CI stages as one bundled file with no TypeScript toolchain, as it
|
|
4
|
+
* calls `./gated-waves` (#3421).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { build } from "esbuild";
|
|
9
|
+
import { expect, test } from "vitest";
|
|
10
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
11
|
+
|
|
12
|
+
test("wave-blocked bundles with nothing but itself", async () => {
|
|
13
|
+
const result = await build({
|
|
14
|
+
entryPoints: [join(REPO, "packages", "core", "src", "wave-blocked.ts")],
|
|
15
|
+
bundle: true,
|
|
16
|
+
platform: "node",
|
|
17
|
+
format: "esm",
|
|
18
|
+
write: false,
|
|
19
|
+
metafile: true,
|
|
20
|
+
outdir: "out",
|
|
21
|
+
logLevel: "silent",
|
|
22
|
+
});
|
|
23
|
+
expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/"))).toEqual(["packages/core/src/wave-blocked.ts"]);
|
|
24
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(12 * 1024);
|
|
25
|
+
});
|