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