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 @@
1import type {
2 PullRequestAction,
3 PullRequestMergeMethod,
4 PullRequestStackHead,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Clock from "effect/Clock";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10
11import * as GitHubCli from "../sourceControl/GitHubCli.ts";
12import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
13
14const stackErrorIdentity = {
15 repository: Schema.String,
16 number: Schema.Int,
17 stackNumber: Schema.Int,
18};
19
20export 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
35export 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
48export 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
61export 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
74export 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
87export 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
100export 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
113export type GitHubStackActionError =
114 | GitHubStackChangedError
115 | GitHubStackUnsupportedError
116 | GitHubStackResponseInvalidError
117 | GitHubStackMergeRejectedError
118 | GitHubStackMergePendingError
119 | GitHubStackPermissionError
120 | GitHubStackRebaseFailedError;
121
122const 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
130const 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
152const 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);
170const 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
182const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
183
184const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
185
186/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
187export 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});