import { expect, it } from "@effect/vitest"; import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; const runGitHubStackAction = ( execute: GitHubCli.GitHubCli["Service"]["execute"], input: Parameters[0], ) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); const stack = [ { number: 50, url: "https://api.github.com/repos/acme/web/stacks/50", base: { ref: "main" }, pull_requests: [ { number: 1, title: "Base", head: { ref: "base", sha: "aaa" }, state: "closed", merged_at: "2026-01-01T00:00:00Z", }, { number: 2, title: "Middle", head: { ref: "middle", sha: "bbb" }, state: "open", draft: false, }, { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, ], }, ]; const input = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 3, stackNumber: 50, expectedStackHeads: [ { number: 2, headSha: "bbb" }, { number: 3, headSha: "ccc" }, ], action: "merge" as const, }; const access = { data: { repository: { pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, }, }, }; const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ data: { processed: processed.map((headRefOid) => ({ headRefOid })), repository: { pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, }, }, }); const rebased = { data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, }; const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; function fake(responses: readonly unknown[]) { const calls: ReadonlyArray[] = []; const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => Effect.sync(() => { calls.push(request.args); const value = responses[calls.length - 1]; if (value === undefined) throw new Error("Unexpected GitHub request"); return { exitCode: ChildProcessSpawner.ExitCode(0), // @effect-diagnostics-next-line preferSchemaOverJson:off stdout: JSON.stringify(value), stderr: "", stdoutTruncated: false, stderrTruncated: false, stdoutInvalidUtf8: false, }; }); return { execute, calls }; } it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => Effect.gen(function* () { const api = fake([stack, { status: "enqueued", details: {} }]); yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); expect(api.calls).toHaveLength(2); expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); expect(api.calls[1]).toContain("sha=ccc"); expect(api.calls[1]).toContain("merge_action=default"); expect(api.calls[1]).toContain("merge_method=squash"); }), ); it.effect("merges through the selected layer without including later draft layers", () => Effect.gen(function* () { const fiveLayers = [ { ...stack[0], pull_requests: Array.from({ length: 5 }, (_, index) => ({ number: index + 1, head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, state: "open", draft: index >= 3, })), }, ]; const api = fake([fiveLayers, { status: "merged", details: {} }]); yield* runGitHubStackAction(api.execute, { ...input, number: 3, expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), }); expect(api.calls).toHaveLength(2); expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); expect(api.calls[1]).toContain("sha=sha-3"); expect(api.calls[1]).toContain("merge_action=default"); }), ); it.effect("rejects stale reviewed heads below a selected middle layer", () => Effect.gen(function* () { const api = fake([stack]); const result = yield* runGitHubStackAction(api.execute, { ...input, number: 2, expectedStackHeads: [{ number: 2, headSha: "old-head" }], }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); expect(api.calls).toHaveLength(1); }), ); it.effect("does not merge from an already merged layer", () => Effect.gen(function* () { const api = fake([stack]); const result = yield* runGitHubStackAction(api.execute, { ...input, number: 1, expectedStackHeads: [], }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackUnsupportedError" }, }); expect(api.calls).toHaveLength(1); }), ); it.effect("polls an accepted merge and reports a later rule rejection", () => Effect.gen(function* () { const api = fake([ stack, { status: "pending", details: { uuid: "operation" } }, { status: "failed", details: { message: "Required checks have not passed" } }, ]); const fiber = yield* runGitHubStackAction(api.execute, input).pipe( Effect.result, Effect.forkChild, ); yield* TestClock.adjust("1 second"); const result = yield* Fiber.join(fiber); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackMergeRejectedError" }, }); expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); }), ); it.effect("retains stack identity and a rejection response without a message", () => Effect.gen(function* () { const rejection = { status: "failed", details: {} }; const api = fake([stack, rejection]); const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackMergeRejectedError", repository: input.repository, number: input.number, stackNumber: input.stackNumber, cause: rejection, }, }); }), ); it.effect("refuses a changed stack before performing any mutation", () => Effect.gen(function* () { const api = fake([stack]); const result = yield* runGitHubStackAction(api.execute, { ...input, expectedStackHeads: [ { number: 2, headSha: "old" }, { number: 3, headSha: "ccc" }, ], }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); expect(api.calls).toHaveLength(1); }), ); it.effect("rebases unmerged layers bottom to top without local git commands", () => Effect.gen(function* () { const api = fake([stack, access, ...rebaseResponses]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); const mutations = api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation")), ); expect(mutations).toHaveLength(2); expect(mutations[0]).toContain("id=PR_2"); expect(mutations[0]).toContain("sha=bbb"); expect(mutations[1]).toContain("id=PR_3"); expect(mutations[1]).toContain("sha=ccc"); expect(api.calls.every((args) => args[0] === "api")).toBe(true); }), ); it.effect("does not update later layers after a rebase failure", () => Effect.gen(function* () { const api = fake([stack, access, branch(2, "bbb")]); const execute: typeof api.execute = (request) => !request.args.some((arg) => arg.startsWith("query=mutation")) ? api.execute(request) : Effect.fail( new GitHubCli.GitHubCliAuthenticationError({ command: "gh", cwd: "/repo", cause: new Error("denied"), }), ); const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( Effect.result, ); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, }); }), ); it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => Effect.gen(function* () { const api = fake([ stack, { data: { repository: { ...access.data.repository, pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, }, }, }, ]); const result = yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch", }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackPermissionError" }, }); expect(api.calls).toHaveLength(2); expect(api.calls.every((args) => args[0] === "api")).toBe(true); }), ); it.effect("allows a fork that explicitly permits maintainer updates", () => Effect.gen(function* () { const api = fake([ stack, { data: { repository: { ...access.data.repository, pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, }, }, }, ...rebaseResponses, ]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); expect(api.calls.at(-1)).toContain("id=PR_3"); }), ); it.effect("bounds polling and reports a still-running merge without claiming success", () => Effect.gen(function* () { const api = fake([ stack, ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), ]); const fiber = yield* runGitHubStackAction(api.execute, input).pipe( Effect.result, Effect.forkChild, ); yield* TestClock.adjust("6 minutes"); expect(yield* Fiber.join(fiber)).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackMergePendingError" }, }); expect(api.calls.length).toBeLessThan(40); }), ); it.effect("rejects a push after preflight without rebasing the new revision", () => Effect.gen(function* () { const api = fake([stack, access, branch(2, "new-head")]); const result = yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch", }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, }); expect(api.calls).toHaveLength(3); }), ); it.effect("skips current layers without submitting a rebase mutation", () => Effect.gen(function* () { const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( false, ); }), ); it.effect("keeps earlier progress and stops after a later layer fails", () => Effect.gen(function* () { const api = fake([ stack, access, branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), { data: { updatePullRequestBranch: null } }, ]); const result = yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch", }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, }); }), ); it.effect("reports partial progress when a later head changes during the rebase", () => Effect.gen(function* () { const api = fake([ stack, access, branch(2, "bbb"), rebased, branch(3, "concurrent-head", 1, ["rebased-sha"]), ]); const result = yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch", }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, }); if (result._tag === "Failure") { expect(result.failure.message).toContain("Earlier updates remain on GitHub"); } expect( api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), ).toHaveLength(1); }), ); it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => Effect.gen(function* () { const api = fake([ stack, access, branch(2, "bbb", rebasedParent ? 1 : 0), ...(rebasedParent ? [rebased] : []), branch(3, "ccc", 1, ["concurrent-parent-head"]), ]); const result = yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch", }).pipe(Effect.result); expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, }); expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( true, ); expect( api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), ).toHaveLength(rebasedParent ? 1 : 0); }), );