| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Request from "effect/Request"; |
| 9 | import * as RequestResolver from "effect/RequestResolver"; |
| 10 | import * as Result from "effect/Result"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import { |
| 13 | SourceControlProviderError, |
| 14 | TrimmedNonEmptyString, |
| 15 | type ChangeRequest, |
| 16 | type SourceControlProviderDiscoveryItem, |
| 17 | type SourceControlRepositoryCloneUrls, |
| 18 | } from "@t3tools/contracts"; |
| 19 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 20 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 21 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 22 | import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 23 | |
| 24 | import * as GitHubApi from "./GitHubApi.ts"; |
| 25 | import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts"; |
| 26 | import { |
| 27 | decodeGitHubPullRequestEntries, |
| 28 | type NormalizedGitHubPullRequestRecord, |
| 29 | } from "./gitHubPullRequests.ts"; |
| 30 | import { |
| 31 | gitHubApiHostForRemote, |
| 32 | parseFetchRemotes, |
| 33 | parseGitHubRepositorySelector, |
| 34 | parsePullRequestReference, |
| 35 | resolveGitHubRepository, |
| 36 | type GitHubRepositoryLocator, |
| 37 | } from "./gitHubRepositoryResolution.ts"; |
| 38 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 39 | import { |
| 40 | effectiveGitHubAccount, |
| 41 | findAuthenticatedGitHubAccount, |
| 42 | parseGitHubAuthStatus, |
| 43 | type GitHubAuthStatusAccount, |
| 44 | } from "./gitHubAuthStatus.ts"; |
| 45 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 46 | import { |
| 47 | combinedAuthOutput, |
| 48 | firstSafeAuthLine, |
| 49 | probeSourceControlProvider, |
| 50 | providerAuth, |
| 51 | type SourceControlAuthProbeInput, |
| 52 | type SourceControlCliDiscoverySpec, |
| 53 | type SourceControlManagedCliDiscoverySpec, |
| 54 | } from "@t3tools/source-control-core/server/discovery"; |
| 55 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 56 | import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; |
| 57 | |
| 58 | import * as GitHubClient from "../client/definition.ts"; |
| 59 | |
| 60 | const decodeLinkSubject = Schema.decodeUnknownEffect( |
| 61 | Schema.fromJsonString( |
| 62 | Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), |
| 63 | ), |
| 64 | ); |
| 65 | |
| 66 | function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { |
| 67 | return { provider: SourceControlProviderKind.make("github"), ...record }; |
| 68 | } |
| 69 | |
| 70 | function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) { |
| 71 | return accounts.map((entry) => ({ |
| 72 | host: entry.host, |
| 73 | account: entry.account, |
| 74 | active: entry.active, |
| 75 | authenticated: entry.authenticated, |
| 76 | ...(entry.error === null ? {} : { error: entry.error }), |
| 77 | ...(entry.environmentVariable === null |
| 78 | ? {} |
| 79 | : { environmentVariable: entry.environmentVariable }), |
| 80 | })); |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will |
| 85 | * use: Settings can pin a login per host or turn a host off, and an environment token beats both. |
| 86 | */ |
| 87 | export function parseGitHubAuth( |
| 88 | input: SourceControlAuthProbeInput, |
| 89 | settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}), |
| 90 | ) { |
| 91 | const output = combinedAuthOutput(input); |
| 92 | const authStatus = parseGitHubAuthStatus(input.stdout); |
| 93 | const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; |
| 94 | const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); |
| 95 | // Lead with the host gh would pick, unless Settings turned it off. |
| 96 | const orderedHosts = fallback |
| 97 | ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] |
| 98 | : hosts; |
| 99 | const chosen = orderedHosts |
| 100 | .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) |
| 101 | .find((entry) => entry !== undefined); |
| 102 | const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; |
| 103 | |
| 104 | if (chosen) { |
| 105 | return { |
| 106 | ...providerAuth({ |
| 107 | status: "authenticated", |
| 108 | account: chosen.account, |
| 109 | host: chosen.host, |
| 110 | detail: |
| 111 | chosen.environmentVariable === null |
| 112 | ? undefined |
| 113 | : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, |
| 114 | }), |
| 115 | ...accounts, |
| 116 | }; |
| 117 | } |
| 118 | |
| 119 | if (fallback) { |
| 120 | return { |
| 121 | ...providerAuth({ |
| 122 | status: "unauthenticated", |
| 123 | host: fallback.host, |
| 124 | detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", |
| 125 | }), |
| 126 | ...accounts, |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; |
| 131 | if (authStatus.parsed) { |
| 132 | return { |
| 133 | ...providerAuth({ |
| 134 | status: "unauthenticated", |
| 135 | host: failedAccount?.host, |
| 136 | detail: |
| 137 | failedAccount?.error ?? |
| 138 | "Run `gh auth login` to authenticate GitHub CLI with an active account.", |
| 139 | }), |
| 140 | ...accounts, |
| 141 | }; |
| 142 | } |
| 143 | |
| 144 | // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit |
| 145 | // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. |
| 146 | if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { |
| 147 | return providerAuth({ |
| 148 | status: "unknown", |
| 149 | detail: |
| 150 | "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", |
| 151 | }); |
| 152 | } |
| 153 | |
| 154 | if (input.exitCode !== 0) { |
| 155 | return providerAuth({ |
| 156 | status: "unauthenticated", |
| 157 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", |
| 158 | }); |
| 159 | } |
| 160 | |
| 161 | return providerAuth({ |
| 162 | status: "unknown", |
| 163 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | export const discovery = { |
| 168 | type: "cli", |
| 169 | kind: SourceControlProviderKind.make("github"), |
| 170 | label: "GitHub", |
| 171 | executable: "gh", |
| 172 | versionArgs: ["--version"], |
| 173 | authArgs: ["auth", "status", "--json", "hosts"], |
| 174 | parseAuth: parseGitHubAuth, |
| 175 | installHint: |
| 176 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", |
| 177 | } satisfies SourceControlCliDiscoverySpec; |
| 178 | |
| 179 | const decodeViewer = Schema.decodeUnknownOption( |
| 180 | Schema.fromJsonString(Schema.Struct({ login: Schema.String })), |
| 181 | ); |
| 182 | |
| 183 | /** The environment variable gh would take a github.com token from, if one is set. */ |
| 184 | function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { |
| 185 | return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one |
| 190 | * over. Reads the |
| 191 | * GitHub settings on every probe, so a saved account choice shows on rescan. An environment |
| 192 | * token is checked against the API, since `gh auth status` may not know it. |
| 193 | */ |
| 194 | export const makeDiscovery = Effect.gen(function* () { |
| 195 | const api = yield* GitHubApi.GitHubApi; |
| 196 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; |
| 197 | const process = sourceControlHost.process; |
| 198 | const environment = yield* HostProcess.Environment; |
| 199 | |
| 200 | return { |
| 201 | type: "managed-cli", |
| 202 | kind: discovery.kind, |
| 203 | label: discovery.label, |
| 204 | installHint: discovery.installHint, |
| 205 | probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { |
| 206 | const settings = readSourceControlHostSettings( |
| 207 | GitHubClient.settings, |
| 208 | yield* sourceControlHost.settings.get.pipe( |
| 209 | Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]), |
| 210 | Effect.orElseSucceed(() => undefined), |
| 211 | ), |
| 212 | ); |
| 213 | const cli = yield* probeSourceControlProvider({ |
| 214 | cwd, |
| 215 | process, |
| 216 | spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, |
| 217 | }); |
| 218 | // A token saved in Settings wins over the environment, which wins over gh. |
| 219 | const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; |
| 220 | const variable = environmentTokenVariable(environment); |
| 221 | const tokenSource = savedToken ? "the token saved in Settings" : variable; |
| 222 | // A host turned off in Settings stays off even with a token. |
| 223 | if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; |
| 224 | const viewer = yield* api |
| 225 | .rest({ host: "github.com", operation: "discovery", path: "user" }) |
| 226 | .pipe(Effect.result); |
| 227 | const login = Result.isSuccess(viewer) |
| 228 | ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login |
| 229 | : undefined; |
| 230 | // The per-host logins stay, so the account picker still lists every host gh knows. |
| 231 | const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; |
| 232 | return { |
| 233 | ...cli, |
| 234 | status: "available" as const, |
| 235 | auth: { |
| 236 | ...(login !== undefined |
| 237 | ? providerAuth({ |
| 238 | status: "authenticated", |
| 239 | account: login, |
| 240 | host: "github.com", |
| 241 | detail: savedToken |
| 242 | ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." |
| 243 | : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, |
| 244 | }) |
| 245 | : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" |
| 246 | ? // Only a refusal says the token is bad; a network error or a pause says nothing. |
| 247 | providerAuth({ |
| 248 | status: "unknown", |
| 249 | host: "github.com", |
| 250 | detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, |
| 251 | }) |
| 252 | : providerAuth({ |
| 253 | status: "unauthenticated", |
| 254 | host: "github.com", |
| 255 | detail: savedToken |
| 256 | ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." |
| 257 | : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, |
| 258 | })), |
| 259 | ...accounts, |
| 260 | }, |
| 261 | } satisfies SourceControlProviderDiscoveryItem; |
| 262 | }), |
| 263 | /** |
| 264 | * Claims a custom host GitHub holds a credential for, from whichever source supplies it |
| 265 | * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its |
| 266 | * error says so instead of "unsupported host". |
| 267 | */ |
| 268 | refineUnknownRemote: ({ context }) => { |
| 269 | // Identity resolution also probes providers before a web base URL is known. |
| 270 | const url = URL.parse(context.provider.baseUrl); |
| 271 | if (!url?.host) return Effect.succeed(null); |
| 272 | return api.credential(url.host).pipe( |
| 273 | Effect.as(true), |
| 274 | Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), |
| 275 | Effect.orElseSucceed(() => false), |
| 276 | Effect.map((known) => |
| 277 | known |
| 278 | ? ({ |
| 279 | kind: SourceControlProviderKind.make("github"), |
| 280 | name: "GitHub Self-Hosted", |
| 281 | baseUrl: context.provider.baseUrl, |
| 282 | } as const) |
| 283 | : null, |
| 284 | ), |
| 285 | ); |
| 286 | }, |
| 287 | } satisfies SourceControlManagedCliDiscoverySpec; |
| 288 | }); |
| 289 | |
| 290 | /** |
| 291 | * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the |
| 292 | * operation and request context; `cause` keeps the raw failure for logs, never for transport. |
| 293 | */ |
| 294 | class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ |
| 295 | readonly detail: string; |
| 296 | readonly cause: unknown; |
| 297 | }> {} |
| 298 | |
| 299 | const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); |
| 300 | |
| 301 | const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; |
| 302 | const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; |
| 303 | |
| 304 | /** `notFound` names what was missing, which only the operation knows. */ |
| 305 | function fromGitHubApiError( |
| 306 | error: GitHubApi.GitHubApiError, |
| 307 | notFound = PULL_REQUEST_NOT_FOUND, |
| 308 | ): GitHubFailure { |
| 309 | switch (error._tag) { |
| 310 | case "GitHubCliMissingError": |
| 311 | return failure( |
| 312 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", |
| 313 | error, |
| 314 | ); |
| 315 | case "GitHubNotSignedInError": |
| 316 | case "GitHubHostDisabledError": |
| 317 | // A missing or turned-off credential already says what to do about it. |
| 318 | return failure(error.message, error); |
| 319 | case "GitHubApiAuthenticationError": |
| 320 | return failure( |
| 321 | "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", |
| 322 | error, |
| 323 | ); |
| 324 | case "GitHubApiRateLimitError": |
| 325 | case "SourceControlRateLimitPausedError": |
| 326 | return failure( |
| 327 | "GitHub API rate limit exceeded. Requests resume when the limit resets.", |
| 328 | error, |
| 329 | ); |
| 330 | case "GitHubApiNotFoundError": |
| 331 | return failure(notFound, error); |
| 332 | case "GitHubCliFailedError": |
| 333 | case "GitHubApiResponseError": |
| 334 | case "GitHubApiRequestError": |
| 335 | // GitHub's own reason ("A pull request already exists…") when it gave one. |
| 336 | return failure(error.message.trim() || "GitHub request failed.", error); |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); |
| 341 | |
| 342 | const RawRepositorySchema = Schema.Struct({ |
| 343 | full_name: TrimmedNonEmptyString, |
| 344 | html_url: TrimmedNonEmptyString, |
| 345 | ssh_url: TrimmedNonEmptyString, |
| 346 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), |
| 347 | }); |
| 348 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); |
| 349 | |
| 350 | function repositoryCloneUrls( |
| 351 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, |
| 352 | ): SourceControlRepositoryCloneUrls { |
| 353 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; |
| 354 | } |
| 355 | |
| 356 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); |
| 357 | |
| 358 | type PullRequestListState = "open" | "closed" | "merged" | "all"; |
| 359 | |
| 360 | /** The pull request fields every read selects, in GraphQL. */ |
| 361 | const PULL_REQUEST_NODE_SELECTION = |
| 362 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; |
| 363 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { |
| 364 | open: ["OPEN"], |
| 365 | closed: ["CLOSED"], |
| 366 | merged: ["MERGED"], |
| 367 | all: ["OPEN", "CLOSED", "MERGED"], |
| 368 | }; |
| 369 | /** |
| 370 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one |
| 371 | * single lookup, but half that keeps each answer near half a second. |
| 372 | */ |
| 373 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; |
| 374 | /** |
| 375 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each |
| 376 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. |
| 377 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no |
| 378 | * matter how few heads it holds, so reads without reserve wait longer. |
| 379 | */ |
| 380 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; |
| 381 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; |
| 382 | /** |
| 383 | * Background documents fill up under the longer window, and a failed document fails every head |
| 384 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own |
| 385 | * processing limit; twenty-five took ~7s. |
| 386 | */ |
| 387 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; |
| 388 | /** A full document is 5,000 rows of well under 2 KB each. */ |
| 389 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; |
| 390 | /** |
| 391 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch |
| 392 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the |
| 393 | * top because the newest come first. |
| 394 | */ |
| 395 | const OWNER_HEAD_SCAN_LIMIT = 100; |
| 396 | |
| 397 | class PullRequestsByHeadRead extends Request.Class< |
| 398 | { |
| 399 | readonly host: string; |
| 400 | readonly owner: string; |
| 401 | readonly name: string; |
| 402 | readonly headRefName: string; |
| 403 | readonly state: PullRequestListState; |
| 404 | readonly limit: number; |
| 405 | readonly allowReserve: boolean; |
| 406 | }, |
| 407 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, |
| 408 | GitHubFailure |
| 409 | > {} |
| 410 | |
| 411 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ |
| 412 | function buildPullRequestsByHeadQuery( |
| 413 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, |
| 414 | ): { readonly document: string; readonly variables: Record<string, unknown> } { |
| 415 | const variables: Record<string, unknown> = {}; |
| 416 | const declarations: string[] = ["$owner: String!", "$name: String!"]; |
| 417 | const selections: string[] = []; |
| 418 | for (const [index, lookup] of lookups.entries()) { |
| 419 | variables[`h${index}`] = lookup.headRefName; |
| 420 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; |
| 421 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); |
| 422 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the |
| 423 | // same rows. |
| 424 | selections.push( |
| 425 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, |
| 426 | ); |
| 427 | } |
| 428 | return { |
| 429 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, |
| 430 | variables, |
| 431 | }; |
| 432 | } |
| 433 | |
| 434 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { |
| 435 | repository(owner: $owner, name: $name) { |
| 436 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } |
| 437 | } |
| 438 | }`; |
| 439 | |
| 440 | const decodePullRequestsByHead = decodeJsonResult( |
| 441 | Schema.Struct({ |
| 442 | data: Schema.Struct({ |
| 443 | repository: Schema.NullOr( |
| 444 | Schema.Record( |
| 445 | Schema.String, |
| 446 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), |
| 447 | ), |
| 448 | ), |
| 449 | }), |
| 450 | }), |
| 451 | ); |
| 452 | |
| 453 | const decodePullRequestByNumber = decodeJsonResult( |
| 454 | Schema.Struct({ |
| 455 | data: Schema.Struct({ |
| 456 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), |
| 457 | }), |
| 458 | }), |
| 459 | ); |
| 460 | |
| 461 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ |
| 462 | export function pullRequestCheckoutBranchName(input: { |
| 463 | readonly headRefName: string; |
| 464 | readonly headOwner: string | null; |
| 465 | readonly isCrossRepository: boolean; |
| 466 | readonly defaultBranch: string | null; |
| 467 | }): string { |
| 468 | // gh prefixes the owner only where the fork's branch would take over the default branch's |
| 469 | // name, which is the one collision every fork pull request from `main` would hit. |
| 470 | return input.isCrossRepository && |
| 471 | input.headOwner !== null && |
| 472 | input.defaultBranch !== null && |
| 473 | input.headRefName === input.defaultBranch |
| 474 | ? `${input.headOwner}/${input.headRefName}` |
| 475 | : input.headRefName; |
| 476 | } |
| 477 | |
| 478 | /** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ |
| 479 | const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => |
| 480 | context === undefined ? undefined : new URL(context.provider.baseUrl).host; |
| 481 | |
| 482 | export const make = Effect.gen(function* () { |
| 483 | const api = yield* GitHubApi.GitHubApi; |
| 484 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; |
| 485 | const process = sourceControlHost.process; |
| 486 | const environment = yield* HostProcess.Environment; |
| 487 | const git = sourceControlHost.git; |
| 488 | const fileSystem = yield* FileSystem.FileSystem; |
| 489 | |
| 490 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => |
| 491 | process.run({ |
| 492 | operation: "GitHubSourceControlProvider.resolveRepository", |
| 493 | command: "git", |
| 494 | args, |
| 495 | cwd, |
| 496 | allowNonZeroExit: true, |
| 497 | timeoutMs: 5_000, |
| 498 | }); |
| 499 | |
| 500 | /** |
| 501 | * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is |
| 502 | * a fallback when the checkout's remotes cannot resolve that repository. |
| 503 | */ |
| 504 | const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( |
| 505 | function* (input: { |
| 506 | readonly cwd: string; |
| 507 | readonly host?: string | undefined; |
| 508 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; |
| 509 | }) { |
| 510 | const envRepository = environment.GH_REPO?.trim(); |
| 511 | const hostHint = input.host ?? contextHost(input.context); |
| 512 | const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase(); |
| 513 | if (envRepository) { |
| 514 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); |
| 515 | if (locator !== null) return locator; |
| 516 | } |
| 517 | const [remotes, resolved] = yield* Effect.all([ |
| 518 | gitRead(input.cwd, ["remote", "-v"]), |
| 519 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), |
| 520 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); |
| 521 | const { host, locator } = resolveGitHubRepository({ |
| 522 | remotes: remotes?.exitCode === 0 ? remotes.stdout : "", |
| 523 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", |
| 524 | hostHint, |
| 525 | defaultHost, |
| 526 | }); |
| 527 | if (locator !== null) return locator; |
| 528 | if (input.context) { |
| 529 | const remote = normalizeGitRemoteUrl(input.context.remoteUrl); |
| 530 | const fallback = parseGitHubRepositorySelector( |
| 531 | `${host}/${remote.slice(remote.indexOf("/") + 1)}`, |
| 532 | host, |
| 533 | ); |
| 534 | if (fallback !== null) return fallback; |
| 535 | } |
| 536 | return yield* failure( |
| 537 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, |
| 538 | ); |
| 539 | }, |
| 540 | ); |
| 541 | |
| 542 | const graphqlJson = <A>( |
| 543 | input: GitHubApi.GitHubGraphQlInput, |
| 544 | decode: (raw: string) => Result.Result<A, unknown>, |
| 545 | decodeDetail: string, |
| 546 | ) => |
| 547 | api.graphql(input).pipe( |
| 548 | Effect.mapError(fromGitHubApiError), |
| 549 | Effect.flatMap((raw) => { |
| 550 | const decoded = decode(raw); |
| 551 | return Result.isSuccess(decoded) |
| 552 | ? Effect.succeed(decoded.success) |
| 553 | : Effect.fail(failure(decodeDetail, decoded.failure)); |
| 554 | }), |
| 555 | ); |
| 556 | |
| 557 | const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => |
| 558 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); |
| 559 | |
| 560 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ |
| 561 | key: ({ request, context }) => |
| 562 | [ |
| 563 | request.host, |
| 564 | request.owner, |
| 565 | request.name, |
| 566 | String(request.allowReserve), |
| 567 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) |
| 568 | ?.credentialFingerprint ?? "", |
| 569 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 570 | ].join("\0"), |
| 571 | resolver: (entries) => { |
| 572 | const [first] = entries; |
| 573 | const { host, owner, name, allowReserve } = first.request; |
| 574 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); |
| 575 | return graphqlJson( |
| 576 | { |
| 577 | host, |
| 578 | operation: "listPullRequestsByHead", |
| 579 | query: query.document, |
| 580 | variables: { owner, name, ...query.variables }, |
| 581 | allowReserve, |
| 582 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred |
| 583 | // fork pull requests, so the usual cap would cut the answer short. |
| 584 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, |
| 585 | }, |
| 586 | decodePullRequestsByHead, |
| 587 | "GitHub returned an invalid change request list.", |
| 588 | ).pipe( |
| 589 | Effect.flatMap((decoded) => |
| 590 | decoded.data.repository === null |
| 591 | ? Effect.fail(notFound("The repository could not be read.")) |
| 592 | : Effect.succeed(decoded.data.repository), |
| 593 | ), |
| 594 | Effect.map((aliases) => { |
| 595 | for (const [index, entry] of entries.entries()) { |
| 596 | const alias = aliases[`h${index}`]; |
| 597 | entry.completeUnsafe( |
| 598 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), |
| 599 | ); |
| 600 | } |
| 601 | }), |
| 602 | Effect.catchCause((cause) => |
| 603 | Effect.sync(() => { |
| 604 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); |
| 605 | }), |
| 606 | ), |
| 607 | ); |
| 608 | }, |
| 609 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); |
| 610 | const interactiveHeadResolver = headResolver.pipe( |
| 611 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), |
| 612 | ); |
| 613 | const backgroundHeadResolver = headResolver.pipe( |
| 614 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), |
| 615 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), |
| 616 | ); |
| 617 | |
| 618 | /** |
| 619 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the |
| 620 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together |
| 621 | * share one GraphQL document. |
| 622 | */ |
| 623 | const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { |
| 624 | readonly cwd: string; |
| 625 | readonly headSelector: string; |
| 626 | readonly state: PullRequestListState; |
| 627 | readonly limit: number; |
| 628 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; |
| 629 | readonly host?: string | undefined; |
| 630 | readonly allowReserve: boolean; |
| 631 | }) { |
| 632 | const locator = yield* resolveRepository(input); |
| 633 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); |
| 634 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the |
| 635 | // owner is matched on the rows it returns. |
| 636 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); |
| 637 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); |
| 638 | const rows = yield* Effect.request( |
| 639 | new PullRequestsByHeadRead({ |
| 640 | ...locator, |
| 641 | headRefName, |
| 642 | state: input.state, |
| 643 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, |
| 644 | allowReserve: input.allowReserve, |
| 645 | }), |
| 646 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, |
| 647 | ); |
| 648 | if (!ownerMatch) return rows; |
| 649 | const headOwner = ownerMatch[1]!.toLowerCase(); |
| 650 | return rows |
| 651 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) |
| 652 | .slice(0, limit); |
| 653 | }); |
| 654 | |
| 655 | const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( |
| 656 | function* (input: { |
| 657 | readonly cwd: string; |
| 658 | readonly reference: string; |
| 659 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; |
| 660 | readonly host?: string | undefined; |
| 661 | }) { |
| 662 | const parsed = parsePullRequestReference(input.reference); |
| 663 | if (parsed.kind === "branch") { |
| 664 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. |
| 665 | const lookup = (state: PullRequestListState) => |
| 666 | listByHead({ |
| 667 | cwd: input.cwd, |
| 668 | headSelector: parsed.headSelector, |
| 669 | state, |
| 670 | limit: 1, |
| 671 | host: input.host, |
| 672 | context: input.context, |
| 673 | allowReserve: true, |
| 674 | }); |
| 675 | const [open] = yield* lookup("open"); |
| 676 | const found = open ?? (yield* lookup("all"))[0]; |
| 677 | if (found === undefined) return yield* notFound("No pull request has this head branch."); |
| 678 | return found; |
| 679 | } |
| 680 | const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); |
| 681 | const decodeDetail = "GitHub returned an invalid pull request."; |
| 682 | const decoded = yield* graphqlJson( |
| 683 | { |
| 684 | host: locator.host, |
| 685 | operation: "getPullRequest", |
| 686 | query: PULL_REQUEST_BY_NUMBER_QUERY, |
| 687 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, |
| 688 | allowReserve: true, |
| 689 | }, |
| 690 | decodePullRequestByNumber, |
| 691 | decodeDetail, |
| 692 | ); |
| 693 | const node = decoded.data.repository?.pullRequest; |
| 694 | if (node == null) return yield* notFound("The pull request does not exist."); |
| 695 | const [record] = decodeGitHubPullRequestEntries([node]); |
| 696 | if (record === undefined) |
| 697 | return yield* failure(decodeDetail, new Error("Malformed pull request.")); |
| 698 | return record; |
| 699 | }, |
| 700 | ); |
| 701 | |
| 702 | const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( |
| 703 | locator: GitHubRepositoryLocator, |
| 704 | ) { |
| 705 | const response = yield* rest( |
| 706 | { |
| 707 | host: locator.host, |
| 708 | operation: "getRepository", |
| 709 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, |
| 710 | allowReserve: true, |
| 711 | }, |
| 712 | REPOSITORY_NOT_FOUND, |
| 713 | ); |
| 714 | const decoded = decodeRawRepository(response.body); |
| 715 | if (Result.isFailure(decoded)) { |
| 716 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); |
| 717 | } |
| 718 | return decoded.success; |
| 719 | }); |
| 720 | |
| 721 | const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( |
| 722 | host: string, |
| 723 | ) { |
| 724 | const response = yield* rest({ |
| 725 | host, |
| 726 | operation: "getViewer", |
| 727 | path: "user", |
| 728 | allowReserve: true, |
| 729 | }); |
| 730 | const decoded = decodeViewerLogin(response.body); |
| 731 | if (Result.isFailure(decoded)) { |
| 732 | return yield* failure("GitHub request failed.", decoded.failure); |
| 733 | } |
| 734 | return decoded.success.login; |
| 735 | }); |
| 736 | |
| 737 | // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client |
| 738 | // sees is fixed and the raw error stays in `cause`. |
| 739 | const gitFailure = (cause: unknown) => |
| 740 | failure("The pull request could not be checked out with git.", cause); |
| 741 | |
| 742 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => |
| 743 | git.execute({ |
| 744 | operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, |
| 745 | cwd, |
| 746 | args, |
| 747 | }); |
| 748 | |
| 749 | /** |
| 750 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a |
| 751 | * fork gets a remote of its own), checked out under the name gh would give it, and set to |
| 752 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. |
| 753 | * An existing branch fast-forwards, or with `force` is reset to the pull request. |
| 754 | */ |
| 755 | const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( |
| 756 | function* (input: { |
| 757 | readonly cwd: string; |
| 758 | readonly reference: string; |
| 759 | readonly force?: boolean; |
| 760 | readonly context?: SourceControlProvider.SourceControlProviderContext; |
| 761 | }) { |
| 762 | const reference = parsePullRequestReference(input.reference); |
| 763 | const pullRequest = yield* readPullRequest(input); |
| 764 | const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); |
| 765 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); |
| 766 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; |
| 767 | const isCrossRepository = |
| 768 | pullRequest.isCrossRepository ?? |
| 769 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); |
| 770 | const headOwner = |
| 771 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; |
| 772 | |
| 773 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( |
| 774 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), |
| 775 | Effect.mapError(gitFailure), |
| 776 | ); |
| 777 | const remoteFor = (nameWithOwner: string) => |
| 778 | parseFetchRemotes(remotes).find( |
| 779 | (remote) => |
| 780 | (gitHubApiHostForRemote(remote.url) ?? |
| 781 | normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && |
| 782 | normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === |
| 783 | nameWithOwner.toLowerCase(), |
| 784 | )?.name ?? null; |
| 785 | const baseRemote = Effect.suspend(() => { |
| 786 | const known = remoteFor(baseNameWithOwner); |
| 787 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); |
| 788 | }); |
| 789 | // A fork's branch named like the base's default branch is checked out under the owner's |
| 790 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout |
| 791 | // would reset the local default branch to the fork's commit, so it fails instead. |
| 792 | const defaultBranch = isCrossRepository |
| 793 | ? yield* readRepository(base).pipe( |
| 794 | Effect.map((repository) => repository.default_branch ?? null), |
| 795 | ) |
| 796 | : null; |
| 797 | const localBranch = pullRequestCheckoutBranchName({ |
| 798 | headRefName: pullRequest.headRefName, |
| 799 | headOwner, |
| 800 | isCrossRepository, |
| 801 | defaultBranch, |
| 802 | }); |
| 803 | |
| 804 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ |
| 805 | const headRemote = Effect.gen(function* () { |
| 806 | if (!isCrossRepository) return yield* baseRemote; |
| 807 | if (headNameWithOwner === null) return yield* failure("The fork is gone."); |
| 808 | const known = remoteFor(headNameWithOwner); |
| 809 | if (known !== null) return known; |
| 810 | const [owner, name] = headNameWithOwner.split("/"); |
| 811 | const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); |
| 812 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); |
| 813 | return yield* git.ensureRemote({ |
| 814 | cwd: input.cwd, |
| 815 | preferredName: headOwner ?? "fork", |
| 816 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, |
| 817 | }); |
| 818 | }); |
| 819 | |
| 820 | const exists = (yield* git |
| 821 | .listLocalBranchNames(input.cwd) |
| 822 | .pipe(Effect.mapError(gitFailure))).includes(localBranch); |
| 823 | |
| 824 | // The commit to check out, fetched from the head branch where it still exists. |
| 825 | const target = yield* Effect.gen(function* () { |
| 826 | const remoteName = yield* headRemote; |
| 827 | yield* git.fetchRemoteTrackingBranch({ |
| 828 | cwd: input.cwd, |
| 829 | remoteName, |
| 830 | remoteBranch: pullRequest.headRefName, |
| 831 | }); |
| 832 | return { |
| 833 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, |
| 834 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, |
| 835 | }; |
| 836 | }).pipe( |
| 837 | Effect.catch(() => |
| 838 | Effect.gen(function* () { |
| 839 | yield* runGit(input.cwd, "fetchPullRef", [ |
| 840 | "fetch", |
| 841 | "--quiet", |
| 842 | "--no-tags", |
| 843 | yield* baseRemote, |
| 844 | `refs/pull/${pullRequest.number}/head`, |
| 845 | ]); |
| 846 | const { commitSha } = yield* git.resolveCommit({ |
| 847 | cwd: input.cwd, |
| 848 | revision: "FETCH_HEAD", |
| 849 | }); |
| 850 | return { ref: commitSha, upstream: null }; |
| 851 | }), |
| 852 | ), |
| 853 | Effect.mapError(gitFailure), |
| 854 | ); |
| 855 | |
| 856 | yield* Effect.gen(function* () { |
| 857 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); |
| 858 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); |
| 859 | if (exists) { |
| 860 | yield* runGit( |
| 861 | input.cwd, |
| 862 | "sync", |
| 863 | input.force === true |
| 864 | ? ["reset", "--hard", "--quiet", target.ref] |
| 865 | : ["merge", "--ff-only", "--quiet", target.ref], |
| 866 | ); |
| 867 | } |
| 868 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an |
| 869 | // existing branch's upstream as it was. |
| 870 | if (target.upstream !== null) { |
| 871 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); |
| 872 | } |
| 873 | }).pipe(Effect.mapError(gitFailure)); |
| 874 | }, |
| 875 | ); |
| 876 | |
| 877 | /** Reports a failure against the operation and request that made it, with transport-safe context. */ |
| 878 | const providerError = |
| 879 | ( |
| 880 | operation: string, |
| 881 | cwd: string, |
| 882 | context?: { readonly reference?: string; readonly repository?: string }, |
| 883 | ) => |
| 884 | (error: GitHubFailure) => |
| 885 | new SourceControlProviderError({ |
| 886 | provider: SourceControlProviderKind.make("github"), |
| 887 | operation, |
| 888 | cwd, |
| 889 | ...(context?.reference === undefined |
| 890 | ? {} |
| 891 | : { |
| 892 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 893 | context.reference, |
| 894 | ), |
| 895 | }), |
| 896 | ...(context?.repository === undefined |
| 897 | ? {} |
| 898 | : { |
| 899 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 900 | context.repository, |
| 901 | ), |
| 902 | }), |
| 903 | detail: error.detail, |
| 904 | cause: error.cause, |
| 905 | }); |
| 906 | |
| 907 | const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( |
| 908 | input: { readonly cwd: string; readonly url: URL }, |
| 909 | endpoint: string, |
| 910 | ) { |
| 911 | const result = yield* api |
| 912 | .rest({ |
| 913 | host: input.url.host, |
| 914 | operation: "resolveLink", |
| 915 | path: endpoint, |
| 916 | maxResponseBytes: 1_000_000, |
| 917 | }) |
| 918 | .pipe( |
| 919 | Effect.timeout("3 seconds"), |
| 920 | Effect.mapError( |
| 921 | (cause) => |
| 922 | new SourceControlProviderError({ |
| 923 | provider: SourceControlProviderKind.make("github"), |
| 924 | operation: "resolveLink", |
| 925 | cwd: input.cwd, |
| 926 | detail: "The linked subject could not be read.", |
| 927 | cause, |
| 928 | }), |
| 929 | ), |
| 930 | ); |
| 931 | const subject = yield* decodeLinkSubject(result.body).pipe( |
| 932 | Effect.mapError( |
| 933 | (cause) => |
| 934 | new SourceControlProviderError({ |
| 935 | provider: SourceControlProviderKind.make("github"), |
| 936 | operation: "resolveLink.decode", |
| 937 | cwd: input.cwd, |
| 938 | detail: "The linked subject could not be read.", |
| 939 | cause, |
| 940 | }), |
| 941 | ), |
| 942 | ); |
| 943 | return { title: subject.title, body: subject.body ?? null }; |
| 944 | }); |
| 945 | |
| 946 | return SourceControlProvider.SourceControlProvider.of({ |
| 947 | kind: SourceControlProviderKind.make("github"), |
| 948 | // `gh pr list --head` filters on the head ref name alone and accepts anything, so an |
| 949 | // `owner:branch` or `remote:branch` selector silently lists zero pull requests while |
| 950 | // spending a GraphQL call; the bare branch is always among the selectors. Without the owner, |
| 951 | // a bare branch also lists same-named branches on other forks (`main`, `patch-1`), so read a |
| 952 | // full page and let the owner check pick the right head. gh fetches up to 100 in one |
| 953 | // request, and GitHub prices a first:100 connection like first:1. |
| 954 | headBranchProbe: ({ headSelectors }) => ({ |
| 955 | headSelectors: headSelectors.filter((selector) => !selector.includes(":")), |
| 956 | limit: 100, |
| 957 | }), |
| 958 | readChangeRequestTemplate: ({ cwd, treeish }) => |
| 959 | GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), |
| 960 | resolveLink: (input) => { |
| 961 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. |
| 962 | if (input.url.host !== "github.com") return undefined; |
| 963 | const match = /^\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( |
| 964 | input.url.pathname, |
| 965 | ); |
| 966 | if (!match) return undefined; |
| 967 | return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); |
| 968 | }, |
| 969 | listChangeRequests: (input) => |
| 970 | // An open lookup is a user waiting on a status; the rest may be a background sweep. |
| 971 | (input.state === "open" ? Effect.succeed(true) : SourceControlRateLimit.Interactive).pipe( |
| 972 | Effect.flatMap((allowReserve) => |
| 973 | listByHead({ |
| 974 | cwd: input.cwd, |
| 975 | headSelector: input.headSelector, |
| 976 | state: input.state, |
| 977 | limit: input.limit ?? (input.state === "open" ? 1 : 20), |
| 978 | host: contextHost(input.context), |
| 979 | context: input.context, |
| 980 | allowReserve, |
| 981 | }), |
| 982 | ), |
| 983 | Effect.map((records) => records.map(toChangeRequest)), |
| 984 | Effect.mapError( |
| 985 | providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), |
| 986 | ), |
| 987 | ), |
| 988 | getChangeRequest: (input) => |
| 989 | readPullRequest({ ...input, host: contextHost(input.context) }).pipe( |
| 990 | Effect.map(toChangeRequest), |
| 991 | Effect.mapError( |
| 992 | providerError("getChangeRequest", input.cwd, { reference: input.reference }), |
| 993 | ), |
| 994 | ), |
| 995 | createChangeRequest: (input) => |
| 996 | Effect.gen(function* () { |
| 997 | const locator = yield* resolveRepository(input); |
| 998 | const body = yield* fileSystem |
| 999 | .readFileString(input.bodyFile) |
| 1000 | .pipe( |
| 1001 | Effect.mapError((cause) => |
| 1002 | failure("The pull request description could not be read.", cause), |
| 1003 | ), |
| 1004 | ); |
| 1005 | yield* rest({ |
| 1006 | host: locator.host, |
| 1007 | operation: "createPullRequest", |
| 1008 | method: "POST", |
| 1009 | // A user's own write may spend the reserve the background leaves for it. |
| 1010 | allowReserve: true, |
| 1011 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, |
| 1012 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. |
| 1013 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. |
| 1014 | body: { |
| 1015 | base: input.baseRefName, |
| 1016 | head: input.headSelector, |
| 1017 | title: input.title, |
| 1018 | body, |
| 1019 | maintainer_can_modify: true, |
| 1020 | }, |
| 1021 | }); |
| 1022 | }).pipe( |
| 1023 | Effect.mapError( |
| 1024 | providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), |
| 1025 | ), |
| 1026 | ), |
| 1027 | getRepositoryCloneUrls: (input) => |
| 1028 | Effect.gen(function* () { |
| 1029 | const fallbackHost = (yield* resolveRepository(input).pipe( |
| 1030 | Effect.map((locator) => locator.host), |
| 1031 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), |
| 1032 | )).toLowerCase(); |
| 1033 | // A bare name is the signed-in account's repository, the way `gh repo view` reads it. |
| 1034 | const bareName = input.repository.trim().replace(/\.git$/i, ""); |
| 1035 | const locator = |
| 1036 | parseGitHubRepositorySelector(input.repository, fallbackHost) ?? |
| 1037 | (/^[^/\s]+$/.test(bareName) |
| 1038 | ? { host: fallbackHost, owner: yield* readViewerLogin(fallbackHost), name: bareName } |
| 1039 | : null); |
| 1040 | if (locator === null) return yield* failure("Repositories are named owner/name."); |
| 1041 | return repositoryCloneUrls(yield* readRepository(locator)); |
| 1042 | }).pipe( |
| 1043 | Effect.mapError( |
| 1044 | providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), |
| 1045 | ), |
| 1046 | ), |
| 1047 | createRepository: (input) => |
| 1048 | Effect.gen(function* () { |
| 1049 | const locator = parseGitHubRepositorySelector( |
| 1050 | input.repository, |
| 1051 | (environment.GH_HOST ?? "github.com").toLowerCase(), |
| 1052 | ); |
| 1053 | const viewer = locator === null ? null : yield* readViewerLogin(locator.host); |
| 1054 | const owner = locator?.owner ?? viewer; |
| 1055 | const name = locator?.name ?? input.repository.trim(); |
| 1056 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); |
| 1057 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); |
| 1058 | const response = yield* rest( |
| 1059 | { |
| 1060 | host, |
| 1061 | operation: "createRepository", |
| 1062 | method: "POST", |
| 1063 | allowReserve: true, |
| 1064 | path: |
| 1065 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, |
| 1066 | body: { name, private: input.visibility === "private" }, |
| 1067 | }, |
| 1068 | // GitHub answers 404 for an organization the account cannot create repositories in. |
| 1069 | `No organization named ${owner ?? "that"} that this account can create repositories in.`, |
| 1070 | ); |
| 1071 | const decoded = decodeRawRepository(response.body); |
| 1072 | if (Result.isFailure(decoded)) { |
| 1073 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); |
| 1074 | } |
| 1075 | return repositoryCloneUrls(decoded.success); |
| 1076 | }).pipe( |
| 1077 | Effect.mapError( |
| 1078 | providerError("createRepository", input.cwd, { repository: input.repository }), |
| 1079 | ), |
| 1080 | ), |
| 1081 | getDefaultBranch: (input) => |
| 1082 | Effect.gen(function* () { |
| 1083 | const locator = yield* resolveRepository({ |
| 1084 | cwd: input.cwd, |
| 1085 | host: contextHost(input.context), |
| 1086 | context: input.context, |
| 1087 | }); |
| 1088 | const repository = yield* readRepository(locator); |
| 1089 | const branch = repository.default_branch?.trim() ?? ""; |
| 1090 | return branch.length > 0 ? branch : null; |
| 1091 | }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), |
| 1092 | checkoutChangeRequest: (input) => |
| 1093 | checkoutPullRequest(input).pipe( |
| 1094 | Effect.mapError( |
| 1095 | providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), |
| 1096 | ), |
| 1097 | ), |
| 1098 | }); |
| 1099 | }); |