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.ts (1 of 53 files). Show all

Unified · Split

apps/server/src/pullRequest/githubStackActions.ts+418−0
@@ -0,0 +1,418 @@
1+import type {
2+ PullRequestAction,
3+ PullRequestMergeMethod,
4+ PullRequestStackHead,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as Clock from "effect/Clock";
8+import * as Result from "effect/Result";
9+import * as Schema from "effect/Schema";
10+
11+import * as GitHubCli from "../sourceControl/GitHubCli.ts";
12+import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
13+
14+const stackErrorIdentity = {
15+ repository: Schema.String,
16+ number: Schema.Int,
17+ stackNumber: Schema.Int,
18+};
19+
20+export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()(
21+ "GitHubStackChangedError",
22+ { ...stackErrorIdentity, completed: Schema.Int },
23+) {
24+ get detail(): string {
25+ return this.message;
26+ }
27+
28+ override get message(): string {
29+ return this.completed > 0
30+ ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
31+ : "The stack changed. Refresh it before trying again.";
32+ }
33+}
34+
35+export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()(
36+ "GitHubStackUnsupportedError",
37+ stackErrorIdentity,
38+) {
39+ get detail(): string {
40+ return this.message;
41+ }
42+
43+ override get message(): string {
44+ return "This operation is not supported for this stack.";
45+ }
46+}
47+
48+export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()(
49+ "GitHubStackResponseInvalidError",
50+ { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
51+) {
52+ get detail(): string {
53+ return this.message;
54+ }
55+
56+ override get message(): string {
57+ return "GitHub returned an unreadable stack operation response.";
58+ }
59+}
60+
61+export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()(
62+ "GitHubStackMergeRejectedError",
63+ { ...stackErrorIdentity, cause: Schema.Defect() },
64+) {
65+ get detail(): string {
66+ return this.message;
67+ }
68+
69+ override get message(): string {
70+ return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
71+ }
72+}
73+
74+export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()(
75+ "GitHubStackMergePendingError",
76+ stackErrorIdentity,
77+) {
78+ get detail(): string {
79+ return this.message;
80+ }
81+
82+ override get message(): string {
83+ return "The merge is still running on GitHub. Check its status there before submitting another request.";
84+ }
85+}
86+
87+export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()(
88+ "GitHubStackPermissionError",
89+ stackErrorIdentity,
90+) {
91+ get detail(): string {
92+ return this.message;
93+ }
94+
95+ override get message(): string {
96+ return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
97+ }
98+}
99+
100+export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()(
101+ "GitHubStackRebaseFailedError",
102+ { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
103+) {
104+ get detail(): string {
105+ return this.message;
106+ }
107+
108+ override get message(): string {
109+ return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
110+ }
111+}
112+
113+export type GitHubStackActionError =
114+ | GitHubStackChangedError
115+ | GitHubStackUnsupportedError
116+ | GitHubStackResponseInvalidError
117+ | GitHubStackMergeRejectedError
118+ | GitHubStackMergePendingError
119+ | GitHubStackPermissionError
120+ | GitHubStackRebaseFailedError;
121+
122+const MergeResponse = Schema.Struct({
123+ status: Schema.Literals(["pending", "merged", "enqueued", "failed"]),
124+ details: Schema.Struct({
125+ uuid: Schema.optional(Schema.String),
126+ message: Schema.optional(Schema.String),
127+ }),
128+});
129+
130+const decodeBranchAccess = Schema.decodeEffect(
131+ Schema.fromJsonString(
132+ Schema.Struct({
133+ data: Schema.Struct({
134+ repository: Schema.NullOr(
135+ Schema.Record(
136+ Schema.String,
137+ Schema.NullOr(
138+ Schema.Struct({
139+ headRepository: Schema.NullOr(
140+ Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }),
141+ ),
142+ maintainerCanModify: Schema.Boolean,
143+ }),
144+ ),
145+ ),
146+ ),
147+ }),
148+ }),
149+ ),
150+);
151+
152+const decodeRebaseBranch = Schema.decodeEffect(
153+ Schema.fromJsonString(
154+ Schema.Struct({
155+ data: Schema.Struct({
156+ processed: Schema.optional(
157+ Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))),
158+ ),
159+ repository: Schema.Struct({
160+ pullRequest: Schema.Struct({
161+ id: Schema.String,
162+ headRefOid: Schema.String,
163+ baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }),
164+ }),
165+ }),
166+ }),
167+ }),
168+ ),
169+);
170+const decodeRebaseResponse = Schema.decodeEffect(
171+ Schema.fromJsonString(
172+ Schema.Struct({
173+ data: Schema.Struct({
174+ updatePullRequestBranch: Schema.Struct({
175+ pullRequest: Schema.Struct({ headRefOid: Schema.String }),
176+ }),
177+ }),
178+ }),
179+ ),
180+);
181+
182+const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
183+
184+const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
185+
186+/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
187+export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: {
188+ cwd: string;
189+ repository: string;
190+ host: string;
191+ number: number;
192+ stackNumber: number;
193+ expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
194+ action: PullRequestAction;
195+ mergeMethod?: PullRequestMergeMethod;
196+}) {
197+ const github = yield* GitHubCli.GitHubCli;
198+ const identity = {
199+ repository: input.repository,
200+ number: input.number,
201+ stackNumber: input.stackNumber,
202+ };
203+ if (input.action !== "merge" && input.action !== "update-branch")
204+ return yield* new GitHubStackUnsupportedError({ ...identity });
205+ const endpoint = `repos/${input.repository}`;
206+ const read = yield* github.execute({
207+ cwd: input.cwd,
208+ args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`],
209+ });
210+ const decoded = decodePullRequestStacksJson(read.stdout);
211+ if (Result.isFailure(decoded))
212+ return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
213+ const stack = decoded.success;
214+ const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1;
215+ const target = stack?.layers[targetIndex];
216+ if (
217+ stack?.number !== input.stackNumber ||
218+ target === undefined ||
219+ (input.action === "update-branch" && targetIndex !== stack.layers.length - 1)
220+ ) {
221+ return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
222+ }
223+ const affectedLayers =
224+ input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers;
225+ const open = affectedLayers.filter((layer) => layer.state !== "merged");
226+ if (input.action === "merge" && target.state !== "open")
227+ return yield* new GitHubStackUnsupportedError({ ...identity });
228+ if (
229+ !input.expectedStackHeads ||
230+ input.expectedStackHeads.length !== open.length ||
231+ new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length ||
232+ open.some(
233+ (layer) =>
234+ !layer.headSha ||
235+ !input.expectedStackHeads?.some(
236+ (expected) => expected.number === layer.number && expected.headSha === layer.headSha,
237+ ),
238+ )
239+ ) {
240+ return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
241+ }
242+ if (open.length === 0 || open.some((layer) => layer.state !== "open"))
243+ return yield* new GitHubStackUnsupportedError({ ...identity });
244+ if (input.action === "update-branch") {
245+ const [owner, name] = input.repository.split("/");
246+ const permissions = yield* github.execute({
247+ cwd: input.cwd,
248+ args: [
249+ "api",
250+ "--hostname",
251+ input.host,
252+ "graphql",
253+ "-f",
254+ `owner=${owner}`,
255+ "-f",
256+ `name=${name}`,
257+ "-f",
258+ `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
259+ .map(
260+ (layer) =>
261+ `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
262+ )
263+ .join(" ")}}}`,
264+ ],
265+ });
266+ const access = yield* decodeBranchAccess(permissions.stdout).pipe(
267+ Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
268+ );
269+ // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
270+ // will make it stale. Check branch write access separately before touching any layer.
271+ if (
272+ open.some((layer) => {
273+ const pr = access.data.repository?.[`pr${layer.number}`];
274+ return (
275+ !pr?.headRepository ||
276+ (!pr.maintainerCanModify &&
277+ !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? ""))
278+ );
279+ })
280+ )
281+ return yield* new GitHubStackPermissionError({ ...identity });
282+ const processed: Array<{ id: string; number: number; headSha: string }> = [];
283+ for (const [index, layer] of open.entries()) {
284+ yield* Effect.gen(function* () {
285+ const read = yield* github.execute({
286+ cwd: input.cwd,
287+ args: [
288+ "api",
289+ "--hostname",
290+ input.host,
291+ "graphql",
292+ "-f",
293+ `owner=${owner}`,
294+ "-f",
295+ `name=${name}`,
296+ "-F",
297+ `number=${layer.number}`,
298+ "-f",
299+ `sha=${layer.headSha}`,
300+ "-f",
301+ `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${
302+ processed.length === 0
303+ ? ""
304+ : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}`
305+ } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
306+ ],
307+ });
308+ const {
309+ data: {
310+ processed: observed,
311+ repository: { pullRequest: pr },
312+ },
313+ } = yield* decodeRebaseBranch(read.stdout);
314+ // A push to an earlier layer must not silently become the next layer's new base.
315+ const changed = processed.find(
316+ (head, index) => observed?.[index]?.headRefOid !== head.headSha,
317+ );
318+ if (changed !== undefined)
319+ return yield* new GitHubStackChangedError({
320+ ...identity,
321+ number: changed.number,
322+ completed: index,
323+ });
324+ if (pr.headRefOid !== layer.headSha)
325+ return yield* new GitHubStackChangedError({
326+ ...identity,
327+ number: layer.number,
328+ completed: index,
329+ });
330+ if (pr.baseRef.compare.behindBy === 0) {
331+ processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid });
332+ return;
333+ }
334+ // Pass the reviewed revision to GitHub, including when a push races this read.
335+ const updated = yield* github.execute({
336+ cwd: input.cwd,
337+ args: [
338+ "api",
339+ "--hostname",
340+ input.host,
341+ "graphql",
342+ "-f",
343+ `id=${pr.id}`,
344+ "-f",
345+ `sha=${layer.headSha}`,
346+ "-f",
347+ "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
348+ ],
349+ });
350+ const response = yield* decodeRebaseResponse(updated.stdout);
351+ processed.push({
352+ id: pr.id,
353+ number: layer.number,
354+ headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid,
355+ });
356+ }).pipe(
357+ Effect.mapError((cause) =>
358+ cause._tag === "GitHubStackChangedError"
359+ ? cause
360+ : new GitHubStackRebaseFailedError({
361+ ...identity,
362+ number: layer.number,
363+ completed: index,
364+ cause,
365+ }),
366+ ),
367+ );
368+ }
369+ return;
370+ }
371+ if (open.some((layer) => layer.isDraft))
372+ return yield* new GitHubStackUnsupportedError({ ...identity });
373+ const decode = (raw: string) =>
374+ decodeMergeResponse(raw).pipe(
375+ Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
376+ );
377+ const request = yield* github.execute({
378+ cwd: input.cwd,
379+ args: [
380+ "api",
381+ "--hostname",
382+ input.host,
383+ "--method",
384+ "PUT",
385+ `${endpoint}/pulls/${input.number}/merge-async`,
386+ "-f",
387+ `merge_method=${input.mergeMethod ?? "merge"}`,
388+ "-f",
389+ "merge_action=default",
390+ "-f",
391+ `sha=${target.headSha}`,
392+ ],
393+ });
394+ let result = yield* decode(request.stdout);
395+ const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
396+ for (
397+ let attempt = 0;
398+ result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline;
399+ attempt++
400+ ) {
401+ const uuid = result.details.uuid;
402+ if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
403+ yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
404+ const poll = yield* github.execute({
405+ cwd: input.cwd,
406+ args: [
407+ "api",
408+ "--hostname",
409+ input.host,
410+ `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
411+ ],
412+ });
413+ result = yield* decode(poll.stdout);
414+ }
415+ if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
416+ if (result.status === "failed")
417+ return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result });
418+});