@intentius/chant 0.124.29 → 0.124.30

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 (103) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +4 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -5
  5. package/dist/cli/handlers/pr.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +30 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +49 -0
  10. package/dist/cli/registry.d.ts +17 -1
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/pr-pipeline.d.ts +24 -0
  13. package/dist/components/pr-pipeline.d.ts.map +1 -1
  14. package/dist/components/pr-pipeline.js +16 -0
  15. package/dist/components/pr-run.d.ts +20 -2
  16. package/dist/components/pr-run.d.ts.map +1 -1
  17. package/dist/components/pr-run.js +26 -13
  18. package/dist/components/wave-plan.d.ts +11 -0
  19. package/dist/components/wave-plan.d.ts.map +1 -1
  20. package/dist/components/wave-plan.js +26 -0
  21. package/dist/lifecycle/release-ledger.d.ts +8 -3
  22. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.js +2 -0
  24. package/dist/lifecycle/run-ledger.d.ts +1 -1
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/lifecycle/run-ledger.js +9 -0
  27. package/dist/op/activities/activity-contracts.d.ts +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  29. package/dist/op/activities/activity-contracts.js +1 -0
  30. package/dist/op/activities/shell.d.ts +6 -0
  31. package/dist/op/activities/shell.d.ts.map +1 -1
  32. package/dist/op/activities/shell.js +1 -1
  33. package/dist/op/local-executor.d.ts +23 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +9 -3
  36. package/dist/op/runtime.d.ts +27 -0
  37. package/dist/op/runtime.d.ts.map +1 -1
  38. package/dist/op/runtime.js +20 -0
  39. package/dist/pr-forge.d.ts +23 -0
  40. package/dist/pr-forge.d.ts.map +1 -1
  41. package/dist/pr-forge.js +44 -0
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +7 -0
  45. package/dist/run-event-stream.d.ts +2 -2
  46. package/dist/run-event-stream.d.ts.map +1 -1
  47. package/dist/run-event-stream.js +2 -2
  48. package/dist/run-event.d.ts +15 -0
  49. package/dist/run-event.d.ts.map +1 -1
  50. package/dist/run-event.js +2 -0
  51. package/dist/wave-runtime.mjs +171 -171
  52. package/dist/workspace/ci-green-forge.d.ts +2 -0
  53. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  54. package/dist/workspace/ci-green.d.ts +2 -0
  55. package/dist/workspace/ci-green.d.ts.map +1 -1
  56. package/dist/workspace/ci-green.js +4 -2
  57. package/dist/workspace/ci-run.d.ts +222 -0
  58. package/dist/workspace/ci-run.d.ts.map +1 -0
  59. package/dist/workspace/ci-run.js +464 -0
  60. package/dist/workspace/ci-serve.d.ts +214 -0
  61. package/dist/workspace/ci-serve.d.ts.map +1 -0
  62. package/dist/workspace/ci-serve.js +412 -0
  63. package/dist/workspace/pr-report.schema.json +5 -0
  64. package/dist/workspace/run-event.schema.json +14 -3
  65. package/dist/workspace/status-stewards.d.ts +2 -0
  66. package/dist/workspace/status-stewards.d.ts.map +1 -1
  67. package/dist/workspace/status-stewards.js +1 -0
  68. package/dist/workspace/status.schema.json +12 -1
  69. package/package.json +1 -1
  70. package/src/cli/handlers/build.test.ts +15 -0
  71. package/src/cli/handlers/build.ts +4 -1
  72. package/src/cli/handlers/fan-out.test.ts +82 -0
  73. package/src/cli/handlers/fan-out.ts +34 -5
  74. package/src/cli/handlers/pr.ts +5 -0
  75. package/src/cli/main.ts +30 -0
  76. package/src/cli/parse-args.ts +35 -0
  77. package/src/cli/registry.ts +17 -1
  78. package/src/components/pr-pipeline.ts +32 -0
  79. package/src/components/pr-run.test.ts +127 -0
  80. package/src/components/pr-run.ts +32 -12
  81. package/src/components/wave-plan.ts +30 -0
  82. package/src/lifecycle/release-ledger.ts +9 -3
  83. package/src/lifecycle/run-ledger.ts +7 -1
  84. package/src/op/activities/activity-contracts.ts +1 -0
  85. package/src/op/activities/shell.ts +7 -1
  86. package/src/op/local-executor.ts +27 -4
  87. package/src/op/runtime.ts +42 -0
  88. package/src/pr-forge.test.ts +25 -0
  89. package/src/pr-forge.ts +62 -0
  90. package/src/pr-loop.test.ts +13 -1
  91. package/src/pr-loop.ts +16 -0
  92. package/src/run-event-stream.ts +4 -3
  93. package/src/run-event.ts +17 -0
  94. package/src/workspace/ci-green-forge.ts +2 -0
  95. package/src/workspace/ci-green.ts +6 -2
  96. package/src/workspace/ci-run.test.ts +416 -0
  97. package/src/workspace/ci-run.ts +571 -0
  98. package/src/workspace/ci-serve.test.ts +268 -0
  99. package/src/workspace/ci-serve.ts +521 -0
  100. package/src/workspace/pr-report.schema.json +5 -0
  101. package/src/workspace/run-event.schema.json +14 -3
  102. package/src/workspace/status-stewards.ts +3 -0
  103. package/src/workspace/status.schema.json +12 -1
@@ -135,3 +135,33 @@ export async function readComponentOutputs(
135
135
  }
136
136
  return found;
137
137
  }
138
+
139
+ /**
140
+ * Read the deployed outputs of each of `names` that `outputs` holds nothing
141
+ * for yet (#4104), through {@link readComponentOutputs}, and add them to
142
+ * `outputs`. A name that is not among `components`, or whose steps cannot
143
+ * say, is left out, so its dependents' references stay unresolved. Returns,
144
+ * per component read, the names of the outputs the read gave, sorted.
145
+ *
146
+ * `components` must be the whole graph, not the selection: the dependency a
147
+ * selected member reads is often one the change did not select.
148
+ */
149
+ export async function readDeployedOutputs(
150
+ names: readonly string[],
151
+ components: ReadonlyMap<string, DriverComponent>,
152
+ registry: CapabilityRegistry,
153
+ ctxOf: (component: string) => DeployContext,
154
+ outputs: Record<string, Record<string, unknown>>,
155
+ ): Promise<Record<string, string[]>> {
156
+ const read: Record<string, string[]> = {};
157
+ for (const name of names) {
158
+ if (name in outputs || name in read) continue;
159
+ const component = components.get(name);
160
+ if (!component) continue;
161
+ const found = await readComponentOutputs(component, registry, ctxOf(name), outputs);
162
+ if (!found) continue;
163
+ outputs[name] = found;
164
+ read[name] = Object.keys(found).sort();
165
+ }
166
+ return read;
167
+ }
@@ -47,16 +47,22 @@ export interface RunOrigin {
47
47
  * Which id space minted `runId`: `github` (a GitHub Actions run id — also
48
48
  * the spelling Forgejo Actions uses, since it implements the same env
49
49
  * contract; `url` disambiguates the actual host), `gitlab` (a CI pipeline
50
- * id), `op` (an orchestrator/Op run id), or `local` (minted on the machine
51
- * that ran the deploy, resolvable nowhere).
50
+ * id), `op` (an orchestrator/Op run id), `local` (minted on the machine
51
+ * that ran the deploy, resolvable nowhere), or `chant` (a job the chant
52
+ * runner ran, `chant ci run`, #4094, named by its Op run id). `chant` was
53
+ * added within contract 1 by {@link RUN_ORIGIN_CHANT_FLOOR}, so a reader
54
+ * that switches on `forge` keeps a case for a value it does not know.
52
55
  */
53
- forge: "github" | "gitlab" | "op" | "local";
56
+ forge: "github" | "gitlab" | "op" | "local" | "chant";
54
57
  /** Repo/project slug on the forge (`GITHUB_REPOSITORY` / `CI_PROJECT_PATH`), when the environment named one. */
55
58
  repo?: string;
56
59
  /** Resolved link to the run, when the environment provided enough to build one. */
57
60
  url?: string;
58
61
  }
59
62
 
63
+ /** The first chant that writes `runOrigin.forge: "chant"`, for a job `chant ci run` ran (#4094). */
64
+ export const RUN_ORIGIN_CHANT_FLOOR = "0.124.30";
65
+
60
66
  /**
61
67
  * The gate resolution that admitted a deploy (#4020): the gate's address in the
62
68
  * gate ledger (`_gates/<op>.jsonl`, `op` and `gate`) and when the resolution
@@ -38,7 +38,7 @@ import { randomUUID } from "node:crypto";
38
38
  import { sortedJsonReplacer } from "../utils";
39
39
  import type { OpConfig } from "../op/types";
40
40
  import type { StepRecord } from "../op/local-executor";
41
- import type { OpRunRecord, OpRunRecordInput, OpRunPhaseRecord, OpRunStepRecord } from "../op/runtime";
41
+ import { opRunFailureOf, type OpRunRecord, type OpRunRecordInput, type OpRunPhaseRecord, type OpRunStepRecord } from "../op/runtime";
42
42
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
43
43
 
44
44
  /** Prefix folding the per-op run ledgers into one flat name under `<env>/`. See the module doc. */
@@ -215,6 +215,12 @@ export async function readRunLedger(
215
215
  malformed++;
216
216
  continue;
217
217
  }
218
+ // A `failure` (#4094) that is not one is dropped and the line kept, as a gate line's wrong-typed `gitSha` is.
219
+ if ("failure" in parsed) {
220
+ const failure = opRunFailureOf(parsed.failure);
221
+ if (failure) parsed.failure = failure;
222
+ else delete parsed.failure;
223
+ }
218
224
  records.push(parsed as OpRunRecord);
219
225
  } catch {
220
226
  malformed++;
@@ -54,6 +54,7 @@ export const shellCmdContract = activityContract(
54
54
  cmd: z.string(),
55
55
  env: z.record(z.string(), z.string()).optional(),
56
56
  cwd: z.string().optional(),
57
+ inheritEnv: z.boolean().optional(),
57
58
  okExit: z.array(z.number()).optional(),
58
59
  gatedExit: z.number().optional(),
59
60
  gate: z.strictObject({ op: z.string(), gate: z.string() }).optional(),
@@ -13,6 +13,12 @@ export interface ShellCmdArgs {
13
13
  env?: Record<string, string>;
14
14
  /** Working directory. Default: process.cwd(). */
15
15
  cwd?: string;
16
+ /**
17
+ * Whether the command sees this process's environment. Default true. With
18
+ * false it sees `env` alone, plus the in-flight record's variable, so a job
19
+ * the chant runner runs (`chant ci run`, #4094) gets a clean environment.
20
+ */
21
+ inheritEnv?: boolean;
16
22
  /**
17
23
  * Exit codes that count as success. Default `[0]`.
18
24
  *
@@ -129,7 +135,7 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
129
135
  cwd: args.cwd,
130
136
  // In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
131
137
  // file the command appends activity lines to (../run-live.ts).
132
- env: { ...process.env, ...liveRunEnv(), ...args.env },
138
+ env: { ...(args.inheritEnv === false ? {} : process.env), ...liveRunEnv(), ...args.env },
133
139
  maxBuffer: MAX_STDOUT_BYTES,
134
140
  signal,
135
141
  });
@@ -56,7 +56,7 @@ import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
56
56
  import { runEnvOf } from "../lifecycle/run-ledger";
57
57
  import { resolveCommitSha } from "../lifecycle/commit-sha";
58
58
  import { resolveCiRun, type RunOrigin } from "../lifecycle/release-ledger";
59
- import { runEventsOfStepRecord, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
59
+ import { runEventsOfStepRecord, type RunEventKind, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
60
60
  import { openRunEventStore, teeLines } from "../run-event-store";
61
61
  import { openRunEventStream, runEventRunOf, type RunEventSink, type RunEventStream } from "../run-event-stream";
62
62
  import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
@@ -1300,6 +1300,24 @@ export interface RunOpOptions {
1300
1300
  * turn and `local` otherwise. Omitted, nothing is written.
1301
1301
  */
1302
1302
  runEvents?: { write: (line: string) => void; runtime?: RunEventRuntime; runOrigin?: RunOrigin };
1303
+ /**
1304
+ * What ran the Op, when a runner says so itself (#4094): `chant ci run`
1305
+ * runs a pull-request job as an Op and passes `kind: "pipeline"`,
1306
+ * `runtime: "ci"` and `runOrigin: { forge: "chant" }`. The run's events
1307
+ * carry all three, kept on this host as well as written to
1308
+ * {@link runEvents}, and the run record carries `runOrigin` in place of
1309
+ * the CI run the environment names. Omitted, the run is an `op` run named
1310
+ * as described on {@link runEvents}.
1311
+ */
1312
+ runner?: { kind: RunEventKind; runtime: RunEventRuntime; runOrigin: RunOrigin };
1313
+ /**
1314
+ * Says why a failed run failed (#4094), given the run's step records once
1315
+ * it has settled `fail`. What it returns is stored on the record as
1316
+ * `failure`, before the record is signed, and carried on the `run-settle`
1317
+ * event. `chant ci run` reads the job's pull-request report here, while
1318
+ * the checkout it ran in still exists.
1319
+ */
1320
+ describeFailure?: (records: readonly StepRecord[]) => OpRunRecord["failure"];
1303
1321
  /**
1304
1322
  * Append the run's {@link OpRunRecord} to the run ledger
1305
1323
  * (`../lifecycle/run-ledger.ts`) when the run settles (#2118). Opt-in on
@@ -1464,7 +1482,7 @@ async function runOpInTurn(
1464
1482
  const stream = eventsWrite
1465
1483
  ? openRunEventStream(
1466
1484
  runEventRunOf({
1467
- kind: "op",
1485
+ kind: options.runner?.kind ?? "op",
1468
1486
  name: config.name,
1469
1487
  runId,
1470
1488
  ...(commit ? { gitSha: commit } : {}),
@@ -1472,6 +1490,7 @@ async function runOpInTurn(
1472
1490
  steward: options.steward !== undefined,
1473
1491
  ...(options.runEvents?.runtime ? { runtime: options.runEvents.runtime } : {}),
1474
1492
  ...(options.runEvents?.runOrigin ? { runOrigin: options.runEvents.runOrigin } : {}),
1493
+ ...(options.runner ? { runtime: options.runner.runtime, runOrigin: options.runner.runOrigin } : {}),
1475
1494
  }),
1476
1495
  eventsWrite,
1477
1496
  )
@@ -1612,9 +1631,13 @@ async function runOpInTurn(
1612
1631
  ...(options.steward ? { steward: options.steward } : {}),
1613
1632
  ...(resumed ? { resumed } : {}),
1614
1633
  ...(commit ? { gitSha: commit } : {}),
1615
- ...(ciRun ? { runOrigin: ciRun.runOrigin } : {}),
1634
+ ...(options.runner ? { runOrigin: options.runner.runOrigin } : ciRun ? { runOrigin: ciRun.runOrigin } : {}),
1616
1635
  phaseDurations,
1617
1636
  });
1637
+ if (status === "fail" && options.describeFailure) {
1638
+ const failure = options.describeFailure(settled);
1639
+ if (failure) input.failure = failure;
1640
+ }
1618
1641
  let record: OpRunRecord = { version: 1, ...input, id: runId };
1619
1642
  if (options.signRecord) {
1620
1643
  try {
@@ -1627,7 +1650,7 @@ async function runOpInTurn(
1627
1650
  }
1628
1651
  // The stream settles as the run does, with the first failing step's error.
1629
1652
  const firstError = status === "fail" ? settled.find((r) => r.status === "fail")?.error : undefined;
1630
- stream?.settle(status === "fail" ? "failed" : status, firstError);
1653
+ stream?.settle(status === "fail" ? "failed" : status, firstError, input.failure);
1631
1654
  if (!options.ledger) return record;
1632
1655
  try {
1633
1656
  return (await appendRunRecord(input, options.ledger)).record;
package/src/op/runtime.ts CHANGED
@@ -183,6 +183,48 @@ export interface OpRunRecord {
183
183
  * written before 0.124.28.
184
184
  */
185
185
  statement?: { payloadType: string; payload: string; signatures: { keyid: string; sig: string }[] };
186
+ /**
187
+ * Why a failed run failed (#4094), for a reader that acts on it without
188
+ * reading the log: a short `code`, a one-line `reason`, the phase that
189
+ * failed and the exit code of the command that failed. `chant ci run`
190
+ * writes it on every failed job, from the job's pull-request report when
191
+ * it wrote one (`not-a-reviewer`, `plan-failed`, `failed` and the other
192
+ * refusals and statuses), else `command-failed`, `setup-failed` or
193
+ * `build-failed`. Optional within contract 1, written by chant
194
+ * {@link OP_RUN_FAILURE_FLOOR} and newer; absent on a run that passed and
195
+ * on a run nothing explained.
196
+ */
197
+ failure?: OpRunFailureReason;
198
+ }
199
+
200
+ /** Why a run failed, on {@link OpRunRecord.failure} and on the run's `run-settle` event (#4094). */
201
+ export interface OpRunFailureReason {
202
+ /** A short code: a pull-request report's refusal or status, or `command-failed`, `setup-failed`, `build-failed`. */
203
+ code: string;
204
+ /** One line a person reads. */
205
+ reason: string;
206
+ /** The phase that failed, when one did. */
207
+ phase?: string;
208
+ /** The exit code of the command that failed, when it exited with one. */
209
+ exitCode?: number;
210
+ }
211
+
212
+ /** The first chant that writes {@link OpRunRecord.failure} and `failure` on `run-settle` (#4094). */
213
+ export const OP_RUN_FAILURE_FLOOR = "0.124.30";
214
+
215
+ /** `value` as an {@link OpRunFailureReason}, or undefined when it is not one, so a reader drops a malformed field and keeps the record. */
216
+ export function opRunFailureOf(value: unknown): OpRunFailureReason | undefined {
217
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
218
+ const v = value as Record<string, unknown>;
219
+ if (typeof v.code !== "string" || v.code === "" || typeof v.reason !== "string") return undefined;
220
+ if (v.phase !== undefined && typeof v.phase !== "string") return undefined;
221
+ if (v.exitCode !== undefined && !Number.isInteger(v.exitCode)) return undefined;
222
+ return {
223
+ code: v.code,
224
+ reason: v.reason,
225
+ ...(typeof v.phase === "string" ? { phase: v.phase } : {}),
226
+ ...(typeof v.exitCode === "number" ? { exitCode: v.exitCode } : {}),
227
+ };
186
228
  }
187
229
 
188
230
  /** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
@@ -782,6 +782,31 @@ describe("pull requests over REST (#3798)", () => {
782
782
  expect((calls[1].body as { target_branch: string }).target_branch).toBe("main");
783
783
  });
784
784
 
785
+ test("openPullRequests lists the heads into a base and marks forks; branchHead reads a branch's commit (#4095)", async () => {
786
+ const f = fakeFetch({
787
+ "GET /api/v1/repos/acme/infra/pulls": [
788
+ { number: 12, head: { ref: "x", sha: "c".repeat(40), repo: { full_name: "mallory/infra" } }, base: { ref: "main", sha: "b".repeat(40) }, user: { login: "mallory" } },
789
+ { number: 4, head: { ref: "y", sha: "d".repeat(40), repo: { full_name: "acme/infra" } }, base: { ref: "main", sha: "b".repeat(40) }, user: { login: "driver" } },
790
+ { number: 5, head: { ref: "z", sha: "e".repeat(40) }, base: { ref: "release" } },
791
+ ],
792
+ "GET /api/v1/repos/acme/infra/branches/main": { name: "main", commit: { id: "f".repeat(40) } },
793
+ });
794
+ const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
795
+ expect(await forge.openPullRequests!("main")).toEqual([
796
+ { number: 4, head: "y", headSha: "d".repeat(40), base: "main", baseSha: "b".repeat(40), author: "driver", fork: false },
797
+ { number: 12, head: "x", headSha: "c".repeat(40), base: "main", baseSha: "b".repeat(40), author: "mallory", fork: true },
798
+ ]);
799
+ expect(f.calls[0].url).toBe("https://forge.example/api/v1/repos/acme/infra/pulls?state=open&limit=50&page=1");
800
+ expect(await forge.branchHead!("main")).toBe("f".repeat(40));
801
+ expect(await forge.branchHead!("gone")).toBeUndefined();
802
+
803
+ const g = fakeFetch({ "GET /repos/acme/infra/pulls": [], "GET /repos/acme/infra/branches/main": { commit: { sha: SHA } } });
804
+ const github = githubForge({ ...gh, fetch: g.fetch });
805
+ expect(await github.openPullRequests!("main")).toEqual([]);
806
+ expect(g.calls[0].url).toBe("https://api.github.com/repos/acme/infra/pulls?state=open&base=main&per_page=100&page=1");
807
+ expect(await github.branchHead!("main")).toBe(SHA);
808
+ });
809
+
785
810
  test("findPullRequest takes the oldest open one from this repository, not a fork's", async () => {
786
811
  const f = fakeFetch({
787
812
  "GET /api/v1/repos/acme/infra/pulls": [
package/src/pr-forge.ts CHANGED
@@ -212,6 +212,23 @@ export interface PullRequest {
212
212
  base?: string;
213
213
  }
214
214
 
215
+ /** An open pull request as `chant ci serve` polls it (#4095). */
216
+ export interface OpenPullRequest {
217
+ number: number;
218
+ /** Its head branch. */
219
+ head: string;
220
+ /** The commit its head branch points at. */
221
+ headSha: string;
222
+ /** The branch it merges into. */
223
+ base: string;
224
+ /** The base branch's commit as the forge last measured the pull request against it. */
225
+ baseSha?: string;
226
+ /** Who opened it. */
227
+ author?: string;
228
+ /** Its head branch lives in another repository, a fork. */
229
+ fork: boolean;
230
+ }
231
+
215
232
  /** What {@link PrForge.mergePullRequest} merges, and how. */
216
233
  export interface MergeOptions {
217
234
  /** The head commit the caller reviewed. The merge happens only while the pull request's head is still this commit. */
@@ -339,6 +356,13 @@ export interface PrForge {
339
356
  * still throw.
340
357
  */
341
358
  mergePullRequest(pr: number, opts: MergeOptions): Promise<MergeResult>;
359
+ /**
360
+ * GitHub and Forgejo (#4095): every open pull request into `base`, with
361
+ * its head commit, oldest first. `chant ci serve` polls it.
362
+ */
363
+ openPullRequests?(base: string): Promise<OpenPullRequest[]>;
364
+ /** GitHub and Forgejo (#4095): the commit `branch` points at, or undefined when there is no such branch. */
365
+ branchHead?(branch: string): Promise<string | undefined>;
342
366
  }
343
367
 
344
368
  /** A GitHub or Forgejo client. */
@@ -667,6 +691,44 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
667
691
  const open = listed.filter((p) => fromHere(p, head));
668
692
  return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
669
693
  },
694
+ async openPullRequests(base) {
695
+ // GitHub filters by base server-side; Forgejo's list is matched here.
696
+ const query = kind === "github" ? `?state=open&base=${encodeURIComponent(base)}` : "?state=open";
697
+ let listed: unknown[];
698
+ try {
699
+ listed = await api.list(`${repo}/pulls${query}`, pageParams, pageSize);
700
+ } catch (err) {
701
+ // Forgejo answers 404 while it counts the repository as empty, as findPullRequest notes.
702
+ if (kind !== "forgejo" || !(err instanceof ForgeApiError) || err.status !== 404) throw err;
703
+ await api.call("GET", repo);
704
+ return [];
705
+ }
706
+ const out: OpenPullRequest[] = [];
707
+ for (const v of listed) {
708
+ if (!isObject(v) || !isObject(v.head) || !isObject(v.base)) continue;
709
+ const number = num(v.number);
710
+ const head = str(v.head.ref);
711
+ const headSha = str(v.head.sha);
712
+ const into = str(v.base.ref);
713
+ if (number === undefined || !head || !headSha || into !== base) continue;
714
+ const baseSha = str(v.base.sha);
715
+ const author = isObject(v.user) ? str(v.user.login) : undefined;
716
+ out.push({ number, head, headSha, base: into, ...(baseSha ? { baseSha } : {}), ...(author ? { author } : {}), fork: !fromHere(v, head) });
717
+ }
718
+ return out.sort((a, b) => a.number - b.number);
719
+ },
720
+ async branchHead(branch) {
721
+ let b: unknown;
722
+ try {
723
+ b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
724
+ } catch (err) {
725
+ if (err instanceof ForgeApiError && err.status === 404) return undefined;
726
+ throw err;
727
+ }
728
+ // GitHub names the commit `sha`, Forgejo `id`.
729
+ const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
730
+ return commit ? str(commit.sha) ?? str(commit.id) : undefined;
731
+ },
670
732
  async editPullRequest(pr, change) {
671
733
  return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
672
734
  },
@@ -13,6 +13,7 @@ import { composeChangeSet, type ChangeSetPart } from "./change-set";
13
13
  import {
14
14
  describePlanChanged,
15
15
  memberCounts,
16
+ PR_REPORT_DEPLOYED_OUTPUTS_FLOOR,
16
17
  PR_REPORT_SCHEMA_ID,
17
18
  prApproveCommand,
18
19
  prNoteMarker,
@@ -109,6 +110,9 @@ const resumedReport: PrReport = {
109
110
  resumed: { applied: ["a"] },
110
111
  };
111
112
 
113
+ /** A plan whose members read `net` and `a` from the deployed estate (#4104). */
114
+ const deployedReport: PrReport = { ...planReport, deployedOutputs: { a: ["id"], net: ["cidr"], dns: [] } };
115
+
112
116
  describe("the pr-report schema", () => {
113
117
  test("is a valid schema and names this module's id", () => {
114
118
  expect(validSchema(schema)).toBe(true);
@@ -116,7 +120,7 @@ describe("the pr-report schema", () => {
116
120
  });
117
121
 
118
122
  test("every stage's report validates, and its change set validates against the change-set schema", () => {
119
- for (const report of [planReport, refusedReport, appliedReport, resumedReport]) {
123
+ for (const report of [planReport, refusedReport, appliedReport, resumedReport, deployedReport]) {
120
124
  expectValid(report);
121
125
  expectValidChangeSet(report.changeSet);
122
126
  expect(report.changeSet.digest).toBe(report.digest);
@@ -158,6 +162,14 @@ describe("the note", () => {
158
162
  expect(renderPrNote(appliedReport)).not.toContain("Resumed");
159
163
  });
160
164
 
165
+ test("a plan that read dependencies from the deployed estate names each and its outputs (#4104)", () => {
166
+ const note = renderPrNote(deployedReport);
167
+ expect(note).toContain("Planned against outputs read from the deployed estate: `a` (`id`); `net` (`cidr`); `dns` (none yet).");
168
+ expect(renderPrNote(planReport)).not.toContain("deployed estate");
169
+ expect(PR_REPORT_DEPLOYED_OUTPUTS_FLOOR).toBe("0.124.30");
170
+ expect(schema.required).not.toContain("deployedOutputs");
171
+ });
172
+
161
173
  test("a small limit leaves the summary out rather than cutting the table", () => {
162
174
  const note = renderPrNote(planReport, { limit: 800 });
163
175
  expect(note).toContain("| `app` |");
package/src/pr-loop.ts CHANGED
@@ -46,6 +46,9 @@ export const PR_REPORT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace
46
46
  /** The read-contract version the report follows. */
47
47
  export const PR_REPORT_CONTRACT = 1;
48
48
 
49
+ /** The first chant whose report can carry `deployedOutputs` (#4104). */
50
+ export const PR_REPORT_DEPLOYED_OUTPUTS_FLOOR = "0.124.30";
51
+
49
52
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
50
53
  export const PR_APPLY_GATE = "pr-apply";
51
54
 
@@ -183,6 +186,14 @@ export interface PrReport {
183
186
  * the rest under the same approval and did not run them again.
184
187
  */
185
188
  resumed?: { applied: string[] };
189
+ /**
190
+ * Per dependency a member was planned against, the names of the outputs
191
+ * read from the deployed estate (#4104), sorted. A dependency outside the
192
+ * change is here too. Seeded outputs (`--seed-outputs`, a resume record)
193
+ * are not. Absent when nothing was read. Added within contract 1 by chant
194
+ * {@link PR_REPORT_DEPLOYED_OUTPUTS_FLOOR}.
195
+ */
196
+ deployedOutputs?: Record<string, string[]>;
186
197
  approval: PrApproval;
187
198
  /** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
188
199
  selection: {
@@ -324,6 +335,11 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
324
335
  if (report.selection.unclaimed.length > 0) {
325
336
  out.push("", `Changed, and deployed by no component: ${report.selection.unclaimed.map(code).join(", ")}.`);
326
337
  }
338
+ const deployed = Object.entries(report.deployedOutputs ?? {});
339
+ if (deployed.length > 0) {
340
+ const each = deployed.map(([dep, names]) => `${code(dep)} (${names.length > 0 ? names.map(code).join(", ") : "none yet"})`);
341
+ out.push("", `Planned against outputs read from the deployed estate: ${each.join("; ")}.`);
342
+ }
327
343
  if (report.status !== "nothing") {
328
344
  out.push("", `Plan digest: ${code(report.digest)}`, "", ...approvalLines(report, options.approver ?? "<you>"));
329
345
  }
@@ -26,6 +26,7 @@ import {
26
26
  type RunEventRun,
27
27
  type RunEventRuntime,
28
28
  type RunEventSettleStatus,
29
+ type RunEventFailure,
29
30
  } from "./run-event";
30
31
 
31
32
  /** Where an emitter hands a run's events, before the writer stamps them. */
@@ -174,7 +175,7 @@ export interface RunEventStream {
174
175
  */
175
176
  nested: RunEventSink;
176
177
  /** End the stream with `run-settle`, unless it has ended. */
177
- settle(status: RunEventSettleStatus, error?: string): void;
178
+ settle(status: RunEventSettleStatus, error?: string, failure?: RunEventFailure): void;
178
179
  /** Whether `run-settle` has been written. */
179
180
  readonly settled: boolean;
180
181
  }
@@ -219,9 +220,9 @@ export function openRunEventStream(
219
220
  nested: (body) => {
220
221
  if (body.type !== "run-start" && body.type !== "run-settle") emit(body);
221
222
  },
222
- settle(status, error) {
223
+ settle(status, error, failure) {
223
224
  if (settled) return;
224
- emit({ type: "run-settle", status, durationMs: null, ...(error !== undefined ? { error } : {}) });
225
+ emit({ type: "run-settle", status, durationMs: null, ...(error !== undefined ? { error } : {}), ...(failure ? { failure } : {}) });
225
226
  },
226
227
  get settled() {
227
228
  return settled;
package/src/run-event.ts CHANGED
@@ -105,8 +105,25 @@ export interface RunEventRunSettle {
105
105
  status: RunEventSettleStatus;
106
106
  durationMs: number | null;
107
107
  error?: string;
108
+ /**
109
+ * Why a failed run failed, as its run record's `failure` says (#4094):
110
+ * `chant ci run` writes it on a failed job. Optional, added within
111
+ * contract 1 by chant {@link RUN_EVENT_FAILURE_FLOOR}.
112
+ */
113
+ failure?: RunEventFailure;
108
114
  }
109
115
 
116
+ /** Why a run failed: a short code, a line for a person, the phase and the exit code when known (#4094). */
117
+ export interface RunEventFailure {
118
+ code: string;
119
+ reason: string;
120
+ phase?: string;
121
+ exitCode?: number;
122
+ }
123
+
124
+ /** The first chant that writes `failure` on `run-settle` (#4094). */
125
+ export const RUN_EVENT_FAILURE_FLOOR = "0.124.30";
126
+
110
127
  export interface RunEventWaveStart {
111
128
  type: "wave-start";
112
129
  /** 1-based, into `run-start`'s `waves`. */
@@ -37,6 +37,8 @@ export interface CheckRun {
37
37
  * time in milliseconds, which orders a re-run after the run it replaced.
38
38
  */
39
39
  opRun?: OpRunRef;
40
+ /** For a failed Op run, its record's `failure.code` (#4094), which a revoke note names. Absent otherwise. */
41
+ failureCode?: string;
40
42
  }
41
43
 
42
44
  /** The Op run a {@link CheckRun} stands for (#4044). */
@@ -117,6 +117,8 @@ export interface RunVerdict {
117
117
  verdict: Verdict;
118
118
  /** The Op run this is, when the source is chant runs (#4044). */
119
119
  opRun?: OpRunRef;
120
+ /** Why the Op run failed, its record's `failure.code` (#4094), when it says. */
121
+ failureCode?: string;
120
122
  }
121
123
 
122
124
  export interface PhaseVerdict {
@@ -144,7 +146,7 @@ export function evaluatePhase(phase: CiPhase, latest: readonly CheckRun[]): Phas
144
146
  if (matched.length === 0) unmatched.push(pattern);
145
147
  for (const r of matched) {
146
148
  if (runs.some((x) => x.name === r.name)) continue;
147
- runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}) });
149
+ runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}), ...(r.failureCode ? { failureCode: r.failureCode } : {}) });
148
150
  }
149
151
  }
150
152
  const verdict: Verdict = runs.some((r) => r.verdict === "fail")
@@ -263,7 +265,8 @@ export function greenAnnotation(green: CiGreen, sha: string, verdict: CommitVerd
263
265
  /** The annotation of a revoked tag: the phase and the run that failed, and the time, signed when `signer` is given (#4031). */
264
266
  export function revokedAnnotation(green: CiGreen, sha: string, failure: NonNullable<CommitVerdict["failure"]>, at: Date, signer?: CiTagSigner): string {
265
267
  const record: CiTagRecord = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), ...sourceField(green), phase: failure.phase, run: runRecord(failure.run) };
266
- return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${recordJson(record, signer)}\n`;
268
+ const code = failure.run.failureCode ? `, ${failure.run.failureCode}` : "";
269
+ return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status}${code})\n\n${recordJson(record, signer)}\n`;
267
270
  }
268
271
 
269
272
  /**
@@ -627,6 +630,7 @@ export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
627
630
  completedAt: settled ? record.ended : null,
628
631
  url: record.runOrigin?.url ?? null,
629
632
  opRun: { id: record.id, env: record.env, signer, statement: record.statement! },
633
+ ...(record.status === "fail" && record.failure ? { failureCode: record.failure.code } : {}),
630
634
  };
631
635
  }
632
636