1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Request from "effect/Request";
9import * as RequestResolver from "effect/RequestResolver";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12import {
13 SourceControlProviderError,
14 TrimmedNonEmptyString,
15 type ChangeRequest,
16 type SourceControlProviderDiscoveryItem,
17 type SourceControlRepositoryCloneUrls,
18} from "@t3tools/contracts";
19import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21import { decodeJsonResult } from "@t3tools/shared/schemaJson";
22import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
23
24import * as GitHubApi from "./GitHubApi.ts";
25import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts";
26import {
27 decodeGitHubPullRequestEntries,
28 type NormalizedGitHubPullRequestRecord,
29} from "./gitHubPullRequests.ts";
30import {
31 gitHubApiHostForRemote,
32 parseFetchRemotes,
33 parseGitHubRepositorySelector,
34 parsePullRequestReference,
35 resolveGitHubRepository,
36 type GitHubRepositoryLocator,
37} from "./gitHubRepositoryResolution.ts";
38import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
39import {
40 effectiveGitHubAccount,
41 findAuthenticatedGitHubAccount,
42 parseGitHubAuthStatus,
43 type GitHubAuthStatusAccount,
44} from "./gitHubAuthStatus.ts";
45import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
46import {
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";
55import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
56import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
57
58import * as GitHubClient from "../client/definition.ts";
59
60const decodeLinkSubject = Schema.decodeUnknownEffect(
61 Schema.fromJsonString(
62 Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }),
63 ),
64);
65
66function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
67 return { provider: SourceControlProviderKind.make("github"), ...record };
68}
69
70function 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 */
87export 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
167export 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
179const 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. */
184function 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 */
194export 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 */
294class GitHubFailure extends Data.TaggedError("GitHubFailure")<{
295 readonly detail: string;
296 readonly cause: unknown;
297}> {}
298
299const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause });
300
301const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again.";
302const 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. */
305function 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
340const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause));
341
342const 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});
348const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
349
350function 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
356const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
357
358type PullRequestListState = "open" | "closed" | "merged" | "all";
359
360/** The pull request fields every read selects, in GraphQL. */
361const PULL_REQUEST_NODE_SELECTION =
362 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
363const 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 */
373const 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 */
380const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
381const 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 */
387const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
388/** A full document is 5,000 rows of well under 2 KB each. */
389const 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 */
395const OWNER_HEAD_SCAN_LIMIT = 100;
396
397class 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. */
412function 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
434const 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
440const 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
453const 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. */
462export 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. */
479const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) =>
480 context === undefined ? undefined : new URL(context.provider.baseUrl).host;
481
482export 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});