History

feat(prs): navigate, merge and rebase GitHub stacks (#10875)

Julius Marminge committed 2 months ago

Commit de379641 parent f0401c653 files · +3104 −464Browse files

Showing the changes to apps/server/src/pullRequest/githubStackActions.test.ts (1 of 53 files). Show all

Unified · Split

apps/server/src/pullRequest/githubStackActions.test.ts+413−0
@@ -0,0 +1,413 @@
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 TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/unstable/process";
7import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";
9
10const 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
15const 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];
39const 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};
51const access = {
52 data: {
53 repository: {
54 pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
55 pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
56 },
57 },
58};
59
60const 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});
68const rebased = {
69 data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } },
70};
71const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
72
73function 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
93it.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
105it.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
131it.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
144it.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
160it.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
181it.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
199it.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
214it.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
230it.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
253it.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
279it.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
298it.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
317it.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
332it.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
342it.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
363it.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
389it.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);