@intentius/chant 0.62.0 → 0.63.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/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -37,16 +37,46 @@ const execAsync = promisify(exec);
|
|
|
37
37
|
* sets `GITHUB_REPOSITORY`. Both are sticky now (chant #2292, #2297): each
|
|
38
38
|
* finds and edits the OPEN issue it already owns by a hidden marker, the same
|
|
39
39
|
* recipe `comment` mode's note uses, and opens a new one only when it finds
|
|
40
|
+
* none. Which is why that marker has to name the Op (chant #2319) and why
|
|
41
|
+
* this mode refuses a step that supplies neither an `op` nor a `marker` — see
|
|
42
|
+
* {@link issueMarker} and {@link noIssueIdentityMessage}.
|
|
43
|
+
*
|
|
40
44
|
* none. See {@link postOrUpdateGithubIssue} for the GitHub/GHES/Forgejo half
|
|
41
45
|
* — including why it does not use GitHub's Search API, and the decision that
|
|
42
46
|
* this mode never closes the issue itself. Only a run outside any known CI
|
|
43
47
|
* job — no `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue
|
|
44
48
|
* create`'s own ambient repo detection, still marker-prefixed so a later CI
|
|
45
|
-
* run of the same Op finds and edits it instead of opening a second one.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
49
|
+
* run of the same Op finds and edits it instead of opening a second one.
|
|
50
|
+
*
|
|
51
|
+
* The credential did not always travel (chant #2320). The URL and the
|
|
52
|
+
* GET/PATCH/POST triple matched `postOrUpdateComment`'s, but that function
|
|
53
|
+
* resolved a token through {@link commentTokenFrom} and forwarded it as
|
|
54
|
+
* `GH_TOKEN` while the issue path forwarded no environment at all, so
|
|
55
|
+
* `CHANT_FORGEJO_TOKEN` never reached `gh`. Both paths now resolve the same
|
|
56
|
+
* way, in the same order, and forward the same variable, with the two
|
|
57
|
+
* refusals differing only in what they name ({@link noCommentTokenMessage},
|
|
58
|
+
* {@link noIssueTokenMessage}). The one deliberate exception is the `gh issue
|
|
59
|
+
* create` fallback above, which runs outside every CI job and so leaves the
|
|
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 does 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 this function's POST/PATCH calls (like `postOrUpdateComment`'s) carry
|
|
72
|
+
* only `GH_TOKEN` — no `GH_HOST`, no `GH_ENTERPRISE_TOKEN`. Every write this
|
|
73
|
+
* mode makes on a real Forgejo instance fails with `{"message":"token is
|
|
74
|
+
* required"}` (HTTP 401), confirmed with `GH_DEBUG=api` sending no
|
|
75
|
+
* `Authorization` header at all. The forgejo Op generator refuses
|
|
76
|
+
* `findingMode: "issue"` by name for this reason (chant #2315); this
|
|
77
|
+
* activity's own behavior is unchanged; a hand-authored (non-generated)
|
|
78
|
+
* workflow that calls it directly against a Forgejo host will hit the same
|
|
79
|
+
* 401 the generator now refuses to produce.
|
|
50
80
|
*/
|
|
51
81
|
export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
|
|
52
82
|
|
|
@@ -79,13 +109,33 @@ export interface ReconcilePrArgs {
|
|
|
79
109
|
owned?: boolean;
|
|
80
110
|
/** PR / issue title. Default derived from env. */
|
|
81
111
|
title?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The name of the Op this step belongs to, which is what makes an `issue`
|
|
114
|
+
* mode marker unique (#2319) — see {@link issueMarker}. Set it to the
|
|
115
|
+
* enclosing `Op`'s own `name`; both in-tree composites do, and a
|
|
116
|
+
* hand-written step should too.
|
|
117
|
+
*
|
|
118
|
+
* `issue` mode needs this or an explicit `marker`, and refuses by name
|
|
119
|
+
* ({@link noIssueIdentityMessage}) with neither: without one the marker
|
|
120
|
+
* falls back to naming only the env, two Ops over one env collide on it,
|
|
121
|
+
* and each nightly run silently overwrites the other's report.
|
|
122
|
+
*
|
|
123
|
+
* `comment` mode ignores it. That mode's marker is scoped to one pull
|
|
124
|
+
* request rather than to the repository, so its collision (two `comment`
|
|
125
|
+
* Ops over one env, both triggered by one pull request) is bounded by that
|
|
126
|
+
* pull request's life and leaves nothing behind. Tracked separately rather
|
|
127
|
+
* than folded in here, because changing `commentMarker` would orphan the
|
|
128
|
+
* comments already sitting on open pull requests.
|
|
129
|
+
*/
|
|
130
|
+
op?: string;
|
|
82
131
|
/**
|
|
83
132
|
* Hidden marker identifying this Op's own comment or issue. The activity
|
|
84
133
|
* writes it as the first line and finds it again by it on the next run, so
|
|
85
134
|
* a re-run edits one comment/issue instead of stacking a new one. Default:
|
|
86
|
-
* {@link commentMarker} for `comment` mode, {@link
|
|
87
|
-
* mode,
|
|
88
|
-
*
|
|
135
|
+
* {@link commentMarker} for `comment` mode, keyed on `env`; {@link
|
|
136
|
+
* issueMarker} for `issue` mode, keyed on `op` and `env` together. Supply
|
|
137
|
+
* it directly to own uniqueness yourself — in `issue` mode that is the one
|
|
138
|
+
* way to satisfy the identity requirement without passing `op`.
|
|
89
139
|
*/
|
|
90
140
|
marker?: string;
|
|
91
141
|
/**
|
|
@@ -177,21 +227,132 @@ export interface PullRequestContext {
|
|
|
177
227
|
* environments own two comments and each updates in place.
|
|
178
228
|
*/
|
|
179
229
|
export function commentMarker(env: string): string {
|
|
180
|
-
return `<!-- chant-reconcile:${env
|
|
230
|
+
return `<!-- chant-reconcile:${markerSlug(env)} -->`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The hidden marker that makes an `issue`-mode finding findable across re-runs
|
|
235
|
+
* (#2292, #2297): written as the issue description's first line, matched on
|
|
236
|
+
* the next run by a `startswith` check (GitHub/Forgejo) or a server-side
|
|
237
|
+
* `search` plus the same check (GitLab). The same recipe {@link commentMarker}
|
|
238
|
+
* names for the `comment` mode's note, kept as its own function (rather than
|
|
239
|
+
* reused) because the two modes write to different resources and a caller may
|
|
240
|
+
* run both against the same `env`.
|
|
241
|
+
*
|
|
242
|
+
* Keyed on the owning Op *and* the env, not the env alone (#2319). An
|
|
243
|
+
* env-keyed marker was not unique, and once #2311 made this mode edit in place
|
|
244
|
+
* that stopped being merely untidy: `postOrUpdateGithubIssue` PATCHes both the
|
|
245
|
+
* title and the body of whatever it matches, so two Ops resolving to one
|
|
246
|
+
* marker rewrite each other's report on every run and the issue alternates
|
|
247
|
+
* between two unrelated findings. Two in-tree pairings hit it — a stock
|
|
248
|
+
* `TerraformWatchOp` and a `live: true` one over the same root (both pass the
|
|
249
|
+
* root as `env`), and a `ReconcileOp` for a chant environment whose name
|
|
250
|
+
* matches some terraform root — and both are ordinary configurations, not
|
|
251
|
+
* abuse. The Op name is the identifier closest to unique that this activity
|
|
252
|
+
* can be handed: it names the Op's output directory and is what `chant run`
|
|
253
|
+
* takes, so two Ops in a project do not share a raw name.
|
|
254
|
+
* `lexicons/github`'s gate notice reached the same conclusion first, keying
|
|
255
|
+
* its own sticky marker on `$CHANT_OP` alone.
|
|
256
|
+
*
|
|
257
|
+
* Both halves are slugified the same way {@link reconcileBranchName} slugifies
|
|
258
|
+
* an env, which also keeps the value free of the quotes and backslashes it is
|
|
259
|
+
* interpolated next to and of anything URL-encoding should have to escape out
|
|
260
|
+
* of. `/` cannot survive that slugify, so it separates the two halves
|
|
261
|
+
* unambiguously: the marker parses back to exactly one `op` slug and one `env`
|
|
262
|
+
* slug.
|
|
263
|
+
*
|
|
264
|
+
* What that leaves, and what is accepted rather than solved (#2319 pre-merge
|
|
265
|
+
* review): the slug is not injective. {@link markerSlug} collapses every run
|
|
266
|
+
* of characters outside `[A-Za-z0-9._-]` to a single `-`, so `"app watch"` and
|
|
267
|
+
* `"app:watch"` share a slug, and so do `"café"` and `"caf€"`. Two Ops named
|
|
268
|
+
* that way in one project over one env would still collide. Nothing in this
|
|
269
|
+
* repository constrains an Op name's character set today, so this cannot be
|
|
270
|
+
* closed here — it wants a charset rule on `OpConfig.name` (which would also
|
|
271
|
+
* settle the output directory and the branch name, both of which slugify the
|
|
272
|
+
* same way and have the same aliasing). Until then the residual is: distinct
|
|
273
|
+
* raw names, distinct markers, unless the names differ only in characters the
|
|
274
|
+
* slug does not keep.
|
|
275
|
+
*/
|
|
276
|
+
export function issueMarker(op: string, env: string): string {
|
|
277
|
+
return `<!-- chant-reconcile-issue:${markerSlug(op)}/${markerSlug(env)} -->`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The slugify both markers share: everything outside `[A-Za-z0-9._-]` collapses to `-`. */
|
|
281
|
+
function markerSlug(s: string): string {
|
|
282
|
+
return s.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What any mode says about a supplied `marker` it cannot use safely (#2319
|
|
287
|
+
* pre-merge review).
|
|
288
|
+
*
|
|
289
|
+
* The marker is interpolated raw into the `--jq` filter that finds this Op's
|
|
290
|
+
* own comment or issue, inside a jq string literal: `startswith("<marker>")`.
|
|
291
|
+
* Only the whole filter is shell-quoted, so a `"` or a `\` in the marker
|
|
292
|
+
* closes or escapes past that literal — at best a jq syntax error, at worst a
|
|
293
|
+
* filter that matches something other than what the caller wrote. A control
|
|
294
|
+
* character does the same to the jq program's own line structure.
|
|
295
|
+
*
|
|
296
|
+
* Markers this activity builds itself cannot contain any of the three:
|
|
297
|
+
* {@link markerSlug} keeps them to `[A-Za-z0-9._-]` plus the fixed `<!-- … -->`
|
|
298
|
+
* frame. This checks the one that comes in from outside, and refuses rather
|
|
299
|
+
* than escaping, because a marker is an identity a caller has to be able to
|
|
300
|
+
* predict — silently rewriting it would move the issue this run owns.
|
|
301
|
+
*/
|
|
302
|
+
export function unsafeMarkerMessage(marker: string): string {
|
|
303
|
+
return (
|
|
304
|
+
`reconcilePr was given the marker ${JSON.stringify(marker)}, which it cannot use. The marker goes into ` +
|
|
305
|
+
'the `--jq` filter that finds this Op\'s own comment or issue, as `startswith("<marker>")`, so a double ' +
|
|
306
|
+
"quote, a backslash or a control character in it either breaks that filter or changes what it matches. " +
|
|
307
|
+
"Markers this activity builds itself are slugified to letters, digits, dot, underscore and hyphen and " +
|
|
308
|
+
"cannot contain any of the three. Keep a supplied marker to printable text without `\"` or `\\`, or drop " +
|
|
309
|
+
"`marker` and pass `op` instead."
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The caller's `marker` as this activity will actually use it, or `undefined`
|
|
315
|
+
* when the caller supplied nothing usable (#2319 pre-merge review).
|
|
316
|
+
*
|
|
317
|
+
* Blank is not a marker. `""` and `" "` used to satisfy the `issue`-mode
|
|
318
|
+
* identity check — `args.marker === undefined` is false for both, and `??`
|
|
319
|
+
* only falls through on nullish — and then built `startswith("")`, which is
|
|
320
|
+
* true of every issue body in the repository. So the step took whichever OPEN
|
|
321
|
+
* non-pull-request issue the forge listed first, very possibly a human's, and
|
|
322
|
+
* PATCHed its title and body. That is the precise failure #2319 exists to
|
|
323
|
+
* close, reachable through the escape hatch #2319 added, which is why the
|
|
324
|
+
* check lives on the field rather than in one mode's branch.
|
|
325
|
+
*
|
|
326
|
+
* Trimming rather than only rejecting: a marker with a trailing space is
|
|
327
|
+
* written and matched consistently either way, and normalizing it here means
|
|
328
|
+
* one definition of "blank" for both modes.
|
|
329
|
+
*/
|
|
330
|
+
export function suppliedMarker(marker: string | undefined): string | undefined {
|
|
331
|
+
const trimmed = marker?.trim();
|
|
332
|
+
if (!trimmed) return undefined;
|
|
333
|
+
if (/["\\]|[\u0000-\u001f\u007f]/.test(trimmed)) throw new Error(unsafeMarkerMessage(trimmed));
|
|
334
|
+
return trimmed;
|
|
181
335
|
}
|
|
182
336
|
|
|
183
337
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
338
|
+
* What an `issue`-mode step says when it was given no identity to key its
|
|
339
|
+
* marker on (#2319).
|
|
340
|
+
*
|
|
341
|
+
* A refusal rather than a fall back to the pre-#2319 env-only marker, because
|
|
342
|
+
* the fallback is the bug: it is silent, it looks like it worked, and what it
|
|
343
|
+
* costs is the *other* Op's report, which nobody is watching the log of. The
|
|
344
|
+
* step that has to change is the one being refused, and the message names the
|
|
345
|
+
* two ways to change it.
|
|
192
346
|
*/
|
|
193
|
-
export function
|
|
194
|
-
return
|
|
347
|
+
export function noIssueIdentityMessage(env: string): string {
|
|
348
|
+
return (
|
|
349
|
+
'reconcilePr mode "issue" edits the one OPEN issue carrying this Op\'s hidden marker in place, so the ' +
|
|
350
|
+
`marker has to name the Op. This step supplies env "${env}" and no identity, and an env alone is not ` +
|
|
351
|
+
"unique: two Ops over one env — a stock terraform drift watch and a live one over the same root, or a " +
|
|
352
|
+
"terraform root and a chant environment that happen to share a name — resolve to the same marker, and " +
|
|
353
|
+
"each run then rewrites the other's issue title and body. Pass `op` with the Op's own name (every " +
|
|
354
|
+
"composite in tree does), or pass an explicit `marker` you keep unique yourself."
|
|
355
|
+
);
|
|
195
356
|
}
|
|
196
357
|
|
|
197
358
|
/** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
|
|
@@ -334,6 +495,16 @@ export function noCommentTokenMessage(repo: string, number: number): string {
|
|
|
334
495
|
);
|
|
335
496
|
}
|
|
336
497
|
|
|
498
|
+
/** What an `issue`-mode step says on a repository it has no credential for (#2320). */
|
|
499
|
+
export function noIssueTokenMessage(repo: string): string {
|
|
500
|
+
return (
|
|
501
|
+
`reconcilePr mode "issue" has repository ${repo} to open or edit its finding in and no token to do it ` +
|
|
502
|
+
"with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions or " +
|
|
503
|
+
"Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
|
|
504
|
+
"job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
337
508
|
/**
|
|
338
509
|
* Post `body` as one comment on `ctx`'s pull request, or edit the comment this
|
|
339
510
|
* Op already owns there. The sticky-comment recipe the github lexicon's
|
|
@@ -395,12 +566,23 @@ async function postOrUpdateComment(
|
|
|
395
566
|
* Minimal `gh` invocation shape {@link postOrUpdateGithubIssue} threads its
|
|
396
567
|
* calls through. `reconcilePr` wraps `execAsync` (carrying its own `signal`)
|
|
397
568
|
* to this shape; `lexiconUpgrade` passes its injectable `GhRunner` straight
|
|
398
|
-
* through, since the two already share
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
569
|
+
* through, since the two already share this signature. One function, not two
|
|
570
|
+
* copies, because the recipe below — search, then PATCH or POST — is
|
|
571
|
+
* identical for both callers; only the issue's title/body and the marker that
|
|
572
|
+
* owns it differ.
|
|
573
|
+
*
|
|
574
|
+
* The `opts` argument exists so `postOrUpdateGithubIssue` can hand `gh` the
|
|
575
|
+
* token it resolved (chant #2320) rather than leaving `gh` to find one
|
|
576
|
+
* ambiently. A caller that adds nothing of its own can ignore it and pass it
|
|
577
|
+
* straight to `exec`; `reconcilePr` merges it with the `signal` it already
|
|
578
|
+
* carries. It is deliberately not a whole `ExecOptions`: an implementation
|
|
579
|
+
* that had to honour `cwd` or `shell` too would be a second exec wrapper, and
|
|
580
|
+
* the point of this type is that there is only one.
|
|
402
581
|
*/
|
|
403
|
-
export type GhExec = (
|
|
582
|
+
export type GhExec = (
|
|
583
|
+
cmd: string,
|
|
584
|
+
opts?: { env?: NodeJS.ProcessEnv },
|
|
585
|
+
) => Promise<{ stdout: string; stderr: string }>;
|
|
404
586
|
|
|
405
587
|
/**
|
|
406
588
|
* Open `title`/`body` as one GitHub-shaped issue in `repo`, or edit the issue
|
|
@@ -415,6 +597,21 @@ export type GhExec = (cmd: string) => Promise<{ stdout: string; stderr: string }
|
|
|
415
597
|
* same as `postOrUpdateComment` (#2291) — so this reaches GitHub Enterprise
|
|
416
598
|
* Server the same way it reaches github.com.
|
|
417
599
|
*
|
|
600
|
+
* The credential is resolved and forwarded the same way too (chant #2320),
|
|
601
|
+
* through {@link commentTokenFrom} and out as `GH_TOKEN` on every call. It
|
|
602
|
+
* did not used to be: `reconcilePr` handed this `(cmd) => execAsync(cmd, {
|
|
603
|
+
* signal })` with no `env` at all, so `CHANT_FORGEJO_TOKEN` never reached
|
|
604
|
+
* `gh` and the one case that variable exists for — posting to a Forgejo
|
|
605
|
+
* instance other than the one the job runs on, where `github.token`'s scope
|
|
606
|
+
* stops at its own instance — failed with a 401 or a 404 that named nothing.
|
|
607
|
+
* With no token at all the operator got `gh`'s own error rather than chant's.
|
|
608
|
+
* The refusal here is {@link noIssueTokenMessage}, not the `comment` mode's
|
|
609
|
+
* {@link noCommentTokenMessage}: same resolution order, same advice, but that
|
|
610
|
+
* message names a pull request this mode does not have, and this function is
|
|
611
|
+
* shared with `lexiconUpgrade`, which is not `reconcilePr` at all. The same
|
|
612
|
+
* split GitLab already makes between `noGitlabNoteTokenMessage` and
|
|
613
|
+
* `noGitlabIssueTokenMessage`.
|
|
614
|
+
*
|
|
418
615
|
* ## Why plain pagination instead of GitHub's Search API
|
|
419
616
|
*
|
|
420
617
|
* GitHub offers real server-side full-text search — `gh issue list --search`
|
|
@@ -428,9 +625,9 @@ export type GhExec = (cmd: string) => Promise<{ stdout: string; stderr: string }
|
|
|
428
625
|
* read-after-write the sticky recipe depends on — a false "not found"
|
|
429
626
|
* means a duplicate issue, which is the bug this activity exists to fix.
|
|
430
627
|
* 2. **The marker itself.** The hidden marker is deliberately punctuation-
|
|
431
|
-
* heavy (`<!-- chant-reconcile-issue:app -->`) so it renders
|
|
432
|
-
* GitHub's search tokenizer is not documented to preserve that
|
|
433
|
-
* through `in:body` matching, and a wrong query would either miss the
|
|
628
|
+
* heavy (`<!-- chant-reconcile-issue:nightly/app -->`) so it renders
|
|
629
|
+
* invisibly. GitHub's search tokenizer is not documented to preserve that
|
|
630
|
+
* shape through `in:body` matching, and a wrong query would either miss the
|
|
434
631
|
* marker (a duplicate, the same failure mode as above) or over-match and
|
|
435
632
|
* still need the same client-side `startswith` check this function
|
|
436
633
|
* already does — at which point the search bought nothing but risk.
|
|
@@ -441,7 +638,15 @@ export type GhExec = (cmd: string) => Promise<{ stdout: string; stderr: string }
|
|
|
441
638
|
* endpoint does carry a real `q` search parameter, but that is a
|
|
442
639
|
* different endpoint shape than GitHub's, which would mean forking this
|
|
443
640
|
* function by forge — exactly what the `comment` path (#2291)
|
|
444
|
-
* deliberately does not do).
|
|
641
|
+
* deliberately does not do). Chant #2315 confirmed this against a live
|
|
642
|
+
* call rather than only the spec: plain paginated `GET .../issues?state=
|
|
643
|
+
* open` on a real Forgejo 12.0.4+gitea-1.22.0 instance interleaves pull
|
|
644
|
+
* requests with issues exactly as GitHub's endpoint does, and the
|
|
645
|
+
* `.pull_request == null` filter below excludes them correctly. The write
|
|
646
|
+
* calls this function makes do not clear the same instance — see the
|
|
647
|
+
* module doc's `issue` bullet for why, and why the forgejo Op generator
|
|
648
|
+
* refuses this mode rather than generating a job that would 401 on every
|
|
649
|
+
* run.
|
|
445
650
|
*
|
|
446
651
|
* So this reuses the recipe `postOrUpdateComment` already proved for PR
|
|
447
652
|
* comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
|
|
@@ -479,13 +684,20 @@ export async function postOrUpdateGithubIssue(
|
|
|
479
684
|
body: string,
|
|
480
685
|
exec: GhExec,
|
|
481
686
|
): Promise<string> {
|
|
687
|
+
const token = commentTokenFrom(process.env);
|
|
688
|
+
if (!token) throw new Error(noIssueTokenMessage(repo));
|
|
689
|
+
const env = { ...process.env, GH_TOKEN: token.value };
|
|
690
|
+
|
|
482
691
|
const base = githubApiBaseFrom(process.env);
|
|
483
692
|
const listUrl = `${base}/repos/${repo}/issues?state=open`;
|
|
484
693
|
const jq =
|
|
485
694
|
'map(select((.pull_request == null) and ((.body // "") | startswith(' +
|
|
486
695
|
`"${marker}"))))` +
|
|
487
696
|
" | .[0].number // empty";
|
|
488
|
-
const { stdout: found } = await exec(
|
|
697
|
+
const { stdout: found } = await exec(
|
|
698
|
+
`gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
|
|
699
|
+
{ env },
|
|
700
|
+
);
|
|
489
701
|
// `--paginate` prints one `--jq` result per page, same as postOrUpdateComment.
|
|
490
702
|
const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
|
|
491
703
|
const titleField = shellQuote(`title=${title}`);
|
|
@@ -495,11 +707,13 @@ export async function postOrUpdateGithubIssue(
|
|
|
495
707
|
const { stdout } = await exec(
|
|
496
708
|
`gh api --method PATCH ${shellQuote(`${base}/repos/${repo}/issues/${existing}`)} ` +
|
|
497
709
|
`-f ${titleField} -f ${bodyField} --jq .html_url`,
|
|
710
|
+
{ env },
|
|
498
711
|
);
|
|
499
712
|
return stdout.trim();
|
|
500
713
|
}
|
|
501
714
|
const { stdout } = await exec(
|
|
502
715
|
`gh api --method POST ${shellQuote(`${base}/repos/${repo}/issues`)} -f ${titleField} -f ${bodyField} --jq .html_url`,
|
|
716
|
+
{ env },
|
|
503
717
|
);
|
|
504
718
|
return stdout.trim();
|
|
505
719
|
}
|
|
@@ -896,6 +1110,36 @@ async function derivePlanEntries(
|
|
|
896
1110
|
return entriesFromPlan(stdout);
|
|
897
1111
|
}
|
|
898
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* The marker the run will write and find its finding by, for the two modes
|
|
1115
|
+
* that have one, or `undefined` for the two that do not (#2319, and its
|
|
1116
|
+
* pre-merge review).
|
|
1117
|
+
*
|
|
1118
|
+
* Pure, and called before anything else in {@link reconcilePr} so that both
|
|
1119
|
+
* refusals it can raise — no identity, unusable marker — happen before the
|
|
1120
|
+
* activity shells out to anything.
|
|
1121
|
+
*
|
|
1122
|
+
* `issue` mode requires an identity: the Op's name, or a marker the caller
|
|
1123
|
+
* keeps unique. `comment` mode does not, because its marker is scoped to one
|
|
1124
|
+
* pull request rather than to the repository — see `ReconcilePrArgs.op` and
|
|
1125
|
+
* {@link issueMarker} for that asymmetry and what is tracked separately.
|
|
1126
|
+
* Both modes get the same {@link suppliedMarker} treatment of the field,
|
|
1127
|
+
* because a blank or unusable marker is a property of the field, not of the
|
|
1128
|
+
* mode reading it.
|
|
1129
|
+
*/
|
|
1130
|
+
function resolveMarker(mode: ReconcileMode, args: ReconcilePrArgs): string | undefined {
|
|
1131
|
+
if (mode === "issue") {
|
|
1132
|
+
const supplied = suppliedMarker(args.marker);
|
|
1133
|
+
const op = args.op?.trim();
|
|
1134
|
+
if (!supplied && !op) throw new Error(noIssueIdentityMessage(args.env));
|
|
1135
|
+
return supplied ?? issueMarker(op as string, args.env);
|
|
1136
|
+
}
|
|
1137
|
+
if (mode === "comment") {
|
|
1138
|
+
return suppliedMarker(args.marker) ?? commentMarker(args.env);
|
|
1139
|
+
}
|
|
1140
|
+
return undefined;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
899
1143
|
/**
|
|
900
1144
|
* Reconcile activity: turn regenerated TypeScript into a reviewable artifact.
|
|
901
1145
|
*
|
|
@@ -907,7 +1151,11 @@ async function derivePlanEntries(
|
|
|
907
1151
|
* CI job (any trigger — the point of this mode is a cron run that has no
|
|
908
1152
|
* merge request), the same recipe against one GitLab issue (#2292). Neither
|
|
909
1153
|
* branch ever closes the issue itself — see {@link postOrUpdateGithubIssue}
|
|
910
|
-
* for that decision and why.
|
|
1154
|
+
* for that decision and why. The marker names the Op as well as the env
|
|
1155
|
+
* (#2319), so the step needs `op` or an explicit `marker` and fails by name
|
|
1156
|
+
* with neither; an issue left over from the pre-#2319 env-only marker
|
|
1157
|
+
* matches no Op's marker now and is left where it is, unedited, rather than
|
|
1158
|
+
* adopted by whichever Op happens to run first.
|
|
911
1159
|
* - `comment` — post the body as one comment on the pull request that
|
|
912
1160
|
* triggered the run, editing that same comment on every re-run rather than
|
|
913
1161
|
* stacking a new one (#2231) — on GitHub and on Forgejo alike (#2291), the
|
|
@@ -931,6 +1179,18 @@ async function derivePlanEntries(
|
|
|
931
1179
|
export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal): Promise<ReconcileResult> {
|
|
932
1180
|
const mode = args.mode ?? "pull-request";
|
|
933
1181
|
const owned = args.owned ?? false;
|
|
1182
|
+
|
|
1183
|
+
// Resolved first, ahead of the `chant lifecycle plan` shell-out below
|
|
1184
|
+
// (#2319 pre-merge review). The identity refusal used to sit inside the
|
|
1185
|
+
// `issue` branch, which is after that derivation, so the arg shape that
|
|
1186
|
+
// actually reaches it — `{ env, op, mode, owned }`, `ReconcileOp`'s own,
|
|
1187
|
+
// with no `body` to short-circuit the plan — ran a shell command before
|
|
1188
|
+
// failing. `chant lifecycle plan` is read-only, so nothing was written; but
|
|
1189
|
+
// a plan that fails first hands the operator a plan error in place of the
|
|
1190
|
+
// named refusal this design rests on, which is the whole point of refusing
|
|
1191
|
+
// by name.
|
|
1192
|
+
const resolvedMarker = resolveMarker(mode, args);
|
|
1193
|
+
|
|
934
1194
|
// A caller-supplied body means the finding is already written, so there is
|
|
935
1195
|
// nothing for `chant lifecycle plan` to tell us (#2087).
|
|
936
1196
|
const entries = args.entries ?? (args.body !== undefined ? [] : await derivePlanEntries(args.env, owned, signal));
|
|
@@ -942,7 +1202,9 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
942
1202
|
}
|
|
943
1203
|
|
|
944
1204
|
if (mode === "issue") {
|
|
945
|
-
|
|
1205
|
+
// Non-null because `resolveMarker` returns a string for this mode or
|
|
1206
|
+
// throws, and it already ran at the top of the function.
|
|
1207
|
+
const marker = resolvedMarker!;
|
|
946
1208
|
|
|
947
1209
|
// GitLab first, because its check is the narrow one: `CI_PROJECT_ID` is
|
|
948
1210
|
// set on every GitLab CI job, and nothing outside GitLab CI sets it
|
|
@@ -961,8 +1223,8 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
961
1223
|
// the close decision.
|
|
962
1224
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
963
1225
|
if (repo) {
|
|
964
|
-
const issueUrl = await postOrUpdateGithubIssue(repo, marker, title, summary, (cmd) =>
|
|
965
|
-
execAsync(cmd, { signal }),
|
|
1226
|
+
const issueUrl = await postOrUpdateGithubIssue(repo, marker, title, summary, (cmd, opts) =>
|
|
1227
|
+
execAsync(cmd, { signal, ...opts }),
|
|
966
1228
|
);
|
|
967
1229
|
return { mode, summary, entries, issueUrl };
|
|
968
1230
|
}
|
|
@@ -972,6 +1234,17 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
972
1234
|
// marker is still written here — it costs nothing, and means a later CI
|
|
973
1235
|
// run of the same Op finds and edits this issue instead of opening a
|
|
974
1236
|
// second one.
|
|
1237
|
+
//
|
|
1238
|
+
// This is the one path that does NOT go through `commentTokenFrom`
|
|
1239
|
+
// (#2320), deliberately. It is reached only when the run is outside every
|
|
1240
|
+
// CI job chant recognizes, which is where `gh auth login`'s stored
|
|
1241
|
+
// credential is the credential — and that is a login `commentTokenFrom`
|
|
1242
|
+
// cannot see, so refusing on a missing environment variable here would
|
|
1243
|
+
// reject the exact setup the branch exists to serve. `gh` still reads
|
|
1244
|
+
// GH_TOKEN and GITHUB_TOKEN off this process's own environment if they
|
|
1245
|
+
// are set; what it does not get is CHANT_FORGEJO_TOKEN promoted into
|
|
1246
|
+
// GH_TOKEN, because promoting it would silently outrank the login the
|
|
1247
|
+
// operator is standing in front of.
|
|
975
1248
|
const { stdout } = await execAsync(
|
|
976
1249
|
`gh issue create --title ${shellQuote(title)} --body ${shellQuote(`${marker}\n\n${summary}`)}`,
|
|
977
1250
|
{ signal },
|
|
@@ -983,7 +1256,7 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
983
1256
|
// The trigger context is read here rather than passed in: a step's args
|
|
984
1257
|
// are serialized at build time, and the pull request is not known until
|
|
985
1258
|
// the run. Missing context is fatal — see `noPullRequestContextMessage`.
|
|
986
|
-
const marker =
|
|
1259
|
+
const marker = resolvedMarker!;
|
|
987
1260
|
|
|
988
1261
|
// GitLab first, because its check is the narrow one: only a
|
|
989
1262
|
// `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID` (#2256), so
|
package/src/op/builders.ts
CHANGED
|
@@ -107,16 +107,22 @@ export function activity(
|
|
|
107
107
|
* that reaches this step with no resolution newer than its pending fact
|
|
108
108
|
* records the pending fact and ends `gated`. `chant approve <op> <gate>`
|
|
109
109
|
* writes the resolution, and the next run walks through carrying the approver.
|
|
110
|
+
*
|
|
111
|
+
* Pass `plan` to bind the approval to a specific plan (#2300) — normally the
|
|
112
|
+
* Plan phase's own digest, `plan.out.planDigest`. Then a resolution counts
|
|
113
|
+
* only for that plan, and a run whose fresh plan differs is refused by name
|
|
114
|
+
* instead of applying a change nobody approved. See {@link GateStep.plan}.
|
|
110
115
|
*/
|
|
111
116
|
export function gate(
|
|
112
117
|
name: string,
|
|
113
|
-
opts?: { timeout?: string; description?: string },
|
|
118
|
+
opts?: { timeout?: string; description?: string; plan?: GateStep["plan"] },
|
|
114
119
|
): GateStep {
|
|
115
120
|
return {
|
|
116
121
|
kind: "gate",
|
|
117
122
|
gate: name,
|
|
118
123
|
...(opts?.timeout ? { timeout: opts.timeout } : {}),
|
|
119
124
|
...(opts?.description ? { description: opts.description } : {}),
|
|
125
|
+
...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
|
|
120
126
|
};
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* approve <op> <gate>` line to run. Approve, run again, and the same Op
|
|
20
20
|
* converges — no durable workflow engine is involved.
|
|
21
21
|
*
|
|
22
|
+
* The gate binds the plan (#2300). The Plan phase's `lifecycleDiff` publishes
|
|
23
|
+
* a digest of its change set, the gate step references it, and a resolution
|
|
24
|
+
* counts only for that digest. Approve, change the declared source, re-run,
|
|
25
|
+
* and the run stops again with both digests named rather than applying a
|
|
26
|
+
* change set the approver never saw.
|
|
27
|
+
*
|
|
22
28
|
* @example
|
|
23
29
|
* ```typescript
|
|
24
30
|
* // additive, local executor
|
|
@@ -38,6 +44,7 @@
|
|
|
38
44
|
*/
|
|
39
45
|
|
|
40
46
|
import { Op, phase, activity, gate } from "../builders";
|
|
47
|
+
import { stepOutput } from "../step-output-ref";
|
|
41
48
|
import { gateName } from "../gate-name";
|
|
42
49
|
import type { OpResource } from "../resource";
|
|
43
50
|
import { defaultOutput, hasNativeRollback, type ApplyTarget, type DeleteMode } from "../activities/apply";
|
|
@@ -129,6 +136,10 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
|
|
|
129
136
|
phase("Plan", [
|
|
130
137
|
{
|
|
131
138
|
kind: "activity" as const,
|
|
139
|
+
// #2300: the gate below references this step's digest, and a
|
|
140
|
+
// reference needs an id. `plan` rather than `diff`, because what the
|
|
141
|
+
// Approve phase binds to is the plan this step produced.
|
|
142
|
+
id: "plan",
|
|
132
143
|
fn: "lifecycleDiff",
|
|
133
144
|
args: { env: config.env, live: true },
|
|
134
145
|
outcomeAttribute: { name: "Drift", from: "drifted" },
|
|
@@ -141,6 +152,11 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
|
|
|
141
152
|
phase("Approve", [
|
|
142
153
|
gate(gateName(config.gate ?? {}) || `approve-${config.name}`, {
|
|
143
154
|
...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
|
|
155
|
+
// #2300: the approval is for the change set the Plan phase just
|
|
156
|
+
// produced. Edit the source between approving and re-running and
|
|
157
|
+
// the gate refuses, naming the approved digest and the planned one,
|
|
158
|
+
// instead of applying what nobody read.
|
|
159
|
+
plan: stepOutput("plan", "planDigest"),
|
|
144
160
|
description:
|
|
145
161
|
config.gate?.description ??
|
|
146
162
|
`Approve apply to ${config.env} (delete mode: ${deleteMode}` +
|
|
@@ -12,6 +12,7 @@ import { ReconcileOp } from "./reconcile-op";
|
|
|
12
12
|
import { ApplyOp } from "./apply-op";
|
|
13
13
|
import { DECLARABLE_MARKER } from "../../declarable";
|
|
14
14
|
import { EffectReceipt, receiptExpectation } from "../../effect-receipt";
|
|
15
|
+
import { isStepOutputRef } from "../step-output-ref";
|
|
15
16
|
|
|
16
17
|
function getProps(entity: unknown): Record<string, unknown> {
|
|
17
18
|
return (entity as { props: Record<string, unknown> }).props;
|
|
@@ -115,14 +116,14 @@ describe("ReconcileOp: configuration", () => {
|
|
|
115
116
|
expect(phases.map((p) => p.name)).toEqual(["Snapshot", "Plan", "Reconcile"]);
|
|
116
117
|
const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
|
|
117
118
|
expect(reconcileStep.fn).toBe("reconcilePr");
|
|
118
|
-
expect(reconcileStep.args).toEqual({ env: "prod", mode: "pull-request", owned: false });
|
|
119
|
+
expect(reconcileStep.args).toEqual({ env: "prod", op: "p", mode: "pull-request", owned: false });
|
|
119
120
|
});
|
|
120
121
|
|
|
121
122
|
test("scope.owned + onDrift flow into the reconcilePr step", () => {
|
|
122
123
|
const { op } = ReconcileOp({ name: "p", env: "prod", onDrift: "issue", scope: { owned: true } });
|
|
123
124
|
const phases = getProps(op).phases as Array<Record<string, unknown>>;
|
|
124
125
|
const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
|
|
125
|
-
expect(reconcileStep.args).toEqual({ env: "prod", mode: "issue", owned: true });
|
|
126
|
+
expect(reconcileStep.args).toEqual({ env: "prod", op: "p", mode: "issue", owned: true });
|
|
126
127
|
});
|
|
127
128
|
|
|
128
129
|
test("labels include Reconcile + Env", () => {
|
|
@@ -204,6 +205,18 @@ describe("ApplyOp: gating + deletes", () => {
|
|
|
204
205
|
const { op } = ApplyOp({ name: "p", env: "prod" });
|
|
205
206
|
expect(getProps(op).labels).toEqual({ Apply: "true", Env: "prod" });
|
|
206
207
|
});
|
|
208
|
+
|
|
209
|
+
// #2300 — the gate approves the change set the Plan phase produced, not the
|
|
210
|
+
// next run of the Op. The Plan step carries the id the reference needs.
|
|
211
|
+
test("the gate binds the Plan phase's own change-set digest", () => {
|
|
212
|
+
const { op } = ApplyOp({ name: "p", env: "prod", delete: "gated" });
|
|
213
|
+
const phases = getProps(op).phases as Array<Record<string, unknown>>;
|
|
214
|
+
const planStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
|
|
215
|
+
expect(planStep.id).toBe("plan");
|
|
216
|
+
const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
|
|
217
|
+
expect(isStepOutputRef(gateStep.plan)).toBe(true);
|
|
218
|
+
expect(gateStep.plan).toMatchObject({ step: "plan", path: "planDigest" });
|
|
219
|
+
});
|
|
207
220
|
});
|
|
208
221
|
|
|
209
222
|
describe("ApplyOp: compensation (#125, total-or-refused in #1449)", () => {
|
|
@@ -34,6 +34,24 @@ describe("ReconcileOp composite — PR/issue URL outcome (#8)", () => {
|
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
describe("ReconcileOp composite — the finding step names its Op (#2319)", () => {
|
|
38
|
+
test("passes the Op's own name as `op`, which is what keeps the issue marker unique", () => {
|
|
39
|
+
const { op } = ReconcileOp({ name: "prod-reconcile", env: "prod", onDrift: "issue" });
|
|
40
|
+
expect((reconcileStep(op).args as { op: string }).op).toBe("prod-reconcile");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("two Ops over one env carry two identities", () => {
|
|
44
|
+
// The collision #2319 reports: `env` alone is shared here by construction,
|
|
45
|
+
// and a `TerraformWatchOp` over a root named "prod" would share it too.
|
|
46
|
+
const a = ReconcileOp({ name: "prod-reconcile", env: "prod", onDrift: "issue" });
|
|
47
|
+
const b = ReconcileOp({ name: "prod-reconcile-owned", env: "prod", onDrift: "issue" });
|
|
48
|
+
const argsA = reconcileStep(a.op).args as { op: string; env: string };
|
|
49
|
+
const argsB = reconcileStep(b.op).args as { op: string; env: string };
|
|
50
|
+
expect(argsA.env).toBe(argsB.env);
|
|
51
|
+
expect(argsA.op).not.toBe(argsB.op);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
37
55
|
describe("ReconcileOp composite — cadence on the op (#2120)", () => {
|
|
38
56
|
function opProps(op: unknown): Record<string, unknown> {
|
|
39
57
|
return (op as { props: Record<string, unknown> }).props;
|
|
@@ -96,10 +96,16 @@ export function ReconcileOp(config: ReconcileOpConfig): ReconcileOpResources {
|
|
|
96
96
|
// regenerates via `chant import --from`, and opens a PR. Surface the
|
|
97
97
|
// opened PR/issue URL as an outcome so `chant run` prints it — the
|
|
98
98
|
// reconcile's result is the link.
|
|
99
|
+
//
|
|
100
|
+
// `op` names this Op in the marker `issue` mode's sticky issue is
|
|
101
|
+
// found by (#2319). The env alone is not unique across Ops: it shares
|
|
102
|
+
// a marker namespace with `TerraformWatchOp`, which passes a terraform
|
|
103
|
+
// *root* as its `env`, so a chant environment and a root that happen
|
|
104
|
+
// to share a name resolved to one marker and overwrote each other.
|
|
99
105
|
{
|
|
100
106
|
kind: "activity" as const,
|
|
101
107
|
fn: "reconcilePr",
|
|
102
|
-
args: { env: config.env, mode: onDrift, owned },
|
|
108
|
+
args: { env: config.env, op: config.name, mode: onDrift, owned },
|
|
103
109
|
...(reconcileOutcome ? { outcomeAttribute: reconcileOutcome } : {}),
|
|
104
110
|
},
|
|
105
111
|
]),
|