| author | |
| committer | |
| log | adc3c9327abedda6a36694e2344e620fe0d62ae4 |
| tree | 002c1ab76257344e3b44977372ccd017da6863df |
| parent | 1564aaa5d29deb2520d1211f9f35dd6150b1512e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>15 files changed, 2867 insertions(+), 2593 deletions(-)
apps/server/scripts/measure-pr-preview.ts+50-67| ... | @@ -1,18 +1,21 @@ | ... | @@ -1,18 +1,21 @@ |
| 1 | // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 | 1 | // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 |
| 2 | // Numbers form a session with shared repository-permission caches. Browser and | 2 | // Numbers form a session with shared repository-permission caches. Browser and |
| 3 | // service caches are excluded. Uses real GitHub reads, without a server or database. | 3 | // service caches are excluded. Uses real GitHub reads, without a server or database. |
| 4 | // CLI-generated GraphQL | 4 | // Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. |
| 5 | // queries are replayed with rateLimit.cost, outside the timed section, to measure | ||
| 6 | // their cost without confusing other applications' traffic with this process's. | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 9 | import * as Console from "effect/Console"; | 7 | import * as Console from "effect/Console"; |
| 10 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | ||
| 11 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import { FetchHttpClient } from "effect/http"; | ||
| 12 | 12 | ||
| 13 | import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; | 13 | import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; |
| 14 | import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; | 14 | import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; |
| 15 | import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; | 15 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 16 | import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; | ||
| 17 | import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; | ||
| 18 | import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; | ||
| 16 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | 19 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 17 | 20 | ||
| 18 | const [repository, ...numbers] = process.argv.slice(2); | 21 | const [repository, ...numbers] = process.argv.slice(2); |
| ... | @@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes | ... | @@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes |
| 20 | throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); | 23 | throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); |
| 21 | } | 24 | } |
| 22 | 25 | ||
| 23 | type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string }; | 26 | type Read = { graphqlRequests: number; restRequests: number; cost: number }; |
| 24 | const reads: Read[] = []; | 27 | const reads: Read[] = []; |
| 25 | const layerMeasuredProcess = Layer.effect( | 28 | const decodeCost = Schema.decodeUnknownOption( |
| 26 | VcsProcess.VcsProcess, | 29 | Schema.fromJsonString( |
| 30 | Schema.Struct({ | ||
| 31 | data: Schema.Struct({ rateLimit: Schema.Struct({ cost: Schema.Number }) }), | ||
| 32 | }), | ||
| 33 | ), | ||
| 34 | ); | ||
| 35 | const measuredApi = Layer.effect( | ||
| 36 | GitHubApi.GitHubApi, | ||
| 27 | Effect.gen(function* () { | 37 | Effect.gen(function* () { |
| 28 | const vcs = yield* VcsProcess.VcsProcess; | 38 | const api = yield* GitHubApi.make; |
| 29 | return VcsProcess.VcsProcess.of({ | 39 | return GitHubApi.GitHubApi.of({ |
| 30 | run: (input) => | 40 | ...api, |
| 31 | vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe( | 41 | graphql: (input) => |
| 32 | Effect.tap((output) => | 42 | api.graphql(input).pipe( |
| 33 | Effect.sync(() => { | 43 | Effect.tap((body) => |
| 34 | const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length; | 44 | Effect.sync(() => |
| 35 | const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length; | ||
| 36 | const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1]; | ||
| 37 | reads.push({ | 45 | reads.push({ |
| 38 | graphqlRequests, | 46 | graphqlRequests: 1, |
| 39 | restRequests: requests - graphqlRequests, | 47 | restRequests: 0, |
| 40 | cost: Number(cost ?? 0), | 48 | cost: Option.match(decodeCost(body), { |
| 41 | debug: output.stderr, | 49 | onNone: () => 0, |
| 42 | }); | 50 | onSome: (decoded) => decoded.data.rateLimit.cost, |
| 43 | }), | 51 | }), |
| 52 | }), | ||
| 53 | ), | ||
| 44 | ), | 54 | ), |
| 45 | ), | 55 | ), |
| 56 | rest: (input) => | ||
| 57 | api | ||
| 58 | .rest(input) | ||
| 59 | .pipe( | ||
| 60 | Effect.tap(() => | ||
| 61 | Effect.sync(() => reads.push({ graphqlRequests: 0, restRequests: 1, cost: 0 })), | ||
| 62 | ), | ||
| 63 | ), | ||
| 46 | }); | 64 | }); |
| 47 | }), | 65 | }), |
| 48 | ).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)); | 66 | ).pipe( |
| 49 | 67 | Layer.provide(GitHubCredentials.layer), | |
| 50 | const layerServices = GitHubPullRequestCli.layer.pipe( | 68 | Layer.provide(GitHubGraphQlBudget.layer), |
| 51 | Layer.provide(GitHubCli.layer), | 69 | Layer.provide(SourceControlRateLimit.layer), |
| 52 | Layer.provideMerge(layerMeasuredProcess), | 70 | Layer.provide(FetchHttpClient.layer), |
| 71 | Layer.provide(VcsProcess.layer), | ||
| 53 | Layer.provide(NodeServices.layer), | 72 | Layer.provide(NodeServices.layer), |
| 54 | ); | 73 | ); |
| 55 | 74 | ||
| 56 | const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); | 75 | const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi)); |
| 76 | |||
| 57 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 77 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 58 | const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) { | ||
| 59 | // The REST quota probe synthesizes rateLimit.cost for budget admission. | ||
| 60 | // It does not spend GraphQL points. | ||
| 61 | if (!read.graphqlRequests) return 0; | ||
| 62 | if (read.cost) return read.cost; | ||
| 63 | const vcs = yield* VcsProcess.VcsProcess; | ||
| 64 | let cost = 0; | ||
| 65 | let found = 0; | ||
| 66 | for (const match of read.debug.matchAll( | ||
| 67 | /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g, | ||
| 68 | )) { | ||
| 69 | const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }"); | ||
| 70 | const output = yield* vcs.run({ | ||
| 71 | operation: "measurePrPreview.cost", | ||
| 72 | command: "gh", | ||
| 73 | cwd: process.cwd(), | ||
| 74 | args: [ | ||
| 75 | "api", | ||
| 76 | "graphql", | ||
| 77 | "--hostname", | ||
| 78 | "github.com", | ||
| 79 | "--input", | ||
| 80 | "-", | ||
| 81 | "--jq", | ||
| 82 | ".data.rateLimit.cost", | ||
| 83 | ], | ||
| 84 | stdin: encodeJson({ query, variables: decodeJson(match[2]!) }), | ||
| 85 | env: { GH_DEBUG: "" }, | ||
| 86 | }); | ||
| 87 | cost += Number(output.stdout.trim()); | ||
| 88 | found++; | ||
| 89 | } | ||
| 90 | if (found !== read.graphqlRequests) | ||
| 91 | throw new Error("Could not account for every GraphQL request"); | ||
| 92 | return cost; | ||
| 93 | }); | ||
| 94 | 78 | ||
| 95 | for (const mode of ["detail", "preview"] as const) { | 79 | for (const mode of ["detail", "preview"] as const) { |
| 96 | // Start each side cold, then retain the caches shared across PRs in one session. | 80 | // Start each side cold, then retain the caches shared across PRs in one session. |
| ... | @@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) { | ... | @@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) { |
| 108 | : provider.getChangeRequestPreview!(input); | 92 | : provider.getChangeRequestPreview!(input); |
| 109 | const elapsedMs = Math.round(performance.now() - start); | 93 | const elapsedMs = Math.round(performance.now() - start); |
| 110 | const measured = [...reads]; | 94 | const measured = [...reads]; |
| 111 | const costs = yield* Effect.forEach(measured, graphqlCost); | 95 | const points = measured.reduce((total, read) => total + read.cost, 0); |
| 112 | const points = costs.reduce((total, cost) => total + cost, 0); | ||
| 113 | return { | 96 | return { |
| 114 | repository, | 97 | repository, |
| 115 | number, | 98 | number, |
| ... | @@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) { | ... | @@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) { |
| 123 | }), | 106 | }), |
| 124 | ); | 107 | ); |
| 125 | }), | 108 | }), |
| 126 | ).pipe(Effect.provide(layerServices)), | 109 | ).pipe(Effect.provide(services)), |
| 127 | ); | 110 | ); |
| 128 | for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row))); | 111 | for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row))); |
| 129 | } | 112 | } |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+1033-1008| ... | @@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber"; | ... | @@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as TestClock from "effect/testing/TestClock"; | 9 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | import { ChildProcessSpawner } from "effect/process"; | 10 | import * as Redacted from "effect/Redacted"; |
| 11 | 11 | ||
| 12 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 12 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 13 | |
| 14 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | 14 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; |
| 15 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | ||
| 16 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | ||
| 15 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | 17 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 18 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | ||
| 16 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 19 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 20 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | ||
| 17 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; | 21 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 18 | 22 | ||
| 19 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 23 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| ... | @@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | ... | @@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ |
| 48 | }, | 52 | }, |
| 49 | }); | 53 | }); |
| 50 | 54 | ||
| 51 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); | 55 | /** One request the CLI made, as the transport received it. */ |
| 52 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => | 56 | type ApiCall = |
| 57 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) | ||
| 58 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); | ||
| 59 | |||
| 60 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; | ||
| 61 | |||
| 62 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); | ||
| 63 | const defaultCredential = (host: string) => | ||
| 64 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); | ||
| 65 | const mockedCredential = vi.fn(defaultCredential); | ||
| 66 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => | ||
| 53 | Effect.succeed(output('{"data":{}}')), | 67 | Effect.succeed(output('{"data":{}}')), |
| 54 | ); | 68 | ); |
| 55 | const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>(); | 69 | |
| 70 | /** | ||
| 71 | * A transport that answers from the mocks above but spends the real GraphQL budget, the way | ||
| 72 | * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. | ||
| 73 | */ | ||
| 74 | const mockApi = Layer.effect( | ||
| 75 | GitHubApi.GitHubApi, | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | ||
| 78 | return GitHubApi.GitHubApi.of({ | ||
| 79 | graphql: (input) => | ||
| 80 | budget | ||
| 81 | .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) | ||
| 82 | .pipe( | ||
| 83 | Effect.flatMap((query) => | ||
| 84 | (input.query.includes("query PullRequestStackMemberships") | ||
| 85 | ? mockedStackMemberships | ||
| 86 | : mockedExecute)({ kind: "graphql", ...input, query }), | ||
| 87 | ), | ||
| 88 | Effect.map((answer) => answer.body), | ||
| 89 | Effect.tap((body) => budget.observe(input.host, body)), | ||
| 90 | ), | ||
| 91 | rest: (input) => mockedExecute({ kind: "rest", ...input }), | ||
| 92 | credential: (host) => mockedCredential(host), | ||
| 93 | }); | ||
| 94 | }), | ||
| 95 | ); | ||
| 56 | 96 | ||
| 57 | const layer = it.layer( | 97 | const layer = it.layer( |
| 58 | GitHubPullRequestCli.layer.pipe( | 98 | GitHubPullRequestCli.layer.pipe( |
| 59 | Layer.provide( | 99 | Layer.provideMerge(mockApi), |
| 60 | Layer.mock(GitHubCli.GitHubCli)({ | ||
| 61 | execute: (input) => | ||
| 62 | input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) | ||
| 63 | ? mockedStackMemberships(input) | ||
| 64 | : mockedExecute(input), | ||
| 65 | getPullRequest: mockedGetPullRequest, | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | Layer.provideMerge(GitHubGraphQlBudget.layer), | 100 | Layer.provideMerge(GitHubGraphQlBudget.layer), |
| 69 | Layer.provide(NodeCrypto.layer), | 101 | Layer.provide(NodeCrypto.layer), |
| 70 | ), | 102 | ), |
| 71 | ); | 103 | ); |
| 72 | 104 | ||
| 73 | function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { | 105 | /** A successful answer; GraphQL reads take its body. */ |
| 74 | return { | 106 | function output( |
| 75 | exitCode: ChildProcessSpawner.ExitCode(0), | 107 | body: string, |
| 76 | stdout, | 108 | truncated = false, |
| 77 | stderr: "", | 109 | invalidUtf8 = false, |
| 78 | stdoutTruncated, | 110 | ): GitHubApi.GitHubRestResponse { |
| 79 | stderrTruncated: false, | 111 | return { status: 200, headers: {}, body, truncated, invalidUtf8 }; |
| 80 | stdoutInvalidUtf8, | ||
| 81 | }; | ||
| 82 | } | 112 | } |
| 83 | 113 | ||
| 84 | function pullRequests( | 114 | /** |
| 115 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most | ||
| 116 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. | ||
| 117 | */ | ||
| 118 | function rows( | ||
| 85 | count: number, | 119 | count: number, |
| 86 | firstNumber: number, | 120 | firstNumber: number, |
| 87 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), | 121 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), |
| 88 | ): string { | 122 | ): ReadonlyArray<Record<string, unknown>> { |
| 89 | return JSON.stringify( | 123 | return Array.from({ length: count }, (_, index) => { |
| 90 | Array.from({ length: count }, (_, index) => ({ | 124 | const number = firstNumber + index; |
| 91 | number: firstNumber + index, | 125 | const { reviewRequests, checks, ...rest } = overrides(number) as { |
| 92 | title: `Pull request ${firstNumber + index}`, | 126 | reviewRequests?: ReadonlyArray<Record<string, string>>; |
| 93 | url: `https://github.com/acme/web/pull/${firstNumber + index}`, | 127 | checks?: string; |
| 128 | } & Record<string, unknown>; | ||
| 129 | return { | ||
| 130 | number, | ||
| 131 | title: `Pull request ${number}`, | ||
| 132 | url: `https://github.com/acme/web/pull/${number}`, | ||
| 94 | headRefName: "feat/page", | 133 | headRefName: "feat/page", |
| 95 | baseRefName: "main", | 134 | baseRefName: "main", |
| 96 | createdAt: "2026-07-01T00:00:00Z", | 135 | createdAt: "2026-07-01T00:00:00Z", |
| 97 | updatedAt: "2026-07-02T00:00:00Z", | 136 | updatedAt: "2026-07-02T00:00:00Z", |
| 98 | ...overrides(firstNumber + index), | 137 | repository: { nameWithOwner: "acme/web" }, |
| 99 | })), | 138 | ...(reviewRequests === undefined |
| 100 | ); | 139 | ? {} |
| 140 | : { | ||
| 141 | reviewRequests: { | ||
| 142 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), | ||
| 143 | }, | ||
| 144 | }), | ||
| 145 | ...(checks === undefined | ||
| 146 | ? {} | ||
| 147 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | ||
| 148 | ...rest, | ||
| 149 | }; | ||
| 150 | }); | ||
| 151 | } | ||
| 152 | |||
| 153 | /** A page of the searched listing. */ | ||
| 154 | function pullRequests( | ||
| 155 | count: number, | ||
| 156 | firstNumber: number, | ||
| 157 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | ||
| 158 | ): string { | ||
| 159 | return encodeJson({ | ||
| 160 | data: { | ||
| 161 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, | ||
| 162 | }, | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | |||
| 166 | /** A page of a repository's own list, which is what the search-free fallback reads. */ | ||
| 167 | function listedPullRequests( | ||
| 168 | count: number, | ||
| 169 | firstNumber: number, | ||
| 170 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | ||
| 171 | ): string { | ||
| 172 | return encodeJson({ | ||
| 173 | data: { | ||
| 174 | repository: { | ||
| 175 | pullRequests: { | ||
| 176 | pageInfo: { hasNextPage: false }, | ||
| 177 | nodes: rows(count, firstNumber, overrides), | ||
| 178 | }, | ||
| 179 | }, | ||
| 180 | }, | ||
| 181 | }); | ||
| 101 | } | 182 | } |
| 102 | 183 | ||
| 184 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ | ||
| 185 | const emptySearch = () => output(pullRequests(0, 1)); | ||
| 186 | const emptyList = () => output(listedPullRequests(0, 1)); | ||
| 187 | |||
| 103 | function pullRequestFiles(count: number, firstIndex: number): string { | 188 | function pullRequestFiles(count: number, firstIndex: number): string { |
| 104 | return JSON.stringify( | 189 | return encodeJson( |
| 105 | Array.from({ length: count }, (_, index) => ({ | 190 | Array.from({ length: count }, (_, index) => ({ |
| 106 | filename: `src/file${firstIndex + index}.ts`, | 191 | filename: `src/file${firstIndex + index}.ts`, |
| 107 | status: "modified", | 192 | status: "modified", |
| ... | @@ -139,7 +224,7 @@ function reviewThreadsPage( | ... | @@ -139,7 +224,7 @@ function reviewThreadsPage( |
| 139 | nodes: ReadonlyArray<Record<string, unknown>>, | 224 | nodes: ReadonlyArray<Record<string, unknown>>, |
| 140 | endCursor: string | null, | 225 | endCursor: string | null, |
| 141 | ): string { | 226 | ): string { |
| 142 | return JSON.stringify({ | 227 | return encodeJson({ |
| 143 | data: { | 228 | data: { |
| 144 | repository: { | 229 | repository: { |
| 145 | pullRequest: { | 230 | pullRequest: { |
| ... | @@ -160,7 +245,7 @@ function threadCommentsPage( | ... | @@ -160,7 +245,7 @@ function threadCommentsPage( |
| 160 | totalCount: number, | 245 | totalCount: number, |
| 161 | pullRequestId = "PR_7", | 246 | pullRequestId = "PR_7", |
| 162 | ): string { | 247 | ): string { |
| 163 | return JSON.stringify({ | 248 | return encodeJson({ |
| 164 | data: { | 249 | data: { |
| 165 | repository: { pullRequest: { id: "PR_7" } }, | 250 | repository: { pullRequest: { id: "PR_7" } }, |
| 166 | node: { | 251 | node: { |
| ... | @@ -171,26 +256,43 @@ function threadCommentsPage( | ... | @@ -171,26 +256,43 @@ function threadCommentsPage( |
| 171 | }); | 256 | }); |
| 172 | } | 257 | } |
| 173 | 258 | ||
| 174 | /** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */ | 259 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ |
| 175 | const diffRefused = new GitHubCli.GitHubCliCommandError({ | 260 | const diffRefused = new GitHubApi.GitHubApiResponseError({ |
| 176 | command: "gh", | 261 | host: "github.com", |
| 177 | cwd: "/w", | 262 | operation: "getPullRequestDiff", |
| 178 | cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"), | 263 | status: 406, |
| 179 | }); | 264 | }); |
| 180 | 265 | ||
| 181 | /** The whole invocation the nth call made, so both argv and stdin can be asserted. */ | 266 | /** The nth request the CLI made. */ |
| 182 | function callAt(index: number) { | 267 | function callAt(index: number): ApiCall { |
| 183 | const call = mockedExecute.mock.calls[index]; | 268 | const call = mockedExecute.mock.calls[index]; |
| 184 | assert.isDefined(call); | 269 | assert.isDefined(call); |
| 185 | return call[0]; | 270 | return call[0]; |
| 186 | } | 271 | } |
| 187 | 272 | ||
| 188 | /** The one argument `--search` carries, which is where every listing filter ends up. */ | 273 | /** The GraphQL variables of the nth request. */ |
| 274 | function varsAt(index: number): Readonly<Record<string, unknown>> { | ||
| 275 | const call = callAt(index); | ||
| 276 | assert.strictEqual(call.kind, "graphql"); | ||
| 277 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; | ||
| 278 | } | ||
| 279 | |||
| 280 | /** The GraphQL document of the nth request. */ | ||
| 281 | function queryAt(index: number): string { | ||
| 282 | const call = callAt(index); | ||
| 283 | return call.kind === "graphql" ? call.query : ""; | ||
| 284 | } | ||
| 285 | |||
| 286 | /** The REST path of the nth request. */ | ||
| 287 | function pathAt(index: number): string { | ||
| 288 | const call = callAt(index); | ||
| 289 | return call.kind === "rest" ? call.path : ""; | ||
| 290 | } | ||
| 291 | |||
| 292 | /** The search the nth request carried, or undefined for a read that did not search. */ | ||
| 189 | function searchOfCall(index: number): string | undefined { | 293 | function searchOfCall(index: number): string | undefined { |
| 190 | const args = callAt(index).args; | 294 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; |
| 191 | const flag = args.indexOf("--search"); | 295 | return typeof q === "string" ? q : undefined; |
| 192 | // Absent is its own answer: a read that carries no `--search` at all is what the fallback is. | ||
| 193 | return flag === -1 ? undefined : args[flag + 1]; | ||
| 194 | } | 296 | } |
| 195 | 297 | ||
| 196 | /** One row as a search answers it, which is the listing's row one connection deeper. */ | 298 | /** One row as a search answers it, which is the listing's row one connection deeper. */ |
| ... | @@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) { | ... | @@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) { |
| 214 | } | 316 | } |
| 215 | 317 | ||
| 216 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { | 318 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { |
| 217 | return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); | 319 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); |
| 218 | } | 320 | } |
| 219 | 321 | ||
| 220 | /** The search a batched read sent, which travels in the request body rather than in argv. */ | 322 | const searchQueryOfCall = searchOfCall; |
| 221 | function searchQueryOfCall(index: number): string | undefined { | 323 | |
| 222 | const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } }; | 324 | /** |
| 223 | return body.variables?.q; | 325 | * Answers every request with the body of the first route whose needle its GraphQL document or |
| 326 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. | ||
| 327 | */ | ||
| 328 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { | ||
| 329 | mockedExecute.mockImplementation((call) => { | ||
| 330 | const target = call.kind === "graphql" ? call.query : call.path; | ||
| 331 | const match = routes.find(([needle]) => target.includes(needle)); | ||
| 332 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); | ||
| 333 | }); | ||
| 334 | } | ||
| 335 | |||
| 336 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ | ||
| 337 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { | ||
| 338 | return mockedExecute.mock.calls.flatMap(([call]) => | ||
| 339 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], | ||
| 340 | ); | ||
| 341 | } | ||
| 342 | |||
| 343 | /** Every REST request whose path contains `needle`, in order. */ | ||
| 344 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { | ||
| 345 | return mockedExecute.mock.calls.flatMap(([call]) => | ||
| 346 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], | ||
| 347 | ); | ||
| 224 | } | 348 | } |
| 225 | 349 | ||
| 350 | /** | ||
| 351 | * The check that a client-given node hangs off the pull request it names. Routed before the node | ||
| 352 | * id lookup, whose document it contains. | ||
| 353 | */ | ||
| 354 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; | ||
| 355 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ | ||
| 356 | data: { | ||
| 357 | repository: { pullRequest: { id: pullRequestId } }, | ||
| 358 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, | ||
| 359 | }, | ||
| 360 | }); | ||
| 361 | |||
| 362 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ | ||
| 363 | const checkContextsPage = ( | ||
| 364 | nodes: ReadonlyArray<Record<string, unknown>>, | ||
| 365 | endCursor: string | null, | ||
| 366 | headRefOid = "abc123", | ||
| 367 | ) => | ||
| 368 | encodeJson({ | ||
| 369 | data: { | ||
| 370 | repository: { | ||
| 371 | pullRequest: { | ||
| 372 | headRefOid, | ||
| 373 | commits: { | ||
| 374 | nodes: [ | ||
| 375 | { | ||
| 376 | commit: { | ||
| 377 | statusCheckRollup: { | ||
| 378 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, | ||
| 379 | }, | ||
| 380 | }, | ||
| 381 | }, | ||
| 382 | ], | ||
| 383 | }, | ||
| 384 | }, | ||
| 385 | }, | ||
| 386 | }, | ||
| 387 | }); | ||
| 388 | |||
| 389 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ | ||
| 390 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); | ||
| 391 | |||
| 392 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ | ||
| 393 | const crossRepositoryDetail = (headRefOid = "abc123") => | ||
| 394 | coreResponse({ | ||
| 395 | headRefName: "feat/page", | ||
| 396 | headRefOid, | ||
| 397 | isCrossRepository: true, | ||
| 398 | headRepositoryOwner: { login: "octocat" }, | ||
| 399 | }); | ||
| 400 | |||
| 401 | /** Open pull requests on one head branch, every one of them from the same fork head. */ | ||
| 402 | const heads = (numbers: ReadonlyArray<number>) => ({ | ||
| 403 | data: { | ||
| 404 | repository: { | ||
| 405 | pullRequests: { | ||
| 406 | pageInfo: { hasNextPage: false, endCursor: null }, | ||
| 407 | nodes: numbers.map((number) => ({ | ||
| 408 | number, | ||
| 409 | headRefOid: "abc123", | ||
| 410 | isCrossRepository: true, | ||
| 411 | headRepositoryOwner: { login: "octocat" }, | ||
| 412 | })), | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | }, | ||
| 416 | }); | ||
| 417 | |||
| 418 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ | ||
| 419 | workflow_runs: ids.map((id) => ({ | ||
| 420 | id, | ||
| 421 | name: id === 10 ? "build" : `run ${id}`, | ||
| 422 | html_url: `https://example.com/${id}`, | ||
| 423 | })), | ||
| 424 | }); | ||
| 425 | |||
| 426 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ | ||
| 427 | function workflowApprovalRoutes( | ||
| 428 | detail: () => unknown, | ||
| 429 | headsAnswer: unknown, | ||
| 430 | runsAnswer: unknown, | ||
| 431 | ): void { | ||
| 432 | mockedExecute.mockImplementation((call) => | ||
| 433 | Effect.sync(() => | ||
| 434 | output( | ||
| 435 | call.kind === "rest" | ||
| 436 | ? call.path.endsWith("/approve") | ||
| 437 | ? "" | ||
| 438 | : encodeJson(runsAnswer) | ||
| 439 | : call.query.includes("headRefName: $head") | ||
| 440 | ? encodeJson(headsAnswer) | ||
| 441 | : encodeJson(detail()), | ||
| 442 | ), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | } | ||
| 446 | |||
| 447 | /** The pull request node id lookup, which the layer caches for every test after the first. */ | ||
| 448 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; | ||
| 449 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); | ||
| 450 | |||
| 226 | afterEach(() => { | 451 | afterEach(() => { |
| 227 | mockedExecute.mockReset(); | 452 | mockedExecute.mockReset(); |
| 228 | mockedStackMemberships.mockReset(); | 453 | mockedStackMemberships.mockReset(); |
| 229 | mockedGetPullRequest.mockReset(); | 454 | mockedCredential.mockReset(); |
| 455 | mockedCredential.mockImplementation(defaultCredential); | ||
| 230 | }); | 456 | }); |
| 231 | 457 | ||
| 232 | it.effect( | 458 | it.effect( |
| ... | @@ -234,22 +460,41 @@ it.effect( | ... | @@ -234,22 +460,41 @@ it.effect( |
| 234 | () => | 460 | () => |
| 235 | Effect.gen(function* () { | 461 | Effect.gen(function* () { |
| 236 | let activeToken = "broad-credential"; | 462 | let activeToken = "broad-credential"; |
| 237 | const commands: VcsProcess.VcsProcessInput[] = []; | 463 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = |
| 238 | const github = yield* GitHubCli.make.pipe( | 464 | []; |
| 239 | Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)), | 465 | const credentials = Layer.succeed( |
| 240 | Effect.provideService(VcsProcess.VcsProcess, { | 466 | GitHubCredentials.GitHubCredentials, |
| 241 | run: (input) => | 467 | GitHubCredentials.GitHubCredentials.of({ |
| 242 | Effect.sync(() => { | 468 | get: (host) => |
| 243 | commands.push(input); | 469 | Effect.sync(() => ({ |
| 244 | if (input.args[0] === "auth") return output(activeToken); | 470 | host, |
| 245 | if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}'); | 471 | token: Redacted.make(activeToken), |
| 246 | return output(""); | 472 | source: "gh" as const, |
| 247 | }), | 473 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, |
| 474 | })), | ||
| 475 | invalidate: () => Effect.void, | ||
| 248 | }), | 476 | }), |
| 249 | ); | 477 | ); |
| 478 | const http = Layer.succeed( | ||
| 479 | HttpClient.HttpClient, | ||
| 480 | HttpClient.make((request) => | ||
| 481 | Effect.sync(() => { | ||
| 482 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); | ||
| 483 | const body = request.url.endsWith("/user") | ||
| 484 | ? { id: 123, login: "same-account" } | ||
| 485 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; | ||
| 486 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); | ||
| 487 | }), | ||
| 488 | ), | ||
| 489 | ); | ||
| 250 | const cli = yield* GitHubPullRequestCli.make.pipe( | 490 | const cli = yield* GitHubPullRequestCli.make.pipe( |
| 251 | Effect.provideService(GitHubCli.GitHubCli, github), | 491 | Effect.provide( |
| 252 | Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, NodeCrypto.layer)), | 492 | GitHubApi.layer.pipe( |
| 493 | Layer.provide(Layer.mergeAll(credentials, http)), | ||
| 494 | Layer.provide(GitHubGraphQlBudget.layer), | ||
| 495 | Layer.provide(SourceControlRateLimit.layer), | ||
| 496 | ), | ||
| 497 | ), | ||
| 253 | ); | 498 | ); |
| 254 | const input = { cwd: "/repo", host: "github.com" }; | 499 | const input = { cwd: "/repo", host: "github.com" }; |
| 255 | const first = yield* cli.withVerifiedCredential(input, (identity) => | 500 | const first = yield* cli.withVerifiedCredential(input, (identity) => |
| ... | @@ -270,16 +515,17 @@ it.effect( | ... | @@ -270,16 +515,17 @@ it.effect( |
| 270 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); | 515 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); |
| 271 | expect(encodeJson([first, second])).not.toContain("broad-credential"); | 516 | expect(encodeJson([first, second])).not.toContain("broad-credential"); |
| 272 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); | 517 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); |
| 273 | expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({ | 518 | // The comment was written under the credential the page verified, not the switched one. |
| 274 | GH_TOKEN: "broad-credential", | 519 | expect( |
| 275 | GITHUB_TOKEN: "broad-credential", | 520 | requests |
| 276 | GH_DEBUG: "", | 521 | .filter((request) => request.url.endsWith("/graphql")) |
| 277 | }); | 522 | .map((request) => request.authorization), |
| 523 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | ||
| 278 | expect( | 524 | expect( |
| 279 | commands | 525 | requests |
| 280 | .filter((command) => command.args[0] === "api") | 526 | .filter((request) => request.url.endsWith("/user")) |
| 281 | .map((command) => command.env?.GH_TOKEN), | 527 | .map((request) => request.authorization), |
| 282 | ).toEqual(["broad-credential", "restricted-credential"]); | 528 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); |
| 283 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | 529 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 284 | accountId: "123", | 530 | accountId: "123", |
| 285 | viewer: "same-account", | 531 | viewer: "same-account", |
| ... | @@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 394 | }, | 640 | }, |
| 395 | }); | 641 | }); |
| 396 | expect(mockedExecute).toHaveBeenCalledTimes(1); | 642 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 397 | expect(callAt(0).args).toEqual( | 643 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); |
| 398 | expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]), | ||
| 399 | ); | ||
| 400 | }), | 644 | }), |
| 401 | ); | 645 | ); |
| 402 | 646 | ||
| 403 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | 647 | it.effect("coalesces concurrent identity verification for the same host and credential", () => |
| 404 | Effect.gen(function* () { | 648 | Effect.gen(function* () { |
| 405 | mockedExecute.mockImplementation((input) => | 649 | mockedExecute.mockImplementation(() => |
| 406 | input.args[0] === "auth" | 650 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), |
| 407 | ? Effect.succeed(output("shared-credential")) | ||
| 408 | : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | ||
| 409 | ); | 651 | ); |
| 410 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 652 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 411 | const results = yield* Effect.all( | 653 | const results = yield* Effect.all( |
| ... | @@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 417 | expect(results).toEqual( | 659 | expect(results).toEqual( |
| 418 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), | 660 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), |
| 419 | ); | 661 | ); |
| 420 | expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1); | 662 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 663 | expect(pathAt(0)).toBe("user"); | ||
| 421 | }), | 664 | }), |
| 422 | ); | 665 | ); |
| 423 | 666 | ||
| ... | @@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 426 | () => | 669 | () => |
| 427 | Effect.gen(function* () { | 670 | Effect.gen(function* () { |
| 428 | const firstStarted = yield* Deferred.make<void>(); | 671 | const firstStarted = yield* Deferred.make<void>(); |
| 429 | const secondStarted = yield* Deferred.make<void>(); | ||
| 430 | let tokens = 0; | ||
| 431 | let verifications = 0; | 672 | let verifications = 0; |
| 432 | mockedExecute.mockImplementation((input) => | 673 | mockedExecute.mockImplementation(() => |
| 433 | Effect.gen(function* () { | 674 | Effect.gen(function* () { |
| 434 | if (input.args[0] === "auth") { | ||
| 435 | if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined); | ||
| 436 | return output("cancel-credential"); | ||
| 437 | } | ||
| 438 | if (++verifications === 1) { | 675 | if (++verifications === 1) { |
| 439 | yield* Deferred.succeed(firstStarted, undefined); | 676 | yield* Deferred.succeed(firstStarted, undefined); |
| 440 | return yield* Effect.never; | 677 | return yield* Effect.never; |
| ... | @@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 446 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; | 683 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; |
| 447 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | 684 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 448 | yield* Deferred.await(firstStarted); | 685 | yield* Deferred.await(firstStarted); |
| 686 | // The second reader is queued on the same credential's lock behind the first. | ||
| 449 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | 687 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 450 | yield* Deferred.await(secondStarted); | 688 | yield* Effect.yieldNow; |
| 451 | yield* Fiber.interrupt(first); | 689 | yield* Fiber.interrupt(first); |
| 452 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); | 690 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); |
| 453 | expect(verifications).toBe(2); | 691 | expect(verifications).toBe(2); |
| ... | @@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 479 | mockedExecute.mockReturnValueOnce( | 717 | mockedExecute.mockReturnValueOnce( |
| 480 | Effect.succeed( | 718 | Effect.succeed( |
| 481 | output( | 719 | output( |
| 482 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 720 | encodeJson({ |
| 483 | JSON.stringify({ | ||
| 484 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, | 721 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, |
| 485 | }), | 722 | }), |
| 486 | ), | 723 | ), |
| ... | @@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 526 | ); | 763 | ); |
| 527 | assert.strictEqual(eight?.headBranch, "feat/8"); | 764 | assert.strictEqual(eight?.headBranch, "feat/8"); |
| 528 | expect(mockedExecute).toHaveBeenCalledOnce(); | 765 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 529 | const document = callAt(0).args.at(-1) ?? ""; | 766 | const document = queryAt(0); |
| 530 | expect(document).toContain( | 767 | expect(document).toContain( |
| 531 | 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', | 768 | 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', |
| 532 | ); | 769 | ); |
| ... | @@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 575 | // GitHub had no answer for #8, so its watch reads it in full. | 812 | // GitHub had no answer for #8, so its watch reads it in full. |
| 576 | expect(eight).toBeNull(); | 813 | expect(eight).toBeNull(); |
| 577 | expect(mockedExecute).toHaveBeenCalledOnce(); | 814 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 578 | expect(callAt(0).args.at(-1) ?? "").toContain( | 815 | const call = callAt(0); |
| 816 | expect(call.kind === "graphql" ? call.query : "").toContain( | ||
| 579 | 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', | 817 | 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', |
| 580 | ); | 818 | ); |
| 581 | }), | 819 | }), |
| ... | @@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 588 | .mockReturnValueOnce( | 826 | .mockReturnValueOnce( |
| 589 | Effect.succeed( | 827 | Effect.succeed( |
| 590 | output( | 828 | output( |
| 591 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 829 | encodeJson({ |
| 592 | JSON.stringify({ | 830 | data: { |
| 593 | number: 7, | 831 | s0: { |
| 594 | title: "Reuse the summary", | 832 | pullRequest: { |
| 595 | url: "https://github.com/acme/web/pull/7", | 833 | number: 7, |
| 596 | author: { login: "octocat", name: "Octo Cat" }, | 834 | title: "Reuse the summary", |
| 597 | baseRefName: "main", | 835 | url: "https://github.com/acme/web/pull/7", |
| 598 | headRefName: "feat/summary", | 836 | author: { login: "octocat", name: "Octo Cat" }, |
| 599 | state: "OPEN", | 837 | baseRefName: "main", |
| 600 | isDraft: false, | 838 | headRefName: "feat/summary", |
| 601 | mergeable: "MERGEABLE", | 839 | state: "OPEN", |
| 602 | reviewDecision: "APPROVED", | 840 | isDraft: false, |
| 603 | additions: 12, | 841 | mergeable: "MERGEABLE", |
| 604 | deletions: 3, | 842 | reviewDecision: "APPROVED", |
| 605 | changedFiles: 2, | 843 | additions: 12, |
| 606 | createdAt: "2026-08-20T00:00:00.000Z", | 844 | deletions: 3, |
| 607 | updatedAt: "2026-08-24T12:34:56.000Z", | 845 | changedFiles: 2, |
| 608 | reviewRequests: [], | 846 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 609 | labels: [], | 847 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, |
| 610 | statusCheckRollup: [ | 848 | }, |
| 611 | { | ||
| 612 | __typename: "CheckRun", | ||
| 613 | status: "COMPLETED", | ||
| 614 | conclusion: "SUCCESS", | ||
| 615 | name: "ci", | ||
| 616 | }, | 849 | }, |
| 617 | ], | 850 | }, |
| 618 | body: "", | ||
| 619 | }), | 851 | }), |
| 620 | ), | 852 | ), |
| 621 | ), | 853 | ), |
| ... | @@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 631 | assert.strictEqual(summary.headBranch, "feat/summary"); | 863 | assert.strictEqual(summary.headBranch, "feat/summary"); |
| 632 | assert.strictEqual(summary.checksState, "passing"); | 864 | assert.strictEqual(summary.checksState, "passing"); |
| 633 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 865 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 634 | expect(callAt(1).args).toEqual([ | 866 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); |
| 635 | "pr", | ||
| 636 | "view", | ||
| 637 | "7", | ||
| 638 | "--repo", | ||
| 639 | "github.com/acme/web", | ||
| 640 | "--json", | ||
| 641 | expect.stringContaining("statusCheckRollup"), | ||
| 642 | ]); | ||
| 643 | }), | 867 | }), |
| 644 | ); | 868 | ); |
| 645 | 869 | ||
| ... | @@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 648 | mockedExecute.mockReturnValueOnce( | 872 | mockedExecute.mockReturnValueOnce( |
| 649 | Effect.succeed( | 873 | Effect.succeed( |
| 650 | output( | 874 | output( |
| 651 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 875 | encodeJson([ |
| 652 | JSON.stringify([ | ||
| 653 | { | 876 | { |
| 654 | id: 42, | 877 | id: 42, |
| 655 | number: 3, | 878 | number: 3, |
| ... | @@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 688 | { number: 7, headBranch: "feat/two", state: "open" }, | 911 | { number: 7, headBranch: "feat/two", state: "open" }, |
| 689 | ], | 912 | ], |
| 690 | }); | 913 | }); |
| 691 | assert.deepStrictEqual(callAt(0).args, [ | 914 | expect(callAt(0)).toMatchObject({ |
| 692 | "api", | 915 | kind: "rest", |
| 693 | "--hostname", | 916 | host: "ghe.example.com", |
| 694 | "ghe.example.com", | 917 | path: "repos/acme/web/stacks?pull_request=7", |
| 695 | "repos/acme/web/stacks?pull_request=7", | 918 | }); |
| 696 | ]); | ||
| 697 | }), | 919 | }), |
| 698 | ); | 920 | ); |
| 699 | 921 | ||
| ... | @@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 707 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | 929 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, |
| 708 | ], | 930 | ], |
| 709 | }; | 931 | }; |
| 710 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 932 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); |
| 711 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); | ||
| 712 | mockedExecute.mockReturnValueOnce( | 933 | mockedExecute.mockReturnValueOnce( |
| 713 | Effect.succeed( | 934 | Effect.succeed( |
| 714 | output( | 935 | output( |
| 715 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 936 | encodeJson({ |
| 716 | JSON.stringify({ | ||
| 717 | ...minimal, | 937 | ...minimal, |
| 718 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | 938 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], |
| 719 | }), | 939 | }), |
| ... | @@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 733 | headSha: "abc123", | 953 | headSha: "abc123", |
| 734 | isDraft: false, | 954 | isDraft: false, |
| 735 | }); | 955 | }); |
| 736 | expect(callAt(1).args).toEqual([ | 956 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); |
| 737 | "api", | ||
| 738 | "--hostname", | ||
| 739 | "github.com", | ||
| 740 | "repos/acme/web/stacks/3", | ||
| 741 | ]); | ||
| 742 | }), | 957 | }), |
| 743 | ); | 958 | ); |
| 744 | 959 | ||
| ... | @@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 760 | 975 | ||
| 761 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | 976 | it.effect("reads a host that refuses the stacks preview as not stacked", () => |
| 762 | Effect.gen(function* () { | 977 | Effect.gen(function* () { |
| 763 | // The CLI classifies a missing preview endpoint as not found. | ||
| 764 | mockedExecute.mockReturnValueOnce( | 978 | mockedExecute.mockReturnValueOnce( |
| 765 | Effect.fail( | 979 | Effect.fail( |
| 766 | new GitHubCli.GitHubPullRequestNotFoundError({ | 980 | new GitHubApi.GitHubApiNotFoundError({ |
| 767 | command: "gh", | 981 | host: "github.com", |
| 768 | cwd: "/w", | 982 | operation: "getPullRequestStack", |
| 769 | cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), | ||
| 770 | }), | 983 | }), |
| 771 | ), | 984 | ), |
| 772 | ); | 985 | ); |
| ... | @@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 783 | }), | 996 | }), |
| 784 | ); | 997 | ); |
| 785 | 998 | ||
| 786 | it.effect("does not read a signed-out gh as an unstacked pull request", () => | 999 | it.effect("does not read a refused credential as an unstacked pull request", () => |
| 787 | Effect.gen(function* () { | 1000 | Effect.gen(function* () { |
| 788 | mockedExecute.mockReturnValueOnce( | 1001 | mockedExecute.mockReturnValueOnce( |
| 789 | Effect.fail( | 1002 | Effect.fail( |
| 790 | new GitHubCli.GitHubCliAuthenticationError({ | 1003 | new GitHubApi.GitHubApiAuthenticationError({ |
| 791 | command: "gh", | 1004 | host: "github.com", |
| 792 | cwd: "/w", | 1005 | operation: "getPullRequestStack", |
| 793 | cause: new Error("gh auth login"), | ||
| 794 | }), | 1006 | }), |
| 795 | ), | 1007 | ), |
| 796 | ); | 1008 | ); |
| ... | @@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 805 | }), | 1017 | }), |
| 806 | ); | 1018 | ); |
| 807 | 1019 | ||
| 808 | assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); | 1020 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); |
| 809 | }), | 1021 | }), |
| 810 | ); | 1022 | ); |
| 811 | 1023 | ||
| 812 | it.effect("preserves transient stack failures instead of reporting no stack", () => | 1024 | it.effect("preserves transient stack failures instead of reporting no stack", () => |
| 813 | Effect.gen(function* () { | 1025 | Effect.gen(function* () { |
| 814 | const failure = new GitHubCli.GitHubCliCommandError({ | 1026 | const failure = new GitHubApi.GitHubApiResponseError({ |
| 815 | command: "gh", | 1027 | host: "github.com", |
| 816 | cwd: "/w", | 1028 | operation: "getPullRequestStack", |
| 817 | cause: new Error("HTTP 503"), | 1029 | status: 503, |
| 818 | }); | 1030 | }); |
| 819 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | 1031 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); |
| 820 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1032 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 867 | 1079 | ||
| 868 | assert.strictEqual(batch.items.length, 3); | 1080 | assert.strictEqual(batch.items.length, 3); |
| 869 | assert.isFalse(batch.truncated); | 1081 | assert.isFalse(batch.truncated); |
| 870 | const args = callAt(0).args; | 1082 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); |
| 871 | expect(args).toContain("--repo"); | 1083 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); |
| 872 | expect(args).toContain("github.com/acme/web"); | 1084 | expect(queryAt(0)).toContain("first: 11"); |
| 873 | expect(args).toContain("--state"); | ||
| 874 | expect(args).toContain("open"); | ||
| 875 | expect(args).toContain("--limit"); | ||
| 876 | expect(args).toContain("11"); | ||
| 877 | }), | 1085 | }), |
| 878 | ); | 1086 | ); |
| 879 | 1087 | ||
| ... | @@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 899 | 1107 | ||
| 900 | it.effect("excludes merged pull requests from the Closed tab", () => | 1108 | it.effect("excludes merged pull requests from the Closed tab", () => |
| 901 | Effect.gen(function* () { | 1109 | Effect.gen(function* () { |
| 902 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1110 | mockedExecute.mockImplementation((call) => |
| 1111 | Effect.succeed( | ||
| 1112 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1113 | ? emptyList() | ||
| 1114 | : emptySearch(), | ||
| 1115 | ), | ||
| 1116 | ); | ||
| 903 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1117 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 904 | 1118 | ||
| 905 | yield* cli.listPullRequests({ | 1119 | yield* cli.listPullRequests({ |
| ... | @@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 912 | limit: 10, | 1126 | limit: 10, |
| 913 | }); | 1127 | }); |
| 914 | 1128 | ||
| 915 | // `--state closed` includes merged pull requests, so the tab narrows through search. | 1129 | // A closed pull request may also be a merged one, so the tab narrows through search. |
| 916 | expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc"); | 1130 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); |
| 917 | }), | 1131 | }), |
| 918 | ); | 1132 | ); |
| 919 | 1133 | ||
| 920 | it.effect("narrows to the author on the authored tab", () => | 1134 | it.effect("narrows to the author on the authored tab", () => |
| 921 | Effect.gen(function* () { | 1135 | Effect.gen(function* () { |
| 922 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1136 | mockedExecute.mockImplementation((call) => |
| 1137 | Effect.succeed( | ||
| 1138 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1139 | ? emptyList() | ||
| 1140 | : emptySearch(), | ||
| 1141 | ), | ||
| 1142 | ); | ||
| 923 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1143 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 924 | 1144 | ||
| 925 | yield* cli.listPullRequests({ | 1145 | yield* cli.listPullRequests({ |
| ... | @@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 932 | limit: 10, | 1152 | limit: 10, |
| 933 | }); | 1153 | }); |
| 934 | 1154 | ||
| 935 | const args = callAt(0).args; | 1155 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); |
| 936 | expect(args).toContain("--author"); | ||
| 937 | expect(args).toContain("bilal"); | ||
| 938 | }), | 1156 | }), |
| 939 | ); | 1157 | ); |
| 940 | 1158 | ||
| 941 | it.effect("narrows through search on the reviewing tab", () => | 1159 | it.effect("narrows through search on the reviewing tab", () => |
| 942 | Effect.gen(function* () { | 1160 | Effect.gen(function* () { |
| 943 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1161 | mockedExecute.mockImplementation((call) => |
| 1162 | Effect.succeed( | ||
| 1163 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1164 | ? emptyList() | ||
| 1165 | : emptySearch(), | ||
| 1166 | ), | ||
| 1167 | ); | ||
| 944 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1168 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 945 | 1169 | ||
| 946 | yield* cli.listPullRequests({ | 1170 | yield* cli.listPullRequests({ |
| ... | @@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 953 | limit: 10, | 1177 | limit: 10, |
| 954 | }); | 1178 | }); |
| 955 | 1179 | ||
| 956 | expect(searchOfCall(0)).toBe("review-requested:bilal sort:updated-desc"); | 1180 | expect(searchOfCall(0)).toBe( |
| 1181 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", | ||
| 1182 | ); | ||
| 957 | }), | 1183 | }), |
| 958 | ); | 1184 | ); |
| 959 | 1185 | ||
| ... | @@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1008 | }), | 1234 | }), |
| 1009 | ); | 1235 | ); |
| 1010 | 1236 | ||
| 1011 | it.effect("keeps a searched-for qualifier inside the phrase, and out of argv", () => | 1237 | it.effect("keeps a searched-for qualifier inside the phrase", () => |
| 1012 | Effect.gen(function* () { | 1238 | Effect.gen(function* () { |
| 1013 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | 1239 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1014 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1240 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1024 | query: 'x" is:merged repo:evil/repo', | 1250 | query: 'x" is:merged repo:evil/repo', |
| 1025 | }); | 1251 | }); |
| 1026 | 1252 | ||
| 1027 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening | 1253 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. |
| 1028 | // it — and the whole document travels over stdin rather than in a visible argv. | ||
| 1029 | assert.strictEqual( | 1254 | assert.strictEqual( |
| 1030 | searchQueryOfCall(0), | 1255 | searchQueryOfCall(0), |
| 1031 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', | 1256 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', |
| 1032 | ); | 1257 | ); |
| 1033 | expect(callAt(0).args).not.toContain("-f"); | ||
| 1034 | }), | 1258 | }), |
| 1035 | ); | 1259 | ); |
| 1036 | 1260 | ||
| ... | @@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1139 | 1363 | ||
| 1140 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | 1364 | it.effect("enriches only the visible fallback rows after filtering and widening", () => |
| 1141 | Effect.gen(function* () { | 1365 | Effect.gen(function* () { |
| 1142 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1366 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1143 | mockedExecute.mockReturnValueOnce( | 1367 | mockedExecute.mockReturnValueOnce( |
| 1144 | Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), | 1368 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), |
| 1145 | ); | 1369 | ); |
| 1146 | mockedExecute.mockReturnValueOnce( | 1370 | mockedExecute.mockReturnValueOnce( |
| 1147 | Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | 1371 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), |
| 1148 | ); | 1372 | ); |
| 1149 | mockedStackMemberships.mockReturnValueOnce( | 1373 | mockedStackMemberships.mockReturnValueOnce( |
| 1150 | Effect.succeed( | 1374 | Effect.succeed( |
| ... | @@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1180 | expect(batch.truncated).toBe(true); | 1404 | expect(batch.truncated).toBe(true); |
| 1181 | expect(batch.continues).toBe(false); | 1405 | expect(batch.continues).toBe(false); |
| 1182 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | 1406 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); |
| 1183 | const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; | 1407 | const membership = mockedStackMemberships.mock.calls[0]?.[0]; |
| 1408 | const query = membership?.kind === "graphql" ? membership.query : ""; | ||
| 1184 | expect(query).toContain("pullRequest(number: 4)"); | 1409 | expect(query).toContain("pullRequest(number: 4)"); |
| 1185 | expect(query).toContain("pullRequest(number: 5)"); | 1410 | expect(query).toContain("pullRequest(number: 5)"); |
| 1186 | expect(query).not.toContain("pullRequest(number: 1)"); | 1411 | expect(query).not.toContain("pullRequest(number: 1)"); |
| ... | @@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1192 | Effect.gen(function* () { | 1417 | Effect.gen(function* () { |
| 1193 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | 1418 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); |
| 1194 | mockedStackMemberships.mockImplementation((input) => | 1419 | mockedStackMemberships.mockImplementation((input) => |
| 1195 | input.args.at(-1)?.includes("pullRequest(number: 26)") | 1420 | input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") |
| 1196 | ? Effect.fail( | 1421 | ? Effect.fail( |
| 1197 | new GitHubCli.GitHubCliCommandError({ | 1422 | new GitHubApi.GitHubApiResponseError({ |
| 1198 | command: "gh", | 1423 | host: "github.com", |
| 1199 | cwd: "/w", | 1424 | operation: "listPullRequestStackMemberships", |
| 1200 | cause: new Error("HTTP 502"), | 1425 | status: 502, |
| 1201 | }), | 1426 | }), |
| 1202 | ) | 1427 | ) |
| 1203 | : Effect.succeed( | 1428 | : Effect.succeed( |
| ... | @@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1238 | 1463 | ||
| 1239 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | 1464 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => |
| 1240 | Effect.gen(function* () { | 1465 | Effect.gen(function* () { |
| 1241 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1466 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1242 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1467 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); |
| 1243 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | 1468 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); |
| 1244 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1469 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1245 | const input = { | 1470 | const input = { |
| ... | @@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1268 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is | 1493 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is |
| 1269 | // dropped rather than shown as a change of no size. | 1494 | // dropped rather than shown as a change of no size. |
| 1270 | Effect.succeed( | 1495 | Effect.succeed( |
| 1271 | output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), | 1496 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), |
| 1272 | ), | 1497 | ), |
| 1273 | ); | 1498 | ); |
| 1274 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1499 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1285 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, | 1510 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, |
| 1286 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, | 1511 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, |
| 1287 | ]); | 1512 | ]); |
| 1288 | const document = callAt(0).args.at(-1) ?? ""; | 1513 | const document = queryAt(0); |
| 1289 | expect(document).toContain('s0: repository(owner: "acme", name: "web")'); | 1514 | expect(document).toContain('s0: repository(owner: "acme", name: "web")'); |
| 1290 | expect(document).toContain("pullRequest(number: 25)"); | 1515 | expect(document).toContain("pullRequest(number: 25)"); |
| 1291 | }), | 1516 | }), |
| ... | @@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1310 | 1535 | ||
| 1311 | it.effect("hands a search to GitHub rather than to the rows already read", () => | 1536 | it.effect("hands a search to GitHub rather than to the rows already read", () => |
| 1312 | Effect.gen(function* () { | 1537 | Effect.gen(function* () { |
| 1313 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1538 | mockedExecute.mockImplementation((call) => |
| 1539 | Effect.succeed( | ||
| 1540 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1541 | ? emptyList() | ||
| 1542 | : emptySearch(), | ||
| 1543 | ), | ||
| 1544 | ); | ||
| 1314 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1545 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1315 | 1546 | ||
| 1316 | yield* cli.listPullRequests({ | 1547 | yield* cli.listPullRequests({ |
| ... | @@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1326 | 1557 | ||
| 1327 | // The recency qualifier rides along, because free text would otherwise reorder the page | 1558 | // The recency qualifier rides along, because free text would otherwise reorder the page |
| 1328 | // by relevance and truncation would drop the newest matches. | 1559 | // by relevance and truncation would drop the newest matches. |
| 1329 | expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc'); | 1560 | expect(searchOfCall(0)).toBe( |
| 1561 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', | ||
| 1562 | ); | ||
| 1330 | }), | 1563 | }), |
| 1331 | ); | 1564 | ); |
| 1332 | 1565 | ||
| 1333 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => | 1566 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => |
| 1334 | Effect.gen(function* () { | 1567 | Effect.gen(function* () { |
| 1335 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1568 | mockedExecute.mockImplementation((call) => |
| 1569 | Effect.succeed( | ||
| 1570 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1571 | ? emptyList() | ||
| 1572 | : emptySearch(), | ||
| 1573 | ), | ||
| 1574 | ); | ||
| 1336 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1575 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1337 | 1576 | ||
| 1338 | yield* cli.listPullRequests({ | 1577 | yield* cli.listPullRequests({ |
| ... | @@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1346 | query: "page", | 1585 | query: "page", |
| 1347 | }); | 1586 | }); |
| 1348 | 1587 | ||
| 1349 | // One `--search` is all gh reads, so a second would silently drop the first. | 1588 | expect(searchOfCall(0)).toBe( |
| 1350 | const args = callAt(0).args; | 1589 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', |
| 1351 | assert.strictEqual(args.filter((arg) => arg === "--search").length, 1); | 1590 | ); |
| 1352 | expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc'); | ||
| 1353 | }), | 1591 | }), |
| 1354 | ); | 1592 | ); |
| 1355 | 1593 | ||
| 1356 | it.effect("carries the further narrowings into the search as qualifiers", () => | 1594 | it.effect("carries the further narrowings into the search as qualifiers", () => |
| 1357 | Effect.gen(function* () { | 1595 | Effect.gen(function* () { |
| 1358 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1596 | mockedExecute.mockImplementation((call) => |
| 1597 | Effect.succeed( | ||
| 1598 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1599 | ? emptyList() | ||
| 1600 | : emptySearch(), | ||
| 1601 | ), | ||
| 1602 | ); | ||
| 1359 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1603 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1360 | 1604 | ||
| 1361 | yield* cli.listPullRequests({ | 1605 | yield* cli.listPullRequests({ |
| ... | @@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1379 | // Quotes around anything a reader typed, and the one character that could end a quoted | 1623 | // Quotes around anything a reader typed, and the one character that could end a quoted |
| 1380 | // value early dropped rather than escaped. | 1624 | // value early dropped rather than escaped. |
| 1381 | expect(searchOfCall(0)).toBe( | 1625 | expect(searchOfCall(0)).toBe( |
| 1382 | 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' + | 1626 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + |
| 1383 | "review:changes_requested status:failure sort:updated-desc", | 1627 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", |
| 1384 | ); | 1628 | ); |
| 1385 | }), | 1629 | }), |
| 1386 | ); | 1630 | ); |
| 1387 | 1631 | ||
| 1388 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => | 1632 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => |
| 1389 | Effect.gen(function* () { | 1633 | Effect.gen(function* () { |
| 1390 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1634 | mockedExecute.mockImplementation((call) => |
| 1635 | Effect.succeed( | ||
| 1636 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1637 | ? emptyList() | ||
| 1638 | : emptySearch(), | ||
| 1639 | ), | ||
| 1640 | ); | ||
| 1391 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1641 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1392 | 1642 | ||
| 1393 | yield* cli.listPullRequests({ | 1643 | yield* cli.listPullRequests({ |
| ... | @@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1401 | filters: { author: "me" }, | 1651 | filters: { author: "me" }, |
| 1402 | }); | 1652 | }); |
| 1403 | 1653 | ||
| 1404 | expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc'); | 1654 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); |
| 1405 | }), | 1655 | }), |
| 1406 | ); | 1656 | ); |
| 1407 | 1657 | ||
| 1408 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => | 1658 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => |
| 1409 | Effect.gen(function* () { | 1659 | Effect.gen(function* () { |
| 1410 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1660 | mockedExecute.mockImplementation((call) => |
| 1661 | Effect.succeed( | ||
| 1662 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1663 | ? emptyList() | ||
| 1664 | : emptySearch(), | ||
| 1665 | ), | ||
| 1666 | ); | ||
| 1411 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1667 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1412 | 1668 | ||
| 1413 | yield* cli.listPullRequests({ | 1669 | yield* cli.listPullRequests({ |
| ... | @@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1422 | }); | 1678 | }); |
| 1423 | 1679 | ||
| 1424 | // One qualifier satisfied by either size, and a second one that must hold as well. | 1680 | // One qualifier satisfied by either size, and a second one that must hold as well. |
| 1425 | expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc'); | 1681 | expect(searchOfCall(0)).toBe( |
| 1426 | expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc'); | 1682 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', |
| 1683 | ); | ||
| 1427 | }), | 1684 | }), |
| 1428 | ); | 1685 | ); |
| 1429 | 1686 | ||
| ... | @@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1431 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", | 1688 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", |
| 1432 | () => | 1689 | () => |
| 1433 | Effect.gen(function* () { | 1690 | Effect.gen(function* () { |
| 1434 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1691 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1435 | mockedExecute.mockReturnValueOnce( | 1692 | mockedExecute.mockReturnValueOnce( |
| 1436 | Effect.succeed( | 1693 | Effect.succeed( |
| 1437 | output( | 1694 | output( |
| 1438 | pullRequests(2, 1, (number) => ({ | 1695 | listedPullRequests(2, 1, (number) => ({ |
| 1439 | statusCheckRollup: | 1696 | checks: number === 1 ? "SUCCESS" : "FAILURE", |
| 1440 | number === 1 | ||
| 1441 | ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] | ||
| 1442 | : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }], | ||
| 1443 | })), | 1697 | })), |
| 1444 | ), | 1698 | ), |
| 1445 | ), | 1699 | ), |
| ... | @@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1471 | 1725 | ||
| 1472 | it.effect("fails a checks filter for a row whose checks are still pending", () => | 1726 | it.effect("fails a checks filter for a row whose checks are still pending", () => |
| 1473 | Effect.gen(function* () { | 1727 | Effect.gen(function* () { |
| 1474 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1728 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1475 | mockedExecute.mockReturnValueOnce( | 1729 | mockedExecute.mockReturnValueOnce( |
| 1476 | Effect.succeed( | 1730 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), |
| 1477 | output( | ||
| 1478 | pullRequests(1, 1, () => ({ | ||
| 1479 | statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }], | ||
| 1480 | })), | ||
| 1481 | ), | ||
| 1482 | ), | ||
| 1483 | ); | 1731 | ); |
| 1484 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1732 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1485 | 1733 | ||
| ... | @@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1504 | "falls back for a repository the index does not cover even under a judgeable filter", | 1752 | "falls back for a repository the index does not cover even under a judgeable filter", |
| 1505 | () => | 1753 | () => |
| 1506 | Effect.gen(function* () { | 1754 | Effect.gen(function* () { |
| 1507 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1755 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1508 | mockedExecute.mockReturnValueOnce( | 1756 | mockedExecute.mockReturnValueOnce( |
| 1509 | Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), | 1757 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), |
| 1510 | ); | 1758 | ); |
| 1511 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1759 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1512 | 1760 | ||
| ... | @@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1555 | 1803 | ||
| 1556 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => | 1804 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => |
| 1557 | Effect.gen(function* () { | 1805 | Effect.gen(function* () { |
| 1558 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1806 | mockedExecute.mockImplementation((call) => |
| 1807 | Effect.succeed( | ||
| 1808 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1809 | ? emptyList() | ||
| 1810 | : emptySearch(), | ||
| 1811 | ), | ||
| 1812 | ); | ||
| 1559 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1813 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1560 | 1814 | ||
| 1561 | yield* cli.listPullRequests({ | 1815 | yield* cli.listPullRequests({ |
| ... | @@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1572 | // Every word stays inside one phrase: nothing before it, nothing after it, and the | 1826 | // Every word stays inside one phrase: nothing before it, nothing after it, and the |
| 1573 | // leading dashes are text rather than the start of another argument. | 1827 | // leading dashes are text rather than the start of another argument. |
| 1574 | expect(searchOfCall(0)).toBe( | 1828 | expect(searchOfCall(0)).toBe( |
| 1575 | String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`, | 1829 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, |
| 1576 | ); | 1830 | ); |
| 1577 | expect(callAt(0).args).not.toContain("is:merged"); | ||
| 1578 | }), | 1831 | }), |
| 1579 | ); | 1832 | ); |
| 1580 | 1833 | ||
| 1581 | it.effect("escapes a backslash before the quote it would otherwise let out", () => | 1834 | it.effect("escapes a backslash before the quote it would otherwise let out", () => |
| 1582 | Effect.gen(function* () { | 1835 | Effect.gen(function* () { |
| 1583 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1836 | mockedExecute.mockImplementation((call) => |
| 1837 | Effect.succeed( | ||
| 1838 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1839 | ? emptyList() | ||
| 1840 | : emptySearch(), | ||
| 1841 | ), | ||
| 1842 | ); | ||
| 1584 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1843 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1585 | 1844 | ||
| 1586 | yield* cli.listPullRequests({ | 1845 | yield* cli.listPullRequests({ |
| ... | @@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1596 | 1855 | ||
| 1597 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where | 1856 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where |
| 1598 | // this says it does; escaping the quote alone would have closed it early. | 1857 | // this says it does; escaping the quote alone would have closed it early. |
| 1599 | expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`); | 1858 | expect(searchOfCall(0)).toBe( |
| 1859 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, | ||
| 1860 | ); | ||
| 1600 | }), | 1861 | }), |
| 1601 | ); | 1862 | ); |
| 1602 | 1863 | ||
| 1603 | it.effect("asks for nothing but the order when the reader typed only spaces", () => | 1864 | it.effect("asks for nothing but the order when the reader typed only spaces", () => |
| 1604 | Effect.gen(function* () { | 1865 | Effect.gen(function* () { |
| 1605 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 1866 | mockedExecute.mockImplementation((call) => |
| 1867 | Effect.succeed( | ||
| 1868 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 1869 | ? emptyList() | ||
| 1870 | : emptySearch(), | ||
| 1871 | ), | ||
| 1872 | ); | ||
| 1606 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1873 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1607 | 1874 | ||
| 1608 | yield* cli.listPullRequests({ | 1875 | yield* cli.listPullRequests({ |
| ... | @@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1618 | 1885 | ||
| 1619 | // An empty phrase would match nothing rather than everything, so it is left out; the | 1886 | // An empty phrase would match nothing rather than everything, so it is left out; the |
| 1620 | // order the page reads rows in is asked for whether or not anything was typed. | 1887 | // order the page reads rows in is asked for whether or not anything was typed. |
| 1621 | expect(searchOfCall(0)).toBe("sort:updated-desc"); | 1888 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); |
| 1622 | }), | 1889 | }), |
| 1623 | ); | 1890 | ); |
| 1624 | 1891 | ||
| ... | @@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1640 | 1907 | ||
| 1641 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — | 1908 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — |
| 1642 | // which is what keeps the ones beside them from being skipped. | 1909 | // which is what keeps the ones beside them from being skipped. |
| 1643 | expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc"); | 1910 | expect(searchOfCall(0)).toBe( |
| 1911 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", | ||
| 1912 | ); | ||
| 1644 | assert.isTrue(batch.continues); | 1913 | assert.isTrue(batch.continues); |
| 1645 | }), | 1914 | }), |
| 1646 | ); | 1915 | ); |
| ... | @@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1650 | // The fallback is for a repository the index does not cover. Under a text search an empty | 1919 | // The fallback is for a repository the index does not cover. Under a text search an empty |
| 1651 | // answer means the text matched nothing, and listing everything instead would fill the | 1920 | // answer means the text matched nothing, and listing everything instead would fill the |
| 1652 | // page with rows the reader did not search for. | 1921 | // page with rows the reader did not search for. |
| 1653 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1922 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1654 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1923 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1655 | 1924 | ||
| 1656 | const batch = yield* cli.listPullRequests({ | 1925 | const batch = yield* cli.listPullRequests({ |
| ... | @@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1669 | }), | 1938 | }), |
| 1670 | ); | 1939 | ); |
| 1671 | 1940 | ||
| 1672 | it.effect("reads a repository GitHub will not search the way gh lists one", () => | 1941 | it.effect("reads a repository GitHub will not search from its own list", () => |
| 1673 | Effect.gen(function* () { | 1942 | Effect.gen(function* () { |
| 1674 | // GitHub answers for a repository outside its search index with no rows and no error. | 1943 | // GitHub answers for a repository outside its search index with no rows and no error. |
| 1675 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1944 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1676 | mockedExecute.mockReturnValueOnce( | 1945 | mockedExecute.mockReturnValueOnce( |
| 1677 | Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))), | 1946 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), |
| 1678 | ); | 1947 | ); |
| 1679 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 1948 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1680 | 1949 | ||
| ... | @@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1689 | }); | 1958 | }); |
| 1690 | 1959 | ||
| 1691 | assert.strictEqual(batch.items.length, 3); | 1960 | assert.strictEqual(batch.items.length, 3); |
| 1692 | // The fallback itself uses no search, then narrows the decoded rows locally. They still | 1961 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive |
| 1693 | // arrive in gh's own order, so nothing can carry on from them. | 1962 | // newest-created first, so nothing can carry on from them. |
| 1694 | expect(searchOfCall(1)).toBeUndefined(); | 1963 | expect(searchOfCall(1)).toBeUndefined(); |
| 1964 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | ||
| 1695 | assert.isFalse(batch.continues); | 1965 | assert.isFalse(batch.continues); |
| 1696 | }), | 1966 | }), |
| 1697 | ); | 1967 | ); |
| 1698 | 1968 | ||
| 1699 | it.effect("keeps state and involvement filters on the search-free fallback", () => | 1969 | it.effect("keeps state and involvement filters on the search-free fallback", () => |
| 1700 | Effect.gen(function* () { | 1970 | Effect.gen(function* () { |
| 1701 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 1971 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1702 | mockedExecute.mockReturnValueOnce( | 1972 | mockedExecute.mockReturnValueOnce( |
| 1703 | Effect.succeed( | 1973 | Effect.succeed( |
| 1704 | output( | 1974 | output( |
| 1705 | pullRequests(4, 1, (number) => ({ | 1975 | listedPullRequests(4, 1, (number) => ({ |
| 1706 | state: number === 4 ? "OPEN" : "CLOSED", | 1976 | state: number === 4 ? "OPEN" : "CLOSED", |
| 1707 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), | 1977 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), |
| 1708 | reviewRequests: | 1978 | reviewRequests: |
| ... | @@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1734 | it.effect("grows the search-free fallback until it fills the filtered page", () => | 2004 | it.effect("grows the search-free fallback until it fills the filtered page", () => |
| 1735 | Effect.gen(function* () { | 2005 | Effect.gen(function* () { |
| 1736 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); | 2006 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); |
| 1737 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 2007 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1738 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated)))); | 2008 | mockedExecute.mockReturnValueOnce( |
| 2009 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | ||
| 2010 | ); | ||
| 1739 | mockedExecute.mockReturnValueOnce( | 2011 | mockedExecute.mockReturnValueOnce( |
| 1740 | Effect.succeed( | 2012 | Effect.succeed( |
| 1741 | output( | 2013 | output( |
| 1742 | pullRequests(4, 1, (number) => | 2014 | listedPullRequests(4, 1, (number) => |
| 1743 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), | 2015 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), |
| 1744 | ), | 2016 | ), |
| 1745 | ), | 2017 | ), |
| ... | @@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1758 | }); | 2030 | }); |
| 1759 | 2031 | ||
| 1760 | expect(batch.items.map((item) => item.number)).toEqual([4]); | 2032 | expect(batch.items.map((item) => item.number)).toEqual([4]); |
| 1761 | const firstFallbackArgs = callAt(1).args; | 2033 | expect(queryAt(1)).toContain("first: 3,"); |
| 1762 | const secondFallbackArgs = callAt(2).args; | 2034 | expect(queryAt(2)).toContain("first: 6,"); |
| 1763 | expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3"); | ||
| 1764 | expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6"); | ||
| 1765 | assert.isFalse(batch.truncated); | 2035 | assert.isFalse(batch.truncated); |
| 1766 | }), | 2036 | }), |
| 1767 | ); | 2037 | ); |
| 1768 | 2038 | ||
| 1769 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => | 2039 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => |
| 1770 | Effect.gen(function* () { | 2040 | Effect.gen(function* () { |
| 1771 | mockedExecute.mockImplementation((_input) => { | 2041 | // A repository with far more pull requests than the bound, none of them for this reader. |
| 1772 | if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]")); | 2042 | let listed = 0; |
| 1773 | const args = callAt(mockedExecute.mock.calls.length - 1).args; | 2043 | mockedExecute.mockImplementation((call) => { |
| 1774 | const limit = Number(args[args.indexOf("--limit") + 1]); | 2044 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { |
| 2045 | return Effect.succeed(emptySearch()); | ||
| 2046 | } | ||
| 2047 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); | ||
| 2048 | const nodes = rows(first, listed + 1, () => ({ | ||
| 2049 | reviewRequests: [{ login: "somebody-else" }], | ||
| 2050 | })); | ||
| 2051 | listed += first; | ||
| 1775 | return Effect.succeed( | 2052 | return Effect.succeed( |
| 1776 | output( | 2053 | output( |
| 1777 | pullRequests(limit, 1, () => ({ | 2054 | encodeJson({ |
| 1778 | reviewRequests: [{ login: "somebody-else" }], | 2055 | data: { |
| 1779 | })), | 2056 | repository: { |
| 2057 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, | ||
| 2058 | }, | ||
| 2059 | }, | ||
| 2060 | }), | ||
| 1780 | ), | 2061 | ), |
| 1781 | ); | 2062 | ); |
| 1782 | }); | 2063 | }); |
| ... | @@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1792 | limit: 2, | 2073 | limit: 2, |
| 1793 | }); | 2074 | }); |
| 1794 | 2075 | ||
| 1795 | const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args; | 2076 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. |
| 1796 | expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000"); | 2077 | const last = mockedExecute.mock.calls.length - 1; |
| 2078 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); | ||
| 2079 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); | ||
| 1797 | assert.strictEqual(batch.items.length, 0); | 2080 | assert.strictEqual(batch.items.length, 0); |
| 1798 | assert.isTrue(batch.truncated); | 2081 | assert.isTrue(batch.truncated); |
| 1799 | }), | 2082 | }), |
| ... | @@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1801 | 2084 | ||
| 1802 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => | 2085 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => |
| 1803 | Effect.gen(function* () { | 2086 | Effect.gen(function* () { |
| 1804 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 2087 | mockedExecute.mockImplementation((call) => |
| 2088 | Effect.succeed( | ||
| 2089 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 2090 | ? emptyList() | ||
| 2091 | : emptySearch(), | ||
| 2092 | ), | ||
| 2093 | ); | ||
| 1805 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2094 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1806 | 2095 | ||
| 1807 | yield* cli.listPullRequests({ | 2096 | yield* cli.listPullRequests({ |
| ... | @@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1821 | }), | 2110 | }), |
| 1822 | ); | 2111 | ); |
| 1823 | 2112 | ||
| 2113 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ | ||
| 2114 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | ||
| 2115 | data: { | ||
| 2116 | repository: { | ||
| 2117 | pullRequest: { | ||
| 2118 | id: "PR_7", | ||
| 2119 | headRefOid: "abc123", | ||
| 2120 | isMergeQueueEnabled: false, | ||
| 2121 | mergeStateStatus: "BLOCKED", | ||
| 2122 | baseRef: { compare: { behindBy: 2 } }, | ||
| 2123 | ...pullRequest, | ||
| 2124 | }, | ||
| 2125 | }, | ||
| 2126 | }, | ||
| 2127 | }); | ||
| 2128 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ | ||
| 2129 | data: { | ||
| 2130 | repository: { | ||
| 2131 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, | ||
| 2132 | }, | ||
| 2133 | }, | ||
| 2134 | }); | ||
| 2135 | |||
| 1824 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => | 2136 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => |
| 1825 | Effect.gen(function* () { | 2137 | Effect.gen(function* () { |
| 1826 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2138 | route(["query PullRequestActionState", actionState()]); |
| 1827 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2139 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1828 | 2140 | ||
| 1829 | yield* cli.runPullRequestAction({ | 2141 | yield* cli.runPullRequestAction({ |
| ... | @@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1833 | number: 7, | 2145 | number: 7, |
| 1834 | action: "update-branch", | 2146 | action: "update-branch", |
| 1835 | }); | 2147 | }); |
| 1836 | // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise. | ||
| 1837 | expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]); | ||
| 1838 | |||
| 1839 | yield* cli.runPullRequestAction({ | 2148 | yield* cli.runPullRequestAction({ |
| 1840 | cwd: "/w", | 2149 | cwd: "/w", |
| 1841 | repository: "acme/web", | 2150 | repository: "acme/web", |
| ... | @@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1844 | action: "update-branch", | 2153 | action: "update-branch", |
| 1845 | updateMethod: "rebase", | 2154 | updateMethod: "rebase", |
| 1846 | }); | 2155 | }); |
| 1847 | expect(callAt(1).args).toEqual([ | 2156 | |
| 1848 | "pr", | 2157 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. |
| 1849 | "update-branch", | 2158 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ |
| 1850 | "7", | 2159 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, |
| 1851 | "--repo", | 2160 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, |
| 1852 | "github.com/acme/web", | ||
| 1853 | "--rebase", | ||
| 1854 | ]); | 2161 | ]); |
| 1855 | }), | 2162 | }), |
| 1856 | ); | 2163 | ); |
| 1857 | 2164 | ||
| 1858 | it.effect("merges with the strategy it was asked for", () => | 2165 | it.effect("merges with the strategy it was asked for", () => |
| 1859 | Effect.gen(function* () { | 2166 | Effect.gen(function* () { |
| 1860 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2167 | route(["query PullRequestActionState", actionState()]); |
| 1861 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2168 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1862 | 2169 | ||
| 1863 | yield* cli.runPullRequestAction({ | 2170 | yield* cli.runPullRequestAction({ |
| ... | @@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1869 | mergeMethod: "squash", | 2176 | mergeMethod: "squash", |
| 1870 | }); | 2177 | }); |
| 1871 | 2178 | ||
| 1872 | expect(callAt(0).args).toEqual([ | 2179 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 1873 | "pr", | 2180 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, |
| 1874 | "merge", | ||
| 1875 | "7", | ||
| 1876 | "--repo", | ||
| 1877 | "github.com/acme/web", | ||
| 1878 | "--squash", | ||
| 1879 | ]); | 2181 | ]); |
| 2182 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | ||
| 1880 | }), | 2183 | }), |
| 1881 | ); | 2184 | ); |
| 1882 | 2185 | ||
| ... | @@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1884 | "removes agent credits from the proposed message for %s", | 2187 | "removes agent credits from the proposed message for %s", |
| 1885 | (action) => | 2188 | (action) => |
| 1886 | Effect.gen(function* () { | 2189 | Effect.gen(function* () { |
| 1887 | mockedExecute | 2190 | route( |
| 1888 | .mockReturnValueOnce( | 2191 | [ |
| 1889 | Effect.succeed( | 2192 | "query PullRequestMergeMessage", |
| 1890 | output( | 2193 | mergeMessage( |
| 1891 | encodeJson({ | 2194 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", |
| 1892 | data: { | ||
| 1893 | repository: { | ||
| 1894 | pullRequest: { | ||
| 1895 | isMergeQueueEnabled: false, | ||
| 1896 | headRefOid: "abc123", | ||
| 1897 | viewerMergeBodyText: | ||
| 1898 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | ||
| 1899 | }, | ||
| 1900 | }, | ||
| 1901 | }, | ||
| 1902 | }), | ||
| 1903 | ), | ||
| 1904 | ), | 2195 | ), |
| 1905 | ) | 2196 | ], |
| 1906 | .mockReturnValueOnce(Effect.succeed(output(""))); | 2197 | ["query PullRequestActionState", actionState()], |
| 2198 | ); | ||
| 1907 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2199 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1908 | yield* cli.runPullRequestAction({ | 2200 | yield* cli.runPullRequestAction({ |
| 1909 | cwd: "/w", | 2201 | cwd: "/w", |
| ... | @@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1914 | mergeMethod: "squash", | 2206 | mergeMethod: "squash", |
| 1915 | removeAgentCreditsOnMerge: true, | 2207 | removeAgentCreditsOnMerge: true, |
| 1916 | }); | 2208 | }); |
| 1917 | expect(callAt(0).args).toContain("method=SQUASH"); | 2209 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); |
| 1918 | expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]); | 2210 | expect( |
| 1919 | expect(callAt(1).args).toContain("--match-head-commit"); | 2211 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), |
| 1920 | expect(callAt(1).args).toContain("abc123"); | 2212 | ).toEqual([ |
| 1921 | expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>"); | 2213 | { |
| 1922 | expect(callAt(1).args.join(" ")).not.toContain("alice@example.com"); | 2214 | input: { |
| 2215 | pullRequestId: "PR_7", | ||
| 2216 | mergeMethod: "SQUASH", | ||
| 2217 | // Pinned to the head the message was read from, so it cannot describe other commits. | ||
| 2218 | expectedHeadOid: "abc123", | ||
| 2219 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", | ||
| 2220 | }, | ||
| 2221 | }, | ||
| 2222 | ]); | ||
| 1923 | }), | 2223 | }), |
| 1924 | ); | 2224 | ); |
| 1925 | 2225 | ||
| ... | @@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1936 | }, | 2236 | }, |
| 1937 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => | 2237 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => |
| 1938 | Effect.gen(function* () { | 2238 | Effect.gen(function* () { |
| 1939 | mockedExecute | 2239 | route( |
| 1940 | .mockReturnValueOnce( | 2240 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], |
| 1941 | Effect.succeed( | 2241 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], |
| 1942 | output( | 2242 | ); |
| 1943 | encodeJson({ | ||
| 1944 | data: { | ||
| 1945 | repository: { | ||
| 1946 | pullRequest: { | ||
| 1947 | isMergeQueueEnabled: queued, | ||
| 1948 | headRefOid: "abc123", | ||
| 1949 | viewerMergeBodyText: body, | ||
| 1950 | }, | ||
| 1951 | }, | ||
| 1952 | }, | ||
| 1953 | }), | ||
| 1954 | ), | ||
| 1955 | ), | ||
| 1956 | ) | ||
| 1957 | .mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 1958 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2243 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1959 | yield* cli.runPullRequestAction({ | 2244 | yield* cli.runPullRequestAction({ |
| 1960 | cwd: "/w", | 2245 | cwd: "/w", |
| ... | @@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1964 | action: "merge", | 2249 | action: "merge", |
| 1965 | removeAgentCreditsOnMerge: true, | 2250 | removeAgentCreditsOnMerge: true, |
| 1966 | }); | 2251 | }); |
| 1967 | expect(callAt(0).args).toContain("method=MERGE"); | 2252 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); |
| 1968 | expect(callAt(1).args).not.toContain("--body-file"); | 2253 | // A merge queue takes the pull request through auto-merge, with its own message. |
| 1969 | expect(callAt(1).stdin).toBeUndefined(); | 2254 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ |
| 2255 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | ||
| 2256 | ]); | ||
| 1970 | }), | 2257 | }), |
| 1971 | ); | 2258 | ); |
| 1972 | 2259 | ||
| 1973 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => | 2260 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => |
| 1974 | Effect.gen(function* () { | 2261 | Effect.gen(function* () { |
| 1975 | mockedExecute | 2262 | route( |
| 1976 | .mockReturnValueOnce( | 2263 | [ |
| 1977 | Effect.succeed( | 2264 | "query PullRequestMergeMessage", |
| 1978 | output( | 2265 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), |
| 1979 | encodeJson({ | 2266 | ], |
| 1980 | data: { | 2267 | ["query PullRequestActionState", actionState()], |
| 1981 | repository: { | 2268 | ); |
| 1982 | pullRequest: { | ||
| 1983 | isMergeQueueEnabled: false, | ||
| 1984 | headRefOid: "abc123", | ||
| 1985 | viewerMergeBodyText: "Co-authored-by: Claude <noreply@anthropic.com>", | ||
| 1986 | }, | ||
| 1987 | }, | ||
| 1988 | }, | ||
| 1989 | }), | ||
| 1990 | ), | ||
| 1991 | ), | ||
| 1992 | ) | ||
| 1993 | .mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 1994 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2269 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1995 | yield* cli.runPullRequestAction({ | 2270 | yield* cli.runPullRequestAction({ |
| 1996 | cwd: "/w", | 2271 | cwd: "/w", |
| ... | @@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2000 | action: "merge", | 2275 | action: "merge", |
| 2001 | removeAgentCreditsOnMerge: true, | 2276 | removeAgentCreditsOnMerge: true, |
| 2002 | }); | 2277 | }); |
| 2003 | expect(callAt(1).stdin).toBe(""); | 2278 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2004 | expect(callAt(1).args).toContain("--body-file"); | 2279 | { |
| 2280 | input: { | ||
| 2281 | pullRequestId: "PR_7", | ||
| 2282 | mergeMethod: "MERGE", | ||
| 2283 | expectedHeadOid: "abc123", | ||
| 2284 | commitBody: "", | ||
| 2285 | }, | ||
| 2286 | }, | ||
| 2287 | ]); | ||
| 2005 | }), | 2288 | }), |
| 2006 | ); | 2289 | ); |
| 2007 | 2290 | ||
| 2008 | it.effect("does not fetch a message for rebase merges", () => | 2291 | it.effect("does not fetch a message for rebase merges", () => |
| 2009 | Effect.gen(function* () { | 2292 | Effect.gen(function* () { |
| 2010 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2293 | route(["query PullRequestActionState", actionState()]); |
| 2011 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2294 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2012 | yield* cli.runPullRequestAction({ | 2295 | yield* cli.runPullRequestAction({ |
| 2013 | cwd: "/w", | 2296 | cwd: "/w", |
| ... | @@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2018 | mergeMethod: "rebase", | 2301 | mergeMethod: "rebase", |
| 2019 | removeAgentCreditsOnMerge: true, | 2302 | removeAgentCreditsOnMerge: true, |
| 2020 | }); | 2303 | }); |
| 2021 | expect(mockedExecute).toHaveBeenCalledTimes(1); | 2304 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); |
| 2022 | expect(callAt(0).args).toContain("--rebase"); | 2305 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2306 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | ||
| 2307 | ]); | ||
| 2023 | }), | 2308 | }), |
| 2024 | ); | 2309 | ); |
| 2025 | 2310 | ||
| 2026 | it.effect("refuses to merge when the proposed message cannot be read", () => | 2311 | it.effect("refuses to merge when the proposed message cannot be read", () => |
| 2027 | Effect.gen(function* () { | 2312 | Effect.gen(function* () { |
| 2028 | mockedExecute.mockReturnValue( | 2313 | route( |
| 2029 | Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')), | 2314 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], |
| 2315 | ["query PullRequestActionState", actionState()], | ||
| 2030 | ); | 2316 | ); |
| 2031 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2317 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2032 | const result = yield* Effect.result( | 2318 | const result = yield* Effect.result( |
| ... | @@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2040 | }), | 2326 | }), |
| 2041 | ); | 2327 | ); |
| 2042 | expect(result._tag).toBe("Failure"); | 2328 | expect(result._tag).toBe("Failure"); |
| 2043 | expect(mockedExecute).toHaveBeenCalledTimes(1); | 2329 | expect(variablesOf("mergePullRequest(")).toEqual([]); |
| 2044 | }), | 2330 | }), |
| 2045 | ); | 2331 | ); |
| 2046 | 2332 | ||
| 2047 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | 2333 | it.effect("arms auto-merge with the same strategy a merge would have used", () => |
| 2048 | Effect.gen(function* () { | 2334 | Effect.gen(function* () { |
| 2049 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2335 | route(["query PullRequestActionState", actionState()]); |
| 2050 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2336 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2051 | 2337 | ||
| 2052 | yield* cli.runPullRequestAction({ | 2338 | yield* cli.runPullRequestAction({ |
| ... | @@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2057 | action: "enable-auto-merge", | 2343 | action: "enable-auto-merge", |
| 2058 | mergeMethod: "squash", | 2344 | mergeMethod: "squash", |
| 2059 | }); | 2345 | }); |
| 2060 | expect(callAt(0).args).toEqual([ | 2346 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. |
| 2061 | "pr", | 2347 | yield* cli.runPullRequestAction({ |
| 2062 | "merge", | 2348 | cwd: "/w", |
| 2063 | "7", | 2349 | repository: "acme/web", |
| 2064 | "--repo", | 2350 | host: "github.com", |
| 2065 | "github.com/acme/web", | 2351 | number: 7, |
| 2066 | "--auto", | 2352 | action: "enable-auto-merge", |
| 2067 | "--squash", | 2353 | }); |
| 2354 | |||
| 2355 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ | ||
| 2356 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | ||
| 2357 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | ||
| 2068 | ]); | 2358 | ]); |
| 2359 | expect(variablesOf("mergePullRequest(")).toEqual([]); | ||
| 2360 | }), | ||
| 2361 | ); | ||
| 2362 | |||
| 2363 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => | ||
| 2364 | Effect.gen(function* () { | ||
| 2365 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); | ||
| 2366 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 2069 | 2367 | ||
| 2070 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | ||
| 2071 | yield* cli.runPullRequestAction({ | 2368 | yield* cli.runPullRequestAction({ |
| 2072 | cwd: "/w", | 2369 | cwd: "/w", |
| 2073 | repository: "acme/web", | 2370 | repository: "acme/web", |
| 2074 | host: "github.com", | 2371 | host: "github.com", |
| 2075 | number: 7, | 2372 | number: 7, |
| 2076 | action: "enable-auto-merge", | 2373 | action: "enable-auto-merge", |
| 2374 | mergeMethod: "squash", | ||
| 2077 | }); | 2375 | }); |
| 2078 | expect(callAt(1).args).toEqual([ | 2376 | |
| 2079 | "pr", | 2377 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2080 | "merge", | 2378 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, |
| 2081 | "7", | ||
| 2082 | "--repo", | ||
| 2083 | "github.com/acme/web", | ||
| 2084 | "--auto", | ||
| 2085 | "--merge", | ||
| 2086 | ]); | 2379 | ]); |
| 2380 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | ||
| 2087 | }), | 2381 | }), |
| 2088 | ); | 2382 | ); |
| 2089 | 2383 | ||
| 2090 | it.effect("takes auto-merge back off without naming a strategy", () => | 2384 | it.effect("takes auto-merge back off without naming a strategy", () => |
| 2091 | Effect.gen(function* () { | 2385 | Effect.gen(function* () { |
| 2092 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2386 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2093 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2387 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2094 | 2388 | ||
| 2095 | yield* cli.runPullRequestAction({ | 2389 | yield* cli.runPullRequestAction({ |
| ... | @@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2101 | mergeMethod: "squash", | 2395 | mergeMethod: "squash", |
| 2102 | }); | 2396 | }); |
| 2103 | 2397 | ||
| 2104 | expect(callAt(0).args).toEqual([ | 2398 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 2105 | "pr", | ||
| 2106 | "merge", | ||
| 2107 | "7", | ||
| 2108 | "--repo", | ||
| 2109 | "github.com/acme/web", | ||
| 2110 | "--disable-auto", | ||
| 2111 | ]); | ||
| 2112 | }), | 2399 | }), |
| 2113 | ); | 2400 | ); |
| 2114 | 2401 | ||
| 2115 | it.effect("opens a pull request that reverts a merged pull request", () => | 2402 | it.effect("opens a pull request that reverts a merged pull request", () => |
| 2116 | Effect.gen(function* () { | 2403 | Effect.gen(function* () { |
| 2117 | mockedExecute.mockReturnValueOnce( | 2404 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2118 | Effect.succeed( | ||
| 2119 | output( | ||
| 2120 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response. | ||
| 2121 | JSON.stringify({ | ||
| 2122 | data: { repository: { pullRequest: { id: "PR_7" } } }, | ||
| 2123 | }), | ||
| 2124 | ), | ||
| 2125 | ), | ||
| 2126 | ); | ||
| 2127 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 2128 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2405 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2129 | 2406 | ||
| 2130 | yield* cli.runPullRequestAction({ | 2407 | yield* cli.runPullRequestAction({ |
| ... | @@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2135 | action: "revert", | 2412 | action: "revert", |
| 2136 | }); | 2413 | }); |
| 2137 | 2414 | ||
| 2138 | expect(callAt(0).args).toContain("owner=acme"); | 2415 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 2139 | expect(callAt(0).args).toContain("name=web"); | ||
| 2140 | expect(callAt(0).args).toContain("number=7"); | ||
| 2141 | expect(callAt(1).args).toEqual([ | ||
| 2142 | "api", | ||
| 2143 | "graphql", | ||
| 2144 | "--hostname", | ||
| 2145 | "github.com", | ||
| 2146 | "--input", | ||
| 2147 | "-", | ||
| 2148 | ]); | ||
| 2149 | expect(callAt(1).stdin).toContain("revertPullRequest"); | ||
| 2150 | expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"'); | ||
| 2151 | }), | 2416 | }), |
| 2152 | ); | 2417 | ); |
| 2153 | 2418 | ||
| ... | @@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2189 | 2454 | ||
| 2190 | it.effect("finds and approves every workflow waiting on a maintainer", () => | 2455 | it.effect("finds and approves every workflow waiting on a maintainer", () => |
| 2191 | Effect.gen(function* () { | 2456 | Effect.gen(function* () { |
| 2192 | const detail = output( | 2457 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); |
| 2193 | encodeJson( | ||
| 2194 | coreResponse({ | ||
| 2195 | number: 7, | ||
| 2196 | title: "Pull request 7", | ||
| 2197 | url: "https://github.com/acme/web/pull/7", | ||
| 2198 | headRefName: "feat/page", | ||
| 2199 | headRefOid: "abc123", | ||
| 2200 | isCrossRepository: true, | ||
| 2201 | headRepositoryOwner: { login: "octocat" }, | ||
| 2202 | baseRefName: "main", | ||
| 2203 | createdAt: "2026-07-01T00:00:00Z", | ||
| 2204 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 2205 | }), | ||
| 2206 | ), | ||
| 2207 | ); | ||
| 2208 | const heads = output( | ||
| 2209 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | ||
| 2210 | JSON.stringify([ | ||
| 2211 | { | ||
| 2212 | number: 7, | ||
| 2213 | headRefOid: "abc123", | ||
| 2214 | isCrossRepository: true, | ||
| 2215 | headRepositoryOwner: { login: "octocat" }, | ||
| 2216 | }, | ||
| 2217 | ]), | ||
| 2218 | ); | ||
| 2219 | const runs = output( | ||
| 2220 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | ||
| 2221 | JSON.stringify([ | ||
| 2222 | { databaseId: 10, workflowName: "build", url: "https://example.com/10" }, | ||
| 2223 | { databaseId: 11, workflowName: "test", url: "https://example.com/11" }, | ||
| 2224 | ]), | ||
| 2225 | ); | ||
| 2226 | for (const result of [ | ||
| 2227 | detail, | ||
| 2228 | heads, | ||
| 2229 | runs, | ||
| 2230 | detail, | ||
| 2231 | heads, | ||
| 2232 | runs, | ||
| 2233 | output(""), | ||
| 2234 | detail, | ||
| 2235 | heads, | ||
| 2236 | runs, | ||
| 2237 | output(""), | ||
| 2238 | ]) { | ||
| 2239 | mockedExecute.mockReturnValueOnce(Effect.succeed(result)); | ||
| 2240 | } | ||
| 2241 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2458 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2242 | 2459 | ||
| 2243 | yield* cli.runPullRequestAction({ | 2460 | yield* cli.runPullRequestAction({ |
| ... | @@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2248 | action: "approve-workflows", | 2465 | action: "approve-workflows", |
| 2249 | }); | 2466 | }); |
| 2250 | 2467 | ||
| 2251 | expect(callAt(1).args).toEqual([ | 2468 | // Open pull requests on this head branch, and runs scoped to this exact head. |
| 2252 | "pr", | 2469 | expect(variablesOf("headRefName: $head")[0]).toEqual({ |
| 2253 | "list", | 2470 | owner: "acme", |
| 2254 | "--repo", | 2471 | name: "web", |
| 2255 | "github.com/acme/web", | 2472 | head: "feat/page", |
| 2256 | "--state", | 2473 | after: null, |
| 2257 | "open", | 2474 | }); |
| 2258 | "--head", | 2475 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( |
| 2259 | "feat/page", | 2476 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", |
| 2260 | "--limit", | 2477 | ); |
| 2261 | "1001", | 2478 | // Each run is approved only after the head is read again and still lists it. |
| 2262 | "--json", | 2479 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ |
| 2263 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | 2480 | ["POST", "repos/acme/web/actions/runs/10/approve"], |
| 2264 | ]); | 2481 | ["POST", "repos/acme/web/actions/runs/11/approve"], |
| 2265 | expect(callAt(2).args).toEqual([ | ||
| 2266 | "run", | ||
| 2267 | "list", | ||
| 2268 | "--repo", | ||
| 2269 | "github.com/acme/web", | ||
| 2270 | "--commit", | ||
| 2271 | "abc123", | ||
| 2272 | "--branch", | ||
| 2273 | "feat/page", | ||
| 2274 | "--event", | ||
| 2275 | "pull_request", | ||
| 2276 | "--status", | ||
| 2277 | "action_required", | ||
| 2278 | "--limit", | ||
| 2279 | "1001", | ||
| 2280 | "--json", | ||
| 2281 | "databaseId,workflowName,url", | ||
| 2282 | ]); | ||
| 2283 | expect([callAt(6).args, callAt(10).args]).toEqual([ | ||
| 2284 | [ | ||
| 2285 | "api", | ||
| 2286 | "--method", | ||
| 2287 | "POST", | ||
| 2288 | "--hostname", | ||
| 2289 | "github.com", | ||
| 2290 | "repos/acme/web/actions/runs/10/approve", | ||
| 2291 | "--silent", | ||
| 2292 | ], | ||
| 2293 | [ | ||
| 2294 | "api", | ||
| 2295 | "--method", | ||
| 2296 | "POST", | ||
| 2297 | "--hostname", | ||
| 2298 | "github.com", | ||
| 2299 | "repos/acme/web/actions/runs/11/approve", | ||
| 2300 | "--silent", | ||
| 2301 | ], | ||
| 2302 | ]); | 2482 | ]); |
| 2303 | expect(mockedExecute).toHaveBeenCalledTimes(11); | 2483 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); |
| 2304 | }), | 2484 | }), |
| 2305 | ); | 2485 | ); |
| 2306 | 2486 | ||
| 2307 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | 2487 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => |
| 2308 | Effect.gen(function* () { | 2488 | Effect.gen(function* () { |
| 2309 | const detail = { | 2489 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); |
| 2310 | number: 7, | 2490 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2311 | title: "Pull request 7", | 2491 | |
| 2312 | url: "https://github.com/acme/web/pull/7", | 2492 | const runs = yield* cli |
| 2313 | headRefName: "feat/page", | 2493 | .listWorkflowRunsRequiringApproval({ |
| 2314 | headRefOid: "abc123", | 2494 | cwd: "/w", |
| 2315 | isCrossRepository: true, | 2495 | repository: "acme/web", |
| 2316 | headRepositoryOwner: { login: "octocat" }, | 2496 | host: "github.com", |
| 2317 | baseRefName: "main", | 2497 | number: 7, |
| 2318 | createdAt: "2026-07-01T00:00:00Z", | 2498 | headSha: "abc123", |
| 2319 | updatedAt: "2026-07-02T00:00:00Z", | 2499 | headBranch: "feat/page", |
| 2320 | }; | 2500 | headRepositoryOwner: "octocat", |
| 2321 | for (const value of [ | 2501 | isCrossRepository: true, |
| 2322 | coreResponse(detail), | 2502 | }) |
| 2323 | [ | 2503 | .pipe( |
| 2324 | { | 2504 | Effect.provideService(KnownWorkflowRuns, { |
| 2325 | number: 7, | 2505 | headSha: "abc123", |
| 2326 | headRefOid: "abc123", | 2506 | runs: [ |
| 2327 | isCrossRepository: true, | 2507 | // Live shape: waiting on approval is completed + action_required. |
| 2328 | headRepositoryOwner: { login: "octocat" }, | 2508 | { |
| 2329 | }, | 2509 | id: 10, |
| 2330 | ], | 2510 | status: "completed", |
| 2331 | [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], | 2511 | conclusion: "action_required", |
| 2332 | coreResponse({ ...detail, headRefOid: "def456" }), | 2512 | head_branch: "feat/page", |
| 2333 | ]) { | 2513 | }, |
| 2334 | mockedExecute.mockReturnValueOnce( | 2514 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, |
| 2335 | Effect.succeed( | 2515 | ], |
| 2336 | output( | 2516 | }), |
| 2337 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | ||
| 2338 | JSON.stringify(value), | ||
| 2339 | ), | ||
| 2340 | ), | ||
| 2341 | ); | 2517 | ); |
| 2342 | } | 2518 | |
| 2519 | expect(runs.map((run) => run.id)).toEqual([10]); | ||
| 2520 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); | ||
| 2521 | }), | ||
| 2522 | ); | ||
| 2523 | |||
| 2524 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | ||
| 2525 | Effect.gen(function* () { | ||
| 2526 | let detailReads = 0; | ||
| 2527 | workflowApprovalRoutes( | ||
| 2528 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), | ||
| 2529 | heads([7]), | ||
| 2530 | workflowRuns([10]), | ||
| 2531 | ); | ||
| 2343 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2532 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2344 | 2533 | ||
| 2345 | const error = yield* Effect.flip( | 2534 | const error = yield* Effect.flip( |
| ... | @@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2356 | _tag: "GitHubWorkflowApprovalHeadChangedError", | 2545 | _tag: "GitHubWorkflowApprovalHeadChangedError", |
| 2357 | number: 7, | 2546 | number: 7, |
| 2358 | }); | 2547 | }); |
| 2359 | expect(mockedExecute).toHaveBeenCalledTimes(4); | 2548 | assert.strictEqual(detailReads, 2); |
| 2549 | expect(restCallsTo("/approve")).toEqual([]); | ||
| 2360 | }), | 2550 | }), |
| 2361 | ); | 2551 | ); |
| 2362 | 2552 | ||
| ... | @@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2364 | Effect.gen(function* () { | 2554 | Effect.gen(function* () { |
| 2365 | const headsStarted = yield* Deferred.make<void>(); | 2555 | const headsStarted = yield* Deferred.make<void>(); |
| 2366 | const runsStarted = yield* Deferred.make<void>(); | 2556 | const runsStarted = yield* Deferred.make<void>(); |
| 2367 | mockedExecute.mockImplementation(({ args }) => | 2557 | mockedExecute.mockImplementation((call) => |
| 2368 | args[0] === "pr" | 2558 | call.kind === "graphql" |
| 2369 | ? Deferred.succeed(headsStarted, undefined).pipe( | 2559 | ? Deferred.succeed(headsStarted, undefined).pipe( |
| 2370 | Effect.andThen(Deferred.await(runsStarted)), | 2560 | Effect.andThen(Deferred.await(runsStarted)), |
| 2371 | Effect.as( | 2561 | Effect.as(output(encodeJson(heads([7])))), |
| 2372 | output( | ||
| 2373 | '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]', | ||
| 2374 | ), | ||
| 2375 | ), | ||
| 2376 | ) | 2562 | ) |
| 2377 | : Deferred.succeed(runsStarted, undefined).pipe( | 2563 | : Deferred.succeed(runsStarted, undefined).pipe( |
| 2378 | Effect.andThen(Deferred.await(headsStarted)), | 2564 | Effect.andThen(Deferred.await(headsStarted)), |
| 2379 | Effect.as( | 2565 | Effect.as(output(encodeJson(workflowRuns([10])))), |
| 2380 | output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'), | ||
| 2381 | ), | ||
| 2382 | ), | 2566 | ), |
| 2383 | ); | 2567 | ); |
| 2384 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2568 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2400 | 2584 | ||
| 2401 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | 2585 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => |
| 2402 | Effect.gen(function* () { | 2586 | Effect.gen(function* () { |
| 2403 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 2587 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); |
| 2404 | mockedExecute.mockReturnValueOnce( | ||
| 2405 | Effect.succeed( | ||
| 2406 | output( | ||
| 2407 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | ||
| 2408 | JSON.stringify( | ||
| 2409 | [7, 8].map((number) => ({ | ||
| 2410 | number, | ||
| 2411 | headRefOid: "abc123", | ||
| 2412 | isCrossRepository: true, | ||
| 2413 | headRepositoryOwner: { login: "octocat" }, | ||
| 2414 | })), | ||
| 2415 | ), | ||
| 2416 | ), | ||
| 2417 | ), | ||
| 2418 | ); | ||
| 2419 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2588 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2420 | 2589 | ||
| 2421 | const error = yield* Effect.flip( | 2590 | const error = yield* Effect.flip( |
| ... | @@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2438 | observedCount: 2, | 2607 | observedCount: 2, |
| 2439 | limit: 1_000, | 2608 | limit: 1_000, |
| 2440 | }); | 2609 | }); |
| 2441 | expect(error.detail).toContain("instead of uniquely matching #7"); | 2610 | expect(error.message).toContain("instead of uniquely matching #7"); |
| 2442 | expect(mockedExecute).toHaveBeenCalledTimes(2); | ||
| 2443 | }), | 2611 | }), |
| 2444 | ); | 2612 | ); |
| 2445 | 2613 | ||
| ... | @@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2487 | 2655 | ||
| 2488 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => | 2656 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => |
| 2489 | Effect.gen(function* () { | 2657 | Effect.gen(function* () { |
| 2490 | mockedExecute.mockReturnValueOnce( | 2658 | let runPages = 0; |
| 2491 | Effect.succeed( | 2659 | mockedExecute.mockImplementation((call) => { |
| 2492 | output( | 2660 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); |
| 2493 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 2661 | const page = runPages++; |
| 2494 | JSON.stringify([ | 2662 | return Effect.succeed( |
| 2495 | { | ||
| 2496 | number: 7, | ||
| 2497 | headRefOid: "abc123", | ||
| 2498 | isCrossRepository: true, | ||
| 2499 | headRepositoryOwner: { login: "octocat" }, | ||
| 2500 | }, | ||
| 2501 | ]), | ||
| 2502 | ), | ||
| 2503 | ), | ||
| 2504 | ); | ||
| 2505 | mockedExecute.mockReturnValueOnce( | ||
| 2506 | Effect.succeed( | ||
| 2507 | output( | 2663 | output( |
| 2508 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 2664 | encodeJson( |
| 2509 | JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))), | 2665 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), |
| 2666 | ), | ||
| 2510 | ), | 2667 | ), |
| 2511 | ), | 2668 | ); |
| 2512 | ); | 2669 | }); |
| 2513 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2670 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2514 | 2671 | ||
| 2515 | const error = yield* Effect.flip( | 2672 | const error = yield* Effect.flip( |
| ... | @@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2525 | }), | 2682 | }), |
| 2526 | ); | 2683 | ); |
| 2527 | 2684 | ||
| 2685 | // Paged a hundred at a time, it stops at the first page past the bound. | ||
| 2528 | expect(error).toMatchObject({ | 2686 | expect(error).toMatchObject({ |
| 2529 | _tag: "GitHubWorkflowApprovalRefusedError", | 2687 | _tag: "GitHubWorkflowApprovalRefusedError", |
| 2530 | reason: "run-list-truncated", | 2688 | reason: "run-list-truncated", |
| 2531 | number: 7, | 2689 | number: 7, |
| 2532 | observedCount: 1_001, | 2690 | observedCount: 1_100, |
| 2533 | limit: 1_000, | 2691 | limit: 1_000, |
| 2534 | }); | 2692 | }); |
| 2535 | expect(error.detail).toContain("more than 1000 workflow runs"); | 2693 | expect(error.message).toContain("more than 1000 workflow runs"); |
| 2536 | expect(mockedExecute).toHaveBeenCalledTimes(2); | 2694 | assert.strictEqual(runPages, 11); |
| 2537 | }), | 2695 | }), |
| 2538 | ); | 2696 | ); |
| 2539 | 2697 | ||
| 2540 | it.effect("returns a pull request to draft by undoing ready", () => | 2698 | it.effect("returns a pull request to draft by converting it", () => |
| 2541 | Effect.gen(function* () { | 2699 | Effect.gen(function* () { |
| 2542 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2700 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2543 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2701 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2544 | 2702 | ||
| 2545 | yield* cli.runPullRequestAction({ | 2703 | yield* cli.runPullRequestAction({ |
| ... | @@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2550 | action: "draft", | 2708 | action: "draft", |
| 2551 | }); | 2709 | }); |
| 2552 | 2710 | ||
| 2553 | // gh has no `draft` command; going back is `ready --undo`. | 2711 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 2554 | expect(callAt(0).args).toEqual([ | 2712 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); |
| 2555 | "pr", | ||
| 2556 | "ready", | ||
| 2557 | "7", | ||
| 2558 | "--repo", | ||
| 2559 | "github.com/acme/web", | ||
| 2560 | "--undo", | ||
| 2561 | ]); | ||
| 2562 | }), | 2713 | }), |
| 2563 | ); | 2714 | ); |
| 2564 | 2715 | ||
| 2565 | it.effect("sends a comment body over stdin, never in argv", () => | 2716 | it.effect("sends a comment body as a variable, never inside the document", () => |
| 2566 | Effect.gen(function* () { | 2717 | Effect.gen(function* () { |
| 2567 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | 2718 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2568 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2719 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2569 | 2720 | ||
| 2570 | yield* cli.commentOnPullRequest({ | 2721 | yield* cli.commentOnPullRequest({ |
| ... | @@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2575 | body: "Looks good.", | 2726 | body: "Looks good.", |
| 2576 | }); | 2727 | }); |
| 2577 | 2728 | ||
| 2578 | // argv shows up in process listings and in process-runner failure messages. | 2729 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); |
| 2579 | expect(callAt(0).args).toEqual([ | 2730 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); |
| 2580 | "pr", | ||
| 2581 | "comment", | ||
| 2582 | "7", | ||
| 2583 | "--repo", | ||
| 2584 | "github.com/acme/web", | ||
| 2585 | "--body-file", | ||
| 2586 | "-", | ||
| 2587 | ]); | ||
| 2588 | expect(callAt(0).stdin).toBe("Looks good."); | ||
| 2589 | expect(callAt(0).args).not.toContain("Looks good."); | ||
| 2590 | }), | 2731 | }), |
| 2591 | ); | 2732 | ); |
| 2592 | 2733 | ||
| 2593 | it.effect("names the host on every repository it addresses", () => | 2734 | it.effect("names the host on every repository it addresses", () => |
| 2594 | Effect.gen(function* () { | 2735 | Effect.gen(function* () { |
| 2595 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | 2736 | mockedExecute.mockImplementation((call) => |
| 2737 | Effect.succeed( | ||
| 2738 | call.kind === "graphql" && call.query.includes("pullRequests(") | ||
| 2739 | ? emptyList() | ||
| 2740 | : emptySearch(), | ||
| 2741 | ), | ||
| 2742 | ); | ||
| 2596 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2743 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2597 | 2744 | ||
| 2598 | yield* cli.listPullRequests({ | 2745 | yield* cli.listPullRequests({ |
| ... | @@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2605 | limit: 10, | 2752 | limit: 10, |
| 2606 | }); | 2753 | }); |
| 2607 | 2754 | ||
| 2608 | // A bare `owner/repo` resolves against github.com, which is a different repository. | 2755 | // A request sent to github.com would read a different repository of the same name. |
| 2609 | expect(callAt(0).args).toContain("github.acme.dev/acme/web"); | 2756 | assert.isAbove(mockedExecute.mock.calls.length, 0); |
| 2757 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( | ||
| 2758 | new Set(["github.acme.dev"]), | ||
| 2759 | ); | ||
| 2760 | expect(searchOfCall(0)).toContain("repo:acme/web"); | ||
| 2610 | }), | 2761 | }), |
| 2611 | ); | 2762 | ); |
| 2612 | 2763 | ||
| ... | @@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2615 | mockedExecute.mockReturnValueOnce( | 2766 | mockedExecute.mockReturnValueOnce( |
| 2616 | Effect.succeed( | 2767 | Effect.succeed( |
| 2617 | output( | 2768 | output( |
| 2618 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 2769 | encodeJson({ |
| 2619 | JSON.stringify({ | ||
| 2620 | data: { | 2770 | data: { |
| 2621 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, | 2771 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, |
| 2622 | }, | 2772 | }, |
| ... | @@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2633 | number: 7, | 2783 | number: 7, |
| 2634 | }); | 2784 | }); |
| 2635 | 2785 | ||
| 2636 | const args = callAt(0).args; | 2786 | expect(callAt(0).host).toBe("github.acme.dev"); |
| 2637 | expect(args).toContain("--hostname"); | 2787 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 2638 | expect(args).toContain("github.acme.dev"); | ||
| 2639 | expect(args).toContain("owner=acme"); | ||
| 2640 | expect(args).toContain("name=web"); | ||
| 2641 | }), | 2788 | }), |
| 2642 | ); | 2789 | ); |
| 2643 | 2790 | ||
| ... | @@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2653 | number: 7, | 2800 | number: 7, |
| 2654 | }); | 2801 | }); |
| 2655 | 2802 | ||
| 2803 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | ||
| 2656 | assert.isNull(diff.nextCursor); | 2804 | assert.isNull(diff.nextCursor); |
| 2657 | assert.isFalse(diff.truncated); | 2805 | assert.isFalse(diff.truncated); |
| 2658 | // The common case pays for one request and not the files API on top of it. | 2806 | // The common case pays for one request and not the files API on top of it. |
| 2659 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 2807 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 2660 | // `--patch` asks gh for a format-patch stream, which repeats a file once per commit. | 2808 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. |
| 2661 | // The review needs GitHub's combined pull-request diff: one section per changed file. | 2809 | const call = callAt(0); |
| 2662 | expect(callAt(0).args).not.toContain("--patch"); | 2810 | assert.strictEqual(call.kind, "rest"); |
| 2811 | if (call.kind === "rest") { | ||
| 2812 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); | ||
| 2813 | assert.strictEqual(call.accept, "application/vnd.github.diff"); | ||
| 2814 | } | ||
| 2663 | }), | 2815 | }), |
| 2664 | ); | 2816 | ); |
| 2665 | 2817 | ||
| ... | @@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2682 | assert.isNull(diff.nextCursor); | 2834 | assert.isNull(diff.nextCursor); |
| 2683 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | 2835 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 2684 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); | 2836 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); |
| 2685 | const args = callAt(1).args; | 2837 | assert.strictEqual(callAt(1).host, "github.acme.dev"); |
| 2686 | expect(args).toContain("--hostname"); | 2838 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); |
| 2687 | expect(args).toContain("github.acme.dev"); | ||
| 2688 | expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1"); | ||
| 2689 | }), | 2839 | }), |
| 2690 | ); | 2840 | ); |
| 2691 | 2841 | ||
| ... | @@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2709 | }), | 2859 | }), |
| 2710 | ); | 2860 | ); |
| 2711 | 2861 | ||
| 2712 | it.effect("carries on from a cursor without asking `gh pr diff` again", () => | 2862 | it.effect("carries on from a cursor without asking for the whole diff again", () => |
| 2713 | Effect.gen(function* () { | 2863 | Effect.gen(function* () { |
| 2714 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | 2864 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 2715 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | 2865 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); |
| ... | @@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2725 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); | 2875 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); |
| 2726 | // The second slice is one request: the cursor already says where to read. | 2876 | // The second slice is one request: the cursor already says where to read. |
| 2727 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | 2877 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 2728 | expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2"); | 2878 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); |
| 2729 | }), | 2879 | }), |
| 2730 | ); | 2880 | ); |
| 2731 | 2881 | ||
| ... | @@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2748 | }), | 2898 | }), |
| 2749 | ); | 2899 | ); |
| 2750 | 2900 | ||
| 2751 | it.effect("reads a named commit from the commit endpoint rather than from `gh pr diff`", () => | 2901 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => |
| 2752 | Effect.gen(function* () { | 2902 | Effect.gen(function* () { |
| 2753 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); | 2903 | mockedExecute.mockReturnValueOnce( |
| 2904 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), | ||
| 2905 | ); | ||
| 2754 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2906 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2755 | 2907 | ||
| 2756 | const diff = yield* cli.getPullRequestDiff({ | 2908 | const diff = yield* cli.getPullRequestDiff({ |
| ... | @@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2761 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | 2913 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", |
| 2762 | }); | 2914 | }); |
| 2763 | 2915 | ||
| 2764 | // One request: the commit's own changes never take the `gh pr diff` road. | 2916 | // One request: the commit's own changes never take the whole-diff road. |
| 2765 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 2917 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 2766 | assert.isNull(diff.nextCursor); | 2918 | assert.isNull(diff.nextCursor); |
| 2919 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | ||
| 2767 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | 2920 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 2768 | const args = callAt(0).args; | 2921 | assert.strictEqual( |
| 2769 | expect(args).toContain( | 2922 | pathAt(0), |
| 2770 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", | 2923 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", |
| 2771 | ); | 2924 | ); |
| 2772 | // The commit endpoint wraps its files in an object, which jq unwraps for the decoder. | ||
| 2773 | expect(args).toContain(".files // []"); | ||
| 2774 | }), | 2925 | }), |
| 2775 | ); | 2926 | ); |
| 2776 | 2927 | ||
| 2777 | it.effect("pages inside a commit the way it pages the pull request's own files", () => | 2928 | it.effect("pages inside a commit the way it pages the pull request's own files", () => |
| 2778 | Effect.gen(function* () { | 2929 | Effect.gen(function* () { |
| 2779 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | 2930 | mockedExecute.mockReturnValueOnce( |
| 2931 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), | ||
| 2932 | ); | ||
| 2780 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2933 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2781 | const target = { | 2934 | const target = { |
| 2782 | cwd: "/w", | 2935 | cwd: "/w", |
| ... | @@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2788 | 2941 | ||
| 2789 | const first = yield* cli.getPullRequestDiff(target); | 2942 | const first = yield* cli.getPullRequestDiff(target); |
| 2790 | assert.isNotNull(first.nextCursor); | 2943 | assert.isNotNull(first.nextCursor); |
| 2791 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); | 2944 | mockedExecute.mockReturnValueOnce( |
| 2945 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), | ||
| 2946 | ); | ||
| 2792 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | 2947 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); |
| 2793 | 2948 | ||
| 2794 | assert.isNull(second.nextCursor); | 2949 | assert.isNull(second.nextCursor); |
| 2795 | expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | 2950 | expect(second.patch).toContain("src/file100.ts"); |
| 2951 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | ||
| 2796 | }), | 2952 | }), |
| 2797 | ); | 2953 | ); |
| 2798 | 2954 | ||
| ... | @@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2817 | 2973 | ||
| 2818 | it.effect("expands a new file from a root commit without requiring a parent", () => | 2974 | it.effect("expands a new file from a root commit without requiring a parent", () => |
| 2819 | Effect.gen(function* () { | 2975 | Effect.gen(function* () { |
| 2820 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n"))); | 2976 | mockedExecute.mockReturnValueOnce( |
| 2977 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), | ||
| 2978 | ); | ||
| 2821 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); | 2979 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); |
| 2822 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 2980 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2823 | 2981 | ||
| ... | @@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2834 | 2992 | ||
| 2835 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); | 2993 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); |
| 2836 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 2994 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 2837 | expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d"); | 2995 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); |
| 2996 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); | ||
| 2838 | }), | 2997 | }), |
| 2839 | ); | 2998 | ); |
| 2840 | 2999 | ||
| ... | @@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2866 | 3025 | ||
| 2867 | it.effect("reports an oversized diff file with its path and reason", () => | 3026 | it.effect("reports an oversized diff file with its path and reason", () => |
| 2868 | Effect.gen(function* () { | 3027 | Effect.gen(function* () { |
| 2869 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | 3028 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 2870 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | 3029 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); |
| 2871 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3030 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2872 | 3031 | ||
| ... | @@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2887 | assert.strictEqual(error.path, "src/large.ts"); | 3046 | assert.strictEqual(error.path, "src/large.ts"); |
| 2888 | assert.strictEqual(error.reason, "oversized"); | 3047 | assert.strictEqual(error.reason, "oversized"); |
| 2889 | } | 3048 | } |
| 3049 | // A deleted file is read at the base revision only. | ||
| 3050 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); | ||
| 2890 | }), | 3051 | }), |
| 2891 | ); | 3052 | ); |
| 2892 | 3053 | ||
| 2893 | it.effect("reports undecodable diff file contents as binary", () => | 3054 | it.effect("reports undecodable diff file contents as binary", () => |
| 2894 | Effect.gen(function* () { | 3055 | Effect.gen(function* () { |
| 2895 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | 3056 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 2896 | mockedExecute.mockReturnValueOnce( | 3057 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); |
| 2897 | Effect.succeed(output("binary\uFFFDcontents", false, true)), | ||
| 2898 | ); | ||
| 2899 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3058 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2900 | 3059 | ||
| 2901 | const error = yield* Effect.flip( | 3060 | const error = yield* Effect.flip( |
| ... | @@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2920 | 3079 | ||
| 2921 | it.effect("returns valid text containing a literal replacement character", () => | 3080 | it.effect("returns valid text containing a literal replacement character", () => |
| 2922 | Effect.gen(function* () { | 3081 | Effect.gen(function* () { |
| 2923 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | 3082 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 2924 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); | 3083 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); |
| 2925 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3084 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2926 | 3085 | ||
| 2927 | const contents = yield* cli.getPullRequestDiffFileContents({ | 3086 | const contents = yield* cli.getPullRequestDiffFileContents({ |
| ... | @@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2934 | newPath: "docs/encoding.md", | 3093 | newPath: "docs/encoding.md", |
| 2935 | }); | 3094 | }); |
| 2936 | 3095 | ||
| 2937 | assert.strictEqual(contents.oldContents, "before\uFFFDafter"); | 3096 | assert.strictEqual(contents.oldContents, "before�after"); |
| 2938 | }), | 3097 | }), |
| 2939 | ); | 3098 | ); |
| 2940 | 3099 | ||
| ... | @@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2996 | mockedExecute.mockReturnValueOnce( | 3155 | mockedExecute.mockReturnValueOnce( |
| 2997 | Effect.succeed( | 3156 | Effect.succeed( |
| 2998 | output( | 3157 | output( |
| 2999 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3158 | encodeJson({ |
| 3000 | JSON.stringify({ | ||
| 3001 | data: { | 3159 | data: { |
| 3002 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], | 3160 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], |
| 3003 | rateLimit: { | 3161 | rateLimit: { |
| ... | @@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3020 | ids: ["MDQ6VXNlcjE="], | 3178 | ids: ["MDQ6VXNlcjE="], |
| 3021 | }); | 3179 | }); |
| 3022 | 3180 | ||
| 3023 | expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE="); | 3181 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); |
| 3024 | expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }"); | 3182 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); |
| 3025 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); | 3183 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); |
| 3026 | }), | 3184 | }), |
| 3027 | ); | 3185 | ); |
| ... | @@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3039 | 3197 | ||
| 3040 | it.effect("looks up the authenticated account on the requested enterprise host", () => | 3198 | it.effect("looks up the authenticated account on the requested enterprise host", () => |
| 3041 | Effect.gen(function* () { | 3199 | Effect.gen(function* () { |
| 3042 | mockedExecute | 3200 | mockedExecute.mockReturnValueOnce( |
| 3043 | .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential"))) | 3201 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), |
| 3044 | .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}'))); | 3202 | ); |
| 3045 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3203 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3046 | 3204 | ||
| 3047 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); | 3205 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); |
| 3048 | 3206 | ||
| 3049 | expect(login).toBe("enterprise-user"); | 3207 | expect(login).toBe("enterprise-user"); |
| 3050 | expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]); | 3208 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); |
| 3051 | expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]); | 3209 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); |
| 3052 | }), | 3210 | }), |
| 3053 | ); | 3211 | ); |
| 3054 | 3212 | ||
| ... | @@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3056 | Effect.gen(function* () { | 3214 | Effect.gen(function* () { |
| 3057 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3215 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3058 | const input = { cwd: "/w", host: "github.identity-cache.test" }; | 3216 | const input = { cwd: "/w", host: "github.identity-cache.test" }; |
| 3059 | mockedExecute | 3217 | mockedCredential.mockImplementation((host) => |
| 3060 | .mockReturnValueOnce(Effect.succeed(output("test-credential-a"))) | 3218 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), |
| 3061 | .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | 3219 | ); |
| 3220 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | ||
| 3062 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | 3221 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3063 | accountId: "123", | 3222 | accountId: "123", |
| 3064 | viewer: "maria-rcks", | 3223 | viewer: "maria-rcks", |
| 3065 | }); | 3224 | }); |
| 3066 | expect(callAt(1).env).toMatchObject({ | ||
| 3067 | GH_ENTERPRISE_TOKEN: "test-credential-a", | ||
| 3068 | GH_DEBUG: "", | ||
| 3069 | }); | ||
| 3070 | 3225 | ||
| 3071 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a"))); | 3226 | // The same credential again is answered from what was verified, without asking GitHub. |
| 3072 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | 3227 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3073 | accountId: "123", | 3228 | accountId: "123", |
| 3074 | viewer: "maria-rcks", | 3229 | viewer: "maria-rcks", |
| 3075 | }); | 3230 | }); |
| 3076 | expect(mockedExecute).toHaveBeenCalledTimes(3); | 3231 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 3077 | 3232 | ||
| 3078 | mockedExecute | 3233 | // A switched credential is not trusted on the strength of the old one's answer. |
| 3079 | .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) | 3234 | mockedCredential.mockImplementation((host) => |
| 3080 | .mockReturnValueOnce( | 3235 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), |
| 3081 | Effect.fail( | 3236 | ); |
| 3082 | new GitHubCli.GitHubCliCommandError({ | 3237 | mockedExecute.mockReturnValueOnce( |
| 3083 | command: "gh", | 3238 | Effect.fail( |
| 3084 | cwd: "/w", | 3239 | new GitHubApi.GitHubApiResponseError({ |
| 3085 | cause: new Error("upstream failed with test-credential-b"), | 3240 | host: "github.com", |
| 3086 | }), | 3241 | operation: "getRoutingIdentity", |
| 3087 | ), | 3242 | status: 502, |
| 3088 | ); | 3243 | }), |
| 3244 | ), | ||
| 3245 | ); | ||
| 3246 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | ||
| 3089 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); | 3247 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); |
| 3090 | expect(failure._tag).toBe("GitHubViewerLoginUnavailableError"); | 3248 | expect(failure._tag).toBe("GitHubApiResponseError"); |
| 3091 | expect(String(failure)).not.toContain("test-credential-b"); | 3249 | expect(String(failure)).not.toContain("test-credential-b"); |
| 3092 | expect(callAt(4).env).toMatchObject({ | 3250 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 3093 | GH_ENTERPRISE_TOKEN: "test-credential-b", | ||
| 3094 | GH_DEBUG: "", | ||
| 3095 | }); | ||
| 3096 | 3251 | ||
| 3097 | mockedExecute | 3252 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); |
| 3098 | .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) | ||
| 3099 | .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | ||
| 3100 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | 3253 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3101 | accountId: "456", | 3254 | accountId: "456", |
| 3102 | viewer: "maria-rcks", | 3255 | viewer: "maria-rcks", |
| ... | @@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3104 | }), | 3257 | }), |
| 3105 | ); | 3258 | ); |
| 3106 | 3259 | ||
| 3107 | it.effect("sends a whole review as one request body over stdin", () => | 3260 | it.effect("sends a whole review as one request", () => |
| 3108 | Effect.gen(function* () { | 3261 | Effect.gen(function* () { |
| 3109 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | 3262 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3110 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3263 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3119 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | 3272 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], |
| 3120 | }); | 3273 | }); |
| 3121 | 3274 | ||
| 3122 | expect(callAt(0).args).toEqual([ | ||
| 3123 | "api", | ||
| 3124 | "--method", | ||
| 3125 | "POST", | ||
| 3126 | "--hostname", | ||
| 3127 | "github.com", | ||
| 3128 | "repos/acme/web/pulls/7/reviews", | ||
| 3129 | "--input", | ||
| 3130 | "-", | ||
| 3131 | ]); | ||
| 3132 | // One request, so nothing is on the pull request until the verdict is. | 3275 | // One request, so nothing is on the pull request until the verdict is. |
| 3133 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 3276 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3134 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3277 | expect(restCallsTo("/reviews")).toMatchObject([ |
| 3135 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ | 3278 | { |
| 3136 | event: "APPROVE", | 3279 | method: "POST", |
| 3137 | body: "Looks right.", | 3280 | path: "repos/acme/web/pulls/7/reviews", |
| 3138 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | 3281 | body: { |
| 3139 | }); | 3282 | event: "APPROVE", |
| 3283 | body: "Looks right.", | ||
| 3284 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | ||
| 3285 | }, | ||
| 3286 | }, | ||
| 3287 | ]); | ||
| 3140 | }), | 3288 | }), |
| 3141 | ); | 3289 | ); |
| 3142 | 3290 | ||
| 3143 | it.effect("sends a reply body over stdin, never in argv", () => | 3291 | it.effect("sends a reply body as a variable, never inside the document", () => |
| 3144 | Effect.gen(function* () { | 3292 | Effect.gen(function* () { |
| 3145 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | 3293 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3146 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3294 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | @@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3153 | body: "Fixed in 42ff8ec.", | 3301 | body: "Fixed in 42ff8ec.", |
| 3154 | }); | 3302 | }); |
| 3155 | 3303 | ||
| 3156 | // A reply is the reader's own words, so it travels the same way a comment body does. | 3304 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ |
| 3157 | expect(callAt(0).args).toEqual([ | 3305 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, |
| 3158 | "api", | ||
| 3159 | "graphql", | ||
| 3160 | "--hostname", | ||
| 3161 | "github.com", | ||
| 3162 | "--input", | ||
| 3163 | "-", | ||
| 3164 | ]); | 3306 | ]); |
| 3165 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3307 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); |
| 3166 | const request = JSON.parse(callAt(0).stdin ?? "") as { | ||
| 3167 | query: string; | ||
| 3168 | variables: Record<string, string>; | ||
| 3169 | }; | ||
| 3170 | expect(request.query).toContain("addPullRequestReviewThreadReply"); | ||
| 3171 | expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." }); | ||
| 3172 | expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec."); | ||
| 3173 | }), | 3308 | }), |
| 3174 | ); | 3309 | ); |
| 3175 | 3310 | ||
| ... | @@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3193 | resolved: false, | 3328 | resolved: false, |
| 3194 | }); | 3329 | }); |
| 3195 | 3330 | ||
| 3196 | const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string }; | 3331 | expect(queryAt(0)).toContain("resolveReviewThread("); |
| 3197 | expect(parse(0).query).toContain("resolveReviewThread("); | 3332 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); |
| 3198 | expect(parse(1).query).toContain("unresolveReviewThread("); | 3333 | expect(queryAt(1)).toContain("unresolveReviewThread("); |
| 3334 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | ||
| 3199 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. | 3335 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. |
| 3200 | expect(callAt(0).args).toContain("github.acme.dev"); | 3336 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); |
| 3201 | }), | 3337 | }), |
| 3202 | ); | 3338 | ); |
| 3203 | 3339 | ||
| 3204 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => | 3340 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => |
| 3205 | Effect.gen(function* () { | 3341 | Effect.gen(function* () { |
| 3206 | mockedExecute.mockReturnValueOnce( | 3342 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3207 | Effect.succeed( | ||
| 3208 | output( | ||
| 3209 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3210 | JSON.stringify({ | ||
| 3211 | data: { | ||
| 3212 | repository: { pullRequest: { id: "PR_kwDOA" } }, | ||
| 3213 | node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, | ||
| 3214 | }, | ||
| 3215 | }), | ||
| 3216 | ), | ||
| 3217 | ), | ||
| 3218 | ); | ||
| 3219 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 3220 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3343 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3221 | 3344 | ||
| 3222 | yield* cli.setReaction({ | 3345 | yield* cli.setReaction({ |
| ... | @@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3230 | }); | 3353 | }); |
| 3231 | 3354 | ||
| 3232 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 3355 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3233 | const scopeCheck = callAt(0).args; | 3356 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ |
| 3234 | expect(scopeCheck).toContain("owner=acme"); | 3357 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, |
| 3235 | expect(scopeCheck).toContain("name=web"); | 3358 | ]); |
| 3236 | expect(scopeCheck).toContain("number=7"); | 3359 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); |
| 3237 | expect(scopeCheck).toContain("subjectId=IC_1"); | ||
| 3238 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3239 | const request = JSON.parse(callAt(1).stdin ?? "") as { | ||
| 3240 | query: string; | ||
| 3241 | variables: Record<string, string>; | ||
| 3242 | }; | ||
| 3243 | expect(request.query).toContain("addReaction("); | ||
| 3244 | expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" }); | ||
| 3245 | }), | 3360 | }), |
| 3246 | ); | 3361 | ); |
| 3247 | 3362 | ||
| ... | @@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3250 | mockedExecute.mockReturnValueOnce( | 3365 | mockedExecute.mockReturnValueOnce( |
| 3251 | Effect.succeed( | 3366 | Effect.succeed( |
| 3252 | output( | 3367 | output( |
| 3253 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3368 | encodeJson({ |
| 3254 | JSON.stringify({ | ||
| 3255 | data: { | 3369 | data: { |
| 3256 | repository: { pullRequest: { id: "PR_thisOne" } }, | 3370 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3257 | // A comment on pull request #99 of a different repository, named as though it | 3371 | // A comment on pull request #99 of a different repository, named as though it |
| ... | @@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3284 | 3398 | ||
| 3285 | it.effect("looks up the pull request's own node id when no subject was given", () => | 3399 | it.effect("looks up the pull request's own node id when no subject was given", () => |
| 3286 | Effect.gen(function* () { | 3400 | Effect.gen(function* () { |
| 3287 | mockedExecute.mockReturnValueOnce( | 3401 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); |
| 3288 | Effect.succeed( | ||
| 3289 | output( | ||
| 3290 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3291 | JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), | ||
| 3292 | ), | ||
| 3293 | ), | ||
| 3294 | ); | ||
| 3295 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 3296 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3402 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3297 | 3403 | ||
| 3298 | // Its own pull request: a node id looked up once is remembered for the life of the service. | 3404 | // Its own pull request: a node id looked up once is remembered for the life of the service. |
| ... | @@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3306 | }); | 3412 | }); |
| 3307 | 3413 | ||
| 3308 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 3414 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3309 | const lookup = callAt(0).args; | 3415 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); |
| 3310 | expect(lookup).toContain("owner=acme"); | 3416 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); |
| 3311 | expect(lookup).toContain("name=web"); | ||
| 3312 | expect(lookup).toContain("number=21"); | ||
| 3313 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3314 | const request = JSON.parse(callAt(1).stdin ?? "") as { | ||
| 3315 | query: string; | ||
| 3316 | variables: Record<string, string>; | ||
| 3317 | }; | ||
| 3318 | expect(request.query).toContain("addReaction("); | ||
| 3319 | expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" }); | ||
| 3320 | }), | 3417 | }), |
| 3321 | ); | 3418 | ); |
| 3322 | 3419 | ||
| 3323 | it.effect("takes a reaction back through the remove mutation", () => | 3420 | it.effect("takes a reaction back through the remove mutation", () => |
| 3324 | Effect.gen(function* () { | 3421 | Effect.gen(function* () { |
| 3325 | mockedExecute.mockReturnValueOnce( | 3422 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3326 | Effect.succeed( | ||
| 3327 | output( | ||
| 3328 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3329 | JSON.stringify({ | ||
| 3330 | data: { | ||
| 3331 | repository: { pullRequest: { id: "PR_kwDOA" } }, | ||
| 3332 | node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, | ||
| 3333 | }, | ||
| 3334 | }), | ||
| 3335 | ), | ||
| 3336 | ), | ||
| 3337 | ); | ||
| 3338 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 3339 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3423 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3340 | 3424 | ||
| 3341 | yield* cli.setReaction({ | 3425 | yield* cli.setReaction({ |
| ... | @@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3348 | reacted: false, | 3432 | reacted: false, |
| 3349 | }); | 3433 | }); |
| 3350 | 3434 | ||
| 3351 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3435 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); |
| 3352 | const request = JSON.parse(callAt(1).stdin ?? "") as { query: string }; | 3436 | expect(variablesOf("addReaction(")).toEqual([]); |
| 3353 | expect(request.query).toContain("removeReaction("); | ||
| 3354 | }), | 3437 | }), |
| 3355 | ); | 3438 | ); |
| 3356 | 3439 | ||
| 3357 | it.effect("rewrites only the words a request named", () => | 3440 | it.effect("rewrites only the words a request named", () => |
| 3358 | Effect.gen(function* () { | 3441 | Effect.gen(function* () { |
| 3359 | mockedExecute.mockReturnValue( | 3442 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); |
| 3360 | Effect.succeed( | ||
| 3361 | output( | ||
| 3362 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3363 | JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), | ||
| 3364 | ), | ||
| 3365 | ), | ||
| 3366 | ); | ||
| 3367 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3443 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3368 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => | 3444 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => |
| 3369 | cli.updatePullRequest({ | 3445 | cli.updatePullRequest({ |
| ... | @@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3379 | yield* rewrite({ title: "Both", body: "at once." }); | 3455 | yield* rewrite({ title: "Both", body: "at once." }); |
| 3380 | 3456 | ||
| 3381 | // One node id lookup for the pull request, then a mutation per rewrite. | 3457 | // One node id lookup for the pull request, then a mutation per rewrite. |
| 3382 | const variablesAt = (index: number) => | 3458 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); |
| 3383 | (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables; | 3459 | expect(variablesOf("updatePullRequest(")).toEqual([ |
| 3384 | expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); | 3460 | { pullRequestId: "PR_kwDOA", title: "A better title" }, |
| 3385 | expect(variablesAt(2)).toEqual({ | 3461 | { pullRequestId: "PR_kwDOA", body: "A better description." }, |
| 3386 | pullRequestId: "PR_kwDOA", | 3462 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, |
| 3387 | body: "A better description.", | 3463 | ]); |
| 3388 | }); | ||
| 3389 | expect(variablesAt(3)).toEqual({ | ||
| 3390 | pullRequestId: "PR_kwDOA", | ||
| 3391 | title: "Both", | ||
| 3392 | body: "at once.", | ||
| 3393 | }); | ||
| 3394 | // The reader's own words, so they travel the way every other body does. | ||
| 3395 | expect(callAt(3).args.join(" ")).not.toContain("at once."); | ||
| 3396 | }), | 3464 | }), |
| 3397 | ); | 3465 | ); |
| 3398 | 3466 | ||
| 3399 | it.effect("rewrites a remark through the mutation its kind needs", () => | 3467 | it.effect("rewrites a remark through the mutation its kind needs", () => |
| 3400 | Effect.gen(function* () { | 3468 | Effect.gen(function* () { |
| 3401 | mockedExecute.mockReturnValue( | 3469 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3402 | Effect.succeed( | ||
| 3403 | output( | ||
| 3404 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3405 | JSON.stringify({ | ||
| 3406 | data: { | ||
| 3407 | repository: { pullRequest: { id: "PR_kwDOA" } }, | ||
| 3408 | node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, | ||
| 3409 | }, | ||
| 3410 | }), | ||
| 3411 | ), | ||
| 3412 | ), | ||
| 3413 | ); | ||
| 3414 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3470 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3415 | const rewrite = (kind: "issue-comment" | "review-comment") => | 3471 | const rewrite = (kind: "issue-comment" | "review-comment") => |
| 3416 | cli.updateComment({ | 3472 | cli.updateComment({ |
| ... | @@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3426 | yield* rewrite("issue-comment"); | 3482 | yield* rewrite("issue-comment"); |
| 3427 | yield* rewrite("review-comment"); | 3483 | yield* rewrite("review-comment"); |
| 3428 | 3484 | ||
| 3429 | const parse = (index: number) => | 3485 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ |
| 3430 | JSON.parse(callAt(index).stdin ?? "") as { | 3486 | "IC_1", |
| 3431 | query: string; | 3487 | "IC_1", |
| 3432 | variables: Record<string, string>; | 3488 | ]); |
| 3433 | }; | 3489 | expect(variablesOf("updateIssueComment(")).toEqual([ |
| 3434 | expect(callAt(0).args).toContain("subjectId=IC_1"); | 3490 | { commentId: "IC_1", body: "Reworded." }, |
| 3435 | expect(parse(1).query).toContain("updateIssueComment("); | 3491 | ]); |
| 3436 | expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); | 3492 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ |
| 3437 | expect(parse(3).query).toContain("updatePullRequestReviewComment("); | 3493 | { commentId: "IC_1", body: "Reworded." }, |
| 3438 | expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); | 3494 | ]); |
| 3439 | }), | 3495 | }), |
| 3440 | ); | 3496 | ); |
| 3441 | 3497 | ||
| ... | @@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3444 | mockedExecute.mockReturnValueOnce( | 3500 | mockedExecute.mockReturnValueOnce( |
| 3445 | Effect.succeed( | 3501 | Effect.succeed( |
| 3446 | output( | 3502 | output( |
| 3447 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3503 | encodeJson({ |
| 3448 | JSON.stringify({ | ||
| 3449 | data: { | 3504 | data: { |
| 3450 | repository: { pullRequest: { id: "PR_thisOne" } }, | 3505 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3451 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | 3506 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, |
| ... | @@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3469 | ); | 3524 | ); |
| 3470 | 3525 | ||
| 3471 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | 3526 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); |
| 3472 | expect(error.message).toContain("updateComment"); | 3527 | if (error._tag === "GitHubSubjectScopeError") |
| 3528 | assert.strictEqual(error.operation, "updateComment"); | ||
| 3473 | // Refused before any mutation was sent. | 3529 | // Refused before any mutation was sent. |
| 3474 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 3530 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3475 | }), | 3531 | }), |
| ... | @@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3500 | output( | 3556 | output( |
| 3501 | encodeJson( | 3557 | encodeJson( |
| 3502 | coreResponse({ | 3558 | coreResponse({ |
| 3503 | number: 7, | ||
| 3504 | title: "Progressive detail", | 3559 | title: "Progressive detail", |
| 3505 | url: "https://github.com/acme/web/pull/7", | ||
| 3506 | author: { login: "octocat" }, | 3560 | author: { login: "octocat" }, |
| 3507 | headRefName: "feature", | ||
| 3508 | baseRefName: "main", | ||
| 3509 | createdAt: "2026-07-01T00:00:00Z", | ||
| 3510 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 3511 | body: "Core body", | 3561 | body: "Core body", |
| 3512 | changedFiles: 2, | 3562 | changedFiles: 2, |
| 3513 | }), | 3563 | }), |
| ... | @@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3518 | mockedExecute.mockReturnValueOnce( | 3568 | mockedExecute.mockReturnValueOnce( |
| 3519 | Effect.succeed( | 3569 | Effect.succeed( |
| 3520 | output( | 3570 | output( |
| 3521 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3571 | encodeJson({ |
| 3522 | JSON.stringify({ | 3572 | data: { |
| 3523 | author: { login: "octocat" }, | 3573 | repository: { |
| 3524 | comments: [], | 3574 | pullRequest: { |
| 3525 | reviews: [], | 3575 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, |
| 3526 | commits: [], | 3576 | commits: { nodes: [] }, |
| 3577 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | ||
| 3578 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | ||
| 3579 | }, | ||
| 3580 | }, | ||
| 3581 | }, | ||
| 3527 | }), | 3582 | }), |
| 3528 | ), | 3583 | ), |
| 3529 | ), | 3584 | ), |
| ... | @@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3541 | 3596 | ||
| 3542 | expect(detail.body).toBe("Core body"); | 3597 | expect(detail.body).toBe("Core body"); |
| 3543 | expect(activity.author?.login).toBe("octocat"); | 3598 | expect(activity.author?.login).toBe("octocat"); |
| 3544 | expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); | 3599 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); |
| 3545 | expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); | 3600 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); |
| 3601 | expect(queryAt(0)).not.toContain("reviews("); | ||
| 3546 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ | 3602 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ |
| 3547 | merge: true, | 3603 | merge: true, |
| 3548 | squash: false, | 3604 | squash: false, |
| 3549 | rebase: true, | 3605 | rebase: true, |
| 3550 | }); | 3606 | }); |
| 3551 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | 3607 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); |
| 3552 | expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); | 3608 | // Conversation activity is its own read, and asks for the head of the conversation once. |
| 3609 | expect(queryAt(1)).toContain("reviews("); | ||
| 3610 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); | ||
| 3611 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 3553 | }), | 3612 | }), |
| 3554 | ); | 3613 | ); |
| 3555 | 3614 | ||
| ... | @@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3621 | 3680 | ||
| 3622 | it.effect("reads every check when the combined response has another page", () => | 3681 | it.effect("reads every check when the combined response has another page", () => |
| 3623 | Effect.gen(function* () { | 3682 | Effect.gen(function* () { |
| 3624 | const response = coreResponse({ | 3683 | mockedExecute.mockReturnValueOnce( |
| 3625 | commits: { | 3684 | Effect.succeed( |
| 3626 | nodes: [ | 3685 | output( |
| 3627 | { | 3686 | encodeJson( |
| 3628 | commit: { | 3687 | coreResponse({ |
| 3629 | statusCheckRollup: { | 3688 | commits: { |
| 3630 | contexts: { | 3689 | nodes: [ |
| 3631 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | 3690 | { |
| 3632 | pageInfo: { hasNextPage: true }, | 3691 | commit: { |
| 3633 | }, | 3692 | statusCheckRollup: { |
| 3693 | contexts: { | ||
| 3694 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | ||
| 3695 | pageInfo: { hasNextPage: true, endCursor: "c1" }, | ||
| 3696 | }, | ||
| 3697 | }, | ||
| 3698 | }, | ||
| 3699 | }, | ||
| 3700 | ], | ||
| 3634 | }, | 3701 | }, |
| 3635 | }, | 3702 | }), |
| 3636 | }, | 3703 | ), |
| 3637 | ], | 3704 | ), |
| 3638 | }, | 3705 | ), |
| 3639 | }); | 3706 | ); |
| 3640 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | ||
| 3641 | mockedExecute.mockReturnValueOnce( | 3707 | mockedExecute.mockReturnValueOnce( |
| 3642 | Effect.succeed( | 3708 | Effect.succeed( |
| 3643 | output( | 3709 | output( |
| 3644 | encodeJson({ | 3710 | checkContextsPage( |
| 3645 | ...response.data.repository.pullRequest, | 3711 | [ |
| 3646 | reviewRequests: [], | ||
| 3647 | labels: [], | ||
| 3648 | statusCheckRollup: [ | ||
| 3649 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | 3712 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, |
| 3650 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | 3713 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, |
| 3651 | ], | 3714 | ], |
| 3652 | }), | 3715 | null, |
| 3716 | ), | ||
| 3653 | ), | 3717 | ), |
| 3654 | ), | 3718 | ), |
| 3655 | ); | 3719 | ); |
| ... | @@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3663 | expect(detail.checks).toHaveLength(2); | 3727 | expect(detail.checks).toHaveLength(2); |
| 3664 | expect(detail.checksState).toBe("failing"); | 3728 | expect(detail.checksState).toBe("failing"); |
| 3665 | expect(detail.checksTruncated).toBe(false); | 3729 | expect(detail.checksTruncated).toBe(false); |
| 3666 | expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]); | 3730 | // The whole rollup is walked from its start, so no check is counted twice or skipped. |
| 3731 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); | ||
| 3732 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 3667 | }), | 3733 | }), |
| 3668 | ); | 3734 | ); |
| 3669 | 3735 | ||
| ... | @@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3744 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | 3810 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); |
| 3745 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | 3811 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); |
| 3746 | expect(mockedExecute).toHaveBeenCalledOnce(); | 3812 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 3747 | yield* cli | 3813 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); |
| 3748 | .getPullRequestDetail(input) | ||
| 3749 | .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); | ||
| 3750 | expect(mockedExecute).toHaveBeenCalledTimes(2); | 3814 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 3751 | }), | 3815 | }), |
| 3752 | ); | 3816 | ); |
| ... | @@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3769 | // What matters is that it fails at all: an empty patch with no cursor would render as a | 3833 | // What matters is that it fails at all: an empty patch with no cursor would render as a |
| 3770 | // change with no files and report the rest of it as already read. The refusal that sent | 3834 | // change with no files and report the rest of it as already read. The refusal that sent |
| 3771 | // the read down this road is the one reported, by design. | 3835 | // the read down this road is the one reported, by design. |
| 3772 | assert.strictEqual(error._tag, "GitHubCliCommandError"); | 3836 | assert.strictEqual(error, diffRefused); |
| 3773 | }), | 3837 | }), |
| 3774 | ); | 3838 | ); |
| 3775 | 3839 | ||
| 3776 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => | 3840 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => |
| 3777 | Effect.gen(function* () { | 3841 | Effect.gen(function* () { |
| 3778 | // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file. | 3842 | // The whole diff came back, but cut at a byte, which lands mid-file. |
| 3779 | mockedExecute.mockReturnValueOnce( | 3843 | mockedExecute.mockReturnValueOnce( |
| 3780 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), | 3844 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), |
| 3781 | ); | 3845 | ); |
| ... | @@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3790 | }); | 3854 | }); |
| 3791 | 3855 | ||
| 3792 | // The severed patch is thrown away; what comes back is assembled from whole files. | 3856 | // The severed patch is thrown away; what comes back is assembled from whole files. |
| 3793 | expect(callAt(1).args.join(" ")).toContain("/pulls/7/files"); | 3857 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); |
| 3794 | expect(slice.patch).toContain("src/file1.ts"); | 3858 | expect(slice.patch).toContain("src/file1.ts"); |
| 3859 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | ||
| 3795 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 3860 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3796 | }), | 3861 | }), |
| 3797 | ); | 3862 | ); |
| ... | @@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3813 | number: 7, | 3878 | number: 7, |
| 3814 | }); | 3879 | }); |
| 3815 | 3880 | ||
| 3816 | // The first page asks from the beginning, which gh only sends as a typed JSON null. | 3881 | // The first page asks from the beginning; the next carries on from where it stopped. |
| 3817 | expect(callAt(0).args).toContain("cursor=null"); | 3882 | expect(varsAt(0)["cursor"]).toBeNull(); |
| 3818 | expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE"); | 3883 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); |
| 3819 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); | 3884 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); |
| 3820 | assert.isFalse(conversation.truncated); | 3885 | assert.isFalse(conversation.truncated); |
| 3821 | }), | 3886 | }), |
| ... | @@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3897 | cursor: "Y3Vyc29yOjI", | 3962 | cursor: "Y3Vyc29yOjI", |
| 3898 | }); | 3963 | }); |
| 3899 | 3964 | ||
| 3900 | expect(callAt(0).args).toContain("owner=acme"); | 3965 | expect(varsAt(0)).toMatchObject({ |
| 3901 | expect(callAt(0).args).toContain("name=web"); | 3966 | owner: "acme", |
| 3902 | expect(callAt(0).args).toContain("number=7"); | 3967 | name: "web", |
| 3903 | expect(callAt(0).args).toContain("threadId=PRRT_1"); | 3968 | number: 7, |
| 3904 | expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI"); | 3969 | threadId: "PRRT_1", |
| 3970 | cursor: "Y3Vyc29yOjI", | ||
| 3971 | }); | ||
| 3905 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 3972 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3906 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); | 3973 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); |
| 3907 | expect(page.nextCursor).toBeNull(); | 3974 | expect(page.nextCursor).toBeNull(); |
| ... | @@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3937 | mockedExecute.mockReturnValue( | 4004 | mockedExecute.mockReturnValue( |
| 3938 | Effect.succeed( | 4005 | Effect.succeed( |
| 3939 | output( | 4006 | output( |
| 3940 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4007 | encodeJson({ |
| 3941 | JSON.stringify({ | ||
| 3942 | data: { | 4008 | data: { |
| 3943 | repository: { | 4009 | repository: { |
| 3944 | mergeCommitAllowed: true, | 4010 | mergeCommitAllowed: true, |
| ... | @@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3963 | 4029 | ||
| 3964 | // One request, because both answers hang off the same repository object. | 4030 | // One request, because both answers hang off the same repository object. |
| 3965 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 4031 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3966 | expect(callAt(0).args).toContain("number=7"); | 4032 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 3967 | expect(callAt(0).args.at(-1)).toContain( | 4033 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); |
| 3968 | "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", | ||
| 3969 | ); | ||
| 3970 | expect(access).toEqual({ | 4034 | expect(access).toEqual({ |
| 3971 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | 4035 | mergeCapabilities: { merge: true, squash: false, rebase: true }, |
| 3972 | canWrite: false, | 4036 | canWrite: false, |
| ... | @@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3977 | }), | 4041 | }), |
| 3978 | ); | 4042 | ); |
| 3979 | 4043 | ||
| 3980 | it.effect("sends the base comparison's variables as gh flags, not as bare words", () => | 4044 | it.effect("sends the base comparison's head as a variable, not inside the document", () => |
| 3981 | Effect.gen(function* () { | 4045 | Effect.gen(function* () { |
| 3982 | mockedExecute.mockReturnValue( | 4046 | mockedExecute.mockReturnValue( |
| 3983 | Effect.succeed( | 4047 | Effect.succeed( |
| 3984 | output( | 4048 | output( |
| 3985 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4049 | encodeJson({ |
| 3986 | JSON.stringify({ | ||
| 3987 | data: { | 4050 | data: { |
| 3988 | repository: { | 4051 | repository: { |
| 3989 | pullRequest: { | 4052 | pullRequest: { |
| ... | @@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4006 | headRef: "fork:feat/page", | 4069 | headRef: "fork:feat/page", |
| 4007 | }); | 4070 | }); |
| 4008 | 4071 | ||
| 4009 | // The tuples are flattened straight into argv, so a variable without its flag is a | 4072 | expect(varsAt(0)).toEqual({ |
| 4010 | // positional argument gh refuses outright. | 4073 | owner: "acme", |
| 4011 | const args = callAt(0).args; | 4074 | name: "web", |
| 4012 | expect(args.slice(0, -2)).toEqual([ | 4075 | number: 7, |
| 4013 | "api", | 4076 | headRef: "fork:feat/page", |
| 4014 | "graphql", | 4077 | }); |
| 4015 | "--hostname", | ||
| 4016 | "github.com", | ||
| 4017 | "-f", | ||
| 4018 | "owner=acme", | ||
| 4019 | "-f", | ||
| 4020 | "name=web", | ||
| 4021 | "-F", | ||
| 4022 | "number=7", | ||
| 4023 | "-f", | ||
| 4024 | "headRef=fork:feat/page", | ||
| 4025 | ]); | ||
| 4026 | expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); | 4078 | expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); |
| 4027 | expect(args.at(-2)).toBe("-f"); | 4079 | expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)); |
| 4028 | expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`); | ||
| 4029 | }), | 4080 | }), |
| 4030 | ); | 4081 | ); |
| 4031 | 4082 | ||
| ... | @@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4034 | mockedExecute.mockReturnValue( | 4085 | mockedExecute.mockReturnValue( |
| 4035 | Effect.succeed( | 4086 | Effect.succeed( |
| 4036 | output( | 4087 | output( |
| 4037 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4088 | encodeJson({ |
| 4038 | JSON.stringify({ | ||
| 4039 | data: { | 4089 | data: { |
| 4040 | repository: { | 4090 | repository: { |
| 4041 | pullRequest: { | 4091 | pullRequest: { |
| ... | @@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4064 | } as const; | 4114 | } as const; |
| 4065 | 4115 | ||
| 4066 | yield* cli.getPullRequestBaseComparison(input); | 4116 | yield* cli.getPullRequestBaseComparison(input); |
| 4067 | expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }"); | 4117 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); |
| 4068 | 4118 | ||
| 4069 | const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input)); | 4119 | const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input)); |
| 4070 | 4120 | ||
| ... | @@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4083 | .mockReturnValueOnce( | 4133 | .mockReturnValueOnce( |
| 4084 | Effect.succeed( | 4134 | Effect.succeed( |
| 4085 | output( | 4135 | output( |
| 4086 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4136 | encodeJson({ |
| 4087 | JSON.stringify({ | ||
| 4088 | data: { | 4137 | data: { |
| 4089 | repository: { | 4138 | repository: { |
| 4090 | pullRequest: { | 4139 | pullRequest: { |
| ... | @@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4106 | .mockReturnValueOnce( | 4155 | .mockReturnValueOnce( |
| 4107 | Effect.succeed( | 4156 | Effect.succeed( |
| 4108 | output( | 4157 | output( |
| 4109 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4158 | encodeJson({ |
| 4110 | JSON.stringify({ | ||
| 4111 | data: { | 4159 | data: { |
| 4112 | repository: { | 4160 | repository: { |
| 4113 | mergeCommitAllowed: true, | 4161 | mergeCommitAllowed: true, |
| ... | @@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4167 | requested: true, | 4215 | requested: true, |
| 4168 | }); | 4216 | }); |
| 4169 | 4217 | ||
| 4170 | const call = callAt(0); | 4218 | expect(callAt(0)).toMatchObject({ |
| 4171 | expect(call.args).toEqual([ | 4219 | kind: "rest", |
| 4172 | "api", | 4220 | method: "POST", |
| 4173 | "--method", | 4221 | path: "repos/acme/web/pulls/7/requested_reviewers", |
| 4174 | "POST", | 4222 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, |
| 4175 | "--hostname", | ||
| 4176 | "github.com", | ||
| 4177 | "repos/acme/web/pulls/7/requested_reviewers", | ||
| 4178 | "--input", | ||
| 4179 | "-", | ||
| 4180 | ]); | ||
| 4181 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 4182 | expect(JSON.parse(call.stdin ?? "")).toEqual({ | ||
| 4183 | reviewers: ["octocat"], | ||
| 4184 | team_reviewers: ["reviewers"], | ||
| 4185 | }); | 4223 | }); |
| 4186 | }), | 4224 | }), |
| 4187 | ); | 4225 | ); |
| ... | @@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4200 | requested: false, | 4238 | requested: false, |
| 4201 | }); | 4239 | }); |
| 4202 | 4240 | ||
| 4203 | const call = callAt(0); | 4241 | expect(callAt(0)).toMatchObject({ |
| 4204 | expect(call.args).toContain("DELETE"); | 4242 | kind: "rest", |
| 4205 | expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers"); | 4243 | method: "DELETE", |
| 4206 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4244 | path: "repos/acme/web/pulls/7/requested_reviewers", |
| 4207 | expect(JSON.parse(call.stdin ?? "")).toEqual({ | 4245 | body: { reviewers: ["octocat"], team_reviewers: [] }, |
| 4208 | reviewers: ["octocat"], | ||
| 4209 | team_reviewers: [], | ||
| 4210 | }); | 4246 | }); |
| 4211 | }), | 4247 | }), |
| 4212 | ); | 4248 | ); |
| ... | @@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4216 | mockedExecute.mockReturnValue( | 4252 | mockedExecute.mockReturnValue( |
| 4217 | Effect.succeed( | 4253 | Effect.succeed( |
| 4218 | output( | 4254 | output( |
| 4219 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4255 | encodeJson({ |
| 4220 | JSON.stringify({ | ||
| 4221 | data: { | 4256 | data: { |
| 4222 | repository: { | 4257 | repository: { |
| 4223 | assignableUsers: { | 4258 | assignableUsers: { |
| ... | @@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4246 | // The people, who has been asked and who opened the pull request all hang off the same | 4281 | // The people, who has been asked and who opened the pull request all hang off the same |
| 4247 | // repository object, so the menu costs one request. | 4282 | // repository object, so the menu costs one request. |
| 4248 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 4283 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4249 | expect(callAt(0).args).toContain("number=7"); | 4284 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 4250 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | 4285 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ |
| 4251 | ["octocat", true], | 4286 | ["octocat", true], |
| 4252 | ["hubot", false], | 4287 | ["hubot", false], |
| ... | @@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4269 | }); | 4304 | }); |
| 4270 | 4305 | ||
| 4271 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 4306 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4272 | const call = callAt(0); | 4307 | expect(callAt(0)).toMatchObject({ |
| 4273 | expect(call.args).toEqual([ | 4308 | kind: "rest", |
| 4274 | "api", | 4309 | method: "POST", |
| 4275 | "--method", | 4310 | path: "repos/acme/web/issues/7/labels", |
| 4276 | "POST", | 4311 | body: { labels: ["bug", "size:XL"] }, |
| 4277 | "--hostname", | 4312 | }); |
| 4278 | "github.com", | ||
| 4279 | "repos/acme/web/issues/7/labels", | ||
| 4280 | "--input", | ||
| 4281 | "-", | ||
| 4282 | ]); | ||
| 4283 | // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body. | ||
| 4284 | expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] }); | ||
| 4285 | }), | 4313 | }), |
| 4286 | ); | 4314 | ); |
| 4287 | 4315 | ||
| ... | @@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4300 | }); | 4328 | }); |
| 4301 | 4329 | ||
| 4302 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 4330 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4303 | expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); | 4331 | expect(callAt(0)).toMatchObject({ |
| 4304 | expect(callAt(0).args).toContain("DELETE"); | 4332 | method: "DELETE", |
| 4305 | expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); | 4333 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", |
| 4334 | }); | ||
| 4335 | expect(callAt(1)).toMatchObject({ | ||
| 4336 | method: "DELETE", | ||
| 4337 | path: "repos/acme/web/issues/7/labels/area%2Fweb", | ||
| 4338 | }); | ||
| 4306 | }), | 4339 | }), |
| 4307 | ); | 4340 | ); |
| 4308 | 4341 | ||
| ... | @@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4311 | const page = (index: number, hasNextPage: boolean) => | 4344 | const page = (index: number, hasNextPage: boolean) => |
| 4312 | Effect.succeed( | 4345 | Effect.succeed( |
| 4313 | output( | 4346 | output( |
| 4314 | JSON.stringify({ | 4347 | encodeJson({ |
| 4315 | data: { | 4348 | data: { |
| 4316 | repository: { | 4349 | repository: { |
| 4317 | pullRequest: { | 4350 | pullRequest: { |
| ... | @@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4343 | 4376 | ||
| 4344 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | 4377 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4345 | // The first page asks from the start; each one after it carries the cursor before it. | 4378 | // The first page asks from the start; each one after it carries the cursor before it. |
| 4346 | assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after="))); | 4379 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ |
| 4347 | expect(callAt(1).args).toContain("after=cursor-0"); | 4380 | null, |
| 4348 | expect(callAt(2).args).toContain("after=cursor-1"); | 4381 | "cursor-0", |
| 4382 | "cursor-1", | ||
| 4383 | ]); | ||
| 4349 | assert.isFalse(viewed.truncated); | 4384 | assert.isFalse(viewed.truncated); |
| 4350 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ | 4385 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ |
| 4351 | ["src/file0.ts", "viewed"], | 4386 | ["src/file0.ts", "viewed"], |
| ... | @@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4363 | mockedExecute.mockReturnValue( | 4398 | mockedExecute.mockReturnValue( |
| 4364 | Effect.succeed( | 4399 | Effect.succeed( |
| 4365 | output( | 4400 | output( |
| 4366 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4401 | encodeJson({ |
| 4367 | JSON.stringify({ | ||
| 4368 | data: { | 4402 | data: { |
| 4369 | repository: { | 4403 | repository: { |
| 4370 | pullRequest: { | 4404 | pullRequest: { |
| ... | @@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4399 | mockedExecute | 4433 | mockedExecute |
| 4400 | .mockReturnValueOnce( | 4434 | .mockReturnValueOnce( |
| 4401 | Effect.succeed( | 4435 | Effect.succeed( |
| 4402 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4436 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), |
| 4403 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | ||
| 4404 | ), | 4437 | ), |
| 4405 | ) | 4438 | ) |
| 4406 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | 4439 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| ... | @@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4419 | 4452 | ||
| 4420 | // One request to learn the pull request's node id, one for every press together. | 4453 | // One request to learn the pull request's node id, one for every press together. |
| 4421 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 4454 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4422 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4455 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); |
| 4423 | const sent = JSON.parse(callAt(1).stdin ?? "") as { | 4456 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); |
| 4424 | query: string; | 4457 | expect(varsAt(1)).toEqual({ |
| 4425 | variables: Record<string, string>; | ||
| 4426 | }; | ||
| 4427 | expect(sent.query).toContain("f0: markFileAsViewed"); | ||
| 4428 | expect(sent.query).toContain("f1: unmarkFileAsViewed"); | ||
| 4429 | expect(sent.variables).toEqual({ | ||
| 4430 | pullRequestId: "PR_1", | 4458 | pullRequestId: "PR_1", |
| 4431 | path0: "src/a.ts", | 4459 | path0: "src/a.ts", |
| 4432 | path1: "src/b.ts", | 4460 | path1: "src/b.ts", |
| ... | @@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4455 | mockedExecute | 4483 | mockedExecute |
| 4456 | .mockReturnValueOnce( | 4484 | .mockReturnValueOnce( |
| 4457 | Effect.succeed( | 4485 | Effect.succeed( |
| 4458 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4486 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), |
| 4459 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | ||
| 4460 | ), | 4487 | ), |
| 4461 | ) | 4488 | ) |
| 4462 | .mockReturnValue(Effect.succeed(output("{}"))); | 4489 | .mockReturnValue(Effect.succeed(output("{}"))); |
| ... | @@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4475 | 4502 | ||
| 4476 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. | 4503 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. |
| 4477 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | 4504 | assert.strictEqual(mockedExecute.mock.calls.length, 4); |
| 4478 | expect(callAt(0).args).toContain("number=24"); | 4505 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); |
| 4479 | const idSentAt = (index: number) => | 4506 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ |
| 4480 | (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) | 4507 | "PR_24", |
| 4481 | .variables.pullRequestId; | 4508 | "PR_24", |
| 4482 | expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]); | 4509 | "PR_24", |
| 4510 | ]); | ||
| 4483 | }), | 4511 | }), |
| 4484 | ); | 4512 | ); |
| 4485 | 4513 | ||
| ... | @@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4489 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) | 4517 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) |
| 4490 | .mockReturnValueOnce( | 4518 | .mockReturnValueOnce( |
| 4491 | Effect.succeed( | 4519 | Effect.succeed( |
| 4492 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4520 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), |
| 4493 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | ||
| 4494 | ), | 4521 | ), |
| 4495 | ) | 4522 | ) |
| 4496 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | 4523 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| ... | @@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4510 | yield* write(); | 4537 | yield* write(); |
| 4511 | 4538 | ||
| 4512 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | 4539 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4513 | const idSentAt = (index: number) => | 4540 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); |
| 4514 | (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) | ||
| 4515 | .variables.pullRequestId; | ||
| 4516 | expect(idSentAt(2)).toEqual("PR_25"); | ||
| 4517 | }), | 4541 | }), |
| 4518 | ); | 4542 | ); |
| 4519 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => | 4543 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => |
| ... | @@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4525 | const HOT = 9_000; | 4549 | const HOT = 9_000; |
| 4526 | const lookupsOf = new Map<number, number>(); | 4550 | const lookupsOf = new Map<number, number>(); |
| 4527 | mockedExecute.mockImplementation((input) => { | 4551 | mockedExecute.mockImplementation((input) => { |
| 4528 | const asked = input.args.find((arg) => arg.startsWith("number=")); | 4552 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { |
| 4529 | if (asked === undefined) return Effect.succeed(output("{}")); | 4553 | return Effect.succeed(output("{}")); |
| 4530 | const number = Number(asked.slice("number=".length)); | 4554 | } |
| 4555 | const number = Number(input.variables?.["number"]); | ||
| 4531 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); | 4556 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); |
| 4532 | return Effect.succeed( | 4557 | return Effect.succeed( |
| 4533 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), | 4558 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+814-936| ... | @@ -1,21 +1,19 @@ | ... | @@ -1,21 +1,19 @@ |
| 1 | import { removeAgentCredits } from "./mergeMessage.ts"; | 1 | import { removeAgentCredits } from "./mergeMessage.ts"; |
| 2 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 4 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 6 | import * as Clock from "effect/Clock"; | 6 | import * as Clock from "effect/Clock"; |
| 7 | import * as Crypto from "effect/Crypto"; | 7 | import * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Exit from "effect/Exit"; | 10 | import * as Exit from "effect/Exit"; |
| 11 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 12 | import * as Redacted from "effect/Redacted"; | ||
| 13 | import * as Request from "effect/Request"; | 12 | import * as Request from "effect/Request"; |
| 14 | import * as RequestResolver from "effect/RequestResolver"; | 13 | import * as RequestResolver from "effect/RequestResolver"; |
| 15 | import * as Result from "effect/Result"; | 14 | import * as Result from "effect/Result"; |
| 16 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 17 | import * as Semaphore from "effect/Semaphore"; | 16 | import * as Semaphore from "effect/Semaphore"; |
| 18 | import * as Hex from "effect/encoding/Hex"; | ||
| 19 | import { | 17 | import { |
| 20 | resolvePullRequestAuthorFilter, | 18 | resolvePullRequestAuthorFilter, |
| 21 | PositiveInt, | 19 | PositiveInt, |
| ... | @@ -23,6 +21,8 @@ import { | ... | @@ -23,6 +21,8 @@ import { |
| 23 | type PullRequestAction, | 21 | type PullRequestAction, |
| 24 | type PullRequestStackHead, | 22 | type PullRequestStackHead, |
| 25 | type PullRequestActor, | 23 | type PullRequestActor, |
| 24 | type PullRequestComment, | ||
| 25 | type PullRequestCommit, | ||
| 26 | type PullRequestFileViewed, | 26 | type PullRequestFileViewed, |
| 27 | type PullRequestInvolvement, | 27 | type PullRequestInvolvement, |
| 28 | type PullRequestListFilters, | 28 | type PullRequestListFilters, |
| ... | @@ -41,19 +41,28 @@ import { | ... | @@ -41,19 +41,28 @@ import { |
| 41 | type PullRequestPreview, | 41 | type PullRequestPreview, |
| 42 | } from "@t3tools/contracts"; | 42 | } from "@t3tools/contracts"; |
| 43 | 43 | ||
| 44 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 44 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; |
| 45 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | 45 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 46 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | 46 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 47 | import { | 47 | import { |
| 48 | ACTOR_AVATARS_GRAPHQL_QUERY, | 48 | ACTOR_AVATARS_GRAPHQL_QUERY, |
| 49 | ADD_REACTION_GRAPHQL_MUTATION, | 49 | ADD_REACTION_GRAPHQL_MUTATION, |
| 50 | buildReviewSubmissionJson, | 50 | buildReviewSubmission, |
| 51 | buildReviewerRequestJson, | 51 | buildReviewerRequest, |
| 52 | buildSetFilesViewedGraphQlMutation, | 52 | buildSetFilesViewedGraphQlMutation, |
| 53 | decodeActorAvatarsJson, | 53 | decodeActorAvatarsJson, |
| 54 | decodePullRequestActivityJson, | 54 | decodePullRequestActivityJson, |
| 55 | decodePullRequestDetailJson, | 55 | decodePullRequestCheckContextsJson, |
| 56 | decodePullRequestCoreJson, | 56 | decodePullRequestCoreJson, |
| 57 | decodeCommitFilesJson, | ||
| 58 | pullRequestCheckContextsGraphQlQuery, | ||
| 59 | pullRequestChecksFromContexts, | ||
| 60 | pullRequestListGraphQlQuery, | ||
| 61 | pullRequestSummaryGraphQlQuery, | ||
| 62 | PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | ||
| 63 | PULL_REQUEST_HEADS_GRAPHQL_QUERY, | ||
| 64 | decodeWorkflowRunsJson, | ||
| 65 | type GitHubCheckContext, | ||
| 57 | pullRequestCoreGraphQlQuery, | 66 | pullRequestCoreGraphQlQuery, |
| 58 | type GitHubPullRequestCore, | 67 | type GitHubPullRequestCore, |
| 59 | type GitHubPullRequestSummary, | 68 | type GitHubPullRequestSummary, |
| ... | @@ -73,7 +82,7 @@ import { | ... | @@ -73,7 +82,7 @@ import { |
| 73 | decodeReactionSubjectScopeJson, | 82 | decodeReactionSubjectScopeJson, |
| 74 | decodeReviewerCandidatesJson, | 83 | decodeReviewerCandidatesJson, |
| 75 | decodeLabelCandidatesJson, | 84 | decodeLabelCandidatesJson, |
| 76 | buildLabelRequestJson, | 85 | buildLabelRequest, |
| 77 | LABEL_CANDIDATES_GRAPHQL_QUERY, | 86 | LABEL_CANDIDATES_GRAPHQL_QUERY, |
| 78 | decodeReviewDismissalsJson, | 87 | decodeReviewDismissalsJson, |
| 79 | decodeReviewThreadCommentsJson, | 88 | decodeReviewThreadCommentsJson, |
| ... | @@ -83,14 +92,10 @@ import { | ... | @@ -83,14 +92,10 @@ import { |
| 83 | buildPullRequestWatchFingerprintsGraphQlQuery, | 92 | buildPullRequestWatchFingerprintsGraphQlQuery, |
| 84 | buildPullRequestStackMembershipsGraphQlQuery, | 93 | buildPullRequestStackMembershipsGraphQlQuery, |
| 85 | decodePullRequestStackMembershipsJson, | 94 | decodePullRequestStackMembershipsJson, |
| 86 | encodeGraphQlRequestJson, | ||
| 87 | pullRequestSearchGraphQlQuery, | 95 | pullRequestSearchGraphQlQuery, |
| 88 | PULL_REQUEST_SEARCH_MAX_ROWS, | 96 | PULL_REQUEST_SEARCH_MAX_ROWS, |
| 89 | PULL_REQUEST_ACTIVITY_JSON_FIELDS, | ||
| 90 | BASE_COMPARISON_GRAPHQL_QUERY, | 97 | BASE_COMPARISON_GRAPHQL_QUERY, |
| 91 | decodeBaseComparisonJson, | 98 | decodeBaseComparisonJson, |
| 92 | PULL_REQUEST_DETAIL_JSON_FIELDS, | ||
| 93 | PULL_REQUEST_LIST_JSON_FIELDS, | ||
| 94 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | 99 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, |
| 95 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | 100 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 96 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | 101 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| ... | @@ -110,9 +115,10 @@ import { | ... | @@ -110,9 +115,10 @@ import { |
| 110 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | 115 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, |
| 111 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, | 116 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, |
| 112 | decodeViewerPermissionsJson, | 117 | decodeViewerPermissionsJson, |
| 113 | decodeWorkflowRunApprovalsJson, | ||
| 114 | type GitHubBaseComparison, | 118 | type GitHubBaseComparison, |
| 115 | type GitHubPullRequestActivity, | 119 | type GitHubPullRequestActivity, |
| 120 | type GitHubPullRequestActivityPage, | ||
| 121 | type GitHubWorkflowRunPage, | ||
| 116 | type GitHubPullRequestHead, | 122 | type GitHubPullRequestHead, |
| 117 | type GitHubPullRequestListItem, | 123 | type GitHubPullRequestListItem, |
| 118 | type GitHubPullRequestSearchItem, | 124 | type GitHubPullRequestSearchItem, |
| ... | @@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq | ... | @@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq |
| 139 | cause: Schema.Defect(), | 145 | cause: Schema.Defect(), |
| 140 | }, | 146 | }, |
| 141 | ) { | 147 | ) { |
| 142 | get detail(): string { | ||
| 143 | return `GitHub CLI returned an unreadable ${this.operation} response.`; | ||
| 144 | } | ||
| 145 | |||
| 146 | override get message(): string { | 148 | override get message(): string { |
| 147 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 149 | return `GitHub CLI returned an unreadable ${this.operation} response.`; |
| 148 | } | 150 | } |
| 149 | } | 151 | } |
| 150 | 152 | ||
| ... | @@ -156,12 +158,8 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub | ... | @@ -156,12 +158,8 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub |
| 156 | cwd: Schema.String, | 158 | cwd: Schema.String, |
| 157 | }, | 159 | }, |
| 158 | ) { | 160 | ) { |
| 159 | get detail(): string { | ||
| 160 | return "GitHub CLI returned no login for the authenticated account."; | ||
| 161 | } | ||
| 162 | |||
| 163 | override get message(): string { | 161 | override get message(): string { |
| 164 | return `GitHub CLI failed in getViewerLogin: ${this.detail}`; | 162 | return "GitHub CLI returned no login for the authenticated account."; |
| 165 | } | 163 | } |
| 166 | } | 164 | } |
| 167 | 165 | ||
| ... | @@ -175,12 +173,8 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr | ... | @@ -175,12 +173,8 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr |
| 175 | number: Schema.Int, | 173 | number: Schema.Int, |
| 176 | }, | 174 | }, |
| 177 | ) { | 175 | ) { |
| 178 | get detail(): string { | ||
| 179 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | ||
| 180 | } | ||
| 181 | |||
| 182 | override get message(): string { | 176 | override get message(): string { |
| 183 | return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`; | 177 | return `Pull request ${this.repository}#${this.number} reported no update time.`; |
| 184 | } | 178 | } |
| 185 | } | 179 | } |
| 186 | 180 | ||
| ... | @@ -192,12 +186,8 @@ export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorEr | ... | @@ -192,12 +186,8 @@ export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorEr |
| 192 | cwd: Schema.String, | 186 | cwd: Schema.String, |
| 193 | }, | 187 | }, |
| 194 | ) { | 188 | ) { |
| 195 | get detail(): string { | ||
| 196 | return "The diff cursor was not one this pull request handed out."; | ||
| 197 | } | ||
| 198 | |||
| 199 | override get message(): string { | 189 | override get message(): string { |
| 200 | return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; | 190 | return "The diff cursor was not one this pull request handed out."; |
| 201 | } | 191 | } |
| 202 | } | 192 | } |
| 203 | 193 | ||
| ... | @@ -209,12 +199,8 @@ export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitEr | ... | @@ -209,12 +199,8 @@ export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitEr |
| 209 | cwd: Schema.String, | 199 | cwd: Schema.String, |
| 210 | }, | 200 | }, |
| 211 | ) { | 201 | ) { |
| 212 | get detail(): string { | ||
| 213 | return "The named commit was not a commit sha."; | ||
| 214 | } | ||
| 215 | |||
| 216 | override get message(): string { | 202 | override get message(): string { |
| 217 | return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; | 203 | return "The named commit was not a commit sha."; |
| 218 | } | 204 | } |
| 219 | } | 205 | } |
| 220 | 206 | ||
| ... | @@ -228,15 +214,11 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitH | ... | @@ -228,15 +214,11 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitH |
| 228 | commit: Schema.optional(Schema.String), | 214 | commit: Schema.optional(Schema.String), |
| 229 | }, | 215 | }, |
| 230 | ) { | 216 | ) { |
| 231 | get detail(): string { | 217 | override get message(): string { |
| 232 | return this.commit === undefined | 218 | return this.commit === undefined |
| 233 | ? `Pull request #${this.number} reported no usable base and head revisions.` | 219 | ? `Pull request #${this.number} reported no usable base and head revisions.` |
| 234 | : `Commit ${this.commit} reported no usable revisions for this file.`; | 220 | : `Commit ${this.commit} reported no usable revisions for this file.`; |
| 235 | } | 221 | } |
| 236 | |||
| 237 | override get message(): string { | ||
| 238 | return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; | ||
| 239 | } | ||
| 240 | } | 222 | } |
| 241 | 223 | ||
| 242 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | 224 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ |
| ... | @@ -249,15 +231,11 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<G | ... | @@ -249,15 +231,11 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<G |
| 249 | reason: Schema.Literals(["oversized", "binary"]), | 231 | reason: Schema.Literals(["oversized", "binary"]), |
| 250 | }, | 232 | }, |
| 251 | ) { | 233 | ) { |
| 252 | get detail(): string { | 234 | override get message(): string { |
| 253 | return this.reason === "oversized" | 235 | return this.reason === "oversized" |
| 254 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | 236 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` |
| 255 | : `The diff file '${this.path}' is binary.`; | 237 | : `The diff file '${this.path}' is binary.`; |
| 256 | } | 238 | } |
| 257 | |||
| 258 | override get message(): string { | ||
| 259 | return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; | ||
| 260 | } | ||
| 261 | } | 239 | } |
| 262 | 240 | ||
| 263 | /** | 241 | /** |
| ... | @@ -274,12 +252,8 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepo | ... | @@ -274,12 +252,8 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepo |
| 274 | operation: Schema.String, | 252 | operation: Schema.String, |
| 275 | }, | 253 | }, |
| 276 | ) { | 254 | ) { |
| 277 | get detail(): string { | ||
| 278 | return "A repository was named that GitHub cannot address."; | ||
| 279 | } | ||
| 280 | |||
| 281 | override get message(): string { | 255 | override get message(): string { |
| 282 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 256 | return "A repository was named that GitHub cannot address."; |
| 283 | } | 257 | } |
| 284 | } | 258 | } |
| 285 | 259 | ||
| ... | @@ -292,12 +266,8 @@ export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectSco | ... | @@ -292,12 +266,8 @@ export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectSco |
| 292 | operation: Schema.String, | 266 | operation: Schema.String, |
| 293 | }, | 267 | }, |
| 294 | ) { | 268 | ) { |
| 295 | get detail(): string { | ||
| 296 | return "The named subject did not belong to the named pull request."; | ||
| 297 | } | ||
| 298 | |||
| 299 | override get message(): string { | 269 | override get message(): string { |
| 300 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 270 | return "The named subject did not belong to the named pull request."; |
| 301 | } | 271 | } |
| 302 | } | 272 | } |
| 303 | 273 | ||
| ... | @@ -313,7 +283,7 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu | ... | @@ -313,7 +283,7 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu |
| 313 | limit: Schema.Int, | 283 | limit: Schema.Int, |
| 314 | }, | 284 | }, |
| 315 | ) { | 285 | ) { |
| 316 | get detail(): string { | 286 | override get message(): string { |
| 317 | if (this.reason === "head-list-truncated") { | 287 | if (this.reason === "head-list-truncated") { |
| 318 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | 288 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; |
| 319 | } | 289 | } |
| ... | @@ -322,10 +292,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu | ... | @@ -322,10 +292,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu |
| 322 | } | 292 | } |
| 323 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | 293 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; |
| 324 | } | 294 | } |
| 325 | |||
| 326 | override get message(): string { | ||
| 327 | return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`; | ||
| 328 | } | ||
| 329 | } | 295 | } |
| 330 | 296 | ||
| 331 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ | 297 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ |
| ... | @@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr | ... | @@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr |
| 337 | number: Schema.Int, | 303 | number: Schema.Int, |
| 338 | }, | 304 | }, |
| 339 | ) { | 305 | ) { |
| 340 | get detail(): string { | ||
| 341 | return `GitHub did not report a complete head revision for #${this.number}.`; | ||
| 342 | } | ||
| 343 | |||
| 344 | override get message(): string { | 306 | override get message(): string { |
| 345 | return `GitHub CLI refused approve-workflows: ${this.detail}`; | 307 | return `GitHub did not report a complete head revision for #${this.number}.`; |
| 346 | } | 308 | } |
| 347 | } | 309 | } |
| 348 | 310 | ||
| ... | @@ -355,18 +317,14 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G | ... | @@ -355,18 +317,14 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G |
| 355 | number: Schema.Int, | 317 | number: Schema.Int, |
| 356 | }, | 318 | }, |
| 357 | ) { | 319 | ) { |
| 358 | get detail(): string { | ||
| 359 | return `The head revision of #${this.number} changed before its workflows could be approved.`; | ||
| 360 | } | ||
| 361 | |||
| 362 | override get message(): string { | 320 | override get message(): string { |
| 363 | return `GitHub CLI refused approve-workflows: ${this.detail}`; | 321 | return `The head revision of #${this.number} changed before its workflows could be approved.`; |
| 364 | } | 322 | } |
| 365 | } | 323 | } |
| 366 | 324 | ||
| 367 | export type GitHubPullRequestCliError = | 325 | export type GitHubPullRequestCliError = |
| 368 | | GitHubStackActionError | 326 | | GitHubStackActionError |
| 369 | | GitHubCli.GitHubCliError | 327 | | GitHubApi.GitHubApiError |
| 370 | | GitHubPullRequestReadError | 328 | | GitHubPullRequestReadError |
| 371 | | GitHubDiffCursorError | 329 | | GitHubDiffCursorError |
| 372 | | GitHubDiffCommitError | 330 | | GitHubDiffCommitError |
| ... | @@ -383,10 +341,26 @@ export type GitHubPullRequestCliError = | ... | @@ -383,10 +341,26 @@ export type GitHubPullRequestCliError = |
| 383 | 341 | ||
| 384 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ | 342 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ |
| 385 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | 343 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 386 | const DIFF_TIMEOUT_MS = 60_000; | 344 | const DIFF_TIMEOUT = Duration.seconds(60); |
| 387 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ | 345 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ |
| 388 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | 346 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; |
| 389 | 347 | ||
| 348 | /** What `gh pr list --state` asked a repository's own list for, without search. */ | ||
| 349 | const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = { | ||
| 350 | all: ["OPEN", "CLOSED", "MERGED"], | ||
| 351 | open: ["OPEN"], | ||
| 352 | // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out | ||
| 353 | // locally by `matchesUnsortedListing`. | ||
| 354 | closed: ["CLOSED", "MERGED"], | ||
| 355 | merged: ["MERGED"], | ||
| 356 | }; | ||
| 357 | |||
| 358 | interface GitHubPullRequestListPage { | ||
| 359 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | ||
| 360 | readonly rawCount: number; | ||
| 361 | readonly endCursor: string | null; | ||
| 362 | } | ||
| 363 | |||
| 390 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ | 364 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ |
| 391 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; | 365 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; |
| 392 | 366 | ||
| ... | @@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128; | ... | @@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128; |
| 413 | */ | 387 | */ |
| 414 | const REVIEW_THREAD_PAGES = 10; | 388 | const REVIEW_THREAD_PAGES = 10; |
| 415 | 389 | ||
| 390 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ | ||
| 391 | const CHECK_CONTEXT_PAGES = 10; | ||
| 392 | |||
| 416 | export interface GitHubPullRequestListBatch { | 393 | export interface GitHubPullRequestListBatch { |
| 417 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | 394 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; |
| 418 | readonly truncated: boolean; | 395 | readonly truncated: boolean; |
| ... | @@ -960,50 +937,6 @@ function matchesFilters( | ... | @@ -960,50 +937,6 @@ function matchesFilters( |
| 960 | ); | 937 | ); |
| 961 | } | 938 | } |
| 962 | 939 | ||
| 963 | function involvementArgs(input: { | ||
| 964 | readonly state: PullRequestListState; | ||
| 965 | readonly involvement: PullRequestInvolvement; | ||
| 966 | readonly viewer: string; | ||
| 967 | readonly query?: string | undefined; | ||
| 968 | /** Where to carry on from, which only a search can express. */ | ||
| 969 | readonly cursor?: ProviderListCursor | undefined; | ||
| 970 | /** | ||
| 971 | * Ask GitHub for the order the page reads its rows in. False on the fallback read, which | ||
| 972 | * cannot use search at all and takes whatever order `gh pr list` answers in. | ||
| 973 | */ | ||
| 974 | readonly sorted: boolean; | ||
| 975 | readonly filters?: PullRequestListFilters | undefined; | ||
| 976 | }): ReadonlyArray<string> { | ||
| 977 | // `--state closed` includes merged pull requests, so the Closed tab additionally excludes | ||
| 978 | // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh` | ||
| 979 | // takes one `--search`, so the reader's text joins the qualifiers rather than replacing them. | ||
| 980 | const query = input.query?.trim() ?? ""; | ||
| 981 | // The fallback read exists because this repository's search index answered nothing, so it goes | ||
| 982 | // nowhere near search: no order, cursor or qualifiers. Its decoded rows are narrowed by state | ||
| 983 | // and involvement below, since widening either would put unrelated pull requests on the page. | ||
| 984 | const searchTerms = !input.sorted | ||
| 985 | ? [] | ||
| 986 | : [ | ||
| 987 | ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), | ||
| 988 | ...(input.state === "closed" ? ["is:unmerged"] : []), | ||
| 989 | ...(query.length === 0 ? [] : [searchPhrase(query)]), | ||
| 990 | // The instant the last slice ended on, and everything before it. Inclusive, because rows | ||
| 991 | // sharing one instant are ordinary and the caller drops the ones it has already sent — | ||
| 992 | // asking for strictly older would lose the rest of them instead. | ||
| 993 | ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), | ||
| 994 | ...filterQualifiers(input.filters, input.viewer), | ||
| 995 | // `gh pr list` answers newest-created first, which is not the order the page reads rows in | ||
| 996 | // and not an order a continuation can carry on from: a change request opened last year and | ||
| 997 | // touched this morning belongs at the top of the list and at the front of the first slice. | ||
| 998 | // Free text would otherwise come back in best-match order, which is worse again. | ||
| 999 | "sort:updated-desc", | ||
| 1000 | ]; | ||
| 1001 | return [ | ||
| 1002 | ...(input.involvement === "authored" ? ["--author", input.viewer] : []), | ||
| 1003 | ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), | ||
| 1004 | ]; | ||
| 1005 | } | ||
| 1006 | |||
| 1007 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ | 940 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ |
| 1008 | function matchesUnsortedListing( | 941 | function matchesUnsortedListing( |
| 1009 | item: GitHubPullRequestListItem, | 942 | item: GitHubPullRequestListItem, |
| ... | @@ -1029,13 +962,11 @@ function matchesUnsortedListing( | ... | @@ -1029,13 +962,11 @@ function matchesUnsortedListing( |
| 1029 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | 962 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; |
| 1030 | 963 | ||
| 1031 | /** | 964 | /** |
| 1032 | * The same listing as one GitHub search across several repositories, which is the only way to | 965 | * A listing as one GitHub search, across one repository or several: the only way to read a |
| 1033 | * read a whole host in one request. | 966 | * whole host in one request, and the only order (`updated`) a continuation can carry on from. |
| 1034 | * | 967 | * |
| 1035 | * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead, | 968 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is |
| 1036 | * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is | 969 | * `is:closed is:unmerged` here. |
| 1037 | * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed | ||
| 1038 | * is:unmerged`. The two belong together; a tab added to one wants adding to the other. | ||
| 1039 | * | 970 | * |
| 1040 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a | 971 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a |
| 1041 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its | 972 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its |
| ... | @@ -1073,15 +1004,6 @@ function searchQuery(input: { | ... | @@ -1073,15 +1004,6 @@ function searchQuery(input: { |
| 1073 | ].join(" "); | 1004 | ].join(" "); |
| 1074 | } | 1005 | } |
| 1075 | 1006 | ||
| 1076 | /** | ||
| 1077 | * The `after` a paged read carries. gh sends a JSON null only through a typed field, and an | ||
| 1078 | * untyped `cursor=` would send the empty string, which GitHub refuses as a cursor rather than | ||
| 1079 | * reading as "start at the beginning". | ||
| 1080 | */ | ||
| 1081 | function cursorVariable(cursor: string | null): readonly [string, string] { | ||
| 1082 | return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | 1007 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` |
| 1086 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { | 1008 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { |
| 1087 | repository(owner: $owner, name: $name) { | 1009 | repository(owner: $owner, name: $name) { |
| ... | @@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m | ... | @@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m |
| 1093 | } | 1015 | } |
| 1094 | }`; | 1016 | }`; |
| 1095 | 1017 | ||
| 1018 | /** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ | ||
| 1019 | const decodeRevisionRefs = Schema.decodeUnknownOption( | ||
| 1020 | Schema.fromJsonString( | ||
| 1021 | Schema.Struct({ | ||
| 1022 | sha: Schema.optional(Schema.String), | ||
| 1023 | parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), | ||
| 1024 | base: Schema.optional(Schema.Struct({ sha: Schema.String })), | ||
| 1025 | head: Schema.optional(Schema.Struct({ sha: Schema.String })), | ||
| 1026 | }), | ||
| 1027 | ), | ||
| 1028 | ); | ||
| 1029 | |||
| 1096 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( | 1030 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( |
| 1097 | Schema.fromJsonString( | 1031 | Schema.fromJsonString( |
| 1098 | Schema.Struct({ | 1032 | Schema.Struct({ |
| ... | @@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult( | ... | @@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult( |
| 1111 | const decodeMergeMessage = (raw: string) => | 1045 | const decodeMergeMessage = (raw: string) => |
| 1112 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | 1046 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); |
| 1113 | 1047 | ||
| 1114 | function actionArgs( | 1048 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ |
| 1115 | action: PullRequestAction, | 1049 | const ACTION_STATE_GRAPHQL_QUERY = ` |
| 1116 | mergeMethod: PullRequestMergeMethod | undefined, | 1050 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { |
| 1117 | updateMethod: PullRequestUpdateMethod | undefined, | 1051 | repository(owner: $owner, name: $name) { |
| 1118 | ): ReadonlyArray<string> { | 1052 | pullRequest(number: $number) { |
| 1119 | switch (action) { | 1053 | id |
| 1120 | case "merge": | 1054 | headRefOid |
| 1121 | return ["merge", `--${mergeMethod ?? "merge"}`]; | 1055 | isMergeQueueEnabled |
| 1122 | // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub | 1056 | mergeStateStatus |
| 1123 | // stores the strategy with the standing instruction rather than choosing one at merge time. | 1057 | baseRef { compare(headRef: $headRef) { behindBy } } |
| 1124 | case "enable-auto-merge": | 1058 | } |
| 1125 | return ["merge", "--auto", `--${mergeMethod ?? "merge"}`]; | ||
| 1126 | case "disable-auto-merge": | ||
| 1127 | return ["merge", "--disable-auto"]; | ||
| 1128 | // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default. | ||
| 1129 | case "update-branch": | ||
| 1130 | return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])]; | ||
| 1131 | case "ready": | ||
| 1132 | return ["ready"]; | ||
| 1133 | case "draft": | ||
| 1134 | return ["ready", "--undo"]; | ||
| 1135 | case "close": | ||
| 1136 | return ["close"]; | ||
| 1137 | case "reopen": | ||
| 1138 | return ["reopen"]; | ||
| 1139 | case "revert": | ||
| 1140 | throw new Error("Revert requires a GraphQL mutation"); | ||
| 1141 | // Handled separately because it may approve several workflow runs rather than mutate the | ||
| 1142 | // pull request itself. | ||
| 1143 | case "approve-workflows": | ||
| 1144 | throw new Error("Workflow approval requires run discovery"); | ||
| 1145 | } | 1059 | } |
| 1146 | } | 1060 | }`; |
| 1061 | |||
| 1062 | const decodeActionStateResponse = Schema.decodeUnknownResult( | ||
| 1063 | Schema.fromJsonString( | ||
| 1064 | Schema.Struct({ | ||
| 1065 | data: Schema.Struct({ | ||
| 1066 | repository: Schema.Struct({ | ||
| 1067 | pullRequest: Schema.Struct({ | ||
| 1068 | id: Schema.String, | ||
| 1069 | headRefOid: Schema.String, | ||
| 1070 | isMergeQueueEnabled: Schema.optional(Schema.Boolean), | ||
| 1071 | mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 1072 | baseRef: Schema.optional( | ||
| 1073 | Schema.NullOr( | ||
| 1074 | Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), | ||
| 1075 | ), | ||
| 1076 | ), | ||
| 1077 | }), | ||
| 1078 | }), | ||
| 1079 | }), | ||
| 1080 | }), | ||
| 1081 | ), | ||
| 1082 | ); | ||
| 1083 | const decodeActionState = (raw: string) => | ||
| 1084 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); | ||
| 1085 | |||
| 1086 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { | ||
| 1087 | mergePullRequest(input: $input) { clientMutationId } | ||
| 1088 | }`; | ||
| 1089 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { | ||
| 1090 | enablePullRequestAutoMerge(input: $input) { clientMutationId } | ||
| 1091 | }`; | ||
| 1092 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | ||
| 1093 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } | ||
| 1094 | }`; | ||
| 1095 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { | ||
| 1096 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } | ||
| 1097 | }`; | ||
| 1098 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | ||
| 1099 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } | ||
| 1100 | }`; | ||
| 1101 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | ||
| 1102 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } | ||
| 1103 | }`; | ||
| 1104 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | ||
| 1105 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | ||
| 1106 | }`; | ||
| 1107 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | ||
| 1108 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | ||
| 1109 | }`; | ||
| 1110 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { | ||
| 1111 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } | ||
| 1112 | }`; | ||
| 1113 | |||
| 1114 | const GRAPHQL_MERGE_METHODS = { | ||
| 1115 | merge: "MERGE", | ||
| 1116 | squash: "SQUASH", | ||
| 1117 | rebase: "REBASE", | ||
| 1118 | } as const satisfies Record<PullRequestMergeMethod, string>; | ||
| 1119 | |||
| 1120 | /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ | ||
| 1121 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); | ||
| 1122 | |||
| 1123 | /** The mutations that only need the pull request's node id. */ | ||
| 1124 | const SIMPLE_ACTION_MUTATIONS = { | ||
| 1125 | "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, | ||
| 1126 | ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, | ||
| 1127 | draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, | ||
| 1128 | close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, | ||
| 1129 | reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, | ||
| 1130 | } as const satisfies Partial<Record<PullRequestAction, string>>; | ||
| 1147 | 1131 | ||
| 1148 | /** @public Service construction is part of the canonical Effect module API. */ | 1132 | /** @public Service construction is part of the canonical Effect module API. */ |
| 1149 | export const make = Effect.gen(function* () { | 1133 | export const make = Effect.gen(function* () { |
| 1150 | const github = yield* GitHubCli.GitHubCli; | 1134 | const api = yield* GitHubApi.GitHubApi; |
| 1151 | const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | ||
| 1152 | const crypto = yield* Crypto.Crypto; | ||
| 1153 | const revalidateChecks = yield* makeChecksRevalidator; | 1135 | const revalidateChecks = yield* makeChecksRevalidator; |
| 1154 | const routingIdentities = new Map< | 1136 | const routingIdentities = new Map< |
| 1155 | string, | 1137 | string, |
| ... | @@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () { | ... | @@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () { |
| 1172 | const unavailable = () => | 1154 | const unavailable = () => |
| 1173 | new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); | 1155 | new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); |
| 1174 | const host = input.host.toLowerCase(); | 1156 | const host = input.host.toLowerCase(); |
| 1175 | const pinned = yield* GitHubCli.PinnedGitHubCredential; | 1157 | // A missing or signed-out credential keeps its own error, so the page can say which. |
| 1176 | if (pinned !== null && pinned.host !== host) return yield* unavailable(); | 1158 | const { token, fingerprint: key } = yield* api.credential(host); |
| 1177 | // Only the digest is retained. Never attach credential lookup output to an error. | 1159 | const credential = { host, token, credentialFingerprint: key }; |
| 1178 | const token = | ||
| 1179 | pinned !== null | ||
| 1180 | ? Redacted.value(pinned.token) | ||
| 1181 | : (yield* github | ||
| 1182 | .execute({ | ||
| 1183 | cwd: input.cwd, | ||
| 1184 | args: ["auth", "token", "--hostname", host], | ||
| 1185 | env: { GH_DEBUG: "" }, | ||
| 1186 | }) | ||
| 1187 | .pipe(Effect.mapError(unavailable))).stdout.trim(); | ||
| 1188 | if (!token) return yield* unavailable(); | ||
| 1189 | const tokenHash = yield* crypto | ||
| 1190 | .digest("SHA-256", new TextEncoder().encode(token)) | ||
| 1191 | .pipe(Effect.map(Hex.encode), Effect.orDie); | ||
| 1192 | const key = `${host}:${tokenHash}`; | ||
| 1193 | const credential = { host, token: Redacted.make(token), credentialFingerprint: key }; | ||
| 1194 | // A cold page may ask several times. Wait per credential and check again after the | 1160 | // A cold page may ask several times. Wait per credential and check again after the |
| 1195 | // first verification; cancellation releases the next waiter without losing its request. | 1161 | // first verification; cancellation releases the next waiter without losing its request. |
| 1196 | return yield* Effect.acquireUseRelease( | 1162 | return yield* Effect.acquireUseRelease( |
| ... | @@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () { | ... | @@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () { |
| 1208 | if (cached !== undefined && now - cached.at < 10 * 60_000) | 1174 | if (cached !== undefined && now - cached.at < 10 * 60_000) |
| 1209 | return { ...credential, ...cached.value }; | 1175 | return { ...credential, ...cached.value }; |
| 1210 | // Pin this read so an auth switch cannot poison its cache entry. | 1176 | // Pin this read so an auth switch cannot poison its cache entry. |
| 1211 | const response = yield* github | 1177 | const response = yield* api |
| 1212 | .execute({ | 1178 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) |
| 1213 | cwd: input.cwd, | 1179 | .pipe( |
| 1214 | args: ["api", "user", "--hostname", host], | 1180 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), |
| 1215 | env: { | 1181 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), |
| 1216 | GH_HOST: host, | 1182 | ); |
| 1217 | GH_TOKEN: token, | 1183 | const identity = yield* decodeRoutingIdentity(response.body).pipe( |
| 1218 | GITHUB_TOKEN: token, | ||
| 1219 | GH_ENTERPRISE_TOKEN: token, | ||
| 1220 | GITHUB_ENTERPRISE_TOKEN: token, | ||
| 1221 | GH_DEBUG: "", | ||
| 1222 | }, | ||
| 1223 | }) | ||
| 1224 | .pipe(Effect.mapError(unavailable)); | ||
| 1225 | const identity = yield* decodeRoutingIdentity(response.stdout).pipe( | ||
| 1226 | Effect.mapError(unavailable), | 1184 | Effect.mapError(unavailable), |
| 1227 | ); | 1185 | ); |
| 1228 | const value = { accountId: String(identity.id), viewer: identity.login }; | 1186 | const value = { accountId: String(identity.id), viewer: identity.login }; |
| ... | @@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () { |
| 1248 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => | 1206 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => |
| 1249 | use({ accountId, viewer, credentialFingerprint }).pipe( | 1207 | use({ accountId, viewer, credentialFingerprint }).pipe( |
| 1250 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), | 1208 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), |
| 1251 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | 1209 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { |
| 1252 | host, | 1210 | host, |
| 1253 | token, | 1211 | token, |
| 1254 | credentialFingerprint, | 1212 | credentialFingerprint, |
| ... | @@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () { |
| 1293 | host: input.host, | 1251 | host: input.host, |
| 1294 | operation: input.operation, | 1252 | operation: input.operation, |
| 1295 | allowReserve: true, | 1253 | allowReserve: true, |
| 1296 | variables: [ | 1254 | variables: { owner, name, number: input.number }, |
| 1297 | ["-f", `owner=${owner}`], | ||
| 1298 | ["-f", `name=${name}`], | ||
| 1299 | ["-F", `number=${input.number}`], | ||
| 1300 | ], | ||
| 1301 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | 1255 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 1302 | decode: decodePullRequestNodeIdJson, | 1256 | decode: decodePullRequestNodeIdJson, |
| 1303 | }).pipe( | 1257 | }).pipe( |
| ... | @@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () { | ... | @@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () { |
| 1333 | host: input.host, | 1287 | host: input.host, |
| 1334 | operation: input.operation, | 1288 | operation: input.operation, |
| 1335 | allowReserve: true, | 1289 | allowReserve: true, |
| 1336 | variables: [ | 1290 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, |
| 1337 | ["-f", `owner=${owner}`], | ||
| 1338 | ["-f", `name=${name}`], | ||
| 1339 | ["-F", `number=${input.number}`], | ||
| 1340 | ["-f", `subjectId=${input.subjectId}`], | ||
| 1341 | ], | ||
| 1342 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | 1291 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| 1343 | decode: decodeReactionSubjectScopeJson, | 1292 | decode: decodeReactionSubjectScopeJson, |
| 1344 | }); | 1293 | }); |
| 1345 | }; | 1294 | }; |
| 1346 | 1295 | ||
| 1347 | // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is | 1296 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ |
| 1348 | // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own | ||
| 1349 | // install rather than to a same-named repository on github.com. | ||
| 1350 | const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [ | ||
| 1351 | "--repo", | ||
| 1352 | `${input.host}/${input.repository}`, | ||
| 1353 | ]; | ||
| 1354 | |||
| 1355 | /** | ||
| 1356 | * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error, | ||
| 1357 | * so a failed mutation is already a failed command rather than a body to inspect. | ||
| 1358 | * | ||
| 1359 | * The query and its variables travel over stdin as one document: a variable can carry a | ||
| 1360 | * body the reader wrote, and argv is visible in process listings and echoed back inside | ||
| 1361 | * process-runner failure messages. | ||
| 1362 | */ | ||
| 1363 | const graphql = (input: { | 1297 | const graphql = (input: { |
| 1364 | readonly cwd: string; | ||
| 1365 | readonly host: string; | 1298 | readonly host: string; |
| 1299 | readonly operation: string; | ||
| 1366 | readonly query: string; | 1300 | readonly query: string; |
| 1367 | readonly variables: Readonly<Record<string, string>>; | 1301 | readonly variables: Readonly<Record<string, unknown>>; |
| 1368 | }) => | 1302 | }) => api.graphql(input).pipe(Effect.asVoid); |
| 1369 | github | 1303 | |
| 1370 | .execute({ | 1304 | const readError = (cwd: string, operation: string, cause: unknown) => |
| 1371 | cwd: input.cwd, | 1305 | new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); |
| 1372 | args: ["api", "graphql", "--hostname", input.host, "--input", "-"], | 1306 | |
| 1373 | stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), | 1307 | /** Decodes an answer, reporting a failure against the read that made it. */ |
| 1374 | }) | 1308 | const decodeWith = <A>( |
| 1375 | .pipe(Effect.asVoid); | 1309 | cwd: string, |
| 1310 | operation: string, | ||
| 1311 | decode: (raw: string) => Result.Result<A, unknown>, | ||
| 1312 | raw: string, | ||
| 1313 | ): Effect.Effect<A, GitHubPullRequestReadError> => { | ||
| 1314 | const decoded = decode(raw.trim()); | ||
| 1315 | return Result.isSuccess(decoded) | ||
| 1316 | ? Effect.succeed(decoded.success) | ||
| 1317 | : Effect.fail(readError(cwd, operation, decoded.failure)); | ||
| 1318 | }; | ||
| 1376 | 1319 | ||
| 1377 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ | 1320 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ |
| 1378 | const graphqlRead = <A>(input: { | 1321 | const graphqlRead = <A>(input: { |
| ... | @@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () { | ... | @@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () { |
| 1380 | readonly host: string; | 1323 | readonly host: string; |
| 1381 | readonly operation: string; | 1324 | readonly operation: string; |
| 1382 | readonly allowReserve?: boolean | undefined; | 1325 | readonly allowReserve?: boolean | undefined; |
| 1383 | /** Variables as `-f` flags, for values this module composed itself. */ | 1326 | readonly variables?: Readonly<Record<string, unknown>>; |
| 1384 | readonly variables?: ReadonlyArray<readonly [string, string]>; | ||
| 1385 | /** | ||
| 1386 | * Variables carrying words the reader typed. Document and variables travel over stdin | ||
| 1387 | * together, because argv is visible in process listings and is echoed back inside a | ||
| 1388 | * process-runner failure message. | ||
| 1389 | */ | ||
| 1390 | readonly privateVariables?: Readonly<Record<string, string>>; | ||
| 1391 | readonly query: string; | 1327 | readonly query: string; |
| 1392 | readonly decode: (raw: string) => Result.Result<A, unknown>; | 1328 | readonly decode: (raw: string) => Result.Result<A, unknown>; |
| 1393 | }): Effect.Effect<A, GitHubPullRequestCliError> => { | 1329 | }): Effect.Effect<A, GitHubPullRequestCliError> => |
| 1394 | return graphQlBudget | 1330 | api |
| 1395 | .query( | 1331 | .graphql({ |
| 1396 | input.host, | 1332 | host: input.host, |
| 1397 | input.query, | 1333 | operation: input.operation, |
| 1398 | input.allowReserve === true ? { allowReserve: true } : undefined, | 1334 | query: input.query, |
| 1399 | ) | 1335 | ...(input.variables === undefined ? {} : { variables: input.variables }), |
| 1336 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | ||
| 1337 | }) | ||
| 1338 | .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); | ||
| 1339 | |||
| 1340 | /** A REST read whose JSON answer is decoded the same way. */ | ||
| 1341 | const restRead = <A>(input: { | ||
| 1342 | readonly cwd: string; | ||
| 1343 | readonly host: string; | ||
| 1344 | readonly operation: string; | ||
| 1345 | readonly path: string; | ||
| 1346 | readonly decode: (raw: string) => Result.Result<A, unknown>; | ||
| 1347 | }): Effect.Effect<A, GitHubPullRequestCliError> => | ||
| 1348 | api | ||
| 1349 | .rest({ host: input.host, operation: input.operation, path: input.path }) | ||
| 1400 | .pipe( | 1350 | .pipe( |
| 1401 | Effect.flatMap((query) => | 1351 | Effect.flatMap((response) => |
| 1402 | github.execute( | 1352 | decodeWith(input.cwd, input.operation, input.decode, response.body), |
| 1403 | input.privateVariables === undefined | ||
| 1404 | ? { | ||
| 1405 | cwd: input.cwd, | ||
| 1406 | args: [ | ||
| 1407 | "api", | ||
| 1408 | "graphql", | ||
| 1409 | "--hostname", | ||
| 1410 | input.host, | ||
| 1411 | ...(input.variables ?? []).flat(), | ||
| 1412 | "-f", | ||
| 1413 | `query=${query}`, | ||
| 1414 | ], | ||
| 1415 | } | ||
| 1416 | : { | ||
| 1417 | cwd: input.cwd, | ||
| 1418 | args: ["api", "graphql", "--hostname", input.host, "--input", "-"], | ||
| 1419 | stdin: encodeGraphQlRequestJson({ | ||
| 1420 | query, | ||
| 1421 | variables: input.privateVariables, | ||
| 1422 | }), | ||
| 1423 | }, | ||
| 1424 | ), | ||
| 1425 | ), | 1353 | ), |
| 1426 | Effect.tap((result) => graphQlBudget.observe(input.host, result.stdout)), | ||
| 1427 | Effect.flatMap((result) => { | ||
| 1428 | const decoded = input.decode(result.stdout.trim()); | ||
| 1429 | return Result.isSuccess(decoded) | ||
| 1430 | ? Effect.succeed(decoded.success) | ||
| 1431 | : Effect.fail( | ||
| 1432 | new GitHubPullRequestReadError({ | ||
| 1433 | command: "gh", | ||
| 1434 | cwd: input.cwd, | ||
| 1435 | operation: input.operation, | ||
| 1436 | cause: decoded.failure, | ||
| 1437 | }), | ||
| 1438 | ); | ||
| 1439 | }), | ||
| 1440 | ); | 1354 | ); |
| 1441 | }; | ||
| 1442 | 1355 | ||
| 1443 | /** | 1356 | /** |
| 1444 | * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300 | 1357 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed |
| 1445 | * changed files, and still serves those files' hunks here. | 1358 | * files, and still serves those files' hunks here. |
| 1446 | * | 1359 | * |
| 1447 | * A page is a whole number of files, so each one parses on its own; the caller carries on from | 1360 | * A page is a whole number of files, so each one parses on its own; the caller carries on from |
| 1448 | * `nextCursor` for as long as GitHub keeps handing pages back. | 1361 | * `nextCursor` for as long as GitHub keeps handing pages back. |
| 1449 | * | 1362 | * |
| 1450 | * A named commit is read from the commit endpoint, which lists the same file entries and pages | 1363 | * A named commit is read from the commit endpoint, which lists the same file entries and pages |
| 1451 | * them the same way — only wrapped in an object, which jq unwraps before they are decoded. | 1364 | * them the same way, only wrapped in an object. |
| 1452 | */ | 1365 | */ |
| 1453 | const diffFilesPage = (input: { | 1366 | const diffFilesPage = (input: { |
| 1454 | readonly cwd: string; | 1367 | readonly cwd: string; |
| ... | @@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () { | ... | @@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () { |
| 1460 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => { | 1373 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => { |
| 1461 | const { owner, name } = parseRepositorySelector(input.repository); | 1374 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1462 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; | 1375 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; |
| 1463 | return github | 1376 | return api |
| 1464 | .execute({ | 1377 | .rest({ |
| 1465 | cwd: input.cwd, | 1378 | host: input.host, |
| 1466 | args: [ | 1379 | operation: "getPullRequestDiff", |
| 1467 | "api", | 1380 | path: |
| 1468 | "--hostname", | ||
| 1469 | input.host, | ||
| 1470 | input.commit === undefined | 1381 | input.commit === undefined |
| 1471 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` | 1382 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` |
| 1472 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, | 1383 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, |
| 1473 | // An empty commit carries no `files` at all, which is a commit with nothing in it | 1384 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, |
| 1474 | // rather than an answer that could not be read. | 1385 | timeout: DIFF_TIMEOUT, |
| 1475 | ...(input.commit === undefined ? [] : ["--jq", ".files // []"]), | ||
| 1476 | ], | ||
| 1477 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | ||
| 1478 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 1479 | }) | 1386 | }) |
| 1480 | .pipe( | 1387 | .pipe( |
| 1481 | Effect.flatMap((result) => { | 1388 | Effect.flatMap((response) => { |
| 1482 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would | 1389 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would |
| 1483 | // fail to parse. Nothing of this page can be shown, and an empty patch would render | 1390 | // fail to parse. Nothing of this page can be shown, and an empty patch would render |
| 1484 | // as a change with no files rather than as the failure it is; slices already handed | 1391 | // as a change with no files rather than as the failure it is; slices already handed |
| 1485 | // over stay with the reader either way. | 1392 | // over stay with the reader either way. |
| 1486 | if (result.stdoutTruncated) { | 1393 | if (response.truncated) { |
| 1487 | return Effect.fail( | 1394 | return Effect.fail( |
| 1488 | new GitHubPullRequestReadError({ | 1395 | readError( |
| 1489 | command: "gh", | 1396 | input.cwd, |
| 1490 | cwd: input.cwd, | 1397 | "getPullRequestDiff", |
| 1491 | operation: "getPullRequestDiff", | 1398 | new Error(`Page ${input.page} of the changed files was too large to read.`), |
| 1492 | cause: new Error(`Page ${input.page} of the changed files was too large to read.`), | 1399 | ), |
| 1493 | }), | ||
| 1494 | ); | ||
| 1495 | } | ||
| 1496 | const decoded = decodePullRequestFilesJson(result.stdout.trim()); | ||
| 1497 | if (!Result.isSuccess(decoded)) { | ||
| 1498 | return Effect.fail( | ||
| 1499 | new GitHubPullRequestReadError({ | ||
| 1500 | command: "gh", | ||
| 1501 | cwd: input.cwd, | ||
| 1502 | operation: "getPullRequestDiff", | ||
| 1503 | cause: decoded.failure, | ||
| 1504 | }), | ||
| 1505 | ); | 1400 | ); |
| 1506 | } | 1401 | } |
| 1402 | return decodeWith( | ||
| 1403 | input.cwd, | ||
| 1404 | "getPullRequestDiff", | ||
| 1405 | input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, | ||
| 1406 | response.body, | ||
| 1407 | ); | ||
| 1408 | }), | ||
| 1409 | Effect.map((files) => { | ||
| 1507 | // Counted before decoding, so a page whose files all failed to decode still moves on | 1410 | // Counted before decoding, so a page whose files all failed to decode still moves on |
| 1508 | // rather than pointing the reader back at the page it just read. | 1411 | // rather than pointing the reader back at the page it just read. |
| 1509 | const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE; | 1412 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; |
| 1510 | return Effect.succeed({ | 1413 | return { |
| 1511 | patch: decoded.success.patch, | 1414 | patch: files.patch, |
| 1512 | truncated: decoded.success.truncated, | 1415 | truncated: files.truncated, |
| 1513 | nextCursor: morePages ? String(input.page + 1) : null, | 1416 | nextCursor: morePages ? String(input.page + 1) : null, |
| 1514 | ...(decoded.success.omittedFileStats.length === 0 | 1417 | ...(files.omittedFileStats.length === 0 |
| 1515 | ? {} | 1418 | ? {} |
| 1516 | : { omittedFileStats: decoded.success.omittedFileStats }), | 1419 | : { omittedFileStats: files.omittedFileStats }), |
| 1517 | }); | 1420 | }; |
| 1518 | }), | 1421 | }), |
| 1519 | ); | 1422 | ); |
| 1520 | }; | 1423 | }; |
| ... | @@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () { | ... | @@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () { |
| 1526 | return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); | 1429 | return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); |
| 1527 | } | 1430 | } |
| 1528 | const { owner, name } = parseRepositorySelector(input.repository); | 1431 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1529 | const refsResult = yield* github.execute({ | 1432 | const refsResponse = yield* api.rest({ |
| 1530 | cwd: input.cwd, | 1433 | host: input.host, |
| 1531 | args: [ | 1434 | operation: "getPullRequestDiffFileContents", |
| 1532 | "api", | 1435 | path: |
| 1533 | "--hostname", | ||
| 1534 | input.host, | ||
| 1535 | input.commit === undefined | 1436 | input.commit === undefined |
| 1536 | ? `repos/${owner}/${name}/pulls/${input.number}` | 1437 | ? `repos/${owner}/${name}/pulls/${input.number}` |
| 1537 | : `repos/${owner}/${name}/commits/${input.commit}`, | 1438 | : `repos/${owner}/${name}/commits/${input.commit}`, |
| 1538 | "--jq", | 1439 | timeout: DIFF_TIMEOUT, |
| 1539 | input.commit === undefined | ||
| 1540 | ? "[.base.sha, .head.sha] | @tsv" | ||
| 1541 | : "[.parents[0].sha, .sha] | @tsv", | ||
| 1542 | ], | ||
| 1543 | maxOutputBytes: 1024, | ||
| 1544 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 1545 | }); | 1440 | }); |
| 1546 | // Keep a leading tab: a root commit has no parent, and jq represents that absent old | 1441 | const refs = decodeRevisionRefs(refsResponse.body); |
| 1547 | // revision as the empty field before the tab. Every file in it is new, so that is a | 1442 | // A root commit has no parent, which is an absent old revision. Every file in it is new, |
| 1548 | // usable answer whenever the caller does not need the old side. | 1443 | // so that is a usable answer whenever the caller does not need the old side. |
| 1549 | const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t"); | 1444 | const baseRef = Option.isSome(refs) |
| 1445 | ? input.commit === undefined | ||
| 1446 | ? refs.value.base?.sha | ||
| 1447 | : (refs.value.parents?.[0]?.sha ?? "") | ||
| 1448 | : undefined; | ||
| 1449 | const headRef = Option.isSome(refs) | ||
| 1450 | ? input.commit === undefined | ||
| 1451 | ? refs.value.head?.sha | ||
| 1452 | : refs.value.sha | ||
| 1453 | : undefined; | ||
| 1550 | const rootCommitNewFile = | 1454 | const rootCommitNewFile = |
| 1551 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; | 1455 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; |
| 1552 | if ( | 1456 | if ( |
| 1553 | refsResult.stdoutTruncated || | 1457 | headRef === undefined || |
| 1554 | !headRef || | ||
| 1555 | extraRefs.length > 0 || | ||
| 1556 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || | 1458 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || |
| 1557 | !isCommitSha(headRef) | 1459 | !isCommitSha(headRef) |
| 1558 | ) { | 1460 | ) { |
| ... | @@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () { | ... | @@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () { |
| 1565 | } | 1467 | } |
| 1566 | 1468 | ||
| 1567 | const readFile = (revision: string, filePath: string) => | 1469 | const readFile = (revision: string, filePath: string) => |
| 1568 | github | 1470 | api |
| 1569 | .execute({ | 1471 | .rest({ |
| 1570 | cwd: input.cwd, | 1472 | host: input.host, |
| 1571 | args: [ | 1473 | operation: "getPullRequestDiffFileContents", |
| 1572 | "api", | 1474 | accept: "application/vnd.github.raw+json", |
| 1573 | "--hostname", | 1475 | path: `repos/${owner}/${name}/contents/${filePath |
| 1574 | input.host, | 1476 | .split("/") |
| 1575 | "--header", | 1477 | .map(encodeURIComponent) |
| 1576 | "Accept: application/vnd.github.raw+json", | 1478 | .join("/")}?ref=${encodeURIComponent(revision)}`, |
| 1577 | `repos/${owner}/${name}/contents/${filePath | 1479 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, |
| 1578 | .split("/") | 1480 | timeout: DIFF_TIMEOUT, |
| 1579 | .map(encodeURIComponent) | ||
| 1580 | .join("/")}?ref=${encodeURIComponent(revision)}`, | ||
| 1581 | ], | ||
| 1582 | maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | ||
| 1583 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 1584 | }) | 1481 | }) |
| 1585 | .pipe( | 1482 | .pipe( |
| 1586 | Effect.flatMap((result) => | 1483 | Effect.flatMap((response) => |
| 1587 | result.stdoutTruncated || | 1484 | response.truncated || response.body.includes("\0") || response.invalidUtf8 |
| 1588 | result.stdout.includes("\0") || | ||
| 1589 | result.stdoutInvalidUtf8 === true | ||
| 1590 | ? Effect.fail( | 1485 | ? Effect.fail( |
| 1591 | new GitHubDiffFileContentsUnavailableError({ | 1486 | new GitHubDiffFileContentsUnavailableError({ |
| 1592 | command: "gh", | 1487 | command: "gh", |
| 1593 | cwd: input.cwd, | 1488 | cwd: input.cwd, |
| 1594 | path: filePath, | 1489 | path: filePath, |
| 1595 | reason: result.stdoutTruncated ? "oversized" : "binary", | 1490 | reason: response.truncated ? "oversized" : "binary", |
| 1596 | }), | 1491 | }), |
| 1597 | ) | 1492 | ) |
| 1598 | : Effect.succeed(result.stdout), | 1493 | : Effect.succeed(response.body), |
| 1599 | ), | 1494 | ), |
| 1600 | ); | 1495 | ); |
| 1601 | 1496 | ||
| 1602 | const [oldContents, newContents] = yield* Effect.all( | 1497 | const [oldContents, newContents] = yield* Effect.all( |
| 1603 | [ | 1498 | [ |
| 1604 | input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath), | 1499 | input.changeType === "new" |
| 1500 | ? Effect.succeed("") | ||
| 1501 | : readFile(baseRef ?? "", input.oldPath), | ||
| 1605 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), | 1502 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), |
| 1606 | ], | 1503 | ], |
| 1607 | { concurrency: 2 }, | 1504 | { concurrency: 2 }, |
| ... | @@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () { | ... | @@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () { |
| 1609 | return { oldContents, newContents }; | 1506 | return { oldContents, newContents }; |
| 1610 | }); | 1507 | }); |
| 1611 | 1508 | ||
| 1612 | const readLegacyDetail = ( | 1509 | /** |
| 1510 | * Every check context of the head commit, a page at a time, for a rollup the detail read cut at | ||
| 1511 | * its first hundred. Each page names the head it read, so a push mid-walk fails rather than | ||
| 1512 | * mixing two revisions' checks. | ||
| 1513 | */ | ||
| 1514 | const readAllCheckContexts = ( | ||
| 1613 | input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0], | 1515 | input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0], |
| 1516 | allowReserve: boolean, | ||
| 1614 | ) => | 1517 | ) => |
| 1615 | github | 1518 | Effect.gen(function* () { |
| 1616 | .execute({ | 1519 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1617 | cwd: input.cwd, | 1520 | const contexts: GitHubCheckContext[] = []; |
| 1618 | args: [ | 1521 | let headSha: string | null = null; |
| 1619 | "pr", | 1522 | let after: string | null = null; |
| 1620 | "view", | 1523 | for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { |
| 1621 | String(input.number), | 1524 | const read: { |
| 1622 | ...repositoryArgs(input), | 1525 | readonly headSha: string; |
| 1623 | "--json", | 1526 | readonly contexts: ReadonlyArray<GitHubCheckContext>; |
| 1624 | PULL_REQUEST_DETAIL_JSON_FIELDS, | 1527 | readonly nextCursor: string | null; |
| 1625 | ], | 1528 | } = yield* graphqlRead({ |
| 1626 | }) | 1529 | cwd: input.cwd, |
| 1627 | .pipe( | 1530 | host: input.host, |
| 1628 | Effect.flatMap((result) => { | 1531 | operation: "getPullRequestDetail", |
| 1629 | const decoded = decodePullRequestDetailJson(result.stdout.trim()); | 1532 | allowReserve, |
| 1630 | return Result.isSuccess(decoded) | 1533 | variables: { owner, name, number: input.number, after }, |
| 1631 | ? Effect.succeed(decoded.success) | 1534 | query: pullRequestCheckContextsGraphQlQuery(input.host), |
| 1632 | : Effect.fail( | 1535 | decode: decodePullRequestCheckContextsJson, |
| 1633 | new GitHubPullRequestReadError({ | 1536 | }); |
| 1634 | command: "gh", | 1537 | if (headSha !== null && read.headSha !== headSha) { |
| 1635 | cwd: input.cwd, | 1538 | return yield* readError( |
| 1636 | operation: "getPullRequestDetail", | 1539 | input.cwd, |
| 1637 | cause: decoded.failure, | 1540 | "getPullRequestDetail", |
| 1638 | }), | 1541 | new Error("Pull request head changed while reading checks."), |
| 1639 | ); | 1542 | ); |
| 1640 | }), | 1543 | } |
| 1641 | ); | 1544 | headSha = read.headSha; |
| 1545 | contexts.push(...read.contexts); | ||
| 1546 | after = read.nextCursor; | ||
| 1547 | if (after === null) break; | ||
| 1548 | } | ||
| 1549 | return { headSha, contexts, truncated: after !== null }; | ||
| 1550 | }); | ||
| 1642 | 1551 | ||
| 1643 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { | 1552 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { |
| 1644 | const { owner, name } = parseRepositorySelector(input.repository); | 1553 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1645 | return GitHubCli.AllowGitHubReserve.pipe( | 1554 | return AllowGitHubReserve.pipe( |
| 1646 | Effect.flatMap((allowReserve) => | 1555 | Effect.flatMap((allowReserve) => |
| 1647 | graphqlRead({ | 1556 | graphqlRead({ |
| 1648 | allowReserve, | 1557 | allowReserve, |
| 1649 | cwd: input.cwd, | 1558 | cwd: input.cwd, |
| 1650 | host: input.host, | 1559 | host: input.host, |
| 1651 | operation: "getPullRequestDetail", | 1560 | operation: "getPullRequestDetail", |
| 1652 | variables: [ | 1561 | variables: { |
| 1653 | ["-f", `owner=${owner}`], | 1562 | owner, |
| 1654 | ["-f", `name=${name}`], | 1563 | name, |
| 1655 | ["-F", `number=${input.number}`], | 1564 | number: input.number, |
| 1656 | ["-f", `headRef=refs/pull/${input.number}/head`], | 1565 | headRef: `refs/pull/${input.number}/head`, |
| 1657 | ], | 1566 | }, |
| 1658 | query: pullRequestCoreGraphQlQuery(input.host), | 1567 | query: pullRequestCoreGraphQlQuery(input.host), |
| 1659 | decode: decodePullRequestCoreJson, | 1568 | decode: decodePullRequestCoreJson, |
| 1660 | }), | 1569 | }).pipe( |
| 1661 | ), | 1570 | // Past a hundred checks the first page would let the rest imply success, so the whole |
| 1662 | // gh already pages check contexts. Keep its complete, deduplicated result for | 1571 | // rollup is walked instead. |
| 1663 | // large check suites instead of letting the first 100 checks imply success. | 1572 | Effect.filterOrElse( |
| 1664 | Effect.filterOrElse( | 1573 | (core) => !core.checksTruncated, |
| 1665 | (core) => !core.checksTruncated, | 1574 | (core) => |
| 1666 | (core) => | 1575 | readAllCheckContexts(input, allowReserve).pipe( |
| 1667 | readLegacyDetail(input).pipe( | 1576 | Effect.filterOrFail( |
| 1668 | Effect.filterOrFail( | 1577 | (all) => all.headSha === core.headSha, |
| 1669 | (detail) => detail.headSha === core.headSha, | 1578 | () => |
| 1670 | () => | 1579 | readError( |
| 1671 | new GitHubPullRequestReadError({ | 1580 | input.cwd, |
| 1672 | command: "gh", | 1581 | "getPullRequestDetail", |
| 1673 | cwd: input.cwd, | 1582 | new Error("Pull request head changed while reading checks."), |
| 1674 | operation: "getPullRequestDetail", | 1583 | ), |
| 1675 | cause: new Error("Pull request head changed while reading checks."), | 1584 | ), |
| 1676 | }), | 1585 | Effect.map((all) => ({ |
| 1677 | ), | 1586 | ...core, |
| 1678 | Effect.map((detail) => ({ | 1587 | ...pullRequestChecksFromContexts(all.contexts), |
| 1679 | ...core, | 1588 | checksTruncated: all.truncated, |
| 1680 | checks: detail.checks, | 1589 | })), |
| 1681 | checksState: detail.checksState, | 1590 | ), |
| 1682 | checksTruncated: false, | ||
| 1683 | })), | ||
| 1684 | ), | 1591 | ), |
| 1592 | ), | ||
| 1685 | ), | 1593 | ), |
| 1686 | ); | 1594 | ); |
| 1687 | }; | 1595 | }; |
| 1688 | 1596 | ||
| 1689 | const workflowApprovalLimit = 1_000; | 1597 | const workflowApprovalLimit = 1_000; |
| 1690 | const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); | ||
| 1691 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | 1598 | const workflowApprovalReadError = (cwd: string, cause: unknown) => |
| 1692 | new GitHubPullRequestReadError({ | 1599 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); |
| 1693 | command: "gh", | 1600 | |
| 1694 | cwd, | 1601 | /** Every open pull request whose head branch carries this name, up to one past the limit. */ |
| 1695 | operation: "listWorkflowRunsRequiringApproval", | 1602 | const listHeadsByBranch = (input: { |
| 1696 | cause, | 1603 | readonly cwd: string; |
| 1604 | readonly repository: string; | ||
| 1605 | readonly host: string; | ||
| 1606 | readonly headBranch: string; | ||
| 1607 | }) => | ||
| 1608 | Effect.gen(function* () { | ||
| 1609 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 1610 | const heads: GitHubPullRequestHead[] = []; | ||
| 1611 | let after: string | null = null; | ||
| 1612 | do { | ||
| 1613 | const page: { | ||
| 1614 | readonly heads: ReadonlyArray<GitHubPullRequestHead>; | ||
| 1615 | readonly nextCursor: string | null; | ||
| 1616 | } = yield* graphqlRead({ | ||
| 1617 | cwd: input.cwd, | ||
| 1618 | host: input.host, | ||
| 1619 | operation: "listWorkflowRunsRequiringApproval", | ||
| 1620 | allowReserve: true, | ||
| 1621 | variables: { owner, name, head: input.headBranch, after }, | ||
| 1622 | query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, | ||
| 1623 | decode: decodePullRequestHeadsJson, | ||
| 1624 | }); | ||
| 1625 | heads.push(...page.heads); | ||
| 1626 | after = page.nextCursor; | ||
| 1627 | } while (after !== null && heads.length <= workflowApprovalLimit); | ||
| 1628 | return heads; | ||
| 1629 | }); | ||
| 1630 | |||
| 1631 | /** | ||
| 1632 | * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is | ||
| 1633 | * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. | ||
| 1634 | */ | ||
| 1635 | const listActionRequiredRuns = (input: { | ||
| 1636 | readonly cwd: string; | ||
| 1637 | readonly repository: string; | ||
| 1638 | readonly host: string; | ||
| 1639 | readonly headSha: string; | ||
| 1640 | readonly headBranch: string; | ||
| 1641 | }) => | ||
| 1642 | Effect.gen(function* () { | ||
| 1643 | // The checks revalidator may have just confirmed every run of this head as current. | ||
| 1644 | const known = yield* KnownWorkflowRuns; | ||
| 1645 | if (known !== null && known.headSha === input.headSha) { | ||
| 1646 | return known.runs.flatMap((run) => | ||
| 1647 | // A run waiting on a maintainer reports `completed` with an `action_required` | ||
| 1648 | // conclusion; the `status=action_required` query matches either. | ||
| 1649 | (run.conclusion === "action_required" || run.status === "action_required") && | ||
| 1650 | run.head_branch === input.headBranch | ||
| 1651 | ? [ | ||
| 1652 | { | ||
| 1653 | id: run.id, | ||
| 1654 | name: run.name?.trim() || `Workflow run ${run.id}`, | ||
| 1655 | url: run.html_url?.trim() || null, | ||
| 1656 | }, | ||
| 1657 | ] | ||
| 1658 | : [], | ||
| 1659 | ); | ||
| 1660 | } | ||
| 1661 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 1662 | const runs: GitHubWorkflowRunApproval[] = []; | ||
| 1663 | for (let page = 1; runs.length <= workflowApprovalLimit; page++) { | ||
| 1664 | const read: GitHubWorkflowRunPage = yield* restRead({ | ||
| 1665 | cwd: input.cwd, | ||
| 1666 | host: input.host, | ||
| 1667 | operation: "listWorkflowRunsRequiringApproval", | ||
| 1668 | path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, | ||
| 1669 | decode: decodeWorkflowRunsJson, | ||
| 1670 | }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); | ||
| 1671 | runs.push(...read.runs); | ||
| 1672 | if (read.rawCount < 100) break; | ||
| 1673 | } | ||
| 1674 | return runs; | ||
| 1697 | }); | 1675 | }); |
| 1676 | |||
| 1698 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = | 1677 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = |
| 1699 | (input) => | 1678 | (input) => |
| 1700 | Effect.all( | 1679 | Effect.all( |
| 1701 | [ | 1680 | [ |
| 1702 | github | 1681 | listHeadsByBranch(input).pipe( |
| 1703 | .execute({ | 1682 | Effect.flatMap( |
| 1704 | cwd: input.cwd, | 1683 | ( |
| 1705 | args: [ | 1684 | heads, |
| 1706 | "pr", | 1685 | ): Effect.Effect< |
| 1707 | "list", | 1686 | GitHubPullRequestHead, |
| 1708 | ...repositoryArgs(input), | 1687 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError |
| 1709 | "--state", | 1688 | > => { |
| 1710 | "open", | 1689 | const exactHeads = heads.filter( |
| 1711 | "--head", | 1690 | (pullRequest) => |
| 1712 | input.headBranch, | 1691 | pullRequest.headSha === input.headSha && |
| 1713 | "--limit", | 1692 | pullRequest.isCrossRepository === true && |
| 1714 | workflowApprovalProbeLimit, | 1693 | pullRequest.headRepositoryOwner?.toLowerCase() === |
| 1715 | "--json", | 1694 | input.headRepositoryOwner.toLowerCase(), |
| 1716 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | 1695 | ); |
| 1717 | ], | 1696 | if (heads.length > workflowApprovalLimit) { |
| 1718 | }) | 1697 | return Effect.fail( |
| 1719 | .pipe( | 1698 | new GitHubWorkflowApprovalRefusedError({ |
| 1720 | Effect.flatMap( | 1699 | command: "gh", |
| 1721 | ( | 1700 | cwd: input.cwd, |
| 1722 | result, | 1701 | number: input.number, |
| 1723 | ): Effect.Effect< | 1702 | reason: "head-list-truncated", |
| 1724 | GitHubPullRequestHead, | 1703 | observedCount: heads.length, |
| 1725 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | 1704 | limit: workflowApprovalLimit, |
| 1726 | > => { | 1705 | }), |
| 1727 | const decoded = decodePullRequestHeadsJson(result.stdout.trim()); | ||
| 1728 | if (!Result.isSuccess(decoded)) { | ||
| 1729 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | ||
| 1730 | } | ||
| 1731 | const exactHeads = decoded.success.filter( | ||
| 1732 | (pullRequest) => | ||
| 1733 | pullRequest.headSha === input.headSha && | ||
| 1734 | pullRequest.isCrossRepository === true && | ||
| 1735 | pullRequest.headRepositoryOwner?.toLowerCase() === | ||
| 1736 | input.headRepositoryOwner.toLowerCase(), | ||
| 1737 | ); | 1706 | ); |
| 1738 | if (decoded.success.length > workflowApprovalLimit) { | 1707 | } |
| 1739 | return Effect.fail( | 1708 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { |
| 1740 | new GitHubWorkflowApprovalRefusedError({ | 1709 | return Effect.fail( |
| 1741 | command: "gh", | 1710 | new GitHubWorkflowApprovalRefusedError({ |
| 1742 | cwd: input.cwd, | 1711 | command: "gh", |
| 1743 | number: input.number, | 1712 | cwd: input.cwd, |
| 1744 | reason: "head-list-truncated", | 1713 | number: input.number, |
| 1745 | observedCount: decoded.success.length, | 1714 | reason: "head-not-unique", |
| 1746 | limit: workflowApprovalLimit, | 1715 | observedCount: exactHeads.length, |
| 1747 | }), | 1716 | limit: workflowApprovalLimit, |
| 1748 | ); | 1717 | }), |
| 1749 | } | 1718 | ); |
| 1750 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | 1719 | } |
| 1751 | return Effect.fail( | 1720 | return Effect.succeed(exactHeads[0]); |
| 1752 | new GitHubWorkflowApprovalRefusedError({ | 1721 | }, |
| 1753 | command: "gh", | ||
| 1754 | cwd: input.cwd, | ||
| 1755 | number: input.number, | ||
| 1756 | reason: "head-not-unique", | ||
| 1757 | observedCount: exactHeads.length, | ||
| 1758 | limit: workflowApprovalLimit, | ||
| 1759 | }), | ||
| 1760 | ); | ||
| 1761 | } | ||
| 1762 | return Effect.succeed(exactHeads[0]); | ||
| 1763 | }, | ||
| 1764 | ), | ||
| 1765 | ), | 1722 | ), |
| 1766 | github | 1723 | ), |
| 1767 | .execute({ | 1724 | listActionRequiredRuns(input).pipe( |
| 1768 | cwd: input.cwd, | 1725 | Effect.flatMap((runs) => |
| 1769 | args: [ | 1726 | runs.length > workflowApprovalLimit |
| 1770 | "run", | 1727 | ? Effect.fail( |
| 1771 | "list", | 1728 | new GitHubWorkflowApprovalRefusedError({ |
| 1772 | ...repositoryArgs(input), | 1729 | command: "gh", |
| 1773 | "--commit", | 1730 | cwd: input.cwd, |
| 1774 | input.headSha, | 1731 | number: input.number, |
| 1775 | "--branch", | 1732 | reason: "run-list-truncated", |
| 1776 | input.headBranch, | 1733 | observedCount: runs.length, |
| 1777 | "--event", | 1734 | limit: workflowApprovalLimit, |
| 1778 | "pull_request", | 1735 | }), |
| 1779 | "--status", | 1736 | ) |
| 1780 | "action_required", | 1737 | : Effect.succeed(runs), |
| 1781 | "--limit", | ||
| 1782 | workflowApprovalProbeLimit, | ||
| 1783 | "--json", | ||
| 1784 | "databaseId,workflowName,url", | ||
| 1785 | ], | ||
| 1786 | }) | ||
| 1787 | .pipe( | ||
| 1788 | Effect.flatMap( | ||
| 1789 | ( | ||
| 1790 | result, | ||
| 1791 | ): Effect.Effect< | ||
| 1792 | ReadonlyArray<GitHubWorkflowRunApproval>, | ||
| 1793 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | ||
| 1794 | > => { | ||
| 1795 | const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); | ||
| 1796 | if (!Result.isSuccess(decoded)) { | ||
| 1797 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | ||
| 1798 | } | ||
| 1799 | return decoded.success.length > workflowApprovalLimit | ||
| 1800 | ? Effect.fail( | ||
| 1801 | new GitHubWorkflowApprovalRefusedError({ | ||
| 1802 | command: "gh", | ||
| 1803 | cwd: input.cwd, | ||
| 1804 | number: input.number, | ||
| 1805 | reason: "run-list-truncated", | ||
| 1806 | observedCount: decoded.success.length, | ||
| 1807 | limit: workflowApprovalLimit, | ||
| 1808 | }), | ||
| 1809 | ) | ||
| 1810 | : Effect.succeed(decoded.success); | ||
| 1811 | }, | ||
| 1812 | ), | ||
| 1813 | ), | 1738 | ), |
| 1739 | ), | ||
| 1814 | ], | 1740 | ], |
| 1815 | { concurrency: 2 }, | 1741 | { concurrency: 2 }, |
| 1816 | ).pipe(Effect.map(([, runs]) => runs)); | 1742 | ).pipe(Effect.map(([, runs]) => runs)); |
| 1817 | 1743 | ||
| 1818 | // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands | 1744 | /** One pull request's summary, through the same aliased query the batch uses. */ |
| 1819 | // the thread overview its author, diff stat, review decision and checks in the same read. | 1745 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { |
| 1820 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => | 1746 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1821 | github | 1747 | return graphqlRead({ |
| 1822 | .execute({ | 1748 | cwd: input.cwd, |
| 1823 | cwd: input.cwd, | 1749 | host: input.host, |
| 1824 | args: [ | 1750 | operation: "getPullRequestSummary", |
| 1825 | "pr", | 1751 | variables: { owner, name, number: input.number }, |
| 1826 | "view", | 1752 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), |
| 1827 | String(input.number), | 1753 | decode: decodePullRequestSummariesJson, |
| 1828 | ...repositoryArgs(input), | 1754 | }).pipe( |
| 1829 | "--json", | 1755 | Effect.flatMap((summaries) => { |
| 1830 | PULL_REQUEST_DETAIL_JSON_FIELDS, | 1756 | const summary = summaries.get(0); |
| 1831 | ], | 1757 | return summary === undefined |
| 1832 | }) | 1758 | ? Effect.fail( |
| 1833 | .pipe( | 1759 | readError( |
| 1834 | Effect.flatMap((result) => { | 1760 | input.cwd, |
| 1835 | const decoded = decodePullRequestDetailJson(result.stdout.trim()); | 1761 | "getPullRequestSummary", |
| 1836 | if (!Result.isSuccess(decoded)) { | 1762 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), |
| 1837 | return Effect.fail( | 1763 | ), |
| 1838 | new GitHubPullRequestReadError({ | 1764 | ) |
| 1839 | command: "gh", | 1765 | : Effect.succeed(summary); |
| 1840 | cwd: input.cwd, | 1766 | }), |
| 1841 | operation: "getPullRequestSummary", | 1767 | ); |
| 1842 | cause: decoded.failure, | 1768 | }; |
| 1843 | }), | ||
| 1844 | ); | ||
| 1845 | } | ||
| 1846 | const detail = decoded.success; | ||
| 1847 | return Effect.succeed({ | ||
| 1848 | number: detail.number, | ||
| 1849 | title: detail.title, | ||
| 1850 | url: detail.url, | ||
| 1851 | headBranch: detail.headBranch, | ||
| 1852 | baseBranch: detail.baseBranch, | ||
| 1853 | state: detail.state, | ||
| 1854 | updatedAt: detail.updatedAt, | ||
| 1855 | closedAt: detail.closedAt ?? null, | ||
| 1856 | mergedAt: detail.mergedAt ?? null, | ||
| 1857 | isDraft: detail.isDraft, | ||
| 1858 | author: detail.author, | ||
| 1859 | additions: detail.additions, | ||
| 1860 | deletions: detail.deletions, | ||
| 1861 | changedFiles: detail.changedFiles, | ||
| 1862 | reviewDecision: detail.reviewDecision, | ||
| 1863 | checksState: detail.checksState, | ||
| 1864 | mergeability: detail.mergeability, | ||
| 1865 | }); | ||
| 1866 | }), | ||
| 1867 | ); | ||
| 1868 | 1769 | ||
| 1869 | /** | 1770 | /** |
| 1870 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads | 1771 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads |
| ... | @@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () { |
| 1876 | key: ({ request, context }) => | 1777 | key: ({ request, context }) => |
| 1877 | JSON.stringify([ | 1778 | JSON.stringify([ |
| 1878 | request.host.toLowerCase(), | 1779 | request.host.toLowerCase(), |
| 1879 | Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) | 1780 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) |
| 1880 | ?.credentialFingerprint ?? null, | 1781 | ?.credentialFingerprint ?? null, |
| 1881 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | 1782 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 1882 | ]), | 1783 | ]), |
| ... | @@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () { |
| 1956 | key: ({ request, context }) => | 1857 | key: ({ request, context }) => |
| 1957 | JSON.stringify([ | 1858 | JSON.stringify([ |
| 1958 | request.host.toLowerCase(), | 1859 | request.host.toLowerCase(), |
| 1959 | Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) | 1860 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) |
| 1960 | ?.credentialFingerprint ?? null, | 1861 | ?.credentialFingerprint ?? null, |
| 1961 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | 1862 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 1962 | ]), | 1863 | ]), |
| ... | @@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () { | ... | @@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () { |
| 2010 | 1911 | ||
| 2011 | listPullRequests: (input) => { | 1912 | listPullRequests: (input) => { |
| 2012 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); | 1913 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); |
| 1914 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 1915 | const query = searchQuery({ ...input, repositories: [input.repository] }); | ||
| 1916 | if (query === null) { | ||
| 1917 | return Effect.fail( | ||
| 1918 | new GitHubRepositorySelectorError({ | ||
| 1919 | command: "gh", | ||
| 1920 | cwd: input.cwd, | ||
| 1921 | operation: "listPullRequests", | ||
| 1922 | }), | ||
| 1923 | ); | ||
| 1924 | } | ||
| 1925 | /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ | ||
| 1926 | const collect = ( | ||
| 1927 | rows: number, | ||
| 1928 | page: ( | ||
| 1929 | after: string | null, | ||
| 1930 | rows: number, | ||
| 1931 | ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestCliError>, | ||
| 1932 | ) => | ||
| 1933 | Effect.gen(function* () { | ||
| 1934 | const items: GitHubPullRequestListItem[] = []; | ||
| 1935 | let rawCount = 0; | ||
| 1936 | let after: string | null = null; | ||
| 1937 | do { | ||
| 1938 | const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); | ||
| 1939 | items.push(...read.items); | ||
| 1940 | rawCount += read.rawCount; | ||
| 1941 | after = read.endCursor; | ||
| 1942 | } while (after !== null && rawCount < rows); | ||
| 1943 | return { items, rawCount }; | ||
| 1944 | }); | ||
| 2013 | const read = ( | 1945 | const read = ( |
| 2014 | continues: boolean, | 1946 | continues: boolean, |
| 2015 | requestedRows = input.limit + 1, | 1947 | requestedRows = input.limit + 1, |
| 2016 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> => | 1948 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> => |
| 2017 | github | 1949 | collect(requestedRows, (after, rows) => |
| 2018 | .execute({ | 1950 | continues |
| 2019 | cwd: input.cwd, | 1951 | ? graphqlRead({ |
| 2020 | args: [ | 1952 | cwd: input.cwd, |
| 2021 | "pr", | 1953 | host: input.host, |
| 2022 | "list", | 1954 | operation: "listPullRequests", |
| 2023 | ...repositoryArgs(input), | 1955 | // The reader's own words are in the query. |
| 2024 | ...involvementArgs({ ...input, sorted: continues }), | 1956 | variables: { q: query, after }, |
| 2025 | "--state", | 1957 | query: pullRequestSearchGraphQlQuery(rows, false, true), |
| 2026 | input.state, | 1958 | decode: decodePullRequestSearchJson, |
| 2027 | "--limit", | 1959 | }) |
| 2028 | // One extra row reveals that the repository has more than the page shows. | 1960 | : graphqlRead({ |
| 2029 | String(requestedRows), | 1961 | cwd: input.cwd, |
| 2030 | "--json", | 1962 | host: input.host, |
| 2031 | PULL_REQUEST_LIST_JSON_FIELDS, | 1963 | operation: "listPullRequests", |
| 2032 | ], | 1964 | variables: { owner, name, states: LIST_STATES[input.state], after }, |
| 2033 | }) | 1965 | query: pullRequestListGraphQlQuery(rows), |
| 2034 | .pipe( | 1966 | decode: decodePullRequestListJson, |
| 2035 | Effect.flatMap((result) => { | 1967 | }), |
| 2036 | const raw = result.stdout.trim(); | 1968 | ).pipe( |
| 2037 | if (raw.length === 0) { | 1969 | Effect.flatMap(({ items: rawItems, rawCount }) => { |
| 2038 | return Effect.succeed({ items: [], truncated: false, continues }); | 1970 | const items = continues |
| 2039 | } | 1971 | ? rawItems |
| 2040 | const decoded = decodePullRequestListJson(raw); | 1972 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); |
| 2041 | if (Result.isSuccess(decoded)) { | 1973 | if ( |
| 2042 | const items = continues | 1974 | !continues && |
| 2043 | ? decoded.success.items | 1975 | items.length < input.limit && |
| 2044 | : decoded.success.items.filter((item) => matchesUnsortedListing(item, input)); | 1976 | rawCount >= requestedRows && |
| 2045 | if ( | 1977 | requestedRows < fallbackMaxRows |
| 2046 | !continues && | 1978 | ) { |
| 2047 | items.length < input.limit && | 1979 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); |
| 2048 | decoded.success.rawCount >= requestedRows && | 1980 | if (nextRows > requestedRows) return read(false, nextRows); |
| 2049 | requestedRows < fallbackMaxRows | 1981 | } |
| 2050 | ) { | 1982 | return Effect.succeed({ |
| 2051 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | 1983 | items: items.slice(0, input.limit), |
| 2052 | if (nextRows > requestedRows) return read(false, nextRows); | 1984 | // One row over the page size is the probe for a next page, and it is |
| 2053 | } | 1985 | // counted before decoding: a skipped malformed row must not end paging. |
| 2054 | return Effect.succeed({ | 1986 | truncated: continues |
| 2055 | items: items.slice(0, input.limit), | 1987 | ? rawCount > input.limit |
| 2056 | // One row over the page size is the probe for a next page, and it is | 1988 | : items.length > input.limit || rawCount >= requestedRows, |
| 2057 | // counted before decoding: a skipped malformed row must not end paging. | 1989 | continues, |
| 2058 | truncated: continues | 1990 | }); |
| 2059 | ? decoded.success.rawCount > input.limit | 1991 | }), |
| 2060 | : items.length > input.limit || decoded.success.rawCount >= requestedRows, | 1992 | ); |
| 2061 | continues, | ||
| 2062 | }); | ||
| 2063 | } | ||
| 2064 | return Effect.fail( | ||
| 2065 | new GitHubPullRequestReadError({ | ||
| 2066 | command: "gh", | ||
| 2067 | cwd: input.cwd, | ||
| 2068 | operation: "listPullRequests", | ||
| 2069 | cause: decoded.failure, | ||
| 2070 | }), | ||
| 2071 | ); | ||
| 2072 | }), | ||
| 2073 | ); | ||
| 2074 | // GitHub does not index every repository for search, and one it will not search answers | 1993 | // GitHub does not index every repository for search, and one it will not search answers |
| 2075 | // with no rows rather than with an error — so an empty listing is read again the way `gh` | 1994 | // with no rows rather than with an error — so an empty listing is read again from the |
| 2076 | // lists without one. Those rows come back newest-created first, an order no `updated:` | 1995 | // repository's own list. Those rows come back newest-created first, an order no `updated:` |
| 2077 | // qualifier can carry on from, so that page says it cannot be continued and the reader | 1996 | // qualifier can carry on from, so that page says it cannot be continued and the reader |
| 2078 | // reaches the rest of it by asking for a larger page, as every listing used to. | 1997 | // reaches the rest of it by asking for a larger page, as every listing used to. |
| 2079 | // | 1998 | // |
| ... | @@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () { |
| 2157 | cwd: input.cwd, | 2076 | cwd: input.cwd, |
| 2158 | host: input.host, | 2077 | host: input.host, |
| 2159 | operation: "searchPullRequests", | 2078 | operation: "searchPullRequests", |
| 2160 | // The reader's own words are in the query, so it travels over stdin rather than in argv. | 2079 | variables: { q: query, after: null }, |
| 2161 | privateVariables: { q: query }, | ||
| 2162 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | 2080 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), |
| 2163 | decode: decodePullRequestSearchJson, | 2081 | decode: decodePullRequestSearchJson, |
| 2164 | }).pipe( | 2082 | }).pipe( |
| ... | @@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () { |
| 2217 | cwd: input.cwd, | 2135 | cwd: input.cwd, |
| 2218 | host: input.host, | 2136 | host: input.host, |
| 2219 | operation: "getPullRequestPreview", | 2137 | operation: "getPullRequestPreview", |
| 2220 | variables: [ | 2138 | variables: { owner, name, number: input.number }, |
| 2221 | ["-f", `owner=${owner}`], | ||
| 2222 | ["-f", `name=${name}`], | ||
| 2223 | ["-F", `number=${input.number}`], | ||
| 2224 | ], | ||
| 2225 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | 2139 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, |
| 2226 | decode: decodePullRequestPreviewJson, | 2140 | decode: decodePullRequestPreviewJson, |
| 2227 | }); | 2141 | }); |
| ... | @@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () { | ... | @@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () { |
| 2230 | 2144 | ||
| 2231 | getPullRequestStack: (input) => { | 2145 | getPullRequestStack: (input) => { |
| 2232 | const { owner, name } = parseRepositorySelector(input.repository); | 2146 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2233 | return github | 2147 | return restRead({ |
| 2234 | .execute({ | 2148 | cwd: input.cwd, |
| 2235 | cwd: input.cwd, | 2149 | host: input.host, |
| 2236 | args: [ | 2150 | operation: "getPullRequestStack", |
| 2237 | "api", | 2151 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, |
| 2238 | "--hostname", | 2152 | decode: decodePullRequestStacksJson, |
| 2239 | input.host, | 2153 | }).pipe( |
| 2240 | `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | 2154 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. |
| 2241 | ], | 2155 | Effect.flatMap((stack) => { |
| 2242 | }) | 2156 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); |
| 2243 | .pipe( | 2157 | return restRead({ |
| 2244 | Effect.flatMap((result) => { | 2158 | cwd: input.cwd, |
| 2245 | const decoded = decodePullRequestStacksJson(result.stdout.trim()); | 2159 | host: input.host, |
| 2246 | return Result.isSuccess(decoded) | 2160 | operation: "getPullRequestStack", |
| 2247 | ? Effect.succeed(decoded.success) | 2161 | path: `repos/${owner}/${name}/stacks/${stack.number}`, |
| 2248 | : Effect.fail( | 2162 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), |
| 2249 | new GitHubPullRequestReadError({ | 2163 | }); |
| 2250 | command: "gh", | 2164 | }), |
| 2251 | cwd: input.cwd, | 2165 | // Hosts without the stacks preview return 404. Other failures must preserve the |
| 2252 | operation: "getPullRequestStack", | 2166 | // previously synced stack and let the caller retry. |
| 2253 | cause: decoded.failure, | 2167 | Effect.catchTags({ |
| 2254 | }), | 2168 | GitHubApiNotFoundError: () => Effect.succeed(null), |
| 2255 | ); | 2169 | }), |
| 2256 | }), | 2170 | ); |
| 2257 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | ||
| 2258 | Effect.flatMap((stack) => { | ||
| 2259 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | ||
| 2260 | return github | ||
| 2261 | .execute({ | ||
| 2262 | cwd: input.cwd, | ||
| 2263 | args: [ | ||
| 2264 | "api", | ||
| 2265 | "--hostname", | ||
| 2266 | input.host, | ||
| 2267 | `repos/${owner}/${name}/stacks/${stack.number}`, | ||
| 2268 | ], | ||
| 2269 | }) | ||
| 2270 | .pipe( | ||
| 2271 | Effect.flatMap((result) => { | ||
| 2272 | const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); | ||
| 2273 | return Result.isSuccess(decoded) | ||
| 2274 | ? Effect.succeed(decoded.success) | ||
| 2275 | : Effect.fail( | ||
| 2276 | new GitHubPullRequestReadError({ | ||
| 2277 | command: "gh", | ||
| 2278 | cwd: input.cwd, | ||
| 2279 | operation: "getPullRequestStack", | ||
| 2280 | cause: decoded.failure, | ||
| 2281 | }), | ||
| 2282 | ); | ||
| 2283 | }), | ||
| 2284 | ); | ||
| 2285 | }), | ||
| 2286 | // Hosts without the stacks preview return 404. Other failures must preserve the | ||
| 2287 | // previously synced stack and let the caller retry. | ||
| 2288 | Effect.catchTags({ | ||
| 2289 | GitHubPullRequestNotFoundError: () => Effect.succeed(null), | ||
| 2290 | }), | ||
| 2291 | ); | ||
| 2292 | }, | 2171 | }, |
| 2293 | 2172 | ||
| 2294 | getPullRequestBaseComparison: (input) => { | 2173 | getPullRequestBaseComparison: (input) => { |
| ... | @@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () { | ... | @@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () { |
| 2298 | host: input.host, | 2177 | host: input.host, |
| 2299 | operation: "getPullRequestBaseComparison", | 2178 | operation: "getPullRequestBaseComparison", |
| 2300 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | 2179 | ...(input.allowReserve === true ? { allowReserve: true } : {}), |
| 2301 | variables: [ | 2180 | variables: { owner, name, number: input.number, headRef: input.headRef }, |
| 2302 | ["-f", `owner=${owner}`], | ||
| 2303 | ["-f", `name=${name}`], | ||
| 2304 | ["-F", `number=${input.number}`], | ||
| 2305 | ["-f", `headRef=${input.headRef}`], | ||
| 2306 | ], | ||
| 2307 | query: BASE_COMPARISON_GRAPHQL_QUERY, | 2181 | query: BASE_COMPARISON_GRAPHQL_QUERY, |
| 2308 | decode: decodeBaseComparisonJson, | 2182 | decode: decodeBaseComparisonJson, |
| 2309 | }); | 2183 | }); |
| 2310 | }, | 2184 | }, |
| 2311 | 2185 | ||
| 2312 | getPullRequestActivity: (input) => | 2186 | getPullRequestActivity: (input) => |
| 2313 | github | 2187 | Effect.gen(function* () { |
| 2314 | .execute({ | 2188 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2315 | cwd: input.cwd, | 2189 | let author: PullRequestActor | null = null; |
| 2316 | args: [ | 2190 | let commits: ReadonlyArray<PullRequestCommit> = []; |
| 2317 | "pr", | 2191 | const remarks: PullRequestComment[] = []; |
| 2318 | "view", | 2192 | let commentsAfter: string | null = null; |
| 2319 | String(input.number), | 2193 | let reviewsAfter: string | null = null; |
| 2320 | ...repositoryArgs(input), | 2194 | let withComments = true; |
| 2321 | "--json", | 2195 | let withReviews = true; |
| 2322 | PULL_REQUEST_ACTIVITY_JSON_FIELDS, | 2196 | // Both remark lists page on their own; a page asks only for the ones with more to give. |
| 2323 | ], | 2197 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { |
| 2324 | }) | 2198 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ |
| 2325 | .pipe( | 2199 | cwd: input.cwd, |
| 2326 | Effect.flatMap((result) => { | 2200 | host: input.host, |
| 2327 | const decoded = decodePullRequestActivityJson(result.stdout.trim()); | 2201 | operation: "getPullRequestActivity", |
| 2328 | return Result.isSuccess(decoded) | 2202 | variables: { |
| 2329 | ? Effect.succeed(decoded.success) | 2203 | owner, |
| 2330 | : Effect.fail( | 2204 | name, |
| 2331 | new GitHubPullRequestReadError({ | 2205 | number: input.number, |
| 2332 | command: "gh", | 2206 | head: page === 0, |
| 2333 | cwd: input.cwd, | 2207 | withComments, |
| 2334 | operation: "getPullRequestActivity", | 2208 | commentsAfter, |
| 2335 | cause: decoded.failure, | 2209 | withReviews, |
| 2336 | }), | 2210 | reviewsAfter, |
| 2337 | ); | 2211 | }, |
| 2338 | }), | 2212 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, |
| 2339 | ), | 2213 | decode: decodePullRequestActivityJson, |
| 2214 | }); | ||
| 2215 | if (page === 0) { | ||
| 2216 | author = read.author ?? null; | ||
| 2217 | commits = read.commits ?? []; | ||
| 2218 | } | ||
| 2219 | remarks.push(...read.remarks); | ||
| 2220 | commentsAfter = read.nextCommentsCursor; | ||
| 2221 | reviewsAfter = read.nextReviewsCursor; | ||
| 2222 | withComments = withComments && commentsAfter !== null; | ||
| 2223 | withReviews = withReviews && reviewsAfter !== null; | ||
| 2224 | } | ||
| 2225 | return { | ||
| 2226 | author, | ||
| 2227 | comments: remarks.toSorted((left, right) => | ||
| 2228 | left.createdAt.localeCompare(right.createdAt), | ||
| 2229 | ), | ||
| 2230 | commits, | ||
| 2231 | } satisfies GitHubPullRequestActivity; | ||
| 2232 | }), | ||
| 2340 | 2233 | ||
| 2341 | getPullRequestDiff: (input) => { | 2234 | getPullRequestDiff: (input) => { |
| 2342 | const filesPage = (page: number) => | 2235 | const filesPage = (page: number) => |
| ... | @@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () { | ... | @@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () { |
| 2363 | if (input.commit !== undefined) { | 2256 | if (input.commit !== undefined) { |
| 2364 | return filesPage(1); | 2257 | return filesPage(1); |
| 2365 | } | 2258 | } |
| 2366 | return github | 2259 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2367 | .execute({ | 2260 | return api |
| 2368 | cwd: input.cwd, | 2261 | .rest({ |
| 2369 | args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"], | 2262 | host: input.host, |
| 2370 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | 2263 | operation: "getPullRequestDiff", |
| 2371 | timeoutMs: DIFF_TIMEOUT_MS, | 2264 | path: `repos/${owner}/${name}/pulls/${input.number}`, |
| 2265 | accept: "application/vnd.github.diff", | ||
| 2266 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | ||
| 2267 | timeout: DIFF_TIMEOUT, | ||
| 2372 | }) | 2268 | }) |
| 2373 | .pipe( | 2269 | .pipe( |
| 2374 | Effect.flatMap((result) => | 2270 | Effect.flatMap((response) => |
| 2375 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor | 2271 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor |
| 2376 | // something the reader can carry on from. The files API can serve the same change a | 2272 | // something the reader can carry on from. The files API can serve the same change a |
| 2377 | // whole number of files at a time, so an oversized patch takes that road as well. | 2273 | // whole number of files at a time, so an oversized patch takes that road as well. |
| 2378 | result.stdoutTruncated | 2274 | response.truncated |
| 2379 | ? filesPage(1) | 2275 | ? filesPage(1) |
| 2380 | : // One read served the whole patch, so there is no next slice to ask for. | 2276 | : // One read served the whole patch, so there is no next slice to ask for. |
| 2381 | Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }), | 2277 | Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), |
| 2382 | ), | 2278 | ), |
| 2383 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read | 2279 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read |
| 2384 | // from the files API instead, a page per call. Only once the direct read has failed: a | 2280 | // from the files API instead, a page per call. Only once the direct read has failed: a |
| 2385 | // pull request GitHub will serve a diff for must not pay for a second request. A | 2281 | // pull request GitHub will serve a diff for must not pay for a second request. A |
| 2386 | // fallback that fails too reports the original refusal, which is the one that explains | 2282 | // fallback that fails too reports the original refusal, which is the one that explains |
| 2387 | // the page. Narrowed to a command that ran and was refused: a missing `gh` or a | 2283 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or |
| 2388 | // signed-out one fails the same way for every request. | 2284 | // a rate limit fails the same way for every request. |
| 2389 | Effect.catchTags({ | 2285 | Effect.catchTags({ |
| 2390 | GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), | 2286 | GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), |
| 2391 | }), | 2287 | }), |
| 2392 | ); | 2288 | ); |
| 2393 | }, | 2289 | }, |
| ... | @@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () { | ... | @@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () { |
| 2400 | cwd: input.cwd, | 2296 | cwd: input.cwd, |
| 2401 | host: input.host, | 2297 | host: input.host, |
| 2402 | operation: "getReviewThreadComments", | 2298 | operation: "getReviewThreadComments", |
| 2403 | variables: [ | 2299 | variables: { |
| 2404 | ["-f", `owner=${owner}`], | 2300 | owner, |
| 2405 | ["-f", `name=${name}`], | 2301 | name, |
| 2406 | ["-F", `number=${input.number}`], | 2302 | number: input.number, |
| 2407 | ["-f", `threadId=${input.threadId}`], | 2303 | threadId: input.threadId, |
| 2408 | cursorVariable(input.cursor), | 2304 | cursor: input.cursor, |
| 2409 | ], | 2305 | }, |
| 2410 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | 2306 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, |
| 2411 | decode: decodeReviewThreadCommentsJson, | 2307 | decode: decodeReviewThreadCommentsJson, |
| 2412 | }).pipe( | 2308 | }).pipe( |
| ... | @@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () { |
| 2434 | cwd: input.cwd, | 2330 | cwd: input.cwd, |
| 2435 | host: input.host, | 2331 | host: input.host, |
| 2436 | operation: "listReviewThreadComments", | 2332 | operation: "listReviewThreadComments", |
| 2437 | variables: [ | 2333 | variables: { owner, name, number: input.number, cursor }, |
| 2438 | ["-f", `owner=${owner}`], | ||
| 2439 | ["-f", `name=${name}`], | ||
| 2440 | ["-F", `number=${input.number}`], | ||
| 2441 | cursorVariable(cursor), | ||
| 2442 | ], | ||
| 2443 | query: REVIEW_THREADS_GRAPHQL_QUERY, | 2334 | query: REVIEW_THREADS_GRAPHQL_QUERY, |
| 2444 | decode: decodeReviewThreadsJson, | 2335 | decode: decodeReviewThreadsJson, |
| 2445 | }); | 2336 | }); |
| ... | @@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () { |
| 2496 | cwd: input.cwd, | 2387 | cwd: input.cwd, |
| 2497 | host: input.host, | 2388 | host: input.host, |
| 2498 | operation: "listReviewThreadComments", | 2389 | operation: "listReviewThreadComments", |
| 2499 | variables: [ | 2390 | variables: { owner, name, number: input.number, cursor: dismissalCursor }, |
| 2500 | ["-f", `owner=${owner}`], | ||
| 2501 | ["-f", `name=${name}`], | ||
| 2502 | ["-F", `number=${input.number}`], | ||
| 2503 | ["-f", `cursor=${dismissalCursor}`], | ||
| 2504 | ], | ||
| 2505 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, | 2391 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, |
| 2506 | decode: decodeReviewDismissalsJson, | 2392 | decode: decodeReviewDismissalsJson, |
| 2507 | }); | 2393 | }); |
| ... | @@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () { |
| 2547 | cwd: input.cwd, | 2433 | cwd: input.cwd, |
| 2548 | host: input.host, | 2434 | host: input.host, |
| 2549 | operation: "listActorAvatars", | 2435 | operation: "listActorAvatars", |
| 2550 | variables: input.ids.map((id) => ["-f", `ids[]=${id}`]), | 2436 | variables: { ids: input.ids }, |
| 2551 | query: ACTOR_AVATARS_GRAPHQL_QUERY, | 2437 | query: ACTOR_AVATARS_GRAPHQL_QUERY, |
| 2552 | decode: decodeActorAvatarsJson, | 2438 | decode: decodeActorAvatarsJson, |
| 2553 | }); | 2439 | }); |
| ... | @@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () { |
| 2560 | host: input.host, | 2446 | host: input.host, |
| 2561 | operation: "getViewerAccess", | 2447 | operation: "getViewerAccess", |
| 2562 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | 2448 | ...(input.allowReserve === true ? { allowReserve: true } : {}), |
| 2563 | variables: [ | 2449 | variables: { owner, name, number: input.number }, |
| 2564 | ["-f", `owner=${owner}`], | ||
| 2565 | ["-f", `name=${name}`], | ||
| 2566 | ["-F", `number=${input.number}`], | ||
| 2567 | ], | ||
| 2568 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, | 2450 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, |
| 2569 | decode: decodeViewerPermissionsJson, | 2451 | decode: decodeViewerPermissionsJson, |
| 2570 | }); | 2452 | }); |
| ... | @@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () { |
| 2577 | host: input.host, | 2459 | host: input.host, |
| 2578 | operation: "listReviewerCandidates", | 2460 | operation: "listReviewerCandidates", |
| 2579 | allowReserve: true, | 2461 | allowReserve: true, |
| 2580 | variables: [ | 2462 | variables: { owner, name, number: input.number }, |
| 2581 | ["-f", `owner=${owner}`], | ||
| 2582 | ["-f", `name=${name}`], | ||
| 2583 | ["-F", `number=${input.number}`], | ||
| 2584 | ], | ||
| 2585 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, | 2463 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, |
| 2586 | decode: decodeReviewerCandidatesJson, | 2464 | decode: decodeReviewerCandidatesJson, |
| 2587 | }); | 2465 | }); |
| ... | @@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () { | ... | @@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () { |
| 2589 | 2467 | ||
| 2590 | setReviewerRequest: (input) => { | 2468 | setReviewerRequest: (input) => { |
| 2591 | const { owner, name } = parseRepositorySelector(input.repository); | 2469 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2592 | return github | 2470 | // Posting to a login GitHub has already been asked about is what a re-request is, so |
| 2593 | .execute({ | 2471 | // there is nothing to say here about somebody who has reviewed once already. |
| 2594 | cwd: input.cwd, | 2472 | return api |
| 2595 | // Posting to a login GitHub has already been asked about is what a re-request is, so | 2473 | .rest({ |
| 2596 | // there is nothing to say here about somebody who has reviewed once already. The body | 2474 | host: input.host, |
| 2597 | // travels over stdin for the reason every other one does: argv is visible in process | 2475 | operation: "setReviewerRequest", |
| 2598 | // listings and echoed back inside process-runner failure messages. | 2476 | method: input.requested ? "POST" : "DELETE", |
| 2599 | args: [ | 2477 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, |
| 2600 | "api", | 2478 | body: buildReviewerRequest(input.reviewers), |
| 2601 | "--method", | ||
| 2602 | input.requested ? "POST" : "DELETE", | ||
| 2603 | "--hostname", | ||
| 2604 | input.host, | ||
| 2605 | `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | ||
| 2606 | "--input", | ||
| 2607 | "-", | ||
| 2608 | ], | ||
| 2609 | stdin: buildReviewerRequestJson(input.reviewers), | ||
| 2610 | }) | 2479 | }) |
| 2611 | .pipe(Effect.asVoid); | 2480 | .pipe(Effect.asVoid); |
| 2612 | }, | 2481 | }, |
| ... | @@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () { |
| 2618 | host: input.host, | 2487 | host: input.host, |
| 2619 | operation: "listLabelCandidates", | 2488 | operation: "listLabelCandidates", |
| 2620 | allowReserve: true, | 2489 | allowReserve: true, |
| 2621 | variables: [ | 2490 | variables: { owner, name, number: input.number }, |
| 2622 | ["-f", `owner=${owner}`], | ||
| 2623 | ["-f", `name=${name}`], | ||
| 2624 | ["-F", `number=${input.number}`], | ||
| 2625 | ], | ||
| 2626 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, | 2491 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, |
| 2627 | decode: decodeLabelCandidatesJson, | 2492 | decode: decodeLabelCandidatesJson, |
| 2628 | }); | 2493 | }); |
| ... | @@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () { | ... | @@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () { |
| 2635 | // path. The name goes into the path encoded, because a label may carry a space or a slash. | 2500 | // path. The name goes into the path encoded, because a label may carry a space or a slash. |
| 2636 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; | 2501 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; |
| 2637 | if (input.applied) { | 2502 | if (input.applied) { |
| 2638 | return github | 2503 | return api |
| 2639 | .execute({ | 2504 | .rest({ |
| 2640 | cwd: input.cwd, | 2505 | host: input.host, |
| 2641 | args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], | 2506 | operation: "setLabels", |
| 2642 | stdin: buildLabelRequestJson(input.labels), | 2507 | method: "POST", |
| 2508 | path: issue, | ||
| 2509 | body: buildLabelRequest(input.labels), | ||
| 2643 | }) | 2510 | }) |
| 2644 | .pipe(Effect.asVoid); | 2511 | .pipe(Effect.asVoid); |
| 2645 | } | 2512 | } |
| 2646 | return Effect.forEach( | 2513 | return Effect.forEach( |
| 2647 | input.labels, | 2514 | input.labels, |
| 2648 | (label) => | 2515 | (label) => |
| 2649 | github.execute({ | 2516 | api.rest({ |
| 2650 | cwd: input.cwd, | 2517 | host: input.host, |
| 2651 | args: [ | 2518 | operation: "setLabels", |
| 2652 | "api", | 2519 | method: "DELETE", |
| 2653 | "--method", | 2520 | path: `${issue}/${encodeURIComponent(label)}`, |
| 2654 | "DELETE", | ||
| 2655 | "--hostname", | ||
| 2656 | input.host, | ||
| 2657 | `${issue}/${encodeURIComponent(label)}`, | ||
| 2658 | ], | ||
| 2659 | }), | 2521 | }), |
| 2660 | { concurrency: 1, discard: true }, | 2522 | { concurrency: 1, discard: true }, |
| 2661 | ); | 2523 | ); |
| ... | @@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () { | ... | @@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () { |
| 2664 | runPullRequestAction: (input) => { | 2526 | runPullRequestAction: (input) => { |
| 2665 | if (input.stackNumber !== undefined) | 2527 | if (input.stackNumber !== undefined) |
| 2666 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | 2528 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( |
| 2667 | Effect.provideService(GitHubCli.GitHubCli, github), | 2529 | Effect.provideService(GitHubApi.GitHubApi, api), |
| 2668 | ); | 2530 | ); |
| 2669 | if (input.action === "revert") { | 2531 | if (input.action === "revert") { |
| 2670 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | 2532 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( |
| 2671 | Effect.flatMap((pullRequestId) => | 2533 | Effect.flatMap((pullRequestId) => |
| 2672 | graphql({ | 2534 | graphql({ |
| 2673 | cwd: input.cwd, | ||
| 2674 | host: input.host, | 2535 | host: input.host, |
| 2536 | operation: "revertPullRequest", | ||
| 2675 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | 2537 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, |
| 2676 | variables: { pullRequestId }, | 2538 | variables: { pullRequestId }, |
| 2677 | }), | 2539 | }), |
| ... | @@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () { | ... | @@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () { |
| 2742 | }), | 2604 | }), |
| 2743 | Effect.flatMap((currentRuns) => | 2605 | Effect.flatMap((currentRuns) => |
| 2744 | currentRuns.some((current) => current.id === run.id) | 2606 | currentRuns.some((current) => current.id === run.id) |
| 2745 | ? github | 2607 | ? api |
| 2746 | .execute({ | 2608 | .rest({ |
| 2747 | cwd: input.cwd, | 2609 | host: input.host, |
| 2748 | args: [ | 2610 | operation: "approveWorkflowRun", |
| 2749 | "api", | 2611 | method: "POST", |
| 2750 | "--method", | 2612 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, |
| 2751 | "POST", | ||
| 2752 | "--hostname", | ||
| 2753 | input.host, | ||
| 2754 | `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | ||
| 2755 | "--silent", | ||
| 2756 | ], | ||
| 2757 | }) | 2613 | }) |
| 2758 | .pipe(Effect.asVoid) | 2614 | .pipe(Effect.asVoid) |
| 2759 | : Effect.void, | 2615 | : Effect.void, |
| ... | @@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () { | ... | @@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () { |
| 2766 | }), | 2622 | }), |
| 2767 | ); | 2623 | ); |
| 2768 | } | 2624 | } |
| 2769 | const [subcommand, ...flags] = actionArgs( | 2625 | const action = input.action; |
| 2770 | input.action, | 2626 | if (action in SIMPLE_ACTION_MUTATIONS) { |
| 2771 | input.mergeMethod, | 2627 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( |
| 2772 | input.updateMethod, | 2628 | Effect.flatMap((pullRequestId) => |
| 2773 | ); | 2629 | graphql({ |
| 2630 | host: input.host, | ||
| 2631 | operation: "runPullRequestAction", | ||
| 2632 | query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], | ||
| 2633 | variables: { pullRequestId }, | ||
| 2634 | }), | ||
| 2635 | ), | ||
| 2636 | ); | ||
| 2637 | } | ||
| 2774 | return Effect.gen(function* () { | 2638 | return Effect.gen(function* () { |
| 2639 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 2640 | // Read fresh rather than from the node id cache: merging and updating act on the head | ||
| 2641 | // as it stands now, and the merge queue decides which mutation a merge is. | ||
| 2642 | const state = yield* graphqlRead({ | ||
| 2643 | cwd: input.cwd, | ||
| 2644 | host: input.host, | ||
| 2645 | operation: "runPullRequestAction", | ||
| 2646 | allowReserve: true, | ||
| 2647 | query: ACTION_STATE_GRAPHQL_QUERY, | ||
| 2648 | variables: { | ||
| 2649 | owner, | ||
| 2650 | name, | ||
| 2651 | number: input.number, | ||
| 2652 | headRef: `refs/pull/${input.number}/head`, | ||
| 2653 | }, | ||
| 2654 | decode: decodeActionState, | ||
| 2655 | }); | ||
| 2656 | if (action === "update-branch") { | ||
| 2657 | // Already current is done, the way `gh pr update-branch` reports it. | ||
| 2658 | if (state.baseRef?.compare?.behindBy === 0) return; | ||
| 2659 | // GitHub updates with a merge commit unless asked to rebase, which is its own default. | ||
| 2660 | return yield* graphql({ | ||
| 2661 | host: input.host, | ||
| 2662 | operation: "runPullRequestAction", | ||
| 2663 | query: UPDATE_BRANCH_GRAPHQL_MUTATION, | ||
| 2664 | variables: { | ||
| 2665 | pullRequestId: state.id, | ||
| 2666 | expectedHeadOid: state.headRefOid, | ||
| 2667 | updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", | ||
| 2668 | }, | ||
| 2669 | }); | ||
| 2670 | } | ||
| 2775 | let body: string | undefined; | 2671 | let body: string | undefined; |
| 2776 | let expectedHead: string | undefined; | 2672 | let expectedHead: string | undefined; |
| 2777 | if ( | 2673 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { |
| 2778 | input.removeAgentCreditsOnMerge === true && | ||
| 2779 | (input.action === "merge" || input.action === "enable-auto-merge") && | ||
| 2780 | input.mergeMethod !== "rebase" | ||
| 2781 | ) { | ||
| 2782 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 2783 | const message = yield* graphqlRead({ | 2674 | const message = yield* graphqlRead({ |
| 2784 | cwd: input.cwd, | 2675 | cwd: input.cwd, |
| 2785 | host: input.host, | 2676 | host: input.host, |
| 2786 | operation: "runPullRequestAction", | 2677 | operation: "runPullRequestAction", |
| 2787 | allowReserve: true, | 2678 | allowReserve: true, |
| 2788 | query: MERGE_MESSAGE_GRAPHQL_QUERY, | 2679 | query: MERGE_MESSAGE_GRAPHQL_QUERY, |
| 2789 | variables: [ | 2680 | variables: { |
| 2790 | ["-f", `owner=${owner}`], | 2681 | owner, |
| 2791 | ["-f", `name=${name}`], | 2682 | name, |
| 2792 | ["-F", `number=${input.number}`], | 2683 | number: input.number, |
| 2793 | ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`], | 2684 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", |
| 2794 | ], | 2685 | }, |
| 2795 | decode: decodeMergeMessage, | 2686 | decode: decodeMergeMessage, |
| 2796 | }); | 2687 | }); |
| 2797 | // GitHub's merge queue chooses its own message and ignores custom text. | 2688 | // GitHub's merge queue chooses its own message and ignores custom text. |
| ... | @@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () { | ... | @@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () { |
| 2803 | } | 2694 | } |
| 2804 | } | 2695 | } |
| 2805 | } | 2696 | } |
| 2806 | yield* github.execute({ | 2697 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and |
| 2807 | cwd: input.cwd, | 2698 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. |
| 2808 | args: [ | 2699 | // GitHub stores the strategy with a standing instruction rather than choosing one at |
| 2809 | "pr", | 2700 | // merge time, so arming still names it. |
| 2810 | subcommand!, | 2701 | const auto = |
| 2811 | String(input.number), | 2702 | state.isMergeQueueEnabled === true || |
| 2812 | ...repositoryArgs(input), | 2703 | (action === "enable-auto-merge" && |
| 2813 | ...flags, | 2704 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); |
| 2814 | ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]), | 2705 | yield* graphql({ |
| 2815 | ...(body === undefined ? [] : ["--body-file", "-"]), | 2706 | host: input.host, |
| 2816 | ], | 2707 | operation: "runPullRequestAction", |
| 2817 | ...(body === undefined ? {} : { stdin: body }), | 2708 | query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, |
| 2709 | variables: { | ||
| 2710 | input: { | ||
| 2711 | pullRequestId: state.id, | ||
| 2712 | mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], | ||
| 2713 | ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), | ||
| 2714 | ...(body === undefined ? {} : { commitBody: body }), | ||
| 2715 | }, | ||
| 2716 | }, | ||
| 2818 | }); | 2717 | }); |
| 2819 | }); | 2718 | }); |
| 2820 | }, | 2719 | }, |
| 2821 | 2720 | ||
| 2822 | commentOnPullRequest: (input) => | 2721 | commentOnPullRequest: (input) => |
| 2823 | github | 2722 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( |
| 2824 | .execute({ | 2723 | Effect.flatMap((subjectId) => |
| 2825 | cwd: input.cwd, | 2724 | graphql({ |
| 2826 | // The body travels over stdin: argv is visible in process listings and is echoed | 2725 | host: input.host, |
| 2827 | // back inside process-runner failure messages. | 2726 | operation: "commentOnPullRequest", |
| 2828 | args: [ | 2727 | query: ADD_COMMENT_GRAPHQL_MUTATION, |
| 2829 | "pr", | 2728 | variables: { subjectId, body: input.body }, |
| 2830 | "comment", | 2729 | }), |
| 2831 | String(input.number), | 2730 | ), |
| 2832 | ...repositoryArgs(input), | 2731 | ), |
| 2833 | "--body-file", | ||
| 2834 | "-", | ||
| 2835 | ], | ||
| 2836 | stdin: input.body, | ||
| 2837 | }) | ||
| 2838 | .pipe(Effect.asVoid), | ||
| 2839 | 2732 | ||
| 2840 | submitReview: (input) => { | 2733 | submitReview: (input) => { |
| 2841 | const { owner, name } = parseRepositorySelector(input.repository); | 2734 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2842 | return github | 2735 | // The whole review is one request, so nothing is visible to anyone else until the verdict |
| 2843 | .execute({ | 2736 | // is sent. |
| 2844 | cwd: input.cwd, | 2737 | return api |
| 2845 | // The whole review is one request, so nothing is visible to anyone else until the | 2738 | .rest({ |
| 2846 | // verdict is sent. The payload travels over stdin for the same reason a comment | 2739 | host: input.host, |
| 2847 | // body does: argv is visible in process listings and echoed back in failures. | 2740 | operation: "submitReview", |
| 2848 | args: [ | 2741 | method: "POST", |
| 2849 | "api", | 2742 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, |
| 2850 | "--method", | 2743 | body: buildReviewSubmission({ |
| 2851 | "POST", | ||
| 2852 | "--hostname", | ||
| 2853 | input.host, | ||
| 2854 | `repos/${owner}/${name}/pulls/${input.number}/reviews`, | ||
| 2855 | "--input", | ||
| 2856 | "-", | ||
| 2857 | ], | ||
| 2858 | stdin: buildReviewSubmissionJson({ | ||
| 2859 | verdict: input.verdict, | 2744 | verdict: input.verdict, |
| 2860 | body: input.body, | 2745 | body: input.body, |
| 2861 | comments: input.comments, | 2746 | comments: input.comments, |
| ... | @@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () { |
| 2866 | 2751 | ||
| 2867 | replyToReviewThread: (input) => | 2752 | replyToReviewThread: (input) => |
| 2868 | graphql({ | 2753 | graphql({ |
| 2869 | cwd: input.cwd, | ||
| 2870 | host: input.host, | 2754 | host: input.host, |
| 2755 | operation: "replyToReviewThread", | ||
| 2871 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | 2756 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, |
| 2872 | variables: { threadId: input.threadId, body: input.body }, | 2757 | variables: { threadId: input.threadId, body: input.body }, |
| 2873 | }), | 2758 | }), |
| ... | @@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () { |
| 2883 | cwd: input.cwd, | 2768 | cwd: input.cwd, |
| 2884 | host: input.host, | 2769 | host: input.host, |
| 2885 | operation: "getPullRequestFilesViewed", | 2770 | operation: "getPullRequestFilesViewed", |
| 2886 | variables: [ | 2771 | variables: { owner, name, number: input.number, after }, |
| 2887 | ["-f", `owner=${owner}`], | ||
| 2888 | ["-f", `name=${name}`], | ||
| 2889 | ["-F", `number=${input.number}`], | ||
| 2890 | ...(after === null | ||
| 2891 | ? [] | ||
| 2892 | : ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)), | ||
| 2893 | ], | ||
| 2894 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | 2772 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, |
| 2895 | decode: decodePullRequestFilesViewedJson, | 2773 | decode: decodePullRequestFilesViewedJson, |
| 2896 | }).pipe( | 2774 | }).pipe( |
| ... | @@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () { |
| 2913 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( | 2791 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( |
| 2914 | Effect.flatMap((pullRequestId) => | 2792 | Effect.flatMap((pullRequestId) => |
| 2915 | graphql({ | 2793 | graphql({ |
| 2916 | cwd: input.cwd, | ||
| 2917 | host: input.host, | 2794 | host: input.host, |
| 2795 | operation: "setPullRequestFilesViewed", | ||
| 2918 | query: mutation.query, | 2796 | query: mutation.query, |
| 2919 | variables: { pullRequestId, ...mutation.variables }, | 2797 | variables: { pullRequestId, ...mutation.variables }, |
| 2920 | }), | 2798 | }), |
| ... | @@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () { |
| 2924 | 2802 | ||
| 2925 | setReviewThreadResolution: (input) => | 2803 | setReviewThreadResolution: (input) => |
| 2926 | graphql({ | 2804 | graphql({ |
| 2927 | cwd: input.cwd, | ||
| 2928 | host: input.host, | 2805 | host: input.host, |
| 2806 | operation: "setReviewThreadResolution", | ||
| 2929 | query: input.resolved | 2807 | query: input.resolved |
| 2930 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION | 2808 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION |
| 2931 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | 2809 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, |
| ... | @@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () { |
| 2957 | return subjectId.pipe( | 2835 | return subjectId.pipe( |
| 2958 | Effect.flatMap((subjectId) => | 2836 | Effect.flatMap((subjectId) => |
| 2959 | graphql({ | 2837 | graphql({ |
| 2960 | cwd: input.cwd, | ||
| 2961 | host: input.host, | 2838 | host: input.host, |
| 2839 | operation: "setReaction", | ||
| 2962 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, | 2840 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, |
| 2963 | variables: { subjectId, content: gitHubReactionContent(input.content) }, | 2841 | variables: { subjectId, content: gitHubReactionContent(input.content) }, |
| 2964 | }), | 2842 | }), |
| ... | @@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () { |
| 2970 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( | 2848 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( |
| 2971 | Effect.flatMap((pullRequestId) => | 2849 | Effect.flatMap((pullRequestId) => |
| 2972 | graphql({ | 2850 | graphql({ |
| 2973 | cwd: input.cwd, | ||
| 2974 | host: input.host, | 2851 | host: input.host, |
| 2852 | operation: "updatePullRequest", | ||
| 2975 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | 2853 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, |
| 2976 | // A field the caller did not name is left out of the request entirely, so GitHub | 2854 | // A field the caller did not name is left out of the request entirely, so GitHub |
| 2977 | // keeps the words that are there rather than being asked for an empty one. | 2855 | // keeps the words that are there rather than being asked for an empty one. |
| ... | @@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () { | ... | @@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () { |
| 3006 | ), | 2884 | ), |
| 3007 | Effect.flatMap((commentId) => | 2885 | Effect.flatMap((commentId) => |
| 3008 | graphql({ | 2886 | graphql({ |
| 3009 | cwd: input.cwd, | ||
| 3010 | host: input.host, | 2887 | host: input.host, |
| 2888 | operation: "updateComment", | ||
| 3011 | query: | 2889 | query: |
| 3012 | input.kind === "issue-comment" | 2890 | input.kind === "issue-comment" |
| 3013 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION | 2891 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+6-7| ... | @@ -6,7 +6,7 @@ import * as Result from "effect/Result"; | ... | @@ -6,7 +6,7 @@ import * as Result from "effect/Result"; |
| 6 | import type { PullRequestReaction } from "@t3tools/contracts"; | 6 | import type { PullRequestReaction } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; | 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; |
| 9 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 11 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | 11 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 12 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; | 12 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| ... | @@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => { |
| 331 | number: 7, | 331 | number: 7, |
| 332 | }) | 332 | }) |
| 333 | .pipe( | 333 | .pipe( |
| 334 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | 334 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { |
| 335 | host: "github.com", | 335 | host: "github.com", |
| 336 | token: Redacted.make("credential"), | 336 | token: Redacted.make("credential"), |
| 337 | credentialFingerprint: fingerprint, | 337 | credentialFingerprint: fingerprint, |
| ... | @@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => { |
| 378 | commits: [], | 378 | commits: [], |
| 379 | }).pipe( | 379 | }).pipe( |
| 380 | Effect.flatMap((detail) => | 380 | Effect.flatMap((detail) => |
| 381 | GitHubCli.PinnedGitHubCredential.pipe( | 381 | GitHubApi.PinnedGitHubCredential.pipe( |
| 382 | Effect.map((credential) => ({ | 382 | Effect.map((credential) => ({ |
| 383 | ...detail, | 383 | ...detail, |
| 384 | viewerAccess: { | 384 | viewerAccess: { |
| ... | @@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () => | ... | @@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () => |
| 672 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | 672 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 673 | listWorkflowRunsRequiringApproval: () => | 673 | listWorkflowRunsRequiringApproval: () => |
| 674 | Effect.fail( | 674 | Effect.fail( |
| 675 | new GitHubCli.GitHubCliRateLimitError({ | 675 | new GitHubApi.GitHubApiRateLimitError({ |
| 676 | command: "gh", | 676 | host: "github.com", |
| 677 | cwd: "/w", | 677 | operation: "listWorkflowRunsRequiringApproval", |
| 678 | cause: new Error("rate limited"), | ||
| 679 | }), | 678 | }), |
| 680 | ), | 679 | ), |
| 681 | getViewerAccess: () => | 680 | getViewerAccess: () => |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+20-14| ... | @@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest | ... | @@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest |
| 101 | }; | 101 | }; |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ | 104 | /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ |
| 105 | export function gitHubProviderFailure( | 105 | export function gitHubProviderFailure( |
| 106 | error: GitHubPullRequestCli.GitHubPullRequestCliError, | 106 | error: GitHubPullRequestCli.GitHubPullRequestCliError, |
| 107 | ): PullRequestProviderFailure { | 107 | ): PullRequestProviderFailure { |
| 108 | if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" }; | 108 | switch (error._tag) { |
| 109 | if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" }; | 109 | case "GitHubCliMissingError": |
| 110 | if (error._tag === "GitHubCliRateLimitError") | 110 | return { reason: "missing-tool" }; |
| 111 | return { | 111 | case "GitHubNotSignedInError": |
| 112 | reason: "rate-limited", | 112 | case "GitHubApiAuthenticationError": |
| 113 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | 113 | return { reason: "unauthenticated" }; |
| 114 | }; | 114 | case "GitHubApiRateLimitError": |
| 115 | if (error._tag === "SourceControlRateLimitPausedError") { | 115 | return { |
| 116 | return { reason: "rate-limited", retryAt: error.retryAt }; | 116 | reason: "rate-limited", |
| 117 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | ||
| 118 | }; | ||
| 119 | case "SourceControlRateLimitPausedError": | ||
| 120 | return { reason: "rate-limited", retryAt: error.retryAt }; | ||
| 121 | case "GitHubApiNotFoundError": | ||
| 122 | return { reason: "not-found" }; | ||
| 123 | default: | ||
| 124 | return { reason: "failed" }; | ||
| 117 | } | 125 | } |
| 118 | if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" }; | ||
| 119 | return { reason: "failed" }; | ||
| 120 | } | 126 | } |
| 121 | 127 | ||
| 122 | /** | 128 | /** |
| ... | @@ -196,7 +202,7 @@ export const make = Effect.gen(function* () { | ... | @@ -196,7 +202,7 @@ export const make = Effect.gen(function* () { |
| 196 | provider: "github", | 202 | provider: "github", |
| 197 | operation, | 203 | operation, |
| 198 | ...gitHubProviderFailure(error), | 204 | ...gitHubProviderFailure(error), |
| 199 | detail: error.detail, | 205 | detail: error.message, |
| 200 | cause: error, | 206 | cause: error, |
| 201 | }); | 207 | }); |
| 202 | 208 | ||
| ... | @@ -224,7 +230,7 @@ export const make = Effect.gen(function* () { | ... | @@ -224,7 +230,7 @@ export const make = Effect.gen(function* () { |
| 224 | .pipe( | 230 | .pipe( |
| 225 | Effect.matchEffect({ | 231 | Effect.matchEffect({ |
| 226 | onFailure: (error) => | 232 | onFailure: (error) => |
| 227 | error._tag === "GitHubCliRateLimitError" || | 233 | error._tag === "GitHubApiRateLimitError" || |
| 228 | error._tag === "SourceControlRateLimitPausedError" | 234 | error._tag === "SourceControlRateLimitPausedError" |
| 229 | ? Effect.fail(error) | 235 | ? Effect.fail(error) |
| 230 | : Effect.succeed({ | 236 | : Effect.succeed({ |
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | 3 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 5 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 6 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | 6 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 7 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 7 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 8 | import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; | 8 | import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; |
| ... | @@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure"); | ... | @@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure"); |
| 15 | it("classifies rate limits from every pull-request provider", () => { | 15 | it("classifies rate limits from every pull-request provider", () => { |
| 16 | assert.deepStrictEqual( | 16 | assert.deepStrictEqual( |
| 17 | gitHubProviderFailure( | 17 | gitHubProviderFailure( |
| 18 | new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }), | 18 | new GitHubApi.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }), |
| 19 | ), | 19 | ), |
| 20 | { reason: "rate-limited" }, | 20 | { reason: "rate-limited" }, |
| 21 | ); | 21 | ); |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+11-3| ... | @@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; | ... | @@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | ||
| 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 8 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 8 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 9 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | ||
| 9 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | 10 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 11 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | ||
| 10 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 12 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 11 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; | 13 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; |
| 12 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | 14 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; |
| ... | @@ -60,8 +62,14 @@ export const make = Effect.map( | ... | @@ -60,8 +62,14 @@ export const make = Effect.map( |
| 60 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( | 62 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 61 | Layer.provide( | 63 | Layer.provide( |
| 62 | GitHubPullRequestCli.layer.pipe( | 64 | GitHubPullRequestCli.layer.pipe( |
| 63 | Layer.provide(GitHubCli.layer), | 65 | Layer.provide( |
| 64 | Layer.provide(GitHubGraphQlBudget.layer), | 66 | GitHubApi.layer.pipe( |
| 67 | Layer.provide(GitHubCredentials.layer), | ||
| 68 | // The same layers GitHubCli merges, so both share one budget and one pause per host. | ||
| 69 | Layer.provide(GitHubGraphQlBudget.layer), | ||
| 70 | Layer.provide(SourceControlRateLimit.layer), | ||
| 71 | ), | ||
| 72 | ), | ||
| 65 | ), | 73 | ), |
| 66 | ), | 74 | ), |
| 67 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), | 75 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), |
apps/server/src/pullRequest/gitHubConditionalChecks.test.ts+72-46| ... | @@ -4,11 +4,10 @@ import * as Effect from "effect/Effect"; | ... | @@ -4,11 +4,10 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Redacted from "effect/Redacted"; | 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import { ChildProcessSpawner } from "effect/process"; | ||
| 8 | import type { PullRequestCheck } from "@t3tools/contracts"; | 7 | import type { PullRequestCheck } from "@t3tools/contracts"; |
| 9 | 8 | ||
| 10 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 11 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | 10 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 12 | 11 | ||
| 13 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 12 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 14 | 13 | ||
| ... | @@ -27,31 +26,46 @@ it.effect( | ... | @@ -27,31 +26,46 @@ it.effect( |
| 27 | let changed = ""; | 26 | let changed = ""; |
| 28 | let reads = 0; | 27 | let reads = 0; |
| 29 | const requests: string[] = []; | 28 | const requests: string[] = []; |
| 29 | const known: Array<unknown> = []; | ||
| 30 | const revalidate = yield* makeChecksRevalidator.pipe( | 30 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 31 | Effect.provide( | 31 | Effect.provide( |
| 32 | Layer.mock(GitHubCli.GitHubCli)({ | 32 | Layer.mock(GitHubApi.GitHubApi)({ |
| 33 | execute: ({ args }) => | 33 | rest: ({ path, ifNoneMatch }) => |
| 34 | Effect.sync(() => { | 34 | Effect.sync(() => { |
| 35 | const endpoint = args[1]!; | 35 | requests.push(path); |
| 36 | requests.push(endpoint); | 36 | const head = path.endsWith("/pulls/1"); |
| 37 | const head = endpoint.endsWith("/pulls/1"); | ||
| 38 | const modified = | 37 | const modified = |
| 39 | !args.includes("-H") || (endpoint.includes(changed) && changed !== ""); | 38 | ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); |
| 40 | const next = endpoint.includes("check-runs") && endpoint.endsWith("page=1"); | 39 | const next = path.includes("check-runs") && path.endsWith("page=1"); |
| 40 | const runs = path.includes("/actions/runs"); | ||
| 41 | return { | 41 | return { |
| 42 | exitCode: ChildProcessSpawner.ExitCode(modified ? 0 : 1), | 42 | status: modified ? 200 : 304, |
| 43 | stdout: modified | 43 | headers: modified |
| 44 | ? `HTTP/2.0 200 OK\r\nEtag: "${sha}-${changed}"\r\n${next ? 'Link: <https://api.github.com/next>; rel="next"\r\n' : ""}\r\n${head ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 2 }) : ""}` | 44 | ? { |
| 45 | : "HTTP/2.0 304 Not Modified\r\n\r\n", | 45 | etag: `"${sha}-${changed}"`, |
| 46 | stderr: "", | 46 | ...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}), |
| 47 | stdoutTruncated: false, | 47 | } |
| 48 | stderrTruncated: false, | 48 | : {}, |
| 49 | body: !modified | ||
| 50 | ? "" | ||
| 51 | : head | ||
| 52 | ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } }) | ||
| 53 | : runs | ||
| 54 | ? encodeJson({ | ||
| 55 | workflow_runs: [ | ||
| 56 | { id: 9, status: "completed", conclusion: "action_required" }, | ||
| 57 | ], | ||
| 58 | }) | ||
| 59 | : "{}", | ||
| 60 | truncated: false, | ||
| 61 | invalidUtf8: false, | ||
| 49 | }; | 62 | }; |
| 50 | }), | 63 | }), |
| 51 | }), | 64 | }), |
| 52 | ), | 65 | ), |
| 53 | ); | 66 | ); |
| 54 | const read = Effect.sync(() => { | 67 | const read = Effect.gen(function* () { |
| 68 | known.push(yield* KnownWorkflowRuns); | ||
| 55 | reads++; | 69 | reads++; |
| 56 | return { | 70 | return { |
| 57 | state: "open" as const, | 71 | state: "open" as const, |
| ... | @@ -64,10 +78,15 @@ it.effect( | ... | @@ -64,10 +78,15 @@ it.effect( |
| 64 | }); | 78 | }); |
| 65 | const poll = (identity = credential) => | 79 | const poll = (identity = credential) => |
| 66 | revalidate(reference, read).pipe( | 80 | revalidate(reference, read).pipe( |
| 67 | Effect.provideService(GitHubCli.PinnedGitHubCredential, identity), | 81 | Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), |
| 68 | ); | 82 | ); |
| 69 | yield* poll(); | 83 | yield* poll(); |
| 70 | expect(reads).toBe(1); | 84 | expect(reads).toBe(1); |
| 85 | // The fork's runs were just confirmed, so the read is handed them instead of listing them. | ||
| 86 | expect(known[0]).toEqual({ | ||
| 87 | headSha: sha, | ||
| 88 | runs: [{ id: 9, status: "completed", conclusion: "action_required" }], | ||
| 89 | }); | ||
| 71 | requests.length = 0; | 90 | requests.length = 0; |
| 72 | yield* poll(); | 91 | yield* poll(); |
| 73 | expect(reads).toBe(1); | 92 | expect(reads).toBe(1); |
| ... | @@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou | ... | @@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou |
| 113 | let reads = 0; | 132 | let reads = 0; |
| 114 | const revalidate = yield* makeChecksRevalidator.pipe( | 133 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 115 | Effect.provide( | 134 | Effect.provide( |
| 116 | Layer.mock(GitHubCli.GitHubCli)({ | 135 | Layer.mock(GitHubApi.GitHubApi)({ |
| 117 | execute: ({ args }) => | 136 | rest: ({ path, ifNoneMatch }) => |
| 118 | unavailable | 137 | unavailable |
| 119 | ? Effect.fail( | 138 | ? Effect.fail( |
| 120 | new GitHubCli.GitHubCliCommandError({ | 139 | new GitHubApi.GitHubApiResponseError({ |
| 121 | command: "gh", | 140 | host: "github.com", |
| 122 | cwd: "/repo", | 141 | operation: "revalidateChecks", |
| 123 | cause: undefined, | 142 | status: 502, |
| 124 | httpStatus: 502, | ||
| 125 | }), | 143 | }), |
| 126 | ) | 144 | ) |
| 127 | : Effect.succeed({ | 145 | : Effect.succeed( |
| 128 | exitCode: ChildProcessSpawner.ExitCode(0), | 146 | ifNoneMatch !== undefined && etags |
| 129 | stdout: | 147 | ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } |
| 130 | args.includes("-H") && etags | 148 | : { |
| 131 | ? "HTTP/2.0 304 Not Modified\n\n" | 149 | status: 200, |
| 132 | : `HTTP/2.0 200 OK\n${etags ? 'Etag: "one"\n' : ""}\n${args[1]!.endsWith("/pulls/1") ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 1 }) : ""}`, | 150 | headers: etags ? { etag: '"one"' } : {}, |
| 133 | stderr: "", | 151 | body: path.endsWith("/pulls/1") |
| 134 | stdoutTruncated: false, | 152 | ? encodeJson({ |
| 135 | stderrTruncated: false, | 153 | head: { sha, repo: { id: 1 } }, |
| 136 | }), | 154 | base: { repo: { id: 1 } }, |
| 155 | }) | ||
| 156 | : "{}", | ||
| 157 | truncated: false, | ||
| 158 | invalidUtf8: false, | ||
| 159 | }, | ||
| 160 | ), | ||
| 137 | }), | 161 | }), |
| 138 | ), | 162 | ), |
| 139 | ); | 163 | ); |
| ... | @@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou | ... | @@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou |
| 141 | reads++; | 165 | reads++; |
| 142 | return fail | 166 | return fail |
| 143 | ? Effect.fail( | 167 | ? Effect.fail( |
| 144 | new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: "/repo", cause: undefined }), | 168 | new GitHubApi.GitHubApiResponseError({ |
| 169 | host: "github.com", | ||
| 170 | operation: "read", | ||
| 171 | status: 500, | ||
| 172 | }), | ||
| 145 | ) | 173 | ) |
| 146 | : Effect.succeed({ | 174 | : Effect.succeed({ |
| 147 | state: "open" as const, | 175 | state: "open" as const, |
| ... | @@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou | ... | @@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou |
| 152 | }); | 180 | }); |
| 153 | const poll = () => | 181 | const poll = () => |
| 154 | revalidate(reference, read).pipe( | 182 | revalidate(reference, read).pipe( |
| 155 | Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), | 183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), |
| 156 | ); | 184 | ); |
| 157 | yield* poll().pipe(Effect.flip); | 185 | yield* poll().pipe(Effect.flip); |
| 158 | fail = false; | 186 | fail = false; |
| ... | @@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor | ... | @@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor |
| 190 | let reads = 0; | 218 | let reads = 0; |
| 191 | const revalidate = yield* makeChecksRevalidator.pipe( | 219 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 192 | Effect.provide( | 220 | Effect.provide( |
| 193 | Layer.mock(GitHubCli.GitHubCli)({ | 221 | Layer.mock(GitHubApi.GitHubApi)({ |
| 194 | execute: () => { | 222 | rest: () => { |
| 195 | probes++; | 223 | probes++; |
| 196 | return Effect.fail( | 224 | return Effect.fail( |
| 197 | new GitHubCli.GitHubCliCommandError({ | 225 | new GitHubApi.GitHubApiNotFoundError({ |
| 198 | command: "gh", | 226 | host: "github.com", |
| 199 | cwd: "/repo", | 227 | operation: "revalidateChecks", |
| 200 | cause: undefined, | ||
| 201 | httpStatus: 404, | ||
| 202 | }), | 228 | }), |
| 203 | ); | 229 | ); |
| 204 | }, | 230 | }, |
| ... | @@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor | ... | @@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor |
| 211 | }); | 237 | }); |
| 212 | for (let tick = 0; tick < 2; tick++) | 238 | for (let tick = 0; tick < 2; tick++) |
| 213 | yield* revalidate(reference, read).pipe( | 239 | yield* revalidate(reference, read).pipe( |
| 214 | Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), | 240 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), |
| 215 | ); | 241 | ); |
| 216 | expect(probes).toBe(1); | 242 | expect(probes).toBe(1); |
| 217 | expect(reads).toBe(2); | 243 | expect(reads).toBe(2); |
apps/server/src/pullRequest/gitHubConditionalChecks.ts+95-54| ... | @@ -1,32 +1,63 @@ | ... | @@ -1,32 +1,63 @@ |
| 1 | import * as Clock from "effect/Clock"; | 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Cache from "effect/Cache"; | 2 | import * as Cache from "effect/Cache"; |
| 3 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 5 | import * as Semaphore from "effect/Semaphore"; | 7 | import * as Semaphore from "effect/Semaphore"; |
| 6 | import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; | 8 | import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; |
| 7 | 9 | ||
| 8 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 9 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; | 11 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; |
| 10 | import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; | 12 | import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; |
| 11 | import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; | 13 | import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; |
| 12 | 14 | ||
| 13 | const decodeHeadSchema = Schema.Struct({ | 15 | const HeadSchema = Schema.Struct({ |
| 14 | head: Schema.Struct({ sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)) }), | 16 | head: Schema.Struct({ |
| 17 | sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)), | ||
| 18 | // Null once the fork a pull request came from has been deleted. | ||
| 19 | repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })), | ||
| 20 | }), | ||
| 15 | base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), | 21 | base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), |
| 16 | headRepositoryId: Schema.NullOr(PositiveInt), | ||
| 17 | }); | 22 | }); |
| 18 | const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(decodeHeadSchema)); | 23 | const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); |
| 19 | 24 | ||
| 20 | type Validator = { etag: string | undefined; next: boolean }; | 25 | const WorkflowRunSchema = Schema.Struct({ |
| 26 | id: Schema.Int, | ||
| 27 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 28 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 29 | status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 30 | conclusion: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 31 | head_branch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 32 | }); | ||
| 33 | const decodeWorkflowRuns = Schema.decodeUnknownOption( | ||
| 34 | Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })), | ||
| 35 | ); | ||
| 36 | |||
| 37 | export type KnownWorkflowRun = typeof WorkflowRunSchema.Type; | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against | ||
| 41 | * GitHub. A read running under it filters these rather than asking for the runs again. | ||
| 42 | */ | ||
| 43 | export const KnownWorkflowRuns = Context.Reference<{ | ||
| 44 | readonly headSha: string; | ||
| 45 | readonly runs: ReadonlyArray<KnownWorkflowRun>; | ||
| 46 | } | null>("t3/pullRequest/KnownWorkflowRuns", { defaultValue: () => null }); | ||
| 47 | |||
| 48 | type Validator = { etag: string | undefined; next: boolean; body: string }; | ||
| 49 | |||
| 50 | /** A host that answers these with one of them has no conditional REST for it at all. */ | ||
| 51 | const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); | ||
| 21 | 52 | ||
| 22 | export const makeChecksRevalidator = Effect.gen(function* () { | 53 | export const makeChecksRevalidator = Effect.gen(function* () { |
| 23 | const github = yield* GitHubCli.GitHubCli; | 54 | const api = yield* GitHubApi.GitHubApi; |
| 24 | const entries = yield* Cache.makeWith( | 55 | const entries = yield* Cache.makeWith( |
| 25 | (_key: string) => | 56 | (_key: string) => |
| 26 | Effect.sync(() => ({ | 57 | Effect.sync(() => ({ |
| 27 | gate: Semaphore.makeUnsafe(1), | 58 | gate: Semaphore.makeUnsafe(1), |
| 28 | validators: new Map<string, Validator>(), | 59 | validators: new Map<string, Validator>(), |
| 29 | head: null as Schema.Schema.Type<typeof decodeHeadSchema> | null, | 60 | head: null as typeof HeadSchema.Type | null, |
| 30 | value: null as PullRequestChecks | null, | 61 | value: null as PullRequestChecks | null, |
| 31 | readAt: 0, | 62 | readAt: 0, |
| 32 | supported: true, | 63 | supported: true, |
| ... | @@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () { | ... | @@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () { |
| 43 | >, | 74 | >, |
| 44 | ) => | 75 | ) => |
| 45 | Effect.gen(function* () { | 76 | Effect.gen(function* () { |
| 46 | const credential = yield* GitHubCli.PinnedGitHubCredential; | 77 | const credential = yield* GitHubApi.PinnedGitHubCredential; |
| 47 | if (credential === null) return yield* read; | 78 | if (credential === null) return yield* read; |
| 48 | const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; | 79 | const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; |
| 49 | const entry = yield* Cache.get(entries, key); | 80 | const entry = yield* Cache.get(entries, key); |
| 50 | return yield* entry.gate.withPermit( | 81 | return yield* entry.gate.withPermit( |
| 51 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
| 52 | if (!entry.supported) return yield* read; | 83 | if (!entry.supported) return yield* read; |
| 53 | const fail = () => | 84 | const fail = (status: number) => |
| 54 | new GitHubCli.GitHubCliCommandError({ | 85 | new GitHubApi.GitHubApiResponseError({ |
| 55 | command: "gh", | 86 | host: input.host, |
| 56 | cwd: input.cwd, | 87 | operation: "revalidateChecks", |
| 57 | cause: new Error("GitHub returned an invalid conditional checks response."), | 88 | status, |
| 58 | }); | 89 | }); |
| 90 | /** Whether the endpoint has a next page, or null where the host cannot say. */ | ||
| 59 | const get = (endpoint: string, head = false) => | 91 | const get = (endpoint: string, head = false) => |
| 60 | Effect.gen(function* () { | 92 | Effect.gen(function* () { |
| 61 | const previous = entry.validators.get(endpoint); | 93 | const previous = entry.validators.get(endpoint); |
| 62 | const result = yield* github | 94 | const response = yield* api |
| 63 | .execute({ | 95 | .rest({ |
| 64 | cwd: input.cwd, | 96 | host: input.host, |
| 65 | args: [ | 97 | operation: "revalidateChecks", |
| 66 | "api", | 98 | path: endpoint, |
| 67 | endpoint, | 99 | ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), |
| 68 | "--hostname", | ||
| 69 | input.host, | ||
| 70 | "--include", | ||
| 71 | ...(head | ||
| 72 | ? [ | ||
| 73 | "--jq", | ||
| 74 | "{head:{sha:.head.sha},base:{repo:{id:.base.repo.id}},headRepositoryId:.head.repo.id}", | ||
| 75 | ] | ||
| 76 | : ["--silent"]), | ||
| 77 | ...(previous?.etag ? ["-H", `If-None-Match: ${previous.etag}`] : []), | ||
| 78 | ], | ||
| 79 | acceptNotModified: true, | ||
| 80 | }) | 100 | }) |
| 81 | .pipe( | 101 | .pipe( |
| 82 | Effect.catchTags({ | 102 | Effect.catchTags({ |
| 83 | GitHubCliCommandError: (error) => | 103 | GitHubApiNotFoundError: () => |
| 84 | Effect.sync(() => { | 104 | Effect.sync(() => { |
| 85 | entry.supported = ![404, 405, 501].includes(error.httpStatus ?? 0); | 105 | entry.supported = false; |
| 106 | return null; | ||
| 107 | }), | ||
| 108 | GitHubApiResponseError: (error) => | ||
| 109 | Effect.sync(() => { | ||
| 110 | entry.supported = !UNSUPPORTED_STATUSES.has(error.status); | ||
| 86 | return null; | 111 | return null; |
| 87 | }), | 112 | }), |
| 88 | }), | 113 | }), |
| 89 | ); | 114 | ); |
| 90 | if (result === null) { | 115 | if (response === null) { |
| 91 | entry.value = null; | 116 | entry.value = null; |
| 92 | return null; | 117 | return null; |
| 93 | } | 118 | } |
| 94 | if (result.stdoutTruncated || result.stdoutInvalidUtf8) return yield* fail(); | 119 | if (response.status === 304 && previous) return previous.next; |
| 95 | const split = result.stdout.search(/\r?\n\r?\n/); | 120 | if (response.status !== 200 || response.truncated) { |
| 96 | const headers = split < 0 ? result.stdout : result.stdout.slice(0, split); | 121 | return yield* fail(response.status); |
| 97 | const status = /^HTTP\/\S+ (\d+)/.exec(headers)?.[1]; | 122 | } |
| 98 | if (status === "304" && previous) return previous.next; | ||
| 99 | if (status !== "200") return yield* fail(); | ||
| 100 | entry.value = null; | 123 | entry.value = null; |
| 101 | if (head) { | 124 | if (head) { |
| 102 | const decoded = yield* decodeHead(result.stdout.slice(split).trim()).pipe( | 125 | const decoded = yield* decodeHead(response.body).pipe( |
| 103 | Effect.mapError(fail), | 126 | Effect.mapError(() => fail(response.status)), |
| 104 | ); | 127 | ); |
| 105 | if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); | 128 | if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); |
| 106 | entry.head = decoded; | 129 | entry.head = decoded; |
| 107 | } | 130 | } |
| 108 | const validator = { | 131 | const validator = { |
| 109 | etag: /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim(), | 132 | etag: response.headers["etag"]?.trim(), |
| 110 | next: /^link:.*rel="next"/im.test(headers), | 133 | next: /rel="next"/.test(response.headers["link"] ?? ""), |
| 134 | body: response.body, | ||
| 111 | }; | 135 | }; |
| 112 | entry.validators.set(endpoint, validator); | 136 | entry.validators.set(endpoint, validator); |
| 113 | return validator.next; | 137 | return validator.next; |
| ... | @@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () { | ... | @@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () { |
| 115 | const root = `repos/${input.repository}`; | 139 | const root = `repos/${input.repository}`; |
| 116 | if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; | 140 | if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; |
| 117 | const head = entry.head; | 141 | const head = entry.head; |
| 118 | if (head === null) return yield* fail(); | 142 | if (head === null) return yield* fail(200); |
| 143 | const runsEndpoint = | ||
| 144 | head.head.repo?.id !== head.base.repo.id | ||
| 145 | ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request` | ||
| 146 | : null; | ||
| 119 | const endpoints = [ | 147 | const endpoints = [ |
| 120 | `${root}/commits/${head.head.sha}/check-runs?filter=all`, | 148 | `${root}/commits/${head.head.sha}/check-runs?filter=all`, |
| 121 | `${root}/commits/${head.head.sha}/status`, | 149 | `${root}/commits/${head.head.sha}/status`, |
| 122 | ...(head.headRepositoryId !== head.base.repo.id | 150 | ...(runsEndpoint === null ? [] : [runsEndpoint]), |
| 123 | ? [`${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`] | ||
| 124 | : []), | ||
| 125 | ]; | 151 | ]; |
| 152 | const runPages: Array<string> = []; | ||
| 126 | for (const endpoint of endpoints) { | 153 | for (const endpoint of endpoints) { |
| 127 | for (let page = 1; ; page++) { | 154 | for (let page = 1; ; page++) { |
| 128 | if (page > 100) return yield* fail(); | 155 | if (page > 100) return yield* fail(200); |
| 129 | const next = yield* get( | 156 | const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; |
| 130 | `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`, | 157 | const next = yield* get(paged); |
| 131 | ); | ||
| 132 | if (next === null) return yield* read; | 158 | if (next === null) return yield* read; |
| 159 | if (endpoint === runsEndpoint) runPages.push(paged); | ||
| 133 | if (!next) break; | 160 | if (!next) break; |
| 134 | } | 161 | } |
| 135 | } | 162 | } |
| 136 | const now = yield* Clock.currentTimeMillis; | 163 | const now = yield* Clock.currentTimeMillis; |
| 137 | if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; | 164 | if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; |
| 138 | const fresh = yield* read; | 165 | // Every page of the head's runs was just confirmed current, so the read filters them |
| 166 | // rather than listing the runs a second time. | ||
| 167 | const pages = runPages.map((endpoint) => | ||
| 168 | decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), | ||
| 169 | ); | ||
| 170 | const known = | ||
| 171 | runsEndpoint !== null && pages.every(Option.isSome) | ||
| 172 | ? { | ||
| 173 | headSha: head.head.sha, | ||
| 174 | runs: pages.flatMap((page) => | ||
| 175 | Option.isSome(page) ? page.value.workflow_runs : [], | ||
| 176 | ), | ||
| 177 | } | ||
| 178 | : null; | ||
| 179 | const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); | ||
| 139 | const value = { | 180 | const value = { |
| 140 | state: fresh.state, | 181 | state: fresh.state, |
| 141 | checks: fresh.checks, | 182 | checks: fresh.checks, |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+145-114| ... | @@ -6,10 +6,10 @@ import { | ... | @@ -6,10 +6,10 @@ import { |
| 6 | buildPullRequestWatchFingerprintsGraphQlQuery, | 6 | buildPullRequestWatchFingerprintsGraphQlQuery, |
| 7 | decodePullRequestSummariesJson, | 7 | decodePullRequestSummariesJson, |
| 8 | decodePullRequestWatchFingerprintsJson, | 8 | decodePullRequestWatchFingerprintsJson, |
| 9 | buildReviewSubmissionJson, | 9 | buildReviewSubmission, |
| 10 | buildPullRequestStackMembershipsGraphQlQuery, | 10 | buildPullRequestStackMembershipsGraphQlQuery, |
| 11 | decodePullRequestStackMembershipsJson, | 11 | decodePullRequestStackMembershipsJson, |
| 12 | buildReviewerRequestJson, | 12 | buildReviewerRequest, |
| 13 | buildSetFilesViewedGraphQlMutation, | 13 | buildSetFilesViewedGraphQlMutation, |
| 14 | decodeBaseComparisonJson, | 14 | decodeBaseComparisonJson, |
| 15 | decodePullRequestActivityJson, | 15 | decodePullRequestActivityJson, |
| ... | @@ -25,26 +25,50 @@ import { | ... | @@ -25,26 +25,50 @@ import { |
| 25 | decodeReviewThreadCommentsJson, | 25 | decodeReviewThreadCommentsJson, |
| 26 | decodeReviewThreadsJson, | 26 | decodeReviewThreadsJson, |
| 27 | decodeViewerPermissionsJson, | 27 | decodeViewerPermissionsJson, |
| 28 | decodeWorkflowRunApprovalsJson, | 28 | decodeWorkflowRunsJson, |
| 29 | reviewThreadConversation, | 29 | reviewThreadConversation, |
| 30 | REVIEW_THREADS_GRAPHQL_QUERY, | 30 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 31 | pullRequestCoreGraphQlQuery, | 31 | pullRequestCoreGraphQlQuery, |
| 32 | pullRequestSearchGraphQlQuery, | 32 | pullRequestSearchGraphQlQuery, |
| 33 | } from "./gitHubPullRequestJson.ts"; | 33 | } from "./gitHubPullRequestJson.ts"; |
| 34 | 34 | ||
| 35 | /** | ||
| 36 | * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: | ||
| 37 | * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. | ||
| 38 | */ | ||
| 35 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | 39 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { |
| 36 | return JSON.stringify( | 40 | return JSON.stringify({ |
| 37 | entries.map((entry) => ({ | 41 | data: { |
| 38 | number: 1, | 42 | repository: { |
| 39 | title: "Add the pull requests page", | 43 | pullRequests: { |
| 40 | url: "https://github.com/pingdotgg/t3code/pull/1", | 44 | pageInfo: { hasNextPage: false }, |
| 41 | headRefName: "feat/page", | 45 | nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ |
| 42 | baseRefName: "main", | 46 | number: 1, |
| 43 | createdAt: "2026-07-01T00:00:00Z", | 47 | title: "Add the pull requests page", |
| 44 | updatedAt: "2026-07-02T00:00:00Z", | 48 | url: "https://github.com/pingdotgg/t3code/pull/1", |
| 45 | ...entry, | 49 | headRefName: "feat/page", |
| 46 | })), | 50 | baseRefName: "main", |
| 47 | ); | 51 | createdAt: "2026-07-01T00:00:00Z", |
| 52 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 53 | ...(reviewRequests === undefined | ||
| 54 | ? {} | ||
| 55 | : { | ||
| 56 | reviewRequests: { | ||
| 57 | nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({ | ||
| 58 | requestedReviewer, | ||
| 59 | })), | ||
| 60 | }, | ||
| 61 | }), | ||
| 62 | ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), | ||
| 63 | ...(checks === undefined | ||
| 64 | ? {} | ||
| 65 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | ||
| 66 | ...entry, | ||
| 67 | })), | ||
| 68 | }, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | }); | ||
| 48 | } | 72 | } |
| 49 | 73 | ||
| 50 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | 74 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { |
| ... | @@ -136,38 +160,15 @@ describe("pull request list decoding", () => { | ... | @@ -136,38 +160,15 @@ describe("pull request list decoding", () => { |
| 136 | ]); | 160 | ]); |
| 137 | }); | 161 | }); |
| 138 | 162 | ||
| 139 | it("rolls the head commit's checks up to the one word a row has space for", () => { | 163 | it("rolls the head commit's rollup up to the one word a row has space for", () => { |
| 140 | const batch = expectSuccess( | 164 | const batch = expectSuccess( |
| 141 | decodePullRequestListJson( | 165 | decodePullRequestListJson( |
| 142 | listJson([ | 166 | listJson([ |
| 143 | // A failure outranks a run still going, and a completed run has to be read through its | 167 | { checks: "FAILURE" }, |
| 144 | // conclusion rather than its status. | 168 | { checks: "PENDING" }, |
| 145 | { | 169 | { checks: "SUCCESS" }, |
| 146 | statusCheckRollup: [ | 170 | { checks: "ERROR" }, |
| 147 | { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, | 171 | { checks: "EXPECTED" }, |
| 148 | { name: "build", status: "IN_PROGRESS" }, | ||
| 149 | { name: "test", status: "COMPLETED", conclusion: "FAILURE" }, | ||
| 150 | ], | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | statusCheckRollup: [ | ||
| 154 | { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, | ||
| 155 | { name: "build", status: "QUEUED" }, | ||
| 156 | ], | ||
| 157 | }, | ||
| 158 | { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] }, | ||
| 159 | // A commit status reports one `state` and no `status` at all. | ||
| 160 | { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, | ||
| 161 | // Neither a pass, a failure nor a wait is no verdict rather than a green tick. | ||
| 162 | { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, | ||
| 163 | // Cancelled reads as failing here and in the detail header, so the two never flap. | ||
| 164 | { | ||
| 165 | statusCheckRollup: [ | ||
| 166 | { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, | ||
| 167 | { name: "test", status: "COMPLETED", conclusion: "CANCELLED" }, | ||
| 168 | ], | ||
| 169 | }, | ||
| 170 | { statusCheckRollup: [] }, | ||
| 171 | {}, | 172 | {}, |
| 172 | ]), | 173 | ]), |
| 173 | ), | 174 | ), |
| ... | @@ -177,16 +178,15 @@ describe("pull request list decoding", () => { | ... | @@ -177,16 +178,15 @@ describe("pull request list decoding", () => { |
| 177 | "pending", | 178 | "pending", |
| 178 | "passing", | 179 | "passing", |
| 179 | "failing", | 180 | "failing", |
| 180 | null, | 181 | "pending", |
| 181 | "failing", | ||
| 182 | null, | ||
| 183 | null, | 182 | null, |
| 184 | ]); | 183 | ]); |
| 185 | }); | 184 | }); |
| 186 | 185 | ||
| 187 | it("skips malformed entries but still counts them, so paging does not stop early", () => { | 186 | it("skips malformed entries but still counts them, so paging does not stop early", () => { |
| 188 | const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; | 187 | const batch = expectSuccess( |
| 189 | const batch = expectSuccess(decodePullRequestListJson(raw)); | 188 | decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), |
| 189 | ); | ||
| 190 | expect(batch.items).toHaveLength(1); | 190 | expect(batch.items).toHaveLength(1); |
| 191 | expect(batch.rawCount).toBe(2); | 191 | expect(batch.rawCount).toBe(2); |
| 192 | }); | 192 | }); |
| ... | @@ -279,6 +279,43 @@ describe("pull request detail decoding", () => { | ... | @@ -279,6 +279,43 @@ describe("pull request detail decoding", () => { |
| 279 | ], | 279 | ], |
| 280 | }); | 280 | }); |
| 281 | 281 | ||
| 282 | /** The same conversation as the GraphQL activity query answers it. */ | ||
| 283 | const activityJson = (raw: Record<string, unknown>) => | ||
| 284 | JSON.stringify({ | ||
| 285 | data: { | ||
| 286 | repository: { | ||
| 287 | pullRequest: { | ||
| 288 | comments: { nodes: raw["comments"] ?? [] }, | ||
| 289 | reviews: { nodes: raw["reviews"] ?? [] }, | ||
| 290 | commits: { | ||
| 291 | nodes: ((raw["commits"] ?? []) as ReadonlyArray<Record<string, unknown>>).map( | ||
| 292 | ({ authors, ...commit }) => ({ | ||
| 293 | commit: { | ||
| 294 | ...commit, | ||
| 295 | authors: { | ||
| 296 | nodes: ((authors ?? []) as ReadonlyArray<Record<string, unknown>>).map( | ||
| 297 | ({ login, ...author }) => ({ | ||
| 298 | ...author, | ||
| 299 | ...(login === undefined ? {} : { user: { login } }), | ||
| 300 | }), | ||
| 301 | ), | ||
| 302 | }, | ||
| 303 | }, | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | }, | ||
| 307 | }, | ||
| 308 | }, | ||
| 309 | }, | ||
| 310 | }); | ||
| 311 | const activity = (raw: Record<string, unknown>) => | ||
| 312 | Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ | ||
| 313 | comments: page.remarks.toSorted((left, right) => | ||
| 314 | left.createdAt.localeCompare(right.createdAt), | ||
| 315 | ), | ||
| 316 | commits: page.commits ?? [], | ||
| 317 | })); | ||
| 318 | |||
| 282 | it("maps check-run status and commit-status state onto one vocabulary", () => { | 319 | it("maps check-run status and commit-status state onto one vocabulary", () => { |
| 283 | const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); | 320 | const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); |
| 284 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ | 321 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ |
| ... | @@ -333,13 +370,15 @@ describe("pull request detail decoding", () => { | ... | @@ -333,13 +370,15 @@ describe("pull request detail decoding", () => { |
| 333 | it("decodes workflow runs that can be approved", () => { | 370 | it("decodes workflow runs that can be approved", () => { |
| 334 | expect( | 371 | expect( |
| 335 | expectSuccess( | 372 | expectSuccess( |
| 336 | decodeWorkflowRunApprovalsJson( | 373 | decodeWorkflowRunsJson( |
| 337 | JSON.stringify([ | 374 | JSON.stringify({ |
| 338 | { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" }, | 375 | workflow_runs: [ |
| 339 | { databaseId: 11, workflowName: null, url: null }, | 376 | { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, |
| 340 | ]), | 377 | { id: 11, name: null, html_url: null }, |
| 378 | ], | ||
| 379 | }), | ||
| 341 | ), | 380 | ), |
| 342 | ), | 381 | ).runs, |
| 343 | ).toEqual([ | 382 | ).toEqual([ |
| 344 | { id: 10, name: "contributor tests", url: "https://example.com/10" }, | 383 | { id: 10, name: "contributor tests", url: "https://example.com/10" }, |
| 345 | { id: 11, name: "Workflow run 11", url: null }, | 384 | { id: 11, name: "Workflow run 11", url: null }, |
| ... | @@ -398,14 +437,14 @@ describe("pull request detail decoding", () => { | ... | @@ -398,14 +437,14 @@ describe("pull request detail decoding", () => { |
| 398 | }); | 437 | }); |
| 399 | 438 | ||
| 400 | it("merges reviews with comments in time order and keeps a bodyless approval", () => { | 439 | it("merges reviews with comments in time order and keeps a bodyless approval", () => { |
| 401 | const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); | 440 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); |
| 402 | // r2 approved without writing anything, which is still the event worth seeing. | 441 | // r2 approved without writing anything, which is still the event worth seeing. |
| 403 | expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); | 442 | expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); |
| 404 | expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); | 443 | expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); |
| 405 | }); | 444 | }); |
| 406 | 445 | ||
| 407 | it("keeps every attributed commit author, including an unlinked signature", () => { | 446 | it("keeps every attributed commit author, including an unlinked signature", () => { |
| 408 | const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); | 447 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); |
| 409 | expect(detail.commits[0]?.authors).toEqual([ | 448 | expect(detail.commits[0]?.authors).toEqual([ |
| 410 | { login: "octocat", name: "Octo Cat", avatarUrl: null }, | 449 | { login: "octocat", name: "Octo Cat", avatarUrl: null }, |
| 411 | { login: "Pair Author", name: "Pair Author", avatarUrl: null }, | 450 | { login: "Pair Author", name: "Pair Author", avatarUrl: null }, |
| ... | @@ -415,22 +454,20 @@ describe("pull request detail decoding", () => { | ... | @@ -415,22 +454,20 @@ describe("pull request detail decoding", () => { |
| 415 | it("drops the bodyless review GitHub opens to hold line comments", () => { | 454 | it("drops the bodyless review GitHub opens to hold line comments", () => { |
| 416 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | 455 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 417 | const detail = expectSuccess( | 456 | const detail = expectSuccess( |
| 418 | decodePullRequestActivityJson( | 457 | activity({ |
| 419 | JSON.stringify({ | 458 | ...raw, |
| 420 | ...raw, | 459 | reviews: [ |
| 421 | reviews: [ | 460 | // What a reviewer leaving inline comments produces: a container with a state but |
| 422 | // What a reviewer leaving inline comments produces: a container with a state but | 461 | // nothing to read. Its comments come from the review threads instead. |
| 423 | // nothing to read. Its comments come from the review threads instead. | 462 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, |
| 424 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, | 463 | { |
| 425 | { | 464 | id: "r5", |
| 426 | id: "r5", | 465 | body: "Looks good.", |
| 427 | body: "Looks good.", | 466 | state: "COMMENTED", |
| 428 | state: "COMMENTED", | 467 | submittedAt: "2026-07-08T00:00:00Z", |
| 429 | submittedAt: "2026-07-08T00:00:00Z", | 468 | }, |
| 430 | }, | 469 | ], |
| 431 | ], | 470 | }), |
| 432 | }), | ||
| 433 | ), | ||
| 434 | ); | 471 | ); |
| 435 | 472 | ||
| 436 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); | 473 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); |
| ... | @@ -441,12 +478,10 @@ describe("pull request detail decoding", () => { | ... | @@ -441,12 +478,10 @@ describe("pull request detail decoding", () => { |
| 441 | (state) => { | 478 | (state) => { |
| 442 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | 479 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 443 | const detail = expectSuccess( | 480 | const detail = expectSuccess( |
| 444 | decodePullRequestActivityJson( | 481 | activity({ |
| 445 | JSON.stringify({ | 482 | ...raw, |
| 446 | ...raw, | 483 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], |
| 447 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], | 484 | }), |
| 448 | }), | ||
| 449 | ), | ||
| 450 | ); | 485 | ); |
| 451 | 486 | ||
| 452 | expect(detail.comments.map((comment) => comment.id)).toContain("r6"); | 487 | expect(detail.comments.map((comment) => comment.id)).toContain("r6"); |
| ... | @@ -456,12 +491,10 @@ describe("pull request detail decoding", () => { | ... | @@ -456,12 +491,10 @@ describe("pull request detail decoding", () => { |
| 456 | it("drops a review that carries neither a body nor a state", () => { | 491 | it("drops a review that carries neither a body nor a state", () => { |
| 457 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | 492 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 458 | const detail = expectSuccess( | 493 | const detail = expectSuccess( |
| 459 | decodePullRequestActivityJson( | 494 | activity({ |
| 460 | JSON.stringify({ | 495 | ...raw, |
| 461 | ...raw, | 496 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], |
| 462 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], | 497 | }), |
| 463 | }), | ||
| 464 | ), | ||
| 465 | ); | 498 | ); |
| 466 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); | 499 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); |
| 467 | }); | 500 | }); |
| ... | @@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => { | ... | @@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => { |
| 1432 | describe("reviewer request payload", () => { | 1465 | describe("reviewer request payload", () => { |
| 1433 | it("sends people and teams in the two lists GitHub keeps them in", () => { | 1466 | it("sends people and teams in the two lists GitHub keeps them in", () => { |
| 1434 | expect( | 1467 | expect( |
| 1435 | JSON.parse( | 1468 | buildReviewerRequest([ |
| 1436 | buildReviewerRequestJson([ | 1469 | { id: "octocat", kind: "user" }, |
| 1437 | { id: "octocat", kind: "user" }, | 1470 | { id: "reviewers", kind: "team" }, |
| 1438 | { id: "reviewers", kind: "team" }, | 1471 | { id: "hubot", kind: "user" }, |
| 1439 | { id: "hubot", kind: "user" }, | 1472 | ]), |
| 1440 | ]), | ||
| 1441 | ), | ||
| 1442 | ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); | 1473 | ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); |
| 1443 | }); | 1474 | }); |
| 1444 | 1475 | ||
| 1445 | it("sends both lists even where one of them is empty, which is what GitHub reads", () => { | 1476 | it("sends both lists even where one of them is empty, which is what GitHub reads", () => { |
| 1446 | expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({ | 1477 | expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ |
| 1447 | reviewers: ["octocat"], | 1478 | reviewers: ["octocat"], |
| 1448 | team_reviewers: [], | 1479 | team_reviewers: [], |
| 1449 | }); | 1480 | }); |
| ... | @@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => { | ... | @@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => { |
| 1452 | 1483 | ||
| 1453 | describe("review submission payload", () => { | 1484 | describe("review submission payload", () => { |
| 1454 | it("sends the verdict, the summary and every line comment in one body", () => { | 1485 | it("sends the verdict, the summary and every line comment in one body", () => { |
| 1455 | const payload = JSON.parse( | 1486 | const payload = buildReviewSubmission({ |
| 1456 | buildReviewSubmissionJson({ | 1487 | verdict: "request-changes", |
| 1457 | verdict: "request-changes", | 1488 | body: "Two things.", |
| 1458 | body: "Two things.", | 1489 | comments: [ |
| 1459 | comments: [ | 1490 | { |
| 1460 | { | 1491 | path: "src/a.ts", |
| 1461 | path: "src/a.ts", | 1492 | position: { kind: "added", newLine: 12 }, |
| 1462 | position: { kind: "added", newLine: 12 }, | 1493 | body: "rename this", |
| 1463 | body: "rename this", | 1494 | }, |
| 1464 | }, | 1495 | { |
| 1465 | { | 1496 | path: "src/b.ts", |
| 1466 | path: "src/b.ts", | 1497 | position: { kind: "deleted", oldLine: 3 }, |
| 1467 | position: { kind: "deleted", oldLine: 3 }, | 1498 | body: "why remove?", |
| 1468 | body: "why remove?", | 1499 | }, |
| 1469 | }, | 1500 | ], |
| 1470 | ], | 1501 | }); |
| 1471 | }), | ||
| 1472 | ) as Record<string, unknown>; | ||
| 1473 | expect(payload).toEqual({ | 1502 | expect(payload).toEqual({ |
| 1474 | event: "REQUEST_CHANGES", | 1503 | event: "REQUEST_CHANGES", |
| 1475 | body: "Two things.", | 1504 | body: "Two things.", |
| ... | @@ -1481,9 +1510,11 @@ describe("review submission payload", () => { | ... | @@ -1481,9 +1510,11 @@ describe("review submission payload", () => { |
| 1481 | }); | 1510 | }); |
| 1482 | 1511 | ||
| 1483 | it("sends an approval with no words and no comments", () => { | 1512 | it("sends an approval with no words and no comments", () => { |
| 1484 | expect( | 1513 | expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ |
| 1485 | JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })), | 1514 | event: "APPROVE", |
| 1486 | ).toEqual({ event: "APPROVE", body: "", comments: [] }); | 1515 | body: "", |
| 1516 | comments: [], | ||
| 1517 | }); | ||
| 1487 | }); | 1518 | }); |
| 1488 | }); | 1519 | }); |
| 1489 | 1520 |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+485-174| ... | @@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({ | ... | @@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({ |
| 155 | latestReviews: Schema.optional( | 155 | latestReviews: Schema.optional( |
| 156 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), | 156 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), |
| 157 | ), | 157 | ), |
| 158 | /** Asked for by the per-repository listing, and left out of the cross-repository search. */ | ||
| 159 | additions: Schema.optional(Schema.Int), | ||
| 160 | deletions: Schema.optional(Schema.Int), | ||
| 158 | createdAt: Schema.String, | 161 | createdAt: Schema.String, |
| 159 | updatedAt: Schema.String, | 162 | updatedAt: Schema.String, |
| 160 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | 163 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| ... | @@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({ | ... | @@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({ |
| 167 | Schema.Array( | 170 | Schema.Array( |
| 168 | Schema.NullOr( | 171 | Schema.NullOr( |
| 169 | Schema.Struct({ | 172 | Schema.Struct({ |
| 170 | requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), | 173 | requestedReviewer: Schema.optional( |
| 174 | Schema.NullOr( | ||
| 175 | Schema.Struct({ | ||
| 176 | ...RawActorSchema.fields, | ||
| 177 | slug: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 178 | }), | ||
| 179 | ), | ||
| 180 | ), | ||
| 171 | }), | 181 | }), |
| 172 | ), | 182 | ), |
| 173 | ), | 183 | ), |
| ... | @@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({ | ... | @@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({ |
| 215 | ), | 225 | ), |
| 216 | }); | 226 | }); |
| 217 | 227 | ||
| 228 | const RawRowConnectionSchema = Schema.Struct({ | ||
| 229 | pageInfo: Schema.optional( | ||
| 230 | Schema.NullOr( | ||
| 231 | Schema.Struct({ | ||
| 232 | hasNextPage: Schema.Boolean, | ||
| 233 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 234 | }), | ||
| 235 | ), | ||
| 236 | ), | ||
| 237 | // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped | ||
| 238 | // rather than blanking every repository at once. | ||
| 239 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), | ||
| 240 | }); | ||
| 241 | |||
| 218 | const RawSearchSchema = Schema.Struct({ | 242 | const RawSearchSchema = Schema.Struct({ |
| 243 | data: Schema.Struct({ search: RawRowConnectionSchema }), | ||
| 244 | }); | ||
| 245 | |||
| 246 | const RawRepositoryPullRequestsSchema = Schema.Struct({ | ||
| 219 | data: Schema.Struct({ | 247 | data: Schema.Struct({ |
| 220 | search: Schema.Struct({ | 248 | repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), |
| 221 | pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), | ||
| 222 | // Row by row, like the listing's own: a node that is not a pull request — or one field | ||
| 223 | // GitHub changes — is skipped rather than blanking every repository at once. | ||
| 224 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), | ||
| 225 | }), | ||
| 226 | }), | 249 | }), |
| 227 | }); | 250 | }); |
| 228 | 251 | ||
| ... | @@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({ | ... | @@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({ |
| 414 | ), | 437 | ), |
| 415 | }); | 438 | }); |
| 416 | 439 | ||
| 417 | const RawWorkflowRunApprovalSchema = Schema.Struct({ | 440 | /** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ |
| 418 | databaseId: Schema.Int, | 441 | const RawWorkflowRunsSchema = Schema.Struct({ |
| 419 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), | 442 | workflow_runs: Schema.Array( |
| 420 | url: Schema.optional(Schema.NullOr(Schema.String)), | 443 | Schema.Struct({ |
| 444 | id: Schema.Int, | ||
| 445 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 446 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 447 | }), | ||
| 448 | ), | ||
| 421 | }); | 449 | }); |
| 422 | 450 | ||
| 423 | const RawPullRequestHeadSchema = Schema.Struct({ | 451 | const RawPullRequestHeadsSchema = Schema.Struct({ |
| 424 | number: Schema.Int, | 452 | data: Schema.Struct({ |
| 425 | headRefOid: Schema.String, | 453 | repository: Schema.NullOr( |
| 426 | isCrossRepository: Schema.optional(Schema.Boolean), | 454 | Schema.Struct({ |
| 427 | headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), | 455 | pullRequests: Schema.Struct({ |
| 456 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | ||
| 457 | nodes: Schema.Array( | ||
| 458 | Schema.Struct({ | ||
| 459 | number: Schema.Int, | ||
| 460 | headRefOid: Schema.String, | ||
| 461 | isCrossRepository: Schema.optional(Schema.Boolean), | ||
| 462 | headRepositoryOwner: Schema.optional( | ||
| 463 | Schema.NullOr(Schema.Struct({ login: Schema.String })), | ||
| 464 | ), | ||
| 465 | }), | ||
| 466 | ), | ||
| 467 | }), | ||
| 468 | }), | ||
| 469 | ), | ||
| 470 | }), | ||
| 428 | }); | 471 | }); |
| 429 | 472 | ||
| 473 | const RawActivityConnection = <S extends Schema.Top>(node: S) => | ||
| 474 | Schema.optional( | ||
| 475 | Schema.NullOr( | ||
| 476 | Schema.Struct({ | ||
| 477 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | ||
| 478 | nodes: Schema.Array(node), | ||
| 479 | }), | ||
| 480 | ), | ||
| 481 | ); | ||
| 482 | |||
| 430 | const RawActivitySchema = Schema.Struct({ | 483 | const RawActivitySchema = Schema.Struct({ |
| 431 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | 484 | data: Schema.Struct({ |
| 432 | comments: Schema.optional(Schema.Array(RawCommentSchema)), | 485 | repository: Schema.Struct({ |
| 433 | reviews: Schema.optional(Schema.Array(RawReviewSchema)), | 486 | pullRequest: Schema.Struct({ |
| 434 | commits: Schema.optional(Schema.Array(RawCommitSchema)), | 487 | author: Schema.optional(Schema.NullOr(RawActorSchema)), |
| 488 | comments: RawActivityConnection(RawCommentSchema), | ||
| 489 | reviews: RawActivityConnection(RawReviewSchema), | ||
| 490 | commits: Schema.optional( | ||
| 491 | Schema.NullOr( | ||
| 492 | Schema.Struct({ | ||
| 493 | nodes: Schema.Array( | ||
| 494 | Schema.Struct({ | ||
| 495 | commit: Schema.Struct({ | ||
| 496 | oid: Schema.String, | ||
| 497 | messageHeadline: Schema.optional(Schema.String), | ||
| 498 | committedDate: Schema.String, | ||
| 499 | authors: Schema.optional( | ||
| 500 | Schema.NullOr( | ||
| 501 | Schema.Struct({ | ||
| 502 | nodes: Schema.Array( | ||
| 503 | Schema.Struct({ | ||
| 504 | email: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 505 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 506 | user: Schema.optional( | ||
| 507 | Schema.NullOr( | ||
| 508 | Schema.Struct({ | ||
| 509 | login: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 510 | }), | ||
| 511 | ), | ||
| 512 | ), | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | }), | ||
| 516 | ), | ||
| 517 | ), | ||
| 518 | }), | ||
| 519 | }), | ||
| 520 | ), | ||
| 521 | }), | ||
| 522 | ), | ||
| 523 | ), | ||
| 524 | }), | ||
| 525 | }), | ||
| 526 | }), | ||
| 435 | }); | 527 | }); |
| 436 | 528 | ||
| 437 | /** Where a connection carries on from, which is what every paged read below follows. */ | 529 | /** Where a connection carries on from, which is what every paged read below follows. */ |
| 438 | const RawPageInfoSchema = Schema.Struct({ | 530 | function RawPageInfoSchemaFields() { |
| 439 | hasNextPage: Schema.optional(Schema.Boolean), | 531 | return Schema.Struct({ |
| 440 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | 532 | hasNextPage: Schema.optional(Schema.Boolean), |
| 441 | }); | 533 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), |
| 534 | }); | ||
| 535 | } | ||
| 536 | const RawPageInfoSchema = RawPageInfoSchemaFields(); | ||
| 442 | 537 | ||
| 443 | /** | 538 | /** |
| 444 | * What GitHub says the viewer may do with a pull request. Both are optional so that an install | 539 | * What GitHub says the viewer may do with a pull request. Both are optional so that an install |
| ... | @@ -707,19 +802,25 @@ export function decodeActorAvatarsJson( | ... | @@ -707,19 +802,25 @@ export function decodeActorAvatarsJson( |
| 707 | return Result.succeed(avatarsByLogin); | 802 | return Result.succeed(avatarsByLogin); |
| 708 | } | 803 | } |
| 709 | 804 | ||
| 710 | export const PULL_REQUEST_LIST_JSON_FIELDS = | ||
| 711 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup"; | ||
| 712 | |||
| 713 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; | ||
| 714 | |||
| 715 | /** | 805 | /** |
| 716 | * Pull refs let the comparison share the detail read without first resolving a fork branch. | 806 | * Pull refs let the comparison share the detail read without first resolving a fork branch. |
| 717 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and | 807 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and |
| 718 | * an unknown field fails the whole read. | 808 | * an unknown field fails the whole read. |
| 719 | */ | 809 | */ |
| 720 | export const pullRequestCoreGraphQlQuery = (host: string) => { | 810 | function checkContextNodesSelection(host: string): string { |
| 721 | const required = | 811 | const required = |
| 722 | host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; | 812 | host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; |
| 813 | return `nodes { | ||
| 814 | __typename | ||
| 815 | ... on StatusContext { context state targetUrl createdAt description${required} } | ||
| 816 | ... on CheckRun { | ||
| 817 | name status conclusion startedAt completedAt detailsUrl${required} | ||
| 818 | checkSuite { workflowRun { workflow { name } } } | ||
| 819 | } | ||
| 820 | }`; | ||
| 821 | } | ||
| 822 | |||
| 823 | export const pullRequestCoreGraphQlQuery = (host: string) => { | ||
| 723 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | 824 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { |
| 724 | repository(owner: $owner, name: $name) { | 825 | repository(owner: $owner, name: $name) { |
| 725 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | 826 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission |
| ... | @@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => { | ... | @@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => { |
| 738 | labels(first: 100) { nodes { name color } } | 839 | labels(first: 100) { nodes { name color } } |
| 739 | commits(last: 1) { | 840 | commits(last: 1) { |
| 740 | nodes { commit { statusCheckRollup { contexts(first: 100) { | 841 | nodes { commit { statusCheckRollup { contexts(first: 100) { |
| 741 | nodes { | 842 | ${checkContextNodesSelection(host)} |
| 742 | __typename | ||
| 743 | ... on StatusContext { context state targetUrl createdAt description${required} } | ||
| 744 | ... on CheckRun { | ||
| 745 | name status conclusion startedAt completedAt detailsUrl${required} | ||
| 746 | checkSuite { workflowRun { workflow { name } } } | ||
| 747 | } | ||
| 748 | } | ||
| 749 | pageInfo { hasNextPage } | 843 | pageInfo { hasNextPage } |
| 750 | } } } } | 844 | } } } } |
| 751 | } | 845 | } |
| ... | @@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson( | ... | @@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson( |
| 791 | })); | 885 | })); |
| 792 | } | 886 | } |
| 793 | 887 | ||
| 794 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; | 888 | /** |
| 889 | * The conversation a pull request carries outside its review threads: who opened it, its issue | ||
| 890 | * comments, its reviews and its newest commits. The two remark connections page independently, so | ||
| 891 | * each is switched on only while it has more to give; the first read asks for everything. | ||
| 892 | */ | ||
| 893 | export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { | ||
| 894 | repository(owner: $owner, name: $name) { | ||
| 895 | pullRequest(number: $number) { | ||
| 896 | author @include(if: $head) { __typename login avatarUrl ... on User { name } } | ||
| 897 | commits(last: 100) @include(if: $head) { | ||
| 898 | nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } | ||
| 899 | } | ||
| 900 | comments(first: 100, after: $commentsAfter) @include(if: $withComments) { | ||
| 901 | pageInfo { hasNextPage endCursor } | ||
| 902 | nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | ||
| 903 | } | ||
| 904 | reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { | ||
| 905 | pageInfo { hasNextPage endCursor } | ||
| 906 | nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | ||
| 907 | } | ||
| 908 | } | ||
| 909 | } | ||
| 910 | }`; | ||
| 795 | 911 | ||
| 796 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ | 912 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ |
| 797 | const GRAPHQL_PAGE_SIZE = 100; | 913 | const GRAPHQL_PAGE_SIZE = 100; |
| ... | @@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; | ... | @@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; |
| 818 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review | 934 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review |
| 819 | * is already past what a row can say. | 935 | * is already past what a row can say. |
| 820 | */ | 936 | */ |
| 821 | export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { | 937 | export function pullRequestSearchGraphQlQuery( |
| 822 | return `query($q: String!) { | 938 | rows: number, |
| 823 | search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { | 939 | includeStacks = false, |
| 824 | pageInfo { hasNextPage } | 940 | includeStats = false, |
| 941 | ): string { | ||
| 942 | return `query($q: String!, $after: String) { | ||
| 943 | search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { | ||
| 944 | pageInfo { hasNextPage endCursor } | ||
| 825 | nodes { | 945 | nodes { |
| 826 | ... on PullRequest { | 946 | ... on PullRequest { |
| 827 | ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | 947 | ${pullRequestRowSelection(includeStacks, includeStats)} |
| 948 | } | ||
| 949 | } | ||
| 950 | } | ||
| 951 | }`; | ||
| 952 | } | ||
| 953 | |||
| 954 | /** | ||
| 955 | * A repository's pull requests without search, newest created first: the order `gh pr list` lists | ||
| 956 | * in when it does not search, for a repository GitHub's search index does not cover. | ||
| 957 | */ | ||
| 958 | export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { | ||
| 959 | return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { | ||
| 960 | repository(owner: $owner, name: $name) { | ||
| 961 | pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { | ||
| 962 | pageInfo { hasNextPage endCursor } | ||
| 963 | nodes { ${pullRequestRowSelection(includeStacks, true)} } | ||
| 964 | } | ||
| 965 | } | ||
| 966 | }`; | ||
| 967 | } | ||
| 968 | |||
| 969 | function pageRows(rows: number): number { | ||
| 970 | return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); | ||
| 971 | } | ||
| 972 | |||
| 973 | /** One listing row, the same whether it came from a search or from a repository's own list. */ | ||
| 974 | function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { | ||
| 975 | return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | ||
| 828 | number | 976 | number |
| 829 | title | 977 | title |
| 830 | url | 978 | url |
| ... | @@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals | ... | @@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals |
| 836 | mergeable | 984 | mergeable |
| 837 | reviewDecision | 985 | reviewDecision |
| 838 | latestReviews(first: 20) { nodes { state author { login } } } | 986 | latestReviews(first: 20) { nodes { state author { login } } } |
| 987 | ${includeStats ? "additions deletions" : ""} | ||
| 839 | createdAt | 988 | createdAt |
| 840 | updatedAt | 989 | updatedAt |
| 841 | mergedAt | 990 | mergedAt |
| 842 | repository { nameWithOwner } | 991 | repository { nameWithOwner } |
| 843 | reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } | 992 | reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } |
| 844 | labels(first: 20) { nodes { name color } } | 993 | labels(first: 20) { nodes { name color } } |
| 845 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } | 994 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; |
| 846 | } | ||
| 847 | } | ||
| 848 | } | ||
| 849 | }`; | ||
| 850 | } | 995 | } |
| 851 | 996 | ||
| 852 | /** | 997 | /** |
| ... | @@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, | ... | @@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, |
| 1094 | } | 1239 | } |
| 1095 | }`; | 1240 | }`; |
| 1096 | 1241 | ||
| 1097 | /** | ||
| 1098 | * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document | ||
| 1099 | * rather than as `-f name=value` flags, so a reader's own words never reach argv. | ||
| 1100 | */ | ||
| 1101 | const GraphQlRequestSchema = Schema.Struct({ | ||
| 1102 | query: Schema.String, | ||
| 1103 | variables: Schema.Record(Schema.String, Schema.String), | ||
| 1104 | }); | ||
| 1105 | |||
| 1106 | const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema)); | ||
| 1107 | |||
| 1108 | export function encodeGraphQlRequestJson(input: { | ||
| 1109 | readonly query: string; | ||
| 1110 | readonly variables: Readonly<Record<string, string>>; | ||
| 1111 | }): string { | ||
| 1112 | return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); | ||
| 1113 | } | ||
| 1114 | |||
| 1115 | /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ | 1242 | /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ |
| 1116 | const ReviewSubmissionSchema = Schema.Struct({ | 1243 | const ReviewSubmissionSchema = Schema.Struct({ |
| 1117 | event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), | 1244 | event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), |
| ... | @@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({ | ... | @@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({ |
| 1126 | ), | 1253 | ), |
| 1127 | }); | 1254 | }); |
| 1128 | 1255 | ||
| 1129 | const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); | ||
| 1130 | |||
| 1131 | const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = { | 1256 | const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = { |
| 1132 | comment: "COMMENT", | 1257 | comment: "COMMENT", |
| 1133 | approve: "APPROVE", | 1258 | approve: "APPROVE", |
| ... | @@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): { | ... | @@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): { |
| 1166 | } | 1291 | } |
| 1167 | 1292 | ||
| 1168 | /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ | 1293 | /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ |
| 1169 | export function buildReviewSubmissionJson(input: { | 1294 | export function buildReviewSubmission(input: { |
| 1170 | readonly verdict: PullRequestReviewVerdict; | 1295 | readonly verdict: PullRequestReviewVerdict; |
| 1171 | readonly body: string; | 1296 | readonly body: string; |
| 1172 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | 1297 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; |
| 1173 | }): string { | 1298 | }): typeof ReviewSubmissionSchema.Type { |
| 1174 | return encodeReviewSubmission({ | 1299 | return { |
| 1175 | event: REVIEW_EVENTS[input.verdict], | 1300 | event: REVIEW_EVENTS[input.verdict], |
| 1176 | body: input.body, | 1301 | body: input.body, |
| 1177 | comments: input.comments.map((comment) => ({ | 1302 | comments: input.comments.map((comment) => ({ |
| ... | @@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: { | ... | @@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: { |
| 1179 | ...gitHubReviewPosition(comment.position), | 1304 | ...gitHubReviewPosition(comment.position), |
| 1180 | body: comment.body, | 1305 | body: comment.body, |
| 1181 | })), | 1306 | })), |
| 1182 | }); | 1307 | }; |
| 1183 | } | 1308 | } |
| 1184 | 1309 | ||
| 1185 | export interface GitHubPullRequestListItem { | 1310 | export interface GitHubPullRequestListItem { |
| ... | @@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe | ... | @@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe |
| 1644 | }; | 1769 | }; |
| 1645 | } | 1770 | } |
| 1646 | 1771 | ||
| 1647 | function toActivity(raw: Schema.Schema.Type<typeof RawActivitySchema>): GitHubPullRequestActivity { | ||
| 1648 | return { | ||
| 1649 | author: toActor(raw.author), | ||
| 1650 | comments: toComments(raw), | ||
| 1651 | commits: toCommits(raw.commits), | ||
| 1652 | }; | ||
| 1653 | } | ||
| 1654 | |||
| 1655 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | 1772 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); |
| 1656 | const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); | ||
| 1657 | const decodeSearch = decodeJsonResult(RawSearchSchema); | 1773 | const decodeSearch = decodeJsonResult(RawSearchSchema); |
| 1658 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); | 1774 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); |
| 1659 | const decodeStats = decodeJsonResult(RawStatsSchema); | 1775 | const decodeStats = decodeJsonResult(RawStatsSchema); |
| 1660 | const decodeDetail = decodeJsonResult(RawDetailSchema); | 1776 | const decodeDetail = decodeJsonResult(RawDetailSchema); |
| 1661 | const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema)); | 1777 | const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); |
| 1662 | const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); | 1778 | const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); |
| 1663 | const decodeActivity = decodeJsonResult(RawActivitySchema); | 1779 | const decodeActivity = decodeJsonResult(RawActivitySchema); |
| 1780 | const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); | ||
| 1664 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); | 1781 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); |
| 1665 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); | 1782 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); |
| 1666 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); | 1783 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); |
| ... | @@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>; | ... | @@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>; |
| 1669 | 1786 | ||
| 1670 | export interface GitHubPullRequestListBatch { | 1787 | export interface GitHubPullRequestListBatch { |
| 1671 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | 1788 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; |
| 1672 | /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */ | 1789 | /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ |
| 1673 | readonly rawCount: number; | 1790 | readonly rawCount: number; |
| 1791 | /** Where the next page starts, or null once GitHub has handed over every row. */ | ||
| 1792 | readonly endCursor: string | null; | ||
| 1674 | } | 1793 | } |
| 1675 | 1794 | ||
| 1676 | /** Malformed entries are skipped rather than failing the batch: one unexpected pull request | 1795 | /** |
| 1677 | * must not blank the whole list. */ | 1796 | * One page of a repository's own pull request list. Malformed rows are skipped rather than |
| 1797 | * failing the page: one unexpected pull request must not blank the whole list. A repository the | ||
| 1798 | * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. | ||
| 1799 | */ | ||
| 1678 | export function decodePullRequestListJson( | 1800 | export function decodePullRequestListJson( |
| 1679 | raw: string, | 1801 | raw: string, |
| 1680 | ): Result.Result<GitHubPullRequestListBatch, DecodeFailure> { | 1802 | ): Result.Result<GitHubPullRequestListBatch, DecodeFailure> { |
| 1681 | const decoded = decodeUnknownList(raw); | 1803 | const decoded = decodeRepositoryPullRequests(raw); |
| 1682 | if (!Result.isSuccess(decoded)) { | 1804 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 1683 | return Result.fail(decoded.failure); | 1805 | const connection = decoded.success.data.repository?.pullRequests; |
| 1684 | } | 1806 | const nodes = connection?.nodes ?? []; |
| 1685 | const items: GitHubPullRequestListItem[] = []; | 1807 | const items: GitHubPullRequestListItem[] = []; |
| 1686 | for (const entry of decoded.success) { | 1808 | for (const entry of nodes) { |
| 1687 | const item = decodeListEntry(entry); | 1809 | const row = toRow(entry); |
| 1688 | if (Exit.isSuccess(item)) { | 1810 | if (row !== null) items.push(row.item); |
| 1689 | items.push(toListItem(item.value)); | ||
| 1690 | } | ||
| 1691 | } | 1811 | } |
| 1692 | return Result.succeed({ items, rawCount: decoded.success.length }); | 1812 | return Result.succeed({ |
| 1813 | items, | ||
| 1814 | rawCount: nodes.length, | ||
| 1815 | endCursor: | ||
| 1816 | connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, | ||
| 1817 | }); | ||
| 1818 | } | ||
| 1819 | |||
| 1820 | /** | ||
| 1821 | * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as | ||
| 1822 | * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull | ||
| 1823 | * request, or one whose fields no longer decode. | ||
| 1824 | */ | ||
| 1825 | function toRow( | ||
| 1826 | entry: unknown, | ||
| 1827 | ): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { | ||
| 1828 | const decodedNode = decodeSearchItem(entry); | ||
| 1829 | if (!Exit.isSuccess(decodedNode)) return null; | ||
| 1830 | const node = decodedNode.value; | ||
| 1831 | const stack = toStackMembership(node); | ||
| 1832 | const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( | ||
| 1833 | (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { | ||
| 1834 | const reviewer = request?.requestedReviewer; | ||
| 1835 | const login = trimmed(reviewer?.login); | ||
| 1836 | if (login !== null) return [{ login }]; | ||
| 1837 | const slug = trimmed(reviewer?.slug); | ||
| 1838 | return slug === null ? [] : [{ slug }]; | ||
| 1839 | }, | ||
| 1840 | ); | ||
| 1841 | return { | ||
| 1842 | item: { | ||
| 1843 | ...toListItem({ | ||
| 1844 | ...node, | ||
| 1845 | latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => | ||
| 1846 | review === null ? [] : [review], | ||
| 1847 | ), | ||
| 1848 | reviewRequests, | ||
| 1849 | labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), | ||
| 1850 | // The rollup arrives as one enum. Dressed as a single check here so it is read the same | ||
| 1851 | // way the detail's checks are. | ||
| 1852 | statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { | ||
| 1853 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | ||
| 1854 | return state === null ? [] : [{ state }]; | ||
| 1855 | }), | ||
| 1856 | }), | ||
| 1857 | ...(stack === undefined ? {} : { stack }), | ||
| 1858 | }, | ||
| 1859 | repository: trimmed(node.repository?.nameWithOwner), | ||
| 1860 | }; | ||
| 1693 | } | 1861 | } |
| 1694 | 1862 | ||
| 1695 | export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { | 1863 | export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { |
| ... | @@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch { | ... | @@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch { |
| 1703 | readonly rawCount: number; | 1871 | readonly rawCount: number; |
| 1704 | /** More rows than this slice asked for, which is truncation for every repository in it. */ | 1872 | /** More rows than this slice asked for, which is truncation for every repository in it. */ |
| 1705 | readonly hasNextPage: boolean; | 1873 | readonly hasNextPage: boolean; |
| 1874 | /** Where the next page of the same search starts. */ | ||
| 1875 | readonly endCursor: string | null; | ||
| 1706 | } | 1876 | } |
| 1707 | 1877 | ||
| 1708 | /** | 1878 | /** |
| 1709 | * A search answers with the same pull request the listing does, one connection deeper: reviewers | 1879 | * A search answers with the same row a repository's list does, each naming its repository. |
| 1710 | * and labels arrive as connections, and the row names the repository it came from. Flattened to | ||
| 1711 | * the shape `gh pr list --json` hands over so both reads decode into one type. | ||
| 1712 | * | 1880 | * |
| 1713 | * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing | 1881 | * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing |
| 1714 | * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. | 1882 | * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. |
| ... | @@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson( | ... | @@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson( |
| 1720 | if (!Result.isSuccess(decoded)) { | 1888 | if (!Result.isSuccess(decoded)) { |
| 1721 | return Result.fail(decoded.failure); | 1889 | return Result.fail(decoded.failure); |
| 1722 | } | 1890 | } |
| 1723 | const nodes = decoded.success.data.search.nodes ?? []; | 1891 | const search = decoded.success.data.search; |
| 1892 | const nodes = search.nodes ?? []; | ||
| 1724 | const items: GitHubPullRequestSearchItem[] = []; | 1893 | const items: GitHubPullRequestSearchItem[] = []; |
| 1725 | for (const entry of nodes) { | 1894 | for (const entry of nodes) { |
| 1726 | const decodedNode = decodeSearchItem(entry); | 1895 | const row = toRow(entry); |
| 1727 | if (!Exit.isSuccess(decodedNode)) continue; | 1896 | if (row === null || row.repository === null) continue; |
| 1728 | const node = decodedNode.value; | 1897 | items.push({ ...row.item, repository: row.repository }); |
| 1729 | const repository = trimmed(node.repository?.nameWithOwner); | ||
| 1730 | if (repository === null) continue; | ||
| 1731 | const stack = toStackMembership(node); | ||
| 1732 | items.push({ | ||
| 1733 | ...toListItem({ | ||
| 1734 | ...node, | ||
| 1735 | latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => | ||
| 1736 | review === null ? [] : [review], | ||
| 1737 | ), | ||
| 1738 | reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { | ||
| 1739 | const login = trimmed(request?.requestedReviewer?.login); | ||
| 1740 | return login === null ? [] : [{ login }]; | ||
| 1741 | }), | ||
| 1742 | labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), | ||
| 1743 | // The search asks for the verdict rather than the checks behind it, so it arrives as one | ||
| 1744 | // enum. Dressed as a single check here so the rollup is read the same way on both paths. | ||
| 1745 | statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { | ||
| 1746 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | ||
| 1747 | return state === null ? [] : [{ state }]; | ||
| 1748 | }), | ||
| 1749 | }), | ||
| 1750 | ...(stack === undefined ? {} : { stack }), | ||
| 1751 | repository, | ||
| 1752 | }); | ||
| 1753 | } | 1898 | } |
| 1754 | return Result.succeed({ | 1899 | return Result.succeed({ |
| 1755 | items, | 1900 | items, |
| 1756 | rawCount: nodes.length, | 1901 | rawCount: nodes.length, |
| 1757 | hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false, | 1902 | hasNextPage: search.pageInfo?.hasNextPage ?? false, |
| 1903 | endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, | ||
| 1758 | }); | 1904 | }); |
| 1759 | } | 1905 | } |
| 1760 | 1906 | ||
| ... | @@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery( | ... | @@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery( |
| 1900 | return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; | 2046 | return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; |
| 1901 | } | 2047 | } |
| 1902 | 2048 | ||
| 2049 | /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ | ||
| 2050 | export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { | ||
| 2051 | return `query($owner: String!, $name: String!, $number: Int!) { | ||
| 2052 | s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } | ||
| 2053 | }`; | ||
| 2054 | } | ||
| 2055 | |||
| 1903 | const RawSummarySchema = Schema.Struct({ | 2056 | const RawSummarySchema = Schema.Struct({ |
| 1904 | ...RawSearchItemSchema.fields, | 2057 | ...RawSearchItemSchema.fields, |
| 1905 | changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), | 2058 | changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), |
| ... | @@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson( | ... | @@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson( |
| 2147 | requestedReviewer === null ? [] : [requestedReviewer], | 2300 | requestedReviewer === null ? [] : [requestedReviewer], |
| 2148 | ), | 2301 | ), |
| 2149 | labels: pr.labels.nodes, | 2302 | labels: pr.labels.nodes, |
| 2150 | statusCheckRollup: | 2303 | statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), |
| 2151 | contexts?.nodes.map((check) => ({ | ||
| 2152 | ...check, | ||
| 2153 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | ||
| 2154 | })) ?? [], | ||
| 2155 | }), | 2304 | }), |
| 2156 | viewerAccess: { | 2305 | viewerAccess: { |
| 2157 | canWrite: toCanWrite(repository.viewerPermission), | 2306 | canWrite: toCanWrite(repository.viewerPermission), |
| ... | @@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson( | ... | @@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson( |
| 2183 | : Result.fail(decoded.failure); | 2332 | : Result.fail(decoded.failure); |
| 2184 | } | 2333 | } |
| 2185 | 2334 | ||
| 2186 | export function decodeWorkflowRunApprovalsJson( | 2335 | export interface GitHubWorkflowRunPage { |
| 2336 | readonly runs: ReadonlyArray<GitHubWorkflowRunApproval>; | ||
| 2337 | /** Runs on the page, counted before decoding, which is what decides whether to page on. */ | ||
| 2338 | readonly rawCount: number; | ||
| 2339 | } | ||
| 2340 | |||
| 2341 | /** One page of `actions/runs`. */ | ||
| 2342 | export function decodeWorkflowRunsJson( | ||
| 2187 | raw: string, | 2343 | raw: string, |
| 2188 | ): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> { | 2344 | ): Result.Result<GitHubWorkflowRunPage, DecodeFailure> { |
| 2189 | const decoded = decodeWorkflowRunApprovals(raw); | 2345 | const decoded = decodeWorkflowRuns(raw); |
| 2190 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | 2346 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 2191 | return Result.succeed( | 2347 | return Result.succeed({ |
| 2192 | decoded.success.map((run) => ({ | 2348 | runs: decoded.success.workflow_runs.map((run) => ({ |
| 2193 | id: run.databaseId, | 2349 | id: run.id, |
| 2194 | name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`, | 2350 | name: trimmed(run.name) ?? `Workflow run ${run.id}`, |
| 2195 | url: trimmed(run.url), | 2351 | url: trimmed(run.html_url), |
| 2196 | })), | 2352 | })), |
| 2197 | ); | 2353 | rawCount: decoded.success.workflow_runs.length, |
| 2354 | }); | ||
| 2198 | } | 2355 | } |
| 2199 | 2356 | ||
| 2200 | export function decodePullRequestHeadsJson( | 2357 | /** Open pull requests whose head branch has one name, from whichever repository it lives in. */ |
| 2201 | raw: string, | 2358 | export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { |
| 2202 | ): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> { | 2359 | repository(owner: $owner, name: $name) { |
| 2360 | pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { | ||
| 2361 | pageInfo { hasNextPage endCursor } | ||
| 2362 | nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } | ||
| 2363 | } | ||
| 2364 | } | ||
| 2365 | }`; | ||
| 2366 | |||
| 2367 | export function decodePullRequestHeadsJson(raw: string): Result.Result< | ||
| 2368 | { | ||
| 2369 | readonly heads: ReadonlyArray<GitHubPullRequestHead>; | ||
| 2370 | readonly nextCursor: string | null; | ||
| 2371 | }, | ||
| 2372 | DecodeFailure | ||
| 2373 | > { | ||
| 2203 | const decoded = decodePullRequestHeads(raw); | 2374 | const decoded = decodePullRequestHeads(raw); |
| 2204 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | 2375 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 2205 | return Result.succeed( | 2376 | const connection = decoded.success.data.repository?.pullRequests; |
| 2206 | decoded.success.map((pullRequest) => ({ | 2377 | return Result.succeed({ |
| 2378 | heads: (connection?.nodes ?? []).map((pullRequest) => ({ | ||
| 2207 | number: pullRequest.number, | 2379 | number: pullRequest.number, |
| 2208 | headSha: pullRequest.headRefOid, | 2380 | headSha: pullRequest.headRefOid, |
| 2209 | ...(typeof pullRequest.isCrossRepository === "boolean" | 2381 | ...(typeof pullRequest.isCrossRepository === "boolean" |
| ... | @@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson( | ... | @@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson( |
| 2211 | : {}), | 2383 | : {}), |
| 2212 | headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), | 2384 | headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), |
| 2213 | })), | 2385 | })), |
| 2214 | ); | 2386 | nextCursor: nextCursorOf(connection?.pageInfo), |
| 2387 | }); | ||
| 2388 | } | ||
| 2389 | |||
| 2390 | export interface GitHubPullRequestActivityPage { | ||
| 2391 | /** Only on the first page, which is the one that asks for them. */ | ||
| 2392 | readonly author?: PullRequestActor | null; | ||
| 2393 | readonly commits?: ReadonlyArray<PullRequestCommit>; | ||
| 2394 | /** Issue comments and reviews, each list unsorted. */ | ||
| 2395 | readonly remarks: ReadonlyArray<PullRequestComment>; | ||
| 2396 | readonly nextCommentsCursor: string | null; | ||
| 2397 | readonly nextReviewsCursor: string | null; | ||
| 2215 | } | 2398 | } |
| 2216 | 2399 | ||
| 2400 | /** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ | ||
| 2217 | export function decodePullRequestActivityJson( | 2401 | export function decodePullRequestActivityJson( |
| 2218 | raw: string, | 2402 | raw: string, |
| 2219 | ): Result.Result<GitHubPullRequestActivity, DecodeFailure> { | 2403 | ): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> { |
| 2220 | const decoded = decodeActivity(raw); | 2404 | const decoded = decodeActivity(raw); |
| 2221 | return Result.isSuccess(decoded) | 2405 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 2222 | ? Result.succeed(toActivity(decoded.success)) | 2406 | const pr = decoded.success.data.repository.pullRequest; |
| 2223 | : Result.fail(decoded.failure); | 2407 | return Result.succeed({ |
| 2408 | ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), | ||
| 2409 | ...(pr.commits === undefined | ||
| 2410 | ? {} | ||
| 2411 | : { | ||
| 2412 | commits: toCommits( | ||
| 2413 | (pr.commits?.nodes ?? []).map(({ commit }) => ({ | ||
| 2414 | ...commit, | ||
| 2415 | authors: (commit.authors?.nodes ?? []).map((author) => ({ | ||
| 2416 | ...author, | ||
| 2417 | login: author.user?.login ?? null, | ||
| 2418 | })), | ||
| 2419 | })), | ||
| 2420 | ), | ||
| 2421 | }), | ||
| 2422 | remarks: toComments({ | ||
| 2423 | comments: pr.comments?.nodes ?? [], | ||
| 2424 | reviews: pr.reviews?.nodes ?? [], | ||
| 2425 | }), | ||
| 2426 | nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), | ||
| 2427 | nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), | ||
| 2428 | }); | ||
| 2429 | } | ||
| 2430 | |||
| 2431 | /** Every check context of the head commit, a page at a time, for a rollup past one page. */ | ||
| 2432 | export const pullRequestCheckContextsGraphQlQuery = (host: string) => | ||
| 2433 | `query($owner: String!, $name: String!, $number: Int!, $after: String) { | ||
| 2434 | repository(owner: $owner, name: $name) { | ||
| 2435 | pullRequest(number: $number) { | ||
| 2436 | headRefOid | ||
| 2437 | commits(last: 1) { | ||
| 2438 | nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { | ||
| 2439 | ${checkContextNodesSelection(host)} | ||
| 2440 | pageInfo { hasNextPage endCursor } | ||
| 2441 | } } } } | ||
| 2442 | } | ||
| 2443 | } | ||
| 2444 | } | ||
| 2445 | }`; | ||
| 2446 | |||
| 2447 | const RawCheckContextNodeSchema = Schema.Struct({ | ||
| 2448 | ...RawCheckSchema.fields, | ||
| 2449 | checkSuite: Schema.optional( | ||
| 2450 | Schema.NullOr( | ||
| 2451 | Schema.Struct({ | ||
| 2452 | workflowRun: Schema.NullOr( | ||
| 2453 | Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), | ||
| 2454 | ), | ||
| 2455 | }), | ||
| 2456 | ), | ||
| 2457 | ), | ||
| 2458 | }); | ||
| 2459 | |||
| 2460 | const decodeCheckContexts = decodeJsonResult( | ||
| 2461 | Schema.Struct({ | ||
| 2462 | data: Schema.Struct({ | ||
| 2463 | repository: Schema.Struct({ | ||
| 2464 | pullRequest: Schema.Struct({ | ||
| 2465 | headRefOid: Schema.String, | ||
| 2466 | commits: Schema.Struct({ | ||
| 2467 | nodes: Schema.Array( | ||
| 2468 | Schema.Struct({ | ||
| 2469 | commit: Schema.Struct({ | ||
| 2470 | statusCheckRollup: Schema.NullOr( | ||
| 2471 | Schema.Struct({ | ||
| 2472 | contexts: Schema.Struct({ | ||
| 2473 | nodes: Schema.Array(RawCheckContextNodeSchema), | ||
| 2474 | pageInfo: RawPageInfoSchema, | ||
| 2475 | }), | ||
| 2476 | }), | ||
| 2477 | ), | ||
| 2478 | }), | ||
| 2479 | }), | ||
| 2480 | ), | ||
| 2481 | }), | ||
| 2482 | }), | ||
| 2483 | }), | ||
| 2484 | }), | ||
| 2485 | }), | ||
| 2486 | ); | ||
| 2487 | |||
| 2488 | /** A check context as the rollup reports it; opaque outside this module. */ | ||
| 2489 | export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>; | ||
| 2490 | |||
| 2491 | function toCheckContexts( | ||
| 2492 | nodes: ReadonlyArray<Schema.Schema.Type<typeof RawCheckContextNodeSchema>>, | ||
| 2493 | ): ReadonlyArray<GitHubCheckContext> { | ||
| 2494 | return nodes.map((check) => ({ | ||
| 2495 | ...check, | ||
| 2496 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | ||
| 2497 | })); | ||
| 2498 | } | ||
| 2499 | |||
| 2500 | export function decodePullRequestCheckContextsJson(raw: string): Result.Result< | ||
| 2501 | { | ||
| 2502 | readonly headSha: string; | ||
| 2503 | readonly contexts: ReadonlyArray<GitHubCheckContext>; | ||
| 2504 | readonly nextCursor: string | null; | ||
| 2505 | }, | ||
| 2506 | DecodeFailure | ||
| 2507 | > { | ||
| 2508 | const decoded = decodeCheckContexts(raw); | ||
| 2509 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 2510 | const pr = decoded.success.data.repository.pullRequest; | ||
| 2511 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | ||
| 2512 | return Result.succeed({ | ||
| 2513 | headSha: pr.headRefOid, | ||
| 2514 | contexts: toCheckContexts(contexts?.nodes ?? []), | ||
| 2515 | nextCursor: nextCursorOf(contexts?.pageInfo), | ||
| 2516 | }); | ||
| 2517 | } | ||
| 2518 | |||
| 2519 | /** The checks and their one-word verdict, from every context of the head commit. */ | ||
| 2520 | export function pullRequestChecksFromContexts(contexts: ReadonlyArray<GitHubCheckContext>): { | ||
| 2521 | readonly checks: ReadonlyArray<PullRequestCheck>; | ||
| 2522 | readonly checksState: PullRequestChecksState | null; | ||
| 2523 | } { | ||
| 2524 | return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; | ||
| 2224 | } | 2525 | } |
| 2225 | 2526 | ||
| 2226 | export interface GitHubReviewThreadComments { | 2527 | export interface GitHubReviewThreadComments { |
| ... | @@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({ | ... | @@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({ |
| 2778 | team_reviewers: Schema.Array(Schema.String), | 3079 | team_reviewers: Schema.Array(Schema.String), |
| 2779 | }); | 3080 | }); |
| 2780 | 3081 | ||
| 2781 | const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema)); | 3082 | export function buildReviewerRequest( |
| 2782 | |||
| 2783 | export function buildReviewerRequestJson( | ||
| 2784 | reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, | 3083 | reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, |
| 2785 | ): string { | 3084 | ): typeof ReviewerRequestSchema.Type { |
| 2786 | return encodeReviewerRequest({ | 3085 | return { |
| 2787 | reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), | 3086 | reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), |
| 2788 | team_reviewers: reviewers.flatMap((reviewer) => | 3087 | team_reviewers: reviewers.flatMap((reviewer) => |
| 2789 | reviewer.kind === "team" ? [reviewer.id] : [], | 3088 | reviewer.kind === "team" ? [reviewer.id] : [], |
| 2790 | ), | 3089 | ), |
| 2791 | }); | 3090 | }; |
| 2792 | } | 3091 | } |
| 2793 | 3092 | ||
| 2794 | export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | 3093 | export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { |
| ... | @@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson( | ... | @@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson( |
| 2876 | } | 3175 | } |
| 2877 | 3176 | ||
| 2878 | /** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ | 3177 | /** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ |
| 2879 | const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); | 3178 | export function buildLabelRequest(labels: ReadonlyArray<string>): { |
| 2880 | 3179 | readonly labels: ReadonlyArray<string>; | |
| 2881 | const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); | 3180 | } { |
| 2882 | 3181 | return { labels }; | |
| 2883 | export function buildLabelRequestJson(labels: ReadonlyArray<string>): string { | ||
| 2884 | return encodeLabelRequest({ labels }); | ||
| 2885 | } | 3182 | } |
| 2886 | 3183 | ||
| 2887 | /** | 3184 | /** |
| ... | @@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch { | ... | @@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch { |
| 2965 | export function decodePullRequestFilesJson( | 3262 | export function decodePullRequestFilesJson( |
| 2966 | raw: string, | 3263 | raw: string, |
| 2967 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | 3264 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { |
| 2968 | const decoded = decodeUnknownList(raw); | 3265 | return Result.map(decodeUnknownList(raw), toFilesPatch); |
| 2969 | if (!Result.isSuccess(decoded)) { | 3266 | } |
| 2970 | return Result.fail(decoded.failure); | 3267 | |
| 2971 | } | 3268 | /** |
| 3269 | * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped | ||
| 3270 | * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. | ||
| 3271 | */ | ||
| 3272 | export function decodeCommitFilesJson( | ||
| 3273 | raw: string, | ||
| 3274 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | ||
| 3275 | return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); | ||
| 3276 | } | ||
| 3277 | |||
| 3278 | const decodeCommitFiles = decodeJsonResult( | ||
| 3279 | Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), | ||
| 3280 | ); | ||
| 3281 | |||
| 3282 | function toFilesPatch(entries: ReadonlyArray<unknown>): GitHubPullRequestFilesPatch { | ||
| 2972 | const sections: string[] = []; | 3283 | const sections: string[] = []; |
| 2973 | const omittedFileStats: PullRequestOmittedFileStat[] = []; | 3284 | const omittedFileStats: PullRequestOmittedFileStat[] = []; |
| 2974 | let truncated = false; | 3285 | let truncated = false; |
| 2975 | for (const entry of decoded.success) { | 3286 | for (const entry of entries) { |
| 2976 | const file = decodeFileEntry(entry); | 3287 | const file = decodeFileEntry(entry); |
| 2977 | if (Exit.isFailure(file)) continue; | 3288 | if (Exit.isFailure(file)) continue; |
| 2978 | const value = file.value; | 3289 | const value = file.value; |
| ... | @@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson( | ... | @@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson( |
| 3009 | ].join("\n"); | 3320 | ].join("\n"); |
| 3010 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); | 3321 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); |
| 3011 | } | 3322 | } |
| 3012 | return Result.succeed({ | 3323 | return { |
| 3013 | patch: sections.join(""), | 3324 | patch: sections.join(""), |
| 3014 | truncated, | 3325 | truncated, |
| 3015 | rawCount: decoded.success.length, | 3326 | rawCount: entries.length, |
| 3016 | omittedFileStats, | 3327 | omittedFileStats, |
| 3017 | }); | 3328 | }; |
| 3018 | } | 3329 | } |
| 3019 | 3330 | ||
| 3020 | /** | 3331 | /** |
apps/server/src/pullRequest/githubStackActions.test.ts+68-42| ... | @@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest"; | ... | @@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest"; |
| 2 | import * as Layer from "effect/Layer"; | 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as TestClock from "effect/testing/TestClock"; | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; | 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 7 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | ||
| 8 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; | 8 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; |
| 9 | 9 | ||
| 10 | const runGitHubStackAction = ( | 10 | /** |
| 11 | execute: GitHubCli.GitHubCli["Service"]["execute"], | 11 | * One request as the fake saw it, flattened to words: the path or document, then each variable |
| 12 | input: Parameters<typeof runStackAction>[0], | 12 | * or body field as `name=value`, so an assertion reads as "this request carried that value". |
| 13 | ) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); | 13 | */ |
| 14 | type Call = ReadonlyArray<string>; | ||
| 15 | type Send = ( | ||
| 16 | call: Call, | ||
| 17 | kind: "graphql" | "rest", | ||
| 18 | ) => Effect.Effect<string, GitHubApi.GitHubApiError>; | ||
| 19 | |||
| 20 | const runGitHubStackAction = (send: Send, input: Parameters<typeof runStackAction>[0]) => | ||
| 21 | runStackAction(input).pipe( | ||
| 22 | Effect.provide( | ||
| 23 | Layer.mock(GitHubApi.GitHubApi)({ | ||
| 24 | graphql: (request) => { | ||
| 25 | // GitHub refuses a document that declares a variable it never uses. | ||
| 26 | const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); | ||
| 27 | const unused = declared.filter( | ||
| 28 | (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), | ||
| 29 | ); | ||
| 30 | if (unused.length > 0) { | ||
| 31 | return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); | ||
| 32 | } | ||
| 33 | return send(words(request.query, request.variables), "graphql"); | ||
| 34 | }, | ||
| 35 | rest: (request) => | ||
| 36 | send(words(request.path, request.body), "rest").pipe( | ||
| 37 | Effect.map((body) => ({ | ||
| 38 | status: 200, | ||
| 39 | headers: {}, | ||
| 40 | body, | ||
| 41 | truncated: false, | ||
| 42 | invalidUtf8: false, | ||
| 43 | })), | ||
| 44 | ), | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | |||
| 49 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 50 | |||
| 51 | function words(head: string, fields: unknown): Call { | ||
| 52 | return [ | ||
| 53 | head, | ||
| 54 | ...Object.entries((fields ?? {}) as Record<string, unknown>).map( | ||
| 55 | ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, | ||
| 56 | ), | ||
| 57 | ]; | ||
| 58 | } | ||
| 59 | |||
| 60 | const isMutation = (call: Call) => call[0]!.startsWith("mutation"); | ||
| 14 | 61 | ||
| 15 | const stack = [ | 62 | const stack = [ |
| 16 | { | 63 | { |
| ... | @@ -71,21 +118,13 @@ const rebased = { | ... | @@ -71,21 +118,13 @@ const rebased = { |
| 71 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; | 118 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; |
| 72 | 119 | ||
| 73 | function fake(responses: readonly unknown[]) { | 120 | function fake(responses: readonly unknown[]) { |
| 74 | const calls: ReadonlyArray<string>[] = []; | 121 | const calls: Call[] = []; |
| 75 | const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => | 122 | const execute: Send = (call) => |
| 76 | Effect.sync(() => { | 123 | Effect.sync(() => { |
| 77 | calls.push(request.args); | 124 | calls.push(call); |
| 78 | const value = responses[calls.length - 1]; | 125 | const value = responses[calls.length - 1]; |
| 79 | if (value === undefined) throw new Error("Unexpected GitHub request"); | 126 | if (value === undefined) throw new Error("Unexpected GitHub request"); |
| 80 | return { | 127 | return encodeJson(value); |
| 81 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 82 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 83 | stdout: JSON.stringify(value), | ||
| 84 | stderr: "", | ||
| 85 | stdoutTruncated: false, | ||
| 86 | stderrTruncated: false, | ||
| 87 | stdoutInvalidUtf8: false, | ||
| 88 | }; | ||
| 89 | }); | 128 | }); |
| 90 | return { execute, calls }; | 129 | return { execute, calls }; |
| 91 | } | 130 | } |
| ... | @@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", () | ... | @@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", () |
| 215 | Effect.gen(function* () { | 254 | Effect.gen(function* () { |
| 216 | const api = fake([stack, access, ...rebaseResponses]); | 255 | const api = fake([stack, access, ...rebaseResponses]); |
| 217 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | 256 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); |
| 218 | const mutations = api.calls.filter((args) => | 257 | const mutations = api.calls.filter(isMutation); |
| 219 | args.some((arg) => arg.startsWith("query=mutation")), | ||
| 220 | ); | ||
| 221 | expect(mutations).toHaveLength(2); | 258 | expect(mutations).toHaveLength(2); |
| 222 | expect(mutations[0]).toContain("id=PR_2"); | 259 | expect(mutations[0]).toContain("id=PR_2"); |
| 223 | expect(mutations[0]).toContain("sha=bbb"); | 260 | expect(mutations[0]).toContain("sha=bbb"); |
| 224 | expect(mutations[1]).toContain("id=PR_3"); | 261 | expect(mutations[1]).toContain("id=PR_3"); |
| 225 | expect(mutations[1]).toContain("sha=ccc"); | 262 | expect(mutations[1]).toContain("sha=ccc"); |
| 226 | expect(api.calls.every((args) => args[0] === "api")).toBe(true); | ||
| 227 | }), | 263 | }), |
| 228 | ); | 264 | ); |
| 229 | 265 | ||
| 230 | it.effect("does not update later layers after a rebase failure", () => | 266 | it.effect("does not update later layers after a rebase failure", () => |
| 231 | Effect.gen(function* () { | 267 | Effect.gen(function* () { |
| 232 | const api = fake([stack, access, branch(2, "bbb")]); | 268 | const api = fake([stack, access, branch(2, "bbb")]); |
| 233 | const execute: typeof api.execute = (request) => | 269 | const execute: Send = (call, kind) => |
| 234 | !request.args.some((arg) => arg.startsWith("query=mutation")) | 270 | !isMutation(call) |
| 235 | ? api.execute(request) | 271 | ? api.execute(call, kind) |
| 236 | : Effect.fail( | 272 | : Effect.fail( |
| 237 | new GitHubCli.GitHubCliAuthenticationError({ | 273 | new GitHubApi.GitHubApiAuthenticationError({ |
| 238 | command: "gh", | 274 | host: "github.com", |
| 239 | cwd: "/repo", | 275 | operation: "runGitHubStackAction", |
| 240 | cause: new Error("denied"), | ||
| 241 | }), | 276 | }), |
| 242 | ); | 277 | ); |
| 243 | const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( | 278 | const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( |
| ... | @@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr | ... | @@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr |
| 272 | failure: { _tag: "GitHubStackPermissionError" }, | 307 | failure: { _tag: "GitHubStackPermissionError" }, |
| 273 | }); | 308 | }); |
| 274 | expect(api.calls).toHaveLength(2); | 309 | expect(api.calls).toHaveLength(2); |
| 275 | expect(api.calls.every((args) => args[0] === "api")).toBe(true); | ||
| 276 | }), | 310 | }), |
| 277 | ); | 311 | ); |
| 278 | 312 | ||
| ... | @@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () => | ... | @@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () => |
| 333 | Effect.gen(function* () { | 367 | Effect.gen(function* () { |
| 334 | const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); | 368 | const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); |
| 335 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | 369 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); |
| 336 | expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( | 370 | expect(api.calls.some((args) => isMutation(args))).toBe(false); |
| 337 | false, | ||
| 338 | ); | ||
| 339 | }), | 371 | }), |
| 340 | ); | 372 | ); |
| 341 | 373 | ||
| ... | @@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase" | ... | @@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase" |
| 380 | if (result._tag === "Failure") { | 412 | if (result._tag === "Failure") { |
| 381 | expect(result.failure.message).toContain("Earlier updates remain on GitHub"); | 413 | expect(result.failure.message).toContain("Earlier updates remain on GitHub"); |
| 382 | } | 414 | } |
| 383 | expect( | 415 | expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1); |
| 384 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | ||
| 385 | ).toHaveLength(1); | ||
| 386 | }), | 416 | }), |
| 387 | ); | 417 | ); |
| 388 | 418 | ||
| ... | @@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", | ... | @@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", |
| 403 | _tag: "Failure", | 433 | _tag: "Failure", |
| 404 | failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, | 434 | failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, |
| 405 | }); | 435 | }); |
| 406 | expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( | 436 | expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true); |
| 407 | true, | 437 | expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0); |
| 408 | ); | ||
| 409 | expect( | ||
| 410 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | ||
| 411 | ).toHaveLength(rebasedParent ? 1 : 0); | ||
| 412 | }), | 438 | }), |
| 413 | ); | 439 | ); |
apps/server/src/pullRequest/githubStackActions.ts+57-120| ... | @@ -8,7 +8,7 @@ import * as Clock from "effect/Clock"; | ... | @@ -8,7 +8,7 @@ import * as Clock from "effect/Clock"; |
| 8 | import * as Result from "effect/Result"; | 8 | import * as Result from "effect/Result"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | ||
| 11 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 11 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 12 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; | 12 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; |
| 13 | 13 | ||
| 14 | const stackErrorIdentity = { | 14 | const stackErrorIdentity = { |
| ... | @@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang | ... | @@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang |
| 21 | "GitHubStackChangedError", | 21 | "GitHubStackChangedError", |
| 22 | { ...stackErrorIdentity, completed: Schema.Int }, | 22 | { ...stackErrorIdentity, completed: Schema.Int }, |
| 23 | ) { | 23 | ) { |
| 24 | get detail(): string { | ||
| 25 | return this.message; | ||
| 26 | } | ||
| 27 | |||
| 28 | override get message(): string { | 24 | override get message(): string { |
| 29 | return this.completed > 0 | 25 | return this.completed > 0 |
| 30 | ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` | 26 | ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` |
| ... | @@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackU | ... | @@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackU |
| 36 | "GitHubStackUnsupportedError", | 32 | "GitHubStackUnsupportedError", |
| 37 | stackErrorIdentity, | 33 | stackErrorIdentity, |
| 38 | ) { | 34 | ) { |
| 39 | get detail(): string { | ||
| 40 | return this.message; | ||
| 41 | } | ||
| 42 | |||
| 43 | override get message(): string { | 35 | override get message(): string { |
| 44 | return "This operation is not supported for this stack."; | 36 | return "This operation is not supported for this stack."; |
| 45 | } | 37 | } |
| ... | @@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt | ... | @@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt |
| 49 | "GitHubStackResponseInvalidError", | 41 | "GitHubStackResponseInvalidError", |
| 50 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, | 42 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, |
| 51 | ) { | 43 | ) { |
| 52 | get detail(): string { | ||
| 53 | return this.message; | ||
| 54 | } | ||
| 55 | |||
| 56 | override get message(): string { | 44 | override get message(): string { |
| 57 | return "GitHub returned an unreadable stack operation response."; | 45 | return "GitHub returned an unreadable stack operation response."; |
| 58 | } | 46 | } |
| ... | @@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac | ... | @@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac |
| 62 | "GitHubStackMergeRejectedError", | 50 | "GitHubStackMergeRejectedError", |
| 63 | { ...stackErrorIdentity, cause: Schema.Defect() }, | 51 | { ...stackErrorIdentity, cause: Schema.Defect() }, |
| 64 | ) { | 52 | ) { |
| 65 | get detail(): string { | ||
| 66 | return this.message; | ||
| 67 | } | ||
| 68 | |||
| 69 | override get message(): string { | 53 | override get message(): string { |
| 70 | return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; | 54 | return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; |
| 71 | } | 55 | } |
| ... | @@ -75,10 +59,6 @@ export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStack | ... | @@ -75,10 +59,6 @@ export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStack |
| 75 | "GitHubStackMergePendingError", | 59 | "GitHubStackMergePendingError", |
| 76 | stackErrorIdentity, | 60 | stackErrorIdentity, |
| 77 | ) { | 61 | ) { |
| 78 | get detail(): string { | ||
| 79 | return this.message; | ||
| 80 | } | ||
| 81 | |||
| 82 | override get message(): string { | 62 | override get message(): string { |
| 83 | return "The merge is still running on GitHub. Check its status there before submitting another request."; | 63 | return "The merge is still running on GitHub. Check its status there before submitting another request."; |
| 84 | } | 64 | } |
| ... | @@ -88,10 +68,6 @@ export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPe | ... | @@ -88,10 +68,6 @@ export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPe |
| 88 | "GitHubStackPermissionError", | 68 | "GitHubStackPermissionError", |
| 89 | stackErrorIdentity, | 69 | stackErrorIdentity, |
| 90 | ) { | 70 | ) { |
| 91 | get detail(): string { | ||
| 92 | return this.message; | ||
| 93 | } | ||
| 94 | |||
| 95 | override get message(): string { | 71 | override get message(): string { |
| 96 | return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; | 72 | return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; |
| 97 | } | 73 | } |
| ... | @@ -101,10 +77,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStack | ... | @@ -101,10 +77,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStack |
| 101 | "GitHubStackRebaseFailedError", | 77 | "GitHubStackRebaseFailedError", |
| 102 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, | 78 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, |
| 103 | ) { | 79 | ) { |
| 104 | get detail(): string { | ||
| 105 | return this.message; | ||
| 106 | } | ||
| 107 | |||
| 108 | override get message(): string { | 80 | override get message(): string { |
| 109 | return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; | 81 | return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; |
| 110 | } | 82 | } |
| ... | @@ -179,8 +151,6 @@ const decodeRebaseResponse = Schema.decodeEffect( | ... | @@ -179,8 +151,6 @@ const decodeRebaseResponse = Schema.decodeEffect( |
| 179 | ), | 151 | ), |
| 180 | ); | 152 | ); |
| 181 | 153 | ||
| 182 | const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); | ||
| 183 | |||
| 184 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); | 154 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); |
| 185 | 155 | ||
| 186 | /** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ | 156 | /** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ |
| ... | @@ -194,7 +164,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -194,7 +164,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 194 | action: PullRequestAction; | 164 | action: PullRequestAction; |
| 195 | mergeMethod?: PullRequestMergeMethod; | 165 | mergeMethod?: PullRequestMergeMethod; |
| 196 | }) { | 166 | }) { |
| 197 | const github = yield* GitHubCli.GitHubCli; | 167 | const api = yield* GitHubApi.GitHubApi; |
| 198 | const identity = { | 168 | const identity = { |
| 199 | repository: input.repository, | 169 | repository: input.repository, |
| 200 | number: input.number, | 170 | number: input.number, |
| ... | @@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 203 | if (input.action !== "merge" && input.action !== "update-branch") | 173 | if (input.action !== "merge" && input.action !== "update-branch") |
| 204 | return yield* new GitHubStackUnsupportedError({ ...identity }); | 174 | return yield* new GitHubStackUnsupportedError({ ...identity }); |
| 205 | const endpoint = `repos/${input.repository}`; | 175 | const endpoint = `repos/${input.repository}`; |
| 206 | const read = yield* github.execute({ | 176 | const read = yield* api.rest({ |
| 207 | cwd: input.cwd, | 177 | host: input.host, |
| 208 | args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], | 178 | operation: "runGitHubStackAction", |
| 179 | path: `${endpoint}/stacks?pull_request=${input.number}`, | ||
| 209 | }); | 180 | }); |
| 210 | const decoded = decodePullRequestStacksJson(read.stdout); | 181 | const decoded = decodePullRequestStacksJson(read.body); |
| 211 | if (Result.isFailure(decoded)) | 182 | if (Result.isFailure(decoded)) |
| 212 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); | 183 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); |
| 213 | const stack = decoded.success; | 184 | const stack = decoded.success; |
| ... | @@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 243 | return yield* new GitHubStackUnsupportedError({ ...identity }); | 214 | return yield* new GitHubStackUnsupportedError({ ...identity }); |
| 244 | if (input.action === "update-branch") { | 215 | if (input.action === "update-branch") { |
| 245 | const [owner, name] = input.repository.split("/"); | 216 | const [owner, name] = input.repository.split("/"); |
| 246 | const permissions = yield* github.execute({ | 217 | const permissions = yield* api.graphql({ |
| 247 | cwd: input.cwd, | 218 | host: input.host, |
| 248 | args: [ | 219 | operation: "runGitHubStackAction", |
| 249 | "api", | 220 | allowReserve: true, |
| 250 | "--hostname", | 221 | variables: { owner, name }, |
| 251 | input.host, | 222 | query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open |
| 252 | "graphql", | 223 | .map( |
| 253 | "-f", | 224 | (layer) => |
| 254 | `owner=${owner}`, | 225 | `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, |
| 255 | "-f", | 226 | ) |
| 256 | `name=${name}`, | 227 | .join(" ")}}}`, |
| 257 | "-f", | ||
| 258 | `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open | ||
| 259 | .map( | ||
| 260 | (layer) => | ||
| 261 | `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, | ||
| 262 | ) | ||
| 263 | .join(" ")}}}`, | ||
| 264 | ], | ||
| 265 | }); | 228 | }); |
| 266 | const access = yield* decodeBranchAccess(permissions.stdout).pipe( | 229 | const access = yield* decodeBranchAccess(permissions).pipe( |
| 267 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | 230 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), |
| 268 | ); | 231 | ); |
| 269 | // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent | 232 | // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent |
| ... | @@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 282 | const processed: Array<{ id: string; number: number; headSha: string }> = []; | 245 | const processed: Array<{ id: string; number: number; headSha: string }> = []; |
| 283 | for (const [index, layer] of open.entries()) { | 246 | for (const [index, layer] of open.entries()) { |
| 284 | yield* Effect.gen(function* () { | 247 | yield* Effect.gen(function* () { |
| 285 | const read = yield* github.execute({ | 248 | const read = yield* api.graphql({ |
| 286 | cwd: input.cwd, | 249 | host: input.host, |
| 287 | args: [ | 250 | operation: "runGitHubStackAction", |
| 288 | "api", | 251 | allowReserve: true, |
| 289 | "--hostname", | 252 | variables: { |
| 290 | input.host, | 253 | owner, |
| 291 | "graphql", | 254 | name, |
| 292 | "-f", | 255 | number: layer.number, |
| 293 | `owner=${owner}`, | 256 | sha: layer.headSha, |
| 294 | "-f", | 257 | ids: processed.map((head) => head.id), |
| 295 | `name=${name}`, | 258 | }, |
| 296 | "-F", | 259 | // GitHub rejects a declared variable the document never uses, so `$ids` is always |
| 297 | `number=${layer.number}`, | 260 | // selected; an empty list asks for nothing. |
| 298 | "-f", | 261 | query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, |
| 299 | `sha=${layer.headSha}`, | ||
| 300 | "-f", | ||
| 301 | `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ | ||
| 302 | processed.length === 0 | ||
| 303 | ? "" | ||
| 304 | : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` | ||
| 305 | } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, | ||
| 306 | ], | ||
| 307 | }); | 262 | }); |
| 308 | const { | 263 | const { |
| 309 | data: { | 264 | data: { |
| 310 | processed: observed, | 265 | processed: observed, |
| 311 | repository: { pullRequest: pr }, | 266 | repository: { pullRequest: pr }, |
| 312 | }, | 267 | }, |
| 313 | } = yield* decodeRebaseBranch(read.stdout); | 268 | } = yield* decodeRebaseBranch(read); |
| 314 | // A push to an earlier layer must not silently become the next layer's new base. | 269 | // A push to an earlier layer must not silently become the next layer's new base. |
| 315 | const changed = processed.find( | 270 | const changed = processed.find( |
| 316 | (head, index) => observed?.[index]?.headRefOid !== head.headSha, | 271 | (head, index) => observed?.[index]?.headRefOid !== head.headSha, |
| ... | @@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 332 | return; | 287 | return; |
| 333 | } | 288 | } |
| 334 | // Pass the reviewed revision to GitHub, including when a push races this read. | 289 | // Pass the reviewed revision to GitHub, including when a push races this read. |
| 335 | const updated = yield* github.execute({ | 290 | const updated = yield* api.graphql({ |
| 336 | cwd: input.cwd, | 291 | host: input.host, |
| 337 | args: [ | 292 | operation: "runGitHubStackAction", |
| 338 | "api", | 293 | variables: { id: pr.id, sha: layer.headSha }, |
| 339 | "--hostname", | 294 | query: |
| 340 | input.host, | 295 | "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", |
| 341 | "graphql", | ||
| 342 | "-f", | ||
| 343 | `id=${pr.id}`, | ||
| 344 | "-f", | ||
| 345 | `sha=${layer.headSha}`, | ||
| 346 | "-f", | ||
| 347 | "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", | ||
| 348 | ], | ||
| 349 | }); | 296 | }); |
| 350 | const response = yield* decodeRebaseResponse(updated.stdout); | 297 | const response = yield* decodeRebaseResponse(updated); |
| 351 | processed.push({ | 298 | processed.push({ |
| 352 | id: pr.id, | 299 | id: pr.id, |
| 353 | number: layer.number, | 300 | number: layer.number, |
| ... | @@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 374 | decodeMergeResponse(raw).pipe( | 321 | decodeMergeResponse(raw).pipe( |
| 375 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | 322 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), |
| 376 | ); | 323 | ); |
| 377 | const request = yield* github.execute({ | 324 | const request = yield* api.rest({ |
| 378 | cwd: input.cwd, | 325 | host: input.host, |
| 379 | args: [ | 326 | operation: "runGitHubStackAction", |
| 380 | "api", | 327 | method: "PUT", |
| 381 | "--hostname", | 328 | path: `${endpoint}/pulls/${input.number}/merge-async`, |
| 382 | input.host, | 329 | body: { |
| 383 | "--method", | 330 | merge_method: input.mergeMethod ?? "merge", |
| 384 | "PUT", | 331 | merge_action: "default", |
| 385 | `${endpoint}/pulls/${input.number}/merge-async`, | 332 | sha: target.headSha, |
| 386 | "-f", | 333 | }, |
| 387 | `merge_method=${input.mergeMethod ?? "merge"}`, | ||
| 388 | "-f", | ||
| 389 | "merge_action=default", | ||
| 390 | "-f", | ||
| 391 | `sha=${target.headSha}`, | ||
| 392 | ], | ||
| 393 | }); | 334 | }); |
| 394 | let result = yield* decode(request.stdout); | 335 | let result = yield* decode(request.body); |
| 395 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; | 336 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; |
| 396 | for ( | 337 | for ( |
| 397 | let attempt = 0; | 338 | let attempt = 0; |
| ... | @@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* | ... | @@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 401 | const uuid = result.details.uuid; | 342 | const uuid = result.details.uuid; |
| 402 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); | 343 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); |
| 403 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); | 344 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); |
| 404 | const poll = yield* github.execute({ | 345 | const poll = yield* api.rest({ |
| 405 | cwd: input.cwd, | 346 | host: input.host, |
| 406 | args: [ | 347 | operation: "runGitHubStackAction", |
| 407 | "api", | 348 | path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, |
| 408 | "--hostname", | ||
| 409 | input.host, | ||
| 410 | `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | ||
| 411 | ], | ||
| 412 | }); | 349 | }); |
| 413 | result = yield* decode(poll.stdout); | 350 | result = yield* decode(poll.body); |
| 414 | } | 351 | } |
| 415 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); | 352 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); |
| 416 | if (result.status === "failed") | 353 | if (result.status === "failed") |
apps/server/src/sourceControl/GitHubApi.ts+8-2| ... | @@ -117,6 +117,8 @@ export interface GitHubRestResponse { | ... | @@ -117,6 +117,8 @@ export interface GitHubRestResponse { |
| 117 | readonly headers: Readonly<Record<string, string | undefined>>; | 117 | readonly headers: Readonly<Record<string, string | undefined>>; |
| 118 | readonly body: string; | 118 | readonly body: string; |
| 119 | readonly truncated: boolean; | 119 | readonly truncated: boolean; |
| 120 | /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ | ||
| 121 | readonly invalidUtf8: boolean; | ||
| 120 | } | 122 | } |
| 121 | 123 | ||
| 122 | export interface GitHubRestInput { | 124 | export interface GitHubRestInput { |
| ... | @@ -131,6 +133,8 @@ export interface GitHubRestInput { | ... | @@ -131,6 +133,8 @@ export interface GitHubRestInput { |
| 131 | /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ | 133 | /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ |
| 132 | readonly ifNoneMatch?: string; | 134 | readonly ifNoneMatch?: string; |
| 133 | readonly maxResponseBytes?: number; | 135 | readonly maxResponseBytes?: number; |
| 136 | /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ | ||
| 137 | readonly timeout?: Duration.Input; | ||
| 134 | /** Overrides `AllowGitHubReserve` for this one request. */ | 138 | /** Overrides `AllowGitHubReserve` for this one request. */ |
| 135 | readonly allowReserve?: boolean; | 139 | readonly allowReserve?: boolean; |
| 136 | } | 140 | } |
| ... | @@ -381,11 +385,11 @@ export const make = Effect.gen(function* () { | ... | @@ -381,11 +385,11 @@ export const make = Effect.gen(function* () { |
| 381 | readonly operation: string; | 385 | readonly operation: string; |
| 382 | readonly request: HttpClientRequest.HttpClientRequest; | 386 | readonly request: HttpClientRequest.HttpClientRequest; |
| 383 | readonly maxResponseBytes: number; | 387 | readonly maxResponseBytes: number; |
| 388 | readonly timeout?: Duration.Input | undefined; | ||
| 384 | readonly allowReserve: boolean; | 389 | readonly allowReserve: boolean; |
| 385 | readonly acceptNotModified: boolean; | 390 | readonly acceptNotModified: boolean; |
| 386 | /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ | 391 | /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ |
| 387 | readonly graphql?: boolean; | 392 | readonly graphql?: boolean; |
| 388 | readonly timeout?: Duration.Duration | undefined; | ||
| 389 | }) { | 393 | }) { |
| 390 | const host = normalizeHost(input.host); | 394 | const host = normalizeHost(input.host); |
| 391 | // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. | 395 | // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. |
| ... | @@ -435,7 +439,7 @@ export const make = Effect.gen(function* () { | ... | @@ -435,7 +439,7 @@ export const make = Effect.gen(function* () { |
| 435 | // 204, 304 and many refusals carry no body at all, which is an empty answer. | 439 | // 204, 304 and many refusals carry no body at all, which is an empty answer. |
| 436 | Effect.catchIf( | 440 | Effect.catchIf( |
| 437 | (error) => error.reason._tag === "EmptyBodyError", | 441 | (error) => error.reason._tag === "EmptyBodyError", |
| 438 | () => Effect.succeed({ text: "", truncated: false }), | 442 | () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), |
| 439 | ), | 443 | ), |
| 440 | ); | 444 | ); |
| 441 | return { response, collected }; | 445 | return { response, collected }; |
| ... | @@ -468,6 +472,7 @@ export const make = Effect.gen(function* () { | ... | @@ -468,6 +472,7 @@ export const make = Effect.gen(function* () { |
| 468 | headers, | 472 | headers, |
| 469 | body: collected.text, | 473 | body: collected.text, |
| 470 | truncated: collected.truncated, | 474 | truncated: collected.truncated, |
| 475 | invalidUtf8: collected.invalidUtf8, | ||
| 471 | }), | 476 | }), |
| 472 | ), | 477 | ), |
| 473 | RateLimited: () => | 478 | RateLimited: () => |
| ... | @@ -526,6 +531,7 @@ export const make = Effect.gen(function* () { | ... | @@ -526,6 +531,7 @@ export const make = Effect.gen(function* () { |
| 526 | operation: input.operation, | 531 | operation: input.operation, |
| 527 | request, | 532 | request, |
| 528 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | 533 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, |
| 534 | timeout: input.timeout, | ||
| 529 | allowReserve: input.allowReserve ?? interactive, | 535 | allowReserve: input.allowReserve ?? interactive, |
| 530 | acceptNotModified: input.ifNoneMatch !== undefined, | 536 | acceptNotModified: input.ifNoneMatch !== undefined, |
| 531 | }), | 537 | }), |
apps/server/src/sourceControl/GitHubCli.ts+1-4| ... | @@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; | ... | @@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; |
| 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ | 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ |
| 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; | 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; |
| 40 | 40 | ||
| 41 | export const AllowGitHubReserve = Context.Reference<boolean>( | 41 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; |
| 42 | "t3/sourceControl/AllowGitHubReserve", | ||
| 43 | { defaultValue: () => false }, | ||
| 44 | ); | ||
| 45 | 42 | ||
| 46 | function commandHosts(args: ReadonlyArray<string>): Array<string | null> { | 43 | function commandHosts(args: ReadonlyArray<string>): Array<string | null> { |
| 47 | const hosts: Array<string | null> = []; | 44 | const hosts: Array<string | null> = []; |