@intentius/chant 0.124.5 → 0.124.7
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 +32 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.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/lifecycle/rollback.d.ts.map +1 -1
- package/dist/op/activities/forge-pull-request.d.ts +49 -0
- package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +6 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +1 -1
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +6 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +2 -31
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +40 -0
- package/dist/op/gate-run-locator.d.ts.map +1 -0
- package/dist/point-decision.d.ts +63 -1
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +72 -7
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +15 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.ts +84 -11
- package/src/cli/main.ts +4 -2
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/lifecycle/rollback.ts +10 -2
- package/src/op/activities/forge-pull-request.test.ts +91 -0
- package/src/op/activities/forge-pull-request.ts +98 -0
- package/src/op/activities/lexicon-upgrade.test.ts +58 -0
- package/src/op/activities/lexicon-upgrade.ts +34 -8
- package/src/op/activities/propose-upgrade.ts +13 -0
- package/src/op/activities/reconcile.ts +10 -2
- package/src/op/gate-point-ci.e2e.test.ts +39 -2
- package/src/op/gate-point-resume.e2e.test.ts +203 -0
- package/src/op/gate-point-run.ts +3 -0
- package/src/op/gate-point.ts +11 -0
- package/src/op/gate-resume.ts +5 -95
- package/src/op/gate-run-locator.ts +105 -0
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +97 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.test.ts +154 -0
- package/src/pr-forge.ts +179 -12
- package/src/workspace/points-cli.ts +7 -2
- package/src/workspace/records-cli.ts +38 -7
- package/src/workspace/records.schema.json +4 -0
package/src/op/gate-resume.ts
CHANGED
|
@@ -36,102 +36,10 @@ import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
|
36
36
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
37
37
|
import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
|
|
38
38
|
import { humanQuorumSince, tallyGateApprovals } from "./gate";
|
|
39
|
+
import type { GateRunLocator } from "./gate-run-locator";
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
export
|
|
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
|
-
}
|
|
41
|
+
// The locator is read where a pending fact is recorded, which `@intentius/chant/point-decision` does too, so it lives on its own (#3835).
|
|
42
|
+
export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./gate-run-locator";
|
|
135
43
|
|
|
136
44
|
/** What {@link resumeGateRun} did. */
|
|
137
45
|
export type GateResumeOutcome =
|
|
@@ -145,6 +53,8 @@ export interface ResumeGateRunOptions {
|
|
|
145
53
|
fetch?: ForgeFetch;
|
|
146
54
|
}
|
|
147
55
|
|
|
56
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
57
|
+
|
|
148
58
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
149
59
|
|
|
150
60
|
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a CI job that waits at a gate runs, and what its forge needs to start
|
|
3
|
+
* it again (#3683). Read from the job's environment when a pending fact is
|
|
4
|
+
* recorded; `./gate-resume.ts` uses it to resume the job. It imports nothing
|
|
5
|
+
* at run time, so `@intentius/chant/point-decision` records it on the pending
|
|
6
|
+
* fact of a gate that asks a decision point too (#3835).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ForgeKind } from "../pr-forge";
|
|
10
|
+
|
|
11
|
+
/** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
|
|
12
|
+
export interface GateRunLocator {
|
|
13
|
+
forge: ForgeKind;
|
|
14
|
+
/** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
|
|
15
|
+
api: string;
|
|
16
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
17
|
+
repo: string;
|
|
18
|
+
/** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
|
|
19
|
+
run: string;
|
|
20
|
+
/** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
|
|
21
|
+
attempt?: number;
|
|
22
|
+
/** GitLab: the job that waited. */
|
|
23
|
+
job?: string;
|
|
24
|
+
/** GitLab: that job's name, to find a retry of it. */
|
|
25
|
+
jobName?: string;
|
|
26
|
+
/** Forgejo: the workflow file the run came from, which a dispatch names. */
|
|
27
|
+
workflow?: string;
|
|
28
|
+
/** Forgejo: the branch a dispatch runs on. */
|
|
29
|
+
ref?: string;
|
|
30
|
+
/** The run's page, for a person. */
|
|
31
|
+
url?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The locator of the CI job this process runs in, or undefined outside CI or
|
|
38
|
+
* when the environment lacks a part a resume needs. Forgejo Actions sets the
|
|
39
|
+
* `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
|
|
40
|
+
* `GITEA_ACTIONS`.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
|
|
43
|
+
if (env.GITLAB_CI === "true") {
|
|
44
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
|
|
45
|
+
return {
|
|
46
|
+
forge: "gitlab",
|
|
47
|
+
api: trimSlash(env.CI_API_V4_URL),
|
|
48
|
+
repo: env.CI_PROJECT_ID,
|
|
49
|
+
run: env.CI_PIPELINE_ID,
|
|
50
|
+
job: env.CI_JOB_ID,
|
|
51
|
+
...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
|
|
52
|
+
...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
|
|
56
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
57
|
+
const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
|
|
58
|
+
const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
|
|
59
|
+
if (forgejo) {
|
|
60
|
+
const workflow = forgejoWorkflowFile(env);
|
|
61
|
+
const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
|
|
62
|
+
if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
forge: "forgejo",
|
|
65
|
+
api: trimSlash(api),
|
|
66
|
+
repo: env.GITHUB_REPOSITORY,
|
|
67
|
+
run: env.GITHUB_RUN_ID,
|
|
68
|
+
workflow,
|
|
69
|
+
ref: env.GITHUB_REF_NAME,
|
|
70
|
+
...(url ? { url } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
74
|
+
return {
|
|
75
|
+
forge: "github",
|
|
76
|
+
api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
|
|
77
|
+
repo: env.GITHUB_REPOSITORY,
|
|
78
|
+
run: env.GITHUB_RUN_ID,
|
|
79
|
+
...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
|
|
80
|
+
...(url ? { url } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The workflow file a Forgejo run came from, by its base name. Forgejo names
|
|
86
|
+
* a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
|
|
87
|
+
* (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
|
|
88
|
+
*/
|
|
89
|
+
function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
|
|
90
|
+
const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
|
|
91
|
+
if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
|
|
92
|
+
const name = env.GITHUB_WORKFLOW;
|
|
93
|
+
return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether `value` is a {@link GateRunLocator} a resume can use. */
|
|
97
|
+
export function isGateRunLocator(value: unknown): value is GateRunLocator {
|
|
98
|
+
if (!value || typeof value !== "object") return false;
|
|
99
|
+
const v = value as Record<string, unknown>;
|
|
100
|
+
if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
|
|
101
|
+
if (v.forge === "github") return true;
|
|
102
|
+
if (v.forge === "gitlab") return typeof v.job === "string";
|
|
103
|
+
if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
@@ -54,6 +54,7 @@ describe("the point-decision bundle", () => {
|
|
|
54
54
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
55
55
|
"packages/core/src/op/duration.ts",
|
|
56
56
|
"packages/core/src/op/gate-point.ts",
|
|
57
|
+
"packages/core/src/op/gate-run-locator.ts",
|
|
57
58
|
"packages/core/src/point-decision.ts",
|
|
58
59
|
]);
|
|
59
60
|
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
package/src/point-decision.ts
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
* TypeScript, zod or git code, which `point-decision-bundle.test.ts` holds
|
|
21
21
|
* it to. `./op/gate-point-run.ts` re-exports it, and chant's own runs decide
|
|
22
22
|
* by it.
|
|
23
|
+
*
|
|
24
|
+
* A gate's resolution is written only when its run asks again, so a resume
|
|
25
|
+
* job asks {@link pointGateResumable} whether a waiting job's question now
|
|
26
|
+
* has an answer that passes the gate (#3835), with the questions
|
|
27
|
+
* {@link chantPointQuestions} reads. `chant run resume` makes the same check.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
import { execFile } from "node:child_process";
|
|
@@ -31,6 +36,7 @@ import { parseDuration } from "./op/duration";
|
|
|
31
36
|
import type { GateLedgerPort } from "./op/gate";
|
|
32
37
|
import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
|
|
33
38
|
import type { WaitingPoint } from "./op/steward-points";
|
|
39
|
+
import { resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
|
|
34
40
|
|
|
35
41
|
export {
|
|
36
42
|
DEFAULT_GATE_POINT_PASS,
|
|
@@ -40,12 +46,14 @@ export {
|
|
|
40
46
|
gatePointOf,
|
|
41
47
|
gatePointProblems,
|
|
42
48
|
gateSubject,
|
|
49
|
+
parseGateSubject,
|
|
43
50
|
type GatePoint,
|
|
44
51
|
type GatePointFacts,
|
|
45
52
|
type GatePointStore,
|
|
46
53
|
} from "./op/gate-point";
|
|
47
54
|
export type { GateAnswerRef, GateResolutionRecord, GateResolutionInput, PendingGateInput, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
48
55
|
export type { WaitingPoint } from "./op/steward-points";
|
|
56
|
+
export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* The gate ledger as {@link evaluatePointGate} uses it: read an op's facts,
|
|
@@ -107,6 +115,12 @@ export interface PointGateInput {
|
|
|
107
115
|
/** The environment the run was started for: the `gate.env` input. Op gate facts on the ledger record none. */
|
|
108
116
|
env?: string;
|
|
109
117
|
now?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Where this job runs, for `chant run resume` to start it again once the
|
|
120
|
+
* question is answered (#3835). Default: read from the CI environment
|
|
121
|
+
* ({@link resolveGateRunLocator}); false records none.
|
|
122
|
+
*/
|
|
123
|
+
resume?: GateRunLocator | false;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
/** What reaching a gate that asks a point decided. */
|
|
@@ -188,6 +202,7 @@ export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePo
|
|
|
188
202
|
return { satisfied: false, waiting, pending: standing, recorded: false };
|
|
189
203
|
}
|
|
190
204
|
const url = resolveApprovalUrl();
|
|
205
|
+
const resume = input.resume === false ? undefined : (input.resume ?? resolveGateRunLocator());
|
|
191
206
|
const { record, pushed, pushWarning } = await port.appendPending({
|
|
192
207
|
op: input.op,
|
|
193
208
|
gate: input.gate,
|
|
@@ -197,11 +212,77 @@ export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePo
|
|
|
197
212
|
...(input.runId ? { runId: input.runId } : {}),
|
|
198
213
|
...(url ? { url } : {}),
|
|
199
214
|
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
200
|
-
|
|
215
|
+
...(resume ? { resume } : {}),
|
|
216
|
+
answer: { ...ref, ...(input.point.pass ? { pass: input.point.pass } : {}) },
|
|
201
217
|
});
|
|
202
218
|
return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
203
219
|
}
|
|
204
220
|
|
|
221
|
+
/**
|
|
222
|
+
* A question's state as a resume reads it: what `chant workspace points
|
|
223
|
+
* --json` lists for it. {@link chantPointQuestions} reads them through the
|
|
224
|
+
* chant bin; chant's own resume reads the workspace.
|
|
225
|
+
*/
|
|
226
|
+
export interface PointQuestionState {
|
|
227
|
+
id: string;
|
|
228
|
+
state: "escalated" | "proposed" | "answered";
|
|
229
|
+
/** The answer, or null while the question is open. */
|
|
230
|
+
answer: string | boolean | null;
|
|
231
|
+
answeredBy: string[];
|
|
232
|
+
/** When it was answered, as the record says, or null. */
|
|
233
|
+
answeredOn: string | null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether a waiting point-asking gate can be resumed, and why not. */
|
|
237
|
+
export type PointResumeCheck =
|
|
238
|
+
| {
|
|
239
|
+
resumable: true;
|
|
240
|
+
/** The answer that passes the gate, as the resumed run will cite it. */
|
|
241
|
+
answer: GateAnswerRef;
|
|
242
|
+
/** Who answered: the people, joined, or `quorum` when the record names none. */
|
|
243
|
+
answeredBy: string;
|
|
244
|
+
/** When it was answered, or null. A resume reads a run started after it as already resumed. */
|
|
245
|
+
answeredOn: string | null;
|
|
246
|
+
}
|
|
247
|
+
| {
|
|
248
|
+
resumable: false;
|
|
249
|
+
/** `open`: nobody has answered; `refused`: the answer does not pass the gate; `unknown`: no question with the id was read; `not-a-point`: the pending fact cites no question. */
|
|
250
|
+
why: "open" | "refused" | "unknown" | "not-a-point";
|
|
251
|
+
message: string;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether the job behind `standing`, a pending fact a gate that asks a
|
|
256
|
+
* decision point recorded, can be started again (#3835): its question is
|
|
257
|
+
* answered with an answer the gate passes on (the pending fact's
|
|
258
|
+
* `answer.pass`, default a noul point's yes). A gate only writes its
|
|
259
|
+
* resolution when its run asks the point again, so nothing on the gate
|
|
260
|
+
* ledger says an answered question passes; this is the check
|
|
261
|
+
* `chant run resume` makes, and a product's own resume job makes the same
|
|
262
|
+
* call with the questions it read (`chantPointQuestions`). It approves
|
|
263
|
+
* nothing: the resumed run asks again and decides.
|
|
264
|
+
*/
|
|
265
|
+
export function pointGateResumable(standing: Pick<PendingGateRecord, "answer">, question: PointQuestionState | undefined): PointResumeCheck {
|
|
266
|
+
const ref = standing.answer;
|
|
267
|
+
if (!ref) return { resumable: false, why: "not-a-point", message: "the pending fact cites no decision point's question" };
|
|
268
|
+
if (!question || question.id !== ref.id) return { resumable: false, why: "unknown", message: `question ${ref.id} (${ref.point}) can't be read here` };
|
|
269
|
+
if (question.state !== "answered" || question.answer === null) return { resumable: false, why: "open", message: `question ${ref.id} (${ref.point}) is ${question.state}, not answered yet` };
|
|
270
|
+
const pass = ref.pass ?? DEFAULT_GATE_POINT_PASS;
|
|
271
|
+
if (!pass.includes(question.answer)) {
|
|
272
|
+
return {
|
|
273
|
+
resumable: false,
|
|
274
|
+
why: "refused",
|
|
275
|
+
message: `question ${ref.id} (${ref.point}) was answered ${JSON.stringify(question.answer)}, and the gate passes only on ${pass.map((a) => JSON.stringify(a)).join(" or ")}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
resumable: true,
|
|
280
|
+
answer: { point: ref.point, id: ref.id, path: ref.path, answer: question.answer, ...(question.answeredBy.length > 0 ? { answeredBy: question.answeredBy } : {}) },
|
|
281
|
+
answeredBy: question.answeredBy.length > 0 ? question.answeredBy.join(", ") : "quorum",
|
|
282
|
+
answeredOn: question.answeredOn,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
205
286
|
/** How {@link chantGatePointAsker} runs chant. */
|
|
206
287
|
export interface ChantGatePointAskerOptions {
|
|
207
288
|
/** The chant command and its leading arguments, such as `["npx", "chant"]` or `[process.execPath, "/path/to/chant"]`. */
|
|
@@ -278,3 +359,18 @@ export function chantGatePointAsker(opts: ChantGatePointAskerOptions): GatePoint
|
|
|
278
359
|
},
|
|
279
360
|
};
|
|
280
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Every question the workspace lists, by id, read through the chant bin with
|
|
365
|
+
* `chant workspace points --json` (the questions held on `chant/lifecycle`
|
|
366
|
+
* included), for {@link pointGateResumable} in a tool that bundles this
|
|
367
|
+
* module (#3835). Fetch `chant/lifecycle` first, as reading the gate ledger
|
|
368
|
+
* does, so an answer given from another clone is seen.
|
|
369
|
+
*/
|
|
370
|
+
export async function chantPointQuestions(opts: ChantGatePointAskerOptions): Promise<Map<string, PointQuestionState>> {
|
|
371
|
+
const listed = await runChantJson(opts, ["workspace", "points", "--json"]);
|
|
372
|
+
const bad = refusal("chant workspace points", listed);
|
|
373
|
+
if (bad) throw bad;
|
|
374
|
+
const questions = (listed.questions as PointQuestionState[] | undefined) ?? [];
|
|
375
|
+
return new Map(questions.map((q) => [q.id, { id: q.id, state: q.state, answer: q.answer ?? null, answeredBy: q.answeredBy ?? [], answeredOn: q.answeredOn ?? null }]));
|
|
376
|
+
}
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About
|
|
37
|
+
// About 27 KB today.
|
|
38
38
|
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(32 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
forgeTargetFromEnv,
|
|
15
15
|
forgejoForge,
|
|
16
16
|
forgePrincipalOf,
|
|
17
|
+
FORGE_STATUS_STATES,
|
|
17
18
|
githubForge,
|
|
18
19
|
gitlabForge,
|
|
20
|
+
statusStateFor,
|
|
19
21
|
type ForgeFetch,
|
|
22
|
+
type ForgeStatusState,
|
|
20
23
|
} from "./pr-forge";
|
|
21
24
|
import { forgePrincipal, parseForgeIdentity } from "./workspace/identity";
|
|
22
25
|
|
|
@@ -675,3 +678,154 @@ describe("addComment and findIssue (#3818)", () => {
|
|
|
675
678
|
expect(fj.calls[0].headers.Authorization).toBeUndefined();
|
|
676
679
|
});
|
|
677
680
|
});
|
|
681
|
+
|
|
682
|
+
describe("status states and author lists (#3834)", () => {
|
|
683
|
+
const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
|
|
684
|
+
|
|
685
|
+
test("each state goes to each forge as its nearest state", () => {
|
|
686
|
+
const table = Object.fromEntries(FORGE_STATUS_STATES.map((s) => [s, [statusStateFor("github", s), statusStateFor("forgejo", s), statusStateFor("gitlab", s)]]));
|
|
687
|
+
expect(table).toEqual({
|
|
688
|
+
pending: ["pending", "pending", "pending"],
|
|
689
|
+
running: ["pending", "pending", "running"],
|
|
690
|
+
success: ["success", "success", "success"],
|
|
691
|
+
failed: ["failure", "failure", "failed"],
|
|
692
|
+
failure: ["failure", "failure", "failed"],
|
|
693
|
+
canceled: ["error", "error", "canceled"],
|
|
694
|
+
error: ["error", "error", "failed"],
|
|
695
|
+
});
|
|
696
|
+
expect(() => statusStateFor("gitlab", "skipped" as ForgeStatusState)).toThrow(/one of pending, running/);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a running status is running on GitLab and pending on GitHub and Forgejo", async () => {
|
|
700
|
+
const gl = fakeFetch({ [`POST /api/v4/projects/42/statuses/${SHA}`]: {} });
|
|
701
|
+
await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "running", description: "planning" });
|
|
702
|
+
await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "canceled", description: "stopped" });
|
|
703
|
+
expect(gl.calls.map((c) => (c.body as { state: string }).state)).toEqual(["running", "canceled"]);
|
|
704
|
+
const g = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: {} });
|
|
705
|
+
await githubForge({ ...gh, fetch: g.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "running", description: "planning" });
|
|
706
|
+
await githubForge({ ...gh, fetch: g.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "failed", description: "no" });
|
|
707
|
+
expect(g.calls.map((c) => (c.body as { state: string }).state)).toEqual(["pending", "failure"]);
|
|
708
|
+
const fj = fakeFetch({ [`POST /api/v1/repos/acme/infra/statuses/${SHA}`]: {} });
|
|
709
|
+
await forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch: fj.fetch }).setStatus(SHA, { context: "c", state: "running", description: "d" });
|
|
710
|
+
expect(fj.calls[0].body).toMatchObject({ state: "pending" });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const BOTS = ["github-actions[bot]", "gitea-actions", "forgejo-actions"];
|
|
714
|
+
|
|
715
|
+
test("a comment by any of several logins is the caller's own, without case", async () => {
|
|
716
|
+
const { fetch, calls } = fakeFetch({
|
|
717
|
+
"GET /api/v1/repos/acme/infra/issues/4/comments": [
|
|
718
|
+
{ id: 1, body: "<!-- tg -->\nplanted", user: { login: "mallory" } },
|
|
719
|
+
{ id: 2, body: "<!-- tg -->\nold", user: { login: "Forgejo-Actions" } },
|
|
720
|
+
],
|
|
721
|
+
"PATCH /api/v1/repos/acme/infra/issues/comments/2": { id: 2, html_url: "https://cb/2" },
|
|
722
|
+
});
|
|
723
|
+
const forge = forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch });
|
|
724
|
+
expect(await forge.findComment(4, "<!-- tg -->", { author: BOTS })).toEqual({ id: 2, body: "<!-- tg -->\nold" });
|
|
725
|
+
expect(await forge.upsertComment(4, "<!-- tg -->", "<!-- tg -->\nnew", { author: BOTS })).toEqual({ action: "updated", id: 2, url: "https://cb/2" });
|
|
726
|
+
expect(calls.at(-1)?.url).toBe("https://codeberg.org/api/v1/repos/acme/infra/issues/comments/2");
|
|
727
|
+
expect(await forge.findComment(4, "<!-- tg -->", { author: [] })).toBeUndefined();
|
|
728
|
+
expect((await forge.findComment(4, "<!-- tg -->", { author: "mallory" }))?.id).toBe(1);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("an issue by any of several logins, for findIssue and trackIssue", async () => {
|
|
732
|
+
const { fetch, calls } = fakeFetch({
|
|
733
|
+
"GET /repos/acme/infra/issues": [
|
|
734
|
+
{ number: 3, title: "Drift", body: "<!-- d -->\nplanted", user: { login: "mallory" } },
|
|
735
|
+
{ number: 4, title: "Drift", body: "<!-- d -->\nold", user: { login: "github-actions[bot]" } },
|
|
736
|
+
],
|
|
737
|
+
"PATCH /repos/acme/infra/issues/4": { number: 4 },
|
|
738
|
+
});
|
|
739
|
+
const forge = githubForge({ ...gh, fetch });
|
|
740
|
+
expect((await forge.findIssue("<!-- d -->", { author: BOTS }))?.number).toBe(4);
|
|
741
|
+
expect(await forge.trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nnew" }, { author: BOTS })).toMatchObject({ action: "updated", number: 4 });
|
|
742
|
+
expect(calls.at(-1)).toMatchObject({ method: "PATCH", url: "https://api.github.com/repos/acme/infra/issues/4" });
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
describe("pull requests over REST (#3798)", () => {
|
|
747
|
+
const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
|
|
748
|
+
const fjOpts = { ...gh, apiBase: "https://forge.example/api/v1", host: "forge.example" };
|
|
749
|
+
const gl = { apiBase: "https://gl/api/v4", project: "group/infra", token: "t" };
|
|
750
|
+
|
|
751
|
+
test("Forgejo opens a pull request into the default branch read from the repository", async () => {
|
|
752
|
+
const { fetch, calls } = fakeFetch({
|
|
753
|
+
"GET /api/v1/repos/acme/infra": { default_branch: "trunk" },
|
|
754
|
+
"POST /api/v1/repos/acme/infra/pulls": (c: Call) => ({ number: 9, html_url: "https://forge.example/acme/infra/pulls/9", title: (c.body as { title: string }).title, body: "b", head: { ref: "chant/x" }, base: { ref: "trunk" } }),
|
|
755
|
+
});
|
|
756
|
+
const pr = await forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "chant/x", title: "t", body: "b" });
|
|
757
|
+
expect(pr).toEqual({ number: 9, url: "https://forge.example/acme/infra/pulls/9", title: "t", body: "b", head: "chant/x", base: "trunk" });
|
|
758
|
+
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["GET https://forge.example/api/v1/repos/acme/infra", "POST https://forge.example/api/v1/repos/acme/infra/pulls"]);
|
|
759
|
+
expect(calls[1].body).toEqual({ title: "t", body: "b", head: "chant/x", base: "trunk" });
|
|
760
|
+
expect(calls[1].headers.Authorization).toBe("token t0k");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a draft is GitHub's draft field, Forgejo's WIP: and GitLab's Draft: prefix", async () => {
|
|
764
|
+
const g = fakeFetch({ "POST /repos/acme/infra/pulls": { number: 1 } });
|
|
765
|
+
await githubForge({ ...gh, fetch: g.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
766
|
+
expect(g.calls[0].body).toEqual({ title: "t", body: "b", head: "h", base: "main", draft: true });
|
|
767
|
+
const f = fakeFetch({ "POST /api/v1/repos/acme/infra/pulls": { number: 1 } });
|
|
768
|
+
await forgejoForge({ ...fjOpts, fetch: f.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
769
|
+
expect(f.calls[0].body).toEqual({ title: "WIP: t", body: "b", head: "h", base: "main" });
|
|
770
|
+
const l = fakeFetch({ "POST /api/v4/projects/group%2Finfra/merge_requests": { iid: 4, web_url: "https://gl/mr/4", source_branch: "h", target_branch: "main", title: "Draft: t", description: "b" } });
|
|
771
|
+
const mr = await gitlabForge({ ...gl, fetch: l.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
772
|
+
expect(l.calls[0].body).toEqual({ source_branch: "h", target_branch: "main", title: "Draft: t", description: "b" });
|
|
773
|
+
expect(mr).toEqual({ number: 4, url: "https://gl/mr/4", title: "Draft: t", body: "b", head: "h", base: "main" });
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("GitLab reads the default branch off the project", async () => {
|
|
777
|
+
const { fetch, calls } = fakeFetch({
|
|
778
|
+
"GET /api/v4/projects/group%2Finfra": { default_branch: "main" },
|
|
779
|
+
"POST /api/v4/projects/group%2Finfra/merge_requests": { iid: 2 },
|
|
780
|
+
});
|
|
781
|
+
await gitlabForge({ ...gl, fetch }).openPullRequest({ head: "h", title: "t", body: "b" });
|
|
782
|
+
expect((calls[1].body as { target_branch: string }).target_branch).toBe("main");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("findPullRequest takes the oldest open one from this repository, not a fork's", async () => {
|
|
786
|
+
const f = fakeFetch({
|
|
787
|
+
"GET /api/v1/repos/acme/infra/pulls": [
|
|
788
|
+
{ number: 12, title: "fork", body: "", head: { ref: "chant/x", repo: { full_name: "mallory/infra" } } },
|
|
789
|
+
{ number: 11, title: "newer", body: "", head: { ref: "chant/x", repo: { full_name: "acme/infra" } } },
|
|
790
|
+
{ number: 10, title: "mine", body: "b", head: { ref: "chant/x", repo: { full_name: "acme/infra" } }, base: { ref: "main" }, html_url: "https://forge.example/acme/infra/pulls/10" },
|
|
791
|
+
{ number: 3, title: "other", body: "", head: { ref: "other", repo: { full_name: "acme/infra" } } },
|
|
792
|
+
],
|
|
793
|
+
});
|
|
794
|
+
const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
|
|
795
|
+
expect(await forge.findPullRequest("chant/x")).toEqual({ number: 10, title: "mine", body: "b", head: "chant/x", base: "main", url: "https://forge.example/acme/infra/pulls/10" });
|
|
796
|
+
expect(f.calls[0].url).toBe("https://forge.example/api/v1/repos/acme/infra/pulls?state=open&limit=50&page=1");
|
|
797
|
+
expect(await forge.findPullRequest("none")).toBeUndefined();
|
|
798
|
+
|
|
799
|
+
const g = fakeFetch({ "GET /repos/acme/infra/pulls": [] });
|
|
800
|
+
expect(await githubForge({ ...gh, fetch: g.fetch }).findPullRequest("chant/x")).toBeUndefined();
|
|
801
|
+
expect(g.calls[0].url).toBe("https://api.github.com/repos/acme/infra/pulls?state=open&head=acme%3Achant%2Fx&per_page=100&page=1");
|
|
802
|
+
|
|
803
|
+
const l = fakeFetch({
|
|
804
|
+
"GET /api/v4/projects/group%2Finfra/merge_requests": [
|
|
805
|
+
{ iid: 8, source_branch: "chant/x", project_id: 1, source_project_id: 2, title: "fork", description: "" },
|
|
806
|
+
{ iid: 7, source_branch: "chant/x", project_id: 1, source_project_id: 1, title: "mine", description: "d", target_branch: "main" },
|
|
807
|
+
],
|
|
808
|
+
});
|
|
809
|
+
expect(await gitlabForge({ ...gl, fetch: l.fetch }).findPullRequest("chant/x")).toEqual({ number: 7, title: "mine", body: "d", head: "chant/x", base: "main" });
|
|
810
|
+
expect(l.calls[0].url).toBe("https://gl/api/v4/projects/group%2Finfra/merge_requests?state=opened&source_branch=chant%2Fx&per_page=100&page=1");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("editPullRequest and addLabels on each forge", async () => {
|
|
814
|
+
const f = fakeFetch({ "PATCH /api/v1/repos/acme/infra/pulls/10": { number: 10, title: "t2" }, "POST /api/v1/repos/acme/infra/issues/10/labels": [] });
|
|
815
|
+
const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
|
|
816
|
+
expect((await forge.editPullRequest(10, { title: "t2", body: "b2" }))?.title).toBe("t2");
|
|
817
|
+
await forge.addLabels(10, ["minor"]);
|
|
818
|
+
await forge.addLabels(10, []);
|
|
819
|
+
expect(f.calls.map((c) => [c.method, c.body])).toEqual([["PATCH", { title: "t2", body: "b2" }], ["POST", { labels: ["minor"] }]]);
|
|
820
|
+
const l = fakeFetch({ "PUT /api/v4/projects/group%2Finfra/merge_requests/7": { iid: 7 } });
|
|
821
|
+
const g = gitlabForge({ ...gl, fetch: l.fetch });
|
|
822
|
+
await g.editPullRequest(7, { body: "d" });
|
|
823
|
+
await g.addLabels(7, ["minor", "breaking"]);
|
|
824
|
+
expect(l.calls.map((c) => c.body)).toEqual([{ description: "d" }, { add_labels: "minor,breaking" }]);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("a repository with no default branch is refused by name", async () => {
|
|
828
|
+
const { fetch } = fakeFetch({ "GET /api/v1/repos/acme/infra": {} });
|
|
829
|
+
await expect(forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "h", title: "t", body: "b" })).rejects.toThrow(/no default branch.*pass base/);
|
|
830
|
+
});
|
|
831
|
+
});
|