@intentius/chant 0.124.39 → 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/component-endpoint.d.ts +20 -0
- package/dist/cli/component-endpoint.d.ts.map +1 -0
- package/dist/cli/component-endpoint.js +36 -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.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +26 -4
- 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 +514 -58
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -2
- 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/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +17 -6
- 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/auto-release.d.ts +35 -3
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +29 -2
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +2 -0
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/deploy-units.js +5 -0
- 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/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +39 -16
- 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/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
- 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/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +4 -3
- package/dist/lifecycle/publish-npm.d.ts +2 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +31 -8
- 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/release-ledger.d.ts +31 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +19 -1
- 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/status.d.ts +5 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/status.js +10 -5
- 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/live-endpoint.d.ts +18 -0
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +27 -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 +239 -239
- 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 +24 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +85 -6
- 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 +3 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +7 -3
- package/dist/workspace/status.schema.json +1 -0
- 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/component-endpoint.ts +43 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/fan-out.test.ts +10 -0
- package/src/cli/handlers/fan-out.ts +29 -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-endpoint.test.ts +169 -0
- 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 +508 -64
- package/src/cli/handlers/promote.ts +25 -2
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/handlers/run.test.ts +2 -1
- package/src/cli/handlers/run.ts +16 -5
- 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/auto-release-infra.test.ts +131 -0
- package/src/components/auto-release.test.ts +40 -1
- package/src/components/auto-release.ts +50 -4
- package/src/components/cli-support.ts +4 -0
- package/src/components/deploy-units.ts +5 -0
- 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/components/promote.test.ts +39 -0
- package/src/components/promote.ts +38 -14
- 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/__fixtures__/publish-fakes.ts +6 -1
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/publish-declaration.ts +4 -3
- package/src/lifecycle/publish-npm.ts +33 -8
- package/src/lifecycle/publish-targets.test.ts +36 -3
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/release-ledger.ts +42 -1
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/status.test.ts +19 -0
- package/src/lifecycle/status.ts +14 -6
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/live-endpoint.ts +31 -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 +133 -8
- package/src/workspace/ci-run.ts +92 -7
- 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.schema.json +1 -0
- package/src/workspace/status.ts +11 -4
- 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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled watch and reconcile Ops that stopped running (#4289): the UTC
|
|
3
|
+
* cron count, the verdict, the forge clients' `scheduledRuns`, the check the
|
|
4
|
+
* plan job runs, and the warning line in the plan note.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { cronFiresUtc } from "./cron";
|
|
12
|
+
import { checkOverdueOps, opPipelineFile, overdueVerdict, scheduledWatches } from "./schedule-overdue";
|
|
13
|
+
import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "../pr-forge";
|
|
14
|
+
import { renderPrNote, type PrReport } from "../pr-loop";
|
|
15
|
+
import type { OpConfig } from "./types";
|
|
16
|
+
|
|
17
|
+
function fakeFetch(route: (method: string, url: URL) => unknown): ForgeFetch {
|
|
18
|
+
return async (url, init) => {
|
|
19
|
+
const found = route(init.method, new URL(url));
|
|
20
|
+
if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
|
|
21
|
+
if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
|
|
22
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(found) };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("cronFiresUtc", () => {
|
|
27
|
+
test("counts fires in UTC, after the start and up to the end", () => {
|
|
28
|
+
const fires = cronFiresUtc("0 6 * * *", new Date("2026-10-01T06:00:00Z"), new Date("2026-10-04T05:59:00Z"), 5);
|
|
29
|
+
expect(fires.map((d) => d.toISOString())).toEqual(["2026-10-02T06:00:00.000Z", "2026-10-03T06:00:00.000Z"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("stops at max, reads weekday names as numbers and 7 as Sunday", () => {
|
|
33
|
+
expect(cronFiresUtc("*/15 * * * *", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-02T00:00:00Z"), 2)).toHaveLength(2);
|
|
34
|
+
const sundays = cronFiresUtc("0 0 * * 7", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-31T00:00:00Z"), 10);
|
|
35
|
+
expect(sundays.every((d) => d.getUTCDay() === 0)).toBe(true);
|
|
36
|
+
expect(sundays).toHaveLength(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an expression that is not 5 or 6 fields fires never", () => {
|
|
40
|
+
expect(cronFiresUtc("0 6 *", new Date("2026-01-01T00:00:00Z"), new Date("2026-12-31T00:00:00Z"), 2)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("scheduledWatches", () => {
|
|
45
|
+
const op = (name: string, labels?: Record<string, string>, cron?: string): OpConfig =>
|
|
46
|
+
({ name, overview: "", phases: [], ...(labels ? { labels } : {}), ...(cron ? { schedule: { cron } } : {}) }) as OpConfig;
|
|
47
|
+
|
|
48
|
+
test("keeps scheduled Ops labelled Watch or Reconcile, sorted", () => {
|
|
49
|
+
const got = scheduledWatches([
|
|
50
|
+
op("z-watch", { Watch: "true" }, "0 * * * *"),
|
|
51
|
+
op("a-reconcile", { Reconcile: "true" }, "0 6 * * *"),
|
|
52
|
+
op("unscheduled", { Watch: "true" }),
|
|
53
|
+
op("deploy", undefined, "0 6 * * *"),
|
|
54
|
+
]);
|
|
55
|
+
expect(got).toEqual([
|
|
56
|
+
{ op: "a-reconcile", cron: "0 6 * * *" },
|
|
57
|
+
{ op: "z-watch", cron: "0 * * * *" },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("overdueVerdict", () => {
|
|
63
|
+
const watch = { op: "drift", cron: "0 6 * * *" };
|
|
64
|
+
const now = new Date("2026-10-22T12:00:00Z");
|
|
65
|
+
|
|
66
|
+
test("one missed fire is on time; two is overdue", () => {
|
|
67
|
+
expect(overdueVerdict(watch, "github", { last: "2026-10-21T06:01:00Z" }, undefined, now)).toBeUndefined();
|
|
68
|
+
const late = overdueVerdict(watch, "github", { last: "2026-10-20T06:01:00Z" }, undefined, now);
|
|
69
|
+
expect(late?.since).toBe("2026-10-20T06:01:00Z");
|
|
70
|
+
expect(late?.message).toContain("Op `drift` is overdue");
|
|
71
|
+
expect(late?.message).toContain("its last run was on 2026-10-20");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a job that never ran counts from its pipeline file's commit", () => {
|
|
75
|
+
const v = overdueVerdict(watch, "forgejo", {}, "2026-10-01T00:00:00Z", now);
|
|
76
|
+
expect(v?.neverRan).toBe(true);
|
|
77
|
+
expect(v?.message).toContain("no run is on record since its pipeline was added on 2026-10-01");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("nothing to count from is no verdict", () => {
|
|
81
|
+
expect(overdueVerdict(watch, "github", {}, undefined, now)).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("names a disabled workflow and a missing GitLab schedule", () => {
|
|
85
|
+
expect(overdueVerdict(watch, "github", { last: "2026-09-01T00:00:00Z", disabled: "disabled_inactivity" }, undefined, now)?.message).toContain("disabled_inactivity");
|
|
86
|
+
expect(overdueVerdict(watch, "gitlab", { noSchedule: true }, "2026-09-01T00:00:00Z", now)?.message).toContain("No active pipeline schedule sets CHANT_SCHEDULED_OP to drift");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("scheduledRuns", () => {
|
|
91
|
+
test("github: the newest schedule or dispatch run, and a disabled workflow", async () => {
|
|
92
|
+
const fetch = fakeFetch((_m, u) => {
|
|
93
|
+
if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml") return { state: "disabled_inactivity" };
|
|
94
|
+
if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml/runs") {
|
|
95
|
+
return { workflow_runs: [{ created_at: u.searchParams.get("event") === "schedule" ? "2026-08-01T06:00:00Z" : "2026-08-03T10:00:00Z" }] };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
});
|
|
99
|
+
const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch });
|
|
100
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-08-03T10:00:00Z", disabled: "disabled_inactivity" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("forgejo: only this workflow's schedule and dispatch runs", async () => {
|
|
104
|
+
const fetch = fakeFetch((_m, u) =>
|
|
105
|
+
u.pathname === "/api/v1/repos/acme/infra/actions/runs"
|
|
106
|
+
? {
|
|
107
|
+
workflow_runs: [
|
|
108
|
+
{ event: "schedule", workflow_id: "other.yml", created: "2026-10-20T00:00:00Z" },
|
|
109
|
+
{ event: "push", workflow_id: "drift.yml", created: "2026-10-19T00:00:00Z" },
|
|
110
|
+
{ event: "schedule", workflow_id: "drift.yml", created: "2026-10-10T00:00:00Z" },
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
: undefined,
|
|
114
|
+
);
|
|
115
|
+
const forge = forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch });
|
|
116
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-10-10T00:00:00Z" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("gitlab: the schedules that select the Op, and none", async () => {
|
|
120
|
+
const fetch = fakeFetch((_m, u) => {
|
|
121
|
+
const p = u.pathname.replace("/api/v4/projects/42", "");
|
|
122
|
+
if (p === "/pipeline_schedules") return [{ id: 1 }, { id: 2 }];
|
|
123
|
+
if (p === "/pipeline_schedules/1") return { id: 1, variables: [{ key: "CHANT_SCHEDULED_OP", value: "drift" }], last_pipeline: { id: 900 } };
|
|
124
|
+
if (p === "/pipeline_schedules/2") return { id: 2, variables: [{ key: "CHANT_SCHEDULED_OP", value: "other" }], last_pipeline: { id: 901 } };
|
|
125
|
+
if (p === "/pipelines/900") return { id: 900, created_at: "2026-10-01T06:00:00Z" };
|
|
126
|
+
return undefined;
|
|
127
|
+
});
|
|
128
|
+
const forge = gitlabForge({ apiBase: "https://gitlab.com/api/v4", project: "42", token: "t", fetch });
|
|
129
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "ops.gitlab-ci.yml" })).toEqual({ last: "2026-10-01T06:00:00Z" });
|
|
130
|
+
expect(await forge.scheduledRuns!({ op: "nobody", workflow: "ops.gitlab-ci.yml" })).toEqual({ noSchedule: true });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("checkOverdueOps", () => {
|
|
135
|
+
let dir: string | undefined;
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
138
|
+
dir = undefined;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function repo(files: Record<string, string>, date: string): string {
|
|
142
|
+
dir = mkdtempSync(join(tmpdir(), "chant-overdue-"));
|
|
143
|
+
const git = (...args: string[]) =>
|
|
144
|
+
execFileSync("git", ["-C", dir!, ...args], { env: { ...process.env, GIT_COMMITTER_DATE: date, GIT_AUTHOR_DATE: date }, stdio: "pipe" });
|
|
145
|
+
git("init", "-q");
|
|
146
|
+
for (const [path, body] of Object.entries(files)) {
|
|
147
|
+
mkdirSync(join(dir, path, ".."), { recursive: true });
|
|
148
|
+
writeFileSync(join(dir, path), body);
|
|
149
|
+
}
|
|
150
|
+
git("add", ".");
|
|
151
|
+
git("-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "add");
|
|
152
|
+
return dir;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const forgeWith = (kind: PrForge["kind"], scheduledRuns: PrForge["scheduledRuns"]): PrForge => ({ kind, host: "example", scheduledRuns }) as PrForge;
|
|
156
|
+
|
|
157
|
+
test("warns for an Op whose job never ran since its workflow was added, and skips one with no pipeline file", async () => {
|
|
158
|
+
const root = repo({ ".github/workflows/drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
|
|
159
|
+
const asked: string[] = [];
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
const found = await checkOverdueOps({
|
|
162
|
+
watches: [
|
|
163
|
+
{ op: "drift", cron: "0 6 * * *" },
|
|
164
|
+
{ op: "operator-only", cron: "0 6 * * *" },
|
|
165
|
+
],
|
|
166
|
+
forge: forgeWith("github", async (q) => {
|
|
167
|
+
asked.push(q.workflow);
|
|
168
|
+
return {};
|
|
169
|
+
}),
|
|
170
|
+
repoRoot: root,
|
|
171
|
+
now: new Date("2026-10-22T00:00:00Z"),
|
|
172
|
+
log: (l) => lines.push(l),
|
|
173
|
+
});
|
|
174
|
+
expect(asked).toEqual(["drift.yml"]);
|
|
175
|
+
expect(found.map((o) => [o.op, o.neverRan, o.since.slice(0, 10)])).toEqual([["drift", true, "2026-10-01"]]);
|
|
176
|
+
expect(lines).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a forge that will not answer is a log line and no verdict", async () => {
|
|
180
|
+
const root = repo({ ".github/workflows/web-drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
|
|
181
|
+
const lines: string[] = [];
|
|
182
|
+
const found = await checkOverdueOps({
|
|
183
|
+
watches: [{ op: "drift", cron: "0 6 * * *" }],
|
|
184
|
+
forge: forgeWith("github", async () => {
|
|
185
|
+
throw new Error("GET answered 403");
|
|
186
|
+
}),
|
|
187
|
+
repoRoot: root,
|
|
188
|
+
member: "web",
|
|
189
|
+
now: new Date("2026-10-22T00:00:00Z"),
|
|
190
|
+
log: (l) => lines.push(l),
|
|
191
|
+
});
|
|
192
|
+
expect(found).toEqual([]);
|
|
193
|
+
expect(lines[0]).toContain("did not say when Op drift last ran");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("gitlab: the root file whose rules select the Op", () => {
|
|
197
|
+
const root = repo({ "ops.gitlab-ci.yml": 'rules:\n - if: $CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "drift"\n' }, "2026-10-01T00:00:00Z");
|
|
198
|
+
expect(opPipelineFile(root, "gitlab", "drift")).toBe("ops.gitlab-ci.yml");
|
|
199
|
+
expect(opPipelineFile(root, "gitlab", "other")).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("the plan note", () => {
|
|
204
|
+
test("prints each overdue Op as a warning", () => {
|
|
205
|
+
const report = {
|
|
206
|
+
stage: "plan",
|
|
207
|
+
env: "prod",
|
|
208
|
+
base: "b".repeat(40),
|
|
209
|
+
head: "a".repeat(40),
|
|
210
|
+
status: "nothing",
|
|
211
|
+
digest: "",
|
|
212
|
+
members: [],
|
|
213
|
+
selection: { changed: [], dependents: [], unclaimed: [], indeterminate: [], waves: [] },
|
|
214
|
+
changeSet: { members: [], entries: [], summary: { byMember: {} } },
|
|
215
|
+
approval: { status: "pending" },
|
|
216
|
+
overdue: [{ op: "drift", cron: "0 6 * * *", since: "2026-10-01T00:00:00Z", message: "Op `drift` is overdue: test." }],
|
|
217
|
+
} as unknown as PrReport;
|
|
218
|
+
expect(renderPrNote(report)).toContain("Warning: Op `drift` is overdue: test.");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled watch and reconcile Ops that stopped running (#4289).
|
|
3
|
+
*
|
|
4
|
+
* A schedule can stop with no error anywhere. GitHub turns a scheduled
|
|
5
|
+
* workflow off after 60 days with no activity in the repository and drops
|
|
6
|
+
* scheduled runs under load. A GitLab schedule exists only once someone
|
|
7
|
+
* creates it. A watch Op cannot report its own absence, so the pull-request
|
|
8
|
+
* plan job does. For each discovered Op with a `schedule` and the `Watch` or
|
|
9
|
+
* `Reconcile` label, whose generated pipeline file is in the checkout, it
|
|
10
|
+
* asks the forge when the Op's job last ran. When the cron has fired at
|
|
11
|
+
* least twice since then, or since the pipeline file was added for a job
|
|
12
|
+
* that never ran, the job log and the plan note say the Op is overdue.
|
|
13
|
+
*
|
|
14
|
+
* One late or dropped run is not enough: two fires are. A forge that will
|
|
15
|
+
* not answer is a line in the log and no verdict.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { OpConfig } from "./types";
|
|
21
|
+
import { cronFiresUtc } from "./cron";
|
|
22
|
+
import type { ForgeKind, PrForge, ScheduledRuns } from "../pr-forge";
|
|
23
|
+
|
|
24
|
+
/** One scheduled watch or reconcile Op the plan job checks. */
|
|
25
|
+
export interface ScheduledWatch {
|
|
26
|
+
op: string;
|
|
27
|
+
cron: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An Op the plan note warns about. */
|
|
31
|
+
export interface OverdueOp {
|
|
32
|
+
op: string;
|
|
33
|
+
cron: string;
|
|
34
|
+
/** The time the count starts from: the job's last run, or the pipeline file's first commit (ISO 8601). */
|
|
35
|
+
since: string;
|
|
36
|
+
/** True when no run is on record and `since` is the pipeline file's first commit. */
|
|
37
|
+
neverRan?: boolean;
|
|
38
|
+
/** The line for the job log and the plan note. */
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The discovered Ops that are scheduled watch or reconcile Ops: a
|
|
44
|
+
* `schedule`, and the `Watch` label `WatchOp` sets (./composites/watch-op.ts)
|
|
45
|
+
* or the `Reconcile` label `ReconcileOp` sets (./composites/reconcile-op.ts).
|
|
46
|
+
* Sorted by name.
|
|
47
|
+
*/
|
|
48
|
+
export function scheduledWatches(ops: Iterable<OpConfig>): ScheduledWatch[] {
|
|
49
|
+
const out: ScheduledWatch[] = [];
|
|
50
|
+
for (const op of ops) {
|
|
51
|
+
if (!op.schedule?.cron) continue;
|
|
52
|
+
if (op.labels?.Watch !== "true" && op.labels?.Reconcile !== "true") continue;
|
|
53
|
+
out.push({ op: op.name, cron: op.schedule.cron });
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => a.op.localeCompare(b.op));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The file `chant run --generate <forge>` writes for an Op, relative to the
|
|
60
|
+
* repository root: `<dir>/<op>.yml` on GitHub and Forgejo, `<member>-<op>.yml`
|
|
61
|
+
* in a workspace member. GitLab puts every Op in one file, so the root's
|
|
62
|
+
* `*.gitlab-ci.yml` files are read for the one whose rules select the Op.
|
|
63
|
+
* Undefined when the checkout has no such file, so an Op that some other
|
|
64
|
+
* scheduler runs is never checked.
|
|
65
|
+
*/
|
|
66
|
+
export function opPipelineFile(repoRoot: string, forge: ForgeKind, op: string, member?: string): string | undefined {
|
|
67
|
+
const base = member ? `${member}-${op}` : op;
|
|
68
|
+
if (forge === "github" || forge === "forgejo") {
|
|
69
|
+
const path = `.${forge}/workflows/${base}.yml`;
|
|
70
|
+
return existsSync(join(repoRoot, path)) ? path : undefined;
|
|
71
|
+
}
|
|
72
|
+
// The gitlab lexicon's cron rule: `$CHANT_SCHEDULED_OP == "<op>"`.
|
|
73
|
+
const selector = `$CHANT_SCHEDULED_OP == "${op}"`;
|
|
74
|
+
let names: string[];
|
|
75
|
+
try {
|
|
76
|
+
names = readdirSync(repoRoot).filter((n) => n.endsWith(".gitlab-ci.yml")).sort();
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return names.find((n) => {
|
|
81
|
+
try {
|
|
82
|
+
return readFileSync(join(repoRoot, n), "utf8").includes(selector);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** When `path` was first committed (ISO 8601), if the checkout's history has it. */
|
|
90
|
+
export function fileAdded(repoRoot: string, path: string): string | undefined {
|
|
91
|
+
const r = spawnSync("git", ["-C", repoRoot, "log", "--format=%cI", "--diff-filter=A", "--", path], { encoding: "utf-8" });
|
|
92
|
+
if (r.status !== 0) return undefined;
|
|
93
|
+
return r.stdout.trim().split("\n").filter(Boolean).pop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const HINT: Record<ForgeKind, string> = {
|
|
97
|
+
github: "GitHub turns a scheduled workflow off after 60 days with no activity in the repository. Turn the workflow on again under Actions, or run it by hand",
|
|
98
|
+
gitlab: "A GitLab project runs a scheduled Op only from a pipeline schedule. Check its schedule under CI/CD > Schedules",
|
|
99
|
+
forgejo: "Check that Actions is on for the repository and a runner is up, or run the workflow by hand",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether one Op is overdue: its cron has fired at least twice since its
|
|
104
|
+
* last run, or since `added` when no run is on record. Undefined when it is
|
|
105
|
+
* on time, or when nothing says when it should have run.
|
|
106
|
+
*/
|
|
107
|
+
export function overdueVerdict(watch: ScheduledWatch, forge: ForgeKind, runs: ScheduledRuns, added: string | undefined, now: Date): OverdueOp | undefined {
|
|
108
|
+
const since = runs.last ?? added;
|
|
109
|
+
if (!since) return undefined;
|
|
110
|
+
if (cronFiresUtc(watch.cron, new Date(since), now, 2).length < 2) return undefined;
|
|
111
|
+
const day = since.slice(0, 10);
|
|
112
|
+
const what = runs.last ? `its last run was on ${day}` : `no run is on record since its pipeline was added on ${day}`;
|
|
113
|
+
const why = runs.disabled
|
|
114
|
+
? `GitHub has turned the workflow off (${runs.disabled}). Turn it on again under Actions`
|
|
115
|
+
: forge === "gitlab" && runs.noSchedule
|
|
116
|
+
? `No active pipeline schedule sets CHANT_SCHEDULED_OP to ${watch.op}. Add one with the cron \`${watch.cron}\` under CI/CD > Schedules`
|
|
117
|
+
: HINT[forge];
|
|
118
|
+
return {
|
|
119
|
+
op: watch.op,
|
|
120
|
+
cron: watch.cron,
|
|
121
|
+
since,
|
|
122
|
+
...(runs.last ? {} : { neverRan: true }),
|
|
123
|
+
message: `Op \`${watch.op}\` is overdue: its schedule \`${watch.cron}\` has fired at least twice and ${what}. ${why}.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The plan job's check over every scheduled watch or reconcile Op that has a
|
|
129
|
+
* generated pipeline file. Each Op the forge will not answer for is a line
|
|
130
|
+
* in `log` and no verdict. A forge client with no `scheduledRuns` checks
|
|
131
|
+
* nothing.
|
|
132
|
+
*/
|
|
133
|
+
export async function checkOverdueOps(input: {
|
|
134
|
+
watches: readonly ScheduledWatch[];
|
|
135
|
+
forge: PrForge;
|
|
136
|
+
repoRoot: string;
|
|
137
|
+
member?: string;
|
|
138
|
+
now: Date;
|
|
139
|
+
log: (line: string) => void;
|
|
140
|
+
}): Promise<OverdueOp[]> {
|
|
141
|
+
const { forge } = input;
|
|
142
|
+
if (!forge.scheduledRuns) return [];
|
|
143
|
+
const out: OverdueOp[] = [];
|
|
144
|
+
for (const watch of input.watches) {
|
|
145
|
+
const file = opPipelineFile(input.repoRoot, forge.kind, watch.op, input.member);
|
|
146
|
+
if (!file) continue;
|
|
147
|
+
let runs: ScheduledRuns;
|
|
148
|
+
try {
|
|
149
|
+
runs = await forge.scheduledRuns({ op: watch.op, workflow: file.split("/").pop()! });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
input.log(`schedule check: ${forge.kind} did not say when Op ${watch.op} last ran, so it is not checked: ${(err instanceof Error ? err.message : String(err)).split("\n")[0]}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const found = overdueVerdict(watch, forge.kind, runs, runs.last ? undefined : fileAdded(input.repoRoot, file), input.now);
|
|
155
|
+
if (found) {
|
|
156
|
+
input.log(found.message);
|
|
157
|
+
out.push(found);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
@@ -34,10 +34,10 @@ describe("the override-gate bundle", () => {
|
|
|
34
34
|
"packages/core/src/effect-receipt.ts",
|
|
35
35
|
"packages/core/src/intrinsic.ts",
|
|
36
36
|
"packages/core/src/lifecycle/gate-ledger-select.ts",
|
|
37
|
+
"packages/core/src/lifecycle/gate-seal.ts",
|
|
37
38
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
38
39
|
"packages/core/src/op/gate-name.ts",
|
|
39
40
|
"packages/core/src/override-gate.ts",
|
|
40
|
-
"packages/core/src/workspace/trust/gate-seal.ts",
|
|
41
41
|
"packages/core/src/workspace/trust/trust-files.ts",
|
|
42
42
|
]);
|
|
43
43
|
// About 16 KB today.
|
package/src/override-gate.ts
CHANGED
|
@@ -38,14 +38,14 @@ import { spawnSync } from "node:child_process";
|
|
|
38
38
|
import { OVERRIDE_GATE_OP } from "./op/gate-name";
|
|
39
39
|
import { latestPendingGate } from "./lifecycle/gate-ledger-select";
|
|
40
40
|
import { samePlanDigest } from "./lifecycle/plan-digest";
|
|
41
|
-
import { gateSealRefusal, type GateSigners } from "./
|
|
41
|
+
import { gateSealRefusal, type GateSigners } from "./lifecycle/gate-seal";
|
|
42
42
|
import { DEFAULT_SIGNERS_PATH } from "./workspace/trust/trust-files";
|
|
43
43
|
import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
44
44
|
import type { GateApprovalSource } from "./op/op-waves";
|
|
45
45
|
import type { GateAuditApply } from "./lifecycle/gate-audit";
|
|
46
46
|
|
|
47
47
|
export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
48
|
-
export type { GateSigners } from "./
|
|
48
|
+
export type { GateSigners } from "./lifecycle/gate-seal";
|
|
49
49
|
|
|
50
50
|
/** The op every override and denial is recorded under. `./lifecycle/gate-audit.ts` reads this op as overrides by default. */
|
|
51
51
|
export const OVERRIDE_OP = OVERRIDE_GATE_OP;
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About
|
|
38
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
37
|
+
// About 37 KB today, after the pull request authors (#4000) and pullRequestOpen for target locks (#4293).
|
|
38
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(40 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -127,6 +127,17 @@ describe("github", () => {
|
|
|
127
127
|
expect(await githubForge({ ...opts, fetch }).pullRequestFor(SHA)).toBe(9);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("a pull request is open while its state is open and it has not merged; an unknown one is not (#4293)", async () => {
|
|
131
|
+
const { fetch } = fakeFetch({
|
|
132
|
+
"GET /repos/acme/infra/pulls/1": { number: 1, state: "open", merged: false },
|
|
133
|
+
"GET /repos/acme/infra/pulls/2": { number: 2, state: "closed", merged: true },
|
|
134
|
+
});
|
|
135
|
+
const forge = githubForge({ ...opts, fetch });
|
|
136
|
+
expect(await forge.pullRequestOpen!(1)).toBe(true);
|
|
137
|
+
expect(await forge.pullRequestOpen!(2)).toBe(false);
|
|
138
|
+
expect(await forge.pullRequestOpen!(3)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
130
141
|
test("resolves to the note's web URL", async () => {
|
|
131
142
|
const { fetch } = fakeFetch({
|
|
132
143
|
"GET /repos/acme/infra/issues/12/comments": [],
|
|
@@ -237,6 +248,11 @@ describe("forgejo", () => {
|
|
|
237
248
|
expect(await forgejoForge({ ...opts, fetch: none.fetch }).pullRequestFor(SHA)).toBeNull();
|
|
238
249
|
});
|
|
239
250
|
|
|
251
|
+
test("reads whether a pull request is open from the same pulls endpoint (#4293)", async () => {
|
|
252
|
+
const { fetch } = fakeFetch({ "GET /api/v1/repos/acme/infra/pulls/4": { number: 4, state: "open", merged: false } });
|
|
253
|
+
expect(await forgejoForge({ ...opts, fetch }).pullRequestOpen!(4)).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
240
256
|
test("names reviewers on its host", () => {
|
|
241
257
|
expect(forgejoForge({ ...opts, fetch: fakeFetch({}).fetch }).principalOf("Ana")).toBe("forgejo@codeberg.org:ana");
|
|
242
258
|
});
|
|
@@ -295,6 +311,16 @@ describe("gitlab", () => {
|
|
|
295
311
|
expect(await forge.pullRequestFor(SHA)).toBe(5);
|
|
296
312
|
expect(forge.principalOf("ana")).toBe("gitlab@gitlab.example.com:ana");
|
|
297
313
|
});
|
|
314
|
+
|
|
315
|
+
test("a merge request is open while its state is opened (#4293)", async () => {
|
|
316
|
+
const { fetch } = fakeFetch({
|
|
317
|
+
"GET /api/v4/projects/42/merge_requests/5": { iid: 5, state: "opened" },
|
|
318
|
+
"GET /api/v4/projects/42/merge_requests/6": { iid: 6, state: "merged" },
|
|
319
|
+
});
|
|
320
|
+
const forge = gitlabForge({ ...opts, fetch });
|
|
321
|
+
expect(await forge.pullRequestOpen!(5)).toBe(true);
|
|
322
|
+
expect(await forge.pullRequestOpen!(6)).toBe(false);
|
|
323
|
+
});
|
|
298
324
|
});
|
|
299
325
|
|
|
300
326
|
describe("naming reviewers", () => {
|
|
@@ -891,6 +917,13 @@ describe("pull requests over REST (#3798)", () => {
|
|
|
891
917
|
expect(l.calls.map((c) => c.body)).toEqual([{ description: "d" }, { add_labels: "minor,breaking" }]);
|
|
892
918
|
});
|
|
893
919
|
|
|
920
|
+
test("labels reads the names a pull request carries on each forge (#4292)", async () => {
|
|
921
|
+
const f = fakeFetch({ "GET /api/v1/repos/acme/infra/issues/10/labels": [{ name: "plan:prod" }, { name: "bug" }, { id: 3 }] });
|
|
922
|
+
expect(await forgejoForge({ ...fjOpts, fetch: f.fetch }).labels!(10)).toEqual(["plan:prod", "bug"]);
|
|
923
|
+
const l = fakeFetch({ "GET /api/v4/projects/group%2Finfra/merge_requests/7": { iid: 7, labels: ["plan:prod", 4] } });
|
|
924
|
+
expect(await gitlabForge({ ...gl, fetch: l.fetch }).labels!(7)).toEqual(["plan:prod"]);
|
|
925
|
+
});
|
|
926
|
+
|
|
894
927
|
test("Forgejo's 404 on the pull list of a just-pushed empty repository is no pull request (#3845)", async () => {
|
|
895
928
|
const empty = fakeFetch({ "GET /api/v1/repos/acme/infra": { default_branch: "main" } });
|
|
896
929
|
expect(await forgejoForge({ ...fjOpts, fetch: empty.fetch }).findPullRequest("chant/x")).toBeUndefined();
|
package/src/pr-forge.ts
CHANGED
|
@@ -361,6 +361,13 @@ export interface PrForge {
|
|
|
361
361
|
authors(pr: number): Promise<string[]>;
|
|
362
362
|
/** The merged pull request that produced `sha`, or null. */
|
|
363
363
|
pullRequestFor(sha: string): Promise<number | null>;
|
|
364
|
+
/**
|
|
365
|
+
* Whether pull request `pr` (merge request `pr` on GitLab) is still open
|
|
366
|
+
* (#4293): neither merged nor closed. A pull request the forge does not
|
|
367
|
+
* know reads as not open. Target locks (../lifecycle/target-lock.ts) read
|
|
368
|
+
* a lock whose holder is not open as released.
|
|
369
|
+
*/
|
|
370
|
+
pullRequestOpen?(pr: number): Promise<boolean>;
|
|
364
371
|
/**
|
|
365
372
|
* Open a pull request (a merge request on GitLab) from `head` into `base`,
|
|
366
373
|
* or into the default branch when `base` is not given. Over REST on every
|
|
@@ -377,6 +384,10 @@ export interface PrForge {
|
|
|
377
384
|
editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
|
|
378
385
|
/** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
|
|
379
386
|
addLabels(pr: number, labels: readonly string[]): Promise<void>;
|
|
387
|
+
/** The names of the labels pull request `pr` carries (#4292), for `ci.labels`. */
|
|
388
|
+
labels?(pr: number): Promise<string[]>;
|
|
389
|
+
/** Whether pull request `pr` is open, closed unmerged, or merged (#4298), for `pr-env sweep`. */
|
|
390
|
+
pullRequestState?(pr: number): Promise<"open" | "closed" | "merged">;
|
|
380
391
|
/**
|
|
381
392
|
* Merge pull request `pr` only if its head is still `opts.sha`. A moved
|
|
382
393
|
* head, a conflict or a branch rule the forge enforces comes back as
|
|
@@ -391,6 +402,38 @@ export interface PrForge {
|
|
|
391
402
|
openPullRequests?(base: string): Promise<OpenPullRequest[]>;
|
|
392
403
|
/** GitHub and Forgejo (#4095): the commit `branch` points at, or undefined when there is no such branch. */
|
|
393
404
|
branchHead?(branch: string): Promise<string | undefined>;
|
|
405
|
+
/**
|
|
406
|
+
* When a scheduled Op's generated job last ran from its schedule or by hand
|
|
407
|
+
* (#4289). GitHub reads the workflow `workflow` names and its `schedule` and
|
|
408
|
+
* `workflow_dispatch` runs. Forgejo reads the repository's runs and keeps
|
|
409
|
+
* the ones from that workflow file. GitLab reads the active pipeline
|
|
410
|
+
* schedules whose `CHANT_SCHEDULED_OP` variable is `op` and their last
|
|
411
|
+
* pipelines. Throws a {@link ForgeApiError} when the forge will not say.
|
|
412
|
+
*/
|
|
413
|
+
scheduledRuns?(query: ScheduledRunsQuery): Promise<ScheduledRuns>;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Which scheduled Op job {@link PrForge.scheduledRuns} asks about. */
|
|
417
|
+
export interface ScheduledRunsQuery {
|
|
418
|
+
/** The Op's name, the value of GitLab's `CHANT_SCHEDULED_OP` selector. */
|
|
419
|
+
op: string;
|
|
420
|
+
/** GitHub and Forgejo: the Op's workflow file name, such as `drift-watch.yml`. */
|
|
421
|
+
workflow: string;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** What a forge says about a scheduled Op job's runs (#4289). */
|
|
425
|
+
export interface ScheduledRuns {
|
|
426
|
+
/** When the job last ran from its schedule or by hand (ISO 8601), if it ever did. */
|
|
427
|
+
last?: string;
|
|
428
|
+
/** GitHub: the workflow is off, and why (`disabled_inactivity`, `disabled_manually`). */
|
|
429
|
+
disabled?: string;
|
|
430
|
+
/** GitLab: no active pipeline schedule selects the Op. */
|
|
431
|
+
noSchedule?: boolean;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The newest of some ISO 8601 times, ignoring what does not parse. */
|
|
435
|
+
function newestTime(times: readonly (string | undefined)[]): string | undefined {
|
|
436
|
+
return times.filter((t): t is string => typeof t === "string" && !Number.isNaN(Date.parse(t))).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
|
|
394
437
|
}
|
|
395
438
|
|
|
396
439
|
/** A GitHub or Forgejo client. */
|
|
@@ -691,6 +734,15 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
691
734
|
}
|
|
692
735
|
return [...logins].sort();
|
|
693
736
|
},
|
|
737
|
+
async pullRequestOpen(pr) {
|
|
738
|
+
try {
|
|
739
|
+
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
740
|
+
return isObject(got) && got.state === "open" && got.merged !== true;
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (err instanceof ForgeApiError && err.status === 404) return false;
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
},
|
|
694
746
|
async pullRequestFor(sha) {
|
|
695
747
|
if (kind === "forgejo") {
|
|
696
748
|
try {
|
|
@@ -777,6 +829,27 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
777
829
|
const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
|
|
778
830
|
return commit ? str(commit.sha) ?? str(commit.id) : undefined;
|
|
779
831
|
},
|
|
832
|
+
async scheduledRuns(q) {
|
|
833
|
+
const file = encodeURIComponent(q.workflow);
|
|
834
|
+
if (kind === "github") {
|
|
835
|
+
const workflow = await api.call("GET", `${repo}/actions/workflows/${file}`);
|
|
836
|
+
const runs = await Promise.all(["schedule", "workflow_dispatch"].map((event) => api.call("GET", `${repo}/actions/workflows/${file}/runs?event=${event}&per_page=1`)));
|
|
837
|
+
const last = newestTime(runs.flatMap((r) => (isObject(r) && Array.isArray(r.workflow_runs) ? r.workflow_runs.map((w) => (isObject(w) ? str(w.created_at) : undefined)) : [])));
|
|
838
|
+
const state = (isObject(workflow) ? str(workflow.state) : undefined) ?? "active";
|
|
839
|
+
return { ...(last ? { last } : {}), ...(state.startsWith("disabled") ? { disabled: state } : {}) };
|
|
840
|
+
}
|
|
841
|
+
// Forgejo lists a repository's runs newest first; the event and the workflow are read from each run, whatever the query honours.
|
|
842
|
+
const listed = await api.call("GET", `${repo}/actions/runs?event=schedule&limit=50`);
|
|
843
|
+
const runs = Array.isArray(listed) ? listed : isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : [];
|
|
844
|
+
const ours = runs.filter((r): r is Record<string, unknown> => {
|
|
845
|
+
if (!isObject(r)) return false;
|
|
846
|
+
const event = str(r.event) ?? str(r.trigger_event);
|
|
847
|
+
const workflow = str(r.workflow_id) ?? str(r.path);
|
|
848
|
+
return (event === "schedule" || event === "workflow_dispatch") && (workflow === undefined || workflow.endsWith(q.workflow));
|
|
849
|
+
});
|
|
850
|
+
const last = newestTime(ours.map((r) => str(r.created) ?? str(r.created_at) ?? str(r.started)));
|
|
851
|
+
return last ? { last } : {};
|
|
852
|
+
},
|
|
780
853
|
async editPullRequest(pr, change) {
|
|
781
854
|
return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
|
|
782
855
|
},
|
|
@@ -784,6 +857,16 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
784
857
|
if (labels.length === 0) return;
|
|
785
858
|
await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
|
|
786
859
|
},
|
|
860
|
+
async labels(pr) {
|
|
861
|
+
const listed = await api.list(`${repo}/issues/${pr}/labels`, pageParams, pageSize);
|
|
862
|
+
return listed.map((l) => (isObject(l) ? str(l.name) : undefined)).filter((n): n is string => n !== undefined);
|
|
863
|
+
},
|
|
864
|
+
async pullRequestState(pr) {
|
|
865
|
+
const p = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
866
|
+
if (!isObject(p)) return "open";
|
|
867
|
+
if (p.merged === true || (typeof p.merged_at === "string" && p.merged_at !== "")) return "merged";
|
|
868
|
+
return p.state === "closed" ? "closed" : "open";
|
|
869
|
+
},
|
|
787
870
|
async mergePullRequest(pr, m) {
|
|
788
871
|
const method = m.method ?? "merge";
|
|
789
872
|
let res: unknown;
|
|
@@ -962,6 +1045,15 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
962
1045
|
}
|
|
963
1046
|
return [...logins].sort();
|
|
964
1047
|
},
|
|
1048
|
+
async pullRequestOpen(mr) {
|
|
1049
|
+
try {
|
|
1050
|
+
const got = await api.call("GET", `${notes}/${mr}`);
|
|
1051
|
+
return isObject(got) && got.state === "opened";
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
if (err instanceof ForgeApiError && err.status === 404) return false;
|
|
1054
|
+
throw err;
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
965
1057
|
async pullRequestFor(sha) {
|
|
966
1058
|
const mrs = await api.call("GET", `${project}/repository/commits/${sha}/merge_requests`);
|
|
967
1059
|
if (!Array.isArray(mrs)) return null;
|
|
@@ -984,6 +1076,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
984
1076
|
.filter((m): m is PullRequest => m !== undefined)
|
|
985
1077
|
.sort((a, b) => a.number - b.number)[0];
|
|
986
1078
|
},
|
|
1079
|
+
async scheduledRuns(q) {
|
|
1080
|
+
// The variable the gitlab lexicon's generated Op file selects a scheduled job by.
|
|
1081
|
+
const selects = (d: unknown): boolean =>
|
|
1082
|
+
isObject(d) && Array.isArray(d.variables) && d.variables.some((v) => isObject(v) && v.key === "CHANT_SCHEDULED_OP" && v.value === q.op);
|
|
1083
|
+
const schedules = await api.list(`${project}/pipeline_schedules?scope=active`, pageParams, 100);
|
|
1084
|
+
// A schedule's variables are on each schedule, not the list.
|
|
1085
|
+
const details = await Promise.all(schedules.map((s) => api.call("GET", `${project}/pipeline_schedules/${isObject(s) ? num(s.id) : ""}`)));
|
|
1086
|
+
const ours = details.filter(selects);
|
|
1087
|
+
const ids = ours.map((d) => (isObject(d) && isObject(d.last_pipeline) ? num(d.last_pipeline.id) : undefined)).filter((n): n is number => n !== undefined && n > 0);
|
|
1088
|
+
const pipelines = await Promise.all(ids.map((id) => api.call("GET", `${project}/pipelines/${id}`)));
|
|
1089
|
+
const last = newestTime(pipelines.map((p) => (isObject(p) ? str(p.created_at) : undefined)));
|
|
1090
|
+
return { ...(last ? { last } : {}), ...(ours.length === 0 ? { noSchedule: true } : {}) };
|
|
1091
|
+
},
|
|
987
1092
|
async editPullRequest(mr, change) {
|
|
988
1093
|
return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
|
|
989
1094
|
},
|
|
@@ -991,6 +1096,15 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
991
1096
|
if (labels.length === 0) return;
|
|
992
1097
|
await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
|
|
993
1098
|
},
|
|
1099
|
+
async labels(mr) {
|
|
1100
|
+
const m = await api.call("GET", `${notes}/${mr}`);
|
|
1101
|
+
return isObject(m) && Array.isArray(m.labels) ? m.labels.filter((l): l is string => typeof l === "string") : [];
|
|
1102
|
+
},
|
|
1103
|
+
async pullRequestState(mr) {
|
|
1104
|
+
const m = await api.call("GET", `${notes}/${mr}`);
|
|
1105
|
+
if (!isObject(m)) return "open";
|
|
1106
|
+
return m.state === "merged" ? "merged" : m.state === "closed" ? "closed" : "open";
|
|
1107
|
+
},
|
|
994
1108
|
async mergePullRequest(mr, m) {
|
|
995
1109
|
const method = m.method ?? "merge";
|
|
996
1110
|
if (method === "rebase") throw new Error("GitLab has no one-call rebase merge; use method merge or squash");
|