@intentius/chant 0.116.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/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.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/op-waves.d.ts +19 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- 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/main.ts +14 -2
- package/src/cli/registry.ts +6 -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/op-waves-run.ts +1 -1
- package/src/op/op-waves.ts +26 -0
package/src/cli/main.ts
CHANGED
|
@@ -381,7 +381,11 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
381
381
|
} else if (arg === "--pr-loop") {
|
|
382
382
|
result.prLoop = true;
|
|
383
383
|
} else if (arg === "--resume") {
|
|
384
|
-
|
|
384
|
+
// `chant approve <op> <gate> --resume` (#3683) takes no value; the
|
|
385
|
+
// fan-out and pull-request apply take the attempt file they resume.
|
|
386
|
+
const next = args[i + 1];
|
|
387
|
+
if (next === undefined || next.startsWith("-")) result.resumeRun = true;
|
|
388
|
+
else result.resume = args[++i];
|
|
385
389
|
} else if (arg === "--local") {
|
|
386
390
|
result.local = true;
|
|
387
391
|
} else if (arg === "--json") {
|
|
@@ -922,6 +926,9 @@ Ops:
|
|
|
922
926
|
waves spec (#3679): the job a generated waves
|
|
923
927
|
pipeline runs. --decide plans and decides only;
|
|
924
928
|
--share <i> applies one share of a decided wave
|
|
929
|
+
run resume [--op <name>] Start again each CI job that waits at a gate an
|
|
930
|
+
approval now answers, through the forge's API
|
|
931
|
+
(#3683); approves nothing. --dry-run lists them
|
|
925
932
|
run --generate <provider> Write one CI pipeline file per scheduled Op for
|
|
926
933
|
github, gitlab or forgejo (#2533), from every Op that
|
|
927
934
|
declares a schedule or from --spec <file.json>.
|
|
@@ -976,7 +983,10 @@ Ops:
|
|
|
976
983
|
counts only an approval sealed by a key the signers
|
|
977
984
|
file at base lists for --actor. --relayed-by
|
|
978
985
|
<principal> records who carried the approval to chant
|
|
979
|
-
for --actor (a follower, a bot); --sign covers it
|
|
986
|
+
for --actor (a follower, a bot); --sign covers it.
|
|
987
|
+
--resume then starts the CI job that waits at the
|
|
988
|
+
gate again (GitHub re-run, GitLab retry, Forgejo
|
|
989
|
+
dispatch) with CHANT_FORGE_TOKEN
|
|
980
990
|
|
|
981
991
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
982
992
|
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
|
@@ -1829,6 +1839,8 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1829
1839
|
{ name: "run cancel", handler: runOpCancel },
|
|
1830
1840
|
{ name: "run log", handler: runOpLog },
|
|
1831
1841
|
{ name: "run wave", handler: runOpWaveCommand },
|
|
1842
|
+
// #3683 — start the CI jobs that wait at gates whose approval arrived. Imported on first use.
|
|
1843
|
+
{ name: "run resume", handler: async (ctx) => (await import("./handlers/gate-resume")).runResumeCommand(ctx) },
|
|
1832
1844
|
{ name: "run", handler: runOp },
|
|
1833
1845
|
|
|
1834
1846
|
{ name: "operator status", handler: runOperatorStatus },
|
package/src/cli/registry.ts
CHANGED
|
@@ -291,6 +291,12 @@ export interface ParsedArgs {
|
|
|
291
291
|
* partway under that approval.
|
|
292
292
|
*/
|
|
293
293
|
resume?: string;
|
|
294
|
+
/**
|
|
295
|
+
* `chant approve <op> <gate> --resume` (#3683): after recording the
|
|
296
|
+
* approval, start the CI job that waits at the gate again through the
|
|
297
|
+
* forge's API. Set when `--resume` is given no value.
|
|
298
|
+
*/
|
|
299
|
+
resumeRun?: boolean;
|
|
294
300
|
/** `chant audit --tier merge-worthy|all` */
|
|
295
301
|
tier?: string;
|
|
296
302
|
/**
|
|
@@ -46,6 +46,7 @@ import { samePlanDigest } from "./plan-digest";
|
|
|
46
46
|
import { sortedJsonReplacer } from "../utils";
|
|
47
47
|
import { currentGateOrigin, type GateOrigin } from "./gate-origin";
|
|
48
48
|
import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
|
|
49
|
+
import type { GateRunLocator } from "../op/gate-resume";
|
|
49
50
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
50
51
|
|
|
51
52
|
const DIR = "_gates";
|
|
@@ -270,6 +271,12 @@ export interface PendingGateRecord {
|
|
|
270
271
|
expiresAt: string;
|
|
271
272
|
/** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
|
|
272
273
|
url?: string;
|
|
274
|
+
/**
|
|
275
|
+
* The CI job that reached this gate (#3683), when it ran in one: what
|
|
276
|
+
* `chant approve --resume` and `chant run resume` hand the forge to start
|
|
277
|
+
* that job again (`../op/gate-resume.ts`). Absent outside CI.
|
|
278
|
+
*/
|
|
279
|
+
resume?: GateRunLocator;
|
|
273
280
|
/**
|
|
274
281
|
* The plan the run reached this gate with (#2300) — `computePlanDigest`'s
|
|
275
282
|
* output (`./plan-digest.ts`). This is what `chant approve` copies onto the
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3683 end to end on a real Forgejo with a runner: a job waits at a gate
|
|
3
|
+
* (exit 3), an approval lands, and {@link resumeGateRun} starts the job again
|
|
4
|
+
* without a push, from the locator {@link resolveGateRunLocator} reads off the
|
|
5
|
+
* job's own environment. The resume call uses a token with the
|
|
6
|
+
* `write:repository` scope alone, the least Forgejo's dispatch accepts.
|
|
7
|
+
*
|
|
8
|
+
* The job stands in for `chant run wave`: it prints its environment and exits
|
|
9
|
+
* 3 until a file named `approved` exists on the `lifecycle` branch. What a
|
|
10
|
+
* chant gate records and decides is covered by the unit tests; this proves
|
|
11
|
+
* the forge's half.
|
|
12
|
+
*
|
|
13
|
+
* Runs only with CHANT_E2E_FORGEJO_URL and CHANT_E2E_FORGEJO_TOKEN (a token
|
|
14
|
+
* with every scope, which creates and deletes the repository), against a
|
|
15
|
+
* Forgejo whose runner takes `docker` jobs. CHANT_E2E_FORGEJO_RESUME_TOKEN is
|
|
16
|
+
* the token the resume calls with; mint it with the `write:repository` scope
|
|
17
|
+
* alone (Forgejo mints tokens only over basic auth). Without it the resume
|
|
18
|
+
* uses the first token.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { resolveGateRunLocator, resumeGateRun } from "./gate-resume";
|
|
23
|
+
|
|
24
|
+
const URL_ = process.env.CHANT_E2E_FORGEJO_URL?.replace(/\/+$/, "");
|
|
25
|
+
const TOKEN = process.env.CHANT_E2E_FORGEJO_TOKEN;
|
|
26
|
+
const RESUME_TOKEN = process.env.CHANT_E2E_FORGEJO_RESUME_TOKEN ?? TOKEN;
|
|
27
|
+
const api = `${URL_}/api/v1`;
|
|
28
|
+
|
|
29
|
+
const WORKFLOW = `name: gate
|
|
30
|
+
on:
|
|
31
|
+
push:
|
|
32
|
+
branches: [main]
|
|
33
|
+
workflow_dispatch: {}
|
|
34
|
+
jobs:
|
|
35
|
+
wait:
|
|
36
|
+
runs-on: docker
|
|
37
|
+
steps:
|
|
38
|
+
- run: |
|
|
39
|
+
env | grep -E '^(GITHUB_|FORGEJO_|GITEA_)' | grep -v TOKEN | sort | sed 's/^/CHANT_E2E_ENV /'
|
|
40
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token \${{ secrets.GITHUB_TOKEN }}" "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/contents/approved?ref=lifecycle")
|
|
41
|
+
echo "approval lookup answered $code"
|
|
42
|
+
[ "$code" = 200 ] || exit 3
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
async function call(method: string, path: string, body?: unknown, token = TOKEN!): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any -- untyped forge JSON
|
|
46
|
+
const res = await fetch(`${api}${path}`, {
|
|
47
|
+
method,
|
|
48
|
+
headers: { Authorization: `token ${token}`, "Content-Type": "application/json" },
|
|
49
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text.slice(0, 300)}`);
|
|
53
|
+
try {
|
|
54
|
+
return text ? JSON.parse(text) : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
61
|
+
|
|
62
|
+
interface Run {
|
|
63
|
+
id: number;
|
|
64
|
+
status: string;
|
|
65
|
+
event: string;
|
|
66
|
+
trigger_event?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runs(repo: string): Promise<Run[]> {
|
|
70
|
+
const listed = await call("GET", `/repos/${repo}/actions/runs?limit=50`);
|
|
71
|
+
return (listed.workflow_runs ?? listed) as Run[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function finished(repo: string, pick: (r: Run) => boolean, timeoutMs = 240_000): Promise<Run> {
|
|
75
|
+
const end = Date.now() + timeoutMs;
|
|
76
|
+
while (Date.now() < end) {
|
|
77
|
+
const run = (await runs(repo)).find(pick);
|
|
78
|
+
if (run && !["waiting", "running", "blocked", "unknown"].includes(run.status)) return run;
|
|
79
|
+
await sleep(3000);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`no finished run in ${repo} within ${timeoutMs}ms`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe.skipIf(!URL_ || !TOKEN)("resume a waiting job on Forgejo (#3683)", () => {
|
|
85
|
+
const name = `chant-e2e-resume-${Date.now().toString(36)}`;
|
|
86
|
+
let owner = "";
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
if (owner) await call("DELETE", `/repos/${owner}/${name}`).catch(() => undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the job waits, the approval lands, and a dispatch resumes it without a push", async () => {
|
|
93
|
+
owner = (await call("GET", "/user")).login;
|
|
94
|
+
await call("POST", "/user/repos", { name, auto_init: true, default_branch: "main", private: false });
|
|
95
|
+
const repo = `${owner}/${name}`;
|
|
96
|
+
await call("POST", `/repos/${repo}/branches`, { new_branch_name: "lifecycle", old_branch_name: "main" });
|
|
97
|
+
await call("POST", `/repos/${repo}/contents/.forgejo/workflows/gate.yml`, {
|
|
98
|
+
content: Buffer.from(WORKFLOW).toString("base64"),
|
|
99
|
+
message: "gate workflow",
|
|
100
|
+
branch: "main",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The push runs the job, which waits at the gate.
|
|
104
|
+
const waited = await finished(repo, () => true);
|
|
105
|
+
expect(waited.status).toBe("failure");
|
|
106
|
+
|
|
107
|
+
// Its environment, as the job printed it, is what a pending fact records.
|
|
108
|
+
const jobs = await call("GET", `/repos/${repo}/actions/runs/${waited.id}/jobs`);
|
|
109
|
+
const jobId = (jobs.jobs ?? jobs)[0].id;
|
|
110
|
+
const log = String(await call("GET", `/repos/${repo}/actions/jobs/${jobId}/logs`));
|
|
111
|
+
const env: Record<string, string> = {};
|
|
112
|
+
for (const m of log.matchAll(/CHANT_E2E_ENV ([A-Z_]+)=(.*)/g)) env[m[1]!] = m[2]!.trim();
|
|
113
|
+
const loc = resolveGateRunLocator(env);
|
|
114
|
+
expect(loc).toMatchObject({ forge: "forgejo", repo, run: String(waited.id), workflow: "gate.yml", ref: "main" });
|
|
115
|
+
|
|
116
|
+
const token = RESUME_TOKEN!;
|
|
117
|
+
// The job reaches Forgejo by the address its runner uses, which on a
|
|
118
|
+
// local stack is a container name; this test reaches it by its own.
|
|
119
|
+
const reachable = { ...loc!, api };
|
|
120
|
+
|
|
121
|
+
// Approve (here: the file the job looks for), then resume.
|
|
122
|
+
const approvedAt = new Date().toISOString();
|
|
123
|
+
await call("POST", `/repos/${repo}/contents/approved`, { content: Buffer.from("yes").toString("base64"), message: "approve", branch: "lifecycle" });
|
|
124
|
+
const outcome = await resumeGateRun(reachable, { token, since: approvedAt });
|
|
125
|
+
expect(outcome).toMatchObject({ status: "resumed", how: "dispatched gate.yml on main" });
|
|
126
|
+
|
|
127
|
+
const resumed = await finished(repo, (r) => r.id > waited.id);
|
|
128
|
+
expect(resumed.status).toBe("success");
|
|
129
|
+
expect(resumed.trigger_event ?? resumed.event).toBe("workflow_dispatch");
|
|
130
|
+
|
|
131
|
+
// A second resume finds the run that already started and starts nothing.
|
|
132
|
+
expect(await resumeGateRun(reachable, { token, since: approvedAt })).toMatchObject({ status: "skipped" });
|
|
133
|
+
expect((await runs(repo)).filter((r) => r.id > waited.id)).toHaveLength(1);
|
|
134
|
+
}, 600_000);
|
|
135
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { ForgeFetch } from "../pr-forge";
|
|
3
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
4
|
+
import { evaluateGate, memoryGateLedgerPort } from "./gate";
|
|
5
|
+
import {
|
|
6
|
+
isGateRunLocator,
|
|
7
|
+
resolutionAnswering,
|
|
8
|
+
resolveGateRunLocator,
|
|
9
|
+
resumeGateRun,
|
|
10
|
+
resumeTokenFromEnv,
|
|
11
|
+
type GateRunLocator,
|
|
12
|
+
} from "./gate-resume";
|
|
13
|
+
|
|
14
|
+
/** A fake forge: answers by "METHOD path" and records every call. */
|
|
15
|
+
function fakeForge(routes: Record<string, unknown>) {
|
|
16
|
+
const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: string }> = [];
|
|
17
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
18
|
+
calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: init.body } : {}) });
|
|
19
|
+
const key = `${init.method} ${new URL(url).pathname}${new URL(url).search}`;
|
|
20
|
+
const hit = Object.keys(routes).find((k) => key === k || key.startsWith(`${k}?`) || key.startsWith(k));
|
|
21
|
+
if (!hit) return { ok: false, status: 404, text: async () => `no route for ${key}` };
|
|
22
|
+
const body = routes[hit];
|
|
23
|
+
return { ok: true, status: 200, text: async () => (body === null ? "" : JSON.stringify(body)) };
|
|
24
|
+
};
|
|
25
|
+
return { fetch, calls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GITHUB_ENV = {
|
|
29
|
+
GITHUB_ACTIONS: "true",
|
|
30
|
+
GITHUB_REPOSITORY: "acme/db",
|
|
31
|
+
GITHUB_RUN_ID: "42",
|
|
32
|
+
GITHUB_RUN_ATTEMPT: "1",
|
|
33
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
34
|
+
GITHUB_API_URL: "https://api.github.com",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("resolveGateRunLocator", () => {
|
|
38
|
+
test("GitHub Actions: the run and the attempt that waited", () => {
|
|
39
|
+
expect(resolveGateRunLocator(GITHUB_ENV)).toEqual({
|
|
40
|
+
forge: "github",
|
|
41
|
+
api: "https://api.github.com",
|
|
42
|
+
repo: "acme/db",
|
|
43
|
+
run: "42",
|
|
44
|
+
attempt: 1,
|
|
45
|
+
url: "https://github.com/acme/db/actions/runs/42",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("Forgejo Actions: the workflow file and branch a dispatch names", () => {
|
|
50
|
+
const loc = resolveGateRunLocator({
|
|
51
|
+
...GITHUB_ENV,
|
|
52
|
+
FORGEJO_ACTIONS: "true",
|
|
53
|
+
GITHUB_SERVER_URL: "https://code.example.org",
|
|
54
|
+
GITHUB_API_URL: "https://code.example.org/api/v1",
|
|
55
|
+
GITHUB_WORKFLOW: "migrations.yml",
|
|
56
|
+
GITHUB_REF_NAME: "main",
|
|
57
|
+
});
|
|
58
|
+
expect(loc).toMatchObject({ forge: "forgejo", api: "https://code.example.org/api/v1", workflow: "migrations.yml", ref: "main", run: "42" });
|
|
59
|
+
expect(isGateRunLocator(loc)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("GitLab CI: the pipeline, the job and its name", () => {
|
|
63
|
+
expect(
|
|
64
|
+
resolveGateRunLocator({
|
|
65
|
+
GITLAB_CI: "true",
|
|
66
|
+
CI_API_V4_URL: "https://gitlab.example.com/api/v4/",
|
|
67
|
+
CI_PROJECT_ID: "7",
|
|
68
|
+
CI_PIPELINE_ID: "100",
|
|
69
|
+
CI_JOB_ID: "555",
|
|
70
|
+
CI_JOB_NAME: "wave-2-staging",
|
|
71
|
+
}),
|
|
72
|
+
).toEqual({ forge: "gitlab", api: "https://gitlab.example.com/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("outside CI, or missing a part a resume needs, there is none", () => {
|
|
76
|
+
expect(resolveGateRunLocator({})).toBeUndefined();
|
|
77
|
+
expect(resolveGateRunLocator({ GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "a/b" })).toBeUndefined();
|
|
78
|
+
expect(resolveGateRunLocator({ ...GITHUB_ENV, GITEA_ACTIONS: "true", GITHUB_WORKFLOW: "Deploy" })).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("resumeGateRun", () => {
|
|
83
|
+
const github: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/db", run: "42", attempt: 1 };
|
|
84
|
+
|
|
85
|
+
test("GitHub: re-runs the failed jobs of a finished run, with a bearer token", async () => {
|
|
86
|
+
const forge = fakeForge({
|
|
87
|
+
"GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 1 },
|
|
88
|
+
"POST /repos/acme/db/actions/runs/42/rerun-failed-jobs": null,
|
|
89
|
+
});
|
|
90
|
+
const out = await resumeGateRun(github, { token: "t", fetch: forge.fetch });
|
|
91
|
+
expect(out).toMatchObject({ status: "resumed", how: "re-ran the failed jobs of run 42" });
|
|
92
|
+
expect(forge.calls.map((c) => c.method)).toEqual(["GET", "POST"]);
|
|
93
|
+
expect(forge.calls[1]!.headers.Authorization).toBe("Bearer t");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("GitHub: a run still going, or already re-run, is left alone", async () => {
|
|
97
|
+
const running = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "in_progress", run_attempt: 1 } });
|
|
98
|
+
expect(await resumeGateRun(github, { token: "t", fetch: running.fetch })).toMatchObject({ status: "skipped" });
|
|
99
|
+
const rerun = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 2 } });
|
|
100
|
+
const out = await resumeGateRun(github, { token: "t", fetch: rerun.fetch });
|
|
101
|
+
expect(out).toMatchObject({ status: "skipped", reason: expect.stringContaining("already re-run") });
|
|
102
|
+
expect(rerun.calls.some((c) => c.method === "POST")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("GitLab: retries the waiting job, unless a newer job of its name exists", async () => {
|
|
106
|
+
const gitlab: GateRunLocator = { forge: "gitlab", api: "https://gl/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" };
|
|
107
|
+
const forge = fakeForge({
|
|
108
|
+
"GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
|
|
109
|
+
"GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 555, name: "wave-2-staging" }],
|
|
110
|
+
"POST /api/v4/projects/7/jobs/555/retry": { id: 556 },
|
|
111
|
+
});
|
|
112
|
+
expect(await resumeGateRun(gitlab, { token: "t", fetch: forge.fetch })).toMatchObject({ status: "resumed", how: "retried job 555 as job 556" });
|
|
113
|
+
expect(forge.calls[2]!.headers["PRIVATE-TOKEN"]).toBe("t");
|
|
114
|
+
|
|
115
|
+
const retried = fakeForge({
|
|
116
|
+
"GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
|
|
117
|
+
"GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 556, name: "wave-2-staging" }],
|
|
118
|
+
});
|
|
119
|
+
expect(await resumeGateRun(gitlab, { token: "t", fetch: retried.fetch })).toMatchObject({ status: "skipped", reason: expect.stringContaining("job 556") });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("Forgejo: dispatches the workflow on its branch, unless a run of it started after the approval", async () => {
|
|
123
|
+
const forgejo: GateRunLocator = { forge: "forgejo", api: "https://fj/api/v1", repo: "acme/db", run: "9", workflow: "migrations.yml", ref: "main" };
|
|
124
|
+
const forge = fakeForge({
|
|
125
|
+
"GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
|
|
126
|
+
"GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 9, prettyref: "main", created: "2026-10-01T00:00:00Z" }] },
|
|
127
|
+
"POST /api/v1/repos/acme/db/actions/workflows/migrations.yml/dispatches": null,
|
|
128
|
+
});
|
|
129
|
+
const out = await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: forge.fetch });
|
|
130
|
+
expect(out).toMatchObject({ status: "resumed", how: "dispatched migrations.yml on main" });
|
|
131
|
+
expect(JSON.parse(forge.calls[2]!.body!)).toEqual({ ref: "main" });
|
|
132
|
+
expect(forge.calls[2]!.headers.Authorization).toBe("token t");
|
|
133
|
+
|
|
134
|
+
const again = fakeForge({
|
|
135
|
+
"GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
|
|
136
|
+
"GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 10, prettyref: "main", created: "2026-10-02T00:01:00Z" }] },
|
|
137
|
+
});
|
|
138
|
+
expect(await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: again.fetch })).toMatchObject({ status: "skipped" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a refusal from the forge is thrown with its status", async () => {
|
|
142
|
+
const fetch: ForgeFetch = async () => ({ ok: false, status: 403, text: async () => "Resource not accessible by integration" });
|
|
143
|
+
await expect(resumeGateRun(github, { token: "t", fetch })).rejects.toThrow(/403/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("the pending fact and its answer", () => {
|
|
148
|
+
test("a gate reached in CI records where its job runs", async () => {
|
|
149
|
+
const saved = { ...process.env };
|
|
150
|
+
try {
|
|
151
|
+
Object.assign(process.env, GITHUB_ENV);
|
|
152
|
+
const port = memoryGateLedgerPort();
|
|
153
|
+
await evaluateGate(port, { op: "migrations", gate: "migrations-wave-2", planDigest: `sha256:${"a".repeat(64)}`, now: "2026-10-01T00:00:00Z" });
|
|
154
|
+
expect(port.appended[0]!.resume).toMatchObject({ forge: "github", run: "42", attempt: 1 });
|
|
155
|
+
} finally {
|
|
156
|
+
for (const key of Object.keys(GITHUB_ENV)) if (!(key in saved)) delete process.env[key];
|
|
157
|
+
Object.assign(process.env, saved);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("an approval answers the standing fact only for its plan", () => {
|
|
162
|
+
const digest = `sha256:${"a".repeat(64)}`;
|
|
163
|
+
const standing: PendingGateRecord = {
|
|
164
|
+
version: 1, kind: "pending", op: "m", gate: "g", timestamp: "2026-10-01T00:00:00Z", expiresAt: "2026-10-03T00:00:00Z", planDigest: digest,
|
|
165
|
+
};
|
|
166
|
+
const approve = (planDigest: string, timestamp = "2026-10-01T01:00:00Z"): GateResolutionRecord => ({ version: 1, op: "m", gate: "g", resolvedBy: "alice", timestamp, planDigest });
|
|
167
|
+
expect(resolutionAnswering([approve(`sha256:${"b".repeat(64)}`)], standing)).toBeUndefined();
|
|
168
|
+
expect(resolutionAnswering([approve(digest, "2026-09-30T00:00:00Z")], standing)).toBeUndefined();
|
|
169
|
+
expect(resolutionAnswering([approve(digest)], standing)?.resolvedBy).toBe("alice");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("the token: CHANT_FORGE_TOKEN first, then the forge's own", () => {
|
|
173
|
+
expect(resumeTokenFromEnv("github", { GITHUB_TOKEN: "g", CHANT_FORGE_TOKEN: "c" })).toBe("c");
|
|
174
|
+
expect(resumeTokenFromEnv("forgejo", { GITHUB_TOKEN: "g" })).toBe("g");
|
|
175
|
+
expect(resumeTokenFromEnv("gitlab", { GITHUB_TOKEN: "g" })).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -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
|
+
|