Showing the changes to apps/server/src/pullRequest/githubStackActions.test.ts (1 of 53 files). Show all
Unified · Split
| @@ -0,0 +1,413 @@ | ||
| 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 TestClock from "effect/testing/TestClock"; | |
| 6 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 7 | +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 8 | +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; | |
| 9 | + | |
| 10 | +const runGitHubStackAction = ( | |
| 11 | + execute: GitHubCli.GitHubCli["Service"]["execute"], | |
| 12 | + input: Parameters<typeof runStackAction>[0], | |
| 13 | +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); | |
| 14 | + | |
| 15 | +const stack = [ | |
| 16 | + { | |
| 17 | + number: 50, | |
| 18 | + url: "https://api.github.com/repos/acme/web/stacks/50", | |
| 19 | + base: { ref: "main" }, | |
| 20 | + pull_requests: [ | |
| 21 | + { | |
| 22 | + number: 1, | |
| 23 | + title: "Base", | |
| 24 | + head: { ref: "base", sha: "aaa" }, | |
| 25 | + state: "closed", | |
| 26 | + merged_at: "2026-01-01T00:00:00Z", | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + number: 2, | |
| 30 | + title: "Middle", | |
| 31 | + head: { ref: "middle", sha: "bbb" }, | |
| 32 | + state: "open", | |
| 33 | + draft: false, | |
| 34 | + }, | |
| 35 | + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, | |
| 36 | + ], | |
| 37 | + }, | |
| 38 | +]; | |
| 39 | +const input = { | |
| 40 | + cwd: "/repo", | |
| 41 | + repository: "acme/web", | |
| 42 | + host: "github.com", | |
| 43 | + number: 3, | |
| 44 | + stackNumber: 50, | |
| 45 | + expectedStackHeads: [ | |
| 46 | + { number: 2, headSha: "bbb" }, | |
| 47 | + { number: 3, headSha: "ccc" }, | |
| 48 | + ], | |
| 49 | + action: "merge" as const, | |
| 50 | +}; | |
| 51 | +const access = { | |
| 52 | + data: { | |
| 53 | + repository: { | |
| 54 | + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 55 | + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 56 | + }, | |
| 57 | + }, | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ | |
| 61 | + data: { | |
| 62 | + processed: processed.map((headRefOid) => ({ headRefOid })), | |
| 63 | + repository: { | |
| 64 | + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, | |
| 65 | + }, | |
| 66 | + }, | |
| 67 | +}); | |
| 68 | +const rebased = { | |
| 69 | + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, | |
| 70 | +}; | |
| 71 | +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; | |
| 72 | + | |
| 73 | +function fake(responses: readonly unknown[]) { | |
| 74 | + const calls: ReadonlyArray<string>[] = []; | |
| 75 | + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => | |
| 76 | + Effect.sync(() => { | |
| 77 | + calls.push(request.args); | |
| 78 | + const value = responses[calls.length - 1]; | |
| 79 | + if (value === undefined) throw new Error("Unexpected GitHub request"); | |
| 80 | + return { | |
| 81 | + exitCode: ChildProcessSpawner.ExitCode(0), | |
| 82 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 83 | + stdout: JSON.stringify(value), | |
| 84 | + stderr: "", | |
| 85 | + stdoutTruncated: false, | |
| 86 | + stderrTruncated: false, | |
| 87 | + stdoutInvalidUtf8: false, | |
| 88 | + }; | |
| 89 | + }); | |
| 90 | + return { execute, calls }; | |
| 91 | +} | |
| 92 | + | |
| 93 | +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => | |
| 94 | + Effect.gen(function* () { | |
| 95 | + const api = fake([stack, { status: "enqueued", details: {} }]); | |
| 96 | + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); | |
| 97 | + expect(api.calls).toHaveLength(2); | |
| 98 | + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 99 | + expect(api.calls[1]).toContain("sha=ccc"); | |
| 100 | + expect(api.calls[1]).toContain("merge_action=default"); | |
| 101 | + expect(api.calls[1]).toContain("merge_method=squash"); | |
| 102 | + }), | |
| 103 | +); | |
| 104 | + | |
| 105 | +it.effect("merges through the selected layer without including later draft layers", () => | |
| 106 | + Effect.gen(function* () { | |
| 107 | + const fiveLayers = [ | |
| 108 | + { | |
| 109 | + ...stack[0], | |
| 110 | + pull_requests: Array.from({ length: 5 }, (_, index) => ({ | |
| 111 | + number: index + 1, | |
| 112 | + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, | |
| 113 | + state: "open", | |
| 114 | + draft: index >= 3, | |
| 115 | + })), | |
| 116 | + }, | |
| 117 | + ]; | |
| 118 | + const api = fake([fiveLayers, { status: "merged", details: {} }]); | |
| 119 | + yield* runGitHubStackAction(api.execute, { | |
| 120 | + ...input, | |
| 121 | + number: 3, | |
| 122 | + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), | |
| 123 | + }); | |
| 124 | + expect(api.calls).toHaveLength(2); | |
| 125 | + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 126 | + expect(api.calls[1]).toContain("sha=sha-3"); | |
| 127 | + expect(api.calls[1]).toContain("merge_action=default"); | |
| 128 | + }), | |
| 129 | +); | |
| 130 | + | |
| 131 | +it.effect("rejects stale reviewed heads below a selected middle layer", () => | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const api = fake([stack]); | |
| 134 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 135 | + ...input, | |
| 136 | + number: 2, | |
| 137 | + expectedStackHeads: [{ number: 2, headSha: "old-head" }], | |
| 138 | + }).pipe(Effect.result); | |
| 139 | + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | |
| 140 | + expect(api.calls).toHaveLength(1); | |
| 141 | + }), | |
| 142 | +); | |
| 143 | + | |
| 144 | +it.effect("does not merge from an already merged layer", () => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + const api = fake([stack]); | |
| 147 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 148 | + ...input, | |
| 149 | + number: 1, | |
| 150 | + expectedStackHeads: [], | |
| 151 | + }).pipe(Effect.result); | |
| 152 | + expect(result).toMatchObject({ | |
| 153 | + _tag: "Failure", | |
| 154 | + failure: { _tag: "GitHubStackUnsupportedError" }, | |
| 155 | + }); | |
| 156 | + expect(api.calls).toHaveLength(1); | |
| 157 | + }), | |
| 158 | +); | |
| 159 | + | |
| 160 | +it.effect("polls an accepted merge and reports a later rule rejection", () => | |
| 161 | + Effect.gen(function* () { | |
| 162 | + const api = fake([ | |
| 163 | + stack, | |
| 164 | + { status: "pending", details: { uuid: "operation" } }, | |
| 165 | + { status: "failed", details: { message: "Required checks have not passed" } }, | |
| 166 | + ]); | |
| 167 | + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 168 | + Effect.result, | |
| 169 | + Effect.forkChild, | |
| 170 | + ); | |
| 171 | + yield* TestClock.adjust("1 second"); | |
| 172 | + const result = yield* Fiber.join(fiber); | |
| 173 | + expect(result).toMatchObject({ | |
| 174 | + _tag: "Failure", | |
| 175 | + failure: { _tag: "GitHubStackMergeRejectedError" }, | |
| 176 | + }); | |
| 177 | + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); | |
| 178 | + }), | |
| 179 | +); | |
| 180 | + | |
| 181 | +it.effect("retains stack identity and a rejection response without a message", () => | |
| 182 | + Effect.gen(function* () { | |
| 183 | + const rejection = { status: "failed", details: {} }; | |
| 184 | + const api = fake([stack, rejection]); | |
| 185 | + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); | |
| 186 | + expect(result).toMatchObject({ | |
| 187 | + _tag: "Failure", | |
| 188 | + failure: { | |
| 189 | + _tag: "GitHubStackMergeRejectedError", | |
| 190 | + repository: input.repository, | |
| 191 | + number: input.number, | |
| 192 | + stackNumber: input.stackNumber, | |
| 193 | + cause: rejection, | |
| 194 | + }, | |
| 195 | + }); | |
| 196 | + }), | |
| 197 | +); | |
| 198 | + | |
| 199 | +it.effect("refuses a changed stack before performing any mutation", () => | |
| 200 | + Effect.gen(function* () { | |
| 201 | + const api = fake([stack]); | |
| 202 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 203 | + ...input, | |
| 204 | + expectedStackHeads: [ | |
| 205 | + { number: 2, headSha: "old" }, | |
| 206 | + { number: 3, headSha: "ccc" }, | |
| 207 | + ], | |
| 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("rebases unmerged layers bottom to top without local git commands", () => | |
| 215 | + Effect.gen(function* () { | |
| 216 | + const api = fake([stack, access, ...rebaseResponses]); | |
| 217 | + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 218 | + const mutations = api.calls.filter((args) => | |
| 219 | + args.some((arg) => arg.startsWith("query=mutation")), | |
| 220 | + ); | |
| 221 | + expect(mutations).toHaveLength(2); | |
| 222 | + expect(mutations[0]).toContain("id=PR_2"); | |
| 223 | + expect(mutations[0]).toContain("sha=bbb"); | |
| 224 | + expect(mutations[1]).toContain("id=PR_3"); | |
| 225 | + expect(mutations[1]).toContain("sha=ccc"); | |
| 226 | + expect(api.calls.every((args) => args[0] === "api")).toBe(true); | |
| 227 | + }), | |
| 228 | +); | |
| 229 | + | |
| 230 | +it.effect("does not update later layers after a rebase failure", () => | |
| 231 | + Effect.gen(function* () { | |
| 232 | + const api = fake([stack, access, branch(2, "bbb")]); | |
| 233 | + const execute: typeof api.execute = (request) => | |
| 234 | + !request.args.some((arg) => arg.startsWith("query=mutation")) | |
| 235 | + ? api.execute(request) | |
| 236 | + : Effect.fail( | |
| 237 | + new GitHubCli.GitHubCliAuthenticationError({ | |
| 238 | + command: "gh", | |
| 239 | + cwd: "/repo", | |
| 240 | + cause: new Error("denied"), | |
| 241 | + }), | |
| 242 | + ); | |
| 243 | + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( | |
| 244 | + Effect.result, | |
| 245 | + ); | |
| 246 | + expect(result).toMatchObject({ | |
| 247 | + _tag: "Failure", | |
| 248 | + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, | |
| 249 | + }); | |
| 250 | + }), | |
| 251 | +); | |
| 252 | + | |
| 253 | +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => | |
| 254 | + Effect.gen(function* () { | |
| 255 | + const api = fake([ | |
| 256 | + stack, | |
| 257 | + { | |
| 258 | + data: { | |
| 259 | + repository: { | |
| 260 | + ...access.data.repository, | |
| 261 | + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | |
| 262 | + }, | |
| 263 | + }, | |
| 264 | + }, | |
| 265 | + ]); | |
| 266 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 267 | + ...input, | |
| 268 | + action: "update-branch", | |
| 269 | + }).pipe(Effect.result); | |
| 270 | + expect(result).toMatchObject({ | |
| 271 | + _tag: "Failure", | |
| 272 | + failure: { _tag: "GitHubStackPermissionError" }, | |
| 273 | + }); | |
| 274 | + expect(api.calls).toHaveLength(2); | |
| 275 | + expect(api.calls.every((args) => args[0] === "api")).toBe(true); | |
| 276 | + }), | |
| 277 | +); | |
| 278 | + | |
| 279 | +it.effect("allows a fork that explicitly permits maintainer updates", () => | |
| 280 | + Effect.gen(function* () { | |
| 281 | + const api = fake([ | |
| 282 | + stack, | |
| 283 | + { | |
| 284 | + data: { | |
| 285 | + repository: { | |
| 286 | + ...access.data.repository, | |
| 287 | + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, | |
| 288 | + }, | |
| 289 | + }, | |
| 290 | + }, | |
| 291 | + ...rebaseResponses, | |
| 292 | + ]); | |
| 293 | + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 294 | + expect(api.calls.at(-1)).toContain("id=PR_3"); | |
| 295 | + }), | |
| 296 | +); | |
| 297 | + | |
| 298 | +it.effect("bounds polling and reports a still-running merge without claiming success", () => | |
| 299 | + Effect.gen(function* () { | |
| 300 | + const api = fake([ | |
| 301 | + stack, | |
| 302 | + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), | |
| 303 | + ]); | |
| 304 | + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 305 | + Effect.result, | |
| 306 | + Effect.forkChild, | |
| 307 | + ); | |
| 308 | + yield* TestClock.adjust("6 minutes"); | |
| 309 | + expect(yield* Fiber.join(fiber)).toMatchObject({ | |
| 310 | + _tag: "Failure", | |
| 311 | + failure: { _tag: "GitHubStackMergePendingError" }, | |
| 312 | + }); | |
| 313 | + expect(api.calls.length).toBeLessThan(40); | |
| 314 | + }), | |
| 315 | +); | |
| 316 | + | |
| 317 | +it.effect("rejects a push after preflight without rebasing the new revision", () => | |
| 318 | + Effect.gen(function* () { | |
| 319 | + const api = fake([stack, access, branch(2, "new-head")]); | |
| 320 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 321 | + ...input, | |
| 322 | + action: "update-branch", | |
| 323 | + }).pipe(Effect.result); | |
| 324 | + expect(result).toMatchObject({ | |
| 325 | + _tag: "Failure", | |
| 326 | + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, | |
| 327 | + }); | |
| 328 | + expect(api.calls).toHaveLength(3); | |
| 329 | + }), | |
| 330 | +); | |
| 331 | + | |
| 332 | +it.effect("skips current layers without submitting a rebase mutation", () => | |
| 333 | + Effect.gen(function* () { | |
| 334 | + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); | |
| 335 | + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 336 | + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( | |
| 337 | + false, | |
| 338 | + ); | |
| 339 | + }), | |
| 340 | +); | |
| 341 | + | |
| 342 | +it.effect("keeps earlier progress and stops after a later layer fails", () => | |
| 343 | + Effect.gen(function* () { | |
| 344 | + const api = fake([ | |
| 345 | + stack, | |
| 346 | + access, | |
| 347 | + branch(2, "bbb"), | |
| 348 | + rebased, | |
| 349 | + branch(3, "ccc", 1, ["rebased-sha"]), | |
| 350 | + { data: { updatePullRequestBranch: null } }, | |
| 351 | + ]); | |
| 352 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 353 | + ...input, | |
| 354 | + action: "update-branch", | |
| 355 | + }).pipe(Effect.result); | |
| 356 | + expect(result).toMatchObject({ | |
| 357 | + _tag: "Failure", | |
| 358 | + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, | |
| 359 | + }); | |
| 360 | + }), | |
| 361 | +); | |
| 362 | + | |
| 363 | +it.effect("reports partial progress when a later head changes during the rebase", () => | |
| 364 | + Effect.gen(function* () { | |
| 365 | + const api = fake([ | |
| 366 | + stack, | |
| 367 | + access, | |
| 368 | + branch(2, "bbb"), | |
| 369 | + rebased, | |
| 370 | + branch(3, "concurrent-head", 1, ["rebased-sha"]), | |
| 371 | + ]); | |
| 372 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 373 | + ...input, | |
| 374 | + action: "update-branch", | |
| 375 | + }).pipe(Effect.result); | |
| 376 | + expect(result).toMatchObject({ | |
| 377 | + _tag: "Failure", | |
| 378 | + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, | |
| 379 | + }); | |
| 380 | + if (result._tag === "Failure") { | |
| 381 | + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); | |
| 382 | + } | |
| 383 | + expect( | |
| 384 | + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | |
| 385 | + ).toHaveLength(1); | |
| 386 | + }), | |
| 387 | +); | |
| 388 | + | |
| 389 | +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => | |
| 390 | + Effect.gen(function* () { | |
| 391 | + const api = fake([ | |
| 392 | + stack, | |
| 393 | + access, | |
| 394 | + branch(2, "bbb", rebasedParent ? 1 : 0), | |
| 395 | + ...(rebasedParent ? [rebased] : []), | |
| 396 | + branch(3, "ccc", 1, ["concurrent-parent-head"]), | |
| 397 | + ]); | |
| 398 | + const result = yield* runGitHubStackAction(api.execute, { | |
| 399 | + ...input, | |
| 400 | + action: "update-branch", | |
| 401 | + }).pipe(Effect.result); | |
| 402 | + expect(result).toMatchObject({ | |
| 403 | + _tag: "Failure", | |
| 404 | + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, | |
| 405 | + }); | |
| 406 | + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( | |
| 407 | + true, | |
| 408 | + ); | |
| 409 | + expect( | |
| 410 | + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | |
| 411 | + ).toHaveLength(rebasedParent ? 1 : 0); | |
| 412 | + }), | |
| 413 | +); | |