@intentius/chant 0.122.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config-import.d.ts +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify-verify.js +76 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +218 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +113 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +225 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +15 -2
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/policy-import.ts +1 -1
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-plain-node.e2e.test.ts +68 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +36 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +908 -123
- package/src/op/op-waves.test.ts +543 -3
- package/src/op/op-waves.ts +215 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +378 -0
- package/src/pr-forge.ts +546 -47
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/records-write.e2e.test.ts +1 -1
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
- /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
+
import { ForgeApiError, forgejoForge, githubForge, type ForgeFetch, type GithubShapedForge } from "../../pr-forge";
|
|
5
|
+
import { ghHostEnv } from "./gh-host";
|
|
4
6
|
|
|
5
7
|
const execAsync = promisify(exec);
|
|
6
8
|
|
|
@@ -21,14 +23,11 @@ const execAsync = promisify(exec);
|
|
|
21
23
|
* {@link mergeRequestContextFrom}.
|
|
22
24
|
*
|
|
23
25
|
* On Forgejo the same mode posts the same GitHub-shaped comment (chant
|
|
24
|
-
* #2291):
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `github.com`, and Forgejo does not serve `/api/v3` — verified on a real
|
|
30
|
-
* Forgejo 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027. See
|
|
31
|
-
* {@link postOrUpdateComment} and {@link githubApiBaseFrom}.
|
|
26
|
+
* #2291): a Forgejo Actions job sets `GITHUB_REPOSITORY`, `GITHUB_API_URL`
|
|
27
|
+
* and `github.token` the way a GitHub Actions job does, and Forgejo's own
|
|
28
|
+
* `/api/v1` takes the same GET/POST/PATCH calls. The calls go through the
|
|
29
|
+
* forge client in `../../pr-forge.ts` over `fetch` (chant #3757), the client
|
|
30
|
+
* the pull-request loop uses. See {@link githubShapedForgeFrom}.
|
|
32
31
|
*
|
|
33
32
|
* `issue` needs no merge/pull request — its whole point is a cron trigger
|
|
34
33
|
* that has none — so its forge split reads off a wider signal: any GitLab CI
|
|
@@ -39,48 +38,29 @@ const execAsync = promisify(exec);
|
|
|
39
38
|
* recipe `comment` mode's note uses, and opens a new one only when it finds
|
|
40
39
|
* none. Which is why that marker has to name the Op (chant #2319) and why
|
|
41
40
|
* this mode refuses a step that supplies neither an `op` nor a `marker` — see
|
|
42
|
-
* {@link issueMarker} and {@link noIssueIdentityMessage}.
|
|
41
|
+
* {@link issueMarker} and {@link noIssueIdentityMessage}. See {@link
|
|
42
|
+
* postOrUpdateGithubIssue} for why the GitHub/GHES/Forgejo lookup pages
|
|
43
|
+
* rather than using GitHub's Search API, and for the decision that this mode
|
|
44
|
+
* never closes the issue itself. Only a run outside any known CI job — no
|
|
45
|
+
* `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue create`'s
|
|
46
|
+
* own ambient repo detection and the operator's `gh auth login`, still
|
|
47
|
+
* marker-prefixed so a later CI run of the same Op finds and edits it instead
|
|
48
|
+
* of opening a second one.
|
|
43
49
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* create`'s own ambient repo detection, still marker-prefixed so a later CI
|
|
49
|
-
* run of the same Op finds and edits it instead of opening a second one.
|
|
50
|
+
* Both modes resolve the GitHub/Forgejo credential the same way, through
|
|
51
|
+
* {@link commentTokenFrom}: `CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then
|
|
52
|
+
* `GITHUB_TOKEN` (chant #2320), refusing by name before any call when none
|
|
53
|
+
* is set ({@link noCommentTokenMessage}, {@link noIssueTokenMessage}).
|
|
50
54
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `GH_TOKEN`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* credential to `gh auth login` — see the comment on that branch. Parity with
|
|
61
|
-
* `comment` mode is not a working Forgejo write, as the next paragraph says.
|
|
62
|
-
*
|
|
63
|
-
* Settled against a real Forgejo 12.0.4+gitea-1.22.0 instance under chant
|
|
64
|
-
* #2315, the way `comment` mode's endpoints were under #2291: the *read*
|
|
65
|
-
* half works — plain paginated listing (no `/search/issues`, confirmed
|
|
66
|
-
* absent from Forgejo's own OpenAPI spec) and the `.pull_request == null`
|
|
67
|
-
* filter both behave exactly as they do against github.com. The *write*
|
|
68
|
-
* half did not, for a reason no mock could have caught: `gh`'s own
|
|
69
|
-
* `GH_TOKEN`/`GITHUB_TOKEN` only authenticate a request to github.com or a
|
|
70
|
-
* ghe.com subdomain (`gh help environment`), never a self-hosted Forgejo,
|
|
71
|
-
* and both this function's POST/PATCH calls and `postOrUpdateComment`'s
|
|
72
|
-
* carried only `GH_TOKEN`. Every write either made on a real Forgejo
|
|
73
|
-
* instance failed with `{"message":"token is required"}` (HTTP 401), with
|
|
74
|
-
* `GH_DEBUG=api` showing no `Authorization` header at all — which is what
|
|
75
|
-
* shipped in 0.62.0 and 0.63.0, `comment` mode included.
|
|
76
|
-
*
|
|
77
|
-
* Both paths now forward that credential as `GH_ENTERPRISE_TOKEN` as well as
|
|
78
|
-
* `GH_TOKEN`, which is the variable `gh` reads for a host in neither of those
|
|
79
|
-
* two classes (chant #2333). Re-run against the same live instance from a
|
|
80
|
-
* shell with no stored `gh auth login`, the identical POST now sends
|
|
81
|
-
* `Authorization: token …` and returns HTTP 201. See {@link ghCredentialEnv}
|
|
82
|
-
* for the whole probe, for why `GH_HOST` turned out not to be needed
|
|
83
|
-
* alongside it, and for why github.com's own behavior is untouched.
|
|
55
|
+
* Until #3757 these two modes shelled to `gh api`. That reached Forgejo only
|
|
56
|
+
* after two fixes: a full URL, because `gh` resolves a relative path against
|
|
57
|
+
* `/api/v3` (#2291), and the token under `GH_ENTERPRISE_TOKEN`, because `gh`
|
|
58
|
+
* sends `GH_TOKEN` only to github.com and `ghe.com` hosts and a Forgejo write
|
|
59
|
+
* arrived with no `Authorization` header (#2315, #2333, verified against a
|
|
60
|
+
* real Forgejo 12.0.4+gitea-1.22.0 instance). The client sends the header
|
|
61
|
+
* itself, so neither depends on how `gh` classifies a host. {@link
|
|
62
|
+
* ghCredentialEnv} keeps that fix for the `gh` calls `lexiconUpgrade` still
|
|
63
|
+
* makes.
|
|
84
64
|
*/
|
|
85
65
|
export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
|
|
86
66
|
|
|
@@ -471,10 +451,10 @@ export interface CommentToken {
|
|
|
471
451
|
}
|
|
472
452
|
|
|
473
453
|
/**
|
|
474
|
-
* Resolve the token `
|
|
475
|
-
* (chant #2291).
|
|
476
|
-
*
|
|
477
|
-
*
|
|
454
|
+
* Resolve the token the `comment` and `issue` modes post with on GitHub and
|
|
455
|
+
* Forgejo, most specific first (chant #2291). The generated workflow sets
|
|
456
|
+
* `GH_TOKEN` and `GITHUB_TOKEN` to `${{ github.token }}` for every
|
|
457
|
+
* non-`report` finding mode — on
|
|
478
458
|
* Forgejo the same way as on GitHub, and the real-instance read confirmed
|
|
479
459
|
* `github.token` is populated there and good for a 200 read and a 201 write,
|
|
480
460
|
* authored as `forgejo-actions`. So the common case needs nothing set beyond
|
|
@@ -483,8 +463,8 @@ export interface CommentToken {
|
|
|
483
463
|
* `CHANT_FORGEJO_TOKEN` is checked first for the case that ambient token
|
|
484
464
|
* cannot cover: a run posting to a Forgejo instance other than the one the
|
|
485
465
|
* job executes on, where `github.token`'s scope stops at its own instance.
|
|
486
|
-
*
|
|
487
|
-
*
|
|
466
|
+
* A run with none of the three is refused by name before any call, in place
|
|
467
|
+
* of the forge's own opaque 401.
|
|
488
468
|
*/
|
|
489
469
|
export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
|
|
490
470
|
for (const source of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
|
|
@@ -578,78 +558,85 @@ export function noIssueTokenMessage(repo: string): string {
|
|
|
578
558
|
}
|
|
579
559
|
|
|
580
560
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* `
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* too, and Forgejo's `/api/v1` takes the same calls (chant #2291).
|
|
561
|
+
* The GitHub or Forgejo client a `comment` or `issue` finding posts through
|
|
562
|
+
* (#3757): `../../pr-forge.ts`, the one the pull-request loop already uses,
|
|
563
|
+
* over `fetch`. These two modes used to shell to `gh`, which needed `gh` on
|
|
564
|
+
* the runner and needed it to attach a credential to a host it did not
|
|
565
|
+
* recognise (#2333). The token is the one {@link commentTokenFrom} resolved
|
|
566
|
+
* and the base is {@link githubApiBaseFrom}'s, so nothing about what a step
|
|
567
|
+
* sets has changed.
|
|
589
568
|
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
|
|
598
|
-
|
|
569
|
+
* The two forges share every call these modes make and differ on two things
|
|
570
|
+
* the client needs to know: the page parameter (`per_page` against `limit`)
|
|
571
|
+
* and the authorization scheme. A Forgejo run is told apart the way
|
|
572
|
+
* `../gate-resume.ts` tells it apart, by `FORGEJO_ACTIONS` or
|
|
573
|
+
* `GITEA_ACTIONS`, and also by an API base ending in `/api/v1`, which is how
|
|
574
|
+
* a step posting to some other Forgejo instance (`CHANT_FORGEJO_TOKEN`) names
|
|
575
|
+
* it. GitHub serves `/api/v3` or `api.github.com`, never `/api/v1`.
|
|
576
|
+
*/
|
|
577
|
+
export function githubShapedForgeFrom(
|
|
578
|
+
env: Record<string, string | undefined>,
|
|
579
|
+
repo: string,
|
|
580
|
+
token: CommentToken,
|
|
581
|
+
signal?: AbortSignal,
|
|
582
|
+
): GithubShapedForge {
|
|
583
|
+
const apiBase = githubApiBaseFrom(env);
|
|
584
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" || /\/api\/v1$/.test(apiBase);
|
|
585
|
+
// Read at call time, so a test that stubs the global sees its stub.
|
|
586
|
+
const fetchImpl: ForgeFetch = (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) });
|
|
587
|
+
const opts = { apiBase, repo, token: token.value, fetch: fetchImpl };
|
|
588
|
+
if (!forgejo) return githubForge(opts);
|
|
589
|
+
let host: string;
|
|
590
|
+
try {
|
|
591
|
+
host = new URL(apiBase).host;
|
|
592
|
+
} catch {
|
|
593
|
+
host = apiBase;
|
|
594
|
+
}
|
|
595
|
+
return forgejoForge({ ...opts, host });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A forge's refusal, carrying which variable the token came from, the way the GitLab half's errors do. */
|
|
599
|
+
async function withTokenSource<T>(token: CommentToken, call: () => Promise<T>): Promise<T> {
|
|
600
|
+
try {
|
|
601
|
+
return await call();
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (err instanceof ForgeApiError) throw new Error(`${err.message} (token from ${token.source}).`);
|
|
604
|
+
throw err;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Post `body` as one comment on `ctx`'s pull request, or edit the comment this
|
|
610
|
+
* Op already owns there: the comment whose body starts with `marker`, found
|
|
611
|
+
* by paging every comment on the pull request, PATCHed when there is one and
|
|
612
|
+
* POSTed when there is not. The same note recipe `PrPlanReport` and the
|
|
613
|
+
* pull-request loop use. A missing token is refused by name before any call.
|
|
599
614
|
*/
|
|
600
615
|
async function postOrUpdateComment(
|
|
601
616
|
ctx: PullRequestContext,
|
|
602
617
|
marker: string,
|
|
603
618
|
body: string,
|
|
604
619
|
signal?: AbortSignal,
|
|
605
|
-
): Promise<string> {
|
|
620
|
+
): Promise<string | undefined> {
|
|
606
621
|
const token = commentTokenFrom(process.env);
|
|
607
622
|
if (!token) throw new Error(noCommentTokenMessage(ctx.repo, ctx.number));
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
const base = githubApiBaseFrom(process.env);
|
|
611
|
-
const listUrl = `${base}/repos/${ctx.repo}/issues/${ctx.number}/comments`;
|
|
612
|
-
const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
|
|
613
|
-
const { stdout: found } = await execAsync(
|
|
614
|
-
`gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
|
|
615
|
-
{ signal, env },
|
|
616
|
-
);
|
|
617
|
-
// `--paginate` prints one `--jq` result per page, so take the first line
|
|
618
|
-
// that is an id and ignore the empty ones the other pages produce.
|
|
619
|
-
const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
|
|
620
|
-
const field = `body=${marker}\n\n${body}`;
|
|
621
|
-
|
|
622
|
-
if (existing) {
|
|
623
|
-
const { stdout } = await execAsync(
|
|
624
|
-
`gh api --method PATCH ${shellQuote(`${base}/repos/${ctx.repo}/issues/comments/${existing}`)} ` +
|
|
625
|
-
`-f ${shellQuote(field)} --jq .html_url`,
|
|
626
|
-
{ signal, env },
|
|
627
|
-
);
|
|
628
|
-
return stdout.trim();
|
|
629
|
-
}
|
|
630
|
-
const { stdout } = await execAsync(
|
|
631
|
-
`gh api --method POST ${shellQuote(listUrl)} -f ${shellQuote(field)} --jq .html_url`,
|
|
632
|
-
{ signal, env },
|
|
633
|
-
);
|
|
634
|
-
return stdout.trim();
|
|
623
|
+
const forge = githubShapedForgeFrom(process.env, ctx.repo, token, signal);
|
|
624
|
+
return withTokenSource(token, () => forge.upsertNote(ctx.number, marker, `${marker}\n\n${body}`));
|
|
635
625
|
}
|
|
636
626
|
|
|
637
627
|
// ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
|
|
638
628
|
|
|
639
629
|
/**
|
|
640
630
|
* Minimal `gh` invocation shape {@link postOrUpdateGithubIssue} threads its
|
|
641
|
-
* calls through. `
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* identical for both callers; only the issue's title/body and the marker that
|
|
646
|
-
* owns it differ.
|
|
631
|
+
* calls through. `lexiconUpgrade` passes its injectable `GhRunner` straight
|
|
632
|
+
* through, since the two share this signature. `reconcilePr` used to wrap
|
|
633
|
+
* `execAsync` to it too, before its `issue` mode moved onto the forge client
|
|
634
|
+
* (#3757).
|
|
647
635
|
*
|
|
648
636
|
* The `opts` argument exists so `postOrUpdateGithubIssue` can hand `gh` the
|
|
649
637
|
* token it resolved (chant #2320) rather than leaving `gh` to find one
|
|
650
638
|
* ambiently. A caller that adds nothing of its own can ignore it and pass it
|
|
651
|
-
* straight to `exec
|
|
652
|
-
* carries. It is deliberately not a whole `ExecOptions`: an implementation
|
|
639
|
+
* straight to `exec`. It is deliberately not a whole `ExecOptions`: an implementation
|
|
653
640
|
* that had to honour `cwd` or `shell` too would be a second exec wrapper, and
|
|
654
641
|
* the point of this type is that there is only one.
|
|
655
642
|
*/
|
|
@@ -660,16 +647,17 @@ export type GhExec = (
|
|
|
660
647
|
|
|
661
648
|
/**
|
|
662
649
|
* Open `title`/`body` as one GitHub-shaped issue in `repo`, or edit the issue
|
|
663
|
-
* this caller already owns there (#2297)
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
650
|
+
* this caller already owns there (#2297), over `gh api`: find by marker,
|
|
651
|
+
* PATCH when found, POST when not, so a nightly finding carries one issue
|
|
652
|
+
* holding the current state instead of stacking a new one every run.
|
|
653
|
+
* `lexiconUpgrade`'s per-lexicon upgrade-status issue uses it. `reconcilePr`'s
|
|
654
|
+
* `issue` mode used it until #3757 and now keeps the same issue by the same
|
|
655
|
+
* recipe through the forge client's `upsertIssue` (`../../pr-forge.ts`), so
|
|
656
|
+
* the reasoning below covers both.
|
|
669
657
|
*
|
|
670
|
-
* Every call targets a full URL built from {@link githubApiBaseFrom},
|
|
671
|
-
*
|
|
672
|
-
*
|
|
658
|
+
* Every call targets a full URL built from {@link githubApiBaseFrom} (#2291),
|
|
659
|
+
* so this reaches GitHub Enterprise Server and Forgejo the same way it
|
|
660
|
+
* reaches github.com.
|
|
673
661
|
*
|
|
674
662
|
* The credential is resolved and forwarded the same way too (chant #2320),
|
|
675
663
|
* through {@link commentTokenFrom} and out through {@link ghCredentialEnv} on
|
|
@@ -721,7 +709,7 @@ export type GhExec = (
|
|
|
721
709
|
* #2333 gave them a credential `gh` would apply to that host — see the
|
|
722
710
|
* module doc's `issue` bullet and {@link ghCredentialEnv}.
|
|
723
711
|
*
|
|
724
|
-
* So this reuses the recipe `
|
|
712
|
+
* So this reuses the recipe `comment` mode already proved for PR
|
|
725
713
|
* comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
|
|
726
714
|
* prefix match, which is correct no matter how many pages it takes. The cost
|
|
727
715
|
* is real — a repository with many thousands of issues pays for every page
|
|
@@ -771,7 +759,8 @@ export async function postOrUpdateGithubIssue(
|
|
|
771
759
|
`gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
|
|
772
760
|
{ env },
|
|
773
761
|
);
|
|
774
|
-
// `--paginate` prints one `--jq` result per page,
|
|
762
|
+
// `--paginate` prints one `--jq` result per page, so take the first line
|
|
763
|
+
// that is an id and ignore the empty ones the other pages produce.
|
|
775
764
|
const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
|
|
776
765
|
const titleField = shellQuote(`title=${title}`);
|
|
777
766
|
const bodyField = shellQuote(`body=${marker}\n\n${body}`);
|
|
@@ -999,7 +988,7 @@ async function gitlabRequest(
|
|
|
999
988
|
|
|
1000
989
|
/**
|
|
1001
990
|
* Find the note this Op already owns on `ctx`'s merge request, by the same
|
|
1002
|
-
* hidden marker `
|
|
991
|
+
* hidden marker `comment` mode looks a GitHub comment up by: the
|
|
1003
992
|
* marker is the body's first line and the match is a prefix.
|
|
1004
993
|
*
|
|
1005
994
|
* Pages the way GitLab pages, following the `x-next-page` response header
|
|
@@ -1040,11 +1029,10 @@ async function findOwnedNote(
|
|
|
1040
1029
|
* so a merge request pushed to five times carries one note holding the
|
|
1041
1030
|
* current finding rather than five stale ones.
|
|
1042
1031
|
*
|
|
1043
|
-
* Over `fetch` rather than a CLI
|
|
1044
|
-
*
|
|
1045
|
-
* `
|
|
1046
|
-
*
|
|
1047
|
-
* generator emits.
|
|
1032
|
+
* Over `fetch` rather than a CLI: `glab` is on no GitLab runner by default,
|
|
1033
|
+
* and a job whose finding step depended on it would fail on the ordinary
|
|
1034
|
+
* `node:22` image the generator emits. The GitHub and Forgejo half
|
|
1035
|
+
* follows the same rule since #3757.
|
|
1048
1036
|
*/
|
|
1049
1037
|
async function postOrUpdateNote(
|
|
1050
1038
|
ctx: MergeRequestContext,
|
|
@@ -1247,7 +1235,10 @@ function resolveMarker(mode: ReconcileMode, args: ReconcilePrArgs): string | und
|
|
|
1247
1235
|
* supplies one, in which case that text is used verbatim and no plan is
|
|
1248
1236
|
* derived (chant #2087).
|
|
1249
1237
|
*
|
|
1250
|
-
* Requires `chant`
|
|
1238
|
+
* Requires `chant` in the environment. `comment` and `issue` reach the forge
|
|
1239
|
+
* over `fetch` and need nothing else in CI (#3757); `pull-request` needs `git`
|
|
1240
|
+
* and `gh`, and so does an `issue` run outside any CI job, which leaves the
|
|
1241
|
+
* credential to `gh auth login`.
|
|
1251
1242
|
*/
|
|
1252
1243
|
export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal): Promise<ReconcileResult> {
|
|
1253
1244
|
const mode = args.mode ?? "pull-request";
|
|
@@ -1291,13 +1282,17 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
1291
1282
|
}
|
|
1292
1283
|
|
|
1293
1284
|
// GitHub, GHES, or Forgejo next: any job that ran under GitHub Actions or
|
|
1294
|
-
// Forgejo Actions carries GITHUB_REPOSITORY (#2297)
|
|
1295
|
-
//
|
|
1296
|
-
// the
|
|
1285
|
+
// Forgejo Actions carries GITHUB_REPOSITORY (#2297). The issue is kept
|
|
1286
|
+
// through the forge client (#3757) — see postOrUpdateGithubIssue's doc
|
|
1287
|
+
// comment for why the lookup pages rather than searches, and for the
|
|
1288
|
+
// close decision.
|
|
1297
1289
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
1298
1290
|
if (repo) {
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1291
|
+
const token = commentTokenFrom(process.env);
|
|
1292
|
+
if (!token) throw new Error(noIssueTokenMessage(repo));
|
|
1293
|
+
const forge = githubShapedForgeFrom(process.env, repo, token, signal);
|
|
1294
|
+
const issueUrl = await withTokenSource(token, () =>
|
|
1295
|
+
forge.upsertIssue(marker, title, `${marker}\n\n${summary}`),
|
|
1301
1296
|
);
|
|
1302
1297
|
return { mode, summary, entries, issueUrl };
|
|
1303
1298
|
}
|
|
@@ -1368,9 +1363,11 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
1368
1363
|
await execAsync(`git add ${shellQuote(output)}`, { signal });
|
|
1369
1364
|
await execAsync(`git commit -m ${shellQuote(title)}`, { signal });
|
|
1370
1365
|
await execAsync(`git push -u origin ${shellQuote(branch)}`, { signal });
|
|
1366
|
+
// `gh pr create` names no host, so on GitHub Enterprise Server or Forgejo
|
|
1367
|
+
// it is told the job's own (#3780). See `./gh-host.ts`.
|
|
1371
1368
|
const { stdout } = await execAsync(
|
|
1372
1369
|
`gh pr create --title ${shellQuote(title)} --body ${shellQuote(summary)} --head ${shellQuote(branch)}`,
|
|
1373
|
-
{ signal },
|
|
1370
|
+
{ signal, env: await ghHostEnv(process.env) },
|
|
1374
1371
|
);
|
|
1375
1372
|
|
|
1376
1373
|
return { mode, branch, summary, entries, prUrl: stdout.trim() };
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* lexicon's and is tested there.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, test, expect } from "vitest";
|
|
8
|
-
import { evaluateGate, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
|
|
8
|
+
import { evaluateGate, humanQuorumSince, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
|
|
9
|
+
import { resolutionAnswering } from "./gate-resume";
|
|
10
|
+
import { gatesInLedger } from "../workspace/status-gates";
|
|
11
|
+
import { describeApprovalProgress } from "../cli/handlers/operator";
|
|
9
12
|
import { gate } from "./builders";
|
|
10
|
-
import { gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
|
|
13
|
+
import { gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
|
|
11
14
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
12
15
|
|
|
13
16
|
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
@@ -25,8 +28,9 @@ function approval(overrides: Partial<GateResolutionRecord> & { resolvedBy: strin
|
|
|
25
28
|
return { version: 1, op: "release", gate: "ship", planDigest: PLAN_A, approver: { kind: "human" }, ...overrides };
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** A recorded allow, evaluated under `context` (the context the tests' blocks resolve, by default). */
|
|
32
|
+
function allow(mode: "log-only" | "enforce" = "enforce", context: Record<string, unknown> | undefined = { risk: "low" }): GateResolutionRecord["policyDecision"] {
|
|
33
|
+
return { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(context), decision: "allow", determining: ["ship-0"], errors: [] };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A) {
|
|
@@ -168,7 +172,7 @@ describe("gate approval — policy decisions (#2508)", () => {
|
|
|
168
172
|
});
|
|
169
173
|
|
|
170
174
|
test("enforce: an agent's permit passes the gate on its own", async () => {
|
|
171
|
-
const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce" };
|
|
175
|
+
const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce", context: { risk: "low" } };
|
|
172
176
|
const { check } = await decide(
|
|
173
177
|
[approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow() })],
|
|
174
178
|
block,
|
|
@@ -180,7 +184,7 @@ describe("gate approval — policy decisions (#2508)", () => {
|
|
|
180
184
|
});
|
|
181
185
|
|
|
182
186
|
test("enforce: a permit recorded under an older policy version does not count", async () => {
|
|
183
|
-
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
|
|
187
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
|
|
184
188
|
const { check } = await decide(
|
|
185
189
|
[approval({
|
|
186
190
|
resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" },
|
|
@@ -253,7 +257,7 @@ describe("gate approval — policy decisions (#2508)", () => {
|
|
|
253
257
|
});
|
|
254
258
|
|
|
255
259
|
test("enforce: a recorded decision with no mode does not pass the gate (#2512)", async () => {
|
|
256
|
-
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
|
|
260
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
|
|
257
261
|
const { mode: _mode, ...noMode } = allow()!;
|
|
258
262
|
const { check } = await decide(
|
|
259
263
|
[approval({
|
|
@@ -312,6 +316,170 @@ describe("gate approval — policy decisions (#2508)", () => {
|
|
|
312
316
|
});
|
|
313
317
|
});
|
|
314
318
|
|
|
319
|
+
describe("gate approval — a re-recorded pending fact keeps a partly filled quorum (#3206)", () => {
|
|
320
|
+
const twoLogOnly: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "log-only", context: { risk: "low" } };
|
|
321
|
+
const twoEnforce: ResolvedGateApproval = { ...twoLogOnly, mode: "enforce" };
|
|
322
|
+
const alex = approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", policyDecision: allow("log-only") });
|
|
323
|
+
const sam = approval({ resolvedBy: "sam", timestamp: "2026-09-01T13:00:00.000Z" });
|
|
324
|
+
|
|
325
|
+
/** Run the gate once under `before`'s pending fact with `next`, then again after `later` approvals. */
|
|
326
|
+
async function switchThenApprove(before: ResolvedGateApproval, next: ResolvedGateApproval, later: GateResolutionRecord[], planDigest = PLAN_A) {
|
|
327
|
+
const port = memoryGateLedgerPort({ resolutions: [alex], pending: [{ ...PENDING, approval: before }] });
|
|
328
|
+
const first = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: next, now: NOW });
|
|
329
|
+
const ledger = await port.read("release");
|
|
330
|
+
const after = await evaluateGate(
|
|
331
|
+
memoryGateLedgerPort({ resolutions: [...ledger.resolutions, ...later], pending: ledger.pending }),
|
|
332
|
+
{ op: "release", gate: "ship", planDigest, approval: next, now: "2026-09-01T14:00:00.000Z" },
|
|
333
|
+
);
|
|
334
|
+
return { first, after, pending: ledger.pending };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
test("log-only then enforce: the switch re-records the fact, and the human approval from before it still counts", async () => {
|
|
338
|
+
const { first, after } = await switchThenApprove(twoLogOnly, twoEnforce, [sam]);
|
|
339
|
+
expect(first.satisfied).toBe(false);
|
|
340
|
+
if (first.satisfied) return;
|
|
341
|
+
expect(first.recorded).toBe(true);
|
|
342
|
+
expect(first.quorum).toEqual({ approvers: ["alex"], need: 2 });
|
|
343
|
+
|
|
344
|
+
expect(after.satisfied).toBe(true);
|
|
345
|
+
if (!after.satisfied) return;
|
|
346
|
+
expect(after.via).toBe("quorum");
|
|
347
|
+
expect(after.approvals?.map((r) => r.resolvedBy)).toEqual(["alex", "sam"]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a changed context keeps the quorum too", async () => {
|
|
351
|
+
const { after } = await switchThenApprove(twoLogOnly, { ...twoLogOnly, context: { risk: "high" } }, [sam]);
|
|
352
|
+
expect(after.satisfied).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("one approval of two is still one after the switch, so the gate waits", async () => {
|
|
356
|
+
const { after } = await switchThenApprove(twoLogOnly, twoEnforce, []);
|
|
357
|
+
expect(after.satisfied).toBe(false);
|
|
358
|
+
if (after.satisfied) return;
|
|
359
|
+
expect(after.quorum).toEqual({ approvers: ["alex"], need: 2 });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a changed policy version starts the count again", async () => {
|
|
363
|
+
const text = TEXT.replace("low", "medium");
|
|
364
|
+
const edited: GatePolicyRef = { ...POLICY, version: gatePolicyVersion(text), text };
|
|
365
|
+
const { after } = await switchThenApprove(twoLogOnly, { ...twoLogOnly, policy: edited }, [sam]);
|
|
366
|
+
expect(after.satisfied).toBe(false);
|
|
367
|
+
if (after.satisfied) return;
|
|
368
|
+
expect(after.quorum).toEqual({ approvers: ["sam"], need: 2 });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("a fact for another plan in between starts the count again", async () => {
|
|
372
|
+
const pending: PendingGateRecord[] = [
|
|
373
|
+
{ ...PENDING, approval: twoLogOnly },
|
|
374
|
+
{ ...PENDING, timestamp: "2026-09-01T02:00:00.000Z", planDigest: PLAN_B, approval: twoLogOnly },
|
|
375
|
+
{ ...PENDING, timestamp: "2026-09-01T03:00:00.000Z", approval: twoEnforce },
|
|
376
|
+
];
|
|
377
|
+
expect(humanQuorumSince(pending, pending[2]!)).toBe(pending[2]!.timestamp);
|
|
378
|
+
const check = await evaluateGate(
|
|
379
|
+
memoryGateLedgerPort({ resolutions: [alex, sam], pending }),
|
|
380
|
+
{ op: "release", gate: "ship", planDigest: PLAN_A, approval: twoEnforce, now: "2026-09-01T14:00:00.000Z" },
|
|
381
|
+
);
|
|
382
|
+
expect(check.satisfied).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("a fact that had expired before the next was recorded starts the count again", async () => {
|
|
386
|
+
const pending: PendingGateRecord[] = [
|
|
387
|
+
{ ...PENDING, approval: twoLogOnly, expiresAt: "2026-09-01T02:00:00.000Z" },
|
|
388
|
+
{ ...PENDING, timestamp: "2026-09-01T03:00:00.000Z", approval: twoEnforce },
|
|
389
|
+
];
|
|
390
|
+
expect(humanQuorumSince(pending, pending[1]!)).toBe(pending[1]!.timestamp);
|
|
391
|
+
const unexpired = [{ ...pending[0]!, expiresAt: PENDING.expiresAt }, pending[1]!];
|
|
392
|
+
expect(humanQuorumSince(unexpired, unexpired[1]!)).toBe(PENDING.timestamp);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a permit from before the standing fact still never passes, even one recorded under enforce", async () => {
|
|
396
|
+
const bot = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:30:00.000Z", approver: { kind: "agent" }, policyDecision: allow("enforce") });
|
|
397
|
+
const enforceHigh: ResolvedGateApproval = { ...twoEnforce, context: { risk: "high" } };
|
|
398
|
+
const pending: PendingGateRecord[] = [
|
|
399
|
+
{ ...PENDING, approval: enforceHigh },
|
|
400
|
+
{ ...PENDING, timestamp: "2026-09-01T02:00:00.000Z", approval: twoEnforce },
|
|
401
|
+
];
|
|
402
|
+
const check = await evaluateGate(
|
|
403
|
+
memoryGateLedgerPort({ resolutions: [bot], pending }),
|
|
404
|
+
{ op: "release", gate: "ship", planDigest: PLAN_A, approval: twoEnforce, now: NOW },
|
|
405
|
+
);
|
|
406
|
+
expect(check.satisfied).toBe(false);
|
|
407
|
+
if (check.satisfied) return;
|
|
408
|
+
expect(check.recorded).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("workspace status, the resume check and chant approve's progress line count it the same way", async () => {
|
|
412
|
+
const { pending } = await switchThenApprove(twoLogOnly, twoEnforce, []);
|
|
413
|
+
const standing = pending[pending.length - 1]!;
|
|
414
|
+
|
|
415
|
+
const content = [...pending, alex, sam].map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
416
|
+
const gate = gatesInLedger("release", content, [], "2026-09-01T14:00:00.000Z").gates[0];
|
|
417
|
+
expect(gate?.state).toBe("approved");
|
|
418
|
+
expect(gate?.approvals.map((a) => a.principal)).toEqual(["alex", "sam"]);
|
|
419
|
+
|
|
420
|
+
expect(resolutionAnswering([alex, sam], standing, pending)?.resolvedBy).toBe("sam");
|
|
421
|
+
expect(resolutionAnswering([alex, sam], standing)).toBeUndefined();
|
|
422
|
+
|
|
423
|
+
const lines = describeApprovalProgress(twoEnforce, sam, [alex], standing, pending);
|
|
424
|
+
expect(lines).toContain("Quorum: 2 of 2 human approval(s) for this plan (alex, sam).");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("gate approval — a permit counts only under the context it was given for (#3774)", () => {
|
|
429
|
+
const low: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
|
|
430
|
+
const high: ResolvedGateApproval = { ...low, context: { risk: "high" } };
|
|
431
|
+
const bot = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("enforce", low.context) });
|
|
432
|
+
|
|
433
|
+
test("the permit passes the run whose context it was given for", async () => {
|
|
434
|
+
const { check } = await decide([bot], low);
|
|
435
|
+
expect(check.satisfied).toBe(true);
|
|
436
|
+
if (!check.satisfied) return;
|
|
437
|
+
expect(check.via).toBe("policy");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("once the context changes, the permit does not pass, and the run re-records the fact under the new context", async () => {
|
|
441
|
+
// The standing fact is the low-risk one the permit was given against; this
|
|
442
|
+
// run resolved risk "high" and checks approvals before re-recording.
|
|
443
|
+
const port = memoryGateLedgerPort({ resolutions: [bot], pending: [{ ...PENDING, approval: low }] });
|
|
444
|
+
const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: high, now: NOW });
|
|
445
|
+
expect(check.satisfied).toBe(false);
|
|
446
|
+
if (check.satisfied) return;
|
|
447
|
+
expect(check.recorded).toBe(true);
|
|
448
|
+
expect(port.appended[0]?.approval?.context).toEqual({ risk: "high" });
|
|
449
|
+
|
|
450
|
+
// The next run, under the same high-risk context, still waits: nothing new was approved.
|
|
451
|
+
const ledger = await port.read("release");
|
|
452
|
+
const next = await evaluateGate(memoryGateLedgerPort(ledger), { op: "release", gate: "ship", planDigest: PLAN_A, approval: high, now: "2026-09-01T13:00:00.000Z" });
|
|
453
|
+
expect(next.satisfied).toBe(false);
|
|
454
|
+
if (next.satisfied) return;
|
|
455
|
+
expect(next.recorded).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("context keys compare in any order, and nested values count", () => {
|
|
459
|
+
expect(gateContextDigest({ risk: "low", paths: ["a"] })).toBe(gateContextDigest({ paths: ["a"], risk: "low" }));
|
|
460
|
+
expect(gateContextDigest({ plan: { adds: 1, deletes: 0 } })).toBe(gateContextDigest({ plan: { deletes: 0, adds: 1 } }));
|
|
461
|
+
expect(gateContextDigest({ plan: { adds: 1, deletes: 0 } })).not.toBe(gateContextDigest({ plan: { adds: 1, deletes: 1 } }));
|
|
462
|
+
expect(gateContextDigest(undefined)).toBe(gateContextDigest({}));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("a permit recorded before #3774, with no context on it, does not pass an enforced gate", async () => {
|
|
466
|
+
const { contextDigest: _c, ...legacy } = allow()!;
|
|
467
|
+
const { check } = await decide(
|
|
468
|
+
[approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: legacy })],
|
|
469
|
+
low,
|
|
470
|
+
);
|
|
471
|
+
expect(check.satisfied).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a human's approval still counts toward the quorum after the context changes (#3206)", async () => {
|
|
475
|
+
const alex = approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", policyDecision: allow("enforce", low.context) });
|
|
476
|
+
const { check } = await decide([alex], high);
|
|
477
|
+
expect(check.satisfied).toBe(true);
|
|
478
|
+
if (!check.satisfied) return;
|
|
479
|
+
expect(check.via).toBe("quorum");
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
315
483
|
describe("gate approval — the request a policy is asked (#2508)", () => {
|
|
316
484
|
test("carries the approver, the gate and the context, with the plan digest", () => {
|
|
317
485
|
expect(gatePolicyRequest({
|