| author | |
| committer | |
| log | 649418f01fcb4ae5ebbc07739e0bfee8fae3aaec |
| tree | 815149eaaca43ac17850ab696d344215a3d5413c |
| parent | adc3c9327abedda6a36694e2344e620fe0d62ae4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>13 files changed, 1340 insertions(+), 1714 deletions(-)
apps/server/src/assets/AssetAccess.test.ts+15-13| ... | @@ -16,12 +16,12 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -16,12 +16,12 @@ import * as DateTime from "effect/DateTime"; |
| 16 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as FileSystem from "effect/FileSystem"; | 17 | import * as FileSystem from "effect/FileSystem"; |
| 18 | import * as Layer from "effect/Layer"; | 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as Redacted from "effect/Redacted"; | ||
| 19 | import * as Path from "effect/Path"; | 20 | import * as Path from "effect/Path"; |
| 20 | import * as PlatformError from "effect/PlatformError"; | 21 | import * as PlatformError from "effect/PlatformError"; |
| 21 | import * as Schema from "effect/Schema"; | 22 | import * as Schema from "effect/Schema"; |
| 22 | import * as TestClock from "effect/testing/TestClock"; | 23 | import * as TestClock from "effect/testing/TestClock"; |
| 23 | import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; | 24 | import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; |
| 24 | import { ChildProcessSpawner } from "effect/process"; | ||
| 25 | import { vi } from "vite-plus/test"; | 25 | import { vi } from "vite-plus/test"; |
| 26 | 26 | ||
| 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| ... | @@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t | ... | @@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t |
| 35 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | 35 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 36 | import { openMediaFile } from "./MediaFile.ts"; | 36 | import { openMediaFile } from "./MediaFile.ts"; |
| 37 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | 37 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 38 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 38 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; |
| 39 | import { githubMediaResponse } from "./GitHubMediaFetch.ts"; | 39 | import { githubMediaResponse } from "./GitHubMediaFetch.ts"; |
| 40 | 40 | ||
| 41 | vi.mock("node:fs/promises", async (importOriginal) => { | 41 | vi.mock("node:fs/promises", async (importOriginal) => { |
| ... | @@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll( | ... | @@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll( |
| 126 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 126 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 127 | 127 | ||
| 128 | describe("AssetAccess", () => { | 128 | describe("AssetAccess", () => { |
| 129 | it.effect("loads private media immediately after login and reuses the found credential", () => { | 129 | it.effect("loads private media immediately after login with the GitHub credential", () => { |
| 130 | let lookups = 0; | 130 | let lookups = 0; |
| 131 | const authorizations: Array<string | undefined> = []; | 131 | const authorizations: Array<string | undefined> = []; |
| 132 | return Effect.gen(function* () { | 132 | return Effect.gen(function* () { |
| ... | @@ -138,19 +138,21 @@ describe("AssetAccess", () => { | ... | @@ -138,19 +138,21 @@ describe("AssetAccess", () => { |
| 138 | expect((yield* githubMediaResponse(asset, {})).status).toBe(404); | 138 | expect((yield* githubMediaResponse(asset, {})).status).toBe(404); |
| 139 | expect((yield* githubMediaResponse(asset, {})).status).toBe(200); | 139 | expect((yield* githubMediaResponse(asset, {})).status).toBe(200); |
| 140 | expect((yield* githubMediaResponse(asset, {})).status).toBe(200); | 140 | expect((yield* githubMediaResponse(asset, {})).status).toBe(200); |
| 141 | expect(lookups).toBe(2); | 141 | // Caching the token is GitHubCredentials' job; this asks it every time. |
| 142 | expect(lookups).toBe(3); | ||
| 142 | expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); | 143 | expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); |
| 143 | }).pipe( | 144 | }).pipe( |
| 144 | Effect.provide( | 145 | Effect.provide( |
| 145 | Layer.mock(GitHubCli.GitHubCli)({ | 146 | Layer.mock(GitHubCredentials.GitHubCredentials)({ |
| 146 | execute: () => | 147 | get: (host) => |
| 147 | Effect.sync(() => ({ | 148 | ++lookups === 1 |
| 148 | exitCode: ChildProcessSpawner.ExitCode(0), | 149 | ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) |
| 149 | stdout: ++lookups === 1 ? "" : "signed-in", | 150 | : Effect.succeed({ |
| 150 | stderr: "", | 151 | host, |
| 151 | stdoutTruncated: false, | 152 | token: Redacted.make("signed-in"), |
| 152 | stderrTruncated: false, | 153 | source: "gh" as const, |
| 153 | })), | 154 | fingerprint: "fingerprint", |
| 155 | }), | ||
| 154 | }), | 156 | }), |
| 155 | ), | 157 | ), |
| 156 | Effect.provideService( | 158 | Effect.provideService( |
apps/server/src/assets/GitHubMediaFetch.ts+9-42| ... | @@ -12,7 +12,7 @@ import { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | type HttpClientResponse, | 12 | type HttpClientResponse, |
| 13 | } from "effect/http"; | 13 | } from "effect/http"; |
| 14 | 14 | ||
| 15 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 15 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; |
| 16 | 16 | ||
| 17 | /** | 17 | /** |
| 18 | * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned | 18 | * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned |
| ... | @@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => { | ... | @@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => { |
| 37 | const MAX_REDIRECTS = 3; | 37 | const MAX_REDIRECTS = 3; |
| 38 | /** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ | 38 | /** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ |
| 39 | const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; | 39 | const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; |
| 40 | const TOKEN_CACHE_TTL_MS = 5 * 60_000; | ||
| 41 | const TOKEN_CACHE_MAX_ENTRIES = 32; | ||
| 42 | /** Passed through so a seek in a long video costs one upstream range request, not a full download. */ | 40 | /** Passed through so a seek in a long video costs one upstream range request, not a full download. */ |
| 43 | const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; | 41 | const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; |
| 44 | const FORWARDED_RESPONSE_HEADERS = [ | 42 | const FORWARDED_RESPONSE_HEADERS = [ |
| ... | @@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml"; | ... | @@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml"; |
| 57 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; | 55 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; |
| 58 | 56 | ||
| 59 | /** | 57 | /** |
| 60 | * A media request per image and one per video seek, each of which would otherwise spawn `gh`. | 58 | * The github.com credential, or null without one: a public asset still loads, and a private one |
| 61 | * The token is what `gh auth token` would print again on the next call, and it is held no longer | 59 | * fails the way it does in a browser that is not signed in. |
| 62 | * than a signed asset URL lives. | ||
| 63 | */ | 60 | */ |
| 64 | const tokenCache = new Map<string, { readonly at: number; readonly token: Redacted.Redacted }>(); | 61 | const githubToken = GitHubCredentials.GitHubCredentials.pipe( |
| 65 | 62 | Effect.flatMap((credentials) => credentials.get("github.com")), | |
| 66 | const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: { | 63 | Effect.map((credential) => credential.token), |
| 67 | readonly cwd: string; | 64 | Effect.orElseSucceed(() => null), |
| 68 | readonly host: string; | 65 | ); |
| 69 | }) { | ||
| 70 | // `gh` stores a token per host, not per repository, so the directory it runs in is not part | ||
| 71 | // of the answer and must not fragment the cache a client could otherwise churn. This route | ||
| 72 | // pins no credential; if it ever does, the pin belongs in this key. | ||
| 73 | const key = input.host; | ||
| 74 | const now = yield* Clock.currentTimeMillis; | ||
| 75 | const cached = tokenCache.get(key); | ||
| 76 | if (cached !== undefined && now - cached.at < TOKEN_CACHE_TTL_MS) return cached.token; | ||
| 77 | const github = yield* GitHubCli.GitHubCli; | ||
| 78 | // No credential is a normal state: a public asset still loads, and a private one fails the way | ||
| 79 | // it does in a browser that is not signed in. | ||
| 80 | const token = yield* github | ||
| 81 | .execute({ | ||
| 82 | cwd: input.cwd, | ||
| 83 | args: ["auth", "token", "--hostname", input.host], | ||
| 84 | env: { GH_DEBUG: "" }, | ||
| 85 | }) | ||
| 86 | .pipe( | ||
| 87 | Effect.map((output) => output.stdout.trim()), | ||
| 88 | Effect.orElseSucceed(() => ""), | ||
| 89 | ); | ||
| 90 | // A login or recovered CLI failure must take effect on the next media request. | ||
| 91 | if (token.length === 0) return null; | ||
| 92 | if (tokenCache.size >= TOKEN_CACHE_MAX_ENTRIES) { | ||
| 93 | tokenCache.delete(tokenCache.keys().next().value!); | ||
| 94 | } | ||
| 95 | const redacted = Redacted.make(token); | ||
| 96 | tokenCache.set(key, { at: now, token: redacted }); | ||
| 97 | return redacted; | ||
| 98 | }); | ||
| 99 | 66 | ||
| 100 | /** | 67 | /** |
| 101 | * Follows GitHub's redirect to the signed object itself, and never carries the credential off | 68 | * Follows GitHub's redirect to the signed object itself, and never carries the credential off |
| ... | @@ -146,7 +113,7 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon | ... | @@ -146,7 +113,7 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon |
| 146 | requestHeaders: Record<string, string | undefined>, | 113 | requestHeaders: Record<string, string | undefined>, |
| 147 | ) { | 114 | ) { |
| 148 | // Both media hosts are served by github.com's account, which is the host `gh` stores it under. | 115 | // Both media hosts are served by github.com's account, which is the host `gh` stores it under. |
| 149 | const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" }); | 116 | const token = yield* githubToken; |
| 150 | const forwarded: Record<string, string> = {}; | 117 | const forwarded: Record<string, string> = {}; |
| 151 | for (const name of FORWARDED_REQUEST_HEADERS) { | 118 | for (const name of FORWARDED_REQUEST_HEADERS) { |
| 152 | const value = requestHeaders[name]; | 119 | const value = requestHeaders[name]; |
apps/server/src/git/GitManager.test.ts+13-5| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | ThreadId, | 36 | ThreadId, |
| 37 | } from "@t3tools/contracts"; | 37 | } from "@t3tools/contracts"; |
| 38 | import * as DateTime from "effect/DateTime"; | 38 | import * as DateTime from "effect/DateTime"; |
| 39 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | ||
| 39 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 40 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 40 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; | 41 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; |
| 41 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 42 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| ... | @@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 383 | ); | 384 | ); |
| 384 | const ghCalls: string[] = []; | 385 | const ghCalls: string[] = []; |
| 385 | 386 | ||
| 386 | const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { | 387 | // The fake still speaks in gh's command shapes; the service methods below translate to them. |
| 388 | const execute = (input: { | ||
| 389 | readonly cwd: string; | ||
| 390 | readonly args: ReadonlyArray<string>; | ||
| 391 | }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCli.GitHubCliError> => { | ||
| 387 | const args = [...input.args]; | 392 | const args = [...input.args]; |
| 388 | ghCalls.push(args.join(" ")); | 393 | ghCalls.push(args.join(" ")); |
| 389 | 394 | ||
| ... | @@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 518 | 523 | ||
| 519 | return { | 524 | return { |
| 520 | service: { | 525 | service: { |
| 521 | execute, | ||
| 522 | // The fake answers the CLI shape, so batched lookups read it the way the fallback does. | 526 | // The fake answers the CLI shape, so batched lookups read it the way the fallback does. |
| 523 | listPullRequestsByHead: (input) => | 527 | listPullRequestsByHead: (input) => |
| 524 | execute({ | 528 | execute({ |
| ... | @@ -724,7 +728,12 @@ function makeManager(input?: { | ... | @@ -724,7 +728,12 @@ function makeManager(input?: { |
| 724 | discover: Effect.succeed([]), | 728 | discover: Effect.succeed([]), |
| 725 | }), | 729 | }), |
| 726 | ), | 730 | ), |
| 727 | Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), | 731 | Effect.provide( |
| 732 | Layer.merge( | ||
| 733 | Layer.succeed(GitHubCli.GitHubCli, gitHubCli), | ||
| 734 | Layer.mock(GitHubApi.GitHubApi)({}), | ||
| 735 | ), | ||
| 736 | ), | ||
| 728 | ), | 737 | ), |
| 729 | ); | 738 | ); |
| 730 | 739 | ||
| ... | @@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { | ... | @@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2660 | provider: "github", | 2669 | provider: "github", |
| 2661 | providerOperation: "listChangeRequests", | 2670 | providerOperation: "listChangeRequests", |
| 2662 | providerCommand: "gh", | 2671 | providerCommand: "gh", |
| 2663 | errorDetail: | 2672 | errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", |
| 2664 | "GitHub API rate limit exceeded. For the GraphQL quota and reset time, run `gh api graphql -f query='{rateLimit{remaining resetAt}}'`; `gh api rate_limit` reports REST.", | ||
| 2665 | }); | 2673 | }); |
| 2666 | const loggedText = [ | 2674 | const loggedText = [ |
| 2667 | warning?.message ?? "", | 2675 | warning?.message ?? "", |
apps/server/src/server.ts+2-1| ... | @@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 562 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), | 562 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), |
| 563 | Layer.provideMerge(layerServerSettings), | 563 | Layer.provideMerge(layerServerSettings), |
| 564 | // The asset route uses the registry's GitHub credential for private PR media. | 564 | // The asset route uses the registry's GitHub credential for private PR media. |
| 565 | Layer.provideMerge(Layer.mergeAll(layerSourceControlProviderRegistry, GitHubCli.layer)), | 565 | Layer.provideMerge(layerSourceControlProviderRegistry), |
| 566 | Layer.provideMerge(GitHubCli.layer), | ||
| 566 | Layer.provideMerge(layerGit), | 567 | Layer.provideMerge(layerGit), |
| 567 | Layer.provideMerge(layerVcs), | 568 | Layer.provideMerge(layerVcs), |
| 568 | Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), | 569 | Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), |
apps/server/src/sourceControl/GitHubApi.ts+2-1| ... | @@ -145,6 +145,7 @@ export interface GitHubGraphQlInput { | ... | @@ -145,6 +145,7 @@ export interface GitHubGraphQlInput { |
| 145 | readonly query: string; | 145 | readonly query: string; |
| 146 | readonly variables?: Readonly<Record<string, unknown>>; | 146 | readonly variables?: Readonly<Record<string, unknown>>; |
| 147 | readonly allowReserve?: boolean; | 147 | readonly allowReserve?: boolean; |
| 148 | readonly maxResponseBytes?: number; | ||
| 148 | } | 149 | } |
| 149 | 150 | ||
| 150 | export class GitHubApi extends Context.Service< | 151 | export class GitHubApi extends Context.Service< |
| ... | @@ -567,7 +568,7 @@ export const make = Effect.gen(function* () { | ... | @@ -567,7 +568,7 @@ export const make = Effect.gen(function* () { |
| 567 | HttpClientRequest.acceptJson, | 568 | HttpClientRequest.acceptJson, |
| 568 | HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), | 569 | HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), |
| 569 | ), | 570 | ), |
| 570 | maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, | 571 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, |
| 571 | allowReserve, | 572 | allowReserve, |
| 572 | acceptNotModified: false, | 573 | acceptNotModified: false, |
| 573 | graphql: true, | 574 | graphql: true, |
apps/server/src/sourceControl/GitHubCli.test.ts+431-831| ... | @@ -1,157 +1,109 @@ | ... | @@ -1,157 +1,109 @@ |
| 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as Cache from "effect/Cache"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as TestClock from "effect/testing/TestClock"; | 3 | import * as TestClock from "effect/testing/TestClock"; |
| 4 | import * as Clock from "effect/Clock"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 9 | import * as PlatformError from "effect/PlatformError"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 12 | import { ChildProcessSpawner } from "effect/process"; | 9 | import { ChildProcessSpawner } from "effect/process"; |
| 13 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; | ||
| 14 | 10 | ||
| 11 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 15 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 16 | import * as GitHubCli from "./GitHubCli.ts"; | 14 | import * as GitHubCli from "./GitHubCli.ts"; |
| 17 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 18 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | ||
| 19 | 16 | ||
| 20 | const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError)); | 17 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 21 | 18 | ||
| 22 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | 19 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ |
| 23 | exitCode: ChildProcessSpawner.ExitCode(0), | 20 | exitCode: ChildProcessSpawner.ExitCode(exitCode), |
| 24 | stdout, | 21 | stdout, |
| 25 | stderr: "", | 22 | stderr: "", |
| 26 | stdoutTruncated: false, | 23 | stdoutTruncated: false, |
| 27 | stderrTruncated: false, | 24 | stderrTruncated: false, |
| 28 | }); | 25 | }); |
| 29 | 26 | ||
| 30 | const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") => | 27 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => |
| 31 | processOutput( | 28 | entries |
| 32 | JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }), | 29 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) |
| 33 | ); | 30 | .join("\n"); |
| 34 | 31 | ||
| 35 | const isBudgetReading = (input: VcsProcess.VcsProcessInput) => | 32 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ |
| 36 | input.args[0] === "api" && | 33 | status, |
| 37 | input.args[1] === "graphql" && | 34 | headers: {}, |
| 38 | input.args.at(-1)?.includes("rateLimit"); | 35 | body: body === undefined ? "" : encodeJson(body), |
| 39 | 36 | truncated: false, | |
| 40 | const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); | 37 | invalidUtf8: false, |
| 41 | 38 | }); | |
| 42 | // Budget readings are answered here, so `mockRun` sees only the commands under test. | ||
| 43 | const layer = GitHubCli.layer.pipe( | ||
| 44 | Layer.provide( | ||
| 45 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 46 | run: (input) => (isBudgetReading(input) ? Effect.succeed(quotaOutput()) : mockRun(input)), | ||
| 47 | }), | ||
| 48 | ), | ||
| 49 | ); | ||
| 50 | 39 | ||
| 51 | afterEach(() => { | 40 | const node = (number: number, headRefName: string, owner = "acme") => ({ |
| 52 | mockRun.mockReset(); | 41 | number, |
| 42 | title: `PR ${number}`, | ||
| 43 | url: `https://github.com/acme/web/pull/${number}`, | ||
| 44 | baseRefName: "main", | ||
| 45 | headRefName, | ||
| 46 | state: "OPEN", | ||
| 47 | isCrossRepository: owner !== "acme", | ||
| 48 | updatedAt: "2026-01-02T00:00:00Z", | ||
| 49 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | ||
| 50 | headRepositoryOwner: { login: owner }, | ||
| 53 | }); | 51 | }); |
| 54 | 52 | ||
| 55 | it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () => | 53 | /** |
| 56 | Effect.gen(function* () { | 54 | * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what |
| 57 | let readings = 0; | 55 | * `git remote -v` prints; `git` records every driver call. |
| 58 | const commands: string[] = []; | 56 | */ |
| 59 | let remaining = 501; | 57 | function harness(input: { |
| 60 | let resetAt = DateTime.formatIso( | 58 | readonly remotes: string; |
| 61 | DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000), | 59 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; |
| 62 | ); | 60 | readonly localBranches?: ReadonlyArray<string>; |
| 63 | const gh = yield* GitHubCli.make.pipe( | 61 | }) { |
| 64 | Effect.provideService(VcsProcess.VcsProcess, { | 62 | const git: Array<readonly [string, unknown]> = []; |
| 65 | run: (input) => | 63 | const record = |
| 66 | Effect.sync(() => { | 64 | <A>(name: string, value: A) => |
| 67 | if (isBudgetReading(input)) { | 65 | (args: unknown) => |
| 68 | readings++; | 66 | Effect.sync(() => { |
| 69 | assert.strictEqual(input.args[3], "enterprise.test"); | 67 | git.push([name, args]); |
| 70 | return quotaOutput(remaining, resetAt); | 68 | return value; |
| 71 | } | ||
| 72 | commands.push(input.args.slice(0, 2).join(" ")); | ||
| 73 | return processOutput("[]"); | ||
| 74 | }), | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | const read = (command: string) => | ||
| 78 | gh.execute({ | ||
| 79 | cwd: "/repo", | ||
| 80 | args: | ||
| 81 | command === "repo" | ||
| 82 | ? ["repo", "view", "enterprise.test/acme/web", "--json", "name"] | ||
| 83 | : ["pr", command, "--repo=enterprise.test/acme/web", "--json", "number"], | ||
| 84 | }); | 69 | }); |
| 85 | yield* read("list"); | 70 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ |
| 86 | const failure = yield* read("view").pipe(Effect.flip); | 71 | execute: (args) => |
| 87 | assert.strictEqual(failure._tag, "GitHubCliRateLimitError"); | 72 | Effect.sync(() => { |
| 88 | assert.deepStrictEqual(commands, ["pr list"]); | 73 | git.push(["execute", args.args]); |
| 89 | yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); | 74 | return processOutput(""); |
| 90 | yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] }); | ||
| 91 | assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]); | ||
| 92 | // One reading covers the whole window. | ||
| 93 | assert.strictEqual(readings, 1); | ||
| 94 | yield* TestClock.adjust("1 minute"); | ||
| 95 | remaining = 5000; | ||
| 96 | resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000)); | ||
| 97 | yield* Effect.all([read("list"), read("repo")], { concurrency: 2 }); | ||
| 98 | assert.strictEqual(readings, 2); | ||
| 99 | assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]); | ||
| 100 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | ||
| 101 | ); | ||
| 102 | |||
| 103 | it.effect("reads the budget again at a near reset, and every ten minutes in a long window", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | let readings = 0; | ||
| 106 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 107 | let resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000)); | ||
| 108 | const gh = yield* GitHubCli.make.pipe( | ||
| 109 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 110 | run: (input) => | ||
| 111 | Effect.sync(() => { | ||
| 112 | if (!isBudgetReading(input)) return processOutput("[]"); | ||
| 113 | readings++; | ||
| 114 | return quotaOutput(5000, resetAt); | ||
| 115 | }), | ||
| 116 | }), | 75 | }), |
| 117 | ); | 76 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), |
| 118 | const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] }); | 77 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), |
| 119 | yield* read; | 78 | ensureRemote: (args) => |
| 120 | // The window resets ten seconds in, so the reading expires with it. | 79 | Effect.sync(() => { |
| 121 | resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000)); | 80 | git.push(["ensureRemote", args]); |
| 122 | yield* TestClock.adjust("10 seconds"); | 81 | return args.preferredName; |
| 123 | yield* read; | ||
| 124 | assert.strictEqual(readings, 2); | ||
| 125 | yield* TestClock.adjust("9 minutes"); | ||
| 126 | yield* read; | ||
| 127 | assert.strictEqual(readings, 2); | ||
| 128 | yield* TestClock.adjust("1 minute"); | ||
| 129 | yield* read; | ||
| 130 | assert.strictEqual(readings, 3); | ||
| 131 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | ||
| 132 | ); | ||
| 133 | |||
| 134 | it.effect("reads anyway when the budget reading fails", () => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const gh = yield* GitHubCli.make.pipe( | ||
| 137 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 138 | run: (input) => | ||
| 139 | isBudgetReading(input) | ||
| 140 | ? Effect.fail( | ||
| 141 | new VcsProcessSpawnError({ | ||
| 142 | operation: "GitHubCli.execute", | ||
| 143 | command: "gh", | ||
| 144 | cwd: "/gone", | ||
| 145 | cause: new Error("ENOENT"), | ||
| 146 | }), | ||
| 147 | ) | ||
| 148 | : Effect.succeed(processOutput("[]")), | ||
| 149 | }), | 82 | }), |
| 150 | ); | 83 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), |
| 151 | const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }); | 84 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), |
| 152 | assert.strictEqual(result.stdout, "[]"); | 85 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, |
| 153 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | 86 | listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), |
| 154 | ); | 87 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), |
| 88 | }); | ||
| 89 | const process = Layer.mock(VcsProcess.VcsProcess)({ | ||
| 90 | run: (args) => | ||
| 91 | Effect.succeed( | ||
| 92 | args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), | ||
| 93 | ), | ||
| 94 | }); | ||
| 95 | const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( | ||
| 96 | Layer.provide( | ||
| 97 | Layer.mergeAll( | ||
| 98 | driver, | ||
| 99 | process, | ||
| 100 | Layer.mock(GitHubApi.GitHubApi)(input.api), | ||
| 101 | NodeServices.layer, | ||
| 102 | ), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | return { layer, git }; | ||
| 106 | } | ||
| 155 | 107 | ||
| 156 | describe("selectGitHubBaseRepository", () => { | 108 | describe("selectGitHubBaseRepository", () => { |
| 157 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | 109 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => |
| ... | @@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => { | ... | @@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => { |
| 223 | }); | 175 | }); |
| 224 | }); | 176 | }); |
| 225 | 177 | ||
| 226 | describe("GitHubCli.listPullRequestsByHead", () => { | 178 | describe("GitHubCli repository resolution", () => { |
| 227 | const remoteOutput = | 179 | it.effect("reads the repository gh would pick from the remotes", () => { |
| 228 | "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n"; | 180 | const paths: string[] = []; |
| 229 | const node = (number: number, headRefName: string) => ({ | 181 | const { layer } = harness({ |
| 230 | number, | 182 | remotes: remotesOutput( |
| 231 | title: `PR ${number}`, | 183 | ["origin", "git@github.com:me/web.git"], |
| 232 | url: `https://github.com/acme/web/pull/${number}`, | 184 | ["upstream", "https://github.com/acme/web.git"], |
| 233 | baseRefName: "main", | 185 | ), |
| 234 | headRefName, | 186 | api: { |
| 235 | state: "MERGED", | 187 | rest: (input) => |
| 236 | mergedAt: "2026-01-01T00:00:00Z", | 188 | Effect.sync(() => { |
| 237 | updatedAt: "2026-01-02T00:00:00Z", | 189 | paths.push(`${input.host} ${input.path}`); |
| 238 | headRepository: { name: "web", nameWithOwner: "acme/web" }, | 190 | return restResponse({ |
| 239 | headRepositoryOwner: { login: "acme" }, | 191 | full_name: "acme/web", |
| 192 | html_url: "https://github.com/acme/web", | ||
| 193 | ssh_url: "git@github.com:acme/web.git", | ||
| 194 | default_branch: "trunk", | ||
| 195 | }); | ||
| 196 | }), | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | return Effect.gen(function* () { | ||
| 200 | const gh = yield* GitHubCli.GitHubCli; | ||
| 201 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | ||
| 202 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | ||
| 203 | }).pipe(Effect.provide(layer)); | ||
| 240 | }); | 204 | }); |
| 241 | const decodeRequest = Schema.decodeSync( | ||
| 242 | Schema.fromJsonString( | ||
| 243 | Schema.Struct({ | ||
| 244 | query: Schema.String, | ||
| 245 | variables: Schema.Record(Schema.String, Schema.Unknown), | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | ); | ||
| 249 | const jsonOutput = (value: unknown) => processOutput(JSON.stringify(value)); | ||
| 250 | const git = (input: VcsProcess.VcsProcessInput) => | ||
| 251 | input.args[0] === "remote" | ||
| 252 | ? processOutput(remoteOutput) | ||
| 253 | : { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(1) }; | ||
| 254 | 205 | ||
| 255 | it.effect("reads heads on one repository in one GraphQL document", () => | 206 | it.effect("reads an SSH alias remote through github.com", () => { |
| 256 | Effect.gen(function* () { | 207 | const hosts: string[] = []; |
| 257 | const documents: Array<{ query: string; variables: Record<string, unknown> }> = []; | 208 | const { layer } = harness({ |
| 258 | mockRun.mockImplementation((input) => | 209 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), |
| 259 | Effect.sync(() => { | 210 | api: { |
| 260 | if (input.command === "git") return git(input); | 211 | rest: (input) => |
| 261 | documents.push(decodeRequest(input.stdin ?? "")); | 212 | Effect.sync(() => { |
| 262 | return jsonOutput({ | 213 | hosts.push(`${input.host} ${input.path}`); |
| 263 | data: { | 214 | return restResponse({ |
| 264 | repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } }, | 215 | full_name: "acme/web", |
| 265 | rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" }, | 216 | html_url: "https://github.com/acme/web", |
| 266 | }, | 217 | ssh_url: "git@github.com:acme/web.git", |
| 267 | }); | 218 | default_branch: "main", |
| 268 | }), | 219 | }); |
| 220 | }), | ||
| 221 | }, | ||
| 222 | }); | ||
| 223 | return Effect.gen(function* () { | ||
| 224 | const gh = yield* GitHubCli.GitHubCli; | ||
| 225 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | ||
| 226 | // A provider's host hint for the same alias (`github` here) resolves the same way. | ||
| 227 | yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); | ||
| 228 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | ||
| 229 | assert.strictEqual( | ||
| 230 | GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), | ||
| 231 | "github.example.com", | ||
| 269 | ); | 232 | ); |
| 233 | assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); | ||
| 234 | }).pipe(Effect.provide(layer)); | ||
| 235 | }); | ||
| 236 | |||
| 237 | it.effect("fails clearly when no remote is on GitHub", () => { | ||
| 238 | const { layer } = harness({ | ||
| 239 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | ||
| 240 | api: {}, | ||
| 241 | }); | ||
| 242 | return Effect.gen(function* () { | ||
| 243 | const gh = yield* GitHubCli.GitHubCli; | ||
| 244 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | ||
| 245 | assert.strictEqual(error._tag, "GitHubCliCommandError"); | ||
| 246 | assert.include(String((error.cause as Error).message), "No GitHub repository"); | ||
| 247 | }).pipe(Effect.provide(layer)); | ||
| 248 | }); | ||
| 249 | }); | ||
| 250 | |||
| 251 | describe("GitHubCli.listPullRequestsByHead", () => { | ||
| 252 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | ||
| 253 | |||
| 254 | it.effect("reads heads on one repository in one GraphQL document", () => { | ||
| 255 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | ||
| 256 | const { layer } = harness({ | ||
| 257 | remotes, | ||
| 258 | api: { | ||
| 259 | graphql: (input) => | ||
| 260 | Effect.sync(() => { | ||
| 261 | documents.push(input); | ||
| 262 | return encodeJson({ | ||
| 263 | data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } }, | ||
| 264 | }); | ||
| 265 | }), | ||
| 266 | }, | ||
| 267 | }); | ||
| 268 | return Effect.gen(function* () { | ||
| 270 | const gh = yield* GitHubCli.GitHubCli; | 269 | const gh = yield* GitHubCli.GitHubCli; |
| 271 | const lookups = yield* Effect.all( | 270 | const lookups = yield* Effect.all( |
| 272 | ["feature/a", "feature/b"].map((headSelector) => | 271 | ["feature/a", "feature/b"].map((headSelector) => |
| ... | @@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => { | ... | @@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => { |
| 283 | yield* TestClock.adjust("50 millis"); | 282 | yield* TestClock.adjust("50 millis"); |
| 284 | const [first, second] = yield* Fiber.join(lookups); | 283 | const [first, second] = yield* Fiber.join(lookups); |
| 285 | assert.deepStrictEqual( | 284 | assert.deepStrictEqual( |
| 286 | first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]), | 285 | first?.map((pr) => pr.number), |
| 287 | [[7, "merged", "acme/web"]], | 286 | [7], |
| 288 | ); | 287 | ); |
| 289 | assert.deepStrictEqual(second, []); | 288 | assert.deepStrictEqual(second, []); |
| 290 | assert.strictEqual(documents.length, 1); | 289 | assert.strictEqual(documents.length, 1); |
| 291 | assert.include(documents[0]!.query, "rateLimit"); | ||
| 292 | assert.deepStrictEqual(documents[0]!.variables, { | 290 | assert.deepStrictEqual(documents[0]!.variables, { |
| 293 | owner: "acme", | 291 | owner: "acme", |
| 294 | name: "web", | 292 | name: "web", |
| ... | @@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => { | ... | @@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => { |
| 297 | h1: "feature/b", | 295 | h1: "feature/b", |
| 298 | s1: ["OPEN", "CLOSED", "MERGED"], | 296 | s1: ["OPEN", "CLOSED", "MERGED"], |
| 299 | }); | 297 | }); |
| 300 | }).pipe(Effect.provide(layer)), | 298 | }).pipe(Effect.provide(layer)); |
| 301 | ); | 299 | }); |
| 302 | 300 | ||
| 303 | it.effect("asks gh pr list when gh could read another repository", () => | 301 | it.effect("matches an owner:branch selector on the head owner", () => { |
| 304 | Effect.gen(function* () { | 302 | const { layer } = harness({ |
| 305 | const commands: Array<ReadonlyArray<string>> = []; | 303 | remotes, |
| 306 | mockRun.mockImplementation((input) => | 304 | api: { |
| 307 | Effect.sync(() => { | 305 | graphql: (input) => |
| 308 | commands.push([input.command, ...input.args]); | 306 | Effect.succeed( |
| 309 | if (input.command === "git") { | 307 | encodeJson({ |
| 310 | return processOutput( | 308 | data: { |
| 311 | input.args[0] === "remote" | 309 | repository: { |
| 312 | ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n" | 310 | h0: { |
| 313 | : "", | 311 | nodes: |
| 314 | ); | 312 | input.variables?.h0 === "main" |
| 315 | } | 313 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] |
| 316 | return input.args[3] === "feature/empty" | 314 | : [], |
| 317 | ? processOutput("") | 315 | }, |
| 318 | : jsonOutput([node(8, "feature/a")]); | 316 | }, |
| 319 | }), | 317 | }, |
| 320 | ); | 318 | }), |
| 319 | ), | ||
| 320 | }, | ||
| 321 | }); | ||
| 322 | return Effect.gen(function* () { | ||
| 321 | const gh = yield* GitHubCli.GitHubCli; | 323 | const gh = yield* GitHubCli.GitHubCli; |
| 322 | const pullRequests = yield* gh.listPullRequestsByHead({ | 324 | const open = yield* gh |
| 323 | cwd: "/repo", | 325 | .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) |
| 324 | headSelector: "feature/a", | 326 | .pipe(Effect.forkChild); |
| 325 | state: "all", | 327 | yield* TestClock.adjust("50 millis"); |
| 326 | limit: 100, | ||
| 327 | rateLimitHost: "github.com", | ||
| 328 | }); | ||
| 329 | assert.deepStrictEqual( | 328 | assert.deepStrictEqual( |
| 330 | pullRequests.map((pr) => pr.number), | 329 | (yield* Fiber.join(open)).map((pr) => pr.number), |
| 331 | [8], | 330 | [8], |
| 332 | ); | 331 | ); |
| 333 | assert.deepStrictEqual(commands.at(-1), [ | 332 | }).pipe(Effect.provide(layer)); |
| 334 | "gh", | 333 | }); |
| 335 | "pr", | ||
| 336 | "list", | ||
| 337 | "--head", | ||
| 338 | "feature/a", | ||
| 339 | "--state", | ||
| 340 | "all", | ||
| 341 | "--limit", | ||
| 342 | "100", | ||
| 343 | "--json", | ||
| 344 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 345 | ]); | ||
| 346 | const empty = yield* gh.listPullRequestsByHead({ | ||
| 347 | cwd: "/repo", | ||
| 348 | headSelector: "feature/empty", | ||
| 349 | state: "all", | ||
| 350 | limit: 100, | ||
| 351 | rateLimitHost: "github.com", | ||
| 352 | }); | ||
| 353 | assert.deepStrictEqual(empty, []); | ||
| 354 | }).pipe(Effect.provide(layer)), | ||
| 355 | ); | ||
| 356 | 334 | ||
| 357 | it.effect("fails a rate-limited document whole instead of asking head by head", () => | 335 | it.effect("maps API failures onto the errors callers handle", () => { |
| 358 | Effect.gen(function* () { | 336 | const { layer } = harness({ |
| 359 | let ghCalls = 0; | 337 | remotes, |
| 360 | mockRun.mockImplementation((input) => { | 338 | api: { |
| 361 | if (input.command === "git") return Effect.succeed(git(input)); | 339 | graphql: (input) => |
| 362 | ghCalls++; | 340 | Effect.fail( |
| 363 | return Effect.fail( | 341 | input.variables?.h0 === "missing" |
| 364 | new VcsProcessExitError({ | 342 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) |
| 365 | operation: "GitHubCli.execute", | 343 | : new GitHubApi.GitHubApiRateLimitError({ |
| 366 | command: "gh", | 344 | host: "github.com", |
| 367 | cwd: "/repo", | 345 | operation: "x", |
| 368 | exitCode: 1, | 346 | retryAt: 123, |
| 369 | failureKind: "rate-limited", | 347 | }), |
| 370 | detail: "API rate limit exceeded.", | 348 | ), |
| 371 | stderrLength: 24, | 349 | }, |
| 372 | stderrTruncated: false, | 350 | }); |
| 373 | }), | 351 | return Effect.gen(function* () { |
| 374 | ); | ||
| 375 | }); | ||
| 376 | const gh = yield* GitHubCli.GitHubCli; | 352 | const gh = yield* GitHubCli.GitHubCli; |
| 377 | const lookups = yield* Effect.all( | 353 | const read = (headSelector: string) => |
| 378 | ["feature/a", "feature/b"].map((headSelector) => | 354 | gh |
| 379 | gh | 355 | .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) |
| 380 | .listPullRequestsByHead({ | 356 | .pipe(Effect.flip, Effect.forkChild); |
| 381 | cwd: "/repo", | 357 | const missing = yield* read("missing"); |
| 382 | headSelector, | ||
| 383 | state: "all", | ||
| 384 | limit: 100, | ||
| 385 | rateLimitHost: "github.com", | ||
| 386 | }) | ||
| 387 | .pipe(Effect.flip), | ||
| 388 | ), | ||
| 389 | { concurrency: "unbounded" }, | ||
| 390 | ).pipe(Effect.forkChild); | ||
| 391 | yield* TestClock.adjust("50 millis"); | 358 | yield* TestClock.adjust("50 millis"); |
| 392 | const errors = yield* Fiber.join(lookups); | 359 | assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); |
| 393 | assert.deepStrictEqual( | 360 | const limited = yield* read("limited"); |
| 394 | errors.map((error) => error._tag), | 361 | yield* TestClock.adjust("50 millis"); |
| 395 | ["GitHubCliRateLimitError", "GitHubCliRateLimitError"], | 362 | const error = yield* Fiber.join(limited); |
| 396 | ); | ||
| 397 | assert.strictEqual(ghCalls, 1); | ||
| 398 | }).pipe(Effect.provide(layer)), | ||
| 399 | ); | ||
| 400 | }); | ||
| 401 | |||
| 402 | describe("GitHubCli.layer", () => { | ||
| 403 | it.effect("shares the registry budget with CLI reads through nested layer providers", () => | ||
| 404 | Effect.gen(function* () { | ||
| 405 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | ||
| 406 | const gh = yield* GitHubCli.GitHubCli; | ||
| 407 | yield* budget.observe("github.com", quotaOutput(0).stdout); | ||
| 408 | const error = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }).pipe(Effect.flip); | ||
| 409 | assert.strictEqual(error._tag, "GitHubCliRateLimitError"); | 363 | assert.strictEqual(error._tag, "GitHubCliRateLimitError"); |
| 410 | expect(mockRun).not.toHaveBeenCalled(); | 364 | assert.propertyVal(error, "retryAt", 123); |
| 411 | }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))), | 365 | }).pipe(Effect.provide(layer)); |
| 412 | ); | 366 | }); |
| 367 | }); | ||
| 413 | 368 | ||
| 414 | it.effect("keeps quota snapshots separate for verified credentials on the same host", () => | 369 | describe("GitHubCli.getPullRequest", () => { |
| 415 | Effect.gen(function* () { | 370 | it.effect("reads a pull request by number, and by URL on its own repository", () => { |
| 416 | let reads = 0; | 371 | const variables: Array<unknown> = []; |
| 417 | const gh = yield* GitHubCli.make.pipe( | 372 | const { layer } = harness({ |
| 418 | Effect.provideService(VcsProcess.VcsProcess, { | 373 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), |
| 419 | run: (input) => | 374 | api: { |
| 420 | Effect.sync(() => { | 375 | graphql: (input) => |
| 421 | if (isBudgetReading(input)) { | 376 | Effect.sync(() => { |
| 422 | return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000); | 377 | variables.push(input.variables); |
| 423 | } | 378 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); |
| 424 | reads++; | ||
| 425 | return processOutput("[]"); | ||
| 426 | }), | ||
| 427 | }), | ||
| 428 | ); | ||
| 429 | const read = (token: string) => | ||
| 430 | gh.execute({ cwd: "/repo", args: ["pr", "list", "--repo", "github.com/acme/web"] }).pipe( | ||
| 431 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | ||
| 432 | host: "github.com", | ||
| 433 | token: Redacted.make(token), | ||
| 434 | credentialFingerprint: token, | ||
| 435 | }), | 379 | }), |
| 436 | ); | 380 | }, |
| 437 | yield* read("empty").pipe(Effect.flip); | 381 | }); |
| 438 | yield* read("healthy"); | 382 | return Effect.gen(function* () { |
| 439 | yield* read("empty").pipe(Effect.flip); | ||
| 440 | assert.strictEqual(reads, 1); | ||
| 441 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | ||
| 442 | ); | ||
| 443 | |||
| 444 | it.effect("pins concurrent cached commands to their own verified credentials", () => | ||
| 445 | Effect.gen(function* () { | ||
| 446 | mockRun.mockImplementation((input) => | ||
| 447 | Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")), | ||
| 448 | ); | ||
| 449 | const gh = yield* GitHubCli.GitHubCli; | 383 | const gh = yield* GitHubCli.GitHubCli; |
| 450 | // Constructed outside either request, like the PR service's read caches. | 384 | assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); |
| 451 | const cache = yield* Cache.make({ | 385 | yield* gh.getPullRequest({ |
| 452 | lookup: (host: string) => | 386 | cwd: "/repo", |
| 453 | gh.execute({ | 387 | reference: "https://github.com/other/thing/pull/42", |
| 454 | cwd: "/repo", | ||
| 455 | args: ["api", "user", "--hostname", host], | ||
| 456 | env: { GH_DEBUG: "api", GH_TOKEN: "changed-after-verification" }, | ||
| 457 | }), | ||
| 458 | capacity: 2, | ||
| 459 | timeToLive: "1 minute", | ||
| 460 | }); | 388 | }); |
| 461 | const results = yield* Effect.forEach( | 389 | assert.deepStrictEqual(variables, [ |
| 462 | ["github.com", "github.example.test"], | 390 | { owner: "acme", name: "web", number: 42 }, |
| 463 | (host, index) => | 391 | { owner: "other", name: "thing", number: 42 }, |
| 464 | Cache.get(cache, host).pipe( | 392 | ]); |
| 465 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | 393 | }).pipe(Effect.provide(layer)); |
| 466 | host, | 394 | }); |
| 467 | token: Redacted.make(`credential-${index}`), | ||
| 468 | credentialFingerprint: `fingerprint-${index}`, | ||
| 469 | }), | ||
| 470 | ), | ||
| 471 | { concurrency: 2 }, | ||
| 472 | ); | ||
| 473 | expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]); | ||
| 474 | for (const [input] of mockRun.mock.calls) { | ||
| 475 | expect(input.env).toMatchObject({ | ||
| 476 | GH_HOST: input.args[3], | ||
| 477 | GH_DEBUG: "", | ||
| 478 | GH_TOKEN: input.env?.GITHUB_TOKEN, | ||
| 479 | GH_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, | ||
| 480 | GITHUB_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, | ||
| 481 | }); | ||
| 482 | } | ||
| 483 | expect((yield* gh.execute({ cwd: "/repo", args: ["api", "user"] })).stdout).toBe("ambient"); | ||
| 484 | }).pipe(Effect.provide(layer)), | ||
| 485 | ); | ||
| 486 | 395 | ||
| 487 | it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () => | 396 | it.effect("fails a missing pull request as not found", () => { |
| 488 | Effect.gen(function* () { | 397 | const { layer } = harness({ |
| 398 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | ||
| 399 | api: { | ||
| 400 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | ||
| 401 | }, | ||
| 402 | }); | ||
| 403 | return Effect.gen(function* () { | ||
| 489 | const gh = yield* GitHubCli.GitHubCli; | 404 | const gh = yield* GitHubCli.GitHubCli; |
| 490 | for (const args of [ | 405 | const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); |
| 491 | ["api", "user", "--hostname", "other.example.test"], | 406 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); |
| 492 | ["api", "user", "--hostname=other.example.test"], | 407 | }).pipe(Effect.provide(layer)); |
| 493 | ["pr", "view", "1", "--repo", "other.example.test/owner/repo"], | 408 | }); |
| 494 | ["repo", "view", "other.example.test/owner/repo", "--json", "name"], | 409 | }); |
| 495 | ["api", "https://other.example.test/user", "--hostname", "github.com"], | ||
| 496 | ["api", "user"], | ||
| 497 | ]) { | ||
| 498 | const failure = yield* gh.execute({ cwd: "/repo", args }).pipe( | ||
| 499 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | ||
| 500 | host: "github.com", | ||
| 501 | token: Redacted.make("secret-credential"), | ||
| 502 | credentialFingerprint: "fingerprint", | ||
| 503 | }), | ||
| 504 | Effect.flip, | ||
| 505 | ); | ||
| 506 | expect(failure._tag).toBe("GitHubCliCommandError"); | ||
| 507 | expect(yield* encodeGitHubCliError(failure)).not.toContain("secret-credential"); | ||
| 508 | } | ||
| 509 | expect(mockRun).not.toHaveBeenCalled(); | ||
| 510 | }).pipe(Effect.provide(layer)), | ||
| 511 | ); | ||
| 512 | 410 | ||
| 513 | it.effect("pins repository-targeted writes on enterprise hosts", () => | 411 | describe("GitHubCli writes", () => { |
| 514 | Effect.gen(function* () { | 412 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { |
| 515 | mockRun.mockReturnValue(Effect.succeed(processOutput(""))); | 413 | const requests: Array<GitHubApi.GitHubRestInput> = []; |
| 516 | const gh = yield* GitHubCli.GitHubCli; | 414 | const { layer } = harness({ |
| 517 | yield* gh | 415 | remotes: remotesOutput( |
| 518 | .execute({ | 416 | ["origin", "git@github.com:me/web.git"], |
| 519 | cwd: "/repo", | 417 | ["upstream", "git@github.com:acme/web.git"], |
| 520 | args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"], | 418 | ), |
| 521 | }) | 419 | api: { |
| 522 | .pipe( | 420 | rest: (input) => |
| 523 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | 421 | Effect.sync(() => { |
| 524 | host: "github.example.test", | 422 | requests.push(input); |
| 525 | token: Redacted.make("enterprise-credential"), | 423 | return restResponse({ number: 1 }, 201); |
| 526 | credentialFingerprint: "fingerprint", | ||
| 527 | }), | ||
| 528 | ); | ||
| 529 | yield* gh | ||
| 530 | .execute({ | ||
| 531 | cwd: "/repo", | ||
| 532 | args: ["repo", "view", "github.example.test/owner/repo", "--json", "name"], | ||
| 533 | }) | ||
| 534 | .pipe( | ||
| 535 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | ||
| 536 | host: "github.example.test", | ||
| 537 | token: Redacted.make("enterprise-credential"), | ||
| 538 | credentialFingerprint: "fingerprint", | ||
| 539 | }), | 424 | }), |
| 540 | ); | 425 | }, |
| 541 | expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({ | ||
| 542 | GH_HOST: "github.example.test", | ||
| 543 | GH_ENTERPRISE_TOKEN: "enterprise-credential", | ||
| 544 | GH_DEBUG: "", | ||
| 545 | }); | ||
| 546 | }).pipe(Effect.provide(layer)), | ||
| 547 | ); | ||
| 548 | |||
| 549 | it("does not classify a missing cwd as an unavailable gh executable", () => { | ||
| 550 | const context = { command: "gh", cwd: "/repo" } as const; | ||
| 551 | const missingCwd = new VcsProcessSpawnError({ | ||
| 552 | operation: "GitHubCli.execute", | ||
| 553 | command: "gh", | ||
| 554 | cwd: context.cwd, | ||
| 555 | cause: PlatformError.systemError({ | ||
| 556 | _tag: "NotFound", | ||
| 557 | module: "FileSystem", | ||
| 558 | method: "access", | ||
| 559 | pathOrDescriptor: context.cwd, | ||
| 560 | }), | ||
| 561 | }); | 426 | }); |
| 562 | 427 | return Effect.gen(function* () { | |
| 563 | const commandFailure = GitHubCli.fromVcsError(context, missingCwd); | 428 | const fs = yield* FileSystem.FileSystem; |
| 564 | 429 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 565 | assert.equal(commandFailure._tag, "GitHubCliCommandError"); | 430 | yield* fs.writeFileString(bodyFile, "Body"); |
| 566 | assert.strictEqual(commandFailure.cause, missingCwd); | ||
| 567 | assert.notProperty(commandFailure, "operation"); | ||
| 568 | }); | ||
| 569 | |||
| 570 | it.effect("parses pull request view output", () => | ||
| 571 | Effect.gen(function* () { | ||
| 572 | mockRun.mockReturnValueOnce( | ||
| 573 | Effect.succeed( | ||
| 574 | processOutput( | ||
| 575 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 576 | JSON.stringify({ | ||
| 577 | number: 42, | ||
| 578 | title: "Add PR thread creation", | ||
| 579 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 580 | baseRefName: "main", | ||
| 581 | headRefName: "feature/pr-threads", | ||
| 582 | state: "OPEN", | ||
| 583 | isDraft: true, | ||
| 584 | mergedAt: null, | ||
| 585 | updatedAt: "2026-08-24T12:34:56Z", | ||
| 586 | isCrossRepository: true, | ||
| 587 | headRepository: { | ||
| 588 | nameWithOwner: "octocat/codething-mvp", | ||
| 589 | }, | ||
| 590 | headRepositoryOwner: { | ||
| 591 | login: "octocat", | ||
| 592 | }, | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ), | ||
| 596 | ); | ||
| 597 | |||
| 598 | const gh = yield* GitHubCli.GitHubCli; | 431 | const gh = yield* GitHubCli.GitHubCli; |
| 599 | const result = yield* gh.getPullRequest({ | 432 | yield* gh.createPullRequest({ |
| 600 | cwd: "/repo", | 433 | cwd: "/repo", |
| 601 | reference: "#42", | 434 | baseBranch: "main", |
| 435 | headSelector: "me:feature", | ||
| 436 | title: "Title", | ||
| 437 | bodyFile, | ||
| 602 | }); | 438 | }); |
| 603 | 439 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 604 | assert.deepStrictEqual(result, { | 440 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); |
| 605 | number: 42, | 441 | assert.deepStrictEqual(requests[0]!.body, { |
| 606 | title: "Add PR thread creation", | 442 | base: "main", |
| 607 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | 443 | head: "me:feature", |
| 608 | baseRefName: "main", | 444 | title: "Title", |
| 609 | headRefName: "feature/pr-threads", | 445 | body: "Body", |
| 610 | state: "open", | 446 | maintainer_can_modify: true, |
| 611 | closedAt: null, | ||
| 612 | mergedAt: null, | ||
| 613 | isDraft: true, | ||
| 614 | updatedAt: "2026-08-24T12:34:56.000Z", | ||
| 615 | isCrossRepository: true, | ||
| 616 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 617 | headRepositoryOwnerLogin: "octocat", | ||
| 618 | }); | ||
| 619 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 620 | operation: "GitHubCli.execute", | ||
| 621 | command: "gh", | ||
| 622 | args: [ | ||
| 623 | "pr", | ||
| 624 | "view", | ||
| 625 | "#42", | ||
| 626 | "--json", | ||
| 627 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 628 | ], | ||
| 629 | cwd: "/repo", | ||
| 630 | timeoutMs: 30_000, | ||
| 631 | }); | 447 | }); |
| 632 | }).pipe(Effect.provide(layer)), | 448 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); |
| 633 | ); | 449 | }); |
| 634 | |||
| 635 | it.effect("trims pull request fields decoded from gh json", () => | ||
| 636 | Effect.gen(function* () { | ||
| 637 | mockRun.mockReturnValueOnce( | ||
| 638 | Effect.succeed( | ||
| 639 | processOutput( | ||
| 640 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 641 | JSON.stringify({ | ||
| 642 | number: 42, | ||
| 643 | title: " Add PR thread creation \n", | ||
| 644 | url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", | ||
| 645 | baseRefName: " main ", | ||
| 646 | headRefName: "\tfeature/pr-threads\t", | ||
| 647 | state: "OPEN", | ||
| 648 | mergedAt: null, | ||
| 649 | isCrossRepository: true, | ||
| 650 | headRepository: { | ||
| 651 | nameWithOwner: " octocat/codething-mvp ", | ||
| 652 | }, | ||
| 653 | headRepositoryOwner: { | ||
| 654 | login: " octocat ", | ||
| 655 | }, | ||
| 656 | }), | ||
| 657 | ), | ||
| 658 | ), | ||
| 659 | ); | ||
| 660 | 450 | ||
| 451 | it.effect("creates a repository under an organization the viewer is not", () => { | ||
| 452 | const requests: Array<string> = []; | ||
| 453 | const { layer } = harness({ | ||
| 454 | remotes: "", | ||
| 455 | api: { | ||
| 456 | rest: (input) => | ||
| 457 | Effect.sync(() => { | ||
| 458 | requests.push(`${input.method ?? "GET"} ${input.path}`); | ||
| 459 | return input.path === "user" | ||
| 460 | ? restResponse({ login: "me" }) | ||
| 461 | : restResponse({ | ||
| 462 | full_name: "acme/new", | ||
| 463 | html_url: "https://github.com/acme/new", | ||
| 464 | ssh_url: "git@github.com:acme/new.git", | ||
| 465 | }); | ||
| 466 | }), | ||
| 467 | }, | ||
| 468 | }); | ||
| 469 | return Effect.gen(function* () { | ||
| 661 | const gh = yield* GitHubCli.GitHubCli; | 470 | const gh = yield* GitHubCli.GitHubCli; |
| 662 | const result = yield* gh.getPullRequest({ | 471 | const urls = yield* gh.createRepository({ |
| 663 | cwd: "/repo", | 472 | cwd: "/repo", |
| 664 | reference: "#42", | 473 | repository: "acme/new", |
| 474 | visibility: "private", | ||
| 665 | }); | 475 | }); |
| 666 | 476 | assert.deepStrictEqual(urls, { | |
| 667 | assert.deepStrictEqual(result, { | 477 | nameWithOwner: "acme/new", |
| 668 | number: 42, | 478 | url: "https://github.com/acme/new", |
| 669 | title: "Add PR thread creation", | 479 | sshUrl: "git@github.com:acme/new.git", |
| 670 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 671 | baseRefName: "main", | ||
| 672 | headRefName: "feature/pr-threads", | ||
| 673 | state: "open", | ||
| 674 | closedAt: null, | ||
| 675 | mergedAt: null, | ||
| 676 | isCrossRepository: true, | ||
| 677 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 678 | headRepositoryOwnerLogin: "octocat", | ||
| 679 | }); | ||
| 680 | }).pipe(Effect.provide(layer)), | ||
| 681 | ); | ||
| 682 | |||
| 683 | it.effect("skips invalid entries when parsing pr lists", () => | ||
| 684 | Effect.gen(function* () { | ||
| 685 | mockRun.mockReturnValueOnce( | ||
| 686 | Effect.succeed( | ||
| 687 | processOutput( | ||
| 688 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 689 | JSON.stringify([ | ||
| 690 | { | ||
| 691 | number: 0, | ||
| 692 | title: "invalid", | ||
| 693 | url: "https://github.com/pingdotgg/codething-mvp/pull/0", | ||
| 694 | baseRefName: "main", | ||
| 695 | headRefName: "feature/invalid", | ||
| 696 | }, | ||
| 697 | { | ||
| 698 | number: 43, | ||
| 699 | title: " Valid PR ", | ||
| 700 | url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", | ||
| 701 | baseRefName: " main ", | ||
| 702 | headRefName: " feature/pr-list ", | ||
| 703 | headRepository: { | ||
| 704 | nameWithOwner: " ", | ||
| 705 | }, | ||
| 706 | headRepositoryOwner: { | ||
| 707 | login: " ", | ||
| 708 | }, | ||
| 709 | }, | ||
| 710 | ]), | ||
| 711 | ), | ||
| 712 | ), | ||
| 713 | ); | ||
| 714 | |||
| 715 | const gh = yield* GitHubCli.GitHubCli; | ||
| 716 | const result = yield* gh.listOpenPullRequests({ | ||
| 717 | cwd: "/repo", | ||
| 718 | headSelector: "feature/pr-list", | ||
| 719 | }); | 480 | }); |
| 481 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | ||
| 482 | }).pipe(Effect.provide(layer)); | ||
| 483 | }); | ||
| 484 | }); | ||
| 720 | 485 | ||
| 721 | assert.deepStrictEqual(result, [ | 486 | describe("GitHubCli.checkoutPullRequest", () => { |
| 722 | { | 487 | const repository = (fullName: string, defaultBranch = "main") => |
| 723 | number: 43, | 488 | restResponse({ |
| 724 | title: "Valid PR", | 489 | full_name: fullName, |
| 725 | url: "https://github.com/pingdotgg/codething-mvp/pull/43", | 490 | html_url: `https://github.com/${fullName}`, |
| 726 | baseRefName: "main", | 491 | ssh_url: `git@github.com:${fullName}.git`, |
| 727 | headRefName: "feature/pr-list", | 492 | default_branch: defaultBranch, |
| 728 | state: "open", | 493 | }); |
| 729 | closedAt: null, | ||
| 730 | mergedAt: null, | ||
| 731 | }, | ||
| 732 | ]); | ||
| 733 | }).pipe(Effect.provide(layer)), | ||
| 734 | ); | ||
| 735 | 494 | ||
| 736 | it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => | 495 | it.effect("checks a same-repository pull request out from its head branch", () => { |
| 737 | // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as | 496 | const { layer, git } = harness({ |
| 738 | // {id, name} only. These entries must decode instead of being dropped, | 497 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), |
| 739 | // with nameWithOwner rebuilt from the owner login. | 498 | api: { |
| 740 | Effect.gen(function* () { | 499 | graphql: () => |
| 741 | mockRun.mockReturnValueOnce( | 500 | Effect.succeed( |
| 742 | Effect.succeed( | 501 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), |
| 743 | processOutput( | ||
| 744 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 745 | JSON.stringify([ | ||
| 746 | { | ||
| 747 | number: 2829, | ||
| 748 | title: "Codex turn mapping", | ||
| 749 | url: "https://github.com/pingdotgg/codething-mvp/pull/2829", | ||
| 750 | baseRefName: "main", | ||
| 751 | headRefName: "t3code/codex-turn-mapping", | ||
| 752 | state: "OPEN", | ||
| 753 | mergedAt: null, | ||
| 754 | isCrossRepository: false, | ||
| 755 | headRepository: { | ||
| 756 | id: "R_kgDORLtfbQ", | ||
| 757 | name: "codething-mvp", | ||
| 758 | }, | ||
| 759 | headRepositoryOwner: { | ||
| 760 | id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", | ||
| 761 | login: "pingdotgg", | ||
| 762 | }, | ||
| 763 | }, | ||
| 764 | ]), | ||
| 765 | ), | 502 | ), |
| 766 | ), | 503 | }, |
| 767 | ); | 504 | }); |
| 768 | 505 | return Effect.gen(function* () { | |
| 769 | const gh = yield* GitHubCli.GitHubCli; | 506 | const gh = yield* GitHubCli.GitHubCli; |
| 770 | const result = yield* gh.listOpenPullRequests({ | 507 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); |
| 771 | cwd: "/repo", | 508 | assert.deepStrictEqual(git, [ |
| 772 | headSelector: "t3code/codex-turn-mapping", | 509 | [ |
| 773 | }); | 510 | "fetchRemoteTrackingBranch", |
| 774 | 511 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 775 | assert.deepStrictEqual(result, [ | 512 | ], |
| 776 | { | 513 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], |
| 777 | number: 2829, | 514 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], |
| 778 | title: "Codex turn mapping", | 515 | [ |
| 779 | url: "https://github.com/pingdotgg/codething-mvp/pull/2829", | 516 | "setBranchUpstream", |
| 780 | baseRefName: "main", | 517 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, |
| 781 | headRefName: "t3code/codex-turn-mapping", | 518 | ], |
| 782 | state: "open", | ||
| 783 | closedAt: null, | ||
| 784 | mergedAt: null, | ||
| 785 | isCrossRepository: false, | ||
| 786 | headRepositoryNameWithOwner: "pingdotgg/codething-mvp", | ||
| 787 | headRepositoryOwnerLogin: "pingdotgg", | ||
| 788 | }, | ||
| 789 | ]); | 519 | ]); |
| 790 | }).pipe(Effect.provide(layer)), | 520 | }).pipe(Effect.provide(layer)); |
| 791 | ); | 521 | }); |
| 792 | 522 | ||
| 793 | it.effect("reads repository clone URLs", () => | 523 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { |
| 794 | Effect.gen(function* () { | 524 | const { layer, git } = harness({ |
| 795 | mockRun.mockReturnValueOnce( | 525 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), |
| 796 | Effect.succeed( | 526 | localBranches: ["main"], |
| 797 | processOutput( | 527 | api: { |
| 798 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 528 | graphql: () => |
| 799 | JSON.stringify({ | 529 | Effect.succeed( |
| 800 | nameWithOwner: "octocat/codething-mvp", | 530 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), |
| 801 | url: "https://github.com/octocat/codething-mvp", | 531 | ), |
| 802 | sshUrl: "git@github.com:octocat/codething-mvp.git", | 532 | rest: () => |
| 533 | Effect.fail( | ||
| 534 | new GitHubApi.GitHubApiRequestError({ | ||
| 535 | host: "github.com", | ||
| 536 | operation: "x", | ||
| 537 | cause: "offline", | ||
| 803 | }), | 538 | }), |
| 804 | ), | 539 | ), |
| 805 | ), | 540 | }, |
| 806 | ); | 541 | }); |
| 807 | 542 | return Effect.gen(function* () { | |
| 808 | const gh = yield* GitHubCli.GitHubCli; | 543 | const gh = yield* GitHubCli.GitHubCli; |
| 809 | const result = yield* gh.getRepositoryCloneUrls({ | 544 | yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); |
| 810 | cwd: "/repo", | 545 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. |
| 811 | repository: "octocat/codething-mvp", | 546 | assert.deepStrictEqual(git, []); |
| 812 | }); | 547 | }).pipe(Effect.provide(layer)); |
| 813 | 548 | }); | |
| 814 | assert.deepStrictEqual(result, { | ||
| 815 | nameWithOwner: "octocat/codething-mvp", | ||
| 816 | url: "https://github.com/octocat/codething-mvp", | ||
| 817 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 818 | }); | ||
| 819 | }).pipe(Effect.provide(layer)), | ||
| 820 | ); | ||
| 821 | 549 | ||
| 822 | it.effect("creates repositories and parses clone URLs from create output", () => | 550 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { |
| 823 | Effect.gen(function* () { | 551 | const { layer, git } = harness({ |
| 824 | mockRun.mockReturnValueOnce( | 552 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), |
| 825 | Effect.succeed( | 553 | localBranches: ["someone/main"], |
| 826 | processOutput( | 554 | api: { |
| 827 | "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n", | 555 | graphql: () => |
| 556 | Effect.succeed( | ||
| 557 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | ||
| 828 | ), | 558 | ), |
| 829 | ), | 559 | rest: (input) => |
| 830 | ); | 560 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), |
| 831 | 561 | }, | |
| 832 | const gh = yield* GitHubCli.GitHubCli; | 562 | }); |
| 833 | const result = yield* gh.createRepository({ | 563 | return Effect.gen(function* () { |
| 834 | cwd: "/repo", | ||
| 835 | repository: "octocat/codething-mvp", | ||
| 836 | visibility: "private", | ||
| 837 | }); | ||
| 838 | |||
| 839 | assert.deepStrictEqual(result, { | ||
| 840 | nameWithOwner: "octocat/codething-mvp", | ||
| 841 | url: "https://github.com/octocat/codething-mvp", | ||
| 842 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 843 | }); | ||
| 844 | expect(mockRun).toHaveBeenCalledTimes(1); | ||
| 845 | expect(mockRun).toHaveBeenNthCalledWith(1, { | ||
| 846 | operation: "GitHubCli.execute", | ||
| 847 | command: "gh", | ||
| 848 | args: ["repo", "create", "octocat/codething-mvp", "--private"], | ||
| 849 | cwd: "/repo", | ||
| 850 | timeoutMs: 30_000, | ||
| 851 | }); | ||
| 852 | }).pipe(Effect.provide(layer)), | ||
| 853 | ); | ||
| 854 | |||
| 855 | it.effect("falls back to constructed URLs when create output omits a URL", () => | ||
| 856 | Effect.gen(function* () { | ||
| 857 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | ||
| 858 | |||
| 859 | const gh = yield* GitHubCli.GitHubCli; | ||
| 860 | const result = yield* gh.createRepository({ | ||
| 861 | cwd: "/repo", | ||
| 862 | repository: "octocat/codething-mvp", | ||
| 863 | visibility: "private", | ||
| 864 | }); | ||
| 865 | |||
| 866 | assert.deepStrictEqual(result, { | ||
| 867 | nameWithOwner: "octocat/codething-mvp", | ||
| 868 | url: "https://github.com/octocat/codething-mvp", | ||
| 869 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 870 | }); | ||
| 871 | }).pipe(Effect.provide(layer)), | ||
| 872 | ); | ||
| 873 | |||
| 874 | it.effect("surfaces a friendly error when the pull request is not found", () => | ||
| 875 | Effect.gen(function* () { | ||
| 876 | const cause = new VcsProcessExitError({ | ||
| 877 | operation: "GitHubCli.execute", | ||
| 878 | command: "gh pr view", | ||
| 879 | cwd: "/repo", | ||
| 880 | exitCode: 1, | ||
| 881 | failureKind: "not-found", | ||
| 882 | detail: | ||
| 883 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | ||
| 884 | }); | ||
| 885 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 886 | |||
| 887 | const gh = yield* GitHubCli.GitHubCli; | 564 | const gh = yield* GitHubCli.GitHubCli; |
| 888 | const error = yield* gh | 565 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); |
| 889 | .getPullRequest({ | 566 | assert.deepStrictEqual(git, [ |
| 890 | cwd: "/repo", | 567 | [ |
| 891 | reference: "4888", | 568 | "ensureRemote", |
| 892 | }) | 569 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, |
| 893 | .pipe(Effect.flip); | 570 | ], |
| 894 | 571 | [ | |
| 895 | assert.equal(error.message.includes("Pull request not found"), true); | 572 | "fetchRemoteTrackingBranch", |
| 896 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); | 573 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, |
| 897 | assert.strictEqual(error.command, "gh"); | 574 | ], |
| 898 | assert.strictEqual(error.cwd, "/repo"); | 575 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], |
| 899 | assert.strictEqual(error.cause, cause); | 576 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], |
| 900 | assert.equal(error.message.includes(cause.detail), false); | 577 | [ |
| 901 | }).pipe(Effect.provide(layer)), | 578 | "setBranchUpstream", |
| 902 | ); | 579 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, |
| 580 | ], | ||
| 581 | ]); | ||
| 582 | }).pipe(Effect.provide(layer)); | ||
| 583 | }); | ||
| 903 | 584 | ||
| 904 | it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () => | 585 | it("names the local branch the way gh pr checkout does", () => { |
| 905 | Effect.gen(function* () { | 586 | const name = (headRefName: string, isCrossRepository: boolean) => |
| 906 | const cause = new VcsProcessExitError({ | 587 | GitHubCli.pullRequestCheckoutBranchName({ |
| 907 | operation: "GitHubCli.execute", | 588 | headRefName, |
| 908 | command: "gh", | 589 | headOwner: "someone", |
| 909 | cwd: "/repo", | 590 | isCrossRepository, |
| 910 | exitCode: 1, | 591 | defaultBranch: "main", |
| 911 | failureKind: "rate-limited", | ||
| 912 | detail: "API rate limit exceeded.", | ||
| 913 | stderrLength: 82, | ||
| 914 | stderrTruncated: false, | ||
| 915 | }); | 592 | }); |
| 916 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | 593 | assert.strictEqual(name("main", true), "someone/main"); |
| 917 | 594 | assert.strictEqual(name("feature", true), "feature"); | |
| 918 | const gh = yield* GitHubCli.GitHubCli; | 595 | assert.strictEqual(name("main", false), "main"); |
| 919 | const error = yield* gh | 596 | }); |
| 920 | .listOpenPullRequests({ | ||
| 921 | cwd: "/repo", | ||
| 922 | headSelector: "feature/rate-limited", | ||
| 923 | }) | ||
| 924 | .pipe(Effect.flip); | ||
| 925 | |||
| 926 | assert.strictEqual(error._tag, "GitHubCliRateLimitError"); | ||
| 927 | assert.include(error.detail, "GitHub API rate limit exceeded"); | ||
| 928 | assert.include(error.detail, "gh api rate_limit"); | ||
| 929 | assert.strictEqual(error.cause, cause); | ||
| 930 | assert.notInclude(error.message, "user ID"); | ||
| 931 | const paused = yield* gh | ||
| 932 | .execute({ cwd: "/other-repo", args: ["pr", "list"] }) | ||
| 933 | .pipe(Effect.flip); | ||
| 934 | assert.strictEqual(paused._tag, "GitHubCliRateLimitError"); | ||
| 935 | expect(mockRun).toHaveBeenCalledTimes(1); | ||
| 936 | yield* TestClock.adjust("30 seconds"); | ||
| 937 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); | ||
| 938 | yield* gh.execute({ cwd: "/other-repo", args: ["pr", "list"] }); | ||
| 939 | expect(mockRun).toHaveBeenCalledTimes(2); | ||
| 940 | }).pipe(Effect.provide(layer)), | ||
| 941 | ); | ||
| 942 | }); | 597 | }); |
| 943 | |||
| 944 | it.effect("accepts conditional 304 responses and preserves HTTP errors and retry delays", () => | ||
| 945 | Effect.gen(function* () { | ||
| 946 | const gh = yield* GitHubCli.GitHubCli; | ||
| 947 | const request = { | ||
| 948 | cwd: "/repo", | ||
| 949 | args: [ | ||
| 950 | "api", | ||
| 951 | "repos/acme/web/pulls/1", | ||
| 952 | "--hostname", | ||
| 953 | "github.com", | ||
| 954 | "--include", | ||
| 955 | "-H", | ||
| 956 | 'If-None-Match: "one"', | ||
| 957 | ], | ||
| 958 | acceptNotModified: true, | ||
| 959 | }; | ||
| 960 | const respond = (status: number, headers = "") => | ||
| 961 | mockRun.mockImplementation(() => | ||
| 962 | Effect.succeed({ | ||
| 963 | ...processOutput(`HTTP/2.0 ${status}\r\n${headers}\r\n`), | ||
| 964 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 965 | }), | ||
| 966 | ); | ||
| 967 | respond(304); | ||
| 968 | expect((yield* gh.execute(request)).stdout).toContain("304"); | ||
| 969 | expect(mockRun.mock.calls[0]?.[0].allowNonZeroExit).toBe(true); | ||
| 970 | respond(401); | ||
| 971 | expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe( | ||
| 972 | "GitHubCliAuthenticationError", | ||
| 973 | ); | ||
| 974 | respond(403); | ||
| 975 | expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe("GitHubCliCommandError"); | ||
| 976 | for (const status of [403, 429]) { | ||
| 977 | respond(status, "Retry-After: 120\r\n"); | ||
| 978 | expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ | ||
| 979 | _tag: "GitHubCliRateLimitError", | ||
| 980 | retryAt: (yield* Clock.currentTimeMillis) + 120_000, | ||
| 981 | }); | ||
| 982 | } | ||
| 983 | respond( | ||
| 984 | 403, | ||
| 985 | `X-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: ${Math.floor((yield* Clock.currentTimeMillis) / 1_000) + 60}\r\n`, | ||
| 986 | ); | ||
| 987 | expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ | ||
| 988 | _tag: "GitHubCliRateLimitError", | ||
| 989 | retryAt: (yield* Clock.currentTimeMillis) + 60_000, | ||
| 990 | }); | ||
| 991 | respond(500); | ||
| 992 | expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ | ||
| 993 | _tag: "GitHubCliCommandError", | ||
| 994 | httpStatus: 500, | ||
| 995 | }); | ||
| 996 | }).pipe(Effect.provide(layer)), | ||
| 997 | ); |
apps/server/src/sourceControl/GitHubCli.ts+716-723| ... | @@ -1,77 +1,38 @@ | ... | @@ -1,77 +1,38 @@ |
| 1 | import * as Cache from "effect/Cache"; | ||
| 2 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 6 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 7 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | ||
| 11 | import * as Redacted from "effect/Redacted"; | ||
| 12 | import * as Request from "effect/Request"; | 8 | import * as Request from "effect/Request"; |
| 13 | import * as RequestResolver from "effect/RequestResolver"; | 9 | import * as RequestResolver from "effect/RequestResolver"; |
| 14 | import * as Result from "effect/Result"; | 10 | import * as Result from "effect/Result"; |
| 15 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 16 | 12 | ||
| 17 | import { | 13 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; |
| 18 | TrimmedNonEmptyString, | ||
| 19 | type SourceControlRepositoryVisibility, | ||
| 20 | type VcsError, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 15 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 16 | import { | ||
| 17 | detectSourceControlProviderFromRemoteUrl, | ||
| 18 | isSshRemoteUrl, | ||
| 19 | } from "@t3tools/shared/sourceControl"; | ||
| 20 | |||
| 21 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 22 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 24 | 23 | ||
| 25 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 24 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 26 | import * as GitHubApi from "./GitHubApi.ts"; | 25 | import * as GitHubApi from "./GitHubApi.ts"; |
| 26 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | ||
| 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; |
| 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 29 | import { | 29 | import { |
| 30 | decodeGitHubPullRequestEntries, | 30 | decodeGitHubPullRequestEntries, |
| 31 | decodeGitHubPullRequestJson, | ||
| 32 | decodeGitHubPullRequestListJson, | ||
| 33 | type NormalizedGitHubPullRequestRecord, | 31 | type NormalizedGitHubPullRequestRecord, |
| 34 | } from "./gitHubPullRequests.ts"; | 32 | } from "./gitHubPullRequests.ts"; |
| 35 | 33 | ||
| 36 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 37 | |||
| 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ | ||
| 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; | ||
| 40 | |||
| 41 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; | 34 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; |
| 42 | 35 | ||
| 43 | function commandHosts(args: ReadonlyArray<string>): Array<string | null> { | ||
| 44 | const hosts: Array<string | null> = []; | ||
| 45 | const repositoryHost = (repository: string | undefined) => { | ||
| 46 | if (repository === undefined) return null; | ||
| 47 | if (/^https?:\/\//i.test(repository)) { | ||
| 48 | try { | ||
| 49 | return new URL(repository).host.toLowerCase(); | ||
| 50 | } catch { | ||
| 51 | return null; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | const parts = repository.split("/"); | ||
| 55 | return parts.length === 3 ? parts[0]!.toLowerCase() : null; | ||
| 56 | }; | ||
| 57 | if (args[0] === "repo" && args[1] === "view") hosts.push(repositoryHost(args[2])); | ||
| 58 | for (let index = 0; index < args.length; index++) { | ||
| 59 | const arg = args[index]!; | ||
| 60 | if (arg === "--hostname") hosts.push(args[++index]?.toLowerCase() ?? null); | ||
| 61 | else if (arg.startsWith("--hostname=")) hosts.push(arg.slice(11).toLowerCase()); | ||
| 62 | else if (arg === "--repo" || arg === "-R") hosts.push(repositoryHost(args[++index])); | ||
| 63 | else if (arg.startsWith("--repo=")) hosts.push(repositoryHost(arg.slice(7))); | ||
| 64 | else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2))); | ||
| 65 | else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg)); | ||
| 66 | } | ||
| 67 | return hosts; | ||
| 68 | } | ||
| 69 | |||
| 70 | function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean { | ||
| 71 | const hosts = commandHosts(args); | ||
| 72 | return hosts.length > 0 && hosts.every((target) => target === host); | ||
| 73 | } | ||
| 74 | |||
| 75 | const gitHubCliFailureFields = { | 36 | const gitHubCliFailureFields = { |
| 76 | command: Schema.Literal("gh"), | 37 | command: Schema.Literal("gh"), |
| 77 | cwd: Schema.String, | 38 | cwd: Schema.String, |
| ... | @@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava | ... | @@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava |
| 82 | "GitHubCliUnavailableError", | 43 | "GitHubCliUnavailableError", |
| 83 | gitHubCliFailureFields, | 44 | gitHubCliFailureFields, |
| 84 | ) { | 45 | ) { |
| 85 | get detail(): string { | ||
| 86 | return "GitHub CLI (`gh`) is required but not available on PATH."; | ||
| 87 | } | ||
| 88 | |||
| 89 | override get message(): string { | 46 | override get message(): string { |
| 90 | return `GitHub CLI failed in execute: ${this.detail}`; | 47 | return "GitHub CLI (`gh`) is required but not available on PATH. Install it and run `gh auth login`, or set GH_TOKEN."; |
| 91 | } | 48 | } |
| 92 | } | 49 | } |
| 93 | 50 | ||
| ... | @@ -95,12 +52,8 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu | ... | @@ -95,12 +52,8 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu |
| 95 | "GitHubCliAuthenticationError", | 52 | "GitHubCliAuthenticationError", |
| 96 | gitHubCliFailureFields, | 53 | gitHubCliFailureFields, |
| 97 | ) { | 54 | ) { |
| 98 | get detail(): string { | ||
| 99 | return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; | ||
| 100 | } | ||
| 101 | |||
| 102 | override get message(): string { | 55 | override get message(): string { |
| 103 | return `GitHub CLI failed in execute: ${this.detail}`; | 56 | return "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; |
| 104 | } | 57 | } |
| 105 | } | 58 | } |
| 106 | 59 | ||
| ... | @@ -108,12 +61,8 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim | ... | @@ -108,12 +61,8 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim |
| 108 | "GitHubCliRateLimitError", | 61 | "GitHubCliRateLimitError", |
| 109 | { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, | 62 | { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, |
| 110 | ) { | 63 | ) { |
| 111 | get detail(): string { | ||
| 112 | return "GitHub API rate limit exceeded. For the GraphQL quota and reset time, run `gh api graphql -f query='{rateLimit{remaining resetAt}}'`; `gh api rate_limit` reports REST."; | ||
| 113 | } | ||
| 114 | |||
| 115 | override get message(): string { | 64 | override get message(): string { |
| 116 | return `GitHub CLI failed in execute: ${this.detail}`; | 65 | return "GitHub API rate limit exceeded. Requests resume when the limit resets."; |
| 117 | } | 66 | } |
| 118 | } | 67 | } |
| 119 | 68 | ||
| ... | @@ -121,12 +70,8 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPul | ... | @@ -121,12 +70,8 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPul |
| 121 | "GitHubPullRequestNotFoundError", | 70 | "GitHubPullRequestNotFoundError", |
| 122 | gitHubCliFailureFields, | 71 | gitHubCliFailureFields, |
| 123 | ) { | 72 | ) { |
| 124 | get detail(): string { | ||
| 125 | return "Pull request not found. Check the PR number or URL and try again."; | ||
| 126 | } | ||
| 127 | |||
| 128 | override get message(): string { | 73 | override get message(): string { |
| 129 | return `GitHub CLI failed in execute: ${this.detail}`; | 74 | return "Pull request not found. Check the PR number or URL and try again."; |
| 130 | } | 75 | } |
| 131 | } | 76 | } |
| 132 | 77 | ||
| ... | @@ -134,12 +79,13 @@ export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandEr | ... | @@ -134,12 +79,13 @@ export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandEr |
| 134 | "GitHubCliCommandError", | 79 | "GitHubCliCommandError", |
| 135 | { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, | 80 | { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, |
| 136 | ) { | 81 | ) { |
| 137 | get detail(): string { | ||
| 138 | return "GitHub CLI command failed."; | ||
| 139 | } | ||
| 140 | |||
| 141 | override get message(): string { | 82 | override get message(): string { |
| 142 | return `GitHub CLI failed in execute: ${this.detail}`; | 83 | // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. |
| 84 | const reason = | ||
| 85 | this.cause instanceof Error && this.cause.message.trim() !== "" | ||
| 86 | ? this.cause.message.trim() | ||
| 87 | : null; | ||
| 88 | return reason === null ? "GitHub request failed." : reason; | ||
| 143 | } | 89 | } |
| 144 | } | 90 | } |
| 145 | 91 | ||
| ... | @@ -153,12 +99,8 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubP | ... | @@ -153,12 +99,8 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubP |
| 153 | "GitHubPullRequestListDecodeError", | 99 | "GitHubPullRequestListDecodeError", |
| 154 | gitHubCliDecodeFields, | 100 | gitHubCliDecodeFields, |
| 155 | ) { | 101 | ) { |
| 156 | get detail(): string { | ||
| 157 | return "GitHub CLI returned invalid PR list JSON."; | ||
| 158 | } | ||
| 159 | |||
| 160 | override get message(): string { | 102 | override get message(): string { |
| 161 | return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; | 103 | return "GitHub returned an invalid pull request list."; |
| 162 | } | 104 | } |
| 163 | } | 105 | } |
| 164 | 106 | ||
| ... | @@ -166,12 +108,8 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHu | ... | @@ -166,12 +108,8 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHu |
| 166 | "GitHubChangeRequestListDecodeError", | 108 | "GitHubChangeRequestListDecodeError", |
| 167 | gitHubCliDecodeFields, | 109 | gitHubCliDecodeFields, |
| 168 | ) { | 110 | ) { |
| 169 | get detail(): string { | ||
| 170 | return "GitHub CLI returned invalid change request JSON."; | ||
| 171 | } | ||
| 172 | |||
| 173 | override get message(): string { | 111 | override get message(): string { |
| 174 | return `GitHub CLI failed in listChangeRequests: ${this.detail}`; | 112 | return "GitHub returned an invalid change request list."; |
| 175 | } | 113 | } |
| 176 | } | 114 | } |
| 177 | 115 | ||
| ... | @@ -179,12 +117,8 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullR | ... | @@ -179,12 +117,8 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullR |
| 179 | "GitHubPullRequestDecodeError", | 117 | "GitHubPullRequestDecodeError", |
| 180 | gitHubCliDecodeFields, | 118 | gitHubCliDecodeFields, |
| 181 | ) { | 119 | ) { |
| 182 | get detail(): string { | ||
| 183 | return "GitHub CLI returned invalid pull request JSON."; | ||
| 184 | } | ||
| 185 | |||
| 186 | override get message(): string { | 120 | override get message(): string { |
| 187 | return `GitHub CLI failed in getPullRequest: ${this.detail}`; | 121 | return "GitHub returned an invalid pull request."; |
| 188 | } | 122 | } |
| 189 | } | 123 | } |
| 190 | 124 | ||
| ... | @@ -192,12 +126,8 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi | ... | @@ -192,12 +126,8 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi |
| 192 | "GitHubRepositoryDecodeError", | 126 | "GitHubRepositoryDecodeError", |
| 193 | gitHubCliDecodeFields, | 127 | gitHubCliDecodeFields, |
| 194 | ) { | 128 | ) { |
| 195 | get detail(): string { | ||
| 196 | return "GitHub CLI returned invalid repository JSON."; | ||
| 197 | } | ||
| 198 | |||
| 199 | override get message(): string { | 129 | override get message(): string { |
| 200 | return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; | 130 | return "GitHub returned an invalid repository."; |
| 201 | } | 131 | } |
| 202 | } | 132 | } |
| 203 | 133 | ||
| ... | @@ -216,36 +146,31 @@ export type GitHubCliError = typeof GitHubCliError.Type; | ... | @@ -216,36 +146,31 @@ export type GitHubCliError = typeof GitHubCliError.Type; |
| 216 | 146 | ||
| 217 | export const isGitHubCliError = Schema.is(GitHubCliError); | 147 | export const isGitHubCliError = Schema.is(GitHubCliError); |
| 218 | 148 | ||
| 219 | export function fromVcsError( | 149 | /** Maps a GitHub API failure onto the errors callers of this service already handle. */ |
| 220 | context: { | 150 | function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { |
| 221 | readonly command: "gh"; | 151 | const context = { command: "gh" as const, cwd, cause: error }; |
| 222 | readonly cwd: string; | 152 | switch (error._tag) { |
| 223 | }, | 153 | case "GitHubCliMissingError": |
| 224 | error: VcsError, | 154 | return new GitHubCliUnavailableError(context); |
| 225 | ): GitHubCliError { | 155 | case "GitHubNotSignedInError": |
| 226 | if ( | 156 | case "GitHubApiAuthenticationError": |
| 227 | error._tag === "VcsProcessSpawnError" && | 157 | return new GitHubCliAuthenticationError(context); |
| 228 | error.cause instanceof PlatformError.PlatformError && | 158 | case "GitHubCliFailedError": |
| 229 | error.cause.reason._tag === "NotFound" && | 159 | return new GitHubCliCommandError(context); |
| 230 | error.cause.reason.module === "ChildProcess" && | 160 | case "GitHubApiRateLimitError": |
| 231 | error.cause.reason.method === "spawn" | 161 | return new GitHubCliRateLimitError({ |
| 232 | ) { | 162 | ...context, |
| 233 | return new GitHubCliUnavailableError({ ...context, cause: error }); | 163 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), |
| 234 | } | 164 | }); |
| 235 | 165 | case "SourceControlRateLimitPausedError": | |
| 236 | if (error._tag === "VcsProcessExitError") { | 166 | return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); |
| 237 | if (error.failureKind === "authentication") { | 167 | case "GitHubApiNotFoundError": |
| 238 | return new GitHubCliAuthenticationError({ ...context, cause: error }); | 168 | return new GitHubPullRequestNotFoundError(context); |
| 239 | } | 169 | case "GitHubApiResponseError": |
| 240 | if (error.failureKind === "rate-limited") { | 170 | return new GitHubCliCommandError({ ...context, httpStatus: error.status }); |
| 241 | return new GitHubCliRateLimitError({ ...context, cause: error }); | 171 | case "GitHubApiRequestError": |
| 242 | } | 172 | return new GitHubCliCommandError(context); |
| 243 | if (error.failureKind === "not-found") { | ||
| 244 | return new GitHubPullRequestNotFoundError({ ...context, cause: error }); | ||
| 245 | } | ||
| 246 | } | 173 | } |
| 247 | |||
| 248 | return new GitHubCliCommandError({ ...context, cause: error }); | ||
| 249 | } | 174 | } |
| 250 | 175 | ||
| 251 | export interface GitHubPullRequestSummary { | 176 | export interface GitHubPullRequestSummary { |
| ... | @@ -281,19 +206,6 @@ export interface GitHubRepositoryCloneUrls { | ... | @@ -281,19 +206,6 @@ export interface GitHubRepositoryCloneUrls { |
| 281 | export class GitHubCli extends Context.Service< | 206 | export class GitHubCli extends Context.Service< |
| 282 | GitHubCli, | 207 | GitHubCli, |
| 283 | { | 208 | { |
| 284 | readonly execute: (input: { | ||
| 285 | readonly cwd: string; | ||
| 286 | readonly args: ReadonlyArray<string>; | ||
| 287 | readonly timeoutMs?: number; | ||
| 288 | /** Piped to the child's stdin, for payloads that must never appear in argv. */ | ||
| 289 | readonly stdin?: string; | ||
| 290 | readonly env?: NodeJS.ProcessEnv; | ||
| 291 | readonly maxOutputBytes?: number; | ||
| 292 | readonly rateLimitHost?: string; | ||
| 293 | readonly allowReserve?: boolean; | ||
| 294 | readonly acceptNotModified?: boolean; | ||
| 295 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>; | ||
| 296 | |||
| 297 | readonly listOpenPullRequests: (input: { | 209 | readonly listOpenPullRequests: (input: { |
| 298 | readonly cwd: string; | 210 | readonly cwd: string; |
| 299 | readonly headSelector: string; | 211 | readonly headSelector: string; |
| ... | @@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service< | ... | @@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service< |
| 302 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | 214 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; |
| 303 | 215 | ||
| 304 | /** | 216 | /** |
| 305 | * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in | 217 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the |
| 306 | * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a | 218 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive |
| 307 | * checkout whose repository gh could pick another way is asked through `gh pr list`. | 219 | * together share one GraphQL document. |
| 308 | */ | 220 | */ |
| 309 | readonly listPullRequestsByHead: (input: { | 221 | readonly listPullRequestsByHead: (input: { |
| 310 | readonly cwd: string; | 222 | readonly cwd: string; |
| 311 | readonly headSelector: string; | 223 | readonly headSelector: string; |
| 312 | readonly state: "open" | "closed" | "merged" | "all"; | 224 | readonly state: "open" | "closed" | "merged" | "all"; |
| 313 | readonly limit: number; | 225 | readonly limit: number; |
| 314 | /** The checkout's GitHub host. Without it the lookup is not batched. */ | 226 | /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ |
| 315 | readonly rateLimitHost?: string; | 227 | readonly rateLimitHost?: string; |
| 316 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>; | 228 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>; |
| 317 | 229 | ||
| ... | @@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service< | ... | @@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service< |
| 353 | } | 265 | } |
| 354 | >()("t3/sourceControl/GitHubCli") {} | 266 | >()("t3/sourceControl/GitHubCli") {} |
| 355 | 267 | ||
| 356 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | 268 | /** |
| 357 | nameWithOwner: TrimmedNonEmptyString, | 269 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a |
| 358 | url: TrimmedNonEmptyString, | 270 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin |
| 359 | sshUrl: TrimmedNonEmptyString, | 271 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of |
| 272 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | ||
| 273 | * | ||
| 274 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | ||
| 275 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | ||
| 276 | * fall back to the provider's remote, then to the best-ranked remote on the host. | ||
| 277 | */ | ||
| 278 | export function selectGitHubBaseRepository(input: { | ||
| 279 | readonly remotes: string; | ||
| 280 | readonly resolved: string; | ||
| 281 | readonly host: string; | ||
| 282 | }): { readonly owner: string; readonly name: string } | null { | ||
| 283 | const host = input.host.toLowerCase(); | ||
| 284 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | ||
| 285 | for (const line of input.remotes.split("\n")) { | ||
| 286 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | ||
| 287 | if (!match) continue; | ||
| 288 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | ||
| 289 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | ||
| 290 | repositories.set(match[1]!, { owner, name }); | ||
| 291 | } | ||
| 292 | const marks = input.resolved | ||
| 293 | .split("\n") | ||
| 294 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | ||
| 295 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | ||
| 296 | if (marks.length > 1) return null; | ||
| 297 | const [mark] = marks; | ||
| 298 | if (mark) { | ||
| 299 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | ||
| 300 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | ||
| 301 | return owner && name && rest.length === 0 ? { owner, name } : null; | ||
| 302 | } | ||
| 303 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | ||
| 304 | // top place has no defined winner. | ||
| 305 | const score = (remoteName: string) => | ||
| 306 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | ||
| 307 | const ranked = [...repositories.entries()].toSorted( | ||
| 308 | ([left], [right]) => score(right) - score(left), | ||
| 309 | ); | ||
| 310 | const [top, next] = ranked; | ||
| 311 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | ||
| 312 | ? top[1] | ||
| 313 | : null; | ||
| 314 | } | ||
| 315 | |||
| 316 | const RawRepositorySchema = Schema.Struct({ | ||
| 317 | full_name: TrimmedNonEmptyString, | ||
| 318 | html_url: TrimmedNonEmptyString, | ||
| 319 | ssh_url: TrimmedNonEmptyString, | ||
| 320 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 360 | }); | 321 | }); |
| 361 | const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( | 322 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); |
| 362 | Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), | ||
| 363 | ); | ||
| 364 | 323 | ||
| 365 | function normalizeRepositoryCloneUrls( | 324 | function repositoryCloneUrls( |
| 366 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, | 325 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, |
| 367 | ): GitHubRepositoryCloneUrls { | 326 | ): GitHubRepositoryCloneUrls { |
| 368 | return { | 327 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; |
| 369 | nameWithOwner: raw.nameWithOwner, | ||
| 370 | url: raw.url, | ||
| 371 | sshUrl: raw.sshUrl, | ||
| 372 | }; | ||
| 373 | } | 328 | } |
| 374 | 329 | ||
| 375 | /** | 330 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); |
| 376 | * `gh repo create` prints the canonical URL of the new repository on stdout | ||
| 377 | * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a | ||
| 378 | * follow-up `gh repo view`, which can race GitHub's GraphQL eventual | ||
| 379 | * consistency window and falsely report the just-created repo as missing. | ||
| 380 | */ | ||
| 381 | function deriveRepositoryCloneUrlsFromCreateOutput( | ||
| 382 | stdout: string, | ||
| 383 | repository: string, | ||
| 384 | ): GitHubRepositoryCloneUrls { | ||
| 385 | const fallbackHost = "github.com"; | ||
| 386 | const match = stdout.match(/https?:\/\/[^\s]+/); | ||
| 387 | if (match) { | ||
| 388 | const cleaned = match[0].replace(/\.git$/, ""); | ||
| 389 | try { | ||
| 390 | const parsed = new URL(cleaned); | ||
| 391 | const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); | ||
| 392 | const segments = pathname.split("/").filter(Boolean); | ||
| 393 | if (segments.length === 2) { | ||
| 394 | const nameWithOwner = `${segments[0]}/${segments[1]}`; | ||
| 395 | return { | ||
| 396 | nameWithOwner, | ||
| 397 | url: `${parsed.origin}/${nameWithOwner}`, | ||
| 398 | sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, | ||
| 399 | }; | ||
| 400 | } | ||
| 401 | } catch { | ||
| 402 | // Fall through to the input-derived defaults below. | ||
| 403 | } | ||
| 404 | } | ||
| 405 | return { | ||
| 406 | nameWithOwner: repository, | ||
| 407 | url: `https://${fallbackHost}/${repository}`, | ||
| 408 | sshUrl: `git@${fallbackHost}:${repository}.git`, | ||
| 409 | }; | ||
| 410 | } | ||
| 411 | 331 | ||
| 412 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | 332 | type PullRequestListState = "open" | "closed" | "merged" | "all"; |
| 413 | 333 | ||
| 414 | const PULL_REQUEST_LIST_JSON_FIELDS = | 334 | /** The pull request fields every read selects, in GraphQL. */ |
| 415 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner"; | ||
| 416 | /** The `gh pr list --json` fields above, as GraphQL selects them. */ | ||
| 417 | const PULL_REQUEST_NODE_SELECTION = | 335 | const PULL_REQUEST_NODE_SELECTION = |
| 418 | "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | 336 | "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; |
| 419 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | 337 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { |
| ... | @@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | ... | @@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { |
| 424 | }; | 342 | }; |
| 425 | /** | 343 | /** |
| 426 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | 344 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one |
| 427 | * `gh pr list`, but half that keeps each answer near half a second. | 345 | * single lookup, but half that keeps each answer near half a second. |
| 428 | */ | 346 | */ |
| 429 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | 347 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; |
| 430 | /** | 348 | /** |
| ... | @@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50; | ... | @@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50; |
| 433 | */ | 351 | */ |
| 434 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | 352 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; |
| 435 | /** A full document is 5,000 rows of well under 2 KB each. */ | 353 | /** A full document is 5,000 rows of well under 2 KB each. */ |
| 436 | const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000; | 354 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; |
| 437 | const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }"; | 355 | /** |
| 356 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | ||
| 357 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | ||
| 358 | * top because the newest come first. | ||
| 359 | */ | ||
| 360 | const OWNER_HEAD_SCAN_LIMIT = 100; | ||
| 438 | 361 | ||
| 439 | class PullRequestsByHeadRead extends Request.Class< | 362 | class PullRequestsByHeadRead extends Request.Class< |
| 440 | { | 363 | { |
| ... | @@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class< | ... | @@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class< |
| 445 | readonly headRefName: string; | 368 | readonly headRefName: string; |
| 446 | readonly state: PullRequestListState; | 369 | readonly state: PullRequestListState; |
| 447 | readonly limit: number; | 370 | readonly limit: number; |
| 371 | readonly allowReserve: boolean; | ||
| 448 | }, | 372 | }, |
| 449 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | 373 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, |
| 450 | GitHubCliError | 374 | GitHubCliError |
| 451 | > {} | 375 | > {} |
| 452 | 376 | ||
| 453 | const GraphQlVariables = Schema.Record( | ||
| 454 | Schema.String, | ||
| 455 | Schema.Union([Schema.String, Schema.Array(Schema.String)]), | ||
| 456 | ); | ||
| 457 | /** A GraphQL request body for `gh api graphql --input -`. */ | ||
| 458 | const encodeGraphQlRequest = Schema.encodeSync( | ||
| 459 | Schema.fromJsonString(Schema.Struct({ query: Schema.String, variables: GraphQlVariables })), | ||
| 460 | ); | ||
| 461 | |||
| 462 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | 377 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ |
| 463 | function buildPullRequestsByHeadQuery( | 378 | function buildPullRequestsByHeadQuery( |
| 464 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | 379 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, |
| 465 | ): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } { | 380 | ): { readonly document: string; readonly variables: Record<string, unknown> } { |
| 466 | const variables: Record<string, string | ReadonlyArray<string>> = {}; | 381 | const variables: Record<string, unknown> = {}; |
| 467 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | 382 | const declarations: string[] = ["$owner: String!", "$name: String!"]; |
| 468 | const selections: string[] = []; | 383 | const selections: string[] = []; |
| 469 | for (const [index, lookup] of lookups.entries()) { | 384 | for (const [index, lookup] of lookups.entries()) { |
| ... | @@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery( | ... | @@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery( |
| 482 | }; | 397 | }; |
| 483 | } | 398 | } |
| 484 | 399 | ||
| 485 | /** The reset time of a `rateLimit` reading, which sets when the next one is due. */ | 400 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { |
| 486 | const decodeRateLimitReading = (raw: string) => | 401 | repository(owner: $owner, name: $name) { |
| 487 | Result.map( | 402 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } |
| 488 | decodeJsonResult( | 403 | } |
| 489 | Schema.Struct({ | 404 | }`; |
| 490 | data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }), | ||
| 491 | }), | ||
| 492 | )(raw), | ||
| 493 | (reading) => reading.data.rateLimit.resetAt, | ||
| 494 | ); | ||
| 495 | 405 | ||
| 496 | const decodePullRequestsByHead = decodeJsonResult( | 406 | const decodePullRequestsByHead = decodeJsonResult( |
| 497 | Schema.Struct({ | 407 | Schema.Struct({ |
| 498 | data: Schema.Struct({ | 408 | data: Schema.Struct({ |
| 499 | repository: Schema.Record( | 409 | repository: Schema.NullOr( |
| 500 | Schema.String, | 410 | Schema.Record( |
| 501 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | 411 | Schema.String, |
| 412 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | ||
| 413 | ), | ||
| 502 | ), | 414 | ), |
| 503 | }), | 415 | }), |
| 504 | }), | 416 | }), |
| 505 | ); | 417 | ); |
| 506 | 418 | ||
| 419 | const decodePullRequestByNumber = decodeJsonResult( | ||
| 420 | Schema.Struct({ | ||
| 421 | data: Schema.Struct({ | ||
| 422 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | ||
| 423 | }), | ||
| 424 | }), | ||
| 425 | ); | ||
| 426 | |||
| 427 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | ||
| 428 | export interface GitHubRepositoryLocator { | ||
| 429 | readonly host: string; | ||
| 430 | readonly owner: string; | ||
| 431 | readonly name: string; | ||
| 432 | } | ||
| 433 | |||
| 434 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | ||
| 435 | function parseGitHubRepositorySelector( | ||
| 436 | selector: string, | ||
| 437 | defaultHost: string, | ||
| 438 | ): GitHubRepositoryLocator | null { | ||
| 439 | const trimmed = selector.trim().replace(/\.git$/i, ""); | ||
| 440 | if (/^https?:\/\//i.test(trimmed)) { | ||
| 441 | try { | ||
| 442 | const url = new URL(trimmed); | ||
| 443 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | ||
| 444 | return owner && name && rest.length === 0 | ||
| 445 | ? { host: url.host.toLowerCase(), owner, name } | ||
| 446 | : null; | ||
| 447 | } catch { | ||
| 448 | return null; | ||
| 449 | } | ||
| 450 | } | ||
| 451 | const parts = trimmed.split("/").filter(Boolean); | ||
| 452 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | ||
| 453 | if (parts.length === 3) | ||
| 454 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | ||
| 455 | return null; | ||
| 456 | } | ||
| 457 | |||
| 507 | /** | 458 | /** |
| 508 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | 459 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request |
| 509 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | 460 | * URL, or a branch name. |
| 510 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | ||
| 511 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | ||
| 512 | * | ||
| 513 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | ||
| 514 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | ||
| 515 | * ask gh itself. | ||
| 516 | */ | 461 | */ |
| 517 | export function selectGitHubBaseRepository(input: { | 462 | function parsePullRequestReference( |
| 518 | readonly remotes: string; | 463 | reference: string, |
| 519 | readonly resolved: string; | 464 | ): |
| 520 | readonly host: string; | 465 | | { readonly kind: "number"; readonly number: number } |
| 521 | }): { readonly owner: string; readonly name: string } | null { | 466 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } |
| 522 | const host = input.host.toLowerCase(); | 467 | | { readonly kind: "branch"; readonly headSelector: string } { |
| 523 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | 468 | const trimmed = reference.trim(); |
| 524 | for (const line of input.remotes.split("\n")) { | 469 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); |
| 525 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | 470 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; |
| 526 | if (!match) continue; | 471 | if (/^https?:\/\//i.test(trimmed)) { |
| 527 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | 472 | try { |
| 528 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | 473 | const url = new URL(trimmed); |
| 529 | repositories.set(match[1]!, { owner, name }); | 474 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); |
| 475 | if (match) { | ||
| 476 | return { | ||
| 477 | kind: "url", | ||
| 478 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | ||
| 479 | number: Number(match[3]), | ||
| 480 | }; | ||
| 481 | } | ||
| 482 | } catch { | ||
| 483 | // Not a URL after all; read it as a branch. | ||
| 484 | } | ||
| 530 | } | 485 | } |
| 531 | const marks = input.resolved | 486 | return { kind: "branch", headSelector: trimmed }; |
| 532 | .split("\n") | 487 | } |
| 533 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | 488 | |
| 534 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | 489 | /** |
| 535 | if (marks.length > 1) return null; | 490 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH |
| 536 | const [mark] = marks; | 491 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through |
| 537 | if (mark) { | 492 | * `github.com`, which is what such an alias almost always stands for (issue #6198). |
| 538 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | 493 | */ |
| 539 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | 494 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { |
| 540 | return owner && name && rest.length === 0 ? { owner, name } : null; | 495 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); |
| 496 | if (provider === null) return null; | ||
| 497 | const host = new URL(provider.baseUrl).host.toLowerCase(); | ||
| 498 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | ||
| 499 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | ||
| 500 | return host.includes("github") ? "github.com" : null; | ||
| 541 | } | 501 | } |
| 542 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | 502 | return provider.kind === "github" ? host : null; |
| 543 | // top place has no defined winner. | 503 | } |
| 544 | const score = (remoteName: string) => | 504 | |
| 545 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | 505 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ |
| 546 | const ranked = [...repositories.entries()].toSorted( | 506 | function apiHostForHint(host: string): string { |
| 547 | ([left], [right]) => score(right) - score(left), | 507 | const normalized = host.toLowerCase(); |
| 548 | ); | 508 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; |
| 549 | const [top, next] = ranked; | 509 | } |
| 550 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | 510 | |
| 551 | ? top[1] | 511 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ |
| 552 | : null; | 512 | export function pullRequestCheckoutBranchName(input: { |
| 513 | readonly headRefName: string; | ||
| 514 | readonly headOwner: string | null; | ||
| 515 | readonly isCrossRepository: boolean; | ||
| 516 | readonly defaultBranch: string | null; | ||
| 517 | }): string { | ||
| 518 | // gh prefixes the owner only where the fork's branch would take over the default branch's | ||
| 519 | // name, which is the one collision every fork pull request from `main` would hit. | ||
| 520 | return input.isCrossRepository && | ||
| 521 | input.headOwner !== null && | ||
| 522 | input.defaultBranch !== null && | ||
| 523 | input.headRefName === input.defaultBranch | ||
| 524 | ? `${input.headOwner}/${input.headRefName}` | ||
| 525 | : input.headRefName; | ||
| 553 | } | 526 | } |
| 554 | 527 | ||
| 555 | /** @public Service construction is part of the canonical Effect module API. */ | 528 | /** @public Service construction is part of the canonical Effect module API. */ |
| 556 | export const make = Effect.gen(function* () { | 529 | export const make = Effect.gen(function* () { |
| 557 | const process = yield* VcsProcess.VcsProcess; | 530 | const process = yield* VcsProcess.VcsProcess; |
| 558 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | 531 | const environment = yield* HostProcessEnvironment; |
| 559 | const limits = yield* SourceControlRateLimit.SourceControlRateLimit; | 532 | const api = yield* GitHubApi.GitHubApi; |
| 560 | 533 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 561 | const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( | 534 | const fileSystem = yield* FileSystem.FileSystem; |
| 562 | function* (input) { | ||
| 563 | const credential = yield* PinnedGitHubCredential; | ||
| 564 | if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) { | ||
| 565 | return yield* new GitHubCliCommandError({ | ||
| 566 | command: "gh", | ||
| 567 | cwd: input.cwd, | ||
| 568 | cause: new Error("The GitHub command does not target the verified credential's host."), | ||
| 569 | }); | ||
| 570 | } | ||
| 571 | const token = credential === null ? undefined : Redacted.value(credential.token); | ||
| 572 | const env = | ||
| 573 | credential === null | ||
| 574 | ? input.env | ||
| 575 | : { | ||
| 576 | ...input.env, | ||
| 577 | GH_HOST: credential.host, | ||
| 578 | GH_TOKEN: token, | ||
| 579 | GITHUB_TOKEN: token, | ||
| 580 | GH_ENTERPRISE_TOKEN: token, | ||
| 581 | GITHUB_ENTERPRISE_TOKEN: token, | ||
| 582 | GH_DEBUG: "", | ||
| 583 | }; | ||
| 584 | const result = yield* process | ||
| 585 | .run({ | ||
| 586 | operation: "GitHubCli.execute", | ||
| 587 | command: "gh", | ||
| 588 | args: input.args, | ||
| 589 | cwd: input.cwd, | ||
| 590 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 591 | ...(input.acceptNotModified ? { allowNonZeroExit: true } : {}), | ||
| 592 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), | ||
| 593 | ...(env !== undefined ? { env } : {}), | ||
| 594 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), | ||
| 595 | }) | ||
| 596 | .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); | ||
| 597 | if (result.exitCode !== 0 && input.acceptNotModified) { | ||
| 598 | const status = /^HTTP\/\S+ (\d+)/.exec(result.stdout)?.[1]; | ||
| 599 | if (status !== "304" || !input.args.includes("--include")) { | ||
| 600 | const context = { command: "gh" as const, cwd: input.cwd, cause: undefined }; | ||
| 601 | const headers = result.stdout.split(/\r?\n\r?\n/, 1)[0] ?? ""; | ||
| 602 | const header = (name: string) => | ||
| 603 | new RegExp(`^${name}:\\s*(.*)$`, "im").exec(headers)?.[1]?.trim(); | ||
| 604 | if ( | ||
| 605 | status === "429" || | ||
| 606 | (status === "403" && | ||
| 607 | (header("x-ratelimit-remaining") === "0" || | ||
| 608 | header("retry-after") !== undefined || | ||
| 609 | /rate limit/i.test(result.stderr))) | ||
| 610 | ) { | ||
| 611 | const now = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 612 | const reset = Number(header("x-ratelimit-reset")) * 1_000; | ||
| 613 | const retryAt = | ||
| 614 | SourceControlRateLimit.retryAtFromHeader(header("retry-after"), now) ?? | ||
| 615 | (Number.isFinite(reset) && reset > now ? reset : undefined); | ||
| 616 | return yield* new GitHubCliRateLimitError({ | ||
| 617 | ...context, | ||
| 618 | ...(retryAt === undefined ? {} : { retryAt }), | ||
| 619 | }); | ||
| 620 | } | ||
| 621 | if (status === "401") return yield* new GitHubCliAuthenticationError(context); | ||
| 622 | return yield* new GitHubCliCommandError({ | ||
| 623 | ...context, | ||
| 624 | ...(status === undefined ? {} : { httpStatus: Number(status) }), | ||
| 625 | }); | ||
| 626 | } | ||
| 627 | } | ||
| 628 | return result; | ||
| 629 | }, | ||
| 630 | ); | ||
| 631 | 535 | ||
| 632 | /** | 536 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => |
| 633 | * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance | 537 | process.run({ |
| 634 | * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it | 538 | operation: "GitHubCli.resolveRepository", |
| 635 | * current in between. Each reading costs one point and is redone at the reset, or after ten | 539 | command: "git", |
| 636 | * minutes so a `gh auth switch` is not priced against the old account for a whole hour. | 540 | args, |
| 637 | */ | 541 | cwd, |
| 638 | const budgetReading = yield* Cache.makeWith( | 542 | allowNonZeroExit: true, |
| 639 | (key: string) => { | 543 | timeoutMs: 5_000, |
| 640 | const host = key.split("\0")[0]!; | 544 | }); |
| 641 | return executeRaw({ | 545 | |
| 642 | cwd: globalThis.process.cwd(), | 546 | const commandFailure = (cwd: string, detail: string) => |
| 643 | args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`], | 547 | new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); |
| 644 | }).pipe( | ||
| 645 | Effect.tap((result) => budget.observe(host, result.stdout)), | ||
| 646 | Effect.flatMap((result) => | ||
| 647 | Clock.currentTimeMillis.pipe( | ||
| 648 | Effect.map((now) => { | ||
| 649 | const resetAtMs = Date.parse( | ||
| 650 | decodeRateLimitReading(result.stdout).pipe( | ||
| 651 | Result.match({ onFailure: () => "", onSuccess: (reading) => reading }), | ||
| 652 | ), | ||
| 653 | ); | ||
| 654 | // A reset that is missing or already past (a skewed clock) waits a minute, so a | ||
| 655 | // bad reading cannot ask again on every read. | ||
| 656 | return Duration.millis( | ||
| 657 | Number.isFinite(resetAtMs) && resetAtMs > now | ||
| 658 | ? Math.min(resetAtMs - now, 600_000) | ||
| 659 | : 60_000, | ||
| 660 | ); | ||
| 661 | }), | ||
| 662 | ), | ||
| 663 | ), | ||
| 664 | ); | ||
| 665 | }, | ||
| 666 | { | ||
| 667 | capacity: 32, | ||
| 668 | timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.minutes(1)), | ||
| 669 | }, | ||
| 670 | ); | ||
| 671 | 548 | ||
| 672 | /** | 549 | /** |
| 673 | * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives | 550 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else |
| 674 | * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read | 551 | * the remote the caller resolved the provider from, else the best-ranked GitHub remote. |
| 675 | * ignores it and is priced at one point. | ||
| 676 | */ | 552 | */ |
| 677 | const guardedRead = <A>(input: { | 553 | const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { |
| 678 | readonly cwd: string; | 554 | readonly cwd: string; |
| 679 | readonly host: string; | 555 | readonly host?: string | undefined; |
| 680 | readonly document: string; | 556 | }) { |
| 681 | readonly allowReserve: boolean; | 557 | const envRepository = environment.GH_REPO?.trim(); |
| 682 | readonly run: (document: string) => Effect.Effect<A, GitHubCliError>; | 558 | const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); |
| 683 | }) => | 559 | if (envRepository) { |
| 684 | Effect.gen(function* () { | 560 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); |
| 685 | const credential = yield* PinnedGitHubCredential; | 561 | if (locator !== null) return locator; |
| 686 | const key = { provider: "github" as const, host: input.host }; | 562 | } |
| 687 | const guarded = Effect.gen(function* () { | 563 | const [remotes, resolved] = yield* Effect.all([ |
| 688 | const lease = yield* limits.check( | 564 | gitRead(input.cwd, ["remote", "-v"]), |
| 689 | key, | 565 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), |
| 690 | input.allowReserve ? { allowPaused: true } : undefined, | 566 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); |
| 691 | ); | 567 | const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; |
| 692 | // A failed reading leaves the budget unknown; it never blocks the read itself. | 568 | const fetchRemotes = remoteOutput |
| 693 | yield* Cache.get( | 569 | .split("\n") |
| 694 | budgetReading, | 570 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) |
| 695 | `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`, | 571 | .filter((match): match is RegExpExecArray => match !== null) |
| 696 | ).pipe(Effect.ignore); | 572 | .map((match) => ({ |
| 697 | return yield* budget | 573 | name: match[1]!, |
| 698 | .query( | 574 | url: match[2]!, |
| 699 | input.host, | 575 | host: gitHubApiHostForRemote(match[2]!), |
| 700 | input.document, | 576 | })); |
| 701 | input.allowReserve ? { allowReserve: true } : undefined, | 577 | const host = |
| 702 | ) | 578 | (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? |
| 703 | .pipe( | 579 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? |
| 704 | Effect.flatMap(input.run), | 580 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? |
| 705 | Effect.tap(() => limits.recordSuccess({ ...key, lease })), | 581 | defaultHost; |
| 706 | Effect.tapError((error) => | 582 | const selected = selectGitHubBaseRepository({ |
| 707 | error._tag === "GitHubCliRateLimitError" | 583 | remotes: remoteOutput, |
| 708 | ? limits.recordRateLimit({ ...key, lease }) | 584 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", |
| 709 | : Effect.void, | 585 | host, |
| 710 | ), | ||
| 711 | ); | ||
| 712 | }); | ||
| 713 | return yield* guarded.pipe( | ||
| 714 | Effect.provideService( | ||
| 715 | SourceControlRateLimit.CredentialScope, | ||
| 716 | credential?.credentialFingerprint ?? (yield* SourceControlRateLimit.CredentialScope), | ||
| 717 | ), | ||
| 718 | Effect.catchTags({ | ||
| 719 | SourceControlRateLimitPausedError: (cause) => | ||
| 720 | Effect.fail( | ||
| 721 | new GitHubCliRateLimitError({ | ||
| 722 | command: "gh", | ||
| 723 | cwd: input.cwd, | ||
| 724 | retryAt: cause.retryAt, | ||
| 725 | cause, | ||
| 726 | }), | ||
| 727 | ), | ||
| 728 | }), | ||
| 729 | ); | ||
| 730 | }); | 586 | }); |
| 731 | 587 | if (selected !== null) return { host, ...selected }; | |
| 732 | const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( | 588 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among |
| 733 | function* (input) { | 589 | // the ones on this host (an SSH alias counts as its API host). |
| 734 | const [command, action] = input.args; | 590 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); |
| 735 | if ( | 591 | const candidates = fetchRemotes |
| 736 | !( | 592 | .filter((remote) => remote.host === host) |
| 737 | (command === "pr" && (action === "list" || action === "view")) || | 593 | .toSorted((left, right) => { |
| 738 | (command === "repo" && action === "view") | 594 | const l = rank(left.name); |
| 739 | ) | 595 | const r = rank(right.name); |
| 740 | ) | 596 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); |
| 741 | return yield* executeRaw(input); | ||
| 742 | const credential = yield* PinnedGitHubCredential; | ||
| 743 | if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) | ||
| 744 | return yield* executeRaw(input); | ||
| 745 | return yield* guardedRead({ | ||
| 746 | cwd: input.cwd, | ||
| 747 | host: ( | ||
| 748 | credential?.host ?? | ||
| 749 | commandHosts(input.args).find((host) => host !== null) ?? | ||
| 750 | input.rateLimitHost ?? | ||
| 751 | input.env?.GH_HOST ?? | ||
| 752 | globalThis.process.env.GH_HOST ?? | ||
| 753 | "github.com" | ||
| 754 | ).toLowerCase(), | ||
| 755 | document: "query {}", | ||
| 756 | allowReserve: input.allowReserve ?? (yield* AllowGitHubReserve), | ||
| 757 | run: () => executeRaw(input), | ||
| 758 | }); | 597 | }); |
| 759 | }, | 598 | for (const remote of candidates) { |
| 760 | ); | 599 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); |
| 600 | if (owner && name && rest.length === 0) return { host, owner, name }; | ||
| 601 | } | ||
| 602 | return yield* commandFailure( | ||
| 603 | input.cwd, | ||
| 604 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | ||
| 605 | ); | ||
| 606 | }); | ||
| 761 | 607 | ||
| 762 | const listPullRequestsWithCli = (input: { | 608 | const graphqlJson = <A>( |
| 763 | readonly cwd: string; | 609 | cwd: string, |
| 764 | readonly headSelector: string; | 610 | input: GitHubApi.GitHubGraphQlInput, |
| 765 | readonly state: PullRequestListState; | 611 | decode: (raw: string) => Result.Result<A, unknown>, |
| 766 | readonly limit: number; | 612 | onDecodeFailure: (cause: unknown) => GitHubCliError, |
| 767 | readonly rateLimitHost?: string | undefined; | 613 | ) => |
| 768 | }) => | 614 | api.graphql(input).pipe( |
| 769 | execute({ | 615 | Effect.mapError((error) => fromGitHubApiError(cwd, error)), |
| 770 | cwd: input.cwd, | 616 | Effect.flatMap((raw) => { |
| 771 | ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), | 617 | const decoded = decode(raw); |
| 772 | args: [ | ||
| 773 | "pr", | ||
| 774 | "list", | ||
| 775 | "--head", | ||
| 776 | input.headSelector, | ||
| 777 | "--state", | ||
| 778 | input.state, | ||
| 779 | "--limit", | ||
| 780 | String(input.limit), | ||
| 781 | "--json", | ||
| 782 | PULL_REQUEST_LIST_JSON_FIELDS, | ||
| 783 | ], | ||
| 784 | }).pipe( | ||
| 785 | Effect.flatMap((result) => { | ||
| 786 | const raw = result.stdout.trim(); | ||
| 787 | if (raw.length === 0) return Effect.succeed([]); | ||
| 788 | const decoded = decodeGitHubPullRequestListJson(raw); | ||
| 789 | return Result.isSuccess(decoded) | 618 | return Result.isSuccess(decoded) |
| 790 | ? Effect.succeed(decoded.success) | 619 | ? Effect.succeed(decoded.success) |
| 791 | : Effect.fail( | 620 | : Effect.fail(onDecodeFailure(decoded.failure)); |
| 792 | new GitHubChangeRequestListDecodeError({ | ||
| 793 | command: "gh", | ||
| 794 | cwd: input.cwd, | ||
| 795 | cause: decoded.failure, | ||
| 796 | }), | ||
| 797 | ); | ||
| 798 | }), | 621 | }), |
| 799 | ); | 622 | ); |
| 800 | 623 | ||
| 801 | const git = (cwd: string, args: ReadonlyArray<string>) => | 624 | const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => |
| 802 | process.run({ | 625 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); |
| 803 | operation: "GitHubCli.baseRepository", | ||
| 804 | command: "git", | ||
| 805 | args, | ||
| 806 | cwd, | ||
| 807 | allowNonZeroExit: true, | ||
| 808 | timeoutMs: 5_000, | ||
| 809 | }); | ||
| 810 | 626 | ||
| 811 | /** The repository gh reads in `cwd`, or null when gh could pick it another way. */ | 627 | /** Pull requests whose head branch is `headRefName` on one repository. */ |
| 812 | const resolveBaseRepository = (cwd: string, host: string) => | 628 | const readPullRequestsByHead = (input: { |
| 813 | globalThis.process.env.GH_REPO | 629 | readonly cwd: string; |
| 814 | ? Effect.succeed(null) | 630 | readonly locator: GitHubRepositoryLocator; |
| 815 | : Effect.all([ | 631 | readonly lookups: ReadonlyArray< |
| 816 | git(cwd, ["remote", "-v"]), | 632 | Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit"> |
| 817 | git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | 633 | >; |
| 818 | ]).pipe( | 634 | readonly allowReserve: boolean; |
| 819 | Effect.map(([remotes, resolved]) => | 635 | readonly onDecodeFailure: (cause: unknown) => GitHubCliError; |
| 820 | remotes.exitCode === 0 | 636 | }) => { |
| 821 | ? selectGitHubBaseRepository({ | 637 | const query = buildPullRequestsByHeadQuery(input.lookups); |
| 822 | remotes: remotes.stdout, | 638 | return graphqlJson( |
| 823 | resolved: resolved.exitCode === 0 ? resolved.stdout : "", | 639 | input.cwd, |
| 824 | host, | 640 | { |
| 825 | }) | 641 | host: input.locator.host, |
| 826 | : null, | 642 | operation: "listPullRequestsByHead", |
| 827 | ), | 643 | query: query.document, |
| 828 | Effect.orElseSucceed(() => null), | 644 | variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, |
| 829 | ); | 645 | allowReserve: input.allowReserve, |
| 646 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | ||
| 647 | // fork pull requests, so the usual cap would cut the answer short. | ||
| 648 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | ||
| 649 | }, | ||
| 650 | decodePullRequestsByHead, | ||
| 651 | input.onDecodeFailure, | ||
| 652 | ).pipe( | ||
| 653 | Effect.flatMap((decoded) => | ||
| 654 | decoded.data.repository === null | ||
| 655 | ? Effect.fail( | ||
| 656 | new GitHubPullRequestNotFoundError({ | ||
| 657 | command: "gh", | ||
| 658 | cwd: input.cwd, | ||
| 659 | cause: new Error("The repository could not be read."), | ||
| 660 | }), | ||
| 661 | ) | ||
| 662 | : Effect.succeed(decoded.data.repository), | ||
| 663 | ), | ||
| 664 | ); | ||
| 665 | }; | ||
| 830 | 666 | ||
| 831 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | 667 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ |
| 832 | key: ({ request, context }) => | 668 | key: ({ request, context }) => |
| 833 | JSON.stringify([ | 669 | [ |
| 834 | request.host, | 670 | request.host, |
| 835 | request.owner, | 671 | request.owner, |
| 836 | request.name, | 672 | request.name, |
| 837 | Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ?? | 673 | String(request.allowReserve), |
| 838 | null, | 674 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) |
| 675 | ?.credentialFingerprint ?? "", | ||
| 839 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | 676 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 840 | ]), | 677 | ].join("\0"), |
| 841 | resolver: (entries) => { | 678 | resolver: (entries) => { |
| 842 | const [first] = entries; | 679 | const [first] = entries; |
| 843 | const { cwd, host, owner, name } = first.request; | 680 | const { cwd, host, owner, name, allowReserve } = first.request; |
| 844 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); | 681 | return readPullRequestsByHead({ |
| 845 | const readCli = (entry: (typeof entries)[number]) => | ||
| 846 | listPullRequestsWithCli({ | ||
| 847 | cwd: entry.request.cwd, | ||
| 848 | headSelector: entry.request.headRefName, | ||
| 849 | state: entry.request.state, | ||
| 850 | limit: entry.request.limit, | ||
| 851 | rateLimitHost: entry.request.host, | ||
| 852 | }).pipe( | ||
| 853 | Effect.exit, | ||
| 854 | Effect.map((exit) => entry.completeUnsafe(exit)), | ||
| 855 | ); | ||
| 856 | return guardedRead({ | ||
| 857 | cwd, | 682 | cwd, |
| 858 | host, | 683 | locator: { host, owner, name }, |
| 859 | document: query.document, | 684 | lookups: entries.map((entry) => entry.request), |
| 860 | allowReserve: false, | 685 | allowReserve, |
| 861 | run: (document) => | 686 | onDecodeFailure: (cause) => |
| 862 | executeRaw({ | 687 | new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), |
| 863 | cwd, | ||
| 864 | args: ["api", "graphql", "--hostname", host, "--input", "-"], | ||
| 865 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a | ||
| 866 | // hundred fork pull requests, so the 1 MB default would cut the answer short. | ||
| 867 | maxOutputBytes: HEAD_LOOKUP_MAX_OUTPUT_BYTES, | ||
| 868 | stdin: encodeGraphQlRequest({ | ||
| 869 | query: document, | ||
| 870 | variables: { owner, name, ...query.variables }, | ||
| 871 | }), | ||
| 872 | }).pipe(Effect.tap((result) => budget.observe(host, result.stdout))), | ||
| 873 | }).pipe( | 688 | }).pipe( |
| 874 | Effect.flatMap((result) => { | 689 | Effect.map((aliases) => { |
| 875 | const decoded = decodePullRequestsByHead(result.stdout); | 690 | for (const [index, entry] of entries.entries()) { |
| 876 | if (!Result.isSuccess(decoded)) { | 691 | const alias = aliases[`h${index}`]; |
| 877 | return Effect.forEach(entries, readCli, { discard: true }); | 692 | entry.completeUnsafe( |
| 693 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | ||
| 694 | ); | ||
| 878 | } | 695 | } |
| 879 | const aliases = decoded.success.data.repository; | ||
| 880 | return Effect.forEach( | ||
| 881 | entries, | ||
| 882 | (entry, index) => { | ||
| 883 | const alias = aliases[`h${index}`]; | ||
| 884 | if (alias == null) return readCli(entry); | ||
| 885 | entry.completeUnsafe(Exit.succeed(decodeGitHubPullRequestEntries(alias.nodes))); | ||
| 886 | return Effect.void; | ||
| 887 | }, | ||
| 888 | { discard: true }, | ||
| 889 | ); | ||
| 890 | }), | 696 | }), |
| 891 | // A document GitHub refused as a whole (a renamed repository, a field an older | ||
| 892 | // Enterprise host lacks) leaves each lookup to gh. A rate limit fails them all: | ||
| 893 | // asking one at a time would only spend what the pause is saving. | ||
| 894 | Effect.catchIf( | ||
| 895 | (error) => error._tag !== "GitHubCliRateLimitError", | ||
| 896 | () => Effect.forEach(entries, readCli, { discard: true }), | ||
| 897 | ), | ||
| 898 | Effect.catchCause((cause) => | 697 | Effect.catchCause((cause) => |
| 899 | Effect.sync(() => { | 698 | Effect.sync(() => { |
| 900 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | 699 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); |
| ... | @@ -907,174 +706,368 @@ export const make = Effect.gen(function* () { | ... | @@ -907,174 +706,368 @@ export const make = Effect.gen(function* () { |
| 907 | RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT), | 706 | RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT), |
| 908 | ); | 707 | ); |
| 909 | 708 | ||
| 910 | const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn( | 709 | const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { |
| 911 | "GitHubCli.listPullRequestsByHead", | 710 | readonly cwd: string; |
| 912 | )(function* (input) { | 711 | readonly headSelector: string; |
| 913 | const host = input.rateLimitHost?.toLowerCase(); | 712 | readonly state: PullRequestListState; |
| 914 | const credential = yield* PinnedGitHubCredential; | 713 | readonly limit: number; |
| 915 | // `owner:branch` selectors and other hosts keep gh's own handling. | 714 | readonly rateLimitHost?: string | undefined; |
| 916 | const repository = | 715 | readonly allowReserve: boolean; |
| 917 | host === undefined || | 716 | }) { |
| 918 | input.headSelector.includes(":") || | 717 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); |
| 919 | (credential !== null && credential.host !== host) | 718 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); |
| 920 | ? null | 719 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the |
| 921 | : yield* resolveBaseRepository(input.cwd, host); | 720 | // owner is matched on the rows it returns. |
| 922 | if (host === undefined || repository === null) { | 721 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); |
| 923 | return yield* listPullRequestsWithCli(input); | 722 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); |
| 924 | } | 723 | const rows = yield* Effect.request( |
| 925 | return yield* Effect.request( | ||
| 926 | new PullRequestsByHeadRead({ | 724 | new PullRequestsByHeadRead({ |
| 927 | cwd: input.cwd, | 725 | cwd: input.cwd, |
| 928 | host, | 726 | ...locator, |
| 929 | owner: repository.owner, | 727 | headRefName, |
| 930 | name: repository.name, | ||
| 931 | headRefName: input.headSelector, | ||
| 932 | state: input.state, | 728 | state: input.state, |
| 933 | limit: Math.min(Math.max(Math.trunc(input.limit), 1), 100), | 729 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, |
| 730 | allowReserve: input.allowReserve, | ||
| 934 | }), | 731 | }), |
| 935 | headResolver, | 732 | headResolver, |
| 936 | ); | 733 | ); |
| 734 | if (!ownerMatch) return rows; | ||
| 735 | const headOwner = ownerMatch[1]!.toLowerCase(); | ||
| 736 | return rows | ||
| 737 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | ||
| 738 | .slice(0, limit); | ||
| 937 | }); | 739 | }); |
| 938 | 740 | ||
| 939 | return GitHubCli.of({ | 741 | const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) => |
| 940 | execute, | 742 | rows.map(pullRequestSummary); |
| 941 | listPullRequestsByHead, | 743 | |
| 942 | listOpenPullRequests: (input) => | 744 | const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { |
| 943 | execute({ | 745 | readonly cwd: string; |
| 746 | readonly reference: string; | ||
| 747 | readonly rateLimitHost?: string | undefined; | ||
| 748 | }) { | ||
| 749 | const parsed = parsePullRequestReference(input.reference); | ||
| 750 | if (parsed.kind === "branch") { | ||
| 751 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | ||
| 752 | const [open] = yield* listByHead({ | ||
| 944 | cwd: input.cwd, | 753 | cwd: input.cwd, |
| 945 | ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), | 754 | headSelector: parsed.headSelector, |
| 755 | state: "open", | ||
| 756 | limit: 1, | ||
| 757 | rateLimitHost: input.rateLimitHost, | ||
| 946 | allowReserve: true, | 758 | allowReserve: true, |
| 947 | args: [ | 759 | }); |
| 948 | "pr", | 760 | const found = |
| 949 | "list", | 761 | open ?? |
| 950 | "--head", | 762 | (yield* listByHead({ |
| 951 | input.headSelector, | 763 | cwd: input.cwd, |
| 952 | "--state", | 764 | headSelector: parsed.headSelector, |
| 953 | "open", | 765 | state: "all", |
| 954 | "--limit", | 766 | limit: 1, |
| 955 | String(input.limit ?? 1), | 767 | rateLimitHost: input.rateLimitHost, |
| 956 | "--json", | 768 | allowReserve: true, |
| 957 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | 769 | }))[0]; |
| 958 | ], | 770 | if (found === undefined) { |
| 959 | }).pipe( | 771 | return yield* new GitHubPullRequestNotFoundError({ |
| 960 | Effect.map((result) => result.stdout.trim()), | 772 | command: "gh", |
| 961 | Effect.flatMap((raw) => | 773 | cwd: input.cwd, |
| 962 | raw.length === 0 | 774 | cause: new Error("No pull request has this head branch."), |
| 963 | ? Effect.succeed([]) | 775 | }); |
| 964 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | 776 | } |
| 965 | Effect.flatMap((decoded) => { | 777 | return found; |
| 966 | if (!Result.isSuccess(decoded)) { | 778 | } |
| 967 | return Effect.fail( | 779 | const locator = |
| 968 | new GitHubPullRequestListDecodeError({ | 780 | parsed.kind === "url" |
| 969 | command: "gh", | 781 | ? parsed.locator |
| 970 | cwd: input.cwd, | 782 | : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); |
| 971 | cause: decoded.failure, | 783 | const decodeFailure = (cause: unknown) => |
| 972 | }), | 784 | new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); |
| 973 | ); | 785 | const decoded = yield* graphqlJson( |
| 974 | } | 786 | input.cwd, |
| 975 | 787 | { | |
| 976 | return Effect.succeed(decoded.success.map(pullRequestSummary)); | 788 | host: locator.host, |
| 977 | }), | 789 | operation: "getPullRequest", |
| 978 | ), | 790 | query: PULL_REQUEST_BY_NUMBER_QUERY, |
| 979 | ), | 791 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, |
| 980 | ), | ||
| 981 | getPullRequest: (input) => | ||
| 982 | execute({ | ||
| 983 | cwd: input.cwd, | ||
| 984 | ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), | ||
| 985 | allowReserve: true, | 792 | allowReserve: true, |
| 986 | args: [ | 793 | }, |
| 987 | "pr", | 794 | decodePullRequestByNumber, |
| 988 | "view", | 795 | decodeFailure, |
| 989 | input.reference, | 796 | ); |
| 990 | "--json", | 797 | const node = decoded.data.repository?.pullRequest; |
| 991 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | 798 | if (node == null) { |
| 992 | ], | 799 | return yield* new GitHubPullRequestNotFoundError({ |
| 993 | }).pipe( | 800 | command: "gh", |
| 994 | Effect.map((result) => result.stdout.trim()), | ||
| 995 | Effect.flatMap((raw) => | ||
| 996 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | ||
| 997 | Effect.flatMap((decoded) => { | ||
| 998 | if (!Result.isSuccess(decoded)) { | ||
| 999 | return Effect.fail( | ||
| 1000 | new GitHubPullRequestDecodeError({ | ||
| 1001 | command: "gh", | ||
| 1002 | cwd: input.cwd, | ||
| 1003 | cause: decoded.failure, | ||
| 1004 | }), | ||
| 1005 | ); | ||
| 1006 | } | ||
| 1007 | |||
| 1008 | return Effect.succeed(pullRequestSummary(decoded.success)); | ||
| 1009 | }), | ||
| 1010 | ), | ||
| 1011 | ), | ||
| 1012 | ), | ||
| 1013 | getRepositoryCloneUrls: (input) => | ||
| 1014 | execute({ | ||
| 1015 | cwd: input.cwd, | ||
| 1016 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | ||
| 1017 | }).pipe( | ||
| 1018 | Effect.map((result) => result.stdout.trim()), | ||
| 1019 | Effect.flatMap((raw) => | ||
| 1020 | decodeRawGitHubRepositoryCloneUrls(raw).pipe( | ||
| 1021 | Effect.mapError( | ||
| 1022 | (cause) => | ||
| 1023 | new GitHubRepositoryDecodeError({ | ||
| 1024 | command: "gh", | ||
| 1025 | cwd: input.cwd, | ||
| 1026 | cause, | ||
| 1027 | }), | ||
| 1028 | ), | ||
| 1029 | ), | ||
| 1030 | ), | ||
| 1031 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 1032 | ), | ||
| 1033 | createRepository: (input) => | ||
| 1034 | execute({ | ||
| 1035 | cwd: input.cwd, | 801 | cwd: input.cwd, |
| 1036 | args: ["repo", "create", input.repository, `--${input.visibility}`], | 802 | cause: new Error("The pull request does not exist."), |
| 1037 | }).pipe( | 803 | }); |
| 1038 | Effect.map((result) => | 804 | } |
| 1039 | deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), | 805 | const [record] = decodeGitHubPullRequestEntries([node]); |
| 1040 | ), | 806 | if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); |
| 1041 | ), | 807 | return record; |
| 1042 | createPullRequest: (input) => | 808 | }); |
| 1043 | execute({ | 809 | |
| 810 | const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( | ||
| 811 | cwd: string, | ||
| 812 | locator: GitHubRepositoryLocator, | ||
| 813 | ) { | ||
| 814 | const response = yield* rest(cwd, { | ||
| 815 | host: locator.host, | ||
| 816 | operation: "getRepository", | ||
| 817 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | ||
| 818 | allowReserve: true, | ||
| 819 | }); | ||
| 820 | const decoded = decodeRawRepository(response.body); | ||
| 821 | if (Result.isFailure(decoded)) { | ||
| 822 | return yield* new GitHubRepositoryDecodeError({ | ||
| 823 | command: "gh", | ||
| 824 | cwd, | ||
| 825 | cause: decoded.failure, | ||
| 826 | }); | ||
| 827 | } | ||
| 828 | return decoded.success; | ||
| 829 | }); | ||
| 830 | |||
| 831 | const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( | ||
| 832 | cwd: string, | ||
| 833 | host: string, | ||
| 834 | ) { | ||
| 835 | const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); | ||
| 836 | const decoded = decodeViewerLogin(response.body); | ||
| 837 | if (Result.isFailure(decoded)) { | ||
| 838 | return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); | ||
| 839 | } | ||
| 840 | return decoded.success.login; | ||
| 841 | }); | ||
| 842 | |||
| 843 | const gitFailure = (cwd: string) => (cause: unknown) => | ||
| 844 | new GitHubCliCommandError({ command: "gh", cwd, cause }); | ||
| 845 | |||
| 846 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | ||
| 847 | git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); | ||
| 848 | |||
| 849 | /** | ||
| 850 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | ||
| 851 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | ||
| 852 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | ||
| 853 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | ||
| 854 | */ | ||
| 855 | const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( | ||
| 856 | "GitHubCli.checkoutPullRequest", | ||
| 857 | )(function* (input) { | ||
| 858 | const reference = parsePullRequestReference(input.reference); | ||
| 859 | const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); | ||
| 860 | const base = | ||
| 861 | reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); | ||
| 862 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | ||
| 863 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | ||
| 864 | const isCrossRepository = | ||
| 865 | pullRequest.isCrossRepository ?? | ||
| 866 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | ||
| 867 | const headOwner = | ||
| 868 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | ||
| 869 | |||
| 870 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | ||
| 871 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | ||
| 872 | Effect.mapError(gitFailure(input.cwd)), | ||
| 873 | ); | ||
| 874 | const remoteFor = (nameWithOwner: string) => | ||
| 875 | remotes | ||
| 876 | .split("\n") | ||
| 877 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | ||
| 878 | .find( | ||
| 879 | (match) => | ||
| 880 | match !== null && | ||
| 881 | normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === | ||
| 882 | nameWithOwner.toLowerCase(), | ||
| 883 | )?.[1] ?? null; | ||
| 884 | const baseRemote = Effect.suspend(() => { | ||
| 885 | const known = remoteFor(baseNameWithOwner); | ||
| 886 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | ||
| 887 | }); | ||
| 888 | // A fork's branch named like the base's default branch is checked out under the owner's | ||
| 889 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | ||
| 890 | // would reset the local default branch to the fork's commit, so it fails instead. | ||
| 891 | const defaultBranch = isCrossRepository | ||
| 892 | ? yield* readRepository(input.cwd, base).pipe( | ||
| 893 | Effect.map((repository) => repository.default_branch ?? null), | ||
| 894 | ) | ||
| 895 | : null; | ||
| 896 | const localBranch = pullRequestCheckoutBranchName({ | ||
| 897 | headRefName: pullRequest.headRefName, | ||
| 898 | headOwner, | ||
| 899 | isCrossRepository, | ||
| 900 | defaultBranch, | ||
| 901 | }); | ||
| 902 | |||
| 903 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | ||
| 904 | const headRemote = Effect.gen(function* () { | ||
| 905 | if (!isCrossRepository) return yield* baseRemote; | ||
| 906 | if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); | ||
| 907 | const known = remoteFor(headNameWithOwner); | ||
| 908 | if (known !== null) return known; | ||
| 909 | const [owner, name] = headNameWithOwner.split("/"); | ||
| 910 | const fork = yield* readRepository(input.cwd, { | ||
| 911 | host: base.host, | ||
| 912 | owner: owner!, | ||
| 913 | name: name!, | ||
| 914 | }); | ||
| 915 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | ||
| 916 | return yield* git.ensureRemote({ | ||
| 1044 | cwd: input.cwd, | 917 | cwd: input.cwd, |
| 1045 | args: [ | 918 | preferredName: headOwner ?? "fork", |
| 1046 | "pr", | 919 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, |
| 1047 | "create", | 920 | }); |
| 1048 | "--base", | 921 | }); |
| 1049 | input.baseBranch, | 922 | |
| 1050 | "--head", | 923 | const exists = (yield* git |
| 1051 | input.headSelector, | 924 | .listLocalBranchNames(input.cwd) |
| 1052 | "--title", | 925 | .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); |
| 1053 | input.title, | 926 | |
| 1054 | "--body-file", | 927 | // The commit to check out, fetched from the head branch where it still exists. |
| 1055 | input.bodyFile, | 928 | const target = yield* Effect.gen(function* () { |
| 1056 | ], | 929 | const remoteName = yield* headRemote; |
| 1057 | }).pipe(Effect.asVoid), | 930 | yield* git.fetchRemoteTrackingBranch({ |
| 1058 | getDefaultBranch: (input) => | ||
| 1059 | execute({ | ||
| 1060 | cwd: input.cwd, | 931 | cwd: input.cwd, |
| 1061 | ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), | 932 | remoteName, |
| 1062 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | 933 | remoteBranch: pullRequest.headRefName, |
| 1063 | }).pipe( | 934 | }); |
| 1064 | Effect.map((value) => { | 935 | return { |
| 1065 | const trimmed = value.stdout.trim(); | 936 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, |
| 1066 | return trimmed.length > 0 ? trimmed : null; | 937 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, |
| 938 | }; | ||
| 939 | }).pipe( | ||
| 940 | Effect.catch(() => | ||
| 941 | Effect.gen(function* () { | ||
| 942 | yield* runGit(input.cwd, "fetchPullRef", [ | ||
| 943 | "fetch", | ||
| 944 | "--quiet", | ||
| 945 | "--no-tags", | ||
| 946 | yield* baseRemote, | ||
| 947 | `refs/pull/${pullRequest.number}/head`, | ||
| 948 | ]); | ||
| 949 | const { commitSha } = yield* git.resolveCommit({ | ||
| 950 | cwd: input.cwd, | ||
| 951 | revision: "FETCH_HEAD", | ||
| 952 | }); | ||
| 953 | return { ref: commitSha, upstream: null }; | ||
| 1067 | }), | 954 | }), |
| 1068 | ), | 955 | ), |
| 1069 | checkoutPullRequest: (input) => | 956 | Effect.mapError(gitFailure(input.cwd)), |
| 1070 | execute({ | 957 | ); |
| 958 | |||
| 959 | yield* Effect.gen(function* () { | ||
| 960 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | ||
| 961 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | ||
| 962 | if (exists) { | ||
| 963 | yield* runGit( | ||
| 964 | input.cwd, | ||
| 965 | "sync", | ||
| 966 | input.force === true | ||
| 967 | ? ["reset", "--hard", "--quiet", target.ref] | ||
| 968 | : ["merge", "--ff-only", "--quiet", target.ref], | ||
| 969 | ); | ||
| 970 | } | ||
| 971 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | ||
| 972 | // existing branch's upstream as it was. | ||
| 973 | if (target.upstream !== null) { | ||
| 974 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | ||
| 975 | } | ||
| 976 | }).pipe(Effect.mapError(gitFailure(input.cwd))); | ||
| 977 | }); | ||
| 978 | |||
| 979 | return GitHubCli.of({ | ||
| 980 | listPullRequestsByHead: (input) => | ||
| 981 | AllowGitHubReserve.pipe( | ||
| 982 | Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), | ||
| 983 | ), | ||
| 984 | listOpenPullRequests: (input) => | ||
| 985 | listByHead({ | ||
| 1071 | cwd: input.cwd, | 986 | cwd: input.cwd, |
| 1072 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], | 987 | headSelector: input.headSelector, |
| 1073 | }).pipe(Effect.asVoid), | 988 | state: "open", |
| 989 | limit: input.limit ?? 1, | ||
| 990 | rateLimitHost: input.rateLimitHost, | ||
| 991 | allowReserve: true, | ||
| 992 | }).pipe(Effect.map(toSummaries)), | ||
| 993 | getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), | ||
| 994 | getRepositoryCloneUrls: (input) => | ||
| 995 | Effect.gen(function* () { | ||
| 996 | const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( | ||
| 997 | Effect.map((locator) => locator.host), | ||
| 998 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | ||
| 999 | )).toLowerCase(); | ||
| 1000 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | ||
| 1001 | if (locator === null) { | ||
| 1002 | return yield* commandFailure(input.cwd, "Repositories are named owner/name."); | ||
| 1003 | } | ||
| 1004 | return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); | ||
| 1005 | }), | ||
| 1006 | createRepository: (input) => | ||
| 1007 | Effect.gen(function* () { | ||
| 1008 | const locator = parseGitHubRepositorySelector( | ||
| 1009 | input.repository, | ||
| 1010 | (environment.GH_HOST ?? "github.com").toLowerCase(), | ||
| 1011 | ); | ||
| 1012 | const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); | ||
| 1013 | const owner = locator?.owner ?? viewer; | ||
| 1014 | const name = locator?.name ?? input.repository.trim(); | ||
| 1015 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | ||
| 1016 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | ||
| 1017 | const response = yield* rest(input.cwd, { | ||
| 1018 | host, | ||
| 1019 | operation: "createRepository", | ||
| 1020 | method: "POST", | ||
| 1021 | path: | ||
| 1022 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | ||
| 1023 | body: { name, private: input.visibility === "private" }, | ||
| 1024 | }); | ||
| 1025 | const decoded = decodeRawRepository(response.body); | ||
| 1026 | if (Result.isFailure(decoded)) { | ||
| 1027 | return yield* new GitHubRepositoryDecodeError({ | ||
| 1028 | command: "gh", | ||
| 1029 | cwd: input.cwd, | ||
| 1030 | cause: decoded.failure, | ||
| 1031 | }); | ||
| 1032 | } | ||
| 1033 | return repositoryCloneUrls(decoded.success); | ||
| 1034 | }), | ||
| 1035 | createPullRequest: (input) => | ||
| 1036 | Effect.gen(function* () { | ||
| 1037 | const locator = yield* resolveRepository({ cwd: input.cwd }); | ||
| 1038 | const body = yield* fileSystem | ||
| 1039 | .readFileString(input.bodyFile) | ||
| 1040 | .pipe(Effect.mapError(gitFailure(input.cwd))); | ||
| 1041 | yield* rest(input.cwd, { | ||
| 1042 | host: locator.host, | ||
| 1043 | operation: "createPullRequest", | ||
| 1044 | method: "POST", | ||
| 1045 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | ||
| 1046 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | ||
| 1047 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | ||
| 1048 | body: { | ||
| 1049 | base: input.baseBranch, | ||
| 1050 | head: input.headSelector, | ||
| 1051 | title: input.title, | ||
| 1052 | body, | ||
| 1053 | maintainer_can_modify: true, | ||
| 1054 | }, | ||
| 1055 | }); | ||
| 1056 | }), | ||
| 1057 | getDefaultBranch: (input) => | ||
| 1058 | Effect.gen(function* () { | ||
| 1059 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | ||
| 1060 | const repository = yield* readRepository(input.cwd, locator); | ||
| 1061 | const branch = repository.default_branch?.trim() ?? ""; | ||
| 1062 | return branch.length > 0 ? branch : null; | ||
| 1063 | }), | ||
| 1064 | checkoutPullRequest, | ||
| 1074 | }); | 1065 | }); |
| 1075 | }); | 1066 | }); |
| 1076 | 1067 | ||
| 1077 | export const layer = Layer.effect(GitHubCli, make).pipe( | 1068 | export const layer = Layer.effect(GitHubCli, make).pipe( |
| 1069 | Layer.provideMerge(GitHubApi.layer), | ||
| 1070 | Layer.provideMerge(GitHubCredentials.layer), | ||
| 1078 | Layer.provideMerge(GitHubGraphQlBudget.layer), | 1071 | Layer.provideMerge(GitHubGraphQlBudget.layer), |
| 1079 | Layer.provideMerge(SourceControlRateLimit.layer), | 1072 | Layer.provideMerge(SourceControlRateLimit.layer), |
| 1080 | ); | 1073 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+44-64| ... | @@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; | ||
| 6 | import { ChildProcessSpawner } from "effect/process"; | 7 | import { ChildProcessSpawner } from "effect/process"; |
| 7 | 8 | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 9 | import * as GitHubCli from "./GitHubCli.ts"; | 11 | import * as GitHubCli from "./GitHubCli.ts"; |
| 10 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | ||
| 11 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | 12 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 12 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 13 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 13 | 14 | ||
| ... | @@ -25,37 +26,26 @@ const processResult = ( | ... | @@ -25,37 +26,26 @@ const processResult = ( |
| 25 | stderrTruncated: false, | 26 | stderrTruncated: false, |
| 26 | }); | 27 | }); |
| 27 | 28 | ||
| 28 | function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) { | 29 | function makeProvider( |
| 30 | github: Partial<GitHubCli.GitHubCli["Service"]>, | ||
| 31 | api: Partial<GitHubApi.GitHubApi["Service"]> = {}, | ||
| 32 | ) { | ||
| 29 | return GitHubSourceControlProvider.make.pipe( | 33 | return GitHubSourceControlProvider.make.pipe( |
| 30 | Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), | 34 | Effect.provide( |
| 35 | Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), | ||
| 36 | ), | ||
| 31 | ); | 37 | ); |
| 32 | } | 38 | } |
| 33 | 39 | ||
| 34 | it.effect("uses the enterprise quota for a current-repository default branch read", () => | 40 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 35 | Effect.gen(function* () { | 41 | |
| 36 | // github.com is out of quota; the enterprise read must not be priced against it. | 42 | const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ |
| 37 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | 43 | status: 200, |
| 38 | yield* budget.observe( | 44 | headers: {}, |
| 39 | "github.com", | 45 | body, |
| 40 | '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}', | 46 | truncated: false, |
| 41 | ); | 47 | invalidUtf8: false, |
| 42 | const provider = yield* GitHubSourceControlProvider.make; | 48 | }); |
| 43 | const branch = yield* provider.getDefaultBranch({ | ||
| 44 | cwd: "/enterprise-repo", | ||
| 45 | context: { | ||
| 46 | provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, | ||
| 47 | remoteName: "origin", | ||
| 48 | remoteUrl: "https://enterprise.test/acme/web.git", | ||
| 49 | }, | ||
| 50 | }); | ||
| 51 | assert.strictEqual(branch, "main"); | ||
| 52 | }).pipe( | ||
| 53 | Effect.provide(GitHubCli.layer), | ||
| 54 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 55 | run: () => Effect.succeed(processResult("main")), | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | 49 | ||
| 60 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | 50 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => |
| 61 | Effect.gen(function* () { | 51 | Effect.gen(function* () { |
| ... | @@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])( | ... | @@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])( |
| 412 | "resolves %s subjects on the linked host without using the checkout", | 402 | "resolves %s subjects on the linked host without using the checkout", |
| 413 | (kind) => | 403 | (kind) => |
| 414 | Effect.gen(function* () { | 404 | Effect.gen(function* () { |
| 415 | const provider = yield* makeProvider({ | 405 | const provider = yield* makeProvider( |
| 416 | execute: (input) => { | 406 | {}, |
| 417 | assert.deepStrictEqual(input.args, [ | 407 | { |
| 418 | "api", | 408 | rest: (input) => { |
| 419 | "--hostname", | 409 | assert.strictEqual(input.host, "github.com"); |
| 420 | "github.com", | 410 | assert.strictEqual(input.path, "repos/owner/repo/issues/42"); |
| 421 | "repos/owner/repo/issues/42", | 411 | return Effect.succeed( |
| 422 | "--jq", | 412 | restResponse( |
| 423 | "{title, body}", | 413 | encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), |
| 424 | ]); | 414 | ), |
| 425 | assert.strictEqual(input.maxOutputBytes, 32_000); | 415 | ); |
| 426 | assert.strictEqual(input.timeoutMs, 3_000); | 416 | }, |
| 427 | return Effect.succeed({ | ||
| 428 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 429 | stdout: JSON.stringify({ title: "Pairing expiry", body: "Preserve remote access" }), | ||
| 430 | stderr: "", | ||
| 431 | stdoutTruncated: false, | ||
| 432 | stderrTruncated: false, | ||
| 433 | }); | ||
| 434 | }, | 417 | }, |
| 435 | }); | 418 | ); |
| 436 | const lookup = provider.resolveLink?.({ | 419 | const lookup = provider.resolveLink?.({ |
| 437 | cwd: "/unrelated", | 420 | cwd: "/unrelated", |
| 438 | url: new URL(`https://github.com/owner/repo/${kind}/42`), | 421 | url: new URL(`https://github.com/owner/repo/${kind}/42`), |
| ... | @@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)( | ... | @@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)( |
| 456 | "retains the %s failure without exposing its raw contents", | 439 | "retains the %s failure without exposing its raw contents", |
| 457 | (stage) => | 440 | (stage) => |
| 458 | Effect.gen(function* () { | 441 | Effect.gen(function* () { |
| 459 | const cause = new GitHubCli.GitHubCliCommandError({ | 442 | const cause = new GitHubApi.GitHubApiResponseError({ |
| 460 | command: "gh", | 443 | host: "github.com", |
| 461 | cwd: "/repo", | 444 | operation: "resolveLink", |
| 462 | cause: new Error("private response text"), | 445 | status: 500, |
| 463 | }); | ||
| 464 | const provider = yield* makeProvider({ | ||
| 465 | execute: () => | ||
| 466 | stage === "read" | ||
| 467 | ? Effect.fail(cause) | ||
| 468 | : Effect.succeed({ | ||
| 469 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 470 | stdout: "private response text", | ||
| 471 | stderr: "", | ||
| 472 | stdoutTruncated: false, | ||
| 473 | stderrTruncated: false, | ||
| 474 | }), | ||
| 475 | }); | 446 | }); |
| 447 | const provider = yield* makeProvider( | ||
| 448 | {}, | ||
| 449 | { | ||
| 450 | rest: () => | ||
| 451 | stage === "read" | ||
| 452 | ? Effect.fail(cause) | ||
| 453 | : Effect.succeed(restResponse("private response text")), | ||
| 454 | }, | ||
| 455 | ); | ||
| 476 | const lookup = provider.resolveLink?.({ | 456 | const lookup = provider.resolveLink?.({ |
| 477 | cwd: "/repo", | 457 | cwd: "/repo", |
| 478 | url: new URL("https://github.com/owner/repo/issues/42"), | 458 | url: new URL("https://github.com/owner/repo/issues/42"), |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+92-19| ... | @@ -2,22 +2,33 @@ import * as Schema from "effect/Schema"; | ... | @@ -2,22 +2,33 @@ import * as Schema from "effect/Schema"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | 5 | import * as Result from "effect/Result"; |
| 6 | import { | ||
| 7 | SourceControlProviderError, | ||
| 8 | type ChangeRequest, | ||
| 9 | type SourceControlProviderDiscoveryItem, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 6 | 13 | ||
| 14 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 7 | import * as GitHubCli from "./GitHubCli.ts"; | 15 | import * as GitHubCli from "./GitHubCli.ts"; |
| 8 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | 16 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 9 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 17 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 10 | import { | 18 | import { |
| 11 | combinedAuthOutput, | 19 | combinedAuthOutput, |
| 12 | firstSafeAuthLine, | 20 | firstSafeAuthLine, |
| 21 | probeSourceControlProvider, | ||
| 13 | providerAuth, | 22 | providerAuth, |
| 14 | type SourceControlAuthProbeInput, | 23 | type SourceControlAuthProbeInput, |
| 15 | type SourceControlCliDiscoverySpec, | 24 | type SourceControlCliDiscoverySpec, |
| 25 | type SourceControlManagedCliDiscoverySpec, | ||
| 16 | } from "./SourceControlProviderDiscovery.ts"; | 26 | } from "./SourceControlProviderDiscovery.ts"; |
| 27 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 17 | 28 | ||
| 18 | const decodeLinkSubject = Schema.decodeUnknownEffect( | 29 | const decodeLinkSubject = Schema.decodeUnknownEffect( |
| 19 | Schema.fromJsonString( | 30 | Schema.fromJsonString( |
| 20 | Schema.Struct({ title: Schema.String, body: Schema.NullOr(Schema.String) }), | 31 | Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), |
| 21 | ), | 32 | ), |
| 22 | ); | 33 | ); |
| 23 | 34 | ||
| ... | @@ -111,8 +122,70 @@ export const discovery = { | ... | @@ -111,8 +122,70 @@ export const discovery = { |
| 111 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", | 122 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", |
| 112 | } satisfies SourceControlCliDiscoverySpec; | 123 | } satisfies SourceControlCliDiscoverySpec; |
| 113 | 124 | ||
| 125 | const decodeViewer = Schema.decodeUnknownOption( | ||
| 126 | Schema.fromJsonString(Schema.Struct({ login: Schema.String })), | ||
| 127 | ); | ||
| 128 | |||
| 129 | /** The environment variable gh would take a github.com token from, if one is set. */ | ||
| 130 | function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { | ||
| 131 | return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; | ||
| 132 | } | ||
| 133 | |||
| 134 | /** | ||
| 135 | * GitHub is usable with a token from the environment or with `gh` to hand one over. An | ||
| 136 | * environment token is checked against the API, since `gh auth status` may not know it. | ||
| 137 | */ | ||
| 138 | export const makeDiscovery = Effect.gen(function* () { | ||
| 139 | const api = yield* GitHubApi.GitHubApi; | ||
| 140 | const process = yield* VcsProcess.VcsProcess; | ||
| 141 | const environment = yield* HostProcessEnvironment; | ||
| 142 | return { | ||
| 143 | type: "managed-cli", | ||
| 144 | kind: discovery.kind, | ||
| 145 | label: discovery.label, | ||
| 146 | installHint: discovery.installHint, | ||
| 147 | probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { | ||
| 148 | const cli = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); | ||
| 149 | const variable = environmentTokenVariable(environment); | ||
| 150 | if (variable === null) return cli; | ||
| 151 | const viewer = yield* api | ||
| 152 | .rest({ host: "github.com", operation: "discovery", path: "user" }) | ||
| 153 | .pipe(Effect.result); | ||
| 154 | const login = Result.isSuccess(viewer) | ||
| 155 | ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login | ||
| 156 | : undefined; | ||
| 157 | return { | ||
| 158 | ...cli, | ||
| 159 | status: "available" as const, | ||
| 160 | auth: | ||
| 161 | login !== undefined | ||
| 162 | ? providerAuth({ | ||
| 163 | status: "authenticated", | ||
| 164 | account: login, | ||
| 165 | host: "github.com", | ||
| 166 | detail: `Using the token in ${variable} from the server environment.`, | ||
| 167 | }) | ||
| 168 | : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" | ||
| 169 | ? // Only a refusal says the token is bad; a network error or a pause says nothing. | ||
| 170 | providerAuth({ | ||
| 171 | status: "unknown", | ||
| 172 | host: "github.com", | ||
| 173 | detail: `Could not check the token in ${variable}: ${viewer.failure.message}`, | ||
| 174 | }) | ||
| 175 | : providerAuth({ | ||
| 176 | status: "unauthenticated", | ||
| 177 | host: "github.com", | ||
| 178 | detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`, | ||
| 179 | }), | ||
| 180 | } satisfies SourceControlProviderDiscoveryItem; | ||
| 181 | }), | ||
| 182 | refineUnknownRemote: () => Effect.succeed(null), | ||
| 183 | } satisfies SourceControlManagedCliDiscoverySpec; | ||
| 184 | }); | ||
| 185 | |||
| 114 | export const make = Effect.gen(function* () { | 186 | export const make = Effect.gen(function* () { |
| 115 | const github = yield* GitHubCli.GitHubCli; | 187 | const github = yield* GitHubCli.GitHubCli; |
| 188 | const api = yield* GitHubApi.GitHubApi; | ||
| 116 | 189 | ||
| 117 | const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = | 190 | const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = |
| 118 | (input) => { | 191 | (input) => { |
| ... | @@ -138,7 +211,7 @@ export const make = Effect.gen(function* () { | ... | @@ -138,7 +211,7 @@ export const make = Effect.gen(function* () { |
| 138 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | 211 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 139 | input.headSelector, | 212 | input.headSelector, |
| 140 | ), | 213 | ), |
| 141 | detail: error.detail, | 214 | detail: error.message, |
| 142 | cause: error, | 215 | cause: error, |
| 143 | }), | 216 | }), |
| 144 | ), | 217 | ), |
| ... | @@ -177,7 +250,7 @@ export const make = Effect.gen(function* () { | ... | @@ -177,7 +250,7 @@ export const make = Effect.gen(function* () { |
| 177 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | 250 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 178 | input.headSelector, | 251 | input.headSelector, |
| 179 | ), | 252 | ), |
| 180 | detail: error.detail, | 253 | detail: error.message, |
| 181 | cause: error, | 254 | cause: error, |
| 182 | }), | 255 | }), |
| 183 | ), | 256 | ), |
| ... | @@ -188,15 +261,15 @@ export const make = Effect.gen(function* () { | ... | @@ -188,15 +261,15 @@ export const make = Effect.gen(function* () { |
| 188 | input: { readonly cwd: string; readonly url: URL }, | 261 | input: { readonly cwd: string; readonly url: URL }, |
| 189 | endpoint: string, | 262 | endpoint: string, |
| 190 | ) { | 263 | ) { |
| 191 | const result = yield* github | 264 | const result = yield* api |
| 192 | .execute({ | 265 | .rest({ |
| 193 | cwd: input.cwd, | 266 | host: input.url.host, |
| 194 | args: ["api", "--hostname", input.url.host, endpoint, "--jq", "{title, body}"], | 267 | operation: "resolveLink", |
| 195 | env: { GH_PROMPT_DISABLED: "1" }, | 268 | path: endpoint, |
| 196 | timeoutMs: 3_000, | 269 | maxResponseBytes: 1_000_000, |
| 197 | maxOutputBytes: 32_000, | ||
| 198 | }) | 270 | }) |
| 199 | .pipe( | 271 | .pipe( |
| 272 | Effect.timeout("3 seconds"), | ||
| 200 | Effect.mapError( | 273 | Effect.mapError( |
| 201 | (cause) => | 274 | (cause) => |
| 202 | new SourceControlProviderError({ | 275 | new SourceControlProviderError({ |
| ... | @@ -208,7 +281,7 @@ export const make = Effect.gen(function* () { | ... | @@ -208,7 +281,7 @@ export const make = Effect.gen(function* () { |
| 208 | }), | 281 | }), |
| 209 | ), | 282 | ), |
| 210 | ); | 283 | ); |
| 211 | const subject = yield* decodeLinkSubject(result.stdout).pipe( | 284 | const subject = yield* decodeLinkSubject(result.body).pipe( |
| 212 | Effect.mapError( | 285 | Effect.mapError( |
| 213 | (cause) => | 286 | (cause) => |
| 214 | new SourceControlProviderError({ | 287 | new SourceControlProviderError({ |
| ... | @@ -220,7 +293,7 @@ export const make = Effect.gen(function* () { | ... | @@ -220,7 +293,7 @@ export const make = Effect.gen(function* () { |
| 220 | }), | 293 | }), |
| 221 | ), | 294 | ), |
| 222 | ); | 295 | ); |
| 223 | return { title: subject.title, body: subject.body }; | 296 | return { title: subject.title, body: subject.body ?? null }; |
| 224 | }); | 297 | }); |
| 225 | 298 | ||
| 226 | return SourceControlProvider.SourceControlProvider.of({ | 299 | return SourceControlProvider.SourceControlProvider.of({ |
| ... | @@ -255,7 +328,7 @@ export const make = Effect.gen(function* () { | ... | @@ -255,7 +328,7 @@ export const make = Effect.gen(function* () { |
| 255 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | 328 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 256 | input.reference, | 329 | input.reference, |
| 257 | ), | 330 | ), |
| 258 | detail: error.detail, | 331 | detail: error.message, |
| 259 | cause: error, | 332 | cause: error, |
| 260 | }), | 333 | }), |
| 261 | ), | 334 | ), |
| ... | @@ -280,7 +353,7 @@ export const make = Effect.gen(function* () { | ... | @@ -280,7 +353,7 @@ export const make = Effect.gen(function* () { |
| 280 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | 353 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 281 | input.headSelector, | 354 | input.headSelector, |
| 282 | ), | 355 | ), |
| 283 | detail: error.detail, | 356 | detail: error.message, |
| 284 | cause: error, | 357 | cause: error, |
| 285 | }), | 358 | }), |
| 286 | ), | 359 | ), |
| ... | @@ -297,7 +370,7 @@ export const make = Effect.gen(function* () { | ... | @@ -297,7 +370,7 @@ export const make = Effect.gen(function* () { |
| 297 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | 370 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 298 | input.repository, | 371 | input.repository, |
| 299 | ), | 372 | ), |
| 300 | detail: error.detail, | 373 | detail: error.message, |
| 301 | cause: error, | 374 | cause: error, |
| 302 | }), | 375 | }), |
| 303 | ), | 376 | ), |
| ... | @@ -314,7 +387,7 @@ export const make = Effect.gen(function* () { | ... | @@ -314,7 +387,7 @@ export const make = Effect.gen(function* () { |
| 314 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | 387 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 315 | input.repository, | 388 | input.repository, |
| 316 | ), | 389 | ), |
| 317 | detail: error.detail, | 390 | detail: error.message, |
| 318 | cause: error, | 391 | cause: error, |
| 319 | }), | 392 | }), |
| 320 | ), | 393 | ), |
| ... | @@ -335,7 +408,7 @@ export const make = Effect.gen(function* () { | ... | @@ -335,7 +408,7 @@ export const make = Effect.gen(function* () { |
| 335 | operation: "getDefaultBranch", | 408 | operation: "getDefaultBranch", |
| 336 | command: error.command, | 409 | command: error.command, |
| 337 | cwd: input.cwd, | 410 | cwd: input.cwd, |
| 338 | detail: error.detail, | 411 | detail: error.message, |
| 339 | cause: error, | 412 | cause: error, |
| 340 | }), | 413 | }), |
| 341 | ), | 414 | ), |
| ... | @@ -352,7 +425,7 @@ export const make = Effect.gen(function* () { | ... | @@ -352,7 +425,7 @@ export const make = Effect.gen(function* () { |
| 352 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | 425 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 353 | input.reference, | 426 | input.reference, |
| 354 | ), | 427 | ), |
| 355 | detail: error.detail, | 428 | detail: error.message, |
| 356 | cause: error, | 429 | cause: error, |
| 357 | }), | 430 | }), |
| 358 | ), | 431 | ), |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2| ... | @@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ... | @@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 17 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | 17 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 18 | import * as BitbucketApi from "./BitbucketApi.ts"; | 18 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 19 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 19 | import * as GitHubCli from "./GitHubCli.ts"; | 20 | import * as GitHubCli from "./GitHubCli.ts"; |
| 20 | import * as GitLabCli from "./GitLabCli.ts"; | 21 | import * as GitLabCli from "./GitLabCli.ts"; |
| 21 | import * as ForgejoCli from "./ForgejoCli.ts"; | 22 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| ... | @@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: { | ... | @@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 38 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | 39 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 39 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), | 40 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 40 | Layer.mock(GitHubCli.GitHubCli)({}), | 41 | Layer.mock(GitHubCli.GitHubCli)({}), |
| 42 | Layer.mock(GitHubApi.GitHubApi)({}), | ||
| 41 | Layer.mock(GitLabCli.GitLabCli)({}), | 43 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 42 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 44 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 43 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | 45 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+12-3| ... | @@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ... | @@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 14 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | 14 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 15 | import * as BitbucketApi from "./BitbucketApi.ts"; | 15 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 16 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 16 | import * as GitHubCli from "./GitHubCli.ts"; | 17 | import * as GitHubCli from "./GitHubCli.ts"; |
| 17 | import * as GitLabCli from "./GitLabCli.ts"; | 18 | import * as GitLabCli from "./GitLabCli.ts"; |
| 18 | import * as ForgejoCli from "./ForgejoCli.ts"; | 19 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| ... | @@ -41,6 +42,7 @@ function makeRegistry(input: { | ... | @@ -41,6 +42,7 @@ function makeRegistry(input: { |
| 41 | }>; | 42 | }>; |
| 42 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; | 43 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 43 | readonly github?: Partial<GitHubCli.GitHubCli["Service"]>; | 44 | readonly github?: Partial<GitHubCli.GitHubCli["Service"]>; |
| 45 | readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>; | ||
| 44 | readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>; | 46 | readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>; |
| 45 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; | 47 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; |
| 46 | }) { | 48 | }) { |
| ... | @@ -95,6 +97,7 @@ function makeRegistry(input: { | ... | @@ -95,6 +97,7 @@ function makeRegistry(input: { |
| 95 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | 97 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 96 | Layer.mock(BitbucketApi.BitbucketApi)({}), | 98 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 97 | Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), | 99 | Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), |
| 100 | Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), | ||
| 98 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), | 101 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 99 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 102 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 100 | ServerConfig.layerTest(process.cwd(), { | 103 | ServerConfig.layerTest(process.cwd(), { |
| ... | @@ -305,9 +308,15 @@ it.effect( | ... | @@ -305,9 +308,15 @@ it.effect( |
| 305 | Effect.gen(function* () { | 308 | Effect.gen(function* () { |
| 306 | const registry = yield* makeRegistry({ | 309 | const registry = yield* makeRegistry({ |
| 307 | remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], | 310 | remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], |
| 308 | github: { | 311 | githubApi: { |
| 309 | execute: () => | 312 | rest: () => |
| 310 | Effect.succeed(processOutput(JSON.stringify({ title: "GitHub issue", body: null }))), | 313 | Effect.succeed({ |
| 314 | status: 200, | ||
| 315 | headers: {}, | ||
| 316 | body: JSON.stringify({ title: "GitHub issue", body: null }), | ||
| 317 | truncated: false, | ||
| 318 | invalidUtf8: false, | ||
| 319 | }), | ||
| 311 | }, | 320 | }, |
| 312 | gitlab: { | 321 | gitlab: { |
| 313 | execute: () => | 322 | execute: () => |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+2-1| ... | @@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 305 | 305 | ||
| 306 | export const make = Effect.gen(function* () { | 306 | export const make = Effect.gen(function* () { |
| 307 | const github = yield* GitHubSourceControlProvider.make; | 307 | const github = yield* GitHubSourceControlProvider.make; |
| 308 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; | ||
| 308 | const gitlab = yield* GitLabSourceControlProvider.make; | 309 | const gitlab = yield* GitLabSourceControlProvider.make; |
| 309 | const forgejo = yield* ForgejoSourceControlProvider.make; | 310 | const forgejo = yield* ForgejoSourceControlProvider.make; |
| 310 | const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; | 311 | const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; |
| ... | @@ -315,7 +316,7 @@ export const make = Effect.gen(function* () { | ... | @@ -315,7 +316,7 @@ export const make = Effect.gen(function* () { |
| 315 | { | 316 | { |
| 316 | kind: "github", | 317 | kind: "github", |
| 317 | provider: github, | 318 | provider: github, |
| 318 | discovery: GitHubSourceControlProvider.discovery, | 319 | discovery: githubDiscovery, |
| 319 | }, | 320 | }, |
| 320 | { | 321 | { |
| 321 | kind: "gitlab", | 322 | kind: "gitlab", |
apps/server/src/sourceControl/gitHubPullRequests.ts-11| ... | @@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord( | ... | @@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord( |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | 113 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); |
| 114 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | ||
| 115 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | 114 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); |
| 116 | 115 | ||
| 117 | /** | 116 | /** |
| ... | @@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson( | ... | @@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson( |
| 139 | > { | 138 | > { |
| 140 | return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); | 139 | return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); |
| 141 | } | 140 | } |
| 142 | |||
| 143 | export function decodeGitHubPullRequestJson( | ||
| 144 | raw: string, | ||
| 145 | ): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | ||
| 146 | const result = decodeGitHubPullRequest(raw); | ||
| 147 | if (Result.isSuccess(result)) { | ||
| 148 | return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); | ||
| 149 | } | ||
| 150 | return Result.fail(result.failure); | ||
| 151 | } |