@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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Where chant's own pull-request paths open their pull request (#3798).
3
+ *
4
+ * `reconcilePr`'s `pull-request` mode, `lexiconUpgrade`, rollback and
5
+ * `proposeWorkspaceUpgrade` open a pull request with `gh pr`. `gh pr create`,
6
+ * `view`, `edit` and `list` speak GitHub's GraphQL API, which Forgejo does not
7
+ * serve, so #3780's `GH_HOST` sends them to the right Forgejo instance only
8
+ * for them to fail there. GitLab has no `gh` at all. On those two forges each
9
+ * path opens, finds and edits its pull request over REST through
10
+ * `../../pr-forge.ts` instead. GitHub, and a run outside any CI job, keep
11
+ * `gh` and the operator's own `gh auth login`.
12
+ *
13
+ * The forge and its token come from the CI job's environment
14
+ * ({@link forgeTargetFromEnv}). The repository is the one the checkout's
15
+ * remote names, when that remote is on the job's forge, so a path working in
16
+ * another checkout than the job's own still opens its pull request there.
17
+ */
18
+
19
+ import { execFile } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ import { detectForge, forgeClient, forgeTargetFromEnv, type ForgeFetch, type PrForge, type PullRequest, type PullRequestSpec } from "../../pr-forge";
22
+ import { remoteHost } from "./gh-host";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ type Env = Record<string, string | undefined>;
27
+
28
+ /** The repository path a git remote URL names: `owner/name`, or `group/sub/name` on GitLab. */
29
+ export function remoteRepoPath(url: string | undefined): string | undefined {
30
+ const u = url?.trim();
31
+ if (!u) return undefined;
32
+ const scp = /^[^@/\s]+@[^:/\s]+:(?!\/)(.+)$/.exec(u);
33
+ let path: string;
34
+ if (scp) path = scp[1]!;
35
+ else {
36
+ try {
37
+ path = new URL(u).pathname;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ const out = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
43
+ return out.includes("/") ? out : undefined;
44
+ }
45
+
46
+ export interface PullRequestForgeOptions {
47
+ /** A checkout whose remote names the repository. Default: the job's repository. */
48
+ cwd?: string;
49
+ /** The remote in `cwd`. Default `origin`. */
50
+ remote?: string;
51
+ fetch?: ForgeFetch;
52
+ /** Reads a remote's URL. Default `git remote get-url`. For tests. */
53
+ remoteUrl?: (cwd: string, remote: string) => Promise<string | undefined>;
54
+ }
55
+
56
+ const gitRemoteUrl = async (cwd: string, remote: string): Promise<string | undefined> => {
57
+ try {
58
+ return (await execFileAsync("git", ["remote", "get-url", remote], { cwd })).stdout.trim();
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * The REST client a pull request is opened through, or undefined when `gh`
66
+ * serves: on GitHub, and outside a CI job. A Forgejo or GitLab job that lacks
67
+ * a token or a repository throws `ForgeEnvironmentError` naming it, where
68
+ * `gh pr` would have failed on GraphQL.
69
+ */
70
+ export async function restPullRequestForge(env: Env = process.env, opts: PullRequestForgeOptions = {}): Promise<PrForge | undefined> {
71
+ const kind = detectForge(env);
72
+ if (kind !== "forgejo" && kind !== "gitlab") return undefined;
73
+ const target = forgeTargetFromEnv(env, { kind });
74
+ if (opts.cwd) {
75
+ const url = await (opts.remoteUrl ?? gitRemoteUrl)(opts.cwd, opts.remote ?? "origin");
76
+ const path = remoteRepoPath(url);
77
+ if (path && remoteHost(url) === target.host) target.repo = path;
78
+ }
79
+ return forgeClient(target, opts.fetch);
80
+ }
81
+
82
+ /** What {@link upsertPullRequest} did. */
83
+ export interface UpsertedPullRequest {
84
+ action: "opened" | "updated" | "unchanged";
85
+ pr: PullRequest;
86
+ }
87
+
88
+ /**
89
+ * Open a pull request for `spec.head`, or bring the open one's title and
90
+ * body in line with `spec`. An open one already current is left alone.
91
+ */
92
+ export async function upsertPullRequest(forge: PrForge, spec: PullRequestSpec): Promise<UpsertedPullRequest> {
93
+ const open = await forge.findPullRequest(spec.head);
94
+ if (!open) return { action: "opened", pr: await forge.openPullRequest(spec) };
95
+ if (open.title === spec.title && open.body.trim() === spec.body.trim()) return { action: "unchanged", pr: open };
96
+ const edited = await forge.editPullRequest(open.number, { title: spec.title, body: spec.body });
97
+ return { action: "updated", pr: edited ?? { ...open, title: spec.title, body: spec.body } };
98
+ }
@@ -739,3 +739,61 @@ describe("a lexicon with several pins (#3288)", () => {
739
739
  expect(upgradePrTitle("sql", "17.11", "17.12", "postgres-17")).toBe("feat(sql): upgrade postgres-17 17.11 to 17.12");
740
740
  });
741
741
  });
742
+
743
+ // ── Forgejo and GitLab open the PR over REST (#3798) ──────────────────
744
+
745
+ describe("lexiconUpgrade pull request over REST", () => {
746
+ const pin = {
747
+ file: "src/pin.ts",
748
+ pattern: /V = "(.+)"/,
749
+ replace: (v: string) => `V = "${v}"`,
750
+ upstream: { owner: "o", repo: "r", kind: "releases" as const },
751
+ };
752
+
753
+ function fakeForge(open?: { number: number; url: string; body: string }) {
754
+ const calls: string[] = [];
755
+ const forge = {
756
+ findPullRequest: vi.fn(async (head: string) => (calls.push(`find ${head}`), open ? { ...open, title: "t", head } : undefined)),
757
+ openPullRequest: vi.fn(async (spec: { head: string; base?: string }) => (calls.push(`open ${spec.head} -> ${spec.base}`), { number: 5, url: "https://forge/pulls/5", title: "t", body: "b", head: spec.head })),
758
+ editPullRequest: vi.fn(async (n: number) => (calls.push(`edit ${n}`), undefined)),
759
+ addLabels: vi.fn(async (n: number, labels: readonly string[]) => void calls.push(`label ${n} ${labels.join(",")}`)),
760
+ };
761
+ return { forge: forge as unknown as import("../../pr-forge").PrForge, calls };
762
+ }
763
+
764
+ async function run(forge: import("../../pr-forge").PrForge) {
765
+ const { gh, calls } = recordingGh();
766
+ const dir = join(tmpdir(), `chant-3798-${process.pid}`);
767
+ mkdirSync(dir, { recursive: true });
768
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.1.0" }));
769
+ const r = await lexiconUpgrade({
770
+ lexicon: "k3s",
771
+ lexiconDir: dir,
772
+ mode: "pull-request",
773
+ _loadPin: async () => pin,
774
+ _checkPinned: vi.fn(async () => pinnedResult()),
775
+ _applyBump: vi.fn(async () => ({ filePath: "/x/src/pin.ts" })),
776
+ _bumpPackageVersion: vi.fn(),
777
+ _gh: gh,
778
+ _forge: forge,
779
+ });
780
+ return { r, ghCalls: calls };
781
+ }
782
+
783
+ test("opens the pull request and labels it through the forge, with no gh pr call", async () => {
784
+ const { forge, calls } = fakeForge();
785
+ const { r, ghCalls } = await run(forge);
786
+ expect(r.prUrl).toBe("https://forge/pulls/5");
787
+ expect(calls.filter((c) => c.startsWith("open"))).toEqual([`open ${upgradeBranchName("k3s")} -> main`]);
788
+ expect(calls.some((c) => c.startsWith("label"))).toBe(r.semverLabel !== undefined && r.semverLabel !== null);
789
+ expect(ghCalls.filter((c) => c.startsWith("gh "))).toEqual([]);
790
+ });
791
+
792
+ test("edits the open pull request in place of opening another", async () => {
793
+ const { forge, calls } = fakeForge({ number: 9, url: "https://forge/pulls/9", body: "old" });
794
+ const { r } = await run(forge);
795
+ expect(r.prUrl).toBe("https://forge/pulls/9");
796
+ expect(calls).toContain("edit 9");
797
+ expect(calls.some((c) => c.startsWith("open"))).toBe(false);
798
+ });
799
+ });
@@ -31,6 +31,8 @@ import type { UpgradeCheckResult, LexiconId, UpstreamPin } from "../../codegen/p
31
31
  import type { RollingUpgradeResult, RollingLexicon } from "../../codegen/rolling-upgrade";
32
32
  import { bumpForSeverity, bumpPackageJsonVersion } from "../../codegen/version-bump";
33
33
  import { ghHostEnv } from "./gh-host";
34
+ import { restPullRequestForge } from "./forge-pull-request";
35
+ import type { PrForge } from "../../pr-forge";
34
36
  import { postOrUpdateGithubIssue } from "./reconcile";
35
37
 
36
38
  export { bumpPackageJsonVersion };
@@ -99,6 +101,12 @@ export interface LexiconUpgradeArgs {
99
101
  * Defaults to bumpPackageJsonVersion.
100
102
  */
101
103
  _bumpPackageVersion?: BumpPackageVersionFn;
104
+ /**
105
+ * The REST client the pull request is found, opened and labelled through,
106
+ * or null for `gh`. Default: one on a Forgejo or GitLab job, `gh`
107
+ * elsewhere (#3798). For tests.
108
+ */
109
+ _forge?: PrForge | null;
102
110
  }
103
111
 
104
112
  export interface LexiconUpgradeResult {
@@ -416,7 +424,8 @@ async function postLexiconIssue(
416
424
  * Check whether an open PR exists for the given branch.
417
425
  * Returns the PR URL, or null if none.
418
426
  */
419
- async function openPrUrl(branch: string, gh: GhRunner): Promise<string | null> {
427
+ async function openPrUrl(branch: string, gh: GhRunner, forge?: PrForge): Promise<string | null> {
428
+ if (forge) return (await forge.findPullRequest(branch))?.url ?? null;
420
429
  try {
421
430
  const { stdout } = await gh(
422
431
  `gh pr view ${shellQuote(branch)} --json url,state --jq 'select(.state=="OPEN") | .url'`,
@@ -432,7 +441,8 @@ async function openPrUrl(branch: string, gh: GhRunner): Promise<string | null> {
432
441
  * Read the body of the open PR for `branch`, if any.
433
442
  * Returns null when there is no open PR or gh is unavailable.
434
443
  */
435
- async function currentPrBody(branch: string, gh: GhRunner): Promise<string | null> {
444
+ async function currentPrBody(branch: string, gh: GhRunner, forge?: PrForge): Promise<string | null> {
445
+ if (forge) return (await forge.findPullRequest(branch))?.body.trim() || null;
436
446
  try {
437
447
  const { stdout } = await gh(
438
448
  `gh pr view ${shellQuote(branch)} --json body,state --jq 'select(.state=="OPEN") | .body'`,
@@ -750,9 +760,12 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
750
760
 
751
761
  // Idempotency check: if the open PR already has this summary, nothing changed.
752
762
  // Compare trimmed — gh/git round-trips can alter trailing whitespace.
753
- const existingPrBody = await currentPrBody(branch, gh);
763
+ // `gh pr` speaks GraphQL, which Forgejo does not serve: a Forgejo or
764
+ // GitLab job finds, opens and labels the pull request over REST (#3798).
765
+ const forge = args._forge !== undefined ? (args._forge ?? undefined) : await restPullRequestForge(process.env);
766
+ const existingPrBody = await currentPrBody(branch, gh, forge);
754
767
  if (existingPrBody !== null && existingPrBody.trim() === summary.trim()) {
755
- const existingUrl = await openPrUrl(branch, gh);
768
+ const existingUrl = await openPrUrl(branch, gh, forge);
756
769
  return {
757
770
  lexicon,
758
771
  mode,
@@ -825,10 +838,23 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
825
838
  });
826
839
 
827
840
  // Open or update the PR.
828
- const existingUrl = await openPrUrl(branch, gh);
829
841
  let prUrl: string;
830
-
831
- if (existingUrl) {
842
+ const open = forge ? await forge.findPullRequest(branch) : undefined;
843
+ const existingUrl = forge ? null : await openPrUrl(branch, gh);
844
+
845
+ if (forge) {
846
+ let pr = open;
847
+ if (pr) {
848
+ const edited = await forge.editPullRequest(pr.number, { title, body: summary });
849
+ prUrl = edited?.url ?? pr.url ?? "";
850
+ } else {
851
+ pr = await forge.openPullRequest({ head: branch, base: "main", title, body: summary });
852
+ prUrl = pr.url ?? "";
853
+ }
854
+ // Best-effort, as below. Forgejo adds only a label the repository
855
+ // already has; GitHub and GitLab make one.
856
+ if (semverLabel) await forge.addLabels(pr.number, [semverLabel]).catch(() => {/* best-effort */});
857
+ } else if (existingUrl) {
832
858
  await gh(`gh pr edit ${shellQuote(branch)} --title ${shellQuote(title)} --body ${shellQuote(summary)}`);
833
859
  prUrl = existingUrl;
834
860
  } else {
@@ -838,7 +864,7 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
838
864
  prUrl = stdout.trim();
839
865
  }
840
866
 
841
- if (semverLabel) {
867
+ if (semverLabel && !forge) {
842
868
  await ensureLabelOnPr(branch, semverLabel, gh);
843
869
  }
844
870
 
@@ -27,6 +27,8 @@ import { execFile } from "node:child_process";
27
27
  import { promisify } from "node:util";
28
28
  import type { StagedUpgrade } from "../../workspace/lineage-upgrade";
29
29
  import { ghHostEnv } from "./gh-host";
30
+ import { restPullRequestForge, upsertPullRequest } from "./forge-pull-request";
31
+ import type { PrForge } from "../../pr-forge";
30
32
 
31
33
  const execFileAsync = promisify(execFile);
32
34
 
@@ -52,6 +54,11 @@ export interface ProposeWorkspaceUpgradeArgs {
52
54
  cwd?: string;
53
55
  /** Replaces the git and gh child processes. For tests. */
54
56
  _run?: CommandRunner;
57
+ /**
58
+ * The REST client the pull request is opened through, or null for `gh`.
59
+ * Default: one on a Forgejo or GitLab job, `gh` elsewhere (#3798). For tests.
60
+ */
61
+ _forge?: PrForge | null;
55
62
  /** Replaces build and lint in the worktree. For tests. */
56
63
  _runChant?: import("../../workspace/lineage-upgrade").ChantRunner;
57
64
  }
@@ -198,6 +205,12 @@ export async function proposeWorkspaceUpgrade(args: ProposeWorkspaceUpgradeArgs)
198
205
  if (!hasRemote) throw new Error(`proposeWorkspaceUpgrade: no remote "${remote}" to open a pull request on`);
199
206
 
200
207
  const text = body(staged, lines);
208
+ // `gh pr` speaks GraphQL, which Forgejo does not serve (#3798).
209
+ const forge = args._forge !== undefined ? args._forge : await restPullRequestForge(process.env, { cwd: staged.repo, remote });
210
+ if (forge) {
211
+ result.prUrl = (await upsertPullRequest(forge, { head: branch, base: base!, title, body: text })).pr.url;
212
+ return result;
213
+ }
201
214
  const existing = (await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"], staged.repo)).trim();
202
215
  if (existing) {
203
216
  await run("gh", ["pr", "edit", existing, "--title", title, "--body", text], staged.repo);
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { ForgeApiError, forgejoForge, githubForge, type ForgeFetch, type GithubShapedForge } from "../../pr-forge";
5
5
  import { ghHostEnv } from "./gh-host";
6
+ import { restPullRequestForge } from "./forge-pull-request";
6
7
 
7
8
  const execAsync = promisify(exec);
8
9
 
@@ -1363,8 +1364,15 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
1363
1364
  await execAsync(`git add ${shellQuote(output)}`, { signal });
1364
1365
  await execAsync(`git commit -m ${shellQuote(title)}`, { signal });
1365
1366
  await execAsync(`git push -u origin ${shellQuote(branch)}`, { signal });
1366
- // `gh pr create` names no host, so on GitHub Enterprise Server or Forgejo
1367
- // it is told the job's own (#3780). See `./gh-host.ts`.
1367
+ // `gh pr` speaks GraphQL, which Forgejo does not serve, and GitLab has no
1368
+ // `gh`: a Forgejo or GitLab job opens the pull request over REST (#3798).
1369
+ const forge = await restPullRequestForge(process.env, { fetch: (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) }) });
1370
+ if (forge) {
1371
+ const pr = await forge.openPullRequest({ head: branch, title, body: summary });
1372
+ return { mode, branch, summary, entries, prUrl: pr.url ?? "" };
1373
+ }
1374
+ // `gh pr create` names no host, so on GitHub Enterprise Server it is told
1375
+ // the job's own (#3780). See `./gh-host.ts`.
1368
1376
  const { stdout } = await execAsync(
1369
1377
  `gh pr create --title ${shellQuote(title)} --body ${shellQuote(summary)} --head ${shellQuote(branch)}`,
1370
1378
  { signal, env: await ghHostEnv(process.env) },
@@ -20,6 +20,7 @@ import { join } from "node:path";
20
20
  import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
21
21
  import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
22
22
  import { runWorkspacePoints } from "../workspace/points-cli";
23
+ import { runWorkspaceRecords } from "../workspace/records-cli";
23
24
  import type { CommandContext } from "../cli/registry";
24
25
  import { ApplyOp } from "./composites/apply-op";
25
26
  import { memoryGateLedgerPort } from "./gate";
@@ -71,7 +72,7 @@ function clone(name: string): string {
71
72
  }
72
73
 
73
74
  /** `chant workspace points <argv>` through the command's handler, in `cwd`. Returns the document it prints. */
74
- async function points(cwd: string, args: Record<string, unknown>): Promise<Record<string, unknown> & { error?: { code: string } }> {
75
+ async function points(cwd: string, args: Record<string, unknown>, handler = runWorkspacePoints): Promise<Record<string, unknown> & { error?: { code: string } }> {
75
76
  const printed: string[] = [];
76
77
  const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
77
78
  printed.push(String(line));
@@ -79,7 +80,7 @@ async function points(cwd: string, args: Record<string, unknown>): Promise<Recor
79
80
  const was = process.cwd();
80
81
  process.chdir(cwd);
81
82
  try {
82
- await runWorkspacePoints({ args, plugins: [], serializers: [] } as unknown as CommandContext);
83
+ await handler({ args, plugins: [], serializers: [] } as unknown as CommandContext);
83
84
  } finally {
84
85
  process.chdir(was);
85
86
  log.mockRestore();
@@ -169,3 +170,39 @@ describe("a gate's question outlives the CI job that asked it (#3815)", () => {
169
170
  expect(answersIn(tree)).toHaveLength(1);
170
171
  });
171
172
  });
173
+
174
+ describe("chant workspace records lists the answers held on chant/lifecycle (#3833)", () => {
175
+ type Rec = { id: string; path: string; state: string; data: { answer?: unknown }; ledger?: string | null; worktree?: string };
176
+ const answerRecords = (set: Record<string, unknown>): Rec[] => {
177
+ const kinds = set.kinds as { kind: { name: string }; records: Rec[] }[];
178
+ return kinds.find((k) => k.kind.name === "answer")!.records;
179
+ };
180
+
181
+ test("an answer asked and answered on the ledger is listed with the id, state and answer points shows", async () => {
182
+ vi.stubEnv("CI", "");
183
+ const op = config("records-apply", { name: "prod-apply", store: "ledger" });
184
+ const first = await run(op, clone("records-job"));
185
+ expect(first.status).toBe("waiting");
186
+ const id = first.point!.id;
187
+ expect(await points(clone("records-person"), { extraPositional: "answer", extraPositional2: id, answer: "yes", bys: ["alice"] })).toMatchObject({ written: true });
188
+
189
+ // A clone that never held chant/lifecycle: records fetches it, as points answer does.
190
+ const reader = clone("records-reader");
191
+ const listed = answerRecords(await points(reader, { json: true }, runWorkspaceRecords)).find((r) => r.id === id);
192
+ expect(listed).toMatchObject({ id, state: "answered", data: { answer: true }, ledger: expect.stringContaining("chant/lifecycle:") });
193
+ expect(listed!.worktree).toBeUndefined();
194
+
195
+ const doc = (await points(reader, { json: true })) as { questions: { id: string; state: string; answer: unknown; ledger: string | null; path: string }[] };
196
+ const question = doc.questions.find((q) => q.id === id)!;
197
+ expect({ id: listed!.id, state: listed!.state, answer: listed!.data.answer, ledger: listed!.ledger, path: listed!.path }).toEqual({
198
+ id: question.id,
199
+ state: question.state,
200
+ answer: question.answer,
201
+ ledger: question.ledger,
202
+ path: question.path,
203
+ });
204
+
205
+ // The checkout doesn't hold it: --uncommitted leaves it out, and the text output says where it is.
206
+ expect(answerRecords(await points(reader, { json: true, uncommitted: true }, runWorkspaceRecords)).map((r) => r.id)).not.toContain(id);
207
+ });
208
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * #3835: an answered decision point resumes the job waiting on it. A gate
3
+ * that asks a point writes its resolution only when the run asks again, so
4
+ * `chant run resume` used to see no approval and leave the job stopped. Now a
5
+ * pending fact that cites a question is resumable once the question has an
6
+ * answer the gate passes on, and `points answer --resume` resumes right after
7
+ * the answer.
8
+ *
9
+ * Clones of one bare remote stand for the CI job's checkout, the person's
10
+ * clone the answer comes from, the scheduled resume job's checkout and the
11
+ * resumed job's. The gate ledger is the real one on `chant/lifecycle`, so the
12
+ * pending fact, the question and its answer all travel through the remote.
13
+ * The forge is a stubbed `fetch` that answers as GitHub does for a failed run.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
21
+ import { cleanScratch, workspace } from "../workspace/__fixtures__/decide-workspace";
22
+ import { runWorkspacePoints } from "../workspace/points-cli";
23
+ import { runResumeCommand } from "../cli/handlers/gate-resume";
24
+ import type { CommandContext } from "../cli/registry";
25
+ import { readGateLedger } from "../lifecycle/gate-ledger";
26
+ import { pointGateResumable } from "../point-decision";
27
+ import { ApplyOp } from "./composites/apply-op";
28
+ import { gitGateLedgerPort } from "./gate";
29
+ import type { GatePoint } from "./gate-point";
30
+ import { runOpLocally } from "./local-executor";
31
+ import type { ActivityFn } from "./activity-registry";
32
+ import type { OpConfig } from "./types";
33
+
34
+ const PLAN = `jcs1-sha256:${"6".repeat(64)}`;
35
+
36
+ const POINTS = {
37
+ points: {
38
+ "prod-apply": {
39
+ title: "Apply this plan to prod",
40
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
41
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
42
+ deciders: [{ kind: "quorum", count: 1 }],
43
+ },
44
+ },
45
+ };
46
+
47
+ const GITHUB = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "acme/infra", GITHUB_RUN_ID: "42", GITHUB_RUN_ATTEMPT: "1", GITHUB_API_URL: "https://api.github.test" };
48
+
49
+ const applied: string[] = [];
50
+ const activities = new Map<string, ActivityFn>([
51
+ ["chantBuild", async () => ({ ok: true })],
52
+ ["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
53
+ ["nativeApply", async () => {
54
+ applied.push(PLAN);
55
+ return { ok: true };
56
+ }],
57
+ ]);
58
+
59
+ const config = (name: string, point: GatePoint): OpConfig =>
60
+ (ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point } }).op as unknown as { props: OpConfig }).props;
61
+
62
+ let home: string;
63
+ let remote: string;
64
+ let n = 0;
65
+
66
+ function clone(): string {
67
+ const dir = join(home, `clone-${++n}`);
68
+ execFileSync("git", ["clone", "-q", remote, dir], { stdio: "ignore" });
69
+ return dir;
70
+ }
71
+
72
+ /** A wave's run in `cwd`, on the real gate ledger. */
73
+ const run = (op: OpConfig, cwd: string) => runOpLocally(op, activities, {}, undefined, { gates: gitGateLedgerPort({ cwd }), cwd, now: new Date().toISOString() });
74
+
75
+ /** Run a command handler in `cwd`; returns what it printed on standard output, parsed, and its exit code. */
76
+ async function inDir<T>(cwd: string, handler: (ctx: CommandContext) => Promise<number>, args: Record<string, unknown>): Promise<{ code: number; doc: T }> {
77
+ const printed: string[] = [];
78
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
79
+ printed.push(String(line));
80
+ });
81
+ const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
82
+ const was = process.cwd();
83
+ process.chdir(cwd);
84
+ try {
85
+ const code = await handler({ args, plugins: [], serializers: [] } as unknown as CommandContext);
86
+ return { code, doc: JSON.parse(printed.join("\n")) as T };
87
+ } finally {
88
+ process.chdir(was);
89
+ log.mockRestore();
90
+ err.mockRestore();
91
+ }
92
+ }
93
+
94
+ /** The forge: a GitHub run that ended in failure (the job exited 3 at the gate). Records each call. */
95
+ let calls: string[];
96
+ function stubForge(): void {
97
+ calls = [];
98
+ vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
99
+ calls.push(`${init.method} ${new URL(url).pathname}`);
100
+ const body = init.method === "GET" ? JSON.stringify({ status: "completed", conclusion: "failure", run_attempt: 1 }) : "";
101
+ return { ok: true, status: 200, text: async () => body };
102
+ });
103
+ }
104
+
105
+ type Report = { op: string; gate: string; status: string; message: string; approvedBy?: string };
106
+
107
+ /** The job waits at the gate in CI; returns the question's id. */
108
+ async function waitInCi(op: OpConfig): Promise<string> {
109
+ for (const [k, v] of Object.entries(GITHUB)) vi.stubEnv(k, v);
110
+ const first = await run(op, clone());
111
+ expect(first.status).toBe("waiting");
112
+ for (const k of Object.keys(GITHUB)) vi.stubEnv(k, "");
113
+ return first.point!.id;
114
+ }
115
+
116
+ const answer = (id: string, value: string, extra: Record<string, unknown> = {}) =>
117
+ inDir<Record<string, unknown>>(clone(), runWorkspacePoints, { extraPositional: "answer", extraPositional2: id, answer: value, bys: ["alice"], ...extra });
118
+
119
+ beforeAll(() => {
120
+ home = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-point-resume-")));
121
+ const root = workspace(POINTS);
122
+ remote = join(home, "remote.git");
123
+ execFileSync("git", ["clone", "-q", "--bare", root, remote], { stdio: "ignore" });
124
+ });
125
+
126
+ beforeEach(() => {
127
+ vi.stubEnv("CI", "");
128
+ vi.stubEnv("GITHUB_TOKEN", "t");
129
+ stubForge();
130
+ });
131
+
132
+ afterEach(() => {
133
+ vi.unstubAllEnvs();
134
+ vi.unstubAllGlobals();
135
+ });
136
+
137
+ afterAll(() => {
138
+ cleanScratch();
139
+ rmSync(home, { recursive: true, force: true });
140
+ });
141
+
142
+ describe("an answered decision point resumes the wave waiting on it (#3835)", () => {
143
+ test("the wave waits on its point; the point is answered; chant run resume resumes it, and the resumed run passes", async () => {
144
+ const op = config("resume-apply", { name: "prod-apply", store: "ledger" });
145
+ const id = await waitInCi(op);
146
+
147
+ // Before the answer, nothing is resumed.
148
+ const before = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
149
+ expect(before.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "not-approved", message: expect.stringContaining("not answered yet") })]);
150
+ expect(calls).toEqual([]);
151
+
152
+ expect((await answer(id, "yes")).doc).toMatchObject({ written: true, question: { state: "answered", answer: true } });
153
+
154
+ // The scheduled resume job, in its own checkout.
155
+ const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
156
+ expect(resumed.code).toBe(0);
157
+ expect(resumed.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "resumed", approvedBy: "alice" })]);
158
+ expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
159
+
160
+ // The resumed job asks again, finds the answer, writes the resolution that cites it, and applies.
161
+ const job = clone();
162
+ const next = await run(op, job);
163
+ expect(next.status).toBe("ok");
164
+ expect(applied).toContain(PLAN);
165
+ const ledger = await readGateLedger("resume-apply", { cwd: job });
166
+ expect(ledger.resolutions).toEqual([expect.objectContaining({ gate: "approve-apply", resolvedBy: "alice", answer: expect.objectContaining({ id, answer: true }) })]);
167
+ }, 120_000);
168
+
169
+ test("an answer that does not pass the gate resumes nothing", async () => {
170
+ const op = config("refuse-apply", { name: "prod-apply", store: "ledger" });
171
+ const id = await waitInCi(op);
172
+ await answer(id, "no");
173
+
174
+ const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "refuse-apply", json: true });
175
+ expect(resumed.code).toBe(0);
176
+ expect(resumed.doc.reports).toEqual([expect.objectContaining({ status: "not-approved", message: expect.stringContaining("passes only on true") })]);
177
+ expect(calls).toEqual([]);
178
+ }, 120_000);
179
+
180
+ test("a gate's own pass list is on the pending fact: an answer it names resumes, others don't", async () => {
181
+ const op = config("pass-apply", { name: "prod-apply", store: "ledger", pass: [false] });
182
+ const id = await waitInCi(op);
183
+ const { pending } = await gitGateLedgerPort({ cwd: clone() }).read("pass-apply");
184
+ expect(pending[0]!.answer).toMatchObject({ id, pass: [false] });
185
+ expect(pending[0]!.resume).toMatchObject({ forge: "github", run: "42" });
186
+
187
+ const answered = { id, state: "answered" as const, answeredBy: ["alice"], answeredOn: null };
188
+ expect(pointGateResumable(pending[0]!, { ...answered, answer: false })).toMatchObject({ resumable: true, answeredBy: "alice" });
189
+ expect(pointGateResumable(pending[0]!, { ...answered, answer: true })).toMatchObject({ resumable: false, why: "refused" });
190
+ expect(pointGateResumable(pending[0]!, { ...answered, state: "escalated", answer: null })).toMatchObject({ resumable: false, why: "open" });
191
+ expect(pointGateResumable(pending[0]!, undefined)).toMatchObject({ resumable: false, why: "unknown" });
192
+ }, 120_000);
193
+
194
+ test("points answer --resume resumes the job waiting at the gate the question names", async () => {
195
+ const op = config("answer-resume-apply", { name: "prod-apply", store: "ledger" });
196
+ const id = await waitInCi(op);
197
+
198
+ const answered = await answer(id, "yes", { resumeRun: true });
199
+ expect(answered.code).toBe(0);
200
+ expect(answered.doc).toMatchObject({ written: true, question: { state: "answered", subject: "gate:answer-resume-apply/approve-apply" } });
201
+ expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
202
+ }, 120_000);
203
+ });
@@ -32,7 +32,10 @@ import type { GatePointAsker } from "../point-decision";
32
32
  // The decision itself bundles without the workspace (#3816): `@intentius/chant/point-decision`.
33
33
  export {
34
34
  evaluatePointGate,
35
+ pointGateResumable,
35
36
  type GatePointAsker,
37
+ type PointQuestionState,
38
+ type PointResumeCheck,
36
39
  type GatePointQuestion,
37
40
  type GatePointRequest,
38
41
  type PointGateCheck,
@@ -141,3 +141,14 @@ export function gatePointInputs(point: string, declared: readonly string[], fact
141
141
  export function gateSubject(op: string, gate: string, env?: string | null): string {
142
142
  return `gate:${op}/${gate}${env ? `@${env}` : ""}`;
143
143
  }
144
+
145
+ /** The gate a {@link gateSubject} names, or undefined for a subject that names none (#3835). */
146
+ export function parseGateSubject(subject: string | null | undefined): { op: string; gate: string; env?: string } | undefined {
147
+ if (!subject || !subject.startsWith("gate:")) return undefined;
148
+ const rest = subject.slice("gate:".length);
149
+ const at = rest.lastIndexOf("@");
150
+ const named = at > 0 ? rest.slice(0, at) : rest;
151
+ const slash = named.indexOf("/");
152
+ if (slash <= 0 || slash === named.length - 1) return undefined;
153
+ return { op: named.slice(0, slash), gate: named.slice(slash + 1), ...(at > 0 ? { env: rest.slice(at + 1) } : {}) };
154
+ }