@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.
Files changed (203) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts +1 -1
  6. package/dist/cli/handlers/operator.d.ts.map +1 -1
  7. package/dist/cli/handlers/override-approve.d.ts +17 -0
  8. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  9. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +4 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config-import.d.ts +1 -1
  14. package/dist/gate-decision.d.ts +157 -0
  15. package/dist/gate-decision.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-applied.d.ts +75 -0
  17. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-audit.d.ts +145 -0
  19. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  22. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  23. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/override-ledger.d.ts +34 -0
  25. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/rollback.d.ts.map +1 -1
  27. package/dist/lifecycle/work-item-id.d.ts +22 -0
  28. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  29. package/dist/lifecycle/work-lease.d.ts +2 -2
  30. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  31. package/dist/lint/policy-import.d.ts +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify-verify.js +76 -0
  37. package/dist/notify.d.ts +202 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +307 -0
  40. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  41. package/dist/op/activities/activity-contracts.d.ts +4 -4
  42. package/dist/op/activities/gh-host.d.ts +37 -0
  43. package/dist/op/activities/gh-host.d.ts.map +1 -0
  44. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  45. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  46. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  47. package/dist/op/activities/reconcile.d.ts +74 -76
  48. package/dist/op/activities/reconcile.d.ts.map +1 -1
  49. package/dist/op/gate-approval.d.ts +17 -1
  50. package/dist/op/gate-approval.d.ts.map +1 -1
  51. package/dist/op/gate-name.d.ts +8 -1
  52. package/dist/op/gate-name.d.ts.map +1 -1
  53. package/dist/op/gate-resume.d.ts +4 -2
  54. package/dist/op/gate-resume.d.ts.map +1 -1
  55. package/dist/op/gate-review.d.ts +2 -1
  56. package/dist/op/gate-review.d.ts.map +1 -1
  57. package/dist/op/gate.d.ts +43 -6
  58. package/dist/op/gate.d.ts.map +1 -1
  59. package/dist/op/index.d.ts +8 -6
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/op-waves-notify.d.ts +31 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -0
  63. package/dist/op/op-waves-run.d.ts +218 -16
  64. package/dist/op/op-waves-run.d.ts.map +1 -1
  65. package/dist/op/op-waves.d.ts +113 -2
  66. package/dist/op/op-waves.d.ts.map +1 -1
  67. package/dist/op/operator.d.ts +15 -0
  68. package/dist/op/operator.d.ts.map +1 -1
  69. package/dist/op/work-lease-decl.d.ts.map +1 -1
  70. package/dist/op/work-lease-run.d.ts.map +1 -1
  71. package/dist/override-gate.d.ts +224 -0
  72. package/dist/override-gate.d.ts.map +1 -0
  73. package/dist/pr-forge.d.ts +225 -19
  74. package/dist/pr-forge.d.ts.map +1 -1
  75. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  76. package/dist/workspace/conformance/broker.d.ts +1 -1
  77. package/dist/workspace/status-gates.d.ts +1 -0
  78. package/dist/workspace/status-gates.d.ts.map +1 -1
  79. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  80. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  81. package/dist/workspace/trust/policy.d.ts +2 -41
  82. package/dist/workspace/trust/policy.d.ts.map +1 -1
  83. package/dist/workspace/trust/seal.d.ts +2 -27
  84. package/dist/workspace/trust/seal.d.ts.map +1 -1
  85. package/dist/workspace/trust/trust-files.d.ts +57 -0
  86. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  87. package/dist/workspace/work-cli.d.ts.map +1 -1
  88. package/package.json +15 -2
  89. package/src/cli/commands/build.ts +1 -1
  90. package/src/cli/commands/check-lexicon.ts +3 -1
  91. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  92. package/src/cli/commands/lint.test.ts +4 -109
  93. package/src/cli/commands/lint.ts +5 -2
  94. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  95. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  96. package/src/cli/handlers/gate-resume.ts +3 -2
  97. package/src/cli/handlers/operator.test.ts +12 -1
  98. package/src/cli/handlers/operator.ts +25 -13
  99. package/src/cli/handlers/override-approve.test.ts +87 -0
  100. package/src/cli/handlers/override-approve.ts +103 -0
  101. package/src/cli/handlers/run-wave.ts +10 -2
  102. package/src/cli/main.test.ts +8 -0
  103. package/src/cli/main.ts +15 -0
  104. package/src/cli/registry.ts +4 -0
  105. package/src/config-import.ts +1 -1
  106. package/src/discovery/sandbox/run.test.ts +1 -1
  107. package/src/gate-decision-bundle.test.ts +56 -0
  108. package/src/gate-decision.test.ts +237 -0
  109. package/src/gate-decision.ts +321 -0
  110. package/src/lifecycle/gate-applied.ts +115 -0
  111. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  112. package/src/lifecycle/gate-audit.test.ts +134 -0
  113. package/src/lifecycle/gate-audit.ts +438 -0
  114. package/src/lifecycle/gate-ledger-select.ts +113 -0
  115. package/src/lifecycle/gate-ledger.test.ts +22 -0
  116. package/src/lifecycle/gate-ledger.ts +9 -104
  117. package/src/lifecycle/override-ledger.test.ts +63 -0
  118. package/src/lifecycle/override-ledger.ts +69 -0
  119. package/src/lifecycle/rollback.ts +5 -1
  120. package/src/lifecycle/work-item-id.test.ts +71 -0
  121. package/src/lifecycle/work-item-id.ts +35 -0
  122. package/src/lifecycle/work-lease.ts +4 -5
  123. package/src/lint/policy-import.ts +1 -1
  124. package/src/lint/post-synth.test.ts +29 -0
  125. package/src/lint/post-synth.ts +21 -2
  126. package/src/notify-bundle.test.ts +50 -0
  127. package/src/notify-plain-node.e2e.test.ts +68 -0
  128. package/src/notify-verify.test.ts +59 -0
  129. package/src/notify-verify.ts +93 -0
  130. package/src/notify.schema.json +72 -0
  131. package/src/notify.test.ts +279 -0
  132. package/src/notify.ts +398 -0
  133. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  134. package/src/op/activities/gh-host.test.ts +130 -0
  135. package/src/op/activities/gh-host.ts +79 -0
  136. package/src/op/activities/lexicon-upgrade.ts +22 -1
  137. package/src/op/activities/propose-upgrade.ts +5 -1
  138. package/src/op/activities/reconcile.test.ts +284 -280
  139. package/src/op/activities/reconcile.ts +128 -131
  140. package/src/op/gate-approval.test.ts +175 -7
  141. package/src/op/gate-approval.ts +27 -1
  142. package/src/op/gate-name.ts +10 -2
  143. package/src/op/gate-resume.ts +6 -3
  144. package/src/op/gate-review.ts +4 -7
  145. package/src/op/gate.ts +117 -16
  146. package/src/op/index.ts +36 -5
  147. package/src/op/op-waves-notify.test.ts +127 -0
  148. package/src/op/op-waves-notify.ts +101 -0
  149. package/src/op/op-waves-run.ts +908 -123
  150. package/src/op/op-waves.test.ts +543 -3
  151. package/src/op/op-waves.ts +215 -6
  152. package/src/op/operator.test.ts +29 -0
  153. package/src/op/operator.ts +27 -2
  154. package/src/op/run-context.test.ts +2 -2
  155. package/src/op/work-lease-decl.ts +4 -4
  156. package/src/op/work-lease-run.test.ts +5 -0
  157. package/src/op/work-lease-run.ts +4 -3
  158. package/src/override-gate-bundle.test.ts +47 -0
  159. package/src/override-gate.test.ts +239 -0
  160. package/src/override-gate.ts +395 -0
  161. package/src/pr-forge-bundle.test.ts +40 -0
  162. package/src/pr-forge.test.ts +378 -0
  163. package/src/pr-forge.ts +546 -47
  164. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  165. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  166. package/src/workspace/conformance/broker.ts +1 -1
  167. package/src/workspace/records-write-contract.test.ts +3 -2
  168. package/src/workspace/records-write.e2e.test.ts +1 -1
  169. package/src/workspace/status-gates.ts +7 -4
  170. package/src/workspace/trust/gate-seal.test.ts +156 -0
  171. package/src/workspace/trust/gate-seal.ts +254 -0
  172. package/src/workspace/trust/policy.ts +10 -165
  173. package/src/workspace/trust/seal.ts +19 -34
  174. package/src/workspace/trust/trust-files.test.ts +50 -0
  175. package/src/workspace/trust/trust-files.ts +245 -0
  176. package/src/workspace/work-cli.ts +5 -3
  177. package/src/workspace/work-history.test.ts +7 -0
  178. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  179. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  180. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  181. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  182. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  183. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  184. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  185. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  186. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  187. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  188. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  189. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  190. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  191. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  192. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  193. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  194. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  195. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  196. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  197. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  198. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  199. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  200. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  201. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  202. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  203. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
package/src/notify.ts ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Notify (#3761): tell people that a wave waits for an approval, that a run
3
+ * was refused or failed, or that drift was found.
4
+ *
5
+ * terragucci and SQL Yodeler each carried a copy of this (#3756). chant owns
6
+ * the event, its signing and the Slack and Teams posting; a product decides
7
+ * when to send and what its summary says. One event shape,
8
+ * `chant.notify/v1` (`./notify.schema.json`), carries what both send: the
9
+ * wave or environment, the targets concerned, the plan digest, the command
10
+ * that approves it and the run's link.
11
+ *
12
+ * ```ts
13
+ * import { makeNotifyEvent, sendNotify } from "@intentius/chant/notify";
14
+ *
15
+ * const event = makeNotifyEvent({ source: "sql-yodeler", event: "waiting", summary: "prod waits for an approval", targets: ["0007_add_index"], digest, approve: { command } });
16
+ * await sendNotify({ webhook: "YODEL_WEBHOOK_URL", key: "YODEL_WEBHOOK_KEY", slack: "SLACK_WEBHOOK_URL" }, event);
17
+ * ```
18
+ *
19
+ * The configuration names environment variables, never URLs or keys, and
20
+ * nothing here prints a URL or a key. The generic webhook gets the event as
21
+ * JSON, signed over `<timestamp>.<body>` (`./notify-verify.ts`); it is never
22
+ * posted unsigned. Slack and Teams incoming webhooks get a message rendered
23
+ * from the event, unsigned, since neither checks a signature. A delivery that
24
+ * fails is logged and returned; {@link sendNotify} never throws, so a
25
+ * caller's exit code never depends on it.
26
+ *
27
+ * Bundle-safe: this module reaches only `node:crypto` and
28
+ * `./notify-verify.ts` (`./notify-bundle.test.ts`).
29
+ */
30
+
31
+ import { createHash, randomUUID } from "node:crypto";
32
+ import { DEFAULT_HEADER_PREFIX, signWebhook, webhookHeaderNames } from "./notify-verify";
33
+
34
+ export { DEFAULT_HEADER_PREFIX, DEFAULT_TOLERANCE_SECONDS, signWebhook, verifyWebhook, webhookHeaderNames } from "./notify-verify";
35
+ export type { VerifyWebhookInput, VerifyWebhookResult, WebhookHeaders } from "./notify-verify";
36
+
37
+ /** The event's `schema` value. A field is only ever added within a version. */
38
+ export const NOTIFY_SCHEMA = "chant.notify/v1";
39
+ /** The JSON Schema's `$id`. */
40
+ export const NOTIFY_SCHEMA_ID = "https://intentius.io/chant/schemas/notify/v1/notify.schema.json";
41
+
42
+ /** What happened. */
43
+ export const NOTIFY_EVENTS = ["waiting", "refused", "failed", "drift"] as const;
44
+ export type NotifyEventKind = (typeof NOTIFY_EVENTS)[number];
45
+
46
+ /** Where the run is: the CI run's page, the forge, the repository and commit. */
47
+ export interface NotifyRun {
48
+ url?: string;
49
+ forge?: "github" | "gitlab" | "forgejo";
50
+ /** owner/name on GitHub and Forgejo, the project path on GitLab. */
51
+ repository?: string;
52
+ commit?: string;
53
+ /** The pull or merge request the run is for, when there is one. */
54
+ pr?: number;
55
+ }
56
+
57
+ /** `chant.notify/v1`. The JSON Schema is `./notify.schema.json`. */
58
+ export interface NotifyEvent {
59
+ schema: typeof NOTIFY_SCHEMA;
60
+ /** The same for every send of one event about one digest (or drift fingerprint, or commit), so a receiver can drop a repeat. Random when there is none. */
61
+ id: string;
62
+ event: NotifyEventKind;
63
+ /** When it was sent, ISO 8601 UTC. */
64
+ at: string;
65
+ /** The product that sent it: `chant`, `terragucci`, `sql-yodeler`. */
66
+ source: string;
67
+ /** What the run belongs to: the repository, or a name the product gives. */
68
+ project: string;
69
+ /** One line a person can read. */
70
+ summary: string;
71
+ /** The wave, for a product that applies in waves. */
72
+ wave?: { number: number; name?: string; gate?: string };
73
+ /** The environment, for a product that applies per environment. */
74
+ environment?: string;
75
+ /** What the event concerns: the wave's roots or runs, the pending migrations. Empty when the product names none. */
76
+ targets: string[];
77
+ /** The plan digest an approval binds. */
78
+ digest?: string;
79
+ /** On `waiting`: how a person approves it. */
80
+ approve?: {
81
+ /** The command to run, as the product prints it. */
82
+ command: string;
83
+ /** chant's own command for the same approval, when the product's differs. */
84
+ chant?: string;
85
+ /** Under `pr-review`: the pull request whose approving review would approve it. */
86
+ review?: { pr: number; url: string };
87
+ };
88
+ /** The product's own word for the outcome (`gated`, `denied`, `incomplete`). */
89
+ status?: string;
90
+ /** On `refused` and `failed`: why. */
91
+ reason?: string;
92
+ /** The exit code the run ended with. */
93
+ exitCode?: number;
94
+ /** On `drift`: what moved. */
95
+ drift?: { changed: string[]; missing?: string[]; failed?: string[]; fingerprint?: string };
96
+ run?: NotifyRun;
97
+ /** A report page for the run. */
98
+ reportUrl?: string;
99
+ /** The product's own record of the outcome, carried whole for programs that know it. */
100
+ detail?: Record<string, unknown>;
101
+ }
102
+
103
+ /** What a product fills in; {@link makeNotifyEvent} adds the rest. */
104
+ export type NotifyFields = Omit<NotifyEvent, "schema" | "id" | "at" | "project" | "targets" | "run"> & {
105
+ id?: string;
106
+ project?: string;
107
+ targets?: string[];
108
+ run?: NotifyRun;
109
+ };
110
+
111
+ /** The CI run, from its environment: GitHub Actions, Forgejo Actions or GitLab CI. */
112
+ export function ciRun(env: NodeJS.ProcessEnv): NotifyRun {
113
+ const out: NotifyRun = {};
114
+ if (env.CI_JOB_URL) out.url = env.CI_JOB_URL;
115
+ else if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID) out.url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;
116
+ else if (env.CI_PIPELINE_URL) out.url = env.CI_PIPELINE_URL;
117
+ if (env.GITLAB_CI === "true") out.forge = "gitlab";
118
+ else if (env.GITEA_ACTIONS === "true" || env.FORGEJO_ACTIONS === "true") out.forge = "forgejo";
119
+ else if (env.GITHUB_ACTIONS === "true") out.forge = "github";
120
+ const repository = env.GITHUB_REPOSITORY || env.CI_PROJECT_PATH;
121
+ if (repository) out.repository = repository;
122
+ const commit = env.GITHUB_SHA || env.CI_COMMIT_SHA;
123
+ if (commit) out.commit = commit;
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * A whole event: the fields, with the schema, the time, the CI run (the
129
+ * product's own `run` fields win) and an id. The id is a hash over the
130
+ * source, project, event, wave, environment and digest (else the drift
131
+ * fingerprint, else the commit), so a re-run sends the same id; with none of
132
+ * the three it is random.
133
+ */
134
+ export function makeNotifyEvent(fields: NotifyFields, options: { env?: NodeJS.ProcessEnv; now?: Date } = {}): NotifyEvent {
135
+ const env = options.env ?? process.env;
136
+ const { id: given, project, targets, run: own, ...rest } = fields;
137
+ const run = { ...ciRun(env), ...(own ?? {}) };
138
+ const resolvedProject = project ?? run.repository ?? "this project";
139
+ const key = fields.digest ?? fields.drift?.fingerprint ?? run.commit;
140
+ const id =
141
+ given ??
142
+ (key
143
+ ? createHash("sha256").update(JSON.stringify([fields.source, resolvedProject, fields.event, fields.wave?.number ?? null, fields.environment ?? null, key])).digest("hex")
144
+ : randomUUID());
145
+ return {
146
+ schema: NOTIFY_SCHEMA,
147
+ id,
148
+ at: (options.now ?? new Date()).toISOString(),
149
+ ...rest,
150
+ project: resolvedProject,
151
+ targets: targets ?? [],
152
+ ...(Object.keys(run).length > 0 ? { run } : {}),
153
+ };
154
+ }
155
+
156
+ const HEADLINE: Record<NotifyEventKind, string> = {
157
+ waiting: "waits for an approval",
158
+ refused: "was refused",
159
+ failed: "failed",
160
+ drift: "drifted",
161
+ };
162
+
163
+ /** The first line of every chat message: `<source>: <summary>`, or what happened to what when there is no summary. */
164
+ export function notifyHeadline(e: NotifyEvent): string {
165
+ if (e.summary) return `${e.source}: ${e.summary}`;
166
+ const what = e.wave ? `wave ${e.wave.number}${e.wave.name ? ` (${e.wave.name})` : ""} of ${e.project}` : e.environment ? `${e.environment} in ${e.project}` : e.project;
167
+ return `${e.source}: ${what} ${HEADLINE[e.event]}`;
168
+ }
169
+
170
+ /** The label and value lines both chat messages show, in order. */
171
+ function facts(e: NotifyEvent): { title: string; value: string; link?: string }[] {
172
+ const out: { title: string; value: string; link?: string }[] = [];
173
+ if (e.approve?.review) out.push({ title: "Review and approve", value: `pull request ${e.approve.review.pr}, then run it again`, link: e.approve.review.url });
174
+ if (e.drift) {
175
+ if (e.drift.changed.length > 0) out.push({ title: "Drifted", value: e.drift.changed.join(", ") });
176
+ if (e.drift.missing?.length) out.push({ title: "Missing", value: e.drift.missing.join(", ") });
177
+ if (e.drift.failed?.length) out.push({ title: "Not refreshed", value: e.drift.failed.join(", ") });
178
+ } else if (e.targets.length > 0) out.push({ title: "Targets", value: e.targets.join(", ") });
179
+ if (e.digest) out.push({ title: "Digest", value: e.digest });
180
+ if (e.approve) out.push({ title: e.approve.review ? "Or approve" : "Approve", value: e.approve.command });
181
+ if (e.reason) out.push({ title: "Reason", value: e.reason });
182
+ if (e.run?.url) out.push({ title: "Run", value: e.run.url, link: e.run.url });
183
+ if (e.reportUrl) out.push({ title: "Report", value: e.reportUrl, link: e.reportUrl });
184
+ return out;
185
+ }
186
+
187
+ /** A Slack incoming webhook's body: one mrkdwn text, with the approve command as code and the links as links. */
188
+ export function slackMessage(e: NotifyEvent): { text: string } {
189
+ const lines = [`*${notifyHeadline(e)}*`];
190
+ for (const f of facts(e)) {
191
+ if (f.title === "Review and approve") lines.push(`${f.title}: <${f.link}|pull request ${e.approve!.review!.pr}>, then run it again`);
192
+ else if (f.title === "Approve" || f.title === "Or approve" || f.title === "Digest") lines.push(`${f.title}: \`${f.value}\``);
193
+ else if (f.link) lines.push(`${f.title}: <${f.link}>`);
194
+ else lines.push(`${f.title}: ${f.value}`);
195
+ }
196
+ return { text: lines.join("\n") };
197
+ }
198
+
199
+ /** A Teams incoming webhook's body (a Workflows webhook): one Adaptive Card, with buttons that open the review page and the run. */
200
+ export function teamsMessage(e: NotifyEvent): Record<string, unknown> {
201
+ const all = facts(e);
202
+ const actions = [
203
+ ...(e.approve?.review ? [{ type: "Action.OpenUrl", title: "Review and approve", url: e.approve.review.url }] : []),
204
+ ...(e.run?.url ? [{ type: "Action.OpenUrl", title: "Open the run", url: e.run.url }] : []),
205
+ ];
206
+ return {
207
+ type: "message",
208
+ attachments: [
209
+ {
210
+ contentType: "application/vnd.microsoft.card.adaptive",
211
+ contentUrl: null,
212
+ content: {
213
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
214
+ type: "AdaptiveCard",
215
+ version: "1.4",
216
+ body: [
217
+ { type: "TextBlock", text: notifyHeadline(e), weight: "Bolder", wrap: true },
218
+ { type: "FactSet", facts: all.map((f) => ({ title: f.title, value: f.link && f.title === "Review and approve" ? `${f.value}: ${f.link}` : f.value })) },
219
+ ],
220
+ ...(actions.length > 0 ? { actions } : {}),
221
+ },
222
+ },
223
+ ],
224
+ };
225
+ }
226
+
227
+ /** Where events go. Each value is the NAME of an environment variable; the configuration never holds a URL or a key. */
228
+ export interface NotifyConfig {
229
+ /** The variable holding the generic webhook's URL. */
230
+ webhook?: string;
231
+ /** The variable holding the key the webhook's events are signed with. Required with `webhook`. */
232
+ key?: string;
233
+ /** The variable holding a Slack incoming webhook's URL. */
234
+ slack?: string;
235
+ /** The variable holding a Microsoft Teams incoming webhook's (Workflows) URL. */
236
+ teams?: string;
237
+ /** The events to send. Default: all of them. */
238
+ events?: NotifyEventKind[];
239
+ }
240
+
241
+ const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
242
+ const SETTINGS = ["webhook", "key", "slack", "teams", "events"];
243
+
244
+ /** The first problem with a {@link NotifyConfig}, or undefined. `where` prefixes the message. */
245
+ export function notifyConfigProblem(value: unknown, where = "notify"): string | undefined {
246
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return `${where}: expected { webhook, key, slack, teams, events }`;
247
+ const v = value as Record<string, unknown>;
248
+ for (const k of Object.keys(v)) if (!SETTINGS.includes(k)) return `${where}.${k}: unknown setting (expected ${SETTINGS.join(", ")})`;
249
+ for (const k of ["webhook", "key", "slack", "teams"] as const) {
250
+ const n = v[k];
251
+ if (n === undefined) continue;
252
+ if (typeof n !== "string" || !VARIABLE.test(n)) {
253
+ const what = k === "key" ? "signing key" : "URL";
254
+ return `${where}.${k}: the name of the environment variable that holds the ${what}, never the ${k === "key" ? "key" : "URL"} itself`;
255
+ }
256
+ }
257
+ if (v.webhook !== undefined && v.key === undefined) return `${where}.key: a webhook needs a signing key; name the variable that holds it`;
258
+ if (v.key !== undefined && v.webhook === undefined) return `${where}.webhook: a key is for a webhook; name the variable that holds its URL`;
259
+ if (v.webhook === undefined && v.slack === undefined && v.teams === undefined) return `${where}: name a webhook (with its key), a slack or a teams URL variable`;
260
+ if (v.events !== undefined && !(Array.isArray(v.events) && v.events.every((e) => (NOTIFY_EVENTS as readonly unknown[]).includes(e)))) {
261
+ return `${where}.events: a list of ${NOTIFY_EVENTS.join(", ")}`;
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ /** The environment variables a configuration names, for a pipeline to map its secrets into. */
267
+ export function notifyVariables(config: NotifyConfig | undefined): string[] {
268
+ if (!config) return [];
269
+ return [config.webhook, config.key, config.slack, config.teams].filter((n): n is string => typeof n === "string" && n !== "");
270
+ }
271
+
272
+ /** The request {@link sendNotify} posts to the webhook: the event as JSON, signed with `key` over `<timestamp>.<body>`. */
273
+ export function webhookRequest(
274
+ event: NotifyEvent,
275
+ key: string,
276
+ options: { now?: number; prefix?: string; userAgent?: string } = {},
277
+ ): { headers: Record<string, string>; body: string } {
278
+ const body = JSON.stringify(event);
279
+ const now = options.now ?? Math.floor(Date.now() / 1000);
280
+ const names = webhookHeaderNames(options.prefix ?? DEFAULT_HEADER_PREFIX);
281
+ return {
282
+ body,
283
+ headers: {
284
+ "content-type": "application/json",
285
+ "user-agent": options.userAgent ?? event.source,
286
+ [names.event]: event.event,
287
+ [names.delivery]: event.id,
288
+ [names.timestamp]: String(now),
289
+ [names.signature]: signWebhook(key, now, body),
290
+ },
291
+ };
292
+ }
293
+
294
+ /** The part of `fetch` {@link sendNotify} uses. Tests pass their own. */
295
+ export type NotifyFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>;
296
+
297
+ export interface NotifyDelivery {
298
+ target: "webhook" | "slack" | "teams";
299
+ ok: boolean;
300
+ /** `delivered`, the HTTP status, or why there was none. Never a URL or a key. */
301
+ detail: string;
302
+ }
303
+
304
+ export interface SendNotifyOptions {
305
+ env?: NodeJS.ProcessEnv;
306
+ fetch?: NotifyFetch;
307
+ /** Tries per target. A 4xx is not retried. Default 3. */
308
+ attempts?: number;
309
+ /** Waits between tries. Default `setTimeout`, 500 ms times the try. */
310
+ sleep?: (ms: number) => Promise<void>;
311
+ /** Where a failed delivery is written. Default stderr. */
312
+ log?: (line: string) => void;
313
+ /** The webhook headers' prefix. Default `x-chant`. */
314
+ prefix?: string;
315
+ userAgent?: string;
316
+ /** A product's own Slack or Teams body (terragucci's relay buttons), in place of {@link slackMessage} and {@link teamsMessage}. */
317
+ render?: { slack?: (e: NotifyEvent) => unknown; teams?: (e: NotifyEvent) => unknown };
318
+ /** Seconds since the epoch, for the signature's timestamp. Default: the clock. */
319
+ now?: number;
320
+ }
321
+
322
+ /** A failure's message, with every URL it names cut out. */
323
+ function redact(message: string, urls: readonly string[]): string {
324
+ let out = message;
325
+ for (const u of urls) if (u) out = out.split(u).join("<url>");
326
+ return out.replace(/https?:\/\/\S+/g, "<url>");
327
+ }
328
+
329
+ async function post(
330
+ fetchFn: NotifyFetch,
331
+ url: string,
332
+ request: { headers: Record<string, string>; body: string },
333
+ attempts: number,
334
+ sleep: (ms: number) => Promise<void>,
335
+ ): Promise<string | undefined> {
336
+ let last = "not attempted";
337
+ for (let i = 0; i < attempts; i++) {
338
+ if (i > 0) await sleep(500 * i);
339
+ try {
340
+ const res = await fetchFn(url, { method: "POST", ...request, signal: AbortSignal.timeout(10_000) });
341
+ if (res.ok) return undefined;
342
+ last = `HTTP ${String(res.status)}`;
343
+ // A 4xx will not change on a retry: the receiver refused the event.
344
+ if (res.status < 500) return last;
345
+ } catch (e) {
346
+ last = redact(e instanceof Error ? e.message : String(e), [url]);
347
+ }
348
+ }
349
+ return last;
350
+ }
351
+
352
+ const LABEL: Record<NotifyDelivery["target"], string> = { webhook: "the webhook", slack: "Slack", teams: "Teams" };
353
+
354
+ /**
355
+ * Sends `event` to what `config` names, reading the URLs and the key from
356
+ * the environment at send time. Never throws: each delivery's outcome is
357
+ * returned, and a failed one is logged without its URL.
358
+ */
359
+ export async function sendNotify(config: NotifyConfig | undefined, event: NotifyEvent | undefined, options: SendNotifyOptions = {}): Promise<NotifyDelivery[]> {
360
+ if (!config || !event) return [];
361
+ if (config.events && !config.events.includes(event.event)) return [];
362
+ const env = options.env ?? process.env;
363
+ const fetchFn: NotifyFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
364
+ const attempts = Math.max(1, options.attempts ?? 3);
365
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
366
+ const log = options.log ?? ((line: string) => process.stderr.write(line + "\n"));
367
+ const out: NotifyDelivery[] = [];
368
+ const deliver = async (target: NotifyDelivery["target"], variable: string, request: () => { headers: Record<string, string>; body: string } | string) => {
369
+ let d: NotifyDelivery;
370
+ try {
371
+ const url = env[variable];
372
+ if (!url) d = { target, ok: false, detail: `${variable} is not set` };
373
+ else {
374
+ const r = request();
375
+ if (typeof r === "string") d = { target, ok: false, detail: r };
376
+ else {
377
+ const failed = await post(fetchFn, url, r, attempts, sleep);
378
+ d = { target, ok: failed === undefined, detail: failed ?? "delivered" };
379
+ }
380
+ }
381
+ } catch (e) {
382
+ d = { target, ok: false, detail: redact(e instanceof Error ? e.message : String(e), [env[variable] ?? ""]) };
383
+ }
384
+ if (!d.ok) log(`notify: the ${event.event} event was not delivered to ${LABEL[target]}: ${d.detail}`);
385
+ out.push(d);
386
+ };
387
+ const json = (body: unknown) => ({ headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
388
+ if (config.webhook) {
389
+ await deliver("webhook", config.webhook, () => {
390
+ const key = config.key ? env[config.key] : undefined;
391
+ if (!key) return `${config.key ?? "the key variable"} is not set, and an event is never sent unsigned`;
392
+ return webhookRequest(event, key, { ...(options.now !== undefined ? { now: options.now } : {}), ...(options.prefix ? { prefix: options.prefix } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}) });
393
+ });
394
+ }
395
+ if (config.slack) await deliver("slack", config.slack, () => json((options.render?.slack ?? slackMessage)(event)));
396
+ if (config.teams) await deliver("teams", config.teams, () => json((options.render?.teams ?? teamsMessage)(event)));
397
+ return out;
398
+ }
@@ -15,7 +15,7 @@
15
15
  * forces one (429 or 529, say). Every request it read is kept in `requests`.
16
16
  *
17
17
  * It lives under `__fixtures__` because it listens on a port, and no module
18
- * chant runs does (#2657, `test/no-listener.test.ts`): only tests start it.
18
+ * chant runs does (#2657, `test/no-listener.e2e.test.ts`): only tests start it.
19
19
  */
20
20
 
21
21
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * #3780: a `gh` call that names no host is told the forge's.
3
+ */
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, describe, expect, test } from "vitest";
10
+ import { ghEnvForHost, ghHostEnv, remoteHost } from "./gh-host";
11
+ import { withGhHost, type GhRunner } from "./lexicon-upgrade";
12
+
13
+ describe("remoteHost", () => {
14
+ test.each([
15
+ ["https://github.com/acme/infra.git", "github.com"],
16
+ ["https://ghe.acme.internal/acme/infra", "ghe.acme.internal"],
17
+ ["http://forgejo.local:3000/acme/infra.git", "forgejo.local:3000"],
18
+ ["https://x-access-token:abc@GHE.Acme.Internal/acme/infra", "ghe.acme.internal"],
19
+ ["git@ghe.acme.internal:acme/infra.git", "ghe.acme.internal"],
20
+ ["ssh://git@forgejo.local:2222/acme/infra.git", "forgejo.local"],
21
+ ["https://github.com\n", "github.com"],
22
+ ])("%s is on %s", (url, host) => {
23
+ expect(remoteHost(url)).toBe(host);
24
+ });
25
+
26
+ test("nothing, or a path, names no host", () => {
27
+ expect(remoteHost(undefined)).toBeUndefined();
28
+ expect(remoteHost("")).toBeUndefined();
29
+ expect(remoteHost("/srv/git/infra.git")).toBeUndefined();
30
+ });
31
+ });
32
+
33
+ describe("ghEnvForHost", () => {
34
+ test("a GHES or Forgejo host becomes GH_HOST, and the job's token its enterprise token", () => {
35
+ expect(ghEnvForHost({ GH_TOKEN: "t" }, "ghe.acme.internal")).toEqual({
36
+ GH_TOKEN: "t",
37
+ GH_HOST: "ghe.acme.internal",
38
+ GH_ENTERPRISE_TOKEN: "t",
39
+ });
40
+ expect(ghEnvForHost({ GITHUB_TOKEN: "j" }, "forgejo.local:3000")).toMatchObject({
41
+ GH_HOST: "forgejo.local:3000",
42
+ GH_ENTERPRISE_TOKEN: "j",
43
+ });
44
+ });
45
+
46
+ test("github.com, or no host, leaves the environment as it was", () => {
47
+ const env = { GH_TOKEN: "t" };
48
+ expect(ghEnvForHost(env, "github.com")).toBe(env);
49
+ expect(ghEnvForHost(env, undefined)).toBe(env);
50
+ });
51
+
52
+ test("a GH_HOST or an enterprise token the caller set is kept", () => {
53
+ const env = { GH_HOST: "elsewhere", GH_TOKEN: "t" };
54
+ expect(ghEnvForHost(env, "ghe.acme.internal")).toBe(env);
55
+ expect(ghEnvForHost({ GH_TOKEN: "t", GH_ENTERPRISE_TOKEN: "e" }, "ghe.acme.internal").GH_ENTERPRISE_TOKEN).toBe("e");
56
+ expect(ghEnvForHost({ GH_TOKEN: "t", GITHUB_ENTERPRISE_TOKEN: "e" }, "ghe.acme.internal")).not.toHaveProperty("GH_ENTERPRISE_TOKEN");
57
+ });
58
+
59
+ test("with no token in the environment, gh's own login is left to answer", () => {
60
+ expect(ghEnvForHost({}, "ghe.acme.internal")).toEqual({ GH_HOST: "ghe.acme.internal" });
61
+ });
62
+ });
63
+
64
+ describe("ghHostEnv", () => {
65
+ const dirs: string[] = [];
66
+ afterEach(() => {
67
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
68
+ });
69
+
70
+ function repoWithOrigin(url: string): string {
71
+ const dir = mkdtempSync(join(tmpdir(), "chant-gh-host-"));
72
+ dirs.push(dir);
73
+ execFileSync("git", ["init", "-q"], { cwd: dir });
74
+ execFileSync("git", ["remote", "add", "origin", url], { cwd: dir });
75
+ return dir;
76
+ }
77
+
78
+ test("a GitHub Actions job on GHES names its server", async () => {
79
+ const env = await ghHostEnv({ GITHUB_SERVER_URL: "https://ghe.acme.internal", GH_TOKEN: "t" });
80
+ expect(env.GH_HOST).toBe("ghe.acme.internal");
81
+ expect(env.GH_ENTERPRISE_TOKEN).toBe("t");
82
+ });
83
+
84
+ test("a Forgejo Actions job's FORGEJO_SERVER_URL comes first", async () => {
85
+ const env = await ghHostEnv({ FORGEJO_SERVER_URL: "https://code.acme.org", GITHUB_SERVER_URL: "https://other.example" });
86
+ expect(env.GH_HOST).toBe("code.acme.org");
87
+ });
88
+
89
+ test("a github.com job is unchanged", async () => {
90
+ const base = { GITHUB_SERVER_URL: "https://github.com", GH_TOKEN: "t" };
91
+ expect(await ghHostEnv(base)).toBe(base);
92
+ });
93
+
94
+ test("outside a CI job, origin's host", async () => {
95
+ const dir = repoWithOrigin("git@ghe.acme.internal:acme/infra.git");
96
+ expect((await ghHostEnv({ PATH: process.env.PATH }, dir)).GH_HOST).toBe("ghe.acme.internal");
97
+ const gh = repoWithOrigin("https://github.com/acme/infra.git");
98
+ expect(await ghHostEnv({ PATH: process.env.PATH }, gh)).not.toHaveProperty("GH_HOST");
99
+ });
100
+
101
+ test("no origin, no host", async () => {
102
+ const dir = mkdtempSync(join(tmpdir(), "chant-gh-host-"));
103
+ dirs.push(dir);
104
+ expect(await ghHostEnv({ PATH: process.env.PATH }, dir)).not.toHaveProperty("GH_HOST");
105
+ });
106
+ });
107
+
108
+ describe("lexiconUpgrade's gh runner (withGhHost)", () => {
109
+ test("gh calls get the forge's host, git calls and calls with their own env do not", async () => {
110
+ const seen: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
111
+ const run: GhRunner = async (cmd, opts) => {
112
+ seen.push({ cmd, ...(opts?.env ? { env: opts.env } : {}) });
113
+ return { stdout: "", stderr: "" };
114
+ };
115
+ let resolved = 0;
116
+ const gh = withGhHost(run, async () => {
117
+ resolved++;
118
+ return { GH_HOST: "ghe.acme.internal" };
119
+ });
120
+ await gh("git fetch origin main");
121
+ await gh("gh pr view 'lexicon-upgrade/aws' --json url,state");
122
+ await gh("gh label create 'minor' --color 'aaaaaa' --force");
123
+ await gh("gh api https://ghe.acme.internal/api/v3/repos/a/b/issues", { env: { GH_TOKEN: "x" } });
124
+ expect(seen[0]).toEqual({ cmd: "git fetch origin main" });
125
+ expect(seen[1].env?.GH_HOST).toBe("ghe.acme.internal");
126
+ expect(seen[2].env?.GH_HOST).toBe("ghe.acme.internal");
127
+ expect(seen[3].env).toEqual({ GH_TOKEN: "x" });
128
+ expect(resolved).toBe(1);
129
+ });
130
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The host a `gh` call that names none should go to (#3780).
3
+ *
4
+ * `gh pr create`, `gh pr view`, `gh label create` and `gh issue create` take
5
+ * neither a URL nor a host. Outside a checkout they go to github.com; inside
6
+ * one, `gh` matches the git remotes against the hosts it knows, which in a CI
7
+ * job is github.com alone, so a GitHub Enterprise Server or Forgejo remote is
8
+ * not taken for a GitHub host at all. `GH_HOST` names the host for those calls,
9
+ * and `gh` then reads `GH_ENTERPRISE_TOKEN` for it rather than `GH_TOKEN`
10
+ * (#2333), so the job's token is copied across when the job set only that.
11
+ *
12
+ * The host is the job's forge when the run is a GitHub Actions or Forgejo
13
+ * Actions job (`GITHUB_SERVER_URL`, `FORGEJO_SERVER_URL` first), else the
14
+ * `origin` remote's. github.com, or a host nothing names, leaves the
15
+ * environment as it was, so github.com runs are unchanged. A `GH_HOST` already
16
+ * set is the caller's choice and is kept.
17
+ *
18
+ * `gh pr` speaks GraphQL, which Forgejo does not serve, so on Forgejo this
19
+ * makes those calls reach the right instance and fail there by name rather
20
+ * than on github.com.
21
+ */
22
+
23
+ import { execFile } from "node:child_process";
24
+ import { promisify } from "node:util";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ type Env = Record<string, string | undefined>;
29
+
30
+ /** The host of a git remote URL: `https://host/o/r`, `ssh://git@host:22/o/r`, or `git@host:o/r`. */
31
+ export function remoteHost(url: string | undefined): string | undefined {
32
+ const u = url?.trim();
33
+ if (!u) return undefined;
34
+ const scp = /^[^@/\s]+@([^:/\s]+):(?!\/)/.exec(u);
35
+ if (scp) return scp[1].toLowerCase();
36
+ try {
37
+ const parsed = new URL(u);
38
+ // An ssh port is not part of the host gh knows; an http(s) one is.
39
+ return (parsed.protocol === "ssh:" ? parsed.hostname : parsed.host).toLowerCase() || undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ /** The forge host a CI job names, or undefined outside a GitHub Actions or Forgejo Actions job. */
46
+ function jobHost(env: Env): string | undefined {
47
+ return remoteHost(env.FORGEJO_SERVER_URL) ?? remoteHost(env.GITHUB_SERVER_URL);
48
+ }
49
+
50
+ /**
51
+ * `env` with `GH_HOST` set to `host` when it is not github.com, and
52
+ * `GH_ENTERPRISE_TOKEN` set from `GH_TOKEN` or `GITHUB_TOKEN` when neither
53
+ * enterprise variable is. Pure, for tests and for callers that know the host.
54
+ */
55
+ export function ghEnvForHost<E extends Env>(env: E, host: string | undefined): E {
56
+ if (env.GH_HOST?.trim() || !host || host === "github.com") return env;
57
+ const out: Env = { ...env, GH_HOST: host };
58
+ const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
59
+ if (token && !env.GH_ENTERPRISE_TOKEN?.trim() && !env.GITHUB_ENTERPRISE_TOKEN?.trim()) out.GH_ENTERPRISE_TOKEN = token;
60
+ return out as E;
61
+ }
62
+
63
+ /**
64
+ * The environment to run a host-less `gh` call with: {@link ghEnvForHost}
65
+ * for the job's forge, or for `origin`'s host in `cwd` outside a CI job.
66
+ */
67
+ export async function ghHostEnv(env: NodeJS.ProcessEnv = process.env, cwd?: string): Promise<NodeJS.ProcessEnv> {
68
+ if (env.GH_HOST?.trim()) return env;
69
+ let host = jobHost(env);
70
+ if (!host) {
71
+ try {
72
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], cwd ? { cwd } : {});
73
+ host = remoteHost(stdout);
74
+ } catch {
75
+ host = undefined;
76
+ }
77
+ }
78
+ return ghEnvForHost(env, host);
79
+ }
@@ -30,6 +30,7 @@ import { join } from "node:path";
30
30
  import type { UpgradeCheckResult, LexiconId, UpstreamPin } from "../../codegen/pinned-upgrade";
31
31
  import type { RollingUpgradeResult, RollingLexicon } from "../../codegen/rolling-upgrade";
32
32
  import { bumpForSeverity, bumpPackageJsonVersion } from "../../codegen/version-bump";
33
+ import { ghHostEnv } from "./gh-host";
33
34
  import { postOrUpdateGithubIssue } from "./reconcile";
34
35
 
35
36
  export { bumpPackageJsonVersion };
@@ -344,6 +345,25 @@ function shellQuote(s: string): string {
344
345
 
345
346
  const defaultGh: GhRunner = async (cmd, opts) => execAsync(cmd, { ...opts });
346
347
 
348
+ /**
349
+ * `run`, with the forge's host handed to every `gh` call that brings no
350
+ * environment of its own (#3780). `gh pr view`, `gh pr edit`, `gh pr create`,
351
+ * `gh label create` and the ambient `gh issue create` name no host, so on
352
+ * GitHub Enterprise Server or Forgejo they would not reach the job's own
353
+ * forge; see `./gh-host.ts`. A call that passes an `env` (the issue recipe's
354
+ * full-URL `gh api` calls, #2320) already names its host and keeps it. The
355
+ * environment is resolved once, on the first `gh` call. `git` calls pass
356
+ * through untouched.
357
+ */
358
+ export function withGhHost(run: GhRunner, resolve: () => Promise<NodeJS.ProcessEnv> = () => ghHostEnv(process.env)): GhRunner {
359
+ let env: Promise<NodeJS.ProcessEnv> | undefined;
360
+ return async (cmd, opts) => {
361
+ if (!cmd.startsWith("gh ") || opts?.env) return run(cmd, opts);
362
+ env ??= resolve();
363
+ return run(cmd, { ...opts, env: await env });
364
+ };
365
+ }
366
+
347
367
  /**
348
368
  * The hidden marker that makes this lexicon's upgrade-status issue findable
349
369
  * across re-runs (#2297): written as the issue body's first line, matched by
@@ -505,8 +525,9 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
505
525
  const {
506
526
  lexicon,
507
527
  mode = "report",
508
- _gh: gh = defaultGh,
528
+ _gh: rawGh = defaultGh,
509
529
  } = args;
530
+ const gh = withGhHost(rawGh);
510
531
 
511
532
  const lexiconDir = args.lexiconDir ?? `${process.cwd()}/lexicons/${lexicon}`;
512
533