@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.
Files changed (53) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +32 -1
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/lifecycle/rollback.d.ts.map +1 -1
  7. package/dist/op/activities/forge-pull-request.d.ts +49 -0
  8. package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
  9. package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
  10. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  11. package/dist/op/activities/propose-upgrade.d.ts +6 -0
  12. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  13. package/dist/op/activities/reconcile.d.ts.map +1 -1
  14. package/dist/op/gate-point-run.d.ts +1 -1
  15. package/dist/op/gate-point-run.d.ts.map +1 -1
  16. package/dist/op/gate-point.d.ts +6 -0
  17. package/dist/op/gate-point.d.ts.map +1 -1
  18. package/dist/op/gate-resume.d.ts +2 -31
  19. package/dist/op/gate-resume.d.ts.map +1 -1
  20. package/dist/op/gate-run-locator.d.ts +40 -0
  21. package/dist/op/gate-run-locator.d.ts.map +1 -0
  22. package/dist/point-decision.d.ts +63 -1
  23. package/dist/point-decision.d.ts.map +1 -1
  24. package/dist/pr-forge.d.ts +72 -7
  25. package/dist/pr-forge.d.ts.map +1 -1
  26. package/dist/workspace/points-cli.d.ts.map +1 -1
  27. package/dist/workspace/records-cli.d.ts +15 -0
  28. package/dist/workspace/records-cli.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/handlers/gate-resume.ts +84 -11
  31. package/src/cli/main.ts +4 -2
  32. package/src/lifecycle/gate-ledger.ts +7 -0
  33. package/src/lifecycle/rollback.ts +10 -2
  34. package/src/op/activities/forge-pull-request.test.ts +91 -0
  35. package/src/op/activities/forge-pull-request.ts +98 -0
  36. package/src/op/activities/lexicon-upgrade.test.ts +58 -0
  37. package/src/op/activities/lexicon-upgrade.ts +34 -8
  38. package/src/op/activities/propose-upgrade.ts +13 -0
  39. package/src/op/activities/reconcile.ts +10 -2
  40. package/src/op/gate-point-ci.e2e.test.ts +39 -2
  41. package/src/op/gate-point-resume.e2e.test.ts +203 -0
  42. package/src/op/gate-point-run.ts +3 -0
  43. package/src/op/gate-point.ts +11 -0
  44. package/src/op/gate-resume.ts +5 -95
  45. package/src/op/gate-run-locator.ts +105 -0
  46. package/src/point-decision-bundle.test.ts +1 -0
  47. package/src/point-decision.ts +97 -1
  48. package/src/pr-forge-bundle.test.ts +1 -1
  49. package/src/pr-forge.test.ts +154 -0
  50. package/src/pr-forge.ts +179 -12
  51. package/src/workspace/points-cli.ts +7 -2
  52. package/src/workspace/records-cli.ts +38 -7
  53. package/src/workspace/records.schema.json +4 -0
@@ -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
- /** 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
- }
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);
@@ -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
- answer: ref,
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 20 KB today.
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
  });
@@ -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
+ });