@intentius/chant 0.64.0 → 0.66.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.
Files changed (66) hide show
  1. package/dist/behaviour-delta.d.ts +181 -0
  2. package/dist/behaviour-delta.d.ts.map +1 -0
  3. package/dist/behaviour-http.d.ts +106 -0
  4. package/dist/behaviour-http.d.ts.map +1 -0
  5. package/dist/behaviour-overlay.d.ts +61 -0
  6. package/dist/behaviour-overlay.d.ts.map +1 -0
  7. package/dist/behaviour.d.ts +1174 -0
  8. package/dist/behaviour.d.ts.map +1 -0
  9. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  10. package/dist/identity.d.ts +28 -0
  11. package/dist/identity.d.ts.map +1 -1
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +45 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/scenario-eval.d.ts +23 -5
  17. package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
  18. package/dist/lifecycle/scenario.d.ts +45 -3
  19. package/dist/lifecycle/scenario.d.ts.map +1 -1
  20. package/dist/lifecycle/types.d.ts +17 -0
  21. package/dist/lifecycle/types.d.ts.map +1 -1
  22. package/dist/op/activities/activity-contracts.d.ts +42 -0
  23. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  24. package/dist/op/activities/index.d.ts +2 -0
  25. package/dist/op/activities/index.d.ts.map +1 -1
  26. package/dist/op/activities/predict-behaviour.d.ts +207 -0
  27. package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
  28. package/dist/op/activities/reconcile.d.ts +86 -16
  29. package/dist/op/activities/reconcile.d.ts.map +1 -1
  30. package/dist/op/composites/behaviour-op.d.ts +57 -0
  31. package/dist/op/composites/behaviour-op.d.ts.map +1 -0
  32. package/dist/op/composites/index.d.ts +2 -0
  33. package/dist/op/composites/index.d.ts.map +1 -1
  34. package/dist/op/index.d.ts +2 -2
  35. package/dist/op/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/behaviour-delta.test.ts +331 -0
  38. package/src/behaviour-delta.ts +564 -0
  39. package/src/behaviour-http.test.ts +456 -0
  40. package/src/behaviour-http.ts +252 -0
  41. package/src/behaviour-overlay.test.ts +149 -0
  42. package/src/behaviour-overlay.ts +76 -0
  43. package/src/behaviour.test.ts +2011 -0
  44. package/src/behaviour.ts +2127 -0
  45. package/src/cli/handlers/scenario.test.ts +108 -0
  46. package/src/cli/handlers/scenario.ts +63 -16
  47. package/src/fold/subset-doc-parity.test.ts +60 -0
  48. package/src/identity.ts +31 -2
  49. package/src/index.ts +3 -0
  50. package/src/lexicon.ts +46 -0
  51. package/src/lifecycle/scenario-cost.test.ts +183 -0
  52. package/src/lifecycle/scenario-eval.ts +133 -6
  53. package/src/lifecycle/scenario.ts +72 -4
  54. package/src/lifecycle/types.ts +17 -0
  55. package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
  56. package/src/op/activities/activity-contracts.ts +49 -0
  57. package/src/op/activities/index.ts +24 -0
  58. package/src/op/activities/predict-behaviour.test.ts +255 -0
  59. package/src/op/activities/predict-behaviour.ts +468 -0
  60. package/src/op/activities/reconcile.test.ts +84 -0
  61. package/src/op/activities/reconcile.ts +97 -24
  62. package/src/op/activity-contract-registry.test.ts +3 -0
  63. package/src/op/composites/behaviour-op.test.ts +56 -0
  64. package/src/op/composites/behaviour-op.ts +99 -0
  65. package/src/op/composites/index.ts +2 -0
  66. package/src/op/index.ts +2 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The `predictBehaviour` activity and the pull-request finding built on it
3
+ * (#2358, epic #2355).
4
+ *
5
+ * Two activities, because they answer two questions:
6
+ *
7
+ * - {@link predictBehaviour} asks the project's predicting lexicon what the
8
+ * declared estate would do at a stated traffic level, and returns the
9
+ * {@link BehaviourResult} as the contract defines it — a report, or a named
10
+ * refusal. It builds the project in-process, assembles the request from the
11
+ * build the way `lifecycle plan` assembles a deep read's, and hands it to
12
+ * the one configured lexicon that implements the fourth observation method.
13
+ * `isBehaviourRefusalReport` is the branch every consumer of its result
14
+ * takes first.
15
+ * - {@link behaviourFinding} runs that prediction twice — once on the
16
+ * checkout the run is in (the pull request's head) and once on the base
17
+ * branch it targets — differences the two under the rules in
18
+ * `../../behaviour-delta.ts`, and posts the finding in `comment` mode
19
+ * through {@link reconcilePr}. Same sticky comment on GitHub and Forgejo,
20
+ * same merge-request note on GitLab, same marker recipe: nothing here
21
+ * posts anything itself.
22
+ *
23
+ * ## The base side is a checkout, not a snapshot
24
+ *
25
+ * The finding is declared-versus-declared: the graph the pull request
26
+ * proposes against the graph its base branch already holds. Both sides are
27
+ * built from source, so the base branch is checked out into a detached git
28
+ * worktree under the repository's own root (module resolution walks up from
29
+ * there to the same `node_modules` the head build uses) and removed again
30
+ * whether the prediction succeeded or not. A shallow CI clone does not carry
31
+ * the base branch, so it is fetched at depth one first — the same reason
32
+ * `converge.ts` fetches `chant/lifecycle` before reading it.
33
+ *
34
+ * The base branch name comes off the run's own event, the way the pull
35
+ * request itself does in `reconcile.ts`: `GITHUB_BASE_REF` on a GitHub
36
+ * Actions or Forgejo Actions `pull_request` job, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`
37
+ * on a GitLab `merge_request_event` pipeline. A run with neither and no
38
+ * explicit `base` fails by name rather than guessing `main`.
39
+ *
40
+ * ## What the marker names
41
+ *
42
+ * The comment's hidden marker names the Op and the env together
43
+ * ({@link behaviourFindingMarker}), for #2319's reason: a `comment`-mode
44
+ * `reconcilePr` step on the same pull request keys its marker on the env
45
+ * alone, and a behaviour finding over `prod` sharing a marker with a plan
46
+ * finding over `prod` would edit the wrong comment on every push.
47
+ *
48
+ * ## Edge coverage on the declared path
49
+ *
50
+ * `buildGraphIr` produces reference edges and is exhaustive for them. It
51
+ * produces no containment — a subnet's membership of a VPC is not a reference
52
+ * — and #2360's third comment records that nothing on the declared path does
53
+ * yet. So the request never claims `complete`: that would be a true claim
54
+ * about references and a false one about the graph. It cannot honestly claim
55
+ * `partial` either, because `partial` has to name a gap as `dangling` or
56
+ * `unresolvedKinds`, and the gap here is neither — every reference resolved,
57
+ * and the generic builder has no vocabulary for "this kind is a boundary
58
+ * whose containment is missing" (augur's own fixture names its boundary
59
+ * kinds by hand, which is lexicon knowledge core does not have). An earlier
60
+ * draft named every kind no reference touched, and named a queue nobody
61
+ * references as "unresolved", which it is not. So the claim is `unknown`,
62
+ * which the contract tells a consumer to treat exactly as it treats
63
+ * `partial`, and the finding shows each side's coverage and does not compare
64
+ * resilience verdicts across it — see `renderBehaviourFinding`.
65
+ */
66
+
67
+ import { exec } from "node:child_process";
68
+ import { mkdtemp, rm } from "node:fs/promises";
69
+ import { join, resolve } from "node:path";
70
+ import { promisify } from "node:util";
71
+ import {
72
+ isBehaviourRefusalReport,
73
+ type BehaviourEdgeCoverage,
74
+ type BehaviourResult,
75
+ type PredictBehaviourOptions,
76
+ } from "../../behaviour";
77
+ import {
78
+ behaviourDelta,
79
+ renderBehaviourFinding,
80
+ validateBehaviourResult,
81
+ type BehaviourDelta,
82
+ } from "../../behaviour-delta";
83
+ import type { LexiconPlugin } from "../../lexicon";
84
+ import type { SerializerResult } from "../../serializer";
85
+ import { markerSlug, reconcilePr, suppliedMarker, type ReconcileResult } from "./reconcile";
86
+
87
+ const execAsync = promisify(exec);
88
+
89
+ function shellQuote(s: string): string {
90
+ return `'${s.replace(/'/g, "'\\''")}'`;
91
+ }
92
+
93
+ /* -------------------------------------------------------------------------- */
94
+ /* predictBehaviour */
95
+ /* -------------------------------------------------------------------------- */
96
+
97
+ export interface PredictBehaviourArgs {
98
+ /** The environment the prediction is for, mirroring the deep read's `environment`. */
99
+ environment: string;
100
+ /** The traffic level to predict at, verbatim: `100 rps, p50`. Never parsed here. */
101
+ traffic: string;
102
+ /** Deployed stack, for a multi-stack project. */
103
+ stack?: string;
104
+ /** Region the stack is deployed in. */
105
+ region?: string;
106
+ /** Restrict to chant-owned resources; a withheld entity is `filtered`, not absent. */
107
+ owned?: boolean;
108
+ }
109
+
110
+ /** What the activity says when no configured lexicon implements the fourth method. */
111
+ export function noPredictingLexiconMessage(lexicons: readonly string[]): string {
112
+ const configured = lexicons.length > 0 ? lexicons.join(", ") : "(none)";
113
+ return (
114
+ "predictBehaviour has the estate to predict and no lexicon to predict it with: none of the configured " +
115
+ `lexicons (${configured}) implements predictBehaviour(). Add augur to \`lexicons\` in chant.config.ts — it ` +
116
+ "reads whatever the other lexicons declare — and set CHANT_BEHAVIOUR_ENGINE to the engine's address."
117
+ );
118
+ }
119
+
120
+ /** What the activity says when more than one configured lexicon predicts. */
121
+ export function ambiguousPredictorMessage(names: readonly string[]): string {
122
+ return (
123
+ `predictBehaviour found ${names.length} configured lexicons that implement predictBehaviour() (${names.join(", ")}) ` +
124
+ "and predicts with one. Two engines pricing one estate is a merge this activity does not do, because an entity " +
125
+ "priced twice has two provenances and one row; configure one predicting lexicon."
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The declared path's honest coverage claim. See the module doc: reference
131
+ * edges are exhaustive and containment is absent, and the contract has a
132
+ * field for a missing reference and none for missing containment, so the
133
+ * claim is `unknown`. Never `complete`, and not `partial` naming a kind the
134
+ * generic builder has no basis to name. A function rather than a constant so
135
+ * the day the declared path produces containment edges (#2360), the claim
136
+ * changes here and nowhere else.
137
+ */
138
+ export function declaredEdgeCoverage(): BehaviourEdgeCoverage {
139
+ return { verdict: "unknown" };
140
+ }
141
+
142
+ /**
143
+ * Predict the estate declared under `projectPath`. The in-process half of
144
+ * {@link predictBehaviour}, taking the project root explicitly so the
145
+ * finding can run it on the base checkout too.
146
+ *
147
+ * Imports are dynamic so this module carries no load-time edge into the CLI
148
+ * or the build: `cli/plugins` reaches back into the root index, and the
149
+ * activity registry imports this module statically.
150
+ */
151
+ export async function predictDeclared(projectPath: string, args: PredictBehaviourArgs): Promise<BehaviourResult> {
152
+ const { loadChantConfigUpward } = await import("../../config");
153
+ const { loadPlugins, resolveProjectLexicons } = await import("../../cli/plugins");
154
+ const { build } = await import("../../build");
155
+ const { buildGraphIr } = await import("../../graph-ir");
156
+
157
+ const { config } = await loadChantConfigUpward(projectPath);
158
+ const lexicons = await resolveProjectLexicons(projectPath);
159
+ const plugins = (await loadPlugins(lexicons)) as LexiconPlugin[];
160
+ const predictors = plugins.filter((p) => typeof p.predictBehaviour === "function");
161
+ if (predictors.length === 0) throw new Error(noPredictingLexiconMessage(lexicons));
162
+ if (predictors.length > 1) throw new Error(ambiguousPredictorMessage(predictors.map((p) => p.name)));
163
+ const [predictor] = predictors;
164
+
165
+ const sourceDir = resolve(projectPath, config.sourceDir ?? ".");
166
+ const result = await build(sourceDir, plugins.map((p) => p.serializer));
167
+ if (result.errors.length > 0) {
168
+ const messages = result.errors.map((e) => (typeof e === "string" ? e : (e as { message?: string }).message ?? String(e)));
169
+ throw new Error(`predictBehaviour: the project under ${projectPath} did not build: ${messages.join("; ")}`);
170
+ }
171
+
172
+ // Every declared entity with a type, the way augur's own request fixture
173
+ // assembles it: the predicting lexicon decides what reaches the engine and
174
+ // what is declared unmapped, and it can only decide about what it is
175
+ // handed. Filtering here to "resources" would silently drop the kinds the
176
+ // coverage table exists to name.
177
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
178
+ for (const [name, entity] of result.entities) {
179
+ const declarable = entity as { entityType?: unknown; props?: unknown };
180
+ if (typeof declarable.entityType !== "string") continue;
181
+ entities.set(name, {
182
+ entityType: declarable.entityType,
183
+ props: (declarable.props != null ? declarable.props : {}) as Record<string, unknown>,
184
+ });
185
+ }
186
+ const edges = buildGraphIr(result.entities, sourceDir).edges;
187
+
188
+ // The predicting lexicon's own serialized output, as the deep read is handed
189
+ // its lexicon's — a string, not a path (cli/handlers/lifecycle.ts). augur
190
+ // reads nothing off it; the mirror is kept so a lexicon that does gets what
191
+ // the other three methods get.
192
+ const raw = result.outputs.get(predictor.name);
193
+ const buildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
194
+
195
+ const options: PredictBehaviourOptions = {
196
+ environment: args.environment,
197
+ buildOutput,
198
+ entityNames: [...entities.keys()],
199
+ entities,
200
+ ...(args.stack ? { stack: args.stack } : {}),
201
+ ...(args.region ? { region: args.region } : {}),
202
+ ...(args.owned !== undefined ? { owned: args.owned } : {}),
203
+ traffic: args.traffic,
204
+ edges,
205
+ edgeCoverage: declaredEdgeCoverage(),
206
+ };
207
+ const answer = await predictor.predictBehaviour!(options);
208
+ // On arrival, with the names that were asked — `behaviourReport` checks the
209
+ // ordinary route and says a consumer that needs the guarantee checks again.
210
+ return validateBehaviourResult(answer, options.entityNames);
211
+ }
212
+
213
+ /**
214
+ * Predict the declared estate in the working directory at `args.traffic`.
215
+ * Returns the contract's result as it is; a refusal is a result, not a
216
+ * thrown error, and `isBehaviourRefusalReport` is the branch to take first.
217
+ * Uses the `fastIdempotent` profile: the build is offline and the engine call
218
+ * is read-only.
219
+ */
220
+ export async function predictBehaviour(args: PredictBehaviourArgs): Promise<BehaviourResult> {
221
+ return predictDeclared(resolve("."), args);
222
+ }
223
+
224
+ /* -------------------------------------------------------------------------- */
225
+ /* behaviourFinding */
226
+ /* -------------------------------------------------------------------------- */
227
+
228
+ /** How the finding leaves the run. Only the two modes a pull request can carry. */
229
+ export type BehaviourFindingMode = "comment" | "report";
230
+
231
+ export interface BehaviourFindingArgs extends PredictBehaviourArgs {
232
+ /**
233
+ * The name of the Op this step belongs to — it keys the sticky comment's
234
+ * marker together with `environment` ({@link behaviourFindingMarker}), so
235
+ * two Ops over one env own two comments. Required for the same reason
236
+ * `reconcilePr`'s `issue` mode requires it (#2319).
237
+ */
238
+ op: string;
239
+ /** `comment` posts on the triggering pull or merge request; `report` returns the body only. Default: `comment`. */
240
+ mode?: BehaviourFindingMode;
241
+ /**
242
+ * The base branch to predict the other side from. Read off the run's own
243
+ * event when omitted — `GITHUB_BASE_REF`, then `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`
244
+ * — and refused by name when neither is set.
245
+ */
246
+ base?: string;
247
+ /** Comment title, unused on a sticky comment but carried for `reconcilePr`'s result. */
248
+ title?: string;
249
+ }
250
+
251
+ export interface BehaviourFindingResult {
252
+ mode: BehaviourFindingMode;
253
+ /** The base branch the other side was predicted from. */
254
+ base: string;
255
+ /** What the head side was called in the finding. */
256
+ head: string;
257
+ /** The structured delta, before rendering. */
258
+ finding: BehaviourDelta;
259
+ /** True when either side refused, so the finding says "no prediction". */
260
+ refused: boolean;
261
+ /** The rendered Markdown, whether or not it was posted. */
262
+ summary: string;
263
+ /** The posted or updated comment / note URL (comment mode). */
264
+ commentUrl?: string;
265
+ /** `owner/repo#number` (comment mode, GitHub and Forgejo). */
266
+ pullRequest?: string;
267
+ /** `group/project!iid` (comment mode, GitLab). */
268
+ mergeRequest?: string;
269
+ }
270
+
271
+ /**
272
+ * The hidden marker a behaviour finding is found by across re-runs. Keyed on
273
+ * the Op *and* the env, both slugified the same way `reconcilePr`'s markers
274
+ * are, and distinct from both of those by its prefix: a `comment`-mode
275
+ * `reconcilePr` step and this step on one pull request must never match each
276
+ * other's comment.
277
+ */
278
+ export function behaviourFindingMarker(op: string, env: string): string {
279
+ return `<!-- chant-behaviour:${markerSlug(op)}/${markerSlug(env)} -->`;
280
+ }
281
+
282
+ /** What the finding says when the run names no base branch to predict against. */
283
+ export function noBaseRefMessage(): string {
284
+ return (
285
+ "behaviourFinding predicts the pull request's declared estate against its base branch's, and this run " +
286
+ "names no base branch. On GitHub Actions and Forgejo Actions a pull_request event sets GITHUB_BASE_REF; on " +
287
+ "GitLab CI a merge_request_event pipeline sets CI_MERGE_REQUEST_TARGET_BRANCH_NAME. Trigger the Op from " +
288
+ "one of those, or pass `base` with the branch name."
289
+ );
290
+ }
291
+
292
+ /** Where the base branch name comes from, most specific first. Pure — exported for testing. */
293
+ export function baseRefFrom(
294
+ env: Record<string, string | undefined>,
295
+ explicit?: string,
296
+ ): string | undefined {
297
+ const fromArgs = explicit?.trim();
298
+ if (fromArgs) return fromArgs;
299
+ for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
300
+ const value = env[source]?.trim();
301
+ if (value) return value;
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
307
+ export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
308
+ for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
309
+ const value = env[source]?.trim();
310
+ if (value) return value;
311
+ }
312
+ return shortSha?.trim() || "head";
313
+ }
314
+
315
+ /** A checked-out base branch: where its project root is, and how to remove it. */
316
+ export interface BaseCheckout {
317
+ /** The base branch's counterpart of the working directory. */
318
+ projectPath: string;
319
+ cleanup(): Promise<void>;
320
+ }
321
+
322
+ /**
323
+ * Check `base` out into a detached worktree beside the repository. Fetches it
324
+ * at depth one first, because a CI clone carries only the pull request's own
325
+ * ref; a branch already present locally (a full clone, a developer's own
326
+ * checkout) is used as it stands after the fetch fails.
327
+ */
328
+ export async function checkoutBase(base: string, signal?: AbortSignal): Promise<BaseCheckout> {
329
+ const { stdout: rootOut } = await execAsync("git rev-parse --show-toplevel", { signal });
330
+ const { stdout: prefixOut } = await execAsync("git rev-parse --show-prefix", { signal });
331
+ const root = rootOut.trim();
332
+ const prefix = prefixOut.trim();
333
+
334
+ let commitish = "FETCH_HEAD";
335
+ try {
336
+ await execAsync(`git fetch --depth=1 origin ${shellQuote(base)}`, { signal });
337
+ } catch {
338
+ // No remote, or no such branch there: a local branch of that name is
339
+ // the only other thing `base` can honestly mean.
340
+ commitish = base;
341
+ }
342
+
343
+ const dir = await mkdtemp(join(root, ".chant-behaviour-base-"));
344
+ try {
345
+ await execAsync(`git worktree add --detach ${shellQuote(dir)} ${shellQuote(commitish)}`, { signal });
346
+ } catch (err) {
347
+ await rm(dir, { recursive: true, force: true });
348
+ throw new Error(
349
+ `behaviourFinding could not check out base branch ${JSON.stringify(base)}: ${(err as Error).message}`,
350
+ );
351
+ }
352
+ return {
353
+ projectPath: prefix ? join(dir, prefix) : dir,
354
+ async cleanup() {
355
+ try {
356
+ await execAsync(`git worktree remove --force ${shellQuote(dir)}`);
357
+ } catch {
358
+ await rm(dir, { recursive: true, force: true });
359
+ }
360
+ },
361
+ };
362
+ }
363
+
364
+ /** What {@link createBehaviourFinding} takes instead of reaching for the process. */
365
+ export interface BehaviourFindingDeps {
366
+ /** Predict one project root. Default: {@link predictDeclared}. */
367
+ predict?: (projectPath: string, args: PredictBehaviourArgs) => Promise<BehaviourResult>;
368
+ /** Check the base branch out. Default: {@link checkoutBase}. */
369
+ checkout?: (base: string, signal?: AbortSignal) => Promise<BaseCheckout>;
370
+ /** Post the finding. Default: {@link reconcilePr}, whose `comment` mode does the forge split. */
371
+ post?: typeof reconcilePr;
372
+ /** The environment the base and head refs are read from. Default: the process's. */
373
+ env?: Record<string, string | undefined>;
374
+ /** The short commit the head side is named by when the run names no branch. Default: `git rev-parse --short HEAD`. */
375
+ shortSha?: () => Promise<string | undefined>;
376
+ }
377
+
378
+ async function defaultShortSha(): Promise<string | undefined> {
379
+ try {
380
+ const { stdout } = await execAsync("git rev-parse --short HEAD");
381
+ return stdout.trim();
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Build the finding activity, with its four side effects injected so a test
389
+ * can drive the whole thing — predict twice, difference, render, post —
390
+ * against fixtures and a stubbed poster.
391
+ */
392
+ export function createBehaviourFinding(
393
+ deps: BehaviourFindingDeps = {},
394
+ ): (args: BehaviourFindingArgs, signal?: AbortSignal) => Promise<BehaviourFindingResult> {
395
+ const predict = deps.predict ?? predictDeclared;
396
+ const checkout = deps.checkout ?? checkoutBase;
397
+ const post = deps.post ?? reconcilePr;
398
+ const env = deps.env ?? process.env;
399
+ const shortSha = deps.shortSha ?? defaultShortSha;
400
+
401
+ return async function behaviourFinding(args, signal) {
402
+ const mode: BehaviourFindingMode = args.mode ?? "comment";
403
+ if (typeof args.op !== "string" || args.op.trim() === "") {
404
+ throw new Error(
405
+ "behaviourFinding needs `op`, the name of the Op this step belongs to: it keys the comment's marker " +
406
+ "together with `environment`, and an env alone is not unique across Ops (#2319).",
407
+ );
408
+ }
409
+ const base = baseRefFrom(env, args.base);
410
+ if (!base) throw new Error(noBaseRefMessage());
411
+ const head = headRefFrom(env, await shortSha());
412
+ const predictArgs: PredictBehaviourArgs = {
413
+ environment: args.environment,
414
+ traffic: args.traffic,
415
+ ...(args.stack ? { stack: args.stack } : {}),
416
+ ...(args.region ? { region: args.region } : {}),
417
+ ...(args.owned !== undefined ? { owned: args.owned } : {}),
418
+ };
419
+
420
+ // Head first: it is the checkout the run is already in, and a build error
421
+ // there is the pull request's own and should surface before any worktree
422
+ // is created.
423
+ const headResult = await predict(resolve("."), predictArgs);
424
+ const baseCheckout = await checkout(base, signal);
425
+ let baseResult: BehaviourResult;
426
+ try {
427
+ baseResult = await predict(baseCheckout.projectPath, predictArgs);
428
+ } finally {
429
+ await baseCheckout.cleanup();
430
+ }
431
+
432
+ const finding = behaviourDelta(
433
+ { label: "base", ref: base, result: baseResult },
434
+ { label: "head", ref: head, result: headResult },
435
+ );
436
+ const summary = renderBehaviourFinding(finding, { env: args.environment, op: args.op });
437
+ const refused = isBehaviourRefusalReport(baseResult) || isBehaviourRefusalReport(headResult);
438
+ const result: BehaviourFindingResult = { mode, base, head, finding, refused, summary };
439
+ if (mode === "report") return result;
440
+
441
+ const posted: ReconcileResult = await post(
442
+ {
443
+ env: args.environment,
444
+ op: args.op,
445
+ mode: "comment",
446
+ marker: suppliedMarker(behaviourFindingMarker(args.op, args.environment)),
447
+ body: summary,
448
+ title: args.title ?? `Predicted behaviour for ${args.environment} at ${args.traffic}`,
449
+ },
450
+ signal,
451
+ );
452
+ return {
453
+ ...result,
454
+ ...(posted.commentUrl ? { commentUrl: posted.commentUrl } : {}),
455
+ ...(posted.pullRequest ? { pullRequest: posted.pullRequest } : {}),
456
+ ...(posted.mergeRequest ? { mergeRequest: posted.mergeRequest } : {}),
457
+ };
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Predict the pull request's declared estate and its base branch's, and post
463
+ * the delta as one sticky comment on the pull request — or one note on the
464
+ * merge request — that triggered the run. Needs a pull-request-triggered
465
+ * run; fails by name without one, the same way `reconcilePr`'s `comment`
466
+ * mode does, because that is the function that posts.
467
+ */
468
+ export const behaviourFinding = createBehaviourFinding();
@@ -18,6 +18,7 @@ import {
18
18
  suppliedMarker,
19
19
  noIssueTokenMessage,
20
20
  postOrUpdateGithubIssue,
21
+ ghCredentialEnv,
21
22
  } from "./reconcile";
22
23
 
23
24
  // ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
@@ -251,6 +252,32 @@ describe("commentTokenFrom (#2291)", () => {
251
252
  });
252
253
  });
253
254
 
255
+ describe("ghCredentialEnv (#2333)", () => {
256
+ const token = { value: "resolved-token", source: "CHANT_FORGEJO_TOKEN" };
257
+
258
+ test("carries the resolved value under GH_ENTERPRISE_TOKEN, the variable a non-github.com host reads", () => {
259
+ expect(ghCredentialEnv({}, token).GH_ENTERPRISE_TOKEN).toBe("resolved-token");
260
+ });
261
+
262
+ test("carries it under GH_TOKEN too, so github.com reads the same value it always did", () => {
263
+ expect(ghCredentialEnv({}, token).GH_TOKEN).toBe("resolved-token");
264
+ });
265
+
266
+ test("the two never disagree — one resolution, whichever class gh puts the host in", () => {
267
+ const env = ghCredentialEnv({ GH_TOKEN: "stale-ambient" }, token);
268
+ expect(env.GH_TOKEN).toBe(env.GH_ENTERPRISE_TOKEN);
269
+ });
270
+
271
+ test("sets no GH_HOST: the full URL already names the host, and GH_HOST is the default for calls that do not", () => {
272
+ expect(ghCredentialEnv({}, token)).not.toHaveProperty("GH_HOST");
273
+ });
274
+
275
+ test("passes the rest of the base environment through untouched", () => {
276
+ expect(ghCredentialEnv({ PATH: "/usr/bin", GITHUB_API_URL: "http://forgejo.example/api/v1" }, token))
277
+ .toMatchObject({ PATH: "/usr/bin", GITHUB_API_URL: "http://forgejo.example/api/v1" });
278
+ });
279
+ });
280
+
254
281
  describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () => {
255
282
  const repo = "acme/infra";
256
283
 
@@ -341,6 +368,43 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
341
368
  }
342
369
  });
343
370
 
371
+ // #2333: the full URL #2291 built reached Forgejo, but `gh` scopes
372
+ // GH_TOKEN to github.com and ghe.com subdomains, so the POST/PATCH arrived
373
+ // with no Authorization header and a real instance answered 401.
374
+ test("every comment-mode `gh` call carries GH_ENTERPRISE_TOKEN, which is what a Forgejo host reads (#2333)", async () => {
375
+ stubPrEnv("http://forgejo.example/api/v1");
376
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
377
+ ghReplies = [
378
+ { match: "--paginate", stdout: "" },
379
+ { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
380
+ ];
381
+ try {
382
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
383
+ expect(ghCalls.length).toBeGreaterThan(0);
384
+ for (const call of ghCalls) {
385
+ expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
386
+ }
387
+ } finally {
388
+ vi.unstubAllEnvs();
389
+ }
390
+ });
391
+
392
+ test("the PATCH branch carries it too, not only the POST (#2333)", async () => {
393
+ stubPrEnv("http://forgejo.example/api/v1");
394
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
395
+ ghReplies = [
396
+ { match: "--paginate", stdout: "4242\n" },
397
+ { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
398
+ ];
399
+ try {
400
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
401
+ const patch = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
402
+ expect(patch?.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
403
+ } finally {
404
+ vi.unstubAllEnvs();
405
+ }
406
+ });
407
+
344
408
  test("a pull request with no token at all is refused by name, before any `gh` call", async () => {
345
409
  stubPrEnv("http://forgejo.example/api/v1");
346
410
  vi.stubEnv("GH_TOKEN", "");
@@ -1292,6 +1356,26 @@ describe("reconcilePr issue mode sends the token it resolved (#2320)", () => {
1292
1356
  }
1293
1357
  });
1294
1358
 
1359
+ // #2333: the same credential hole comment mode had. Issue mode's writes
1360
+ // were already refused by the forgejo generator for exactly this reason.
1361
+ test("every issue-mode `gh` call carries GH_ENTERPRISE_TOKEN as well (#2333)", async () => {
1362
+ stubForgejoIssueEnv();
1363
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1364
+ ghReplies = [
1365
+ { match: "--paginate", stdout: "" },
1366
+ { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1367
+ ];
1368
+ try {
1369
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1370
+ expect(ghCalls).toHaveLength(2);
1371
+ for (const call of ghCalls) {
1372
+ expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("forgejo-cross-instance");
1373
+ }
1374
+ } finally {
1375
+ vi.unstubAllEnvs();
1376
+ }
1377
+ });
1378
+
1295
1379
  test("falls back to GH_TOKEN then GITHUB_TOKEN, the same order comment mode uses", async () => {
1296
1380
  stubForgejoIssueEnv();
1297
1381
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");