@intentius/chant 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/gate-resume.d.ts +44 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +1 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run-generate.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts +7 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +11 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lexicon.d.ts +22 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +7 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +104 -0
- package/dist/op/gate-resume.d.ts.map +1 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts +15 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +115 -0
- package/dist/op/op-waves-run.d.ts.map +1 -0
- package/dist/op/op-waves.d.ts +158 -0
- package/dist/op/op-waves.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +51 -0
- package/src/cli/handlers/gate-resume.ts +166 -0
- package/src/cli/handlers/operator.ts +8 -2
- package/src/cli/handlers/run-generate.ts +51 -1
- package/src/cli/handlers/run-wave.ts +43 -0
- package/src/cli/main.ts +28 -2
- package/src/cli/registry.ts +11 -1
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-resume.e2e.test.ts +135 -0
- package/src/op/gate-resume.test.ts +177 -0
- package/src/op/gate-resume.ts +267 -0
- package/src/op/gate.ts +4 -0
- package/src/op/generate-pipeline.ts +49 -0
- package/src/op/index.ts +13 -2
- package/src/op/op-waves-run.ts +325 -0
- package/src/op/op-waves.test.ts +95 -0
- package/src/op/op-waves.ts +250 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume the job that waits at a gate (#3683).
|
|
3
|
+
*
|
|
4
|
+
* A CI job that reaches a gate nobody approved records a pending fact on
|
|
5
|
+
* `chant/lifecycle` and exits 3. Nothing re-runs it when the approval lands,
|
|
6
|
+
* because a push to the ledger branch starts no pipeline. This module closes
|
|
7
|
+
* that loop through the forge's API.
|
|
8
|
+
*
|
|
9
|
+
* When the job runs in CI, the pending fact it records carries a
|
|
10
|
+
* {@link GateRunLocator}, read from the job's environment by
|
|
11
|
+
* {@link resolveGateRunLocator}: which forge, the API base, the repository,
|
|
12
|
+
* the run, and what the forge needs to start it again. After an approval,
|
|
13
|
+
* {@link resumeGateRun} starts it again:
|
|
14
|
+
*
|
|
15
|
+
* | Forge | Call | Token |
|
|
16
|
+
* |---|---|---|
|
|
17
|
+
* | GitHub | `POST /repos/{repo}/actions/runs/{run}/rerun-failed-jobs` | `actions: write` (the job's `GITHUB_TOKEN` with that permission, or a fine-grained token with Actions read and write) |
|
|
18
|
+
* | GitLab | `POST /projects/{id}/jobs/{job}/retry` | a project or personal access token with the `api` scope and the Developer role; `CI_JOB_TOKEN` cannot retry jobs |
|
|
19
|
+
* | Forgejo | `POST /repos/{repo}/actions/workflows/{file}/dispatches` on the run's branch | a token with the `write:repository` scope; Forgejo has no API to re-run a run, so it starts a new run of the workflow |
|
|
20
|
+
*
|
|
21
|
+
* GitHub re-runs the failed job and the jobs that need it, at the same
|
|
22
|
+
* commit. GitLab retries the job, and the jobs it skipped run after it. A
|
|
23
|
+
* Forgejo dispatch runs the whole workflow again at the branch's head, so the
|
|
24
|
+
* jobs before the gate run again too; they must be safe to repeat (a wave
|
|
25
|
+
* whose runs already applied plans nothing and applies nothing new).
|
|
26
|
+
*
|
|
27
|
+
* Resuming approves nothing. The resumed job decides the gate again from the
|
|
28
|
+
* ledger, so resuming a run whose approval does not count only makes it wait
|
|
29
|
+
* again. {@link resumeGateRun} skips a run that is still going, that ended
|
|
30
|
+
* well, or that was already resumed (a later GitHub attempt, a newer GitLab
|
|
31
|
+
* job of the same name, a newer Forgejo run of the workflow), so a scheduled
|
|
32
|
+
* resume job can call it every few minutes without starting a run twice.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
36
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
37
|
+
import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
|
|
38
|
+
import { tallyGateApprovals } from "./gate";
|
|
39
|
+
|
|
40
|
+
/** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
|
|
41
|
+
export interface GateRunLocator {
|
|
42
|
+
forge: ForgeKind;
|
|
43
|
+
/** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
|
|
44
|
+
api: string;
|
|
45
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
46
|
+
repo: string;
|
|
47
|
+
/** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
|
|
48
|
+
run: string;
|
|
49
|
+
/** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
|
|
50
|
+
attempt?: number;
|
|
51
|
+
/** GitLab: the job that waited. */
|
|
52
|
+
job?: string;
|
|
53
|
+
/** GitLab: that job's name, to find a retry of it. */
|
|
54
|
+
jobName?: string;
|
|
55
|
+
/** Forgejo: the workflow file the run came from, which a dispatch names. */
|
|
56
|
+
workflow?: string;
|
|
57
|
+
/** Forgejo: the branch a dispatch runs on. */
|
|
58
|
+
ref?: string;
|
|
59
|
+
/** The run's page, for a person. */
|
|
60
|
+
url?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The locator of the CI job this process runs in, or undefined outside CI or
|
|
67
|
+
* when the environment lacks a part a resume needs. Forgejo Actions sets the
|
|
68
|
+
* `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
|
|
69
|
+
* `GITEA_ACTIONS`.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
|
|
72
|
+
if (env.GITLAB_CI === "true") {
|
|
73
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
|
|
74
|
+
return {
|
|
75
|
+
forge: "gitlab",
|
|
76
|
+
api: trimSlash(env.CI_API_V4_URL),
|
|
77
|
+
repo: env.CI_PROJECT_ID,
|
|
78
|
+
run: env.CI_PIPELINE_ID,
|
|
79
|
+
job: env.CI_JOB_ID,
|
|
80
|
+
...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
|
|
81
|
+
...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
|
|
85
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
86
|
+
const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
|
|
87
|
+
const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
|
|
88
|
+
if (forgejo) {
|
|
89
|
+
const workflow = forgejoWorkflowFile(env);
|
|
90
|
+
const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
|
|
91
|
+
if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
forge: "forgejo",
|
|
94
|
+
api: trimSlash(api),
|
|
95
|
+
repo: env.GITHUB_REPOSITORY,
|
|
96
|
+
run: env.GITHUB_RUN_ID,
|
|
97
|
+
workflow,
|
|
98
|
+
ref: env.GITHUB_REF_NAME,
|
|
99
|
+
...(url ? { url } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
103
|
+
return {
|
|
104
|
+
forge: "github",
|
|
105
|
+
api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
|
|
106
|
+
repo: env.GITHUB_REPOSITORY,
|
|
107
|
+
run: env.GITHUB_RUN_ID,
|
|
108
|
+
...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
|
|
109
|
+
...(url ? { url } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The workflow file a Forgejo run came from, by its base name. Forgejo names
|
|
115
|
+
* a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
|
|
116
|
+
* (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
|
|
117
|
+
*/
|
|
118
|
+
function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
|
|
119
|
+
const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
|
|
120
|
+
if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
|
|
121
|
+
const name = env.GITHUB_WORKFLOW;
|
|
122
|
+
return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether `value` is a {@link GateRunLocator} a resume can use. */
|
|
126
|
+
export function isGateRunLocator(value: unknown): value is GateRunLocator {
|
|
127
|
+
if (!value || typeof value !== "object") return false;
|
|
128
|
+
const v = value as Record<string, unknown>;
|
|
129
|
+
if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
|
|
130
|
+
if (v.forge === "github") return true;
|
|
131
|
+
if (v.forge === "gitlab") return typeof v.job === "string";
|
|
132
|
+
if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What {@link resumeGateRun} did. */
|
|
137
|
+
export type GateResumeOutcome =
|
|
138
|
+
| { status: "resumed"; forge: ForgeKind; how: string; url?: string }
|
|
139
|
+
| { status: "skipped"; forge: ForgeKind; reason: string; url?: string };
|
|
140
|
+
|
|
141
|
+
export interface ResumeGateRunOptions {
|
|
142
|
+
token: string;
|
|
143
|
+
/** The approval's time. A Forgejo run of the workflow created after it means the run was already resumed. */
|
|
144
|
+
since?: string;
|
|
145
|
+
fetch?: ForgeFetch;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
149
|
+
|
|
150
|
+
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
151
|
+
return async (method: string, path: string, body?: unknown): Promise<unknown> => {
|
|
152
|
+
const url = `${trimSlash(base)}${path}`;
|
|
153
|
+
const res = await fetchImpl(url, {
|
|
154
|
+
method,
|
|
155
|
+
headers: { ...headers, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
156
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
157
|
+
});
|
|
158
|
+
const text = await res.text();
|
|
159
|
+
if (!res.ok) throw new ForgeApiError(method, url, res.status, text);
|
|
160
|
+
if (!text) return null;
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Start the job a pending fact's locator names again, or say why not. Throws
|
|
171
|
+
* {@link ForgeApiError} when the forge refuses a call, such as a token
|
|
172
|
+
* without the access the table in the module doc lists.
|
|
173
|
+
*/
|
|
174
|
+
export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOptions): Promise<GateResumeOutcome> {
|
|
175
|
+
const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
|
|
176
|
+
const url = loc.url ? { url: loc.url } : {};
|
|
177
|
+
const skip = (reason: string): GateResumeOutcome => ({ status: "skipped", forge: loc.forge, reason, ...url });
|
|
178
|
+
|
|
179
|
+
if (loc.forge === "github") {
|
|
180
|
+
const call = client(loc.api, { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }, fetchImpl);
|
|
181
|
+
const path = `/repos/${loc.repo}/actions/runs/${loc.run}`;
|
|
182
|
+
const run = await call("GET", path);
|
|
183
|
+
if (!isObject(run)) throw new Error(`GET ${path} returned no run`);
|
|
184
|
+
if (run.status !== "completed") return skip(`run ${loc.run} is ${String(run.status)}, not finished`);
|
|
185
|
+
const attempt = typeof run.run_attempt === "number" ? run.run_attempt : undefined;
|
|
186
|
+
if (loc.attempt !== undefined && attempt !== undefined && attempt > loc.attempt) {
|
|
187
|
+
return skip(`run ${loc.run} was already re-run (attempt ${attempt}; attempt ${loc.attempt} waited)`);
|
|
188
|
+
}
|
|
189
|
+
if (run.conclusion === "success") return skip(`run ${loc.run} succeeded`);
|
|
190
|
+
await call("POST", `${path}/rerun-failed-jobs`, {});
|
|
191
|
+
return { status: "resumed", forge: "github", how: `re-ran the failed jobs of run ${loc.run}`, ...url };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (loc.forge === "gitlab") {
|
|
195
|
+
const call = client(loc.api, { "PRIVATE-TOKEN": opts.token }, fetchImpl);
|
|
196
|
+
const project = `/projects/${encodeURIComponent(decodeURIComponent(loc.repo))}`;
|
|
197
|
+
const job = await call("GET", `${project}/jobs/${loc.job}`);
|
|
198
|
+
if (!isObject(job)) throw new Error(`GET ${project}/jobs/${loc.job} returned no job`);
|
|
199
|
+
const status = String(job.status);
|
|
200
|
+
if (status === "success") return skip(`job ${loc.job} succeeded`);
|
|
201
|
+
if (!["failed", "canceled", "skipped"].includes(status)) return skip(`job ${loc.job} is ${status}, not finished`);
|
|
202
|
+
const name = loc.jobName ?? (typeof job.name === "string" ? job.name : undefined);
|
|
203
|
+
if (name) {
|
|
204
|
+
// The pipeline's jobs list leaves retried jobs out, so the job of this name is its newest try.
|
|
205
|
+
const jobs = await call("GET", `${project}/pipelines/${loc.run}/jobs?per_page=100`);
|
|
206
|
+
const newest = Array.isArray(jobs) ? jobs.find((j) => isObject(j) && j.name === name) : undefined;
|
|
207
|
+
if (isObject(newest) && String(newest.id) !== String(loc.job)) {
|
|
208
|
+
return skip(`job ${loc.job} (${name}) was already retried as job ${String(newest.id)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const retried = await call("POST", `${project}/jobs/${loc.job}/retry`);
|
|
212
|
+
const id = isObject(retried) && retried.id !== undefined ? ` as job ${String(retried.id)}` : "";
|
|
213
|
+
return { status: "resumed", forge: "gitlab", how: `retried job ${loc.job}${id}`, ...url };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const call = client(loc.api, { Authorization: `token ${opts.token}`, Accept: "application/json" }, fetchImpl);
|
|
217
|
+
const repo = `/repos/${loc.repo}`;
|
|
218
|
+
const run = await call("GET", `${repo}/actions/runs/${loc.run}`);
|
|
219
|
+
if (!isObject(run)) throw new Error(`GET ${repo}/actions/runs/${loc.run} returned no run`);
|
|
220
|
+
const status = String(run.status);
|
|
221
|
+
if (status === "success") return skip(`run ${loc.run} succeeded`);
|
|
222
|
+
if (["waiting", "running", "blocked", "unknown"].includes(status)) return skip(`run ${loc.run} is ${status}, not finished`);
|
|
223
|
+
const listed = await call("GET", `${repo}/actions/runs?workflow_id=${encodeURIComponent(loc.workflow!)}&limit=50`);
|
|
224
|
+
const runs = isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : Array.isArray(listed) ? listed : [];
|
|
225
|
+
// Forgejo reports `created` in whole seconds.
|
|
226
|
+
const since = opts.since ? Math.floor(new Date(opts.since).getTime() / 1000) * 1000 : undefined;
|
|
227
|
+
const later = runs.find(
|
|
228
|
+
(r) =>
|
|
229
|
+
isObject(r) && Number(r.id) > Number(loc.run) &&
|
|
230
|
+
(r.prettyref === undefined || r.prettyref === loc.ref) &&
|
|
231
|
+
(since === undefined || (typeof r.created === "string" && new Date(r.created).getTime() >= since)),
|
|
232
|
+
);
|
|
233
|
+
if (isObject(later)) return skip(`run ${String(later.id)} of ${loc.workflow} started after the approval`);
|
|
234
|
+
await call("POST", `${repo}/actions/workflows/${encodeURIComponent(loc.workflow!)}/dispatches`, { ref: loc.ref });
|
|
235
|
+
return { status: "resumed", forge: "forgejo", how: `dispatched ${loc.workflow} on ${loc.ref}`, ...url };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The resolution that answers `standing`, read the way a run decides the gate
|
|
240
|
+
* (the plan, the environment, the quorum), or undefined. A workspace's
|
|
241
|
+
* `identity.gates` rule is not applied here: the resumed job applies it, and
|
|
242
|
+
* waits again when the approval does not count.
|
|
243
|
+
*/
|
|
244
|
+
export function resolutionAnswering(
|
|
245
|
+
resolutions: readonly GateResolutionRecord[],
|
|
246
|
+
standing: PendingGateRecord,
|
|
247
|
+
): GateResolutionRecord | undefined {
|
|
248
|
+
const env = standing.environment;
|
|
249
|
+
const mine = resolutions.filter((r) => env === undefined || r.environment === env);
|
|
250
|
+
if (standing.approval) {
|
|
251
|
+
const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval);
|
|
252
|
+
if (tally.permit) return tally.permit;
|
|
253
|
+
return tally.counted.length >= tally.need ? tally.counted[tally.counted.length - 1] : undefined;
|
|
254
|
+
}
|
|
255
|
+
return latestResolutionForPlan(mine, standing.gate, standing.timestamp, standing.planDigest).resolution;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The token a resume uses: `CHANT_FORGE_TOKEN`, else the forge's own
|
|
260
|
+
* (`GITHUB_TOKEN` or `GH_TOKEN` on GitHub and Forgejo, `GITLAB_TOKEN` on
|
|
261
|
+
* GitLab), the same order the pull-request loop reads (`../pr-forge.ts`).
|
|
262
|
+
*/
|
|
263
|
+
export function resumeTokenFromEnv(forge: ForgeKind, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
264
|
+
if (env.CHANT_FORGE_TOKEN) return env.CHANT_FORGE_TOKEN;
|
|
265
|
+
return forge === "gitlab" ? env.GITLAB_TOKEN : env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
266
|
+
}
|
|
267
|
+
|
package/src/op/gate.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { sortedJsonReplacer } from "../utils";
|
|
|
52
52
|
import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
|
|
53
53
|
import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
|
|
54
54
|
import { parseDuration } from "./duration";
|
|
55
|
+
import { resolveGateRunLocator } from "./gate-resume";
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* What appending a pending fact learned about reaching the remote.
|
|
@@ -505,6 +506,8 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
505
506
|
}
|
|
506
507
|
|
|
507
508
|
const url = resolveApprovalUrl();
|
|
509
|
+
// #3683: in CI, where this job runs, so an approval can start it again.
|
|
510
|
+
const resume = resolveGateRunLocator();
|
|
508
511
|
const { record, pushed, pushWarning } = await port.appendPending({
|
|
509
512
|
op: input.op,
|
|
510
513
|
gate: input.gate,
|
|
@@ -515,6 +518,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
515
518
|
...(input.description ? { description: input.description } : {}),
|
|
516
519
|
...(input.runId ? { runId: input.runId } : {}),
|
|
517
520
|
...(url ? { url } : {}),
|
|
521
|
+
...(resume ? { resume } : {}),
|
|
518
522
|
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
519
523
|
...(env !== undefined ? { environment: env } : {}),
|
|
520
524
|
...(input.approval ? { approval: input.approval } : {}),
|
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
type ScheduledOpSpec,
|
|
26
26
|
type OpPipelineFile,
|
|
27
27
|
type OpPipelineJob,
|
|
28
|
+
type OpWavesPipelineOptions,
|
|
28
29
|
} from "../lexicon";
|
|
30
|
+
import { assertOpWavesSpec, type OpWaveJob, type OpWavesSpec } from "./op-waves";
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Load a lexicon's plugin (`@intentius/chant-lexicon-<name>`) to reach its
|
|
@@ -115,3 +117,50 @@ export async function generateOpsPipeline(
|
|
|
115
117
|
const { files, jobs } = plugin.generateOpPipeline(withOpSchedules(ops, discovered.ops), options);
|
|
116
118
|
return { success: true, files, jobs };
|
|
117
119
|
}
|
|
120
|
+
|
|
121
|
+
/** Result of generating an Op waves pipeline (#3679). */
|
|
122
|
+
export interface GenerateOpWavesPipelineResult {
|
|
123
|
+
success: boolean;
|
|
124
|
+
files?: OpPipelineFile[];
|
|
125
|
+
jobs?: OpWaveJob[];
|
|
126
|
+
error?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render ordered waves of Op runs for `lexicon` (#3679, `./op-waves.ts`):
|
|
131
|
+
* check the spec, check that every Op it names exists, then hand it to the
|
|
132
|
+
* lexicon's `generateOpWavesPipeline`.
|
|
133
|
+
*/
|
|
134
|
+
export async function generateOpWavesPipeline(
|
|
135
|
+
spec: OpWavesSpec,
|
|
136
|
+
lexicon: string,
|
|
137
|
+
options: OpWavesPipelineOptions,
|
|
138
|
+
cwd?: string,
|
|
139
|
+
): Promise<GenerateOpWavesPipelineResult> {
|
|
140
|
+
const plugin = await loadLexiconPlugin(lexicon);
|
|
141
|
+
if (!plugin?.generateOpWavesPipeline) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: `Lexicon "${lexicon}" does not render Op waves (no generateOpWavesPipeline). GitLab, GitHub, and Forgejo do.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
assertOpWavesSpec(spec);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
151
|
+
}
|
|
152
|
+
const discovered = await discoverOps({ cwd });
|
|
153
|
+
if (discovered.errors.length > 0) return { success: false, error: discovered.errors.join("\n") };
|
|
154
|
+
const named = [...new Set([spec.op, ...spec.waves.flatMap((w) => w.runs.map((r) => r.op ?? spec.op))])];
|
|
155
|
+
const unknown = named.filter((name) => !discovered.ops.has(name));
|
|
156
|
+
if (unknown.length > 0) {
|
|
157
|
+
const known = [...discovered.ops.keys()].sort().join(", ");
|
|
158
|
+
return { success: false, error: `Unknown Op(s): ${unknown.join(", ")}.${known ? ` Known Ops: ${known}` : " No Ops discovered."}` };
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const { files, jobs } = plugin.generateOpWavesPipeline(spec, options);
|
|
162
|
+
return { success: true, files, jobs };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -49,8 +49,19 @@ export type {
|
|
|
49
49
|
} from "./receipt-store";
|
|
50
50
|
export { discoverOps } from "./discover";
|
|
51
51
|
export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
|
52
|
-
export { generateOpsPipeline, withOpSchedules } from "./generate-pipeline";
|
|
53
|
-
export type { GenerateOpsPipelineResult } from "./generate-pipeline";
|
|
52
|
+
export { generateOpsPipeline, generateOpWavesPipeline, withOpSchedules } from "./generate-pipeline";
|
|
53
|
+
export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from "./generate-pipeline";
|
|
54
|
+
export {
|
|
55
|
+
OP_WAVE_DEFAULT_APPLY,
|
|
56
|
+
OP_WAVE_GATE_POLICIES,
|
|
57
|
+
assertOpWavesSpec,
|
|
58
|
+
opWaveJobs,
|
|
59
|
+
opWaveRecordPath,
|
|
60
|
+
opWaveShare,
|
|
61
|
+
} from "./op-waves";
|
|
62
|
+
export type { OpWave, OpWaveGatePolicy, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
63
|
+
export { opWaveNeedsApproval, parseOpWavesSpec, readOpWavePolicy, runOpWave } from "./op-waves-run";
|
|
64
|
+
export type { OpWaveDecision, OpWaveMember, OpWavePlanFile, RunOpWaveOptions, RunOpWaveResult } from "./op-waves-run";
|
|
54
65
|
export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
|
|
55
66
|
export type { ActivityFn } from "./activity-registry";
|
|
56
67
|
export { loadActivityContracts, mergeActivityContracts } from "./activity-contract-registry";
|