| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import { ChildProcessSpawner } from "effect/process"; |
| 10 | import * as GitHubApi from "./GitHubApi.ts"; |
| 11 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 12 | import { 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 | */ |
| 18 | type Call = ReadonlyArray<string>; |
| 19 | type 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. */ |
| 25 | let gitCalls: Array<ReadonlyArray<string>> = []; |
| 26 | const 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 | |
| 47 | const 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 | |
| 80 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 81 | |
| 82 | function 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 | |
| 91 | const isMutation = (call: Call) => call[0]!.startsWith("mutation"); |
| 92 | |
| 93 | const 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 | ]; |
| 117 | const 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 | }; |
| 129 | const access = { |
| 130 | data: { |
| 131 | repository: { |
| 132 | pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, |
| 133 | pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, |
| 134 | }, |
| 135 | }, |
| 136 | }; |
| 137 | |
| 138 | const 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 | }); |
| 146 | const rebased = { |
| 147 | data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, |
| 148 | }; |
| 149 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; |
| 150 | |
| 151 | function 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 | |
| 163 | it.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 | |
| 175 | it.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 | |
| 201 | it.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 | |
| 214 | it.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 | |
| 230 | it.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 | |
| 251 | it.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 | |
| 269 | it.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 | |
| 284 | it.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 | |
| 309 | it.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 | |
| 328 | it.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 | |
| 348 | it.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 | |
| 367 | it.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 | |
| 398 | it.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 | ); |