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 | ); | ||