| 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 GitHubApi from "./GitHubApi.ts"; |
| 12 | import { aliasedGraphQlDocument } from "./githubGraphQl.ts"; |
| 13 | import { decodePullRequestStacksJson, toCanWrite } from "./gitHubPullRequestJson.ts"; |
| 14 | import { cascadeRebaseStack } from "./githubStackRebase.ts"; |
| 15 | |
| 16 | const stackErrorIdentity = { |
| 17 | repository: Schema.String, |
| 18 | number: Schema.Int, |
| 19 | stackNumber: Schema.Int, |
| 20 | }; |
| 21 | |
| 22 | export 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 | |
| 33 | export 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 | |
| 42 | export 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 | |
| 51 | export 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 | |
| 60 | export 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 | |
| 69 | export 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 | |
| 78 | export 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 | |
| 90 | export type GitHubStackActionError = |
| 91 | | GitHubStackChangedError |
| 92 | | GitHubStackUnsupportedError |
| 93 | | GitHubStackResponseInvalidError |
| 94 | | GitHubStackMergeRejectedError |
| 95 | | GitHubStackMergePendingError |
| 96 | | GitHubStackPermissionError |
| 97 | | GitHubStackRebaseFailedError; |
| 98 | |
| 99 | const 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 | |
| 107 | const 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 | |
| 129 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); |
| 130 | |
| 131 | /** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */ |
| 132 | export 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 | }); |