@intentius/chant 0.124.40 → 0.124.42
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 +18 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +42 -5
- 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 +76 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +615 -55
- 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/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +49 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +46 -2
- package/dist/cli/registry.d.ts +31 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +37 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +44 -4
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- 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 +92 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +150 -21
- 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 +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +8 -3
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- 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/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- 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 +28 -43
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +170 -120
- 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/op/stage-hooks.d.ts +132 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +142 -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 +64 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +331 -44
- package/dist/pr-loop.d.ts +107 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +84 -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/pr-requires.d.ts +38 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +48 -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 +243 -243
- 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/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- 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-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- 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 +66 -4
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +69 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +149 -2
- 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 +94 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- 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 +18 -1
- package/src/cli/handlers/fan-out.ts +43 -6
- 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-before-merge.test.ts +276 -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 +605 -61
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +49 -4
- package/src/cli/parse-args.ts +34 -2
- package/src/cli/registry.ts +31 -2
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +58 -3
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/gate-plan.ts +3 -0
- 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 +194 -21
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +14 -2
- package/src/components/wave-plan.ts +38 -3
- 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/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- 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 +197 -183
- 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/op/stage-hooks.ts +251 -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 +347 -35
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +170 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/pr-requires.test.ts +184 -0
- package/src/pr-requires.ts +79 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -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/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- 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-init.ts +5 -4
- 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 +74 -5
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +149 -2
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +140 -5
- 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 +94 -3
- package/src/workspace/reason-codes.ts +1 -1
- 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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant query "<sql>"` (#4297): one read-only SQL statement over what
|
|
3
|
+
* `chant/lifecycle` holds, run in this process with no server.
|
|
4
|
+
*
|
|
5
|
+
* The rows come from the ledgers chant already writes:
|
|
6
|
+
*
|
|
7
|
+
* - the snapshots, `<env>/<lexicon>.json` and `<env>/<stack>__<lexicon>.json`
|
|
8
|
+
* (`./git.ts` `snapshotStorageKey`), each lexicon's `describeResources`
|
|
9
|
+
* read, so the inventory works for every lexicon;
|
|
10
|
+
* - the Op run ledgers, `<env>/runs__<op>.jsonl` (`./run-ledger.ts`);
|
|
11
|
+
* - the gate ledgers, `_gates/<op>.jsonl` (`./gate-ledger.ts`);
|
|
12
|
+
* - the deploy records, `<env>/releases.jsonl` (`./release-read.ts`);
|
|
13
|
+
* - the releases and their cuts, `_releases/<digest>.json` and
|
|
14
|
+
* `_releases/cuts.jsonl` (`./release-store.ts`).
|
|
15
|
+
*
|
|
16
|
+
* The tables are a contract. {@link QUERY_SCHEMA_VERSION} names it, and the
|
|
17
|
+
* `chant_schema` table lists every table and column with the version, so a
|
|
18
|
+
* saved query can check what it runs against. A column is only added within
|
|
19
|
+
* a version; renaming or removing one, or changing what it holds, is a new
|
|
20
|
+
* version.
|
|
21
|
+
*
|
|
22
|
+
* Everything but {@link runQuery} is pure over a {@link LedgerReadPort}: the
|
|
23
|
+
* caller decides which members to read and how.
|
|
24
|
+
*
|
|
25
|
+
* The engine is `node:sqlite`, the SQLite built into Node.js 22.13 and later.
|
|
26
|
+
* The database is in memory, takes `PRAGMA query_only`, and only a statement
|
|
27
|
+
* that begins with SELECT, WITH or VALUES runs. This mirrors terragucci's
|
|
28
|
+
* `query.ts`, which reads report JSON from a bucket instead.
|
|
29
|
+
*/
|
|
30
|
+
import type { GateOrigin } from "./gate-origin";
|
|
31
|
+
import { parseGateLedger, GATES_DIR, type GateResolutionRecord, type PendingGateRecord } from "./gate-ledger";
|
|
32
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
33
|
+
import { parseReleaseLedger, releaseLedgerLines } from "./release-read";
|
|
34
|
+
import { CUTS_FILE, parseCuts, parseRelease, RELEASES_DIR, verifyReleaseDigest } from "./release-store";
|
|
35
|
+
import { opOfRunLedgerFile, parseRunLedger } from "./run-ledger";
|
|
36
|
+
import type { LifecycleSnapshot } from "./types";
|
|
37
|
+
|
|
38
|
+
/** The version of the table schema below. */
|
|
39
|
+
export const QUERY_SCHEMA_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
type ColumnType = "TEXT" | "INTEGER";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Each table's columns, in order, with their SQLite type. A column named in
|
|
45
|
+
* the doc as JSON holds a JSON text, for `json_extract`. Every table starts
|
|
46
|
+
* with `member`: the workspace member whose ledger the row came from, or NULL
|
|
47
|
+
* for a project outside a workspace.
|
|
48
|
+
*/
|
|
49
|
+
export const QUERY_TABLES = {
|
|
50
|
+
/** One row per resource in each environment's newest snapshot. `attributes` is JSON. */
|
|
51
|
+
resources: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["name", "TEXT"], ["type", "TEXT"], ["physical_id", "TEXT"], ["status", "TEXT"], ["last_updated", "TEXT"], ["ownership", "TEXT"], ["attributes", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"]],
|
|
52
|
+
/** One row per snapshot file: what was observed, when, and how many resources it holds. */
|
|
53
|
+
snapshots: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"], ["depth", "TEXT"], ["resources", "INTEGER"], ["unobserved", "INTEGER"]],
|
|
54
|
+
/** One row per Op run record. `labels` and `outcomes` are JSON. */
|
|
55
|
+
runs: [["member", "TEXT"], ["id", "TEXT"], ["op", "TEXT"], ["env", "TEXT"], ["started", "TEXT"], ["ended", "TEXT"], ["status", "TEXT"], ["git_sha", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["failure_code", "TEXT"], ["failure_reason", "TEXT"], ["failed_phase", "TEXT"], ["gate", "TEXT"], ["steward", "TEXT"], ["labels", "TEXT"], ["outcomes", "TEXT"]],
|
|
56
|
+
/** One row per gate ledger line: `kind` is `pending` or `resolution`. `signed` is 1 for a sealed approval. */
|
|
57
|
+
gates: [["member", "TEXT"], ["op", "TEXT"], ["gate", "TEXT"], ["kind", "TEXT"], ["timestamp", "TEXT"], ["resolved_by", "TEXT"], ["relayed_by", "TEXT"], ["approver_kind", "TEXT"], ["plan_digest", "TEXT"], ["environment", "TEXT"], ["release_digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["url", "TEXT"], ["note", "TEXT"], ["expires_at", "TEXT"], ["origin", "TEXT"], ["empty", "INTEGER"], ["signed", "INTEGER"]],
|
|
58
|
+
/** One row per deploy record in `<env>/releases.jsonl`. `promoted_from`, `restores` and `redeploys` are JSON. */
|
|
59
|
+
deploys: [["member", "TEXT"], ["env", "TEXT"], ["component", "TEXT"], ["digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["timestamp", "TEXT"], ["actor", "TEXT"], ["approver", "TEXT"], ["release_digest", "TEXT"], ["digest_kind", "TEXT"], ["promoted_from", "TEXT"], ["restores", "TEXT"], ["redeploys", "TEXT"]],
|
|
60
|
+
/** One row per release file whose content hashes to its digest. `envs` joins its targets' environments with commas; `artifacts` is JSON. */
|
|
61
|
+
releases: [["member", "TEXT"], ["release_digest", "TEXT"], ["release_member", "TEXT"], ["git_sha", "TEXT"], ["envs", "TEXT"], ["artifacts", "TEXT"]],
|
|
62
|
+
/** One row per line of `_releases/cuts.jsonl`. `signed` is 1 for a signed cut. */
|
|
63
|
+
cuts: [["member", "TEXT"], ["release_digest", "TEXT"], ["label", "TEXT"], ["actor", "TEXT"], ["timestamp", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["channel", "TEXT"], ["signed", "INTEGER"], ["composed_base", "TEXT"]],
|
|
64
|
+
} as const satisfies Record<string, ReadonlyArray<readonly [string, ColumnType]>>;
|
|
65
|
+
|
|
66
|
+
export type QueryTable = keyof typeof QUERY_TABLES;
|
|
67
|
+
|
|
68
|
+
/** The table that describes the others: one row per column, with the schema version. */
|
|
69
|
+
export const SCHEMA_TABLE = "chant_schema";
|
|
70
|
+
|
|
71
|
+
type Value = string | number | null;
|
|
72
|
+
export type QueryRow = Record<string, Value>;
|
|
73
|
+
|
|
74
|
+
/** The rows of each table, as loaded. */
|
|
75
|
+
export type QueryData = Record<QueryTable, QueryRow[]>;
|
|
76
|
+
|
|
77
|
+
/** One ledger to read: a member's, or the project's own with `member` null. */
|
|
78
|
+
export interface QueryLedger {
|
|
79
|
+
member: string | null;
|
|
80
|
+
port: LedgerReadPort;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface QueryLoad {
|
|
84
|
+
data: QueryData;
|
|
85
|
+
/** Lines and files that could not be read as their kind, per table. Left out of the rows. */
|
|
86
|
+
malformed: Record<QueryTable, number>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface QueryResult {
|
|
90
|
+
schemaVersion: number;
|
|
91
|
+
columns: string[];
|
|
92
|
+
rows: QueryRow[];
|
|
93
|
+
/** How many rows each table held. */
|
|
94
|
+
tables: Record<QueryTable, number>;
|
|
95
|
+
malformed: Record<QueryTable, number>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A statement that can't run, or a Node.js with no SQLite. The message is for a person. */
|
|
99
|
+
export class QueryError extends Error {}
|
|
100
|
+
|
|
101
|
+
const TABLE_NAMES = Object.keys(QUERY_TABLES) as QueryTable[];
|
|
102
|
+
const empty = <T>(v: () => T): Record<QueryTable, T> => Object.fromEntries(TABLE_NAMES.map((t) => [t, v()])) as Record<QueryTable, T>;
|
|
103
|
+
const json = (v: unknown): string | null => (v === undefined || v === null ? null : JSON.stringify(v));
|
|
104
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
105
|
+
const bit = (v: boolean): number => (v ? 1 : 0);
|
|
106
|
+
|
|
107
|
+
/** Whether a parsed `.json` file under an environment is a snapshot (an observation baseline or another file is not). */
|
|
108
|
+
function isSnapshot(v: unknown): v is LifecycleSnapshot {
|
|
109
|
+
if (!v || typeof v !== "object") return false;
|
|
110
|
+
const s = v as Partial<LifecycleSnapshot>;
|
|
111
|
+
return typeof s.lexicon === "string" && typeof s.environment === "string" && typeof s.commit === "string" && typeof s.timestamp === "string" && !!s.resources && typeof s.resources === "object" && !Array.isArray(s.resources);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function snapshotRows(member: string | null, env: string, file: string, s: LifecycleSnapshot, data: QueryData): void {
|
|
115
|
+
// The file name folds the stack in as `<stack>__<lexicon>` (git.ts snapshotStorageKey); the record names it too.
|
|
116
|
+
const key = file.slice(0, -".json".length);
|
|
117
|
+
const stack = s.stack ?? (key.includes("__") ? key.slice(0, key.lastIndexOf("__")) : null);
|
|
118
|
+
const at = { git_sha: s.commit, observed_at: s.timestamp };
|
|
119
|
+
const resources = Object.entries(s.resources);
|
|
120
|
+
data.snapshots.push({ member, env, stack, lexicon: s.lexicon, ...at, depth: s.depth ?? "identity", resources: resources.length, unobserved: Object.keys(s.unobserved ?? {}).length });
|
|
121
|
+
for (const [name, r] of resources) {
|
|
122
|
+
data.resources.push({ member, env, stack, lexicon: s.lexicon, name, type: str(r?.type), physical_id: str(r?.physicalId), status: str(r?.status), last_updated: str(r?.lastUpdated), ownership: str(r?.ownership), attributes: json(r?.attributes), ...at });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function gateRow(member: string | null, r: GateResolutionRecord | PendingGateRecord): QueryRow {
|
|
127
|
+
const pending = r.kind === "pending";
|
|
128
|
+
const res = pending ? undefined : (r as GateResolutionRecord);
|
|
129
|
+
const origin: GateOrigin | undefined = (r as { origin?: GateOrigin }).origin;
|
|
130
|
+
return {
|
|
131
|
+
member,
|
|
132
|
+
op: r.op,
|
|
133
|
+
gate: r.gate,
|
|
134
|
+
kind: pending ? "pending" : "resolution",
|
|
135
|
+
timestamp: r.timestamp,
|
|
136
|
+
resolved_by: res?.resolvedBy ?? null,
|
|
137
|
+
relayed_by: str(res?.relayedBy),
|
|
138
|
+
approver_kind: str(res?.approver?.kind),
|
|
139
|
+
plan_digest: str(r.planDigest),
|
|
140
|
+
environment: str(r.environment),
|
|
141
|
+
release_digest: str(r.releaseDigest),
|
|
142
|
+
git_sha: str(r.gitSha),
|
|
143
|
+
run_id: str(r.runId),
|
|
144
|
+
url: str(res?.url),
|
|
145
|
+
note: str(res?.note),
|
|
146
|
+
expires_at: pending ? str((r as PendingGateRecord).expiresAt) : null,
|
|
147
|
+
origin: str(origin),
|
|
148
|
+
empty: bit(res?.empty === true),
|
|
149
|
+
signed: bit(res?.seal !== undefined),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read every table's rows from the ledgers, in the order given. A ledger
|
|
155
|
+
* whose branch is absent reads as no rows. A line or file that is not its
|
|
156
|
+
* kind is counted in `malformed` and left out, as each ledger's own reader
|
|
157
|
+
* does.
|
|
158
|
+
*/
|
|
159
|
+
export async function loadQueryData(ledgers: readonly QueryLedger[]): Promise<QueryLoad> {
|
|
160
|
+
const data = empty<QueryRow[]>(() => []) as QueryData;
|
|
161
|
+
const malformed = empty(() => 0);
|
|
162
|
+
for (const { member, port } of ledgers) {
|
|
163
|
+
const trees = (await port.trees()).sort();
|
|
164
|
+
for (const env of trees.filter((t) => !t.startsWith("_"))) {
|
|
165
|
+
for (const file of (await port.list(env)).sort()) {
|
|
166
|
+
if (file.endsWith(".json")) {
|
|
167
|
+
const text = await port.read(env, file);
|
|
168
|
+
let parsed: unknown;
|
|
169
|
+
try {
|
|
170
|
+
parsed = text === null ? null : JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
parsed = null;
|
|
173
|
+
}
|
|
174
|
+
// Other JSON files live beside the snapshots (the observation baseline); only a snapshot-shaped one is read.
|
|
175
|
+
if (isSnapshot(parsed)) snapshotRows(member, env, file, parsed, data);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (file === "releases.jsonl") {
|
|
179
|
+
const { records, malformed: bad } = parseReleaseLedger(releaseLedgerLines(await port.read(env, file)));
|
|
180
|
+
malformed.deploys += bad;
|
|
181
|
+
for (const d of records) {
|
|
182
|
+
data.deploys.push({ member, env: d.env, component: d.component, digest: d.digest, git_sha: d.gitSha, run_id: d.runId, forge: str(d.runOrigin?.forge), run_url: str(d.runOrigin?.url), timestamp: d.timestamp, actor: d.actor, approver: str(d.approver), release_digest: str(d.releaseDigest), digest_kind: str(d.digestKind), promoted_from: json(d.promotedFrom), restores: json(d.restores), redeploys: json(d.redeploys) });
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (opOfRunLedgerFile(file) !== null) {
|
|
187
|
+
const text = await port.read(env, file);
|
|
188
|
+
if (text === null) continue;
|
|
189
|
+
const { records, malformed: bad } = parseRunLedger(text);
|
|
190
|
+
malformed.runs += bad;
|
|
191
|
+
for (const r of records) {
|
|
192
|
+
data.runs.push({ member, id: r.id, op: r.op, env: r.env, started: r.started, ended: r.ended, status: r.status, git_sha: str(r.gitSha), forge: str(r.runOrigin?.forge), run_url: str(r.runOrigin?.url), failure_code: str(r.failure?.code), failure_reason: str(r.failure?.reason), failed_phase: str(r.failure?.phase), gate: str(r.gate?.name), steward: str(r.steward), labels: json(r.labels ?? {}), outcomes: json(r.outcomes ?? {}) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (trees.includes(GATES_DIR)) {
|
|
198
|
+
for (const file of (await port.list(GATES_DIR)).sort()) {
|
|
199
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
200
|
+
const text = await port.read(GATES_DIR, file);
|
|
201
|
+
if (text === null) continue;
|
|
202
|
+
const { resolutions, pending, malformed: bad } = parseGateLedger(text);
|
|
203
|
+
malformed.gates += bad;
|
|
204
|
+
// Both kinds share the file; ledger order is time order, so they are merged back by timestamp.
|
|
205
|
+
const lines = [...pending, ...resolutions].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
206
|
+
for (const r of lines) data.gates.push(gateRow(member, r));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (trees.includes(RELEASES_DIR)) {
|
|
210
|
+
for (const file of (await port.list(RELEASES_DIR)).sort()) {
|
|
211
|
+
if (file === CUTS_FILE) {
|
|
212
|
+
const { cuts, malformed: bad } = parseCuts(await port.read(RELEASES_DIR, file));
|
|
213
|
+
malformed.cuts += bad;
|
|
214
|
+
for (const c of cuts) {
|
|
215
|
+
data.cuts.push({ member, release_digest: c.releaseDigest, label: c.label, actor: c.actor, timestamp: c.timestamp, run_id: c.runId, forge: str(c.runOrigin?.forge), channel: str(c.channel), signed: bit(c.signature !== null), composed_base: str(c.composed?.base) });
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!file.endsWith(".json")) continue;
|
|
220
|
+
const text = await port.read(RELEASES_DIR, file);
|
|
221
|
+
const release = text === null ? null : parseRelease(text);
|
|
222
|
+
// `readAllReleases` (release-store.ts) counts a file whose content does not hash to its digest as malformed; so does this.
|
|
223
|
+
if (!release || release.releaseDigest.replace(/:/g, "_") !== file.slice(0, -".json".length) || !verifyReleaseDigest(release)) {
|
|
224
|
+
malformed.releases++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
data.releases.push({ member, release_digest: release.releaseDigest, release_member: release.member, git_sha: release.commit, envs: release.targets.map((t) => t.env).join(","), artifacts: json(release.artifactDigests) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { data, malformed };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The part of `node:sqlite` this uses, typed here since the repo's @types/node predates the module. */
|
|
235
|
+
interface SqliteStatement {
|
|
236
|
+
run(...values: Value[]): unknown;
|
|
237
|
+
all(): unknown[];
|
|
238
|
+
columns?(): Array<{ name: string }>;
|
|
239
|
+
}
|
|
240
|
+
interface SqliteModule {
|
|
241
|
+
DatabaseSync: new (path: string) => { exec(sql: string): void; prepare(sql: string): SqliteStatement; close(): void };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** node:sqlite, without the experimental warning some Node.js versions print when it loads. */
|
|
245
|
+
async function sqlite(): Promise<SqliteModule> {
|
|
246
|
+
const emit = process.emitWarning;
|
|
247
|
+
process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => {
|
|
248
|
+
if (/SQLite/.test(typeof warning === "string" ? warning : warning.message)) return;
|
|
249
|
+
return (emit as (...a: unknown[]) => void).call(process, warning, ...rest);
|
|
250
|
+
}) as typeof process.emitWarning;
|
|
251
|
+
try {
|
|
252
|
+
const name = "node:sqlite";
|
|
253
|
+
return (await import(name)) as SqliteModule;
|
|
254
|
+
} catch {
|
|
255
|
+
throw new QueryError(`chant query runs on the SQLite built into Node.js 22.13 or later; this is Node.js ${process.versions.node}`);
|
|
256
|
+
} finally {
|
|
257
|
+
process.emitWarning = emit;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The first word of a statement, past whitespace and comments. */
|
|
262
|
+
export function firstWord(sql: string): string {
|
|
263
|
+
const s = sql.replace(/^(\s+|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/, "");
|
|
264
|
+
return (/^[A-Za-z]+/.exec(s)?.[0] ?? "").toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Run one read-only statement over the tables. */
|
|
268
|
+
export async function runQuery(sql: string, load: QueryLoad): Promise<QueryResult> {
|
|
269
|
+
if (sql.trim() === "") throw new QueryError('chant query needs a statement: chant query "SELECT * FROM resources"');
|
|
270
|
+
if (!["select", "with", "values"].includes(firstWord(sql))) throw new QueryError("chant query runs one SELECT (or WITH, or VALUES) statement; it changes nothing");
|
|
271
|
+
const { DatabaseSync } = await sqlite();
|
|
272
|
+
const db = new DatabaseSync(":memory:");
|
|
273
|
+
try {
|
|
274
|
+
db.exec(`CREATE TABLE ${SCHEMA_TABLE} ("version" INTEGER, "table_name" TEXT, "column_name" TEXT, "type" TEXT, "position" INTEGER)`);
|
|
275
|
+
const describe = db.prepare(`INSERT INTO ${SCHEMA_TABLE} VALUES (?, ?, ?, ?, ?)`);
|
|
276
|
+
for (const table of TABLE_NAMES) {
|
|
277
|
+
const cols = QUERY_TABLES[table] as ReadonlyArray<readonly [string, ColumnType]>;
|
|
278
|
+
db.exec(`CREATE TABLE ${table} (${cols.map(([c, t]) => `"${c}" ${t}`).join(", ")})`);
|
|
279
|
+
cols.forEach(([c, t], i) => describe.run(QUERY_SCHEMA_VERSION, table, c, t, i + 1));
|
|
280
|
+
const insert = db.prepare(`INSERT INTO ${table} VALUES (${cols.map(() => "?").join(", ")})`);
|
|
281
|
+
db.exec("BEGIN");
|
|
282
|
+
for (const row of load.data[table]) insert.run(...cols.map(([c]) => row[c] ?? null));
|
|
283
|
+
db.exec("COMMIT");
|
|
284
|
+
}
|
|
285
|
+
db.exec("PRAGMA query_only = ON");
|
|
286
|
+
let stmt: SqliteStatement;
|
|
287
|
+
try {
|
|
288
|
+
stmt = db.prepare(sql);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
throw new QueryError(`the query does not run: ${(e as Error).message}`);
|
|
291
|
+
}
|
|
292
|
+
let rows: QueryRow[];
|
|
293
|
+
try {
|
|
294
|
+
rows = stmt.all() as QueryRow[];
|
|
295
|
+
} catch (e) {
|
|
296
|
+
throw new QueryError(`the query does not run: ${(e as Error).message}`);
|
|
297
|
+
}
|
|
298
|
+
const columns = typeof stmt.columns === "function" ? stmt.columns().map((c) => c.name) : Object.keys(rows[0] ?? {});
|
|
299
|
+
const tables = Object.fromEntries(TABLE_NAMES.map((t) => [t, load.data[t].length])) as Record<QueryTable, number>;
|
|
300
|
+
return { schemaVersion: QUERY_SCHEMA_VERSION, columns, rows: rows.map((r) => ({ ...r })), tables, malformed: load.malformed };
|
|
301
|
+
} finally {
|
|
302
|
+
db.close();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const cell = (v: Value | undefined): string => (v === null || v === undefined ? "" : String(v));
|
|
307
|
+
|
|
308
|
+
/** The rows as an aligned table, a header and a count. */
|
|
309
|
+
export function formatQueryResult(r: QueryResult): string {
|
|
310
|
+
const widths = r.columns.map((c) => Math.max(c.length, ...r.rows.map((row) => cell(row[c]).length)));
|
|
311
|
+
const line = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
|
|
312
|
+
const out = [line(r.columns), line(widths.map((w) => "-".repeat(w))), ...r.rows.map((row) => line(r.columns.map((c) => cell(row[c]))))];
|
|
313
|
+
out.push(`(${r.rows.length} ${r.rows.length === 1 ? "row" : "rows"})`);
|
|
314
|
+
return out.join("\n");
|
|
315
|
+
}
|
|
@@ -197,7 +197,25 @@ export async function readRunLedger(
|
|
|
197
197
|
): Promise<{ records: OpRunRecord[]; malformed: number }> {
|
|
198
198
|
const content = await readBlobFromPath(environment, fileFor(op), opts);
|
|
199
199
|
if (!content) return { records: [], malformed: 0 };
|
|
200
|
+
return parseRunLedger(content);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The run ledger file name for `op` under `<env>/`, `runs__<op>.jsonl`. */
|
|
204
|
+
export function runLedgerFile(op: string): string {
|
|
205
|
+
return fileFor(op);
|
|
206
|
+
}
|
|
200
207
|
|
|
208
|
+
/** The op a run ledger file name holds, or null when `file` is not one. */
|
|
209
|
+
export function opOfRunLedgerFile(file: string): string | null {
|
|
210
|
+
return file.startsWith(PREFIX) && file.endsWith(".jsonl") && file.length > PREFIX.length + ".jsonl".length ? file.slice(PREFIX.length, -".jsonl".length) : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse the text of one run ledger file the way {@link readRunLedger} reads
|
|
215
|
+
* it (#4297): a reader that fetches the file itself, such as `chant query`,
|
|
216
|
+
* gets the same records and the same malformed count.
|
|
217
|
+
*/
|
|
218
|
+
export function parseRunLedger(content: string): { records: OpRunRecord[]; malformed: number } {
|
|
201
219
|
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
202
220
|
const records: OpRunRecord[] = [];
|
|
203
221
|
let malformed = 0;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4293: target locks a pull request holds, `_locks/<gate>.json` on
|
|
3
|
+
* `chant/lifecycle`, in one clone and across clones through a bare remote.
|
|
4
|
+
*/
|
|
5
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
describeHeldTargetLocks,
|
|
12
|
+
heldTargetLocks,
|
|
13
|
+
parseTargetLocks,
|
|
14
|
+
readTargetLocks,
|
|
15
|
+
releaseTargetLocks,
|
|
16
|
+
renderTargetLocks,
|
|
17
|
+
takeTargetLocks,
|
|
18
|
+
targetLockGateProblem,
|
|
19
|
+
targetLockPath,
|
|
20
|
+
type TargetLock,
|
|
21
|
+
} from "./target-lock";
|
|
22
|
+
|
|
23
|
+
const dirs: string[] = [];
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function tmp(prefix: string): string {
|
|
29
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
30
|
+
dirs.push(d);
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(cwd: string, ...args: string[]): string {
|
|
35
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function configure(dir: string): void {
|
|
39
|
+
git(dir, "config", "user.email", "test@chant.dev");
|
|
40
|
+
git(dir, "config", "user.name", "Test");
|
|
41
|
+
git(dir, "config", "commit.gpgsign", "false");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function initRepo(): string {
|
|
45
|
+
const dir = tmp("chant-target-lock-");
|
|
46
|
+
git(dir, "init", "-q", "-b", "main");
|
|
47
|
+
configure(dir);
|
|
48
|
+
writeFileSync(join(dir, "README.md"), "# t\n");
|
|
49
|
+
git(dir, "add", "README.md");
|
|
50
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const holder = (pr: number, stage: TargetLock["stage"], by = "alice"): TargetLock => ({ pr, by, at: "2026-10-22T10:00:00.000Z", stage, head: "a".repeat(40) });
|
|
55
|
+
|
|
56
|
+
describe("the lock file", () => {
|
|
57
|
+
test("parses a version 1 file and drops malformed entries; anything else holds no locks", () => {
|
|
58
|
+
const text = JSON.stringify({ version: 1, locks: { web: holder(1, "plan"), bad: { pr: "x" }, api: { pr: 2, by: "b", at: "t", stage: "nope" } } });
|
|
59
|
+
expect(parseTargetLocks(text)).toEqual({ version: 1, locks: { web: holder(1, "plan") } });
|
|
60
|
+
expect(parseTargetLocks("not json")).toEqual({ version: 1, locks: {} });
|
|
61
|
+
expect(parseTargetLocks(JSON.stringify({ version: 2, locks: {} }))).toEqual({ version: 1, locks: {} });
|
|
62
|
+
expect(parseTargetLocks(null)).toEqual({ version: 1, locks: {} });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("renders targets sorted, so the same locks are the same blob", () => {
|
|
66
|
+
const a = renderTargetLocks({ version: 1, locks: { web: holder(1, "plan"), api: holder(2, "lock") } });
|
|
67
|
+
const b = renderTargetLocks({ version: 1, locks: { api: holder(2, "lock"), web: holder(1, "plan") } });
|
|
68
|
+
expect(a).toBe(b);
|
|
69
|
+
expect(Object.keys(JSON.parse(a).locks)).toEqual(["api", "web"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the path is _locks/<gate>.json under the member prefix, and a gate name must be a file name", () => {
|
|
73
|
+
expect(targetLockPath("pr-apply")).toBe("_locks/pr-apply.json");
|
|
74
|
+
expect(targetLockPath("pr-apply", "_members/api/")).toBe("_members/api/_locks/pr-apply.json");
|
|
75
|
+
expect(targetLockGateProblem("pr-apply")).toBeNull();
|
|
76
|
+
expect(targetLockGateProblem("../x")).toMatch(/not a lock file name/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("describes held locks by pull request, with how they were taken", () => {
|
|
80
|
+
const text = describeHeldTargetLocks([
|
|
81
|
+
{ target: "api", ...holder(7, "plan", "bob") },
|
|
82
|
+
{ target: "web", ...holder(7, "plan", "bob") },
|
|
83
|
+
{ target: "db", ...holder(9, "lock", "carol") },
|
|
84
|
+
], (n) => `merge request !${n}`);
|
|
85
|
+
expect(text).toBe("api, web are locked by merge request !7 (planned by bob); db is locked by merge request !9 (locked with chant lock by carol)");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("target locks in one clone", () => {
|
|
90
|
+
test("a pull request takes free targets; another is refused with the holder named; unlock frees them", async () => {
|
|
91
|
+
const cwd = initRepo();
|
|
92
|
+
const first = await takeTargetLocks("pr-apply", ["web", "api"], holder(1, "plan"), { cwd });
|
|
93
|
+
expect(first).toEqual({ ok: true, taken: ["api", "web"], released: [], expired: [], pushed: false });
|
|
94
|
+
expect(JSON.parse(git(cwd, "show", "chant/lifecycle:_locks/pr-apply.json")).locks.web.pr).toBe(1);
|
|
95
|
+
|
|
96
|
+
const second = await takeTargetLocks("pr-apply", ["web", "db"], holder(2, "plan", "bob"), { cwd });
|
|
97
|
+
expect(second).toEqual({ ok: false, held: [{ target: "web", ...holder(1, "plan") }] });
|
|
98
|
+
// Nothing of the refused pull request was written.
|
|
99
|
+
expect(Object.keys((await readTargetLocks("pr-apply", { cwd })).locks)).toEqual(["api", "web"]);
|
|
100
|
+
|
|
101
|
+
expect(await heldTargetLocks("pr-apply", ["web", "db"], 2, { cwd })).toHaveLength(1);
|
|
102
|
+
expect(await heldTargetLocks("pr-apply", ["web", "db"], 1, { cwd })).toEqual([]);
|
|
103
|
+
expect(await heldTargetLocks("pr-apply", ["web"], null, { cwd })).toHaveLength(1);
|
|
104
|
+
|
|
105
|
+
expect(await releaseTargetLocks("pr-apply", 1, { cwd, targets: ["api"] })).toEqual({ released: ["api"], pushed: false });
|
|
106
|
+
expect(await releaseTargetLocks("pr-apply", 1, { cwd })).toEqual({ released: ["web"], pushed: false });
|
|
107
|
+
expect(await releaseTargetLocks("pr-apply", 1, { cwd })).toEqual({ released: [], pushed: false });
|
|
108
|
+
expect(await takeTargetLocks("pr-apply", ["web", "db"], holder(2, "plan", "bob"), { cwd })).toMatchObject({ ok: true, taken: ["db", "web"] });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a lock whose pull request merged or closed is taken over and dropped", async () => {
|
|
112
|
+
const cwd = initRepo();
|
|
113
|
+
await takeTargetLocks("pr-apply", ["web", "api"], holder(1, "plan"), { cwd });
|
|
114
|
+
const isOpen = async (pr: number) => pr !== 1;
|
|
115
|
+
const taken = await takeTargetLocks("pr-apply", ["web"], holder(2, "lock", "bob"), { cwd, isOpen });
|
|
116
|
+
expect(taken).toMatchObject({ ok: true, taken: ["web"], expired: ["api", "web"] });
|
|
117
|
+
expect((await readTargetLocks("pr-apply", { cwd })).locks).toEqual({ web: holder(2, "lock", "bob") });
|
|
118
|
+
expect(await heldTargetLocks("pr-apply", ["web"], 3, { cwd, isOpen: async () => false })).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a plan keeps the pull request's own lock and apply locks; an apply takes over its plan locks", async () => {
|
|
122
|
+
const cwd = initRepo();
|
|
123
|
+
await takeTargetLocks("pr-apply", ["web"], holder(1, "lock"), { cwd });
|
|
124
|
+
await takeTargetLocks("pr-apply", ["web", "api"], holder(1, "plan"), { cwd });
|
|
125
|
+
let locks = (await readTargetLocks("pr-apply", { cwd })).locks;
|
|
126
|
+
expect(locks.web.stage).toBe("lock");
|
|
127
|
+
expect(locks.api.stage).toBe("plan");
|
|
128
|
+
await takeTargetLocks("pr-apply", ["api"], holder(1, "apply"), { cwd });
|
|
129
|
+
locks = (await readTargetLocks("pr-apply", { cwd })).locks;
|
|
130
|
+
expect(locks.api.stage).toBe("apply");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a plan with releaseStalePlan drops its plan locks on targets the head no longer reaches", async () => {
|
|
134
|
+
const cwd = initRepo();
|
|
135
|
+
await takeTargetLocks("pr-apply", ["web", "api"], holder(1, "plan"), { cwd });
|
|
136
|
+
await takeTargetLocks("pr-apply", ["db"], holder(1, "lock"), { cwd });
|
|
137
|
+
const again = await takeTargetLocks("pr-apply", ["web"], holder(1, "plan"), { cwd, releaseStalePlan: true });
|
|
138
|
+
expect(again).toMatchObject({ ok: true, taken: ["web"], released: ["api"] });
|
|
139
|
+
expect(Object.keys((await readTargetLocks("pr-apply", { cwd })).locks)).toEqual(["db", "web"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("each gate keeps its own file", async () => {
|
|
143
|
+
const cwd = initRepo();
|
|
144
|
+
await takeTargetLocks("pr-apply", ["web"], holder(1, "plan"), { cwd });
|
|
145
|
+
expect(await takeTargetLocks("staging-apply", ["web"], holder(2, "plan"), { cwd })).toMatchObject({ ok: true });
|
|
146
|
+
expect(git(cwd, "ls-tree", "--name-only", "chant/lifecycle", "_locks/").split("\n").sort()).toEqual(["_locks/pr-apply.json", "_locks/staging-apply.json"]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("target locks across clones, through a bare remote", () => {
|
|
151
|
+
function setup(): { root: string; remote: string; a: string; b: string } {
|
|
152
|
+
const root = tmp("chant-target-lock-remote-");
|
|
153
|
+
const remote = join(root, "remote.git");
|
|
154
|
+
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
155
|
+
const a = join(root, "a");
|
|
156
|
+
git(root, "clone", "-q", remote, a);
|
|
157
|
+
configure(a);
|
|
158
|
+
writeFileSync(join(a, "README.md"), "# t\n");
|
|
159
|
+
git(a, "add", "README.md");
|
|
160
|
+
git(a, "commit", "-q", "-m", "init");
|
|
161
|
+
git(a, "push", "-q", "origin", "main");
|
|
162
|
+
const b = join(root, "b");
|
|
163
|
+
git(root, "clone", "-q", remote, b);
|
|
164
|
+
configure(b);
|
|
165
|
+
return { root, remote, a, b };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test("a lock reaches the remote, the other clone is refused, and an unlock there frees it", async () => {
|
|
169
|
+
const { remote, a, b } = setup();
|
|
170
|
+
expect(await takeTargetLocks("pr-apply", ["web"], holder(1, "plan"), { cwd: a })).toMatchObject({ ok: true, pushed: true });
|
|
171
|
+
expect(JSON.parse(git(remote, "show", "chant/lifecycle:_locks/pr-apply.json")).locks.web.pr).toBe(1);
|
|
172
|
+
expect(await takeTargetLocks("pr-apply", ["web"], holder(2, "plan"), { cwd: b })).toMatchObject({ ok: false, held: [{ target: "web", pr: 1 }] });
|
|
173
|
+
expect(await releaseTargetLocks("pr-apply", 1, { cwd: b })).toEqual({ released: ["web"], pushed: true });
|
|
174
|
+
expect(await takeTargetLocks("pr-apply", ["web"], holder(2, "plan"), { cwd: a })).toMatchObject({ ok: true, taken: ["web"], pushed: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a push the remote refuses is dropped and decided again: two clones can't both lock one target", async () => {
|
|
178
|
+
const { root, remote, a, b } = setup();
|
|
179
|
+
// A writes its lock locally only, with no remote, then pushes it from B's pre-push hook,
|
|
180
|
+
// so B's first push lands after A's and is refused.
|
|
181
|
+
git(a, "remote", "remove", "origin");
|
|
182
|
+
expect(await takeTargetLocks("pr-apply", ["web"], holder(1, "plan"), { cwd: a })).toMatchObject({ ok: true, pushed: false });
|
|
183
|
+
git(a, "remote", "add", "origin", remote);
|
|
184
|
+
const marker = join(root, "raced");
|
|
185
|
+
const hook = join(b, ".git", "hooks", "pre-push");
|
|
186
|
+
writeFileSync(hook, `#!/bin/sh\nif [ ! -e "${marker}" ]; then touch "${marker}"; git -C "${a}" push -q origin chant/lifecycle:chant/lifecycle; fi\nexit 0\n`);
|
|
187
|
+
chmodSync(hook, 0o755);
|
|
188
|
+
|
|
189
|
+
const lost = await takeTargetLocks("pr-apply", ["web", "api"], holder(2, "plan", "bob"), { cwd: b });
|
|
190
|
+
expect(lost).toEqual({ ok: false, held: [{ target: "web", ...holder(1, "plan") }] });
|
|
191
|
+
// B's refused commit is gone: its branch is the remote's, which holds only A's lock.
|
|
192
|
+
expect(git(b, "rev-parse", "chant/lifecycle")).toBe(git(remote, "rev-parse", "chant/lifecycle"));
|
|
193
|
+
expect(Object.keys(JSON.parse(git(remote, "show", "chant/lifecycle:_locks/pr-apply.json")).locks)).toEqual(["web"]);
|
|
194
|
+
});
|
|
195
|
+
});
|