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 GitHubApi from "./GitHubApi.ts";
12import { aliasedGraphQlDocument } from "./githubGraphQl.ts";
13import { decodePullRequestStacksJson, toCanWrite } from "./gitHubPullRequestJson.ts";
14import { cascadeRebaseStack } from "./githubStackRebase.ts";
15
16const stackErrorIdentity = {
17 repository: Schema.String,
18 number: Schema.Int,
19 stackNumber: Schema.Int,
20};
21
22export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()(
23 "GitHubStackChangedError",
24 { ...stackErrorIdentity, completed: Schema.Int },
25) {
26 override get message(): string {
27 return this.completed > 0
28 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
29 : "The stack changed. Refresh it before trying again.";
30 }
31}
32
33export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()(
34 "GitHubStackUnsupportedError",
35 stackErrorIdentity,
36) {
37 override get message(): string {
38 return "This operation is not supported for this stack.";
39 }
40}
41
42export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()(
43 "GitHubStackResponseInvalidError",
44 { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
45) {
46 override get message(): string {
47 return "GitHub returned an unreadable stack operation response.";
48 }
49}
50
51export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()(
52 "GitHubStackMergeRejectedError",
53 { ...stackErrorIdentity, cause: Schema.Defect() },
54) {
55 override get message(): string {
56 return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
57 }
58}
59
60export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()(
61 "GitHubStackMergePendingError",
62 stackErrorIdentity,
63) {
64 override get message(): string {
65 return "The merge is still running on GitHub. Check its status there before submitting another request.";
66 }
67}
68
69export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()(
70 "GitHubStackPermissionError",
71 stackErrorIdentity,
72) {
73 override get message(): string {
74 return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
75 }
76}
77
78export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()(
79 "GitHubStackRebaseFailedError",
80 { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
81) {
82 override get message(): string {
83 // A conflict or a refused push already says which layer and what to do about it.
84 return this.cause instanceof Error && this.cause.message !== ""
85 ? this.cause.message
86 : `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
87 }
88}
89
90export type GitHubStackActionError =
91 | GitHubStackChangedError
92 | GitHubStackUnsupportedError
93 | GitHubStackResponseInvalidError
94 | GitHubStackMergeRejectedError
95 | GitHubStackMergePendingError
96 | GitHubStackPermissionError
97 | GitHubStackRebaseFailedError;
98
99const MergeResponse = Schema.Struct({
100 status: Schema.Literals(["pending", "merged", "enqueued", "failed"]),
101 details: Schema.Struct({
102 uuid: Schema.optional(Schema.String),
103 message: Schema.optional(Schema.String),
104 }),
105});
106
107const decodeBranchAccess = Schema.decodeEffect(
108 Schema.fromJsonString(
109 Schema.Struct({
110 data: Schema.Struct({
111 repository: Schema.NullOr(
112 Schema.Record(
113 Schema.String,
114 Schema.NullOr(
115 Schema.Struct({
116 headRepository: Schema.NullOr(
117 Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }),
118 ),
119 maintainerCanModify: Schema.Boolean,
120 }),
121 ),
122 ),
123 ),
124 }),
125 }),
126 ),
127);
128
129const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
130
131/** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */
132export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: {
133 cwd: string;
134 repository: string;
135 host: string;
136 number: number;
137 stackNumber: number;
138 expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
139 action: PullRequestAction;
140 mergeMethod?: PullRequestMergeMethod;
141}) {
142 const api = yield* GitHubApi.GitHubApi;
143 const identity = {
144 repository: input.repository,
145 number: input.number,
146 stackNumber: input.stackNumber,
147 };
148 if (input.action !== "merge" && input.action !== "update-branch")
149 return yield* new GitHubStackUnsupportedError({ ...identity });
150 const endpoint = `repos/${input.repository}`;
151 const read = yield* api.rest({
152 host: input.host,
153 operation: "runGitHubStackAction",
154 path: `${endpoint}/stacks?pull_request=${input.number}`,
155 });
156 const decoded = decodePullRequestStacksJson(read.body);
157 if (Result.isFailure(decoded))
158 return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
159 const stack = decoded.success;
160 const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1;
161 const target = stack?.layers[targetIndex];
162 if (
163 stack?.number !== input.stackNumber ||
164 target === undefined ||
165 (input.action === "update-branch" && targetIndex !== stack.layers.length - 1)
166 ) {
167 return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
168 }
169 const affectedLayers =
170 input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers;
171 const open = affectedLayers.filter((layer) => layer.state !== "merged");
172 if (input.action === "merge" && target.state !== "open")
173 return yield* new GitHubStackUnsupportedError({ ...identity });
174 if (
175 !input.expectedStackHeads ||
176 input.expectedStackHeads.length !== open.length ||
177 new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length ||
178 open.some(
179 (layer) =>
180 !layer.headSha ||
181 !input.expectedStackHeads?.some(
182 (expected) => expected.number === layer.number && expected.headSha === layer.headSha,
183 ),
184 )
185 ) {
186 return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
187 }
188 if (open.length === 0 || open.some((layer) => layer.state !== "open"))
189 return yield* new GitHubStackUnsupportedError({ ...identity });
190 if (input.action === "update-branch") {
191 const [owner, name] = input.repository.split("/");
192 const permissions = yield* api.graphql({
193 host: input.host,
194 operation: "runGitHubStackAction",
195 allowReserve: true,
196 // `open` is non-empty here, so there is always a document.
197 ...aliasedGraphQlDocument({
198 operation: "query",
199 alias: "pr",
200 key: (layer) => layer.number,
201 items: open,
202 shared: { owner: ["String!", owner], name: ["String!", name] },
203 variables: (layer) => ({ number: ["Int!", layer.number] }),
204 field: ({ number }) =>
205 `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`,
206 within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`,
207 })!,
208 });
209 const access = yield* decodeBranchAccess(permissions).pipe(
210 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
211 );
212 // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
213 // will make it stale. Check branch write access separately before touching any layer.
214 if (
215 open.some((layer) => {
216 const pr = access.data.repository?.[`pr${layer.number}`];
217 return (
218 !pr?.headRepository ||
219 (!pr.maintainerCanModify && !toCanWrite(pr.headRepository.viewerPermission))
220 );
221 })
222 )
223 return yield* new GitHubStackPermissionError({ ...identity });
224 // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old
225 // copy of every lower layer into the one above it. The cascade moves each layer's own commits.
226 yield* cascadeRebaseStack({
227 host: input.host,
228 repository: input.repository,
229 base: stack.base,
230 layers: open.map((layer) => ({
231 number: layer.number,
232 headBranch: layer.headBranch,
233 headSha: layer.headSha!,
234 })),
235 }).pipe(
236 Effect.mapError((cause) =>
237 cause._tag === "GitHubStackRebaseConflictError" ||
238 cause._tag === "GitHubStackRebaseGitError"
239 ? new GitHubStackRebaseFailedError({
240 ...identity,
241 number: cause.number,
242 completed: cause.completed,
243 cause,
244 })
245 : cause,
246 ),
247 );
248 return;
249 }
250 if (open.some((layer) => layer.isDraft))
251 return yield* new GitHubStackUnsupportedError({ ...identity });
252 const decode = (raw: string) =>
253 decodeMergeResponse(raw).pipe(
254 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
255 );
256 const request = yield* api.rest({
257 host: input.host,
258 operation: "runGitHubStackAction",
259 method: "PUT",
260 path: `${endpoint}/pulls/${input.number}/merge-async`,
261 body: {
262 merge_method: input.mergeMethod ?? "merge",
263 merge_action: "default",
264 sha: target.headSha,
265 },
266 });
267 let result = yield* decode(request.body);
268 const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
269 for (
270 let attempt = 0;
271 result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline;
272 attempt++
273 ) {
274 const uuid = result.details.uuid;
275 if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
276 yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
277 const poll = yield* api.rest({
278 host: input.host,
279 operation: "runGitHubStackAction",
280 path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
281 });
282 result = yield* decode(poll.body);
283 }
284 if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
285 if (result.status === "failed")
286 return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result });
287});