1import { expect, it } from "@effect/vitest";
2import * as Layer from "effect/Layer";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7import * as FileSystem from "effect/FileSystem";
8import * as Redacted from "effect/Redacted";
9import { ChildProcessSpawner } from "effect/process";
10import * as GitHubApi from "./GitHubApi.ts";
11import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
12import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";
13
14/**
15 * One request as the fake saw it, flattened to words: the path or document, then each variable
16 * or body field as `name=value`, so an assertion reads as "this request carried that value".
17 */
18type Call = ReadonlyArray<string>;
19type Send = (
20 call: Call,
21 kind: "graphql" | "rest",
22) => Effect.Effect<string, GitHubApi.GitHubApiError>;
23
24/** Every git command the cascade ran, in order; none actually runs. */
25let gitCalls: Array<ReadonlyArray<string>> = [];
26const fakeGit = Layer.mergeAll(
27 TestSourceControlHost.layer({
28 process: {
29 run: (input) =>
30 Effect.sync(() => {
31 gitCalls.push(input.args);
32 const stdout =
33 input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : "";
34 return {
35 exitCode: ChildProcessSpawner.ExitCode(0),
36 stdout,
37 stderr: "",
38 stdoutTruncated: false,
39 stderrTruncated: false,
40 };
41 }),
42 },
43 }),
44 FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }),
45);
46
47const runGitHubStackAction = (send: Send, input: Parameters<typeof runStackAction>[0]) =>
48 runStackAction(input).pipe(
49 Effect.provide(
50 Layer.mergeAll(
51 fakeGit,
52 Layer.mock(GitHubApi.GitHubApi)({
53 credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }),
54 graphql: (request) => {
55 // GitHub refuses a document that declares a variable it never uses.
56 const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!);
57 const unused = declared.filter(
58 (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query),
59 );
60 if (unused.length > 0) {
61 return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`));
62 }
63 return send(words(request.query, request.variables), "graphql");
64 },
65 rest: (request) =>
66 send(words(request.path, request.body), "rest").pipe(
67 Effect.map((body) => ({
68 status: 200,
69 headers: {},
70 body,
71 truncated: false,
72 invalidUtf8: false,
73 })),
74 ),
75 }),
76 ),
77 ),
78 );
79
80const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
81
82function words(head: string, fields: unknown): Call {
83 return [
84 head,
85 ...Object.entries((fields ?? {}) as Record<string, unknown>).map(
86 ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`,
87 ),
88 ];
89}
90
91const isMutation = (call: Call) => call[0]!.startsWith("mutation");
92
93const stack = [
94 {
95 number: 50,
96 url: "https://api.github.com/repos/acme/web/stacks/50",
97 base: { ref: "main" },
98 pull_requests: [
99 {
100 number: 1,
101 title: "Base",
102 head: { ref: "base", sha: "aaa" },
103 state: "closed",
104 merged_at: "2026-01-01T00:00:00Z",
105 },
106 {
107 number: 2,
108 title: "Middle",
109 head: { ref: "middle", sha: "bbb" },
110 state: "open",
111 draft: false,
112 },
113 { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false },
114 ],
115 },
116];
117const input = {
118 cwd: "/repo",
119 repository: "acme/web",
120 host: "github.com",
121 number: 3,
122 stackNumber: 50,
123 expectedStackHeads: [
124 { number: 2, headSha: "bbb" },
125 { number: 3, headSha: "ccc" },
126 ],
127 action: "merge" as const,
128};
129const access = {
130 data: {
131 repository: {
132 pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
133 pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
134 },
135 },
136};
137
138const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({
139 data: {
140 processed: processed.map((headRefOid) => ({ headRefOid })),
141 repository: {
142 pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } },
143 },
144 },
145});
146const rebased = {
147 data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } },
148};
149const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
150
151function fake(responses: readonly unknown[]) {
152 const calls: Call[] = [];
153 const execute: Send = (call) =>
154 Effect.sync(() => {
155 calls.push(call);
156 const value = responses[calls.length - 1];
157 if (value === undefined) throw new Error("Unexpected GitHub request");
158 return encodeJson(value);
159 });
160 return { execute, calls };
161}
162
163it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () =>
164 Effect.gen(function* () {
165 const api = fake([stack, { status: "enqueued", details: {} }]);
166 yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" });
167 expect(api.calls).toHaveLength(2);
168 expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async");
169 expect(api.calls[1]).toContain("sha=ccc");
170 expect(api.calls[1]).toContain("merge_action=default");
171 expect(api.calls[1]).toContain("merge_method=squash");
172 }),
173);
174
175it.effect("merges through the selected layer without including later draft layers", () =>
176 Effect.gen(function* () {
177 const fiveLayers = [
178 {
179 ...stack[0],
180 pull_requests: Array.from({ length: 5 }, (_, index) => ({
181 number: index + 1,
182 head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` },
183 state: "open",
184 draft: index >= 3,
185 })),
186 },
187 ];
188 const api = fake([fiveLayers, { status: "merged", details: {} }]);
189 yield* runGitHubStackAction(api.execute, {
190 ...input,
191 number: 3,
192 expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })),
193 });
194 expect(api.calls).toHaveLength(2);
195 expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async");
196 expect(api.calls[1]).toContain("sha=sha-3");
197 expect(api.calls[1]).toContain("merge_action=default");
198 }),
199);
200
201it.effect("rejects stale reviewed heads below a selected middle layer", () =>
202 Effect.gen(function* () {
203 const api = fake([stack]);
204 const result = yield* runGitHubStackAction(api.execute, {
205 ...input,
206 number: 2,
207 expectedStackHeads: [{ number: 2, headSha: "old-head" }],
208 }).pipe(Effect.result);
209 expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } });
210 expect(api.calls).toHaveLength(1);
211 }),
212);
213
214it.effect("does not merge from an already merged layer", () =>
215 Effect.gen(function* () {
216 const api = fake([stack]);
217 const result = yield* runGitHubStackAction(api.execute, {
218 ...input,
219 number: 1,
220 expectedStackHeads: [],
221 }).pipe(Effect.result);
222 expect(result).toMatchObject({
223 _tag: "Failure",
224 failure: { _tag: "GitHubStackUnsupportedError" },
225 });
226 expect(api.calls).toHaveLength(1);
227 }),
228);
229
230it.effect("polls an accepted merge and reports a later rule rejection", () =>
231 Effect.gen(function* () {
232 const api = fake([
233 stack,
234 { status: "pending", details: { uuid: "operation" } },
235 { status: "failed", details: { message: "Required checks have not passed" } },
236 ]);
237 const fiber = yield* runGitHubStackAction(api.execute, input).pipe(
238 Effect.result,
239 Effect.forkChild,
240 );
241 yield* TestClock.adjust("1 second");
242 const result = yield* Fiber.join(fiber);
243 expect(result).toMatchObject({
244 _tag: "Failure",
245 failure: { _tag: "GitHubStackMergeRejectedError" },
246 });
247 expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation");
248 }),
249);
250
251it.effect("retains stack identity and a rejection response without a message", () =>
252 Effect.gen(function* () {
253 const rejection = { status: "failed", details: {} };
254 const api = fake([stack, rejection]);
255 const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result);
256 expect(result).toMatchObject({
257 _tag: "Failure",
258 failure: {
259 _tag: "GitHubStackMergeRejectedError",
260 repository: input.repository,
261 number: input.number,
262 stackNumber: input.stackNumber,
263 cause: rejection,
264 },
265 });
266 }),
267);
268
269it.effect("refuses a changed stack before performing any mutation", () =>
270 Effect.gen(function* () {
271 const api = fake([stack]);
272 const result = yield* runGitHubStackAction(api.execute, {
273 ...input,
274 expectedStackHeads: [
275 { number: 2, headSha: "old" },
276 { number: 3, headSha: "ccc" },
277 ],
278 }).pipe(Effect.result);
279 expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } });
280 expect(api.calls).toHaveLength(1);
281 }),
282);
283
284it.effect("refuses the entire rebase before mutation when a later fork denies write access", () =>
285 Effect.gen(function* () {
286 const api = fake([
287 stack,
288 {
289 data: {
290 repository: {
291 ...access.data.repository,
292 pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false },
293 },
294 },
295 },
296 ]);
297 const result = yield* runGitHubStackAction(api.execute, {
298 ...input,
299 action: "update-branch",
300 }).pipe(Effect.result);
301 expect(result).toMatchObject({
302 _tag: "Failure",
303 failure: { _tag: "GitHubStackPermissionError" },
304 });
305 expect(api.calls).toHaveLength(2);
306 }),
307);
308
309it.effect("allows a fork that explicitly permits maintainer updates", () =>
310 Effect.gen(function* () {
311 const api = fake([
312 stack,
313 {
314 data: {
315 repository: {
316 ...access.data.repository,
317 pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true },
318 },
319 },
320 },
321 ...rebaseResponses,
322 ]);
323 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
324 expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2);
325 }),
326);
327
328it.effect("rebases for a GitHub App, which GitHub gives no permission to read", () =>
329 Effect.gen(function* () {
330 gitCalls = [];
331 const api = fake([
332 stack,
333 {
334 data: {
335 repository: {
336 pr2: { headRepository: { viewerPermission: null }, maintainerCanModify: false },
337 pr3: { headRepository: { viewerPermission: null }, maintainerCanModify: false },
338 },
339 },
340 },
341 ...rebaseResponses,
342 ]);
343 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
344 expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2);
345 }),
346);
347
348it.effect("bounds polling and reports a still-running merge without claiming success", () =>
349 Effect.gen(function* () {
350 const api = fake([
351 stack,
352 ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })),
353 ]);
354 const fiber = yield* runGitHubStackAction(api.execute, input).pipe(
355 Effect.result,
356 Effect.forkChild,
357 );
358 yield* TestClock.adjust("6 minutes");
359 expect(yield* Fiber.join(fiber)).toMatchObject({
360 _tag: "Failure",
361 failure: { _tag: "GitHubStackMergePendingError" },
362 });
363 expect(api.calls.length).toBeLessThan(40);
364 }),
365);
366
367it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () =>
368 Effect.gen(function* () {
369 gitCalls = [];
370 const api = fake([stack, access]);
371 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
372 // GitHub is only read; every change is a git push with a lease on the reviewed head.
373 expect(api.calls.some(isMutation)).toBe(false);
374 const rebases = gitCalls.filter((args) => args[0] === "rebase");
375 expect(rebases).toEqual([
376 ["rebase", "--quiet", "--onto", "origin/main", "new-sha"],
377 ["rebase", "--quiet", "--onto", "new-sha", "bbb"],
378 ]);
379 expect(gitCalls.filter((args) => args[0] === "push")).toEqual([
380 [
381 "push",
382 "--quiet",
383 "--force-with-lease=refs/heads/middle:bbb",
384 "origin",
385 "new-sha:refs/heads/middle",
386 ],
387 [
388 "push",
389 "--quiet",
390 "--force-with-lease=refs/heads/top:ccc",
391 "origin",
392 "new-sha:refs/heads/top",
393 ],
394 ]);
395 }),
396);
397
398it.effect("checks every layer's write access before any git runs", () =>
399 Effect.gen(function* () {
400 gitCalls = [];
401 const api = fake([
402 stack,
403 {
404 data: {
405 repository: {
406 ...access.data.repository,
407 pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false },
408 },
409 },
410 },
411 ]);
412 yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" }));
413 expect(gitCalls).toEqual([]);
414 }),
415);