@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.
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +4 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -5
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +30 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +49 -0
- package/dist/cli/registry.d.ts +17 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +24 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +16 -0
- package/dist/components/pr-run.d.ts +20 -2
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +26 -13
- package/dist/components/wave-plan.d.ts +11 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +26 -0
- package/dist/lifecycle/release-ledger.d.ts +8 -3
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +2 -0
- package/dist/lifecycle/run-ledger.d.ts +1 -1
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +9 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +1 -0
- package/dist/op/activities/shell.d.ts +6 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +1 -1
- package/dist/op/local-executor.d.ts +23 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/runtime.d.ts +27 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtime.js +20 -0
- package/dist/pr-forge.d.ts +23 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +44 -0
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/run-event-stream.d.ts +2 -2
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +2 -2
- package/dist/run-event.d.ts +15 -0
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +171 -171
- package/dist/workspace/ci-green-forge.d.ts +2 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +2 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +4 -2
- package/dist/workspace/ci-run.d.ts +222 -0
- package/dist/workspace/ci-run.d.ts.map +1 -0
- package/dist/workspace/ci-run.js +464 -0
- package/dist/workspace/ci-serve.d.ts +214 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -0
- package/dist/workspace/ci-serve.js +412 -0
- package/dist/workspace/pr-report.schema.json +5 -0
- package/dist/workspace/run-event.schema.json +14 -3
- package/dist/workspace/status-stewards.d.ts +2 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +1 -0
- package/dist/workspace/status.schema.json +12 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +4 -1
- package/src/cli/handlers/fan-out.test.ts +82 -0
- package/src/cli/handlers/fan-out.ts +34 -5
- package/src/cli/handlers/pr.ts +5 -0
- package/src/cli/main.ts +30 -0
- package/src/cli/parse-args.ts +35 -0
- package/src/cli/registry.ts +17 -1
- package/src/components/pr-pipeline.ts +32 -0
- package/src/components/pr-run.test.ts +127 -0
- package/src/components/pr-run.ts +32 -12
- package/src/components/wave-plan.ts +30 -0
- package/src/lifecycle/release-ledger.ts +9 -3
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/shell.ts +7 -1
- package/src/op/local-executor.ts +27 -4
- package/src/op/runtime.ts +42 -0
- package/src/pr-forge.test.ts +25 -0
- package/src/pr-forge.ts +62 -0
- package/src/pr-loop.test.ts +13 -1
- package/src/pr-loop.ts +16 -0
- package/src/run-event-stream.ts +4 -3
- package/src/run-event.ts +17 -0
- package/src/workspace/ci-green-forge.ts +2 -0
- package/src/workspace/ci-green.ts +6 -2
- package/src/workspace/ci-run.test.ts +416 -0
- package/src/workspace/ci-run.ts +571 -0
- package/src/workspace/ci-serve.test.ts +268 -0
- package/src/workspace/ci-serve.ts +521 -0
- package/src/workspace/pr-report.schema.json +5 -0
- package/src/workspace/run-event.schema.json +14 -3
- package/src/workspace/status-stewards.ts +3 -0
- 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),
|
|
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
|
|
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
|
});
|
package/src/op/local-executor.ts
CHANGED
|
@@ -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. */
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -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
|
},
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -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
|
}
|
package/src/run-event-stream.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|