| 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 | 1 | // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 |
| 2 | 2 | // Numbers form a session with shared repository-permission caches. Browser and |
| 3 | 3 | // service caches are excluded. Uses real GitHub reads, without a server or database. |
| 4 | // CLI-generated GraphQL | |
| 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. | |
| 4 | // Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. | |
| 7 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | 6 | import * as Effect from "effect/Effect"; |
| 9 | 7 | import * as Console from "effect/Console"; |
| 10 | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | |
| 11 | 10 | import * as Schema from "effect/Schema"; |
| 11 | import { FetchHttpClient } from "effect/http"; | |
| 12 | 12 | |
| 13 | 13 | import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; |
| 14 | 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 | 19 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 17 | 20 | |
| 18 | 21 | const [repository, ...numbers] = process.argv.slice(2); |
| ... | ... | @@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes |
| 20 | 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 | 27 | const reads: Read[] = []; |
| 25 | const layerMeasuredProcess = Layer.effect( | |
| 26 | VcsProcess.VcsProcess, | |
| 28 | const decodeCost = Schema.decodeUnknownOption( | |
| 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 | 37 | Effect.gen(function* () { |
| 28 | const vcs = yield* VcsProcess.VcsProcess; | |
| 29 | return VcsProcess.VcsProcess.of({ | |
| 30 | run: (input) => | |
| 31 | vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe( | |
| 32 | Effect.tap((output) => | |
| 33 | Effect.sync(() => { | |
| 34 | const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length; | |
| 35 | const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length; | |
| 36 | const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1]; | |
| 38 | const api = yield* GitHubApi.make; | |
| 39 | return GitHubApi.GitHubApi.of({ | |
| 40 | ...api, | |
| 41 | graphql: (input) => | |
| 42 | api.graphql(input).pipe( | |
| 43 | Effect.tap((body) => | |
| 44 | Effect.sync(() => | |
| 37 | 45 | reads.push({ |
| 38 | graphqlRequests, | |
| 39 | restRequests: requests - graphqlRequests, | |
| 40 | cost: Number(cost ?? 0), | |
| 41 | debug: output.stderr, | |
| 42 | }); | |
| 43 | }), | |
| 46 | graphqlRequests: 1, | |
| 47 | restRequests: 0, | |
| 48 | cost: Option.match(decodeCost(body), { | |
| 49 | onNone: () => 0, | |
| 50 | onSome: (decoded) => decoded.data.rateLimit.cost, | |
| 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)); | |
| 49 | ||
| 50 | const layerServices = GitHubPullRequestCli.layer.pipe( | |
| 51 | Layer.provide(GitHubCli.layer), | |
| 52 | Layer.provideMerge(layerMeasuredProcess), | |
| 66 | ).pipe( | |
| 67 | Layer.provide(GitHubCredentials.layer), | |
| 68 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 69 | Layer.provide(SourceControlRateLimit.layer), | |
| 70 | Layer.provide(FetchHttpClient.layer), | |
| 71 | Layer.provide(VcsProcess.layer), | |
| 53 | 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 | 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 | 79 | for (const mode of ["detail", "preview"] as const) { |
| 96 | 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 | 92 | : provider.getChangeRequestPreview!(input); |
| 109 | 93 | const elapsedMs = Math.round(performance.now() - start); |
| 110 | 94 | const measured = [...reads]; |
| 111 | const costs = yield* Effect.forEach(measured, graphqlCost); | |
| 112 | const points = costs.reduce((total, cost) => total + cost, 0); | |
| 95 | const points = measured.reduce((total, read) => total + read.cost, 0); | |
| 113 | 96 | return { |
| 114 | 97 | repository, |
| 115 | 98 | number, |
| ... | ... | @@ -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 | 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 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 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"; | |
| 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 14 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 12 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 13 | ||
| 14 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | |
| 15 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 16 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 15 | 17 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 18 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 16 | 19 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 20 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | |
| 17 | 21 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 18 | 22 | |
| 19 | 23 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.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"]>(); | |
| 52 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => | |
| 55 | /** One request the CLI made, as the transport received it. */ | |
| 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 | 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 | 97 | const layer = it.layer( |
| 58 | 98 | GitHubPullRequestCli.layer.pipe( |
| 59 | Layer.provide( | |
| 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 | ), | |
| 99 | Layer.provideMerge(mockApi), | |
| 68 | 100 | Layer.provideMerge(GitHubGraphQlBudget.layer), |
| 69 | 101 | Layer.provide(NodeCrypto.layer), |
| 70 | 102 | ), |
| 71 | 103 | ); |
| 72 | 104 | |
| 73 | function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { | |
| 74 | return { | |
| 75 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 76 | stdout, | |
| 77 | stderr: "", | |
| 78 | stdoutTruncated, | |
| 79 | stderrTruncated: false, | |
| 80 | stdoutInvalidUtf8, | |
| 81 | }; | |
| 105 | /** A successful answer; GraphQL reads take its body. */ | |
| 106 | function output( | |
| 107 | body: string, | |
| 108 | truncated = false, | |
| 109 | invalidUtf8 = false, | |
| 110 | ): GitHubApi.GitHubRestResponse { | |
| 111 | return { status: 200, headers: {}, body, truncated, invalidUtf8 }; | |
| 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 | 119 | count: number, |
| 86 | 120 | firstNumber: number, |
| 87 | 121 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), |
| 88 | ): string { | |
| 89 | return JSON.stringify( | |
| 90 | Array.from({ length: count }, (_, index) => ({ | |
| 91 | number: firstNumber + index, | |
| 92 | title: `Pull request ${firstNumber + index}`, | |
| 93 | url: `https://github.com/acme/web/pull/${firstNumber + index}`, | |
| 122 | ): ReadonlyArray<Record<string, unknown>> { | |
| 123 | return Array.from({ length: count }, (_, index) => { | |
| 124 | const number = firstNumber + index; | |
| 125 | const { reviewRequests, checks, ...rest } = overrides(number) as { | |
| 126 | reviewRequests?: ReadonlyArray<Record<string, string>>; | |
| 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 | 133 | headRefName: "feat/page", |
| 95 | 134 | baseRefName: "main", |
| 96 | 135 | createdAt: "2026-07-01T00:00:00Z", |
| 97 | 136 | updatedAt: "2026-07-02T00:00:00Z", |
| 98 | ...overrides(firstNumber + index), | |
| 99 | })), | |
| 100 | ); | |
| 137 | repository: { nameWithOwner: "acme/web" }, | |
| 138 | ...(reviewRequests === undefined | |
| 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 | 188 | function pullRequestFiles(count: number, firstIndex: number): string { |
| 104 | return JSON.stringify( | |
| 189 | return encodeJson( | |
| 105 | 190 | Array.from({ length: count }, (_, index) => ({ |
| 106 | 191 | filename: `src/file${firstIndex + index}.ts`, |
| 107 | 192 | status: "modified", |
| ... | ... | @@ -139,7 +224,7 @@ function reviewThreadsPage( |
| 139 | 224 | nodes: ReadonlyArray<Record<string, unknown>>, |
| 140 | 225 | endCursor: string | null, |
| 141 | 226 | ): string { |
| 142 | return JSON.stringify({ | |
| 227 | return encodeJson({ | |
| 143 | 228 | data: { |
| 144 | 229 | repository: { |
| 145 | 230 | pullRequest: { |
| ... | ... | @@ -160,7 +245,7 @@ function threadCommentsPage( |
| 160 | 245 | totalCount: number, |
| 161 | 246 | pullRequestId = "PR_7", |
| 162 | 247 | ): string { |
| 163 | return JSON.stringify({ | |
| 248 | return encodeJson({ | |
| 164 | 249 | data: { |
| 165 | 250 | repository: { pullRequest: { id: "PR_7" } }, |
| 166 | 251 | node: { |
| ... | ... | @@ -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. */ | |
| 175 | const diffRefused = new GitHubCli.GitHubCliCommandError({ | |
| 176 | command: "gh", | |
| 177 | cwd: "/w", | |
| 178 | cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"), | |
| 259 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ | |
| 260 | const diffRefused = new GitHubApi.GitHubApiResponseError({ | |
| 261 | host: "github.com", | |
| 262 | operation: "getPullRequestDiff", | |
| 263 | status: 406, | |
| 179 | 264 | }); |
| 180 | 265 | |
| 181 | /** The whole invocation the nth call made, so both argv and stdin can be asserted. */ | |
| 182 | function callAt(index: number) { | |
| 266 | /** The nth request the CLI made. */ | |
| 267 | function callAt(index: number): ApiCall { | |
| 183 | 268 | const call = mockedExecute.mock.calls[index]; |
| 184 | 269 | assert.isDefined(call); |
| 185 | 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 | 293 | function searchOfCall(index: number): string | undefined { |
| 190 | const args = callAt(index).args; | |
| 191 | const flag = args.indexOf("--search"); | |
| 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]; | |
| 294 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; | |
| 295 | return typeof q === "string" ? q : undefined; | |
| 194 | 296 | } |
| 195 | 297 | |
| 196 | 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 | 316 | } |
| 215 | 317 | |
| 216 | 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. */ | |
| 221 | function searchQueryOfCall(index: number): string | undefined { | |
| 222 | const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } }; | |
| 223 | return body.variables?.q; | |
| 322 | const searchQueryOfCall = searchOfCall; | |
| 323 | ||
| 324 | /** | |
| 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 | 451 | afterEach(() => { |
| 227 | 452 | mockedExecute.mockReset(); |
| 228 | 453 | mockedStackMemberships.mockReset(); |
| 229 | mockedGetPullRequest.mockReset(); | |
| 454 | mockedCredential.mockReset(); | |
| 455 | mockedCredential.mockImplementation(defaultCredential); | |
| 230 | 456 | }); |
| 231 | 457 | |
| 232 | 458 | it.effect( |
| ... | ... | @@ -234,22 +460,41 @@ it.effect( |
| 234 | 460 | () => |
| 235 | 461 | Effect.gen(function* () { |
| 236 | 462 | let activeToken = "broad-credential"; |
| 237 | const commands: VcsProcess.VcsProcessInput[] = []; | |
| 238 | const github = yield* GitHubCli.make.pipe( | |
| 239 | Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)), | |
| 240 | Effect.provideService(VcsProcess.VcsProcess, { | |
| 241 | run: (input) => | |
| 242 | Effect.sync(() => { | |
| 243 | commands.push(input); | |
| 244 | if (input.args[0] === "auth") return output(activeToken); | |
| 245 | if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}'); | |
| 246 | return output(""); | |
| 247 | }), | |
| 463 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = | |
| 464 | []; | |
| 465 | const credentials = Layer.succeed( | |
| 466 | GitHubCredentials.GitHubCredentials, | |
| 467 | GitHubCredentials.GitHubCredentials.of({ | |
| 468 | get: (host) => | |
| 469 | Effect.sync(() => ({ | |
| 470 | host, | |
| 471 | token: Redacted.make(activeToken), | |
| 472 | source: "gh" as const, | |
| 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 | 490 | const cli = yield* GitHubPullRequestCli.make.pipe( |
| 251 | Effect.provideService(GitHubCli.GitHubCli, github), | |
| 252 | Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, NodeCrypto.layer)), | |
| 491 | Effect.provide( | |
| 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 | 499 | const input = { cwd: "/repo", host: "github.com" }; |
| 255 | 500 | const first = yield* cli.withVerifiedCredential(input, (identity) => |
| ... | ... | @@ -270,16 +515,17 @@ it.effect( |
| 270 | 515 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); |
| 271 | 516 | expect(encodeJson([first, second])).not.toContain("broad-credential"); |
| 272 | 517 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); |
| 273 | expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({ | |
| 274 | GH_TOKEN: "broad-credential", | |
| 275 | GITHUB_TOKEN: "broad-credential", | |
| 276 | GH_DEBUG: "", | |
| 277 | }); | |
| 518 | // The comment was written under the credential the page verified, not the switched one. | |
| 519 | expect( | |
| 520 | requests | |
| 521 | .filter((request) => request.url.endsWith("/graphql")) | |
| 522 | .map((request) => request.authorization), | |
| 523 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | |
| 278 | 524 | expect( |
| 279 | commands | |
| 280 | .filter((command) => command.args[0] === "api") | |
| 281 | .map((command) => command.env?.GH_TOKEN), | |
| 282 | ).toEqual(["broad-credential", "restricted-credential"]); | |
| 525 | requests | |
| 526 | .filter((request) => request.url.endsWith("/user")) | |
| 527 | .map((request) => request.authorization), | |
| 528 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); | |
| 283 | 529 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 284 | 530 | accountId: "123", |
| 285 | 531 | viewer: "same-account", |
| ... | ... | @@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 394 | 640 | }, |
| 395 | 641 | }); |
| 396 | 642 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 397 | expect(callAt(0).args).toEqual( | |
| 398 | expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]), | |
| 399 | ); | |
| 643 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); | |
| 400 | 644 | }), |
| 401 | 645 | ); |
| 402 | 646 | |
| 403 | 647 | it.effect("coalesces concurrent identity verification for the same host and credential", () => |
| 404 | 648 | Effect.gen(function* () { |
| 405 | mockedExecute.mockImplementation((input) => | |
| 406 | input.args[0] === "auth" | |
| 407 | ? Effect.succeed(output("shared-credential")) | |
| 408 | : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 649 | mockedExecute.mockImplementation(() => | |
| 650 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 409 | 651 | ); |
| 410 | 652 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 411 | 653 | const results = yield* Effect.all( |
| ... | ... | @@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 417 | 659 | expect(results).toEqual( |
| 418 | 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 | 669 | () => |
| 427 | 670 | Effect.gen(function* () { |
| 428 | 671 | const firstStarted = yield* Deferred.make<void>(); |
| 429 | const secondStarted = yield* Deferred.make<void>(); | |
| 430 | let tokens = 0; | |
| 431 | 672 | let verifications = 0; |
| 432 | mockedExecute.mockImplementation((input) => | |
| 673 | mockedExecute.mockImplementation(() => | |
| 433 | 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 | 675 | if (++verifications === 1) { |
| 439 | 676 | yield* Deferred.succeed(firstStarted, undefined); |
| 440 | 677 | return yield* Effect.never; |
| ... | ... | @@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 446 | 683 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; |
| 447 | 684 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 448 | 685 | yield* Deferred.await(firstStarted); |
| 686 | // The second reader is queued on the same credential's lock behind the first. | |
| 449 | 687 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 450 | yield* Deferred.await(secondStarted); | |
| 688 | yield* Effect.yieldNow; | |
| 451 | 689 | yield* Fiber.interrupt(first); |
| 452 | 690 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); |
| 453 | 691 | expect(verifications).toBe(2); |
| ... | ... | @@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 479 | 717 | mockedExecute.mockReturnValueOnce( |
| 480 | 718 | Effect.succeed( |
| 481 | 719 | output( |
| 482 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 483 | JSON.stringify({ | |
| 720 | encodeJson({ | |
| 484 | 721 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, |
| 485 | 722 | }), |
| 486 | 723 | ), |
| ... | ... | @@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 526 | 763 | ); |
| 527 | 764 | assert.strictEqual(eight?.headBranch, "feat/8"); |
| 528 | 765 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 529 | const document = callAt(0).args.at(-1) ?? ""; | |
| 766 | const document = queryAt(0); | |
| 530 | 767 | expect(document).toContain( |
| 531 | 768 | 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', |
| 532 | 769 | ); |
| ... | ... | @@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 575 | 812 | // GitHub had no answer for #8, so its watch reads it in full. |
| 576 | 813 | expect(eight).toBeNull(); |
| 577 | 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 | 817 | 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', |
| 580 | 818 | ); |
| 581 | 819 | }), |
| ... | ... | @@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 588 | 826 | .mockReturnValueOnce( |
| 589 | 827 | Effect.succeed( |
| 590 | 828 | output( |
| 591 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 592 | JSON.stringify({ | |
| 593 | number: 7, | |
| 594 | title: "Reuse the summary", | |
| 595 | url: "https://github.com/acme/web/pull/7", | |
| 596 | author: { login: "octocat", name: "Octo Cat" }, | |
| 597 | baseRefName: "main", | |
| 598 | headRefName: "feat/summary", | |
| 599 | state: "OPEN", | |
| 600 | isDraft: false, | |
| 601 | mergeable: "MERGEABLE", | |
| 602 | reviewDecision: "APPROVED", | |
| 603 | additions: 12, | |
| 604 | deletions: 3, | |
| 605 | changedFiles: 2, | |
| 606 | createdAt: "2026-08-20T00:00:00.000Z", | |
| 607 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 608 | reviewRequests: [], | |
| 609 | labels: [], | |
| 610 | statusCheckRollup: [ | |
| 611 | { | |
| 612 | __typename: "CheckRun", | |
| 613 | status: "COMPLETED", | |
| 614 | conclusion: "SUCCESS", | |
| 615 | name: "ci", | |
| 829 | encodeJson({ | |
| 830 | data: { | |
| 831 | s0: { | |
| 832 | pullRequest: { | |
| 833 | number: 7, | |
| 834 | title: "Reuse the summary", | |
| 835 | url: "https://github.com/acme/web/pull/7", | |
| 836 | author: { login: "octocat", name: "Octo Cat" }, | |
| 837 | baseRefName: "main", | |
| 838 | headRefName: "feat/summary", | |
| 839 | state: "OPEN", | |
| 840 | isDraft: false, | |
| 841 | mergeable: "MERGEABLE", | |
| 842 | reviewDecision: "APPROVED", | |
| 843 | additions: 12, | |
| 844 | deletions: 3, | |
| 845 | changedFiles: 2, | |
| 846 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 847 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 848 | }, | |
| 616 | 849 | }, |
| 617 | ], | |
| 618 | body: "", | |
| 850 | }, | |
| 619 | 851 | }), |
| 620 | 852 | ), |
| 621 | 853 | ), |
| ... | ... | @@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 631 | 863 | assert.strictEqual(summary.headBranch, "feat/summary"); |
| 632 | 864 | assert.strictEqual(summary.checksState, "passing"); |
| 633 | 865 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 634 | expect(callAt(1).args).toEqual([ | |
| 635 | "pr", | |
| 636 | "view", | |
| 637 | "7", | |
| 638 | "--repo", | |
| 639 | "github.com/acme/web", | |
| 640 | "--json", | |
| 641 | expect.stringContaining("statusCheckRollup"), | |
| 642 | ]); | |
| 866 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); | |
| 643 | 867 | }), |
| 644 | 868 | ); |
| 645 | 869 | |
| ... | ... | @@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 648 | 872 | mockedExecute.mockReturnValueOnce( |
| 649 | 873 | Effect.succeed( |
| 650 | 874 | output( |
| 651 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 652 | JSON.stringify([ | |
| 875 | encodeJson([ | |
| 653 | 876 | { |
| 654 | 877 | id: 42, |
| 655 | 878 | number: 3, |
| ... | ... | @@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 688 | 911 | { number: 7, headBranch: "feat/two", state: "open" }, |
| 689 | 912 | ], |
| 690 | 913 | }); |
| 691 | assert.deepStrictEqual(callAt(0).args, [ | |
| 692 | "api", | |
| 693 | "--hostname", | |
| 694 | "ghe.example.com", | |
| 695 | "repos/acme/web/stacks?pull_request=7", | |
| 696 | ]); | |
| 914 | expect(callAt(0)).toMatchObject({ | |
| 915 | kind: "rest", | |
| 916 | host: "ghe.example.com", | |
| 917 | path: "repos/acme/web/stacks?pull_request=7", | |
| 918 | }); | |
| 697 | 919 | }), |
| 698 | 920 | ); |
| 699 | 921 | |
| ... | ... | @@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 707 | 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 | |
| 711 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); | |
| 932 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); | |
| 712 | 933 | mockedExecute.mockReturnValueOnce( |
| 713 | 934 | Effect.succeed( |
| 714 | 935 | output( |
| 715 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 716 | JSON.stringify({ | |
| 936 | encodeJson({ | |
| 717 | 937 | ...minimal, |
| 718 | 938 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], |
| 719 | 939 | }), |
| ... | ... | @@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 733 | 953 | headSha: "abc123", |
| 734 | 954 | isDraft: false, |
| 735 | 955 | }); |
| 736 | expect(callAt(1).args).toEqual([ | |
| 737 | "api", | |
| 738 | "--hostname", | |
| 739 | "github.com", | |
| 740 | "repos/acme/web/stacks/3", | |
| 741 | ]); | |
| 956 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); | |
| 742 | 957 | }), |
| 743 | 958 | ); |
| 744 | 959 | |
| ... | ... | @@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 760 | 975 | |
| 761 | 976 | it.effect("reads a host that refuses the stacks preview as not stacked", () => |
| 762 | 977 | Effect.gen(function* () { |
| 763 | // The CLI classifies a missing preview endpoint as not found. | |
| 764 | 978 | mockedExecute.mockReturnValueOnce( |
| 765 | 979 | Effect.fail( |
| 766 | new GitHubCli.GitHubPullRequestNotFoundError({ | |
| 767 | command: "gh", | |
| 768 | cwd: "/w", | |
| 769 | cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), | |
| 980 | new GitHubApi.GitHubApiNotFoundError({ | |
| 981 | host: "github.com", | |
| 982 | operation: "getPullRequestStack", | |
| 770 | 983 | }), |
| 771 | 984 | ), |
| 772 | 985 | ); |
| ... | ... | @@ -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 | 1000 | Effect.gen(function* () { |
| 788 | 1001 | mockedExecute.mockReturnValueOnce( |
| 789 | 1002 | Effect.fail( |
| 790 | new GitHubCli.GitHubCliAuthenticationError({ | |
| 791 | command: "gh", | |
| 792 | cwd: "/w", | |
| 793 | cause: new Error("gh auth login"), | |
| 1003 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 1004 | host: "github.com", | |
| 1005 | operation: "getPullRequestStack", | |
| 794 | 1006 | }), |
| 795 | 1007 | ), |
| 796 | 1008 | ); |
| ... | ... | @@ -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 | 1024 | it.effect("preserves transient stack failures instead of reporting no stack", () => |
| 813 | 1025 | Effect.gen(function* () { |
| 814 | const failure = new GitHubCli.GitHubCliCommandError({ | |
| 815 | command: "gh", | |
| 816 | cwd: "/w", | |
| 817 | cause: new Error("HTTP 503"), | |
| 1026 | const failure = new GitHubApi.GitHubApiResponseError({ | |
| 1027 | host: "github.com", | |
| 1028 | operation: "getPullRequestStack", | |
| 1029 | status: 503, | |
| 818 | 1030 | }); |
| 819 | 1031 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); |
| 820 | 1032 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 867 | 1079 | |
| 868 | 1080 | assert.strictEqual(batch.items.length, 3); |
| 869 | 1081 | assert.isFalse(batch.truncated); |
| 870 | const args = callAt(0).args; | |
| 871 | expect(args).toContain("--repo"); | |
| 872 | expect(args).toContain("github.com/acme/web"); | |
| 873 | expect(args).toContain("--state"); | |
| 874 | expect(args).toContain("open"); | |
| 875 | expect(args).toContain("--limit"); | |
| 876 | expect(args).toContain("11"); | |
| 1082 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); | |
| 1083 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1084 | expect(queryAt(0)).toContain("first: 11"); | |
| 877 | 1085 | }), |
| 878 | 1086 | ); |
| 879 | 1087 | |
| ... | ... | @@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 899 | 1107 | |
| 900 | 1108 | it.effect("excludes merged pull requests from the Closed tab", () => |
| 901 | 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 | 1117 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 904 | 1118 | |
| 905 | 1119 | yield* cli.listPullRequests({ |
| ... | ... | @@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 912 | 1126 | limit: 10, |
| 913 | 1127 | }); |
| 914 | 1128 | |
| 915 | // `--state closed` includes merged pull requests, so the tab narrows through search. | |
| 916 | expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc"); | |
| 1129 | // A closed pull request may also be a merged one, so the tab narrows through search. | |
| 1130 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); | |
| 917 | 1131 | }), |
| 918 | 1132 | ); |
| 919 | 1133 | |
| 920 | 1134 | it.effect("narrows to the author on the authored tab", () => |
| 921 | 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 | 1143 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 924 | 1144 | |
| 925 | 1145 | yield* cli.listPullRequests({ |
| ... | ... | @@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 932 | 1152 | limit: 10, |
| 933 | 1153 | }); |
| 934 | 1154 | |
| 935 | const args = callAt(0).args; | |
| 936 | expect(args).toContain("--author"); | |
| 937 | expect(args).toContain("bilal"); | |
| 1155 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); | |
| 938 | 1156 | }), |
| 939 | 1157 | ); |
| 940 | 1158 | |
| 941 | 1159 | it.effect("narrows through search on the reviewing tab", () => |
| 942 | 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 | 1168 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 945 | 1169 | |
| 946 | 1170 | yield* cli.listPullRequests({ |
| ... | ... | @@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 953 | 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 | 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 | 1238 | Effect.gen(function* () { |
| 1013 | 1239 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1014 | 1240 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1024 | 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 | |
| 1028 | // it — and the whole document travels over stdin rather than in a visible argv. | |
| 1253 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. | |
| 1029 | 1254 | assert.strictEqual( |
| 1030 | 1255 | searchQueryOfCall(0), |
| 1031 | 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 | 1363 | |
| 1140 | 1364 | it.effect("enriches only the visible fallback rows after filtering and widening", () => |
| 1141 | 1365 | Effect.gen(function* () { |
| 1142 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1366 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1143 | 1367 | mockedExecute.mockReturnValueOnce( |
| 1144 | Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1368 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1145 | 1369 | ); |
| 1146 | 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 | 1373 | mockedStackMemberships.mockReturnValueOnce( |
| 1150 | 1374 | Effect.succeed( |
| ... | ... | @@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1180 | 1404 | expect(batch.truncated).toBe(true); |
| 1181 | 1405 | expect(batch.continues).toBe(false); |
| 1182 | 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 | 1409 | expect(query).toContain("pullRequest(number: 4)"); |
| 1185 | 1410 | expect(query).toContain("pullRequest(number: 5)"); |
| 1186 | 1411 | expect(query).not.toContain("pullRequest(number: 1)"); |
| ... | ... | @@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1192 | 1417 | Effect.gen(function* () { |
| 1193 | 1418 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); |
| 1194 | 1419 | mockedStackMemberships.mockImplementation((input) => |
| 1195 | input.args.at(-1)?.includes("pullRequest(number: 26)") | |
| 1420 | input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") | |
| 1196 | 1421 | ? Effect.fail( |
| 1197 | new GitHubCli.GitHubCliCommandError({ | |
| 1198 | command: "gh", | |
| 1199 | cwd: "/w", | |
| 1200 | cause: new Error("HTTP 502"), | |
| 1422 | new GitHubApi.GitHubApiResponseError({ | |
| 1423 | host: "github.com", | |
| 1424 | operation: "listPullRequestStackMemberships", | |
| 1425 | status: 502, | |
| 1201 | 1426 | }), |
| 1202 | 1427 | ) |
| 1203 | 1428 | : Effect.succeed( |
| ... | ... | @@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1238 | 1463 | |
| 1239 | 1464 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => |
| 1240 | 1465 | Effect.gen(function* () { |
| 1241 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1242 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1466 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1467 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); | |
| 1243 | 1468 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); |
| 1244 | 1469 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1245 | 1470 | const input = { |
| ... | ... | @@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1268 | 1493 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is |
| 1269 | 1494 | // dropped rather than shown as a change of no size. |
| 1270 | 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 | 1499 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1285 | 1510 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, |
| 1286 | 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 | 1514 | expect(document).toContain('s0: repository(owner: "acme", name: "web")'); |
| 1290 | 1515 | expect(document).toContain("pullRequest(number: 25)"); |
| 1291 | 1516 | }), |
| ... | ... | @@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1310 | 1535 | |
| 1311 | 1536 | it.effect("hands a search to GitHub rather than to the rows already read", () => |
| 1312 | 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 | 1545 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1315 | 1546 | |
| 1316 | 1547 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1326 | 1557 | |
| 1327 | 1558 | // The recency qualifier rides along, because free text would otherwise reorder the page |
| 1328 | 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 | 1566 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => |
| 1334 | 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 | 1575 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1337 | 1576 | |
| 1338 | 1577 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1346 | 1585 | query: "page", |
| 1347 | 1586 | }); |
| 1348 | 1587 | |
| 1349 | // One `--search` is all gh reads, so a second would silently drop the first. | |
| 1350 | const args = callAt(0).args; | |
| 1351 | assert.strictEqual(args.filter((arg) => arg === "--search").length, 1); | |
| 1352 | expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc'); | |
| 1588 | expect(searchOfCall(0)).toBe( | |
| 1589 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', | |
| 1590 | ); | |
| 1353 | 1591 | }), |
| 1354 | 1592 | ); |
| 1355 | 1593 | |
| 1356 | 1594 | it.effect("carries the further narrowings into the search as qualifiers", () => |
| 1357 | 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 | 1603 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1360 | 1604 | |
| 1361 | 1605 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1379 | 1623 | // Quotes around anything a reader typed, and the one character that could end a quoted |
| 1380 | 1624 | // value early dropped rather than escaped. |
| 1381 | 1625 | expect(searchOfCall(0)).toBe( |
| 1382 | 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' + | |
| 1383 | "review:changes_requested status:failure sort:updated-desc", | |
| 1626 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + | |
| 1627 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", | |
| 1384 | 1628 | ); |
| 1385 | 1629 | }), |
| 1386 | 1630 | ); |
| 1387 | 1631 | |
| 1388 | 1632 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => |
| 1389 | 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 | 1641 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1392 | 1642 | |
| 1393 | 1643 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1401 | 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 | 1658 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => |
| 1409 | 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 | 1667 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1412 | 1668 | |
| 1413 | 1669 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1422 | 1678 | }); |
| 1423 | 1679 | |
| 1424 | 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'); | |
| 1426 | expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc'); | |
| 1681 | expect(searchOfCall(0)).toBe( | |
| 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 | 1688 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", |
| 1432 | 1689 | () => |
| 1433 | 1690 | Effect.gen(function* () { |
| 1434 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1691 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1435 | 1692 | mockedExecute.mockReturnValueOnce( |
| 1436 | 1693 | Effect.succeed( |
| 1437 | 1694 | output( |
| 1438 | pullRequests(2, 1, (number) => ({ | |
| 1439 | statusCheckRollup: | |
| 1440 | number === 1 | |
| 1441 | ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] | |
| 1442 | : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }], | |
| 1695 | listedPullRequests(2, 1, (number) => ({ | |
| 1696 | checks: number === 1 ? "SUCCESS" : "FAILURE", | |
| 1443 | 1697 | })), |
| 1444 | 1698 | ), |
| 1445 | 1699 | ), |
| ... | ... | @@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1471 | 1725 | |
| 1472 | 1726 | it.effect("fails a checks filter for a row whose checks are still pending", () => |
| 1473 | 1727 | Effect.gen(function* () { |
| 1474 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1728 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1475 | 1729 | mockedExecute.mockReturnValueOnce( |
| 1476 | Effect.succeed( | |
| 1477 | output( | |
| 1478 | pullRequests(1, 1, () => ({ | |
| 1479 | statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }], | |
| 1480 | })), | |
| 1481 | ), | |
| 1482 | ), | |
| 1730 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), | |
| 1483 | 1731 | ); |
| 1484 | 1732 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1485 | 1733 | |
| ... | ... | @@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1504 | 1752 | "falls back for a repository the index does not cover even under a judgeable filter", |
| 1505 | 1753 | () => |
| 1506 | 1754 | Effect.gen(function* () { |
| 1507 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1755 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1508 | 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 | 1759 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1512 | 1760 | |
| ... | ... | @@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1555 | 1803 | |
| 1556 | 1804 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => |
| 1557 | 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 | 1813 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1560 | 1814 | |
| 1561 | 1815 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1572 | 1826 | // Every word stays inside one phrase: nothing before it, nothing after it, and the |
| 1573 | 1827 | // leading dashes are text rather than the start of another argument. |
| 1574 | 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 | 1834 | it.effect("escapes a backslash before the quote it would otherwise let out", () => |
| 1582 | 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 | 1843 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1585 | 1844 | |
| 1586 | 1845 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1596 | 1855 | |
| 1597 | 1856 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where |
| 1598 | 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 | 1864 | it.effect("asks for nothing but the order when the reader typed only spaces", () => |
| 1604 | 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 | 1873 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1607 | 1874 | |
| 1608 | 1875 | yield* cli.listPullRequests({ |
| ... | ... | @@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1618 | 1885 | |
| 1619 | 1886 | // An empty phrase would match nothing rather than everything, so it is left out; the |
| 1620 | 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 | 1907 | |
| 1641 | 1908 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — |
| 1642 | 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 | 1913 | assert.isTrue(batch.continues); |
| 1645 | 1914 | }), |
| 1646 | 1915 | ); |
| ... | ... | @@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1650 | 1919 | // The fallback is for a repository the index does not cover. Under a text search an empty |
| 1651 | 1920 | // answer means the text matched nothing, and listing everything instead would fill the |
| 1652 | 1921 | // page with rows the reader did not search for. |
| 1653 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1922 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1654 | 1923 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1655 | 1924 | |
| 1656 | 1925 | const batch = yield* cli.listPullRequests({ |
| ... | ... | @@ -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 | 1942 | Effect.gen(function* () { |
| 1674 | 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 | 1945 | mockedExecute.mockReturnValueOnce( |
| 1677 | Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1946 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1678 | 1947 | ); |
| 1679 | 1948 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1680 | 1949 | |
| ... | ... | @@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1689 | 1958 | }); |
| 1690 | 1959 | |
| 1691 | 1960 | assert.strictEqual(batch.items.length, 3); |
| 1692 | // The fallback itself uses no search, then narrows the decoded rows locally. They still | |
| 1693 | // arrive in gh's own order, so nothing can carry on from them. | |
| 1961 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive | |
| 1962 | // newest-created first, so nothing can carry on from them. | |
| 1694 | 1963 | expect(searchOfCall(1)).toBeUndefined(); |
| 1964 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | |
| 1695 | 1965 | assert.isFalse(batch.continues); |
| 1696 | 1966 | }), |
| 1697 | 1967 | ); |
| 1698 | 1968 | |
| 1699 | 1969 | it.effect("keeps state and involvement filters on the search-free fallback", () => |
| 1700 | 1970 | Effect.gen(function* () { |
| 1701 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1971 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1702 | 1972 | mockedExecute.mockReturnValueOnce( |
| 1703 | 1973 | Effect.succeed( |
| 1704 | 1974 | output( |
| 1705 | pullRequests(4, 1, (number) => ({ | |
| 1975 | listedPullRequests(4, 1, (number) => ({ | |
| 1706 | 1976 | state: number === 4 ? "OPEN" : "CLOSED", |
| 1707 | 1977 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), |
| 1708 | 1978 | reviewRequests: |
| ... | ... | @@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1734 | 2004 | it.effect("grows the search-free fallback until it fills the filtered page", () => |
| 1735 | 2005 | Effect.gen(function* () { |
| 1736 | 2006 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); |
| 1737 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1738 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated)))); | |
| 2007 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2008 | mockedExecute.mockReturnValueOnce( | |
| 2009 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | |
| 2010 | ); | |
| 1739 | 2011 | mockedExecute.mockReturnValueOnce( |
| 1740 | 2012 | Effect.succeed( |
| 1741 | 2013 | output( |
| 1742 | pullRequests(4, 1, (number) => | |
| 2014 | listedPullRequests(4, 1, (number) => | |
| 1743 | 2015 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), |
| 1744 | 2016 | ), |
| 1745 | 2017 | ), |
| ... | ... | @@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1758 | 2030 | }); |
| 1759 | 2031 | |
| 1760 | 2032 | expect(batch.items.map((item) => item.number)).toEqual([4]); |
| 1761 | const firstFallbackArgs = callAt(1).args; | |
| 1762 | const secondFallbackArgs = callAt(2).args; | |
| 1763 | expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3"); | |
| 1764 | expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6"); | |
| 2033 | expect(queryAt(1)).toContain("first: 3,"); | |
| 2034 | expect(queryAt(2)).toContain("first: 6,"); | |
| 1765 | 2035 | assert.isFalse(batch.truncated); |
| 1766 | 2036 | }), |
| 1767 | 2037 | ); |
| 1768 | 2038 | |
| 1769 | 2039 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => |
| 1770 | 2040 | Effect.gen(function* () { |
| 1771 | mockedExecute.mockImplementation((_input) => { | |
| 1772 | if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]")); | |
| 1773 | const args = callAt(mockedExecute.mock.calls.length - 1).args; | |
| 1774 | const limit = Number(args[args.indexOf("--limit") + 1]); | |
| 2041 | // A repository with far more pull requests than the bound, none of them for this reader. | |
| 2042 | let listed = 0; | |
| 2043 | mockedExecute.mockImplementation((call) => { | |
| 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 | 2052 | return Effect.succeed( |
| 1776 | 2053 | output( |
| 1777 | pullRequests(limit, 1, () => ({ | |
| 1778 | reviewRequests: [{ login: "somebody-else" }], | |
| 1779 | })), | |
| 2054 | encodeJson({ | |
| 2055 | data: { | |
| 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 | 2073 | limit: 2, |
| 1793 | 2074 | }); |
| 1794 | 2075 | |
| 1795 | const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args; | |
| 1796 | expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000"); | |
| 2076 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. | |
| 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 | 2080 | assert.strictEqual(batch.items.length, 0); |
| 1798 | 2081 | assert.isTrue(batch.truncated); |
| 1799 | 2082 | }), |
| ... | ... | @@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1801 | 2084 | |
| 1802 | 2085 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => |
| 1803 | 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 | 2094 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1806 | 2095 | |
| 1807 | 2096 | yield* cli.listPullRequests({ |
| ... | ... | @@ -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 | 2136 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => |
| 1825 | 2137 | Effect.gen(function* () { |
| 1826 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2138 | route(["query PullRequestActionState", actionState()]); | |
| 1827 | 2139 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1828 | 2140 | |
| 1829 | 2141 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1833 | 2145 | number: 7, |
| 1834 | 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 | 2148 | yield* cli.runPullRequestAction({ |
| 1840 | 2149 | cwd: "/w", |
| 1841 | 2150 | repository: "acme/web", |
| ... | ... | @@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1844 | 2153 | action: "update-branch", |
| 1845 | 2154 | updateMethod: "rebase", |
| 1846 | 2155 | }); |
| 1847 | expect(callAt(1).args).toEqual([ | |
| 1848 | "pr", | |
| 1849 | "update-branch", | |
| 1850 | "7", | |
| 1851 | "--repo", | |
| 1852 | "github.com/acme/web", | |
| 1853 | "--rebase", | |
| 2156 | ||
| 2157 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. | |
| 2158 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ | |
| 2159 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, | |
| 2160 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, | |
| 1854 | 2161 | ]); |
| 1855 | 2162 | }), |
| 1856 | 2163 | ); |
| 1857 | 2164 | |
| 1858 | 2165 | it.effect("merges with the strategy it was asked for", () => |
| 1859 | 2166 | Effect.gen(function* () { |
| 1860 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2167 | route(["query PullRequestActionState", actionState()]); | |
| 1861 | 2168 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1862 | 2169 | |
| 1863 | 2170 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1869 | 2176 | mergeMethod: "squash", |
| 1870 | 2177 | }); |
| 1871 | 2178 | |
| 1872 | expect(callAt(0).args).toEqual([ | |
| 1873 | "pr", | |
| 1874 | "merge", | |
| 1875 | "7", | |
| 1876 | "--repo", | |
| 1877 | "github.com/acme/web", | |
| 1878 | "--squash", | |
| 2179 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2180 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 1879 | 2181 | ]); |
| 2182 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 1880 | 2183 | }), |
| 1881 | 2184 | ); |
| 1882 | 2185 | |
| ... | ... | @@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1884 | 2187 | "removes agent credits from the proposed message for %s", |
| 1885 | 2188 | (action) => |
| 1886 | 2189 | Effect.gen(function* () { |
| 1887 | mockedExecute | |
| 1888 | .mockReturnValueOnce( | |
| 1889 | Effect.succeed( | |
| 1890 | output( | |
| 1891 | encodeJson({ | |
| 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 | ), | |
| 2190 | route( | |
| 2191 | [ | |
| 2192 | "query PullRequestMergeMessage", | |
| 2193 | mergeMessage( | |
| 2194 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | |
| 1904 | 2195 | ), |
| 1905 | ) | |
| 1906 | .mockReturnValueOnce(Effect.succeed(output(""))); | |
| 2196 | ], | |
| 2197 | ["query PullRequestActionState", actionState()], | |
| 2198 | ); | |
| 1907 | 2199 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1908 | 2200 | yield* cli.runPullRequestAction({ |
| 1909 | 2201 | cwd: "/w", |
| ... | ... | @@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1914 | 2206 | mergeMethod: "squash", |
| 1915 | 2207 | removeAgentCreditsOnMerge: true, |
| 1916 | 2208 | }); |
| 1917 | expect(callAt(0).args).toContain("method=SQUASH"); | |
| 1918 | expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]); | |
| 1919 | expect(callAt(1).args).toContain("--match-head-commit"); | |
| 1920 | expect(callAt(1).args).toContain("abc123"); | |
| 1921 | expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>"); | |
| 1922 | expect(callAt(1).args.join(" ")).not.toContain("alice@example.com"); | |
| 2209 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); | |
| 2210 | expect( | |
| 2211 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), | |
| 2212 | ).toEqual([ | |
| 2213 | { | |
| 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 | 2236 | }, |
| 1937 | 2237 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => |
| 1938 | 2238 | Effect.gen(function* () { |
| 1939 | mockedExecute | |
| 1940 | .mockReturnValueOnce( | |
| 1941 | Effect.succeed( | |
| 1942 | output( | |
| 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(""))); | |
| 2239 | route( | |
| 2240 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], | |
| 2241 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], | |
| 2242 | ); | |
| 1958 | 2243 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1959 | 2244 | yield* cli.runPullRequestAction({ |
| 1960 | 2245 | cwd: "/w", |
| ... | ... | @@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1964 | 2249 | action: "merge", |
| 1965 | 2250 | removeAgentCreditsOnMerge: true, |
| 1966 | 2251 | }); |
| 1967 | expect(callAt(0).args).toContain("method=MERGE"); | |
| 1968 | expect(callAt(1).args).not.toContain("--body-file"); | |
| 1969 | expect(callAt(1).stdin).toBeUndefined(); | |
| 2252 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); | |
| 2253 | // A merge queue takes the pull request through auto-merge, with its own message. | |
| 2254 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ | |
| 2255 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2256 | ]); | |
| 1970 | 2257 | }), |
| 1971 | 2258 | ); |
| 1972 | 2259 | |
| 1973 | 2260 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => |
| 1974 | 2261 | Effect.gen(function* () { |
| 1975 | mockedExecute | |
| 1976 | .mockReturnValueOnce( | |
| 1977 | Effect.succeed( | |
| 1978 | output( | |
| 1979 | encodeJson({ | |
| 1980 | data: { | |
| 1981 | repository: { | |
| 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(""))); | |
| 2262 | route( | |
| 2263 | [ | |
| 2264 | "query PullRequestMergeMessage", | |
| 2265 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), | |
| 2266 | ], | |
| 2267 | ["query PullRequestActionState", actionState()], | |
| 2268 | ); | |
| 1994 | 2269 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 1995 | 2270 | yield* cli.runPullRequestAction({ |
| 1996 | 2271 | cwd: "/w", |
| ... | ... | @@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2000 | 2275 | action: "merge", |
| 2001 | 2276 | removeAgentCreditsOnMerge: true, |
| 2002 | 2277 | }); |
| 2003 | expect(callAt(1).stdin).toBe(""); | |
| 2004 | expect(callAt(1).args).toContain("--body-file"); | |
| 2278 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 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 | 2291 | it.effect("does not fetch a message for rebase merges", () => |
| 2009 | 2292 | Effect.gen(function* () { |
| 2010 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2293 | route(["query PullRequestActionState", actionState()]); | |
| 2011 | 2294 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2012 | 2295 | yield* cli.runPullRequestAction({ |
| 2013 | 2296 | cwd: "/w", |
| ... | ... | @@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2018 | 2301 | mergeMethod: "rebase", |
| 2019 | 2302 | removeAgentCreditsOnMerge: true, |
| 2020 | 2303 | }); |
| 2021 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2022 | expect(callAt(0).args).toContain("--rebase"); | |
| 2304 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); | |
| 2305 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2306 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | |
| 2307 | ]); | |
| 2023 | 2308 | }), |
| 2024 | 2309 | ); |
| 2025 | 2310 | |
| 2026 | 2311 | it.effect("refuses to merge when the proposed message cannot be read", () => |
| 2027 | 2312 | Effect.gen(function* () { |
| 2028 | mockedExecute.mockReturnValue( | |
| 2029 | Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')), | |
| 2313 | route( | |
| 2314 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], | |
| 2315 | ["query PullRequestActionState", actionState()], | |
| 2030 | 2316 | ); |
| 2031 | 2317 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2032 | 2318 | const result = yield* Effect.result( |
| ... | ... | @@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2040 | 2326 | }), |
| 2041 | 2327 | ); |
| 2042 | 2328 | expect(result._tag).toBe("Failure"); |
| 2043 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2329 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2044 | 2330 | }), |
| 2045 | 2331 | ); |
| 2046 | 2332 | |
| 2047 | 2333 | it.effect("arms auto-merge with the same strategy a merge would have used", () => |
| 2048 | 2334 | Effect.gen(function* () { |
| 2049 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2335 | route(["query PullRequestActionState", actionState()]); | |
| 2050 | 2336 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2051 | 2337 | |
| 2052 | 2338 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2057 | 2343 | action: "enable-auto-merge", |
| 2058 | 2344 | mergeMethod: "squash", |
| 2059 | 2345 | }); |
| 2060 | expect(callAt(0).args).toEqual([ | |
| 2061 | "pr", | |
| 2062 | "merge", | |
| 2063 | "7", | |
| 2064 | "--repo", | |
| 2065 | "github.com/acme/web", | |
| 2066 | "--auto", | |
| 2067 | "--squash", | |
| 2346 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | |
| 2347 | yield* cli.runPullRequestAction({ | |
| 2348 | cwd: "/w", | |
| 2349 | repository: "acme/web", | |
| 2350 | host: "github.com", | |
| 2351 | number: 7, | |
| 2352 | action: "enable-auto-merge", | |
| 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 | 2368 | yield* cli.runPullRequestAction({ |
| 2072 | 2369 | cwd: "/w", |
| 2073 | 2370 | repository: "acme/web", |
| 2074 | 2371 | host: "github.com", |
| 2075 | 2372 | number: 7, |
| 2076 | 2373 | action: "enable-auto-merge", |
| 2374 | mergeMethod: "squash", | |
| 2077 | 2375 | }); |
| 2078 | expect(callAt(1).args).toEqual([ | |
| 2079 | "pr", | |
| 2080 | "merge", | |
| 2081 | "7", | |
| 2082 | "--repo", | |
| 2083 | "github.com/acme/web", | |
| 2084 | "--auto", | |
| 2085 | "--merge", | |
| 2376 | ||
| 2377 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2378 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2086 | 2379 | ]); |
| 2380 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2087 | 2381 | }), |
| 2088 | 2382 | ); |
| 2089 | 2383 | |
| 2090 | 2384 | it.effect("takes auto-merge back off without naming a strategy", () => |
| 2091 | 2385 | Effect.gen(function* () { |
| 2092 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2386 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2093 | 2387 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2094 | 2388 | |
| 2095 | 2389 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2101 | 2395 | mergeMethod: "squash", |
| 2102 | 2396 | }); |
| 2103 | 2397 | |
| 2104 | expect(callAt(0).args).toEqual([ | |
| 2105 | "pr", | |
| 2106 | "merge", | |
| 2107 | "7", | |
| 2108 | "--repo", | |
| 2109 | "github.com/acme/web", | |
| 2110 | "--disable-auto", | |
| 2111 | ]); | |
| 2398 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2112 | 2399 | }), |
| 2113 | 2400 | ); |
| 2114 | 2401 | |
| 2115 | 2402 | it.effect("opens a pull request that reverts a merged pull request", () => |
| 2116 | 2403 | Effect.gen(function* () { |
| 2117 | mockedExecute.mockReturnValueOnce( | |
| 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("{}"))); | |
| 2404 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2128 | 2405 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2129 | 2406 | |
| 2130 | 2407 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2135 | 2412 | action: "revert", |
| 2136 | 2413 | }); |
| 2137 | 2414 | |
| 2138 | expect(callAt(0).args).toContain("owner=acme"); | |
| 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"'); | |
| 2415 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2151 | 2416 | }), |
| 2152 | 2417 | ); |
| 2153 | 2418 | |
| ... | ... | @@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2189 | 2454 | |
| 2190 | 2455 | it.effect("finds and approves every workflow waiting on a maintainer", () => |
| 2191 | 2456 | Effect.gen(function* () { |
| 2192 | const detail = output( | |
| 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 | } | |
| 2457 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); | |
| 2241 | 2458 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2242 | 2459 | |
| 2243 | 2460 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2248 | 2465 | action: "approve-workflows", |
| 2249 | 2466 | }); |
| 2250 | 2467 | |
| 2251 | expect(callAt(1).args).toEqual([ | |
| 2252 | "pr", | |
| 2253 | "list", | |
| 2254 | "--repo", | |
| 2255 | "github.com/acme/web", | |
| 2256 | "--state", | |
| 2257 | "open", | |
| 2258 | "--head", | |
| 2259 | "feat/page", | |
| 2260 | "--limit", | |
| 2261 | "1001", | |
| 2262 | "--json", | |
| 2263 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | |
| 2264 | ]); | |
| 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 | ], | |
| 2468 | // Open pull requests on this head branch, and runs scoped to this exact head. | |
| 2469 | expect(variablesOf("headRefName: $head")[0]).toEqual({ | |
| 2470 | owner: "acme", | |
| 2471 | name: "web", | |
| 2472 | head: "feat/page", | |
| 2473 | after: null, | |
| 2474 | }); | |
| 2475 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( | |
| 2476 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", | |
| 2477 | ); | |
| 2478 | // Each run is approved only after the head is read again and still lists it. | |
| 2479 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ | |
| 2480 | ["POST", "repos/acme/web/actions/runs/10/approve"], | |
| 2481 | ["POST", "repos/acme/web/actions/runs/11/approve"], | |
| 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 | 2488 | Effect.gen(function* () { |
| 2309 | const detail = { | |
| 2310 | number: 7, | |
| 2311 | title: "Pull request 7", | |
| 2312 | url: "https://github.com/acme/web/pull/7", | |
| 2313 | headRefName: "feat/page", | |
| 2314 | headRefOid: "abc123", | |
| 2315 | isCrossRepository: true, | |
| 2316 | headRepositoryOwner: { login: "octocat" }, | |
| 2317 | baseRefName: "main", | |
| 2318 | createdAt: "2026-07-01T00:00:00Z", | |
| 2319 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2320 | }; | |
| 2321 | for (const value of [ | |
| 2322 | coreResponse(detail), | |
| 2323 | [ | |
| 2324 | { | |
| 2325 | number: 7, | |
| 2326 | headRefOid: "abc123", | |
| 2327 | isCrossRepository: true, | |
| 2328 | headRepositoryOwner: { login: "octocat" }, | |
| 2329 | }, | |
| 2330 | ], | |
| 2331 | [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], | |
| 2332 | coreResponse({ ...detail, headRefOid: "def456" }), | |
| 2333 | ]) { | |
| 2334 | mockedExecute.mockReturnValueOnce( | |
| 2335 | Effect.succeed( | |
| 2336 | output( | |
| 2337 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 2338 | JSON.stringify(value), | |
| 2339 | ), | |
| 2340 | ), | |
| 2489 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); | |
| 2490 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2491 | ||
| 2492 | const runs = yield* cli | |
| 2493 | .listWorkflowRunsRequiringApproval({ | |
| 2494 | cwd: "/w", | |
| 2495 | repository: "acme/web", | |
| 2496 | host: "github.com", | |
| 2497 | number: 7, | |
| 2498 | headSha: "abc123", | |
| 2499 | headBranch: "feat/page", | |
| 2500 | headRepositoryOwner: "octocat", | |
| 2501 | isCrossRepository: true, | |
| 2502 | }) | |
| 2503 | .pipe( | |
| 2504 | Effect.provideService(KnownWorkflowRuns, { | |
| 2505 | headSha: "abc123", | |
| 2506 | runs: [ | |
| 2507 | // Live shape: waiting on approval is completed + action_required. | |
| 2508 | { | |
| 2509 | id: 10, | |
| 2510 | status: "completed", | |
| 2511 | conclusion: "action_required", | |
| 2512 | head_branch: "feat/page", | |
| 2513 | }, | |
| 2514 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, | |
| 2515 | ], | |
| 2516 | }), | |
| 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 | 2532 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2344 | 2533 | |
| 2345 | 2534 | const error = yield* Effect.flip( |
| ... | ... | @@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2356 | 2545 | _tag: "GitHubWorkflowApprovalHeadChangedError", |
| 2357 | 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 | 2554 | Effect.gen(function* () { |
| 2365 | 2555 | const headsStarted = yield* Deferred.make<void>(); |
| 2366 | 2556 | const runsStarted = yield* Deferred.make<void>(); |
| 2367 | mockedExecute.mockImplementation(({ args }) => | |
| 2368 | args[0] === "pr" | |
| 2557 | mockedExecute.mockImplementation((call) => | |
| 2558 | call.kind === "graphql" | |
| 2369 | 2559 | ? Deferred.succeed(headsStarted, undefined).pipe( |
| 2370 | 2560 | Effect.andThen(Deferred.await(runsStarted)), |
| 2371 | Effect.as( | |
| 2372 | output( | |
| 2373 | '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]', | |
| 2374 | ), | |
| 2375 | ), | |
| 2561 | Effect.as(output(encodeJson(heads([7])))), | |
| 2376 | 2562 | ) |
| 2377 | 2563 | : Deferred.succeed(runsStarted, undefined).pipe( |
| 2378 | 2564 | Effect.andThen(Deferred.await(headsStarted)), |
| 2379 | Effect.as( | |
| 2380 | output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'), | |
| 2381 | ), | |
| 2565 | Effect.as(output(encodeJson(workflowRuns([10])))), | |
| 2382 | 2566 | ), |
| 2383 | 2567 | ); |
| 2384 | 2568 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2400 | 2584 | |
| 2401 | 2585 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => |
| 2402 | 2586 | Effect.gen(function* () { |
| 2403 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 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 | ); | |
| 2587 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); | |
| 2419 | 2588 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2420 | 2589 | |
| 2421 | 2590 | const error = yield* Effect.flip( |
| ... | ... | @@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2438 | 2607 | observedCount: 2, |
| 2439 | 2608 | limit: 1_000, |
| 2440 | 2609 | }); |
| 2441 | expect(error.detail).toContain("instead of uniquely matching #7"); | |
| 2442 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 2610 | expect(error.message).toContain("instead of uniquely matching #7"); | |
| 2443 | 2611 | }), |
| 2444 | 2612 | ); |
| 2445 | 2613 | |
| ... | ... | @@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2487 | 2655 | |
| 2488 | 2656 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => |
| 2489 | 2657 | Effect.gen(function* () { |
| 2490 | mockedExecute.mockReturnValueOnce( | |
| 2491 | Effect.succeed( | |
| 2492 | output( | |
| 2493 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 2494 | JSON.stringify([ | |
| 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( | |
| 2658 | let runPages = 0; | |
| 2659 | mockedExecute.mockImplementation((call) => { | |
| 2660 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); | |
| 2661 | const page = runPages++; | |
| 2662 | return Effect.succeed( | |
| 2507 | 2663 | output( |
| 2508 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 2509 | JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))), | |
| 2664 | encodeJson( | |
| 2665 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), | |
| 2666 | ), | |
| 2510 | 2667 | ), |
| 2511 | ), | |
| 2512 | ); | |
| 2668 | ); | |
| 2669 | }); | |
| 2513 | 2670 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2514 | 2671 | |
| 2515 | 2672 | const error = yield* Effect.flip( |
| ... | ... | @@ -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 | 2686 | expect(error).toMatchObject({ |
| 2529 | 2687 | _tag: "GitHubWorkflowApprovalRefusedError", |
| 2530 | 2688 | reason: "run-list-truncated", |
| 2531 | 2689 | number: 7, |
| 2532 | observedCount: 1_001, | |
| 2690 | observedCount: 1_100, | |
| 2533 | 2691 | limit: 1_000, |
| 2534 | 2692 | }); |
| 2535 | expect(error.detail).toContain("more than 1000 workflow runs"); | |
| 2536 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 2693 | expect(error.message).toContain("more than 1000 workflow runs"); | |
| 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 | 2699 | Effect.gen(function* () { |
| 2542 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2700 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2543 | 2701 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2544 | 2702 | |
| 2545 | 2703 | yield* cli.runPullRequestAction({ |
| ... | ... | @@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2550 | 2708 | action: "draft", |
| 2551 | 2709 | }); |
| 2552 | 2710 | |
| 2553 | // gh has no `draft` command; going back is `ready --undo`. | |
| 2554 | expect(callAt(0).args).toEqual([ | |
| 2555 | "pr", | |
| 2556 | "ready", | |
| 2557 | "7", | |
| 2558 | "--repo", | |
| 2559 | "github.com/acme/web", | |
| 2560 | "--undo", | |
| 2561 | ]); | |
| 2711 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2712 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); | |
| 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 | 2717 | Effect.gen(function* () { |
| 2567 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); | |
| 2718 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2568 | 2719 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2569 | 2720 | |
| 2570 | 2721 | yield* cli.commentOnPullRequest({ |
| ... | ... | @@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2575 | 2726 | body: "Looks good.", |
| 2576 | 2727 | }); |
| 2577 | 2728 | |
| 2578 | // argv shows up in process listings and in process-runner failure messages. | |
| 2579 | expect(callAt(0).args).toEqual([ | |
| 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."); | |
| 2729 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); | |
| 2730 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); | |
| 2590 | 2731 | }), |
| 2591 | 2732 | ); |
| 2592 | 2733 | |
| 2593 | 2734 | it.effect("names the host on every repository it addresses", () => |
| 2594 | 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 | 2743 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2597 | 2744 | |
| 2598 | 2745 | yield* cli.listPullRequests({ |
| ... | ... | @@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2605 | 2752 | limit: 10, |
| 2606 | 2753 | }); |
| 2607 | 2754 | |
| 2608 | // A bare `owner/repo` resolves against github.com, which is a different repository. | |
| 2609 | expect(callAt(0).args).toContain("github.acme.dev/acme/web"); | |
| 2755 | // A request sent to github.com would read a different repository of the same name. | |
| 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 | 2766 | mockedExecute.mockReturnValueOnce( |
| 2616 | 2767 | Effect.succeed( |
| 2617 | 2768 | output( |
| 2618 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 2619 | JSON.stringify({ | |
| 2769 | encodeJson({ | |
| 2620 | 2770 | data: { |
| 2621 | 2771 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, |
| 2622 | 2772 | }, |
| ... | ... | @@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2633 | 2783 | number: 7, |
| 2634 | 2784 | }); |
| 2635 | 2785 | |
| 2636 | const args = callAt(0).args; | |
| 2637 | expect(args).toContain("--hostname"); | |
| 2638 | expect(args).toContain("github.acme.dev"); | |
| 2639 | expect(args).toContain("owner=acme"); | |
| 2640 | expect(args).toContain("name=web"); | |
| 2786 | expect(callAt(0).host).toBe("github.acme.dev"); | |
| 2787 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 2641 | 2788 | }), |
| 2642 | 2789 | ); |
| 2643 | 2790 | |
| ... | ... | @@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2653 | 2800 | number: 7, |
| 2654 | 2801 | }); |
| 2655 | 2802 | |
| 2803 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | |
| 2656 | 2804 | assert.isNull(diff.nextCursor); |
| 2657 | 2805 | assert.isFalse(diff.truncated); |
| 2658 | 2806 | // The common case pays for one request and not the files API on top of it. |
| 2659 | 2807 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 2660 | // `--patch` asks gh for a format-patch stream, which repeats a file once per commit. | |
| 2661 | // The review needs GitHub's combined pull-request diff: one section per changed file. | |
| 2662 | expect(callAt(0).args).not.toContain("--patch"); | |
| 2808 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. | |
| 2809 | const call = callAt(0); | |
| 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 | 2834 | assert.isNull(diff.nextCursor); |
| 2683 | 2835 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 2684 | 2836 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); |
| 2685 | const args = callAt(1).args; | |
| 2686 | expect(args).toContain("--hostname"); | |
| 2687 | expect(args).toContain("github.acme.dev"); | |
| 2688 | expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 2837 | assert.strictEqual(callAt(1).host, "github.acme.dev"); | |
| 2838 | assert.strictEqual(pathAt(1), "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 | 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 | 2863 | Effect.gen(function* () { |
| 2714 | 2864 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 2715 | 2865 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); |
| ... | ... | @@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2725 | 2875 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); |
| 2726 | 2876 | // The second slice is one request: the cursor already says where to read. |
| 2727 | 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 | 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 | 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 | 2906 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2755 | 2907 | |
| 2756 | 2908 | const diff = yield* cli.getPullRequestDiff({ |
| ... | ... | @@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2761 | 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 | 2917 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 2766 | 2918 | assert.isNull(diff.nextCursor); |
| 2919 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | |
| 2767 | 2920 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 2768 | const args = callAt(0).args; | |
| 2769 | expect(args).toContain( | |
| 2921 | assert.strictEqual( | |
| 2922 | pathAt(0), | |
| 2770 | 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 | 2928 | it.effect("pages inside a commit the way it pages the pull request's own files", () => |
| 2778 | 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 | 2933 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2781 | 2934 | const target = { |
| 2782 | 2935 | cwd: "/w", |
| ... | ... | @@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2788 | 2941 | |
| 2789 | 2942 | const first = yield* cli.getPullRequestDiff(target); |
| 2790 | 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 | 2947 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); |
| 2793 | 2948 | |
| 2794 | 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 | 2973 | |
| 2818 | 2974 | it.effect("expands a new file from a root commit without requiring a parent", () => |
| 2819 | 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 | 2979 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); |
| 2822 | 2980 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2823 | 2981 | |
| ... | ... | @@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2834 | 2992 | |
| 2835 | 2993 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); |
| 2836 | 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 | 3025 | |
| 2867 | 3026 | it.effect("reports an oversized diff file with its path and reason", () => |
| 2868 | 3027 | Effect.gen(function* () { |
| 2869 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | |
| 3028 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 2870 | 3029 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); |
| 2871 | 3030 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2872 | 3031 | |
| ... | ... | @@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2887 | 3046 | assert.strictEqual(error.path, "src/large.ts"); |
| 2888 | 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 | 3054 | it.effect("reports undecodable diff file contents as binary", () => |
| 2894 | 3055 | Effect.gen(function* () { |
| 2895 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | |
| 2896 | mockedExecute.mockReturnValueOnce( | |
| 2897 | Effect.succeed(output("binary\uFFFDcontents", false, true)), | |
| 2898 | ); | |
| 3056 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3057 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); | |
| 2899 | 3058 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2900 | 3059 | |
| 2901 | 3060 | const error = yield* Effect.flip( |
| ... | ... | @@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2920 | 3079 | |
| 2921 | 3080 | it.effect("returns valid text containing a literal replacement character", () => |
| 2922 | 3081 | Effect.gen(function* () { |
| 2923 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); | |
| 2924 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); | |
| 3082 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3083 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); | |
| 2925 | 3084 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2926 | 3085 | |
| 2927 | 3086 | const contents = yield* cli.getPullRequestDiffFileContents({ |
| ... | ... | @@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2934 | 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 | 3155 | mockedExecute.mockReturnValueOnce( |
| 2997 | 3156 | Effect.succeed( |
| 2998 | 3157 | output( |
| 2999 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3000 | JSON.stringify({ | |
| 3158 | encodeJson({ | |
| 3001 | 3159 | data: { |
| 3002 | 3160 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], |
| 3003 | 3161 | rateLimit: { |
| ... | ... | @@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3020 | 3178 | ids: ["MDQ6VXNlcjE="], |
| 3021 | 3179 | }); |
| 3022 | 3180 | |
| 3023 | expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE="); | |
| 3024 | expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 3181 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); | |
| 3182 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 3025 | 3183 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); |
| 3026 | 3184 | }), |
| 3027 | 3185 | ); |
| ... | ... | @@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3039 | 3197 | |
| 3040 | 3198 | it.effect("looks up the authenticated account on the requested enterprise host", () => |
| 3041 | 3199 | Effect.gen(function* () { |
| 3042 | mockedExecute | |
| 3043 | .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential"))) | |
| 3044 | .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}'))); | |
| 3200 | mockedExecute.mockReturnValueOnce( | |
| 3201 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), | |
| 3202 | ); | |
| 3045 | 3203 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3046 | 3204 | |
| 3047 | 3205 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); |
| 3048 | 3206 | |
| 3049 | 3207 | expect(login).toBe("enterprise-user"); |
| 3050 | expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]); | |
| 3051 | expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]); | |
| 3208 | expect(mockedCredential).toHaveBeenCalledWith("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 | 3214 | Effect.gen(function* () { |
| 3057 | 3215 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3058 | 3216 | const input = { cwd: "/w", host: "github.identity-cache.test" }; |
| 3059 | mockedExecute | |
| 3060 | .mockReturnValueOnce(Effect.succeed(output("test-credential-a"))) | |
| 3061 | .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3217 | mockedCredential.mockImplementation((host) => | |
| 3218 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), | |
| 3219 | ); | |
| 3220 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3062 | 3221 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3063 | 3222 | accountId: "123", |
| 3064 | 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 | 3227 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3073 | 3228 | accountId: "123", |
| 3074 | 3229 | viewer: "maria-rcks", |
| 3075 | 3230 | }); |
| 3076 | expect(mockedExecute).toHaveBeenCalledTimes(3); | |
| 3231 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 3077 | 3232 | |
| 3078 | mockedExecute | |
| 3079 | .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) | |
| 3080 | .mockReturnValueOnce( | |
| 3081 | Effect.fail( | |
| 3082 | new GitHubCli.GitHubCliCommandError({ | |
| 3083 | command: "gh", | |
| 3084 | cwd: "/w", | |
| 3085 | cause: new Error("upstream failed with test-credential-b"), | |
| 3086 | }), | |
| 3087 | ), | |
| 3088 | ); | |
| 3233 | // A switched credential is not trusted on the strength of the old one's answer. | |
| 3234 | mockedCredential.mockImplementation((host) => | |
| 3235 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), | |
| 3236 | ); | |
| 3237 | mockedExecute.mockReturnValueOnce( | |
| 3238 | Effect.fail( | |
| 3239 | new GitHubApi.GitHubApiResponseError({ | |
| 3240 | host: "github.com", | |
| 3241 | operation: "getRoutingIdentity", | |
| 3242 | status: 502, | |
| 3243 | }), | |
| 3244 | ), | |
| 3245 | ); | |
| 3246 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | |
| 3089 | 3247 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); |
| 3090 | expect(failure._tag).toBe("GitHubViewerLoginUnavailableError"); | |
| 3248 | expect(failure._tag).toBe("GitHubApiResponseError"); | |
| 3091 | 3249 | expect(String(failure)).not.toContain("test-credential-b"); |
| 3092 | expect(callAt(4).env).toMatchObject({ | |
| 3093 | GH_ENTERPRISE_TOKEN: "test-credential-b", | |
| 3094 | GH_DEBUG: "", | |
| 3095 | }); | |
| 3250 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3096 | 3251 | |
| 3097 | mockedExecute | |
| 3098 | .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) | |
| 3099 | .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3252 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3100 | 3253 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3101 | 3254 | accountId: "456", |
| 3102 | 3255 | viewer: "maria-rcks", |
| ... | ... | @@ -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 | 3261 | Effect.gen(function* () { |
| 3109 | 3262 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3110 | 3263 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3119 | 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 | 3275 | // One request, so nothing is on the pull request until the verdict is. |
| 3133 | 3276 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3134 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3135 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ | |
| 3136 | event: "APPROVE", | |
| 3137 | body: "Looks right.", | |
| 3138 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | |
| 3139 | }); | |
| 3277 | expect(restCallsTo("/reviews")).toMatchObject([ | |
| 3278 | { | |
| 3279 | method: "POST", | |
| 3280 | path: "repos/acme/web/pulls/7/reviews", | |
| 3281 | body: { | |
| 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 | 3292 | Effect.gen(function* () { |
| 3145 | 3293 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3146 | 3294 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| ... | ... | @@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3153 | 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. | |
| 3157 | expect(callAt(0).args).toEqual([ | |
| 3158 | "api", | |
| 3159 | "graphql", | |
| 3160 | "--hostname", | |
| 3161 | "github.com", | |
| 3162 | "--input", | |
| 3163 | "-", | |
| 3304 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ | |
| 3305 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, | |
| 3164 | 3306 | ]); |
| 3165 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 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."); | |
| 3307 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); | |
| 3173 | 3308 | }), |
| 3174 | 3309 | ); |
| 3175 | 3310 | |
| ... | ... | @@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3193 | 3328 | resolved: false, |
| 3194 | 3329 | }); |
| 3195 | 3330 | |
| 3196 | const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string }; | |
| 3197 | expect(parse(0).query).toContain("resolveReviewThread("); | |
| 3198 | expect(parse(1).query).toContain("unresolveReviewThread("); | |
| 3331 | expect(queryAt(0)).toContain("resolveReviewThread("); | |
| 3332 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); | |
| 3333 | expect(queryAt(1)).toContain("unresolveReviewThread("); | |
| 3334 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | |
| 3199 | 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 | 3340 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => |
| 3205 | 3341 | Effect.gen(function* () { |
| 3206 | mockedExecute.mockReturnValueOnce( | |
| 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("{}"))); | |
| 3342 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3220 | 3343 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3221 | 3344 | |
| 3222 | 3345 | yield* cli.setReaction({ |
| ... | ... | @@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3230 | 3353 | }); |
| 3231 | 3354 | |
| 3232 | 3355 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3233 | const scopeCheck = callAt(0).args; | |
| 3234 | expect(scopeCheck).toContain("owner=acme"); | |
| 3235 | expect(scopeCheck).toContain("name=web"); | |
| 3236 | expect(scopeCheck).toContain("number=7"); | |
| 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" }); | |
| 3356 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ | |
| 3357 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, | |
| 3358 | ]); | |
| 3359 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3245 | 3360 | }), |
| 3246 | 3361 | ); |
| 3247 | 3362 | |
| ... | ... | @@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3250 | 3365 | mockedExecute.mockReturnValueOnce( |
| 3251 | 3366 | Effect.succeed( |
| 3252 | 3367 | output( |
| 3253 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3254 | JSON.stringify({ | |
| 3368 | encodeJson({ | |
| 3255 | 3369 | data: { |
| 3256 | 3370 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3257 | 3371 | // A comment on pull request #99 of a different repository, named as though it |
| ... | ... | @@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3284 | 3398 | |
| 3285 | 3399 | it.effect("looks up the pull request's own node id when no subject was given", () => |
| 3286 | 3400 | Effect.gen(function* () { |
| 3287 | mockedExecute.mockReturnValueOnce( | |
| 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("{}"))); | |
| 3401 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3296 | 3402 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3297 | 3403 | |
| 3298 | 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 | 3412 | }); |
| 3307 | 3413 | |
| 3308 | 3414 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3309 | const lookup = callAt(0).args; | |
| 3310 | expect(lookup).toContain("owner=acme"); | |
| 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" }); | |
| 3415 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); | |
| 3416 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); | |
| 3320 | 3417 | }), |
| 3321 | 3418 | ); |
| 3322 | 3419 | |
| 3323 | 3420 | it.effect("takes a reaction back through the remove mutation", () => |
| 3324 | 3421 | Effect.gen(function* () { |
| 3325 | mockedExecute.mockReturnValueOnce( | |
| 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("{}"))); | |
| 3422 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3339 | 3423 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3340 | 3424 | |
| 3341 | 3425 | yield* cli.setReaction({ |
| ... | ... | @@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3348 | 3432 | reacted: false, |
| 3349 | 3433 | }); |
| 3350 | 3434 | |
| 3351 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3352 | const request = JSON.parse(callAt(1).stdin ?? "") as { query: string }; | |
| 3353 | expect(request.query).toContain("removeReaction("); | |
| 3435 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3436 | expect(variablesOf("addReaction(")).toEqual([]); | |
| 3354 | 3437 | }), |
| 3355 | 3438 | ); |
| 3356 | 3439 | |
| 3357 | 3440 | it.effect("rewrites only the words a request named", () => |
| 3358 | 3441 | Effect.gen(function* () { |
| 3359 | mockedExecute.mockReturnValue( | |
| 3360 | Effect.succeed( | |
| 3361 | output( | |
| 3362 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3363 | JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), | |
| 3364 | ), | |
| 3365 | ), | |
| 3366 | ); | |
| 3442 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3367 | 3443 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3368 | 3444 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => |
| 3369 | 3445 | cli.updatePullRequest({ |
| ... | ... | @@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3379 | 3455 | yield* rewrite({ title: "Both", body: "at once." }); |
| 3380 | 3456 | |
| 3381 | 3457 | // One node id lookup for the pull request, then a mutation per rewrite. |
| 3382 | const variablesAt = (index: number) => | |
| 3383 | (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables; | |
| 3384 | expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); | |
| 3385 | expect(variablesAt(2)).toEqual({ | |
| 3386 | pullRequestId: "PR_kwDOA", | |
| 3387 | body: "A better description.", | |
| 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."); | |
| 3458 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); | |
| 3459 | expect(variablesOf("updatePullRequest(")).toEqual([ | |
| 3460 | { pullRequestId: "PR_kwDOA", title: "A better title" }, | |
| 3461 | { pullRequestId: "PR_kwDOA", body: "A better description." }, | |
| 3462 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, | |
| 3463 | ]); | |
| 3396 | 3464 | }), |
| 3397 | 3465 | ); |
| 3398 | 3466 | |
| 3399 | 3467 | it.effect("rewrites a remark through the mutation its kind needs", () => |
| 3400 | 3468 | Effect.gen(function* () { |
| 3401 | mockedExecute.mockReturnValue( | |
| 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 | ); | |
| 3469 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3414 | 3470 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3415 | 3471 | const rewrite = (kind: "issue-comment" | "review-comment") => |
| 3416 | 3472 | cli.updateComment({ |
| ... | ... | @@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3426 | 3482 | yield* rewrite("issue-comment"); |
| 3427 | 3483 | yield* rewrite("review-comment"); |
| 3428 | 3484 | |
| 3429 | const parse = (index: number) => | |
| 3430 | JSON.parse(callAt(index).stdin ?? "") as { | |
| 3431 | query: string; | |
| 3432 | variables: Record<string, string>; | |
| 3433 | }; | |
| 3434 | expect(callAt(0).args).toContain("subjectId=IC_1"); | |
| 3435 | expect(parse(1).query).toContain("updateIssueComment("); | |
| 3436 | expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); | |
| 3437 | expect(parse(3).query).toContain("updatePullRequestReviewComment("); | |
| 3438 | expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); | |
| 3485 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ | |
| 3486 | "IC_1", | |
| 3487 | "IC_1", | |
| 3488 | ]); | |
| 3489 | expect(variablesOf("updateIssueComment(")).toEqual([ | |
| 3490 | { commentId: "IC_1", body: "Reworded." }, | |
| 3491 | ]); | |
| 3492 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ | |
| 3493 | { commentId: "IC_1", body: "Reworded." }, | |
| 3494 | ]); | |
| 3439 | 3495 | }), |
| 3440 | 3496 | ); |
| 3441 | 3497 | |
| ... | ... | @@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3444 | 3500 | mockedExecute.mockReturnValueOnce( |
| 3445 | 3501 | Effect.succeed( |
| 3446 | 3502 | output( |
| 3447 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3448 | JSON.stringify({ | |
| 3503 | encodeJson({ | |
| 3449 | 3504 | data: { |
| 3450 | 3505 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3451 | 3506 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, |
| ... | ... | @@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3469 | 3524 | ); |
| 3470 | 3525 | |
| 3471 | 3526 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); |
| 3472 | expect(error.message).toContain("updateComment"); | |
| 3527 | if (error._tag === "GitHubSubjectScopeError") | |
| 3528 | assert.strictEqual(error.operation, "updateComment"); | |
| 3473 | 3529 | // Refused before any mutation was sent. |
| 3474 | 3530 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3475 | 3531 | }), |
| ... | ... | @@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3500 | 3556 | output( |
| 3501 | 3557 | encodeJson( |
| 3502 | 3558 | coreResponse({ |
| 3503 | number: 7, | |
| 3504 | 3559 | title: "Progressive detail", |
| 3505 | url: "https://github.com/acme/web/pull/7", | |
| 3506 | 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 | 3561 | body: "Core body", |
| 3512 | 3562 | changedFiles: 2, |
| 3513 | 3563 | }), |
| ... | ... | @@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3518 | 3568 | mockedExecute.mockReturnValueOnce( |
| 3519 | 3569 | Effect.succeed( |
| 3520 | 3570 | output( |
| 3521 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3522 | JSON.stringify({ | |
| 3523 | author: { login: "octocat" }, | |
| 3524 | comments: [], | |
| 3525 | reviews: [], | |
| 3526 | commits: [], | |
| 3571 | encodeJson({ | |
| 3572 | data: { | |
| 3573 | repository: { | |
| 3574 | pullRequest: { | |
| 3575 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, | |
| 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 | 3596 | |
| 3542 | 3597 | expect(detail.body).toBe("Core body"); |
| 3543 | 3598 | expect(activity.author?.login).toBe("octocat"); |
| 3544 | expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); | |
| 3545 | expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); | |
| 3599 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); | |
| 3600 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); | |
| 3601 | expect(queryAt(0)).not.toContain("reviews("); | |
| 3546 | 3602 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ |
| 3547 | 3603 | merge: true, |
| 3548 | 3604 | squash: false, |
| 3549 | 3605 | rebase: true, |
| 3550 | 3606 | }); |
| 3551 | 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 | 3680 | |
| 3622 | 3681 | it.effect("reads every check when the combined response has another page", () => |
| 3623 | 3682 | Effect.gen(function* () { |
| 3624 | const response = coreResponse({ | |
| 3625 | commits: { | |
| 3626 | nodes: [ | |
| 3627 | { | |
| 3628 | commit: { | |
| 3629 | statusCheckRollup: { | |
| 3630 | contexts: { | |
| 3631 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | |
| 3632 | pageInfo: { hasNextPage: true }, | |
| 3633 | }, | |
| 3683 | mockedExecute.mockReturnValueOnce( | |
| 3684 | Effect.succeed( | |
| 3685 | output( | |
| 3686 | encodeJson( | |
| 3687 | coreResponse({ | |
| 3688 | commits: { | |
| 3689 | nodes: [ | |
| 3690 | { | |
| 3691 | commit: { | |
| 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 | }, | |
| 3636 | }, | |
| 3637 | ], | |
| 3638 | }, | |
| 3639 | }); | |
| 3640 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | |
| 3702 | }), | |
| 3703 | ), | |
| 3704 | ), | |
| 3705 | ), | |
| 3706 | ); | |
| 3641 | 3707 | mockedExecute.mockReturnValueOnce( |
| 3642 | 3708 | Effect.succeed( |
| 3643 | 3709 | output( |
| 3644 | encodeJson({ | |
| 3645 | ...response.data.repository.pullRequest, | |
| 3646 | reviewRequests: [], | |
| 3647 | labels: [], | |
| 3648 | statusCheckRollup: [ | |
| 3710 | checkContextsPage( | |
| 3711 | [ | |
| 3649 | 3712 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, |
| 3650 | 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 | 3727 | expect(detail.checks).toHaveLength(2); |
| 3664 | 3728 | expect(detail.checksState).toBe("failing"); |
| 3665 | 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 | 3810 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); |
| 3745 | 3811 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); |
| 3746 | 3812 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 3747 | yield* cli | |
| 3748 | .getPullRequestDetail(input) | |
| 3749 | .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); | |
| 3813 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3750 | 3814 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 3751 | 3815 | }), |
| 3752 | 3816 | ); |
| ... | ... | @@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3769 | 3833 | // What matters is that it fails at all: an empty patch with no cursor would render as a |
| 3770 | 3834 | // change with no files and report the rest of it as already read. The refusal that sent |
| 3771 | 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 | 3840 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => |
| 3777 | 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 | 3843 | mockedExecute.mockReturnValueOnce( |
| 3780 | 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 | 3854 | }); |
| 3791 | 3855 | |
| 3792 | 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 | 3858 | expect(slice.patch).toContain("src/file1.ts"); |
| 3859 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | |
| 3795 | 3860 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3796 | 3861 | }), |
| 3797 | 3862 | ); |
| ... | ... | @@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3813 | 3878 | number: 7, |
| 3814 | 3879 | }); |
| 3815 | 3880 | |
| 3816 | // The first page asks from the beginning, which gh only sends as a typed JSON null. | |
| 3817 | expect(callAt(0).args).toContain("cursor=null"); | |
| 3818 | expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE"); | |
| 3881 | // The first page asks from the beginning; the next carries on from where it stopped. | |
| 3882 | expect(varsAt(0)["cursor"]).toBeNull(); | |
| 3883 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); | |
| 3819 | 3884 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); |
| 3820 | 3885 | assert.isFalse(conversation.truncated); |
| 3821 | 3886 | }), |
| ... | ... | @@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3897 | 3962 | cursor: "Y3Vyc29yOjI", |
| 3898 | 3963 | }); |
| 3899 | 3964 | |
| 3900 | expect(callAt(0).args).toContain("owner=acme"); | |
| 3901 | expect(callAt(0).args).toContain("name=web"); | |
| 3902 | expect(callAt(0).args).toContain("number=7"); | |
| 3903 | expect(callAt(0).args).toContain("threadId=PRRT_1"); | |
| 3904 | expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI"); | |
| 3965 | expect(varsAt(0)).toMatchObject({ | |
| 3966 | owner: "acme", | |
| 3967 | name: "web", | |
| 3968 | number: 7, | |
| 3969 | threadId: "PRRT_1", | |
| 3970 | cursor: "Y3Vyc29yOjI", | |
| 3971 | }); | |
| 3905 | 3972 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3906 | 3973 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); |
| 3907 | 3974 | expect(page.nextCursor).toBeNull(); |
| ... | ... | @@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3937 | 4004 | mockedExecute.mockReturnValue( |
| 3938 | 4005 | Effect.succeed( |
| 3939 | 4006 | output( |
| 3940 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3941 | JSON.stringify({ | |
| 4007 | encodeJson({ | |
| 3942 | 4008 | data: { |
| 3943 | 4009 | repository: { |
| 3944 | 4010 | mergeCommitAllowed: true, |
| ... | ... | @@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3963 | 4029 | |
| 3964 | 4030 | // One request, because both answers hang off the same repository object. |
| 3965 | 4031 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3966 | expect(callAt(0).args).toContain("number=7"); | |
| 3967 | expect(callAt(0).args.at(-1)).toContain( | |
| 3968 | "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", | |
| 3969 | ); | |
| 4032 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4033 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); | |
| 3970 | 4034 | expect(access).toEqual({ |
| 3971 | 4035 | mergeCapabilities: { merge: true, squash: false, rebase: true }, |
| 3972 | 4036 | canWrite: false, |
| ... | ... | @@ -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 | 4045 | Effect.gen(function* () { |
| 3982 | 4046 | mockedExecute.mockReturnValue( |
| 3983 | 4047 | Effect.succeed( |
| 3984 | 4048 | output( |
| 3985 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3986 | JSON.stringify({ | |
| 4049 | encodeJson({ | |
| 3987 | 4050 | data: { |
| 3988 | 4051 | repository: { |
| 3989 | 4052 | pullRequest: { |
| ... | ... | @@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4006 | 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 | |
| 4010 | // positional argument gh refuses outright. | |
| 4011 | const args = callAt(0).args; | |
| 4012 | expect(args.slice(0, -2)).toEqual([ | |
| 4013 | "api", | |
| 4014 | "graphql", | |
| 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 | ]); | |
| 4072 | expect(varsAt(0)).toEqual({ | |
| 4073 | owner: "acme", | |
| 4074 | name: "web", | |
| 4075 | number: 7, | |
| 4076 | headRef: "fork:feat/page", | |
| 4077 | }); | |
| 4026 | 4078 | expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); |
| 4027 | expect(args.at(-2)).toBe("-f"); | |
| 4028 | expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`); | |
| 4079 | expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)); | |
| 4029 | 4080 | }), |
| 4030 | 4081 | ); |
| 4031 | 4082 | |
| ... | ... | @@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4034 | 4085 | mockedExecute.mockReturnValue( |
| 4035 | 4086 | Effect.succeed( |
| 4036 | 4087 | output( |
| 4037 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4038 | JSON.stringify({ | |
| 4088 | encodeJson({ | |
| 4039 | 4089 | data: { |
| 4040 | 4090 | repository: { |
| 4041 | 4091 | pullRequest: { |
| ... | ... | @@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4064 | 4114 | } as const; |
| 4065 | 4115 | |
| 4066 | 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 | 4119 | const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input)); |
| 4070 | 4120 | |
| ... | ... | @@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4083 | 4133 | .mockReturnValueOnce( |
| 4084 | 4134 | Effect.succeed( |
| 4085 | 4135 | output( |
| 4086 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4087 | JSON.stringify({ | |
| 4136 | encodeJson({ | |
| 4088 | 4137 | data: { |
| 4089 | 4138 | repository: { |
| 4090 | 4139 | pullRequest: { |
| ... | ... | @@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4106 | 4155 | .mockReturnValueOnce( |
| 4107 | 4156 | Effect.succeed( |
| 4108 | 4157 | output( |
| 4109 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4110 | JSON.stringify({ | |
| 4158 | encodeJson({ | |
| 4111 | 4159 | data: { |
| 4112 | 4160 | repository: { |
| 4113 | 4161 | mergeCommitAllowed: true, |
| ... | ... | @@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4167 | 4215 | requested: true, |
| 4168 | 4216 | }); |
| 4169 | 4217 | |
| 4170 | const call = callAt(0); | |
| 4171 | expect(call.args).toEqual([ | |
| 4172 | "api", | |
| 4173 | "--method", | |
| 4174 | "POST", | |
| 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"], | |
| 4218 | expect(callAt(0)).toMatchObject({ | |
| 4219 | kind: "rest", | |
| 4220 | method: "POST", | |
| 4221 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4222 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, | |
| 4185 | 4223 | }); |
| 4186 | 4224 | }), |
| 4187 | 4225 | ); |
| ... | ... | @@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4200 | 4238 | requested: false, |
| 4201 | 4239 | }); |
| 4202 | 4240 | |
| 4203 | const call = callAt(0); | |
| 4204 | expect(call.args).toContain("DELETE"); | |
| 4205 | expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers"); | |
| 4206 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4207 | expect(JSON.parse(call.stdin ?? "")).toEqual({ | |
| 4208 | reviewers: ["octocat"], | |
| 4209 | team_reviewers: [], | |
| 4241 | expect(callAt(0)).toMatchObject({ | |
| 4242 | kind: "rest", | |
| 4243 | method: "DELETE", | |
| 4244 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4245 | body: { reviewers: ["octocat"], team_reviewers: [] }, | |
| 4210 | 4246 | }); |
| 4211 | 4247 | }), |
| 4212 | 4248 | ); |
| ... | ... | @@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4216 | 4252 | mockedExecute.mockReturnValue( |
| 4217 | 4253 | Effect.succeed( |
| 4218 | 4254 | output( |
| 4219 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4220 | JSON.stringify({ | |
| 4255 | encodeJson({ | |
| 4221 | 4256 | data: { |
| 4222 | 4257 | repository: { |
| 4223 | 4258 | assignableUsers: { |
| ... | ... | @@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4246 | 4281 | // The people, who has been asked and who opened the pull request all hang off the same |
| 4247 | 4282 | // repository object, so the menu costs one request. |
| 4248 | 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 | 4285 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ |
| 4251 | 4286 | ["octocat", true], |
| 4252 | 4287 | ["hubot", false], |
| ... | ... | @@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4269 | 4304 | }); |
| 4270 | 4305 | |
| 4271 | 4306 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4272 | const call = callAt(0); | |
| 4273 | expect(call.args).toEqual([ | |
| 4274 | "api", | |
| 4275 | "--method", | |
| 4276 | "POST", | |
| 4277 | "--hostname", | |
| 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"] }); | |
| 4307 | expect(callAt(0)).toMatchObject({ | |
| 4308 | kind: "rest", | |
| 4309 | method: "POST", | |
| 4310 | path: "repos/acme/web/issues/7/labels", | |
| 4311 | body: { labels: ["bug", "size:XL"] }, | |
| 4312 | }); | |
| 4285 | 4313 | }), |
| 4286 | 4314 | ); |
| 4287 | 4315 | |
| ... | ... | @@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4300 | 4328 | }); |
| 4301 | 4329 | |
| 4302 | 4330 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4303 | expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); | |
| 4304 | expect(callAt(0).args).toContain("DELETE"); | |
| 4305 | expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); | |
| 4331 | expect(callAt(0)).toMatchObject({ | |
| 4332 | method: "DELETE", | |
| 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 | 4344 | const page = (index: number, hasNextPage: boolean) => |
| 4312 | 4345 | Effect.succeed( |
| 4313 | 4346 | output( |
| 4314 | JSON.stringify({ | |
| 4347 | encodeJson({ | |
| 4315 | 4348 | data: { |
| 4316 | 4349 | repository: { |
| 4317 | 4350 | pullRequest: { |
| ... | ... | @@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4343 | 4376 | |
| 4344 | 4377 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4345 | 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="))); | |
| 4347 | expect(callAt(1).args).toContain("after=cursor-0"); | |
| 4348 | expect(callAt(2).args).toContain("after=cursor-1"); | |
| 4379 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ | |
| 4380 | null, | |
| 4381 | "cursor-0", | |
| 4382 | "cursor-1", | |
| 4383 | ]); | |
| 4349 | 4384 | assert.isFalse(viewed.truncated); |
| 4350 | 4385 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ |
| 4351 | 4386 | ["src/file0.ts", "viewed"], |
| ... | ... | @@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4363 | 4398 | mockedExecute.mockReturnValue( |
| 4364 | 4399 | Effect.succeed( |
| 4365 | 4400 | output( |
| 4366 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4367 | JSON.stringify({ | |
| 4401 | encodeJson({ | |
| 4368 | 4402 | data: { |
| 4369 | 4403 | repository: { |
| 4370 | 4404 | pullRequest: { |
| ... | ... | @@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4399 | 4433 | mockedExecute |
| 4400 | 4434 | .mockReturnValueOnce( |
| 4401 | 4435 | Effect.succeed( |
| 4402 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4403 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4436 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4404 | 4437 | ), |
| 4405 | 4438 | ) |
| 4406 | 4439 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| ... | ... | @@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4419 | 4452 | |
| 4420 | 4453 | // One request to learn the pull request's node id, one for every press together. |
| 4421 | 4454 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4422 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4423 | const sent = JSON.parse(callAt(1).stdin ?? "") as { | |
| 4424 | query: string; | |
| 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({ | |
| 4455 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); | |
| 4456 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); | |
| 4457 | expect(varsAt(1)).toEqual({ | |
| 4430 | 4458 | pullRequestId: "PR_1", |
| 4431 | 4459 | path0: "src/a.ts", |
| 4432 | 4460 | path1: "src/b.ts", |
| ... | ... | @@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4455 | 4483 | mockedExecute |
| 4456 | 4484 | .mockReturnValueOnce( |
| 4457 | 4485 | Effect.succeed( |
| 4458 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4459 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4486 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4460 | 4487 | ), |
| 4461 | 4488 | ) |
| 4462 | 4489 | .mockReturnValue(Effect.succeed(output("{}"))); |
| ... | ... | @@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4475 | 4502 | |
| 4476 | 4503 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. |
| 4477 | 4504 | assert.strictEqual(mockedExecute.mock.calls.length, 4); |
| 4478 | expect(callAt(0).args).toContain("number=24"); | |
| 4479 | const idSentAt = (index: number) => | |
| 4480 | (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) | |
| 4481 | .variables.pullRequestId; | |
| 4482 | expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]); | |
| 4505 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); | |
| 4506 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ | |
| 4507 | "PR_24", | |
| 4508 | "PR_24", | |
| 4509 | "PR_24", | |
| 4510 | ]); | |
| 4483 | 4511 | }), |
| 4484 | 4512 | ); |
| 4485 | 4513 | |
| ... | ... | @@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4489 | 4517 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) |
| 4490 | 4518 | .mockReturnValueOnce( |
| 4491 | 4519 | Effect.succeed( |
| 4492 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 4493 | output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4520 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4494 | 4521 | ), |
| 4495 | 4522 | ) |
| 4496 | 4523 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| ... | ... | @@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 4510 | 4537 | yield* write(); |
| 4511 | 4538 | |
| 4512 | 4539 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4513 | const idSentAt = (index: number) => | |
| 4514 | (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) | |
| 4515 | .variables.pullRequestId; | |
| 4516 | expect(idSentAt(2)).toEqual("PR_25"); | |
| 4540 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); | |
| 4517 | 4541 | }), |
| 4518 | 4542 | ); |
| 4519 | 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 | 4549 | const HOT = 9_000; |
| 4526 | 4550 | const lookupsOf = new Map<number, number>(); |
| 4527 | 4551 | mockedExecute.mockImplementation((input) => { |
| 4528 | const asked = input.args.find((arg) => arg.startsWith("number=")); | |
| 4529 | if (asked === undefined) return Effect.succeed(output("{}")); | |
| 4530 | const number = Number(asked.slice("number=".length)); | |
| 4552 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { | |
| 4553 | return Effect.succeed(output("{}")); | |
| 4554 | } | |
| 4555 | const number = Number(input.variables?.["number"]); | |
| 4531 | 4556 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); |
| 4532 | 4557 | return Effect.succeed( |
| 4533 | 4558 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+814-936| ... | ... | @@ -1,21 +1,19 @@ |
| 1 | 1 | import { removeAgentCredits } from "./mergeMessage.ts"; |
| 2 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 3 | 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 4 | 4 | import * as Cause from "effect/Cause"; |
| 5 | 5 | import * as Context from "effect/Context"; |
| 6 | 6 | import * as Clock from "effect/Clock"; |
| 7 | import * as Crypto from "effect/Crypto"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | import * as Exit from "effect/Exit"; |
| 11 | 11 | import * as Option from "effect/Option"; |
| 12 | import * as Redacted from "effect/Redacted"; | |
| 13 | 12 | import * as Request from "effect/Request"; |
| 14 | 13 | import * as RequestResolver from "effect/RequestResolver"; |
| 15 | 14 | import * as Result from "effect/Result"; |
| 16 | 15 | import * as Schema from "effect/Schema"; |
| 17 | 16 | import * as Semaphore from "effect/Semaphore"; |
| 18 | import * as Hex from "effect/encoding/Hex"; | |
| 19 | 17 | import { |
| 20 | 18 | resolvePullRequestAuthorFilter, |
| 21 | 19 | PositiveInt, |
| ... | ... | @@ -23,6 +21,8 @@ import { |
| 23 | 21 | type PullRequestAction, |
| 24 | 22 | type PullRequestStackHead, |
| 25 | 23 | type PullRequestActor, |
| 24 | type PullRequestComment, | |
| 25 | type PullRequestCommit, | |
| 26 | 26 | type PullRequestFileViewed, |
| 27 | 27 | type PullRequestInvolvement, |
| 28 | 28 | type PullRequestListFilters, |
| ... | ... | @@ -41,19 +41,28 @@ import { |
| 41 | 41 | type PullRequestPreview, |
| 42 | 42 | } from "@t3tools/contracts"; |
| 43 | 43 | |
| 44 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 45 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | |
| 44 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | |
| 45 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 46 | 46 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 47 | 47 | import { |
| 48 | 48 | ACTOR_AVATARS_GRAPHQL_QUERY, |
| 49 | 49 | ADD_REACTION_GRAPHQL_MUTATION, |
| 50 | buildReviewSubmissionJson, | |
| 51 | buildReviewerRequestJson, | |
| 50 | buildReviewSubmission, | |
| 51 | buildReviewerRequest, | |
| 52 | 52 | buildSetFilesViewedGraphQlMutation, |
| 53 | 53 | decodeActorAvatarsJson, |
| 54 | 54 | decodePullRequestActivityJson, |
| 55 | decodePullRequestDetailJson, | |
| 55 | decodePullRequestCheckContextsJson, | |
| 56 | 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 | 66 | pullRequestCoreGraphQlQuery, |
| 58 | 67 | type GitHubPullRequestCore, |
| 59 | 68 | type GitHubPullRequestSummary, |
| ... | ... | @@ -73,7 +82,7 @@ import { |
| 73 | 82 | decodeReactionSubjectScopeJson, |
| 74 | 83 | decodeReviewerCandidatesJson, |
| 75 | 84 | decodeLabelCandidatesJson, |
| 76 | buildLabelRequestJson, | |
| 85 | buildLabelRequest, | |
| 77 | 86 | LABEL_CANDIDATES_GRAPHQL_QUERY, |
| 78 | 87 | decodeReviewDismissalsJson, |
| 79 | 88 | decodeReviewThreadCommentsJson, |
| ... | ... | @@ -83,14 +92,10 @@ import { |
| 83 | 92 | buildPullRequestWatchFingerprintsGraphQlQuery, |
| 84 | 93 | buildPullRequestStackMembershipsGraphQlQuery, |
| 85 | 94 | decodePullRequestStackMembershipsJson, |
| 86 | encodeGraphQlRequestJson, | |
| 87 | 95 | pullRequestSearchGraphQlQuery, |
| 88 | 96 | PULL_REQUEST_SEARCH_MAX_ROWS, |
| 89 | PULL_REQUEST_ACTIVITY_JSON_FIELDS, | |
| 90 | 97 | BASE_COMPARISON_GRAPHQL_QUERY, |
| 91 | 98 | decodeBaseComparisonJson, |
| 92 | PULL_REQUEST_DETAIL_JSON_FIELDS, | |
| 93 | PULL_REQUEST_LIST_JSON_FIELDS, | |
| 94 | 99 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, |
| 95 | 100 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 96 | 101 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| ... | ... | @@ -110,9 +115,10 @@ import { |
| 110 | 115 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, |
| 111 | 116 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, |
| 112 | 117 | decodeViewerPermissionsJson, |
| 113 | decodeWorkflowRunApprovalsJson, | |
| 114 | 118 | type GitHubBaseComparison, |
| 115 | 119 | type GitHubPullRequestActivity, |
| 120 | type GitHubPullRequestActivityPage, | |
| 121 | type GitHubWorkflowRunPage, | |
| 116 | 122 | type GitHubPullRequestHead, |
| 117 | 123 | type GitHubPullRequestListItem, |
| 118 | 124 | type GitHubPullRequestSearchItem, |
| ... | ... | @@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq |
| 139 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 214 | commit: Schema.optional(Schema.String), |
| 229 | 215 | }, |
| 230 | 216 | ) { |
| 231 | get detail(): string { | |
| 217 | override get message(): string { | |
| 232 | 218 | return this.commit === undefined |
| 233 | 219 | ? `Pull request #${this.number} reported no usable base and head revisions.` |
| 234 | 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 | 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 | 231 | reason: Schema.Literals(["oversized", "binary"]), |
| 250 | 232 | }, |
| 251 | 233 | ) { |
| 252 | get detail(): string { | |
| 234 | override get message(): string { | |
| 253 | 235 | return this.reason === "oversized" |
| 254 | 236 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` |
| 255 | 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 | 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 | 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 | 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 | 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 | 283 | limit: Schema.Int, |
| 314 | 284 | }, |
| 315 | 285 | ) { |
| 316 | get detail(): string { | |
| 286 | override get message(): string { | |
| 317 | 287 | if (this.reason === "head-list-truncated") { |
| 318 | 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 | 292 | } |
| 323 | 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 | 297 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ |
| ... | ... | @@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr |
| 337 | 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 | 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 | 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 | 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 | 325 | export type GitHubPullRequestCliError = |
| 368 | 326 | | GitHubStackActionError |
| 369 | | GitHubCli.GitHubCliError | |
| 327 | | GitHubApi.GitHubApiError | |
| 370 | 328 | | GitHubPullRequestReadError |
| 371 | 329 | | GitHubDiffCursorError |
| 372 | 330 | | GitHubDiffCommitError |
| ... | ... | @@ -383,10 +341,26 @@ export type GitHubPullRequestCliError = |
| 383 | 341 | |
| 384 | 342 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ |
| 385 | 343 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 386 | const DIFF_TIMEOUT_MS = 60_000; | |
| 344 | const DIFF_TIMEOUT = Duration.seconds(60); | |
| 387 | 345 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ |
| 388 | 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 | 364 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ |
| 391 | 365 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; |
| 392 | 366 | |
| ... | ... | @@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128; |
| 413 | 387 | */ |
| 414 | 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 | 393 | export interface GitHubPullRequestListBatch { |
| 417 | 394 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; |
| 418 | 395 | readonly truncated: boolean; |
| ... | ... | @@ -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 | 940 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ |
| 1008 | 941 | function matchesUnsortedListing( |
| 1009 | 942 | item: GitHubPullRequestListItem, |
| ... | ... | @@ -1029,13 +962,11 @@ function matchesUnsortedListing( |
| 1029 | 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 | |
| 1033 | * read a whole host in one request. | |
| 965 | * A listing as one GitHub search, across one repository or several: the only way to read a | |
| 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, | |
| 1036 | * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is | |
| 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. | |
| 968 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is | |
| 969 | * `is:closed is:unmerged` here. | |
| 1039 | 970 | * |
| 1040 | 971 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a |
| 1041 | 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 | 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 | 1007 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` |
| 1086 | 1008 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { |
| 1087 | 1009 | repository(owner: $owner, name: $name) { |
| ... | ... | @@ -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 | 1030 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( |
| 1097 | 1031 | Schema.fromJsonString( |
| 1098 | 1032 | Schema.Struct({ |
| ... | ... | @@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult( |
| 1111 | 1045 | const decodeMergeMessage = (raw: string) => |
| 1112 | 1046 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); |
| 1113 | 1047 | |
| 1114 | function actionArgs( | |
| 1115 | action: PullRequestAction, | |
| 1116 | mergeMethod: PullRequestMergeMethod | undefined, | |
| 1117 | updateMethod: PullRequestUpdateMethod | undefined, | |
| 1118 | ): ReadonlyArray<string> { | |
| 1119 | switch (action) { | |
| 1120 | case "merge": | |
| 1121 | return ["merge", `--${mergeMethod ?? "merge"}`]; | |
| 1122 | // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub | |
| 1123 | // stores the strategy with the standing instruction rather than choosing one at merge time. | |
| 1124 | case "enable-auto-merge": | |
| 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"); | |
| 1048 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ | |
| 1049 | const ACTION_STATE_GRAPHQL_QUERY = ` | |
| 1050 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 1051 | repository(owner: $owner, name: $name) { | |
| 1052 | pullRequest(number: $number) { | |
| 1053 | id | |
| 1054 | headRefOid | |
| 1055 | isMergeQueueEnabled | |
| 1056 | mergeStateStatus | |
| 1057 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 1058 | } | |
| 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 | 1132 | /** @public Service construction is part of the canonical Effect module API. */ |
| 1149 | 1133 | export const make = Effect.gen(function* () { |
| 1150 | const github = yield* GitHubCli.GitHubCli; | |
| 1151 | const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 1152 | const crypto = yield* Crypto.Crypto; | |
| 1134 | const api = yield* GitHubApi.GitHubApi; | |
| 1153 | 1135 | const revalidateChecks = yield* makeChecksRevalidator; |
| 1154 | 1136 | const routingIdentities = new Map< |
| 1155 | 1137 | string, |
| ... | ... | @@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () { |
| 1172 | 1154 | const unavailable = () => |
| 1173 | 1155 | new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); |
| 1174 | 1156 | const host = input.host.toLowerCase(); |
| 1175 | const pinned = yield* GitHubCli.PinnedGitHubCredential; | |
| 1176 | if (pinned !== null && pinned.host !== host) return yield* unavailable(); | |
| 1177 | // Only the digest is retained. Never attach credential lookup output to an error. | |
| 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 }; | |
| 1157 | // A missing or signed-out credential keeps its own error, so the page can say which. | |
| 1158 | const { token, fingerprint: key } = yield* api.credential(host); | |
| 1159 | const credential = { host, token, credentialFingerprint: key }; | |
| 1194 | 1160 | // A cold page may ask several times. Wait per credential and check again after the |
| 1195 | 1161 | // first verification; cancellation releases the next waiter without losing its request. |
| 1196 | 1162 | return yield* Effect.acquireUseRelease( |
| ... | ... | @@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () { |
| 1208 | 1174 | if (cached !== undefined && now - cached.at < 10 * 60_000) |
| 1209 | 1175 | return { ...credential, ...cached.value }; |
| 1210 | 1176 | // Pin this read so an auth switch cannot poison its cache entry. |
| 1211 | const response = yield* github | |
| 1212 | .execute({ | |
| 1213 | cwd: input.cwd, | |
| 1214 | args: ["api", "user", "--hostname", host], | |
| 1215 | env: { | |
| 1216 | GH_HOST: host, | |
| 1217 | GH_TOKEN: token, | |
| 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( | |
| 1177 | const response = yield* api | |
| 1178 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) | |
| 1179 | .pipe( | |
| 1180 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 1181 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), | |
| 1182 | ); | |
| 1183 | const identity = yield* decodeRoutingIdentity(response.body).pipe( | |
| 1226 | 1184 | Effect.mapError(unavailable), |
| 1227 | 1185 | ); |
| 1228 | 1186 | const value = { accountId: String(identity.id), viewer: identity.login }; |
| ... | ... | @@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () { |
| 1248 | 1206 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => |
| 1249 | 1207 | use({ accountId, viewer, credentialFingerprint }).pipe( |
| 1250 | 1208 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), |
| 1251 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | |
| 1209 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 1252 | 1210 | host, |
| 1253 | 1211 | token, |
| 1254 | 1212 | credentialFingerprint, |
| ... | ... | @@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () { |
| 1293 | 1251 | host: input.host, |
| 1294 | 1252 | operation: input.operation, |
| 1295 | 1253 | allowReserve: true, |
| 1296 | variables: [ | |
| 1297 | ["-f", `owner=${owner}`], | |
| 1298 | ["-f", `name=${name}`], | |
| 1299 | ["-F", `number=${input.number}`], | |
| 1300 | ], | |
| 1254 | variables: { owner, name, number: input.number }, | |
| 1301 | 1255 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 1302 | 1256 | decode: decodePullRequestNodeIdJson, |
| 1303 | 1257 | }).pipe( |
| ... | ... | @@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () { |
| 1333 | 1287 | host: input.host, |
| 1334 | 1288 | operation: input.operation, |
| 1335 | 1289 | allowReserve: true, |
| 1336 | variables: [ | |
| 1337 | ["-f", `owner=${owner}`], | |
| 1338 | ["-f", `name=${name}`], | |
| 1339 | ["-F", `number=${input.number}`], | |
| 1340 | ["-f", `subjectId=${input.subjectId}`], | |
| 1341 | ], | |
| 1290 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, | |
| 1342 | 1291 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| 1343 | 1292 | decode: decodeReactionSubjectScopeJson, |
| 1344 | 1293 | }); |
| 1345 | 1294 | }; |
| 1346 | 1295 | |
| 1347 | // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is | |
| 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 | */ | |
| 1296 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ | |
| 1363 | 1297 | const graphql = (input: { |
| 1364 | readonly cwd: string; | |
| 1365 | 1298 | readonly host: string; |
| 1299 | readonly operation: string; | |
| 1366 | 1300 | readonly query: string; |
| 1367 | readonly variables: Readonly<Record<string, string>>; | |
| 1368 | }) => | |
| 1369 | github | |
| 1370 | .execute({ | |
| 1371 | cwd: input.cwd, | |
| 1372 | args: ["api", "graphql", "--hostname", input.host, "--input", "-"], | |
| 1373 | stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), | |
| 1374 | }) | |
| 1375 | .pipe(Effect.asVoid); | |
| 1301 | readonly variables: Readonly<Record<string, unknown>>; | |
| 1302 | }) => api.graphql(input).pipe(Effect.asVoid); | |
| 1303 | ||
| 1304 | const readError = (cwd: string, operation: string, cause: unknown) => | |
| 1305 | new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); | |
| 1306 | ||
| 1307 | /** Decodes an answer, reporting a failure against the read that made it. */ | |
| 1308 | const decodeWith = <A>( | |
| 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 | 1320 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ |
| 1378 | 1321 | const graphqlRead = <A>(input: { |
| ... | ... | @@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () { |
| 1380 | 1323 | readonly host: string; |
| 1381 | 1324 | readonly operation: string; |
| 1382 | 1325 | readonly allowReserve?: boolean | undefined; |
| 1383 | /** Variables as `-f` flags, for values this module composed itself. */ | |
| 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>>; | |
| 1326 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 1391 | 1327 | readonly query: string; |
| 1392 | 1328 | readonly decode: (raw: string) => Result.Result<A, unknown>; |
| 1393 | }): Effect.Effect<A, GitHubPullRequestCliError> => { | |
| 1394 | return graphQlBudget | |
| 1395 | .query( | |
| 1396 | input.host, | |
| 1397 | input.query, | |
| 1398 | input.allowReserve === true ? { allowReserve: true } : undefined, | |
| 1399 | ) | |
| 1329 | }): Effect.Effect<A, GitHubPullRequestCliError> => | |
| 1330 | api | |
| 1331 | .graphql({ | |
| 1332 | host: input.host, | |
| 1333 | operation: input.operation, | |
| 1334 | query: input.query, | |
| 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 | 1350 | .pipe( |
| 1401 | Effect.flatMap((query) => | |
| 1402 | github.execute( | |
| 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 | ), | |
| 1351 | Effect.flatMap((response) => | |
| 1352 | decodeWith(input.cwd, input.operation, input.decode, response.body), | |
| 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 | |
| 1445 | * changed files, and still serves those files' hunks here. | |
| 1357 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed | |
| 1358 | * files, and still serves those files' hunks here. | |
| 1446 | 1359 | * |
| 1447 | 1360 | * A page is a whole number of files, so each one parses on its own; the caller carries on from |
| 1448 | 1361 | * `nextCursor` for as long as GitHub keeps handing pages back. |
| 1449 | 1362 | * |
| 1450 | 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 | 1366 | const diffFilesPage = (input: { |
| 1454 | 1367 | readonly cwd: string; |
| ... | ... | @@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () { |
| 1460 | 1373 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => { |
| 1461 | 1374 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1462 | 1375 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; |
| 1463 | return github | |
| 1464 | .execute({ | |
| 1465 | cwd: input.cwd, | |
| 1466 | args: [ | |
| 1467 | "api", | |
| 1468 | "--hostname", | |
| 1469 | input.host, | |
| 1376 | return api | |
| 1377 | .rest({ | |
| 1378 | host: input.host, | |
| 1379 | operation: "getPullRequestDiff", | |
| 1380 | path: | |
| 1470 | 1381 | input.commit === undefined |
| 1471 | 1382 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` |
| 1472 | 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 | |
| 1474 | // rather than an answer that could not be read. | |
| 1475 | ...(input.commit === undefined ? [] : ["--jq", ".files // []"]), | |
| 1476 | ], | |
| 1477 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1478 | timeoutMs: DIFF_TIMEOUT_MS, | |
| 1384 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1385 | timeout: DIFF_TIMEOUT, | |
| 1479 | 1386 | }) |
| 1480 | 1387 | .pipe( |
| 1481 | Effect.flatMap((result) => { | |
| 1388 | Effect.flatMap((response) => { | |
| 1482 | 1389 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would |
| 1483 | 1390 | // fail to parse. Nothing of this page can be shown, and an empty patch would render |
| 1484 | 1391 | // as a change with no files rather than as the failure it is; slices already handed |
| 1485 | 1392 | // over stay with the reader either way. |
| 1486 | if (result.stdoutTruncated) { | |
| 1393 | if (response.truncated) { | |
| 1487 | 1394 | return Effect.fail( |
| 1488 | new GitHubPullRequestReadError({ | |
| 1489 | command: "gh", | |
| 1490 | cwd: input.cwd, | |
| 1491 | operation: "getPullRequestDiff", | |
| 1492 | cause: new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 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 | }), | |
| 1395 | readError( | |
| 1396 | input.cwd, | |
| 1397 | "getPullRequestDiff", | |
| 1398 | new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 1399 | ), | |
| 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 | 1410 | // Counted before decoding, so a page whose files all failed to decode still moves on |
| 1508 | 1411 | // rather than pointing the reader back at the page it just read. |
| 1509 | const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1510 | return Effect.succeed({ | |
| 1511 | patch: decoded.success.patch, | |
| 1512 | truncated: decoded.success.truncated, | |
| 1412 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1413 | return { | |
| 1414 | patch: files.patch, | |
| 1415 | truncated: files.truncated, | |
| 1513 | 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 }), | |
| 1517 | }); | |
| 1419 | : { omittedFileStats: files.omittedFileStats }), | |
| 1420 | }; | |
| 1518 | 1421 | }), |
| 1519 | 1422 | ); |
| 1520 | 1423 | }; |
| ... | ... | @@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () { |
| 1526 | 1429 | return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); |
| 1527 | 1430 | } |
| 1528 | 1431 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1529 | const refsResult = yield* github.execute({ | |
| 1530 | cwd: input.cwd, | |
| 1531 | args: [ | |
| 1532 | "api", | |
| 1533 | "--hostname", | |
| 1534 | input.host, | |
| 1432 | const refsResponse = yield* api.rest({ | |
| 1433 | host: input.host, | |
| 1434 | operation: "getPullRequestDiffFileContents", | |
| 1435 | path: | |
| 1535 | 1436 | input.commit === undefined |
| 1536 | 1437 | ? `repos/${owner}/${name}/pulls/${input.number}` |
| 1537 | 1438 | : `repos/${owner}/${name}/commits/${input.commit}`, |
| 1538 | "--jq", | |
| 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, | |
| 1439 | timeout: DIFF_TIMEOUT, | |
| 1545 | 1440 | }); |
| 1546 | // Keep a leading tab: a root commit has no parent, and jq represents that absent old | |
| 1547 | // revision as the empty field before the tab. Every file in it is new, so that is a | |
| 1548 | // usable answer whenever the caller does not need the old side. | |
| 1549 | const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t"); | |
| 1441 | const refs = decodeRevisionRefs(refsResponse.body); | |
| 1442 | // A root commit has no parent, which is an absent old revision. Every file in it is new, | |
| 1443 | // so that is a usable answer whenever the caller does not need the old side. | |
| 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 | 1454 | const rootCommitNewFile = |
| 1551 | 1455 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; |
| 1552 | 1456 | if ( |
| 1553 | refsResult.stdoutTruncated || | |
| 1554 | !headRef || | |
| 1555 | extraRefs.length > 0 || | |
| 1457 | headRef === undefined || | |
| 1556 | 1458 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || |
| 1557 | 1459 | !isCommitSha(headRef) |
| 1558 | 1460 | ) { |
| ... | ... | @@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () { |
| 1565 | 1467 | } |
| 1566 | 1468 | |
| 1567 | 1469 | const readFile = (revision: string, filePath: string) => |
| 1568 | github | |
| 1569 | .execute({ | |
| 1570 | cwd: input.cwd, | |
| 1571 | args: [ | |
| 1572 | "api", | |
| 1573 | "--hostname", | |
| 1574 | input.host, | |
| 1575 | "--header", | |
| 1576 | "Accept: application/vnd.github.raw+json", | |
| 1577 | `repos/${owner}/${name}/contents/${filePath | |
| 1578 | .split("/") | |
| 1579 | .map(encodeURIComponent) | |
| 1580 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1581 | ], | |
| 1582 | maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1583 | timeoutMs: DIFF_TIMEOUT_MS, | |
| 1470 | api | |
| 1471 | .rest({ | |
| 1472 | host: input.host, | |
| 1473 | operation: "getPullRequestDiffFileContents", | |
| 1474 | accept: "application/vnd.github.raw+json", | |
| 1475 | path: `repos/${owner}/${name}/contents/${filePath | |
| 1476 | .split("/") | |
| 1477 | .map(encodeURIComponent) | |
| 1478 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1479 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1480 | timeout: DIFF_TIMEOUT, | |
| 1584 | 1481 | }) |
| 1585 | 1482 | .pipe( |
| 1586 | Effect.flatMap((result) => | |
| 1587 | result.stdoutTruncated || | |
| 1588 | result.stdout.includes("\0") || | |
| 1589 | result.stdoutInvalidUtf8 === true | |
| 1483 | Effect.flatMap((response) => | |
| 1484 | response.truncated || response.body.includes("\0") || response.invalidUtf8 | |
| 1590 | 1485 | ? Effect.fail( |
| 1591 | 1486 | new GitHubDiffFileContentsUnavailableError({ |
| 1592 | 1487 | command: "gh", |
| 1593 | 1488 | cwd: input.cwd, |
| 1594 | 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 | 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 | 1502 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), |
| 1606 | 1503 | ], |
| 1607 | 1504 | { concurrency: 2 }, |
| ... | ... | @@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () { |
| 1609 | 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 | 1515 | input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0], |
| 1516 | allowReserve: boolean, | |
| 1614 | 1517 | ) => |
| 1615 | github | |
| 1616 | .execute({ | |
| 1617 | cwd: input.cwd, | |
| 1618 | args: [ | |
| 1619 | "pr", | |
| 1620 | "view", | |
| 1621 | String(input.number), | |
| 1622 | ...repositoryArgs(input), | |
| 1623 | "--json", | |
| 1624 | PULL_REQUEST_DETAIL_JSON_FIELDS, | |
| 1625 | ], | |
| 1626 | }) | |
| 1627 | .pipe( | |
| 1628 | Effect.flatMap((result) => { | |
| 1629 | const decoded = decodePullRequestDetailJson(result.stdout.trim()); | |
| 1630 | return Result.isSuccess(decoded) | |
| 1631 | ? Effect.succeed(decoded.success) | |
| 1632 | : Effect.fail( | |
| 1633 | new GitHubPullRequestReadError({ | |
| 1634 | command: "gh", | |
| 1635 | cwd: input.cwd, | |
| 1636 | operation: "getPullRequestDetail", | |
| 1637 | cause: decoded.failure, | |
| 1638 | }), | |
| 1639 | ); | |
| 1640 | }), | |
| 1641 | ); | |
| 1518 | Effect.gen(function* () { | |
| 1519 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1520 | const contexts: GitHubCheckContext[] = []; | |
| 1521 | let headSha: string | null = null; | |
| 1522 | let after: string | null = null; | |
| 1523 | for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { | |
| 1524 | const read: { | |
| 1525 | readonly headSha: string; | |
| 1526 | readonly contexts: ReadonlyArray<GitHubCheckContext>; | |
| 1527 | readonly nextCursor: string | null; | |
| 1528 | } = yield* graphqlRead({ | |
| 1529 | cwd: input.cwd, | |
| 1530 | host: input.host, | |
| 1531 | operation: "getPullRequestDetail", | |
| 1532 | allowReserve, | |
| 1533 | variables: { owner, name, number: input.number, after }, | |
| 1534 | query: pullRequestCheckContextsGraphQlQuery(input.host), | |
| 1535 | decode: decodePullRequestCheckContextsJson, | |
| 1536 | }); | |
| 1537 | if (headSha !== null && read.headSha !== headSha) { | |
| 1538 | return yield* readError( | |
| 1539 | input.cwd, | |
| 1540 | "getPullRequestDetail", | |
| 1541 | new Error("Pull request head changed while reading checks."), | |
| 1542 | ); | |
| 1543 | } | |
| 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 | 1552 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { |
| 1644 | 1553 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1645 | return GitHubCli.AllowGitHubReserve.pipe( | |
| 1554 | return AllowGitHubReserve.pipe( | |
| 1646 | 1555 | Effect.flatMap((allowReserve) => |
| 1647 | 1556 | graphqlRead({ |
| 1648 | 1557 | allowReserve, |
| 1649 | 1558 | cwd: input.cwd, |
| 1650 | 1559 | host: input.host, |
| 1651 | 1560 | operation: "getPullRequestDetail", |
| 1652 | variables: [ | |
| 1653 | ["-f", `owner=${owner}`], | |
| 1654 | ["-f", `name=${name}`], | |
| 1655 | ["-F", `number=${input.number}`], | |
| 1656 | ["-f", `headRef=refs/pull/${input.number}/head`], | |
| 1657 | ], | |
| 1561 | variables: { | |
| 1562 | owner, | |
| 1563 | name, | |
| 1564 | number: input.number, | |
| 1565 | headRef: `refs/pull/${input.number}/head`, | |
| 1566 | }, | |
| 1658 | 1567 | query: pullRequestCoreGraphQlQuery(input.host), |
| 1659 | 1568 | decode: decodePullRequestCoreJson, |
| 1660 | }), | |
| 1661 | ), | |
| 1662 | // gh already pages check contexts. Keep its complete, deduplicated result for | |
| 1663 | // large check suites instead of letting the first 100 checks imply success. | |
| 1664 | Effect.filterOrElse( | |
| 1665 | (core) => !core.checksTruncated, | |
| 1666 | (core) => | |
| 1667 | readLegacyDetail(input).pipe( | |
| 1668 | Effect.filterOrFail( | |
| 1669 | (detail) => detail.headSha === core.headSha, | |
| 1670 | () => | |
| 1671 | new GitHubPullRequestReadError({ | |
| 1672 | command: "gh", | |
| 1673 | cwd: input.cwd, | |
| 1674 | operation: "getPullRequestDetail", | |
| 1675 | cause: new Error("Pull request head changed while reading checks."), | |
| 1676 | }), | |
| 1677 | ), | |
| 1678 | Effect.map((detail) => ({ | |
| 1679 | ...core, | |
| 1680 | checks: detail.checks, | |
| 1681 | checksState: detail.checksState, | |
| 1682 | checksTruncated: false, | |
| 1683 | })), | |
| 1569 | }).pipe( | |
| 1570 | // Past a hundred checks the first page would let the rest imply success, so the whole | |
| 1571 | // rollup is walked instead. | |
| 1572 | Effect.filterOrElse( | |
| 1573 | (core) => !core.checksTruncated, | |
| 1574 | (core) => | |
| 1575 | readAllCheckContexts(input, allowReserve).pipe( | |
| 1576 | Effect.filterOrFail( | |
| 1577 | (all) => all.headSha === core.headSha, | |
| 1578 | () => | |
| 1579 | readError( | |
| 1580 | input.cwd, | |
| 1581 | "getPullRequestDetail", | |
| 1582 | new Error("Pull request head changed while reading checks."), | |
| 1583 | ), | |
| 1584 | ), | |
| 1585 | Effect.map((all) => ({ | |
| 1586 | ...core, | |
| 1587 | ...pullRequestChecksFromContexts(all.contexts), | |
| 1588 | checksTruncated: all.truncated, | |
| 1589 | })), | |
| 1590 | ), | |
| 1684 | 1591 | ), |
| 1592 | ), | |
| 1685 | 1593 | ), |
| 1686 | 1594 | ); |
| 1687 | 1595 | }; |
| 1688 | 1596 | |
| 1689 | 1597 | const workflowApprovalLimit = 1_000; |
| 1690 | const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); | |
| 1691 | 1598 | const workflowApprovalReadError = (cwd: string, cause: unknown) => |
| 1692 | new GitHubPullRequestReadError({ | |
| 1693 | command: "gh", | |
| 1694 | cwd, | |
| 1695 | operation: "listWorkflowRunsRequiringApproval", | |
| 1696 | cause, | |
| 1599 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); | |
| 1600 | ||
| 1601 | /** Every open pull request whose head branch carries this name, up to one past the limit. */ | |
| 1602 | const listHeadsByBranch = (input: { | |
| 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 | 1677 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = |
| 1699 | 1678 | (input) => |
| 1700 | 1679 | Effect.all( |
| 1701 | 1680 | [ |
| 1702 | github | |
| 1703 | .execute({ | |
| 1704 | cwd: input.cwd, | |
| 1705 | args: [ | |
| 1706 | "pr", | |
| 1707 | "list", | |
| 1708 | ...repositoryArgs(input), | |
| 1709 | "--state", | |
| 1710 | "open", | |
| 1711 | "--head", | |
| 1712 | input.headBranch, | |
| 1713 | "--limit", | |
| 1714 | workflowApprovalProbeLimit, | |
| 1715 | "--json", | |
| 1716 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | |
| 1717 | ], | |
| 1718 | }) | |
| 1719 | .pipe( | |
| 1720 | Effect.flatMap( | |
| 1721 | ( | |
| 1722 | result, | |
| 1723 | ): Effect.Effect< | |
| 1724 | GitHubPullRequestHead, | |
| 1725 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1726 | > => { | |
| 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(), | |
| 1681 | listHeadsByBranch(input).pipe( | |
| 1682 | Effect.flatMap( | |
| 1683 | ( | |
| 1684 | heads, | |
| 1685 | ): Effect.Effect< | |
| 1686 | GitHubPullRequestHead, | |
| 1687 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1688 | > => { | |
| 1689 | const exactHeads = heads.filter( | |
| 1690 | (pullRequest) => | |
| 1691 | pullRequest.headSha === input.headSha && | |
| 1692 | pullRequest.isCrossRepository === true && | |
| 1693 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1694 | input.headRepositoryOwner.toLowerCase(), | |
| 1695 | ); | |
| 1696 | if (heads.length > workflowApprovalLimit) { | |
| 1697 | return Effect.fail( | |
| 1698 | new GitHubWorkflowApprovalRefusedError({ | |
| 1699 | command: "gh", | |
| 1700 | cwd: input.cwd, | |
| 1701 | number: input.number, | |
| 1702 | reason: "head-list-truncated", | |
| 1703 | observedCount: heads.length, | |
| 1704 | limit: workflowApprovalLimit, | |
| 1705 | }), | |
| 1737 | 1706 | ); |
| 1738 | if (decoded.success.length > workflowApprovalLimit) { | |
| 1739 | return Effect.fail( | |
| 1740 | new GitHubWorkflowApprovalRefusedError({ | |
| 1741 | command: "gh", | |
| 1742 | cwd: input.cwd, | |
| 1743 | number: input.number, | |
| 1744 | reason: "head-list-truncated", | |
| 1745 | observedCount: decoded.success.length, | |
| 1746 | limit: workflowApprovalLimit, | |
| 1747 | }), | |
| 1748 | ); | |
| 1749 | } | |
| 1750 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1751 | return Effect.fail( | |
| 1752 | new GitHubWorkflowApprovalRefusedError({ | |
| 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 | ), | |
| 1707 | } | |
| 1708 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1709 | return Effect.fail( | |
| 1710 | new GitHubWorkflowApprovalRefusedError({ | |
| 1711 | command: "gh", | |
| 1712 | cwd: input.cwd, | |
| 1713 | number: input.number, | |
| 1714 | reason: "head-not-unique", | |
| 1715 | observedCount: exactHeads.length, | |
| 1716 | limit: workflowApprovalLimit, | |
| 1717 | }), | |
| 1718 | ); | |
| 1719 | } | |
| 1720 | return Effect.succeed(exactHeads[0]); | |
| 1721 | }, | |
| 1765 | 1722 | ), |
| 1766 | github | |
| 1767 | .execute({ | |
| 1768 | cwd: input.cwd, | |
| 1769 | args: [ | |
| 1770 | "run", | |
| 1771 | "list", | |
| 1772 | ...repositoryArgs(input), | |
| 1773 | "--commit", | |
| 1774 | input.headSha, | |
| 1775 | "--branch", | |
| 1776 | input.headBranch, | |
| 1777 | "--event", | |
| 1778 | "pull_request", | |
| 1779 | "--status", | |
| 1780 | "action_required", | |
| 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 | ), | |
| 1723 | ), | |
| 1724 | listActionRequiredRuns(input).pipe( | |
| 1725 | Effect.flatMap((runs) => | |
| 1726 | runs.length > workflowApprovalLimit | |
| 1727 | ? Effect.fail( | |
| 1728 | new GitHubWorkflowApprovalRefusedError({ | |
| 1729 | command: "gh", | |
| 1730 | cwd: input.cwd, | |
| 1731 | number: input.number, | |
| 1732 | reason: "run-list-truncated", | |
| 1733 | observedCount: runs.length, | |
| 1734 | limit: workflowApprovalLimit, | |
| 1735 | }), | |
| 1736 | ) | |
| 1737 | : Effect.succeed(runs), | |
| 1813 | 1738 | ), |
| 1739 | ), | |
| 1814 | 1740 | ], |
| 1815 | 1741 | { concurrency: 2 }, |
| 1816 | 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 | |
| 1819 | // the thread overview its author, diff stat, review decision and checks in the same read. | |
| 1820 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => | |
| 1821 | github | |
| 1822 | .execute({ | |
| 1823 | cwd: input.cwd, | |
| 1824 | args: [ | |
| 1825 | "pr", | |
| 1826 | "view", | |
| 1827 | String(input.number), | |
| 1828 | ...repositoryArgs(input), | |
| 1829 | "--json", | |
| 1830 | PULL_REQUEST_DETAIL_JSON_FIELDS, | |
| 1831 | ], | |
| 1832 | }) | |
| 1833 | .pipe( | |
| 1834 | Effect.flatMap((result) => { | |
| 1835 | const decoded = decodePullRequestDetailJson(result.stdout.trim()); | |
| 1836 | if (!Result.isSuccess(decoded)) { | |
| 1837 | return Effect.fail( | |
| 1838 | new GitHubPullRequestReadError({ | |
| 1839 | command: "gh", | |
| 1840 | cwd: input.cwd, | |
| 1841 | operation: "getPullRequestSummary", | |
| 1842 | cause: decoded.failure, | |
| 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 | ); | |
| 1744 | /** One pull request's summary, through the same aliased query the batch uses. */ | |
| 1745 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { | |
| 1746 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1747 | return graphqlRead({ | |
| 1748 | cwd: input.cwd, | |
| 1749 | host: input.host, | |
| 1750 | operation: "getPullRequestSummary", | |
| 1751 | variables: { owner, name, number: input.number }, | |
| 1752 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), | |
| 1753 | decode: decodePullRequestSummariesJson, | |
| 1754 | }).pipe( | |
| 1755 | Effect.flatMap((summaries) => { | |
| 1756 | const summary = summaries.get(0); | |
| 1757 | return summary === undefined | |
| 1758 | ? Effect.fail( | |
| 1759 | readError( | |
| 1760 | input.cwd, | |
| 1761 | "getPullRequestSummary", | |
| 1762 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), | |
| 1763 | ), | |
| 1764 | ) | |
| 1765 | : Effect.succeed(summary); | |
| 1766 | }), | |
| 1767 | ); | |
| 1768 | }; | |
| 1868 | 1769 | |
| 1869 | 1770 | /** |
| 1870 | 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 | 1777 | key: ({ request, context }) => |
| 1877 | 1778 | JSON.stringify([ |
| 1878 | 1779 | request.host.toLowerCase(), |
| 1879 | Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) | |
| 1780 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1880 | 1781 | ?.credentialFingerprint ?? null, |
| 1881 | 1782 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 1882 | 1783 | ]), |
| ... | ... | @@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () { |
| 1956 | 1857 | key: ({ request, context }) => |
| 1957 | 1858 | JSON.stringify([ |
| 1958 | 1859 | request.host.toLowerCase(), |
| 1959 | Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) | |
| 1860 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1960 | 1861 | ?.credentialFingerprint ?? null, |
| 1961 | 1862 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), |
| 1962 | 1863 | ]), |
| ... | ... | @@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () { |
| 2010 | 1911 | |
| 2011 | 1912 | listPullRequests: (input) => { |
| 2012 | 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 | 1945 | const read = ( |
| 2014 | 1946 | continues: boolean, |
| 2015 | 1947 | requestedRows = input.limit + 1, |
| 2016 | 1948 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> => |
| 2017 | github | |
| 2018 | .execute({ | |
| 2019 | cwd: input.cwd, | |
| 2020 | args: [ | |
| 2021 | "pr", | |
| 2022 | "list", | |
| 2023 | ...repositoryArgs(input), | |
| 2024 | ...involvementArgs({ ...input, sorted: continues }), | |
| 2025 | "--state", | |
| 2026 | input.state, | |
| 2027 | "--limit", | |
| 2028 | // One extra row reveals that the repository has more than the page shows. | |
| 2029 | String(requestedRows), | |
| 2030 | "--json", | |
| 2031 | PULL_REQUEST_LIST_JSON_FIELDS, | |
| 2032 | ], | |
| 2033 | }) | |
| 2034 | .pipe( | |
| 2035 | Effect.flatMap((result) => { | |
| 2036 | const raw = result.stdout.trim(); | |
| 2037 | if (raw.length === 0) { | |
| 2038 | return Effect.succeed({ items: [], truncated: false, continues }); | |
| 2039 | } | |
| 2040 | const decoded = decodePullRequestListJson(raw); | |
| 2041 | if (Result.isSuccess(decoded)) { | |
| 2042 | const items = continues | |
| 2043 | ? decoded.success.items | |
| 2044 | : decoded.success.items.filter((item) => matchesUnsortedListing(item, input)); | |
| 2045 | if ( | |
| 2046 | !continues && | |
| 2047 | items.length < input.limit && | |
| 2048 | decoded.success.rawCount >= requestedRows && | |
| 2049 | requestedRows < fallbackMaxRows | |
| 2050 | ) { | |
| 2051 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 2052 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 2053 | } | |
| 2054 | return Effect.succeed({ | |
| 2055 | items: items.slice(0, input.limit), | |
| 2056 | // One row over the page size is the probe for a next page, and it is | |
| 2057 | // counted before decoding: a skipped malformed row must not end paging. | |
| 2058 | truncated: continues | |
| 2059 | ? decoded.success.rawCount > input.limit | |
| 2060 | : items.length > input.limit || decoded.success.rawCount >= requestedRows, | |
| 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 | ); | |
| 1949 | collect(requestedRows, (after, rows) => | |
| 1950 | continues | |
| 1951 | ? graphqlRead({ | |
| 1952 | cwd: input.cwd, | |
| 1953 | host: input.host, | |
| 1954 | operation: "listPullRequests", | |
| 1955 | // The reader's own words are in the query. | |
| 1956 | variables: { q: query, after }, | |
| 1957 | query: pullRequestSearchGraphQlQuery(rows, false, true), | |
| 1958 | decode: decodePullRequestSearchJson, | |
| 1959 | }) | |
| 1960 | : graphqlRead({ | |
| 1961 | cwd: input.cwd, | |
| 1962 | host: input.host, | |
| 1963 | operation: "listPullRequests", | |
| 1964 | variables: { owner, name, states: LIST_STATES[input.state], after }, | |
| 1965 | query: pullRequestListGraphQlQuery(rows), | |
| 1966 | decode: decodePullRequestListJson, | |
| 1967 | }), | |
| 1968 | ).pipe( | |
| 1969 | Effect.flatMap(({ items: rawItems, rawCount }) => { | |
| 1970 | const items = continues | |
| 1971 | ? rawItems | |
| 1972 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); | |
| 1973 | if ( | |
| 1974 | !continues && | |
| 1975 | items.length < input.limit && | |
| 1976 | rawCount >= requestedRows && | |
| 1977 | requestedRows < fallbackMaxRows | |
| 1978 | ) { | |
| 1979 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 1980 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 1981 | } | |
| 1982 | return Effect.succeed({ | |
| 1983 | items: items.slice(0, input.limit), | |
| 1984 | // One row over the page size is the probe for a next page, and it is | |
| 1985 | // counted before decoding: a skipped malformed row must not end paging. | |
| 1986 | truncated: continues | |
| 1987 | ? rawCount > input.limit | |
| 1988 | : items.length > input.limit || rawCount >= requestedRows, | |
| 1989 | continues, | |
| 1990 | }); | |
| 1991 | }), | |
| 1992 | ); | |
| 2074 | 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` | |
| 2076 | // lists without one. Those rows come back newest-created first, an order no `updated:` | |
| 1994 | // with no rows rather than with an error — so an empty listing is read again from the | |
| 1995 | // repository's own list. Those rows come back newest-created first, an order no `updated:` | |
| 2077 | 1996 | // qualifier can carry on from, so that page says it cannot be continued and the reader |
| 2078 | 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 | 2076 | cwd: input.cwd, |
| 2158 | 2077 | host: input.host, |
| 2159 | 2078 | operation: "searchPullRequests", |
| 2160 | // The reader's own words are in the query, so it travels over stdin rather than in argv. | |
| 2161 | privateVariables: { q: query }, | |
| 2079 | variables: { q: query, after: null }, | |
| 2162 | 2080 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), |
| 2163 | 2081 | decode: decodePullRequestSearchJson, |
| 2164 | 2082 | }).pipe( |
| ... | ... | @@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () { |
| 2217 | 2135 | cwd: input.cwd, |
| 2218 | 2136 | host: input.host, |
| 2219 | 2137 | operation: "getPullRequestPreview", |
| 2220 | variables: [ | |
| 2221 | ["-f", `owner=${owner}`], | |
| 2222 | ["-f", `name=${name}`], | |
| 2223 | ["-F", `number=${input.number}`], | |
| 2224 | ], | |
| 2138 | variables: { owner, name, number: input.number }, | |
| 2225 | 2139 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, |
| 2226 | 2140 | decode: decodePullRequestPreviewJson, |
| 2227 | 2141 | }); |
| ... | ... | @@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () { |
| 2230 | 2144 | |
| 2231 | 2145 | getPullRequestStack: (input) => { |
| 2232 | 2146 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2233 | return github | |
| 2234 | .execute({ | |
| 2235 | cwd: input.cwd, | |
| 2236 | args: [ | |
| 2237 | "api", | |
| 2238 | "--hostname", | |
| 2239 | input.host, | |
| 2240 | `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2241 | ], | |
| 2242 | }) | |
| 2243 | .pipe( | |
| 2244 | Effect.flatMap((result) => { | |
| 2245 | const decoded = decodePullRequestStacksJson(result.stdout.trim()); | |
| 2246 | return Result.isSuccess(decoded) | |
| 2247 | ? Effect.succeed(decoded.success) | |
| 2248 | : Effect.fail( | |
| 2249 | new GitHubPullRequestReadError({ | |
| 2250 | command: "gh", | |
| 2251 | cwd: input.cwd, | |
| 2252 | operation: "getPullRequestStack", | |
| 2253 | cause: decoded.failure, | |
| 2254 | }), | |
| 2255 | ); | |
| 2256 | }), | |
| 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 | ); | |
| 2147 | return restRead({ | |
| 2148 | cwd: input.cwd, | |
| 2149 | host: input.host, | |
| 2150 | operation: "getPullRequestStack", | |
| 2151 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2152 | decode: decodePullRequestStacksJson, | |
| 2153 | }).pipe( | |
| 2154 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | |
| 2155 | Effect.flatMap((stack) => { | |
| 2156 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | |
| 2157 | return restRead({ | |
| 2158 | cwd: input.cwd, | |
| 2159 | host: input.host, | |
| 2160 | operation: "getPullRequestStack", | |
| 2161 | path: `repos/${owner}/${name}/stacks/${stack.number}`, | |
| 2162 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), | |
| 2163 | }); | |
| 2164 | }), | |
| 2165 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 2166 | // previously synced stack and let the caller retry. | |
| 2167 | Effect.catchTags({ | |
| 2168 | GitHubApiNotFoundError: () => Effect.succeed(null), | |
| 2169 | }), | |
| 2170 | ); | |
| 2292 | 2171 | }, |
| 2293 | 2172 | |
| 2294 | 2173 | getPullRequestBaseComparison: (input) => { |
| ... | ... | @@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () { |
| 2298 | 2177 | host: input.host, |
| 2299 | 2178 | operation: "getPullRequestBaseComparison", |
| 2300 | 2179 | ...(input.allowReserve === true ? { allowReserve: true } : {}), |
| 2301 | variables: [ | |
| 2302 | ["-f", `owner=${owner}`], | |
| 2303 | ["-f", `name=${name}`], | |
| 2304 | ["-F", `number=${input.number}`], | |
| 2305 | ["-f", `headRef=${input.headRef}`], | |
| 2306 | ], | |
| 2180 | variables: { owner, name, number: input.number, headRef: input.headRef }, | |
| 2307 | 2181 | query: BASE_COMPARISON_GRAPHQL_QUERY, |
| 2308 | 2182 | decode: decodeBaseComparisonJson, |
| 2309 | 2183 | }); |
| 2310 | 2184 | }, |
| 2311 | 2185 | |
| 2312 | 2186 | getPullRequestActivity: (input) => |
| 2313 | github | |
| 2314 | .execute({ | |
| 2315 | cwd: input.cwd, | |
| 2316 | args: [ | |
| 2317 | "pr", | |
| 2318 | "view", | |
| 2319 | String(input.number), | |
| 2320 | ...repositoryArgs(input), | |
| 2321 | "--json", | |
| 2322 | PULL_REQUEST_ACTIVITY_JSON_FIELDS, | |
| 2323 | ], | |
| 2324 | }) | |
| 2325 | .pipe( | |
| 2326 | Effect.flatMap((result) => { | |
| 2327 | const decoded = decodePullRequestActivityJson(result.stdout.trim()); | |
| 2328 | return Result.isSuccess(decoded) | |
| 2329 | ? Effect.succeed(decoded.success) | |
| 2330 | : Effect.fail( | |
| 2331 | new GitHubPullRequestReadError({ | |
| 2332 | command: "gh", | |
| 2333 | cwd: input.cwd, | |
| 2334 | operation: "getPullRequestActivity", | |
| 2335 | cause: decoded.failure, | |
| 2336 | }), | |
| 2337 | ); | |
| 2338 | }), | |
| 2339 | ), | |
| 2187 | Effect.gen(function* () { | |
| 2188 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2189 | let author: PullRequestActor | null = null; | |
| 2190 | let commits: ReadonlyArray<PullRequestCommit> = []; | |
| 2191 | const remarks: PullRequestComment[] = []; | |
| 2192 | let commentsAfter: string | null = null; | |
| 2193 | let reviewsAfter: string | null = null; | |
| 2194 | let withComments = true; | |
| 2195 | let withReviews = true; | |
| 2196 | // Both remark lists page on their own; a page asks only for the ones with more to give. | |
| 2197 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { | |
| 2198 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ | |
| 2199 | cwd: input.cwd, | |
| 2200 | host: input.host, | |
| 2201 | operation: "getPullRequestActivity", | |
| 2202 | variables: { | |
| 2203 | owner, | |
| 2204 | name, | |
| 2205 | number: input.number, | |
| 2206 | head: page === 0, | |
| 2207 | withComments, | |
| 2208 | commentsAfter, | |
| 2209 | withReviews, | |
| 2210 | reviewsAfter, | |
| 2211 | }, | |
| 2212 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 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 | 2234 | getPullRequestDiff: (input) => { |
| 2342 | 2235 | const filesPage = (page: number) => |
| ... | ... | @@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () { |
| 2363 | 2256 | if (input.commit !== undefined) { |
| 2364 | 2257 | return filesPage(1); |
| 2365 | 2258 | } |
| 2366 | return github | |
| 2367 | .execute({ | |
| 2368 | cwd: input.cwd, | |
| 2369 | args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"], | |
| 2370 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 2371 | timeoutMs: DIFF_TIMEOUT_MS, | |
| 2259 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2260 | return api | |
| 2261 | .rest({ | |
| 2262 | host: input.host, | |
| 2263 | operation: "getPullRequestDiff", | |
| 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 | 2269 | .pipe( |
| 2374 | Effect.flatMap((result) => | |
| 2270 | Effect.flatMap((response) => | |
| 2375 | 2271 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor |
| 2376 | 2272 | // something the reader can carry on from. The files API can serve the same change a |
| 2377 | 2273 | // whole number of files at a time, so an oversized patch takes that road as well. |
| 2378 | result.stdoutTruncated | |
| 2274 | response.truncated | |
| 2379 | 2275 | ? filesPage(1) |
| 2380 | 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 | 2279 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read |
| 2384 | 2280 | // from the files API instead, a page per call. Only once the direct read has failed: a |
| 2385 | 2281 | // pull request GitHub will serve a diff for must not pay for a second request. A |
| 2386 | 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 | |
| 2388 | // signed-out one fails the same way for every request. | |
| 2283 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or | |
| 2284 | // a rate limit fails the same way for every request. | |
| 2389 | 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 | 2296 | cwd: input.cwd, |
| 2401 | 2297 | host: input.host, |
| 2402 | 2298 | operation: "getReviewThreadComments", |
| 2403 | variables: [ | |
| 2404 | ["-f", `owner=${owner}`], | |
| 2405 | ["-f", `name=${name}`], | |
| 2406 | ["-F", `number=${input.number}`], | |
| 2407 | ["-f", `threadId=${input.threadId}`], | |
| 2408 | cursorVariable(input.cursor), | |
| 2409 | ], | |
| 2299 | variables: { | |
| 2300 | owner, | |
| 2301 | name, | |
| 2302 | number: input.number, | |
| 2303 | threadId: input.threadId, | |
| 2304 | cursor: input.cursor, | |
| 2305 | }, | |
| 2410 | 2306 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, |
| 2411 | 2307 | decode: decodeReviewThreadCommentsJson, |
| 2412 | 2308 | }).pipe( |
| ... | ... | @@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () { |
| 2434 | 2330 | cwd: input.cwd, |
| 2435 | 2331 | host: input.host, |
| 2436 | 2332 | operation: "listReviewThreadComments", |
| 2437 | variables: [ | |
| 2438 | ["-f", `owner=${owner}`], | |
| 2439 | ["-f", `name=${name}`], | |
| 2440 | ["-F", `number=${input.number}`], | |
| 2441 | cursorVariable(cursor), | |
| 2442 | ], | |
| 2333 | variables: { owner, name, number: input.number, cursor }, | |
| 2443 | 2334 | query: REVIEW_THREADS_GRAPHQL_QUERY, |
| 2444 | 2335 | decode: decodeReviewThreadsJson, |
| 2445 | 2336 | }); |
| ... | ... | @@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () { |
| 2496 | 2387 | cwd: input.cwd, |
| 2497 | 2388 | host: input.host, |
| 2498 | 2389 | operation: "listReviewThreadComments", |
| 2499 | variables: [ | |
| 2500 | ["-f", `owner=${owner}`], | |
| 2501 | ["-f", `name=${name}`], | |
| 2502 | ["-F", `number=${input.number}`], | |
| 2503 | ["-f", `cursor=${dismissalCursor}`], | |
| 2504 | ], | |
| 2390 | variables: { owner, name, number: input.number, cursor: dismissalCursor }, | |
| 2505 | 2391 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, |
| 2506 | 2392 | decode: decodeReviewDismissalsJson, |
| 2507 | 2393 | }); |
| ... | ... | @@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () { |
| 2547 | 2433 | cwd: input.cwd, |
| 2548 | 2434 | host: input.host, |
| 2549 | 2435 | operation: "listActorAvatars", |
| 2550 | variables: input.ids.map((id) => ["-f", `ids[]=${id}`]), | |
| 2436 | variables: { ids: input.ids }, | |
| 2551 | 2437 | query: ACTOR_AVATARS_GRAPHQL_QUERY, |
| 2552 | 2438 | decode: decodeActorAvatarsJson, |
| 2553 | 2439 | }); |
| ... | ... | @@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () { |
| 2560 | 2446 | host: input.host, |
| 2561 | 2447 | operation: "getViewerAccess", |
| 2562 | 2448 | ...(input.allowReserve === true ? { allowReserve: true } : {}), |
| 2563 | variables: [ | |
| 2564 | ["-f", `owner=${owner}`], | |
| 2565 | ["-f", `name=${name}`], | |
| 2566 | ["-F", `number=${input.number}`], | |
| 2567 | ], | |
| 2449 | variables: { owner, name, number: input.number }, | |
| 2568 | 2450 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, |
| 2569 | 2451 | decode: decodeViewerPermissionsJson, |
| 2570 | 2452 | }); |
| ... | ... | @@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () { |
| 2577 | 2459 | host: input.host, |
| 2578 | 2460 | operation: "listReviewerCandidates", |
| 2579 | 2461 | allowReserve: true, |
| 2580 | variables: [ | |
| 2581 | ["-f", `owner=${owner}`], | |
| 2582 | ["-f", `name=${name}`], | |
| 2583 | ["-F", `number=${input.number}`], | |
| 2584 | ], | |
| 2462 | variables: { owner, name, number: input.number }, | |
| 2585 | 2463 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, |
| 2586 | 2464 | decode: decodeReviewerCandidatesJson, |
| 2587 | 2465 | }); |
| ... | ... | @@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () { |
| 2589 | 2467 | |
| 2590 | 2468 | setReviewerRequest: (input) => { |
| 2591 | 2469 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2592 | return github | |
| 2593 | .execute({ | |
| 2594 | cwd: input.cwd, | |
| 2595 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2596 | // there is nothing to say here about somebody who has reviewed once already. The body | |
| 2597 | // travels over stdin for the reason every other one does: argv is visible in process | |
| 2598 | // listings and echoed back inside process-runner failure messages. | |
| 2599 | args: [ | |
| 2600 | "api", | |
| 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), | |
| 2470 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2471 | // there is nothing to say here about somebody who has reviewed once already. | |
| 2472 | return api | |
| 2473 | .rest({ | |
| 2474 | host: input.host, | |
| 2475 | operation: "setReviewerRequest", | |
| 2476 | method: input.requested ? "POST" : "DELETE", | |
| 2477 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | |
| 2478 | body: buildReviewerRequest(input.reviewers), | |
| 2610 | 2479 | }) |
| 2611 | 2480 | .pipe(Effect.asVoid); |
| 2612 | 2481 | }, |
| ... | ... | @@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () { |
| 2618 | 2487 | host: input.host, |
| 2619 | 2488 | operation: "listLabelCandidates", |
| 2620 | 2489 | allowReserve: true, |
| 2621 | variables: [ | |
| 2622 | ["-f", `owner=${owner}`], | |
| 2623 | ["-f", `name=${name}`], | |
| 2624 | ["-F", `number=${input.number}`], | |
| 2625 | ], | |
| 2490 | variables: { owner, name, number: input.number }, | |
| 2626 | 2491 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, |
| 2627 | 2492 | decode: decodeLabelCandidatesJson, |
| 2628 | 2493 | }); |
| ... | ... | @@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () { |
| 2635 | 2500 | // path. The name goes into the path encoded, because a label may carry a space or a slash. |
| 2636 | 2501 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; |
| 2637 | 2502 | if (input.applied) { |
| 2638 | return github | |
| 2639 | .execute({ | |
| 2640 | cwd: input.cwd, | |
| 2641 | args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], | |
| 2642 | stdin: buildLabelRequestJson(input.labels), | |
| 2503 | return api | |
| 2504 | .rest({ | |
| 2505 | host: input.host, | |
| 2506 | operation: "setLabels", | |
| 2507 | method: "POST", | |
| 2508 | path: issue, | |
| 2509 | body: buildLabelRequest(input.labels), | |
| 2643 | 2510 | }) |
| 2644 | 2511 | .pipe(Effect.asVoid); |
| 2645 | 2512 | } |
| 2646 | 2513 | return Effect.forEach( |
| 2647 | 2514 | input.labels, |
| 2648 | 2515 | (label) => |
| 2649 | github.execute({ | |
| 2650 | cwd: input.cwd, | |
| 2651 | args: [ | |
| 2652 | "api", | |
| 2653 | "--method", | |
| 2654 | "DELETE", | |
| 2655 | "--hostname", | |
| 2656 | input.host, | |
| 2657 | `${issue}/${encodeURIComponent(label)}`, | |
| 2658 | ], | |
| 2516 | api.rest({ | |
| 2517 | host: input.host, | |
| 2518 | operation: "setLabels", | |
| 2519 | method: "DELETE", | |
| 2520 | path: `${issue}/${encodeURIComponent(label)}`, | |
| 2659 | 2521 | }), |
| 2660 | 2522 | { concurrency: 1, discard: true }, |
| 2661 | 2523 | ); |
| ... | ... | @@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () { |
| 2664 | 2526 | runPullRequestAction: (input) => { |
| 2665 | 2527 | if (input.stackNumber !== undefined) |
| 2666 | 2528 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( |
| 2667 | Effect.provideService(GitHubCli.GitHubCli, github), | |
| 2529 | Effect.provideService(GitHubApi.GitHubApi, api), | |
| 2668 | 2530 | ); |
| 2669 | 2531 | if (input.action === "revert") { |
| 2670 | 2532 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( |
| 2671 | 2533 | Effect.flatMap((pullRequestId) => |
| 2672 | 2534 | graphql({ |
| 2673 | cwd: input.cwd, | |
| 2674 | 2535 | host: input.host, |
| 2536 | operation: "revertPullRequest", | |
| 2675 | 2537 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, |
| 2676 | 2538 | variables: { pullRequestId }, |
| 2677 | 2539 | }), |
| ... | ... | @@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () { |
| 2742 | 2604 | }), |
| 2743 | 2605 | Effect.flatMap((currentRuns) => |
| 2744 | 2606 | currentRuns.some((current) => current.id === run.id) |
| 2745 | ? github | |
| 2746 | .execute({ | |
| 2747 | cwd: input.cwd, | |
| 2748 | args: [ | |
| 2749 | "api", | |
| 2750 | "--method", | |
| 2751 | "POST", | |
| 2752 | "--hostname", | |
| 2753 | input.host, | |
| 2754 | `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2755 | "--silent", | |
| 2756 | ], | |
| 2607 | ? api | |
| 2608 | .rest({ | |
| 2609 | host: input.host, | |
| 2610 | operation: "approveWorkflowRun", | |
| 2611 | method: "POST", | |
| 2612 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2757 | 2613 | }) |
| 2758 | 2614 | .pipe(Effect.asVoid) |
| 2759 | 2615 | : Effect.void, |
| ... | ... | @@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () { |
| 2766 | 2622 | }), |
| 2767 | 2623 | ); |
| 2768 | 2624 | } |
| 2769 | const [subcommand, ...flags] = actionArgs( | |
| 2770 | input.action, | |
| 2771 | input.mergeMethod, | |
| 2772 | input.updateMethod, | |
| 2773 | ); | |
| 2625 | const action = input.action; | |
| 2626 | if (action in SIMPLE_ACTION_MUTATIONS) { | |
| 2627 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( | |
| 2628 | Effect.flatMap((pullRequestId) => | |
| 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 | 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 | 2671 | let body: string | undefined; |
| 2776 | 2672 | let expectedHead: string | undefined; |
| 2777 | if ( | |
| 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); | |
| 2673 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { | |
| 2783 | 2674 | const message = yield* graphqlRead({ |
| 2784 | 2675 | cwd: input.cwd, |
| 2785 | 2676 | host: input.host, |
| 2786 | 2677 | operation: "runPullRequestAction", |
| 2787 | 2678 | allowReserve: true, |
| 2788 | 2679 | query: MERGE_MESSAGE_GRAPHQL_QUERY, |
| 2789 | variables: [ | |
| 2790 | ["-f", `owner=${owner}`], | |
| 2791 | ["-f", `name=${name}`], | |
| 2792 | ["-F", `number=${input.number}`], | |
| 2793 | ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`], | |
| 2794 | ], | |
| 2680 | variables: { | |
| 2681 | owner, | |
| 2682 | name, | |
| 2683 | number: input.number, | |
| 2684 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", | |
| 2685 | }, | |
| 2795 | 2686 | decode: decodeMergeMessage, |
| 2796 | 2687 | }); |
| 2797 | 2688 | // GitHub's merge queue chooses its own message and ignores custom text. |
| ... | ... | @@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () { |
| 2803 | 2694 | } |
| 2804 | 2695 | } |
| 2805 | 2696 | } |
| 2806 | yield* github.execute({ | |
| 2807 | cwd: input.cwd, | |
| 2808 | args: [ | |
| 2809 | "pr", | |
| 2810 | subcommand!, | |
| 2811 | String(input.number), | |
| 2812 | ...repositoryArgs(input), | |
| 2813 | ...flags, | |
| 2814 | ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]), | |
| 2815 | ...(body === undefined ? [] : ["--body-file", "-"]), | |
| 2816 | ], | |
| 2817 | ...(body === undefined ? {} : { stdin: body }), | |
| 2697 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and | |
| 2698 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. | |
| 2699 | // GitHub stores the strategy with a standing instruction rather than choosing one at | |
| 2700 | // merge time, so arming still names it. | |
| 2701 | const auto = | |
| 2702 | state.isMergeQueueEnabled === true || | |
| 2703 | (action === "enable-auto-merge" && | |
| 2704 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); | |
| 2705 | yield* graphql({ | |
| 2706 | host: input.host, | |
| 2707 | operation: "runPullRequestAction", | |
| 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 | 2721 | commentOnPullRequest: (input) => |
| 2823 | github | |
| 2824 | .execute({ | |
| 2825 | cwd: input.cwd, | |
| 2826 | // The body travels over stdin: argv is visible in process listings and is echoed | |
| 2827 | // back inside process-runner failure messages. | |
| 2828 | args: [ | |
| 2829 | "pr", | |
| 2830 | "comment", | |
| 2831 | String(input.number), | |
| 2832 | ...repositoryArgs(input), | |
| 2833 | "--body-file", | |
| 2834 | "-", | |
| 2835 | ], | |
| 2836 | stdin: input.body, | |
| 2837 | }) | |
| 2838 | .pipe(Effect.asVoid), | |
| 2722 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( | |
| 2723 | Effect.flatMap((subjectId) => | |
| 2724 | graphql({ | |
| 2725 | host: input.host, | |
| 2726 | operation: "commentOnPullRequest", | |
| 2727 | query: ADD_COMMENT_GRAPHQL_MUTATION, | |
| 2728 | variables: { subjectId, body: input.body }, | |
| 2729 | }), | |
| 2730 | ), | |
| 2731 | ), | |
| 2839 | 2732 | |
| 2840 | 2733 | submitReview: (input) => { |
| 2841 | 2734 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2842 | return github | |
| 2843 | .execute({ | |
| 2844 | cwd: input.cwd, | |
| 2845 | // The whole review is one request, so nothing is visible to anyone else until the | |
| 2846 | // verdict is sent. The payload travels over stdin for the same reason a comment | |
| 2847 | // body does: argv is visible in process listings and echoed back in failures. | |
| 2848 | args: [ | |
| 2849 | "api", | |
| 2850 | "--method", | |
| 2851 | "POST", | |
| 2852 | "--hostname", | |
| 2853 | input.host, | |
| 2854 | `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2855 | "--input", | |
| 2856 | "-", | |
| 2857 | ], | |
| 2858 | stdin: buildReviewSubmissionJson({ | |
| 2735 | // The whole review is one request, so nothing is visible to anyone else until the verdict | |
| 2736 | // is sent. | |
| 2737 | return api | |
| 2738 | .rest({ | |
| 2739 | host: input.host, | |
| 2740 | operation: "submitReview", | |
| 2741 | method: "POST", | |
| 2742 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2743 | body: buildReviewSubmission({ | |
| 2859 | 2744 | verdict: input.verdict, |
| 2860 | 2745 | body: input.body, |
| 2861 | 2746 | comments: input.comments, |
| ... | ... | @@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () { |
| 2866 | 2751 | |
| 2867 | 2752 | replyToReviewThread: (input) => |
| 2868 | 2753 | graphql({ |
| 2869 | cwd: input.cwd, | |
| 2870 | 2754 | host: input.host, |
| 2755 | operation: "replyToReviewThread", | |
| 2871 | 2756 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, |
| 2872 | 2757 | variables: { threadId: input.threadId, body: input.body }, |
| 2873 | 2758 | }), |
| ... | ... | @@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () { |
| 2883 | 2768 | cwd: input.cwd, |
| 2884 | 2769 | host: input.host, |
| 2885 | 2770 | operation: "getPullRequestFilesViewed", |
| 2886 | variables: [ | |
| 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 | ], | |
| 2771 | variables: { owner, name, number: input.number, after }, | |
| 2894 | 2772 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, |
| 2895 | 2773 | decode: decodePullRequestFilesViewedJson, |
| 2896 | 2774 | }).pipe( |
| ... | ... | @@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () { |
| 2913 | 2791 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( |
| 2914 | 2792 | Effect.flatMap((pullRequestId) => |
| 2915 | 2793 | graphql({ |
| 2916 | cwd: input.cwd, | |
| 2917 | 2794 | host: input.host, |
| 2795 | operation: "setPullRequestFilesViewed", | |
| 2918 | 2796 | query: mutation.query, |
| 2919 | 2797 | variables: { pullRequestId, ...mutation.variables }, |
| 2920 | 2798 | }), |
| ... | ... | @@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () { |
| 2924 | 2802 | |
| 2925 | 2803 | setReviewThreadResolution: (input) => |
| 2926 | 2804 | graphql({ |
| 2927 | cwd: input.cwd, | |
| 2928 | 2805 | host: input.host, |
| 2806 | operation: "setReviewThreadResolution", | |
| 2929 | 2807 | query: input.resolved |
| 2930 | 2808 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION |
| 2931 | 2809 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, |
| ... | ... | @@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () { |
| 2957 | 2835 | return subjectId.pipe( |
| 2958 | 2836 | Effect.flatMap((subjectId) => |
| 2959 | 2837 | graphql({ |
| 2960 | cwd: input.cwd, | |
| 2961 | 2838 | host: input.host, |
| 2839 | operation: "setReaction", | |
| 2962 | 2840 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, |
| 2963 | 2841 | variables: { subjectId, content: gitHubReactionContent(input.content) }, |
| 2964 | 2842 | }), |
| ... | ... | @@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () { |
| 2970 | 2848 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( |
| 2971 | 2849 | Effect.flatMap((pullRequestId) => |
| 2972 | 2850 | graphql({ |
| 2973 | cwd: input.cwd, | |
| 2974 | 2851 | host: input.host, |
| 2852 | operation: "updatePullRequest", | |
| 2975 | 2853 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, |
| 2976 | 2854 | // A field the caller did not name is left out of the request entirely, so GitHub |
| 2977 | 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 | 2884 | ), |
| 3007 | 2885 | Effect.flatMap((commentId) => |
| 3008 | 2886 | graphql({ |
| 3009 | cwd: input.cwd, | |
| 3010 | 2887 | host: input.host, |
| 2888 | operation: "updateComment", | |
| 3011 | 2889 | query: |
| 3012 | 2890 | input.kind === "issue-comment" |
| 3013 | 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 | 6 | import type { PullRequestReaction } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; |
| 9 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 10 | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 11 | 11 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 12 | 12 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| ... | ... | @@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => { |
| 331 | 331 | number: 7, |
| 332 | 332 | }) |
| 333 | 333 | .pipe( |
| 334 | Effect.provideService(GitHubCli.PinnedGitHubCredential, { | |
| 334 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 335 | 335 | host: "github.com", |
| 336 | 336 | token: Redacted.make("credential"), |
| 337 | 337 | credentialFingerprint: fingerprint, |
| ... | ... | @@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => { |
| 378 | 378 | commits: [], |
| 379 | 379 | }).pipe( |
| 380 | 380 | Effect.flatMap((detail) => |
| 381 | GitHubCli.PinnedGitHubCredential.pipe( | |
| 381 | GitHubApi.PinnedGitHubCredential.pipe( | |
| 382 | 382 | Effect.map((credential) => ({ |
| 383 | 383 | ...detail, |
| 384 | 384 | viewerAccess: { |
| ... | ... | @@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () => |
| 672 | 672 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 673 | 673 | listWorkflowRunsRequiringApproval: () => |
| 674 | 674 | Effect.fail( |
| 675 | new GitHubCli.GitHubCliRateLimitError({ | |
| 676 | command: "gh", | |
| 677 | cwd: "/w", | |
| 678 | cause: new Error("rate limited"), | |
| 675 | new GitHubApi.GitHubApiRateLimitError({ | |
| 676 | host: "github.com", | |
| 677 | operation: "listWorkflowRunsRequiringApproval", | |
| 679 | 678 | }), |
| 680 | 679 | ), |
| 681 | 680 | getViewerAccess: () => |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+20-14| ... | ... | @@ -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 | 105 | export function gitHubProviderFailure( |
| 106 | 106 | error: GitHubPullRequestCli.GitHubPullRequestCliError, |
| 107 | 107 | ): PullRequestProviderFailure { |
| 108 | if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" }; | |
| 109 | if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" }; | |
| 110 | if (error._tag === "GitHubCliRateLimitError") | |
| 111 | return { | |
| 112 | reason: "rate-limited", | |
| 113 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 114 | }; | |
| 115 | if (error._tag === "SourceControlRateLimitPausedError") { | |
| 116 | return { reason: "rate-limited", retryAt: error.retryAt }; | |
| 108 | switch (error._tag) { | |
| 109 | case "GitHubCliMissingError": | |
| 110 | return { reason: "missing-tool" }; | |
| 111 | case "GitHubNotSignedInError": | |
| 112 | case "GitHubApiAuthenticationError": | |
| 113 | return { reason: "unauthenticated" }; | |
| 114 | case "GitHubApiRateLimitError": | |
| 115 | return { | |
| 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 | 202 | provider: "github", |
| 197 | 203 | operation, |
| 198 | 204 | ...gitHubProviderFailure(error), |
| 199 | detail: error.detail, | |
| 205 | detail: error.message, | |
| 200 | 206 | cause: error, |
| 201 | 207 | }); |
| 202 | 208 | |
| ... | ... | @@ -224,7 +230,7 @@ export const make = Effect.gen(function* () { |
| 224 | 230 | .pipe( |
| 225 | 231 | Effect.matchEffect({ |
| 226 | 232 | onFailure: (error) => |
| 227 | error._tag === "GitHubCliRateLimitError" || | |
| 233 | error._tag === "GitHubApiRateLimitError" || | |
| 228 | 234 | error._tag === "SourceControlRateLimitPausedError" |
| 229 | 235 | ? Effect.fail(error) |
| 230 | 236 | : Effect.succeed({ |
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | ... | @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 4 | 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 | 6 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 7 | 7 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 8 | 8 | import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; |
| ... | ... | @@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure"); |
| 15 | 15 | it("classifies rate limits from every pull-request provider", () => { |
| 16 | 16 | assert.deepStrictEqual( |
| 17 | 17 | gitHubProviderFailure( |
| 18 | new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }), | |
| 18 | new GitHubApi.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }), | |
| 19 | 19 | ), |
| 20 | 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 | 5 | |
| 6 | 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 7 | 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 | 10 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 11 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 10 | 12 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 11 | 13 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; |
| 12 | 14 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; |
| ... | ... | @@ -60,8 +62,14 @@ export const make = Effect.map( |
| 60 | 62 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 61 | 63 | Layer.provide( |
| 62 | 64 | GitHubPullRequestCli.layer.pipe( |
| 63 | Layer.provide(GitHubCli.layer), | |
| 64 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 65 | Layer.provide( | |
| 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 | 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 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import { ChildProcessSpawner } from "effect/process"; | |
| 8 | 7 | import type { PullRequestCheck } from "@t3tools/contracts"; |
| 9 | 8 | |
| 10 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 11 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 10 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 12 | 11 | |
| 13 | 12 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 14 | 13 | |
| ... | ... | @@ -27,31 +26,46 @@ it.effect( |
| 27 | 26 | let changed = ""; |
| 28 | 27 | let reads = 0; |
| 29 | 28 | const requests: string[] = []; |
| 29 | const known: Array<unknown> = []; | |
| 30 | 30 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 31 | 31 | Effect.provide( |
| 32 | Layer.mock(GitHubCli.GitHubCli)({ | |
| 33 | execute: ({ args }) => | |
| 32 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 33 | rest: ({ path, ifNoneMatch }) => | |
| 34 | 34 | Effect.sync(() => { |
| 35 | const endpoint = args[1]!; | |
| 36 | requests.push(endpoint); | |
| 37 | const head = endpoint.endsWith("/pulls/1"); | |
| 35 | requests.push(path); | |
| 36 | const head = path.endsWith("/pulls/1"); | |
| 38 | 37 | const modified = |
| 39 | !args.includes("-H") || (endpoint.includes(changed) && changed !== ""); | |
| 40 | const next = endpoint.includes("check-runs") && endpoint.endsWith("page=1"); | |
| 38 | ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); | |
| 39 | const next = path.includes("check-runs") && path.endsWith("page=1"); | |
| 40 | const runs = path.includes("/actions/runs"); | |
| 41 | 41 | return { |
| 42 | exitCode: ChildProcessSpawner.ExitCode(modified ? 0 : 1), | |
| 43 | stdout: 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 }) : ""}` | |
| 45 | : "HTTP/2.0 304 Not Modified\r\n\r\n", | |
| 46 | stderr: "", | |
| 47 | stdoutTruncated: false, | |
| 48 | stderrTruncated: false, | |
| 42 | status: modified ? 200 : 304, | |
| 43 | headers: modified | |
| 44 | ? { | |
| 45 | etag: `"${sha}-${changed}"`, | |
| 46 | ...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}), | |
| 47 | } | |
| 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 | 69 | reads++; |
| 56 | 70 | return { |
| 57 | 71 | state: "open" as const, |
| ... | ... | @@ -64,10 +78,15 @@ it.effect( |
| 64 | 78 | }); |
| 65 | 79 | const poll = (identity = credential) => |
| 66 | 80 | revalidate(reference, read).pipe( |
| 67 | Effect.provideService(GitHubCli.PinnedGitHubCredential, identity), | |
| 81 | Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), | |
| 68 | 82 | ); |
| 69 | 83 | yield* poll(); |
| 70 | 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 | 90 | requests.length = 0; |
| 72 | 91 | yield* poll(); |
| 73 | 92 | expect(reads).toBe(1); |
| ... | ... | @@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou |
| 113 | 132 | let reads = 0; |
| 114 | 133 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 115 | 134 | Effect.provide( |
| 116 | Layer.mock(GitHubCli.GitHubCli)({ | |
| 117 | execute: ({ args }) => | |
| 135 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 136 | rest: ({ path, ifNoneMatch }) => | |
| 118 | 137 | unavailable |
| 119 | 138 | ? Effect.fail( |
| 120 | new GitHubCli.GitHubCliCommandError({ | |
| 121 | command: "gh", | |
| 122 | cwd: "/repo", | |
| 123 | cause: undefined, | |
| 124 | httpStatus: 502, | |
| 139 | new GitHubApi.GitHubApiResponseError({ | |
| 140 | host: "github.com", | |
| 141 | operation: "revalidateChecks", | |
| 142 | status: 502, | |
| 125 | 143 | }), |
| 126 | 144 | ) |
| 127 | : Effect.succeed({ | |
| 128 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 129 | stdout: | |
| 130 | args.includes("-H") && etags | |
| 131 | ? "HTTP/2.0 304 Not Modified\n\n" | |
| 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 }) : ""}`, | |
| 133 | stderr: "", | |
| 134 | stdoutTruncated: false, | |
| 135 | stderrTruncated: false, | |
| 136 | }), | |
| 145 | : Effect.succeed( | |
| 146 | ifNoneMatch !== undefined && etags | |
| 147 | ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } | |
| 148 | : { | |
| 149 | status: 200, | |
| 150 | headers: etags ? { etag: '"one"' } : {}, | |
| 151 | body: path.endsWith("/pulls/1") | |
| 152 | ? encodeJson({ | |
| 153 | head: { sha, repo: { id: 1 } }, | |
| 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 | 165 | reads++; |
| 142 | 166 | return fail |
| 143 | 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 | 174 | : Effect.succeed({ |
| 147 | 175 | state: "open" as const, |
| ... | ... | @@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou |
| 152 | 180 | }); |
| 153 | 181 | const poll = () => |
| 154 | 182 | revalidate(reference, read).pipe( |
| 155 | Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), | |
| 183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 156 | 184 | ); |
| 157 | 185 | yield* poll().pipe(Effect.flip); |
| 158 | 186 | fail = false; |
| ... | ... | @@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor |
| 190 | 218 | let reads = 0; |
| 191 | 219 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 192 | 220 | Effect.provide( |
| 193 | Layer.mock(GitHubCli.GitHubCli)({ | |
| 194 | execute: () => { | |
| 221 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 222 | rest: () => { | |
| 195 | 223 | probes++; |
| 196 | 224 | return Effect.fail( |
| 197 | new GitHubCli.GitHubCliCommandError({ | |
| 198 | command: "gh", | |
| 199 | cwd: "/repo", | |
| 200 | cause: undefined, | |
| 201 | httpStatus: 404, | |
| 225 | new GitHubApi.GitHubApiNotFoundError({ | |
| 226 | host: "github.com", | |
| 227 | operation: "revalidateChecks", | |
| 202 | 228 | }), |
| 203 | 229 | ); |
| 204 | 230 | }, |
| ... | ... | @@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor |
| 211 | 237 | }); |
| 212 | 238 | for (let tick = 0; tick < 2; tick++) |
| 213 | 239 | yield* revalidate(reference, read).pipe( |
| 214 | Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), | |
| 240 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 215 | 241 | ); |
| 216 | 242 | expect(probes).toBe(1); |
| 217 | 243 | expect(reads).toBe(2); |
apps/server/src/pullRequest/gitHubConditionalChecks.ts+95-54| ... | ... | @@ -1,32 +1,63 @@ |
| 1 | 1 | import * as Clock from "effect/Clock"; |
| 2 | 2 | import * as Cache from "effect/Cache"; |
| 3 | import * as Context from "effect/Context"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; | |
| 4 | 6 | import * as Schema from "effect/Schema"; |
| 5 | 7 | import * as Semaphore from "effect/Semaphore"; |
| 6 | 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 | 11 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; |
| 10 | 12 | import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; |
| 11 | 13 | import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; |
| 12 | 14 | |
| 13 | const decodeHeadSchema = Schema.Struct({ | |
| 14 | head: Schema.Struct({ sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)) }), | |
| 15 | const HeadSchema = Schema.Struct({ | |
| 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 | 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 | 53 | export const makeChecksRevalidator = Effect.gen(function* () { |
| 23 | const github = yield* GitHubCli.GitHubCli; | |
| 54 | const api = yield* GitHubApi.GitHubApi; | |
| 24 | 55 | const entries = yield* Cache.makeWith( |
| 25 | 56 | (_key: string) => |
| 26 | 57 | Effect.sync(() => ({ |
| 27 | 58 | gate: Semaphore.makeUnsafe(1), |
| 28 | 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 | 61 | value: null as PullRequestChecks | null, |
| 31 | 62 | readAt: 0, |
| 32 | 63 | supported: true, |
| ... | ... | @@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () { |
| 43 | 74 | >, |
| 44 | 75 | ) => |
| 45 | 76 | Effect.gen(function* () { |
| 46 | const credential = yield* GitHubCli.PinnedGitHubCredential; | |
| 77 | const credential = yield* GitHubApi.PinnedGitHubCredential; | |
| 47 | 78 | if (credential === null) return yield* read; |
| 48 | 79 | const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; |
| 49 | 80 | const entry = yield* Cache.get(entries, key); |
| 50 | 81 | return yield* entry.gate.withPermit( |
| 51 | 82 | Effect.gen(function* () { |
| 52 | 83 | if (!entry.supported) return yield* read; |
| 53 | const fail = () => | |
| 54 | new GitHubCli.GitHubCliCommandError({ | |
| 55 | command: "gh", | |
| 56 | cwd: input.cwd, | |
| 57 | cause: new Error("GitHub returned an invalid conditional checks response."), | |
| 84 | const fail = (status: number) => | |
| 85 | new GitHubApi.GitHubApiResponseError({ | |
| 86 | host: input.host, | |
| 87 | operation: "revalidateChecks", | |
| 88 | status, | |
| 58 | 89 | }); |
| 90 | /** Whether the endpoint has a next page, or null where the host cannot say. */ | |
| 59 | 91 | const get = (endpoint: string, head = false) => |
| 60 | 92 | Effect.gen(function* () { |
| 61 | 93 | const previous = entry.validators.get(endpoint); |
| 62 | const result = yield* github | |
| 63 | .execute({ | |
| 64 | cwd: input.cwd, | |
| 65 | args: [ | |
| 66 | "api", | |
| 67 | endpoint, | |
| 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, | |
| 94 | const response = yield* api | |
| 95 | .rest({ | |
| 96 | host: input.host, | |
| 97 | operation: "revalidateChecks", | |
| 98 | path: endpoint, | |
| 99 | ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), | |
| 80 | 100 | }) |
| 81 | 101 | .pipe( |
| 82 | 102 | Effect.catchTags({ |
| 83 | GitHubCliCommandError: (error) => | |
| 103 | GitHubApiNotFoundError: () => | |
| 84 | 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 | 111 | return null; |
| 87 | 112 | }), |
| 88 | 113 | }), |
| 89 | 114 | ); |
| 90 | if (result === null) { | |
| 115 | if (response === null) { | |
| 91 | 116 | entry.value = null; |
| 92 | 117 | return null; |
| 93 | 118 | } |
| 94 | if (result.stdoutTruncated || result.stdoutInvalidUtf8) return yield* fail(); | |
| 95 | const split = result.stdout.search(/\r?\n\r?\n/); | |
| 96 | const headers = split < 0 ? result.stdout : result.stdout.slice(0, split); | |
| 97 | const status = /^HTTP\/\S+ (\d+)/.exec(headers)?.[1]; | |
| 98 | if (status === "304" && previous) return previous.next; | |
| 99 | if (status !== "200") return yield* fail(); | |
| 119 | if (response.status === 304 && previous) return previous.next; | |
| 120 | if (response.status !== 200 || response.truncated) { | |
| 121 | return yield* fail(response.status); | |
| 122 | } | |
| 100 | 123 | entry.value = null; |
| 101 | 124 | if (head) { |
| 102 | const decoded = yield* decodeHead(result.stdout.slice(split).trim()).pipe( | |
| 103 | Effect.mapError(fail), | |
| 125 | const decoded = yield* decodeHead(response.body).pipe( | |
| 126 | Effect.mapError(() => fail(response.status)), | |
| 104 | 127 | ); |
| 105 | 128 | if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); |
| 106 | 129 | entry.head = decoded; |
| 107 | 130 | } |
| 108 | 131 | const validator = { |
| 109 | etag: /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim(), | |
| 110 | next: /^link:.*rel="next"/im.test(headers), | |
| 132 | etag: response.headers["etag"]?.trim(), | |
| 133 | next: /rel="next"/.test(response.headers["link"] ?? ""), | |
| 134 | body: response.body, | |
| 111 | 135 | }; |
| 112 | 136 | entry.validators.set(endpoint, validator); |
| 113 | 137 | return validator.next; |
| ... | ... | @@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () { |
| 115 | 139 | const root = `repos/${input.repository}`; |
| 116 | 140 | if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; |
| 117 | 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 | 147 | const endpoints = [ |
| 120 | 148 | `${root}/commits/${head.head.sha}/check-runs?filter=all`, |
| 121 | 149 | `${root}/commits/${head.head.sha}/status`, |
| 122 | ...(head.headRepositoryId !== head.base.repo.id | |
| 123 | ? [`${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`] | |
| 124 | : []), | |
| 150 | ...(runsEndpoint === null ? [] : [runsEndpoint]), | |
| 125 | 151 | ]; |
| 152 | const runPages: Array<string> = []; | |
| 126 | 153 | for (const endpoint of endpoints) { |
| 127 | 154 | for (let page = 1; ; page++) { |
| 128 | if (page > 100) return yield* fail(); | |
| 129 | const next = yield* get( | |
| 130 | `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`, | |
| 131 | ); | |
| 155 | if (page > 100) return yield* fail(200); | |
| 156 | const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; | |
| 157 | const next = yield* get(paged); | |
| 132 | 158 | if (next === null) return yield* read; |
| 159 | if (endpoint === runsEndpoint) runPages.push(paged); | |
| 133 | 160 | if (!next) break; |
| 134 | 161 | } |
| 135 | 162 | } |
| 136 | 163 | const now = yield* Clock.currentTimeMillis; |
| 137 | 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 | 180 | const value = { |
| 140 | 181 | state: fresh.state, |
| 141 | 182 | checks: fresh.checks, |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+145-114| ... | ... | @@ -6,10 +6,10 @@ import { |
| 6 | 6 | buildPullRequestWatchFingerprintsGraphQlQuery, |
| 7 | 7 | decodePullRequestSummariesJson, |
| 8 | 8 | decodePullRequestWatchFingerprintsJson, |
| 9 | buildReviewSubmissionJson, | |
| 9 | buildReviewSubmission, | |
| 10 | 10 | buildPullRequestStackMembershipsGraphQlQuery, |
| 11 | 11 | decodePullRequestStackMembershipsJson, |
| 12 | buildReviewerRequestJson, | |
| 12 | buildReviewerRequest, | |
| 13 | 13 | buildSetFilesViewedGraphQlMutation, |
| 14 | 14 | decodeBaseComparisonJson, |
| 15 | 15 | decodePullRequestActivityJson, |
| ... | ... | @@ -25,26 +25,50 @@ import { |
| 25 | 25 | decodeReviewThreadCommentsJson, |
| 26 | 26 | decodeReviewThreadsJson, |
| 27 | 27 | decodeViewerPermissionsJson, |
| 28 | decodeWorkflowRunApprovalsJson, | |
| 28 | decodeWorkflowRunsJson, | |
| 29 | 29 | reviewThreadConversation, |
| 30 | 30 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 31 | 31 | pullRequestCoreGraphQlQuery, |
| 32 | 32 | pullRequestSearchGraphQlQuery, |
| 33 | 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 | 39 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { |
| 36 | return JSON.stringify( | |
| 37 | entries.map((entry) => ({ | |
| 38 | number: 1, | |
| 39 | title: "Add the pull requests page", | |
| 40 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 41 | headRefName: "feat/page", | |
| 42 | baseRefName: "main", | |
| 43 | createdAt: "2026-07-01T00:00:00Z", | |
| 44 | updatedAt: "2026-07-02T00:00:00Z", | |
| 45 | ...entry, | |
| 46 | })), | |
| 47 | ); | |
| 40 | return JSON.stringify({ | |
| 41 | data: { | |
| 42 | repository: { | |
| 43 | pullRequests: { | |
| 44 | pageInfo: { hasNextPage: false }, | |
| 45 | nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ | |
| 46 | number: 1, | |
| 47 | title: "Add the pull requests page", | |
| 48 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 49 | headRefName: "feat/page", | |
| 50 | baseRefName: "main", | |
| 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 | 74 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { |
| ... | ... | @@ -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 | 164 | const batch = expectSuccess( |
| 141 | 165 | decodePullRequestListJson( |
| 142 | 166 | listJson([ |
| 143 | // A failure outranks a run still going, and a completed run has to be read through its | |
| 144 | // conclusion rather than its status. | |
| 145 | { | |
| 146 | statusCheckRollup: [ | |
| 147 | { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 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: [] }, | |
| 167 | { checks: "FAILURE" }, | |
| 168 | { checks: "PENDING" }, | |
| 169 | { checks: "SUCCESS" }, | |
| 170 | { checks: "ERROR" }, | |
| 171 | { checks: "EXPECTED" }, | |
| 171 | 172 | {}, |
| 172 | 173 | ]), |
| 173 | 174 | ), |
| ... | ... | @@ -177,16 +178,15 @@ describe("pull request list decoding", () => { |
| 177 | 178 | "pending", |
| 178 | 179 | "passing", |
| 179 | 180 | "failing", |
| 180 | null, | |
| 181 | "failing", | |
| 182 | null, | |
| 181 | "pending", | |
| 183 | 182 | null, |
| 184 | 183 | ]); |
| 185 | 184 | }); |
| 186 | 185 | |
| 187 | 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"}]`; | |
| 189 | const batch = expectSuccess(decodePullRequestListJson(raw)); | |
| 187 | const batch = expectSuccess( | |
| 188 | decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), | |
| 189 | ); | |
| 190 | 190 | expect(batch.items).toHaveLength(1); |
| 191 | 191 | expect(batch.rawCount).toBe(2); |
| 192 | 192 | }); |
| ... | ... | @@ -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 | 319 | it("maps check-run status and commit-status state onto one vocabulary", () => { |
| 283 | 320 | const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); |
| 284 | 321 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ |
| ... | ... | @@ -333,13 +370,15 @@ describe("pull request detail decoding", () => { |
| 333 | 370 | it("decodes workflow runs that can be approved", () => { |
| 334 | 371 | expect( |
| 335 | 372 | expectSuccess( |
| 336 | decodeWorkflowRunApprovalsJson( | |
| 337 | JSON.stringify([ | |
| 338 | { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" }, | |
| 339 | { databaseId: 11, workflowName: null, url: null }, | |
| 340 | ]), | |
| 373 | decodeWorkflowRunsJson( | |
| 374 | JSON.stringify({ | |
| 375 | workflow_runs: [ | |
| 376 | { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, | |
| 377 | { id: 11, name: null, html_url: null }, | |
| 378 | ], | |
| 379 | }), | |
| 341 | 380 | ), |
| 342 | ), | |
| 381 | ).runs, | |
| 343 | 382 | ).toEqual([ |
| 344 | 383 | { id: 10, name: "contributor tests", url: "https://example.com/10" }, |
| 345 | 384 | { id: 11, name: "Workflow run 11", url: null }, |
| ... | ... | @@ -398,14 +437,14 @@ describe("pull request detail decoding", () => { |
| 398 | 437 | }); |
| 399 | 438 | |
| 400 | 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 | 441 | // r2 approved without writing anything, which is still the event worth seeing. |
| 403 | 442 | expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); |
| 404 | 443 | expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); |
| 405 | 444 | }); |
| 406 | 445 | |
| 407 | 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 | 448 | expect(detail.commits[0]?.authors).toEqual([ |
| 410 | 449 | { login: "octocat", name: "Octo Cat", avatarUrl: null }, |
| 411 | 450 | { login: "Pair Author", name: "Pair Author", avatarUrl: null }, |
| ... | ... | @@ -415,22 +454,20 @@ describe("pull request detail decoding", () => { |
| 415 | 454 | it("drops the bodyless review GitHub opens to hold line comments", () => { |
| 416 | 455 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 417 | 456 | const detail = expectSuccess( |
| 418 | decodePullRequestActivityJson( | |
| 419 | JSON.stringify({ | |
| 420 | ...raw, | |
| 421 | reviews: [ | |
| 422 | // What a reviewer leaving inline comments produces: a container with a state but | |
| 423 | // nothing to read. Its comments come from the review threads instead. | |
| 424 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, | |
| 425 | { | |
| 426 | id: "r5", | |
| 427 | body: "Looks good.", | |
| 428 | state: "COMMENTED", | |
| 429 | submittedAt: "2026-07-08T00:00:00Z", | |
| 430 | }, | |
| 431 | ], | |
| 432 | }), | |
| 433 | ), | |
| 457 | activity({ | |
| 458 | ...raw, | |
| 459 | reviews: [ | |
| 460 | // 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. | |
| 462 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, | |
| 463 | { | |
| 464 | id: "r5", | |
| 465 | body: "Looks good.", | |
| 466 | state: "COMMENTED", | |
| 467 | submittedAt: "2026-07-08T00:00:00Z", | |
| 468 | }, | |
| 469 | ], | |
| 470 | }), | |
| 434 | 471 | ); |
| 435 | 472 | |
| 436 | 473 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); |
| ... | ... | @@ -441,12 +478,10 @@ describe("pull request detail decoding", () => { |
| 441 | 478 | (state) => { |
| 442 | 479 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 443 | 480 | const detail = expectSuccess( |
| 444 | decodePullRequestActivityJson( | |
| 445 | JSON.stringify({ | |
| 446 | ...raw, | |
| 447 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], | |
| 448 | }), | |
| 449 | ), | |
| 481 | activity({ | |
| 482 | ...raw, | |
| 483 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], | |
| 484 | }), | |
| 450 | 485 | ); |
| 451 | 486 | |
| 452 | 487 | expect(detail.comments.map((comment) => comment.id)).toContain("r6"); |
| ... | ... | @@ -456,12 +491,10 @@ describe("pull request detail decoding", () => { |
| 456 | 491 | it("drops a review that carries neither a body nor a state", () => { |
| 457 | 492 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 458 | 493 | const detail = expectSuccess( |
| 459 | decodePullRequestActivityJson( | |
| 460 | JSON.stringify({ | |
| 461 | ...raw, | |
| 462 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], | |
| 463 | }), | |
| 464 | ), | |
| 494 | activity({ | |
| 495 | ...raw, | |
| 496 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], | |
| 497 | }), | |
| 465 | 498 | ); |
| 466 | 499 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); |
| 467 | 500 | }); |
| ... | ... | @@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => { |
| 1432 | 1465 | describe("reviewer request payload", () => { |
| 1433 | 1466 | it("sends people and teams in the two lists GitHub keeps them in", () => { |
| 1434 | 1467 | expect( |
| 1435 | JSON.parse( | |
| 1436 | buildReviewerRequestJson([ | |
| 1437 | { id: "octocat", kind: "user" }, | |
| 1438 | { id: "reviewers", kind: "team" }, | |
| 1439 | { id: "hubot", kind: "user" }, | |
| 1440 | ]), | |
| 1441 | ), | |
| 1468 | buildReviewerRequest([ | |
| 1469 | { id: "octocat", kind: "user" }, | |
| 1470 | { id: "reviewers", kind: "team" }, | |
| 1471 | { id: "hubot", kind: "user" }, | |
| 1472 | ]), | |
| 1442 | 1473 | ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); |
| 1443 | 1474 | }); |
| 1444 | 1475 | |
| 1445 | 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 | 1478 | reviewers: ["octocat"], |
| 1448 | 1479 | team_reviewers: [], |
| 1449 | 1480 | }); |
| ... | ... | @@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => { |
| 1452 | 1483 | |
| 1453 | 1484 | describe("review submission payload", () => { |
| 1454 | 1485 | it("sends the verdict, the summary and every line comment in one body", () => { |
| 1455 | const payload = JSON.parse( | |
| 1456 | buildReviewSubmissionJson({ | |
| 1457 | verdict: "request-changes", | |
| 1458 | body: "Two things.", | |
| 1459 | comments: [ | |
| 1460 | { | |
| 1461 | path: "src/a.ts", | |
| 1462 | position: { kind: "added", newLine: 12 }, | |
| 1463 | body: "rename this", | |
| 1464 | }, | |
| 1465 | { | |
| 1466 | path: "src/b.ts", | |
| 1467 | position: { kind: "deleted", oldLine: 3 }, | |
| 1468 | body: "why remove?", | |
| 1469 | }, | |
| 1470 | ], | |
| 1471 | }), | |
| 1472 | ) as Record<string, unknown>; | |
| 1486 | const payload = buildReviewSubmission({ | |
| 1487 | verdict: "request-changes", | |
| 1488 | body: "Two things.", | |
| 1489 | comments: [ | |
| 1490 | { | |
| 1491 | path: "src/a.ts", | |
| 1492 | position: { kind: "added", newLine: 12 }, | |
| 1493 | body: "rename this", | |
| 1494 | }, | |
| 1495 | { | |
| 1496 | path: "src/b.ts", | |
| 1497 | position: { kind: "deleted", oldLine: 3 }, | |
| 1498 | body: "why remove?", | |
| 1499 | }, | |
| 1500 | ], | |
| 1501 | }); | |
| 1473 | 1502 | expect(payload).toEqual({ |
| 1474 | 1503 | event: "REQUEST_CHANGES", |
| 1475 | 1504 | body: "Two things.", |
| ... | ... | @@ -1481,9 +1510,11 @@ describe("review submission payload", () => { |
| 1481 | 1510 | }); |
| 1482 | 1511 | |
| 1483 | 1512 | it("sends an approval with no words and no comments", () => { |
| 1484 | expect( | |
| 1485 | JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })), | |
| 1486 | ).toEqual({ event: "APPROVE", body: "", comments: [] }); | |
| 1513 | expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ | |
| 1514 | event: "APPROVE", | |
| 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 | 155 | latestReviews: Schema.optional( |
| 156 | 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 | 161 | createdAt: Schema.String, |
| 159 | 162 | updatedAt: Schema.String, |
| 160 | 163 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| ... | ... | @@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({ |
| 167 | 170 | Schema.Array( |
| 168 | 171 | Schema.NullOr( |
| 169 | 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 | 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 | 242 | const RawSearchSchema = Schema.Struct({ |
| 243 | data: Schema.Struct({ search: RawRowConnectionSchema }), | |
| 244 | }); | |
| 245 | ||
| 246 | const RawRepositoryPullRequestsSchema = Schema.Struct({ | |
| 219 | 247 | data: Schema.Struct({ |
| 220 | search: Schema.Struct({ | |
| 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 | }), | |
| 248 | repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), | |
| 226 | 249 | }), |
| 227 | 250 | }); |
| 228 | 251 | |
| ... | ... | @@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({ |
| 414 | 437 | ), |
| 415 | 438 | }); |
| 416 | 439 | |
| 417 | const RawWorkflowRunApprovalSchema = Schema.Struct({ | |
| 418 | databaseId: Schema.Int, | |
| 419 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 420 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 440 | /** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ | |
| 441 | const RawWorkflowRunsSchema = Schema.Struct({ | |
| 442 | workflow_runs: Schema.Array( | |
| 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({ | |
| 424 | number: Schema.Int, | |
| 425 | headRefOid: Schema.String, | |
| 426 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 427 | headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), | |
| 451 | const RawPullRequestHeadsSchema = Schema.Struct({ | |
| 452 | data: Schema.Struct({ | |
| 453 | repository: Schema.NullOr( | |
| 454 | Schema.Struct({ | |
| 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 | 483 | const RawActivitySchema = Schema.Struct({ |
| 431 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 432 | comments: Schema.optional(Schema.Array(RawCommentSchema)), | |
| 433 | reviews: Schema.optional(Schema.Array(RawReviewSchema)), | |
| 434 | commits: Schema.optional(Schema.Array(RawCommitSchema)), | |
| 484 | data: Schema.Struct({ | |
| 485 | repository: Schema.Struct({ | |
| 486 | pullRequest: Schema.Struct({ | |
| 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 | 529 | /** Where a connection carries on from, which is what every paged read below follows. */ |
| 438 | const RawPageInfoSchema = Schema.Struct({ | |
| 439 | hasNextPage: Schema.optional(Schema.Boolean), | |
| 440 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 441 | }); | |
| 530 | function RawPageInfoSchemaFields() { | |
| 531 | return Schema.Struct({ | |
| 532 | hasNextPage: Schema.optional(Schema.Boolean), | |
| 533 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 534 | }); | |
| 535 | } | |
| 536 | const RawPageInfoSchema = RawPageInfoSchemaFields(); | |
| 442 | 537 | |
| 443 | 538 | /** |
| 444 | 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 | 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 | 806 | * Pull refs let the comparison share the detail read without first resolving a fork branch. |
| 717 | 807 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and |
| 718 | 808 | * an unknown field fails the whole read. |
| 719 | 809 | */ |
| 720 | export const pullRequestCoreGraphQlQuery = (host: string) => { | |
| 810 | function checkContextNodesSelection(host: string): string { | |
| 721 | 811 | const required = |
| 722 | 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 | 824 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { |
| 724 | 825 | repository(owner: $owner, name: $name) { |
| 725 | 826 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission |
| ... | ... | @@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => { |
| 738 | 839 | labels(first: 100) { nodes { name color } } |
| 739 | 840 | commits(last: 1) { |
| 740 | 841 | nodes { commit { statusCheckRollup { contexts(first: 100) { |
| 741 | nodes { | |
| 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 | } | |
| 842 | ${checkContextNodesSelection(host)} | |
| 749 | 843 | pageInfo { hasNextPage } |
| 750 | 844 | } } } } |
| 751 | 845 | } |
| ... | ... | @@ -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 | 912 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ |
| 797 | 913 | const GRAPHQL_PAGE_SIZE = 100; |
| ... | ... | @@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; |
| 818 | 934 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review |
| 819 | 935 | * is already past what a row can say. |
| 820 | 936 | */ |
| 821 | export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { | |
| 822 | return `query($q: String!) { | |
| 823 | search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { | |
| 824 | pageInfo { hasNextPage } | |
| 937 | export function pullRequestSearchGraphQlQuery( | |
| 938 | rows: number, | |
| 939 | includeStacks = false, | |
| 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 | 945 | nodes { |
| 826 | 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 | 976 | number |
| 829 | 977 | title |
| 830 | 978 | url |
| ... | ... | @@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals |
| 836 | 984 | mergeable |
| 837 | 985 | reviewDecision |
| 838 | 986 | latestReviews(first: 20) { nodes { state author { login } } } |
| 987 | ${includeStats ? "additions deletions" : ""} | |
| 839 | 988 | createdAt |
| 840 | 989 | updatedAt |
| 841 | 990 | mergedAt |
| 842 | 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 | 993 | labels(first: 20) { nodes { name color } } |
| 845 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } | |
| 846 | } | |
| 847 | } | |
| 848 | } | |
| 849 | }`; | |
| 994 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; | |
| 850 | 995 | } |
| 851 | 996 | |
| 852 | 997 | /** |
| ... | ... | @@ -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 | 1242 | /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ |
| 1116 | 1243 | const ReviewSubmissionSchema = Schema.Struct({ |
| 1117 | 1244 | event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), |
| ... | ... | @@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({ |
| 1126 | 1253 | ), |
| 1127 | 1254 | }); |
| 1128 | 1255 | |
| 1129 | const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); | |
| 1130 | ||
| 1131 | 1256 | const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = { |
| 1132 | 1257 | comment: "COMMENT", |
| 1133 | 1258 | approve: "APPROVE", |
| ... | ... | @@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): { |
| 1166 | 1291 | } |
| 1167 | 1292 | |
| 1168 | 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 | 1295 | readonly verdict: PullRequestReviewVerdict; |
| 1171 | 1296 | readonly body: string; |
| 1172 | 1297 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; |
| 1173 | }): string { | |
| 1174 | return encodeReviewSubmission({ | |
| 1298 | }): typeof ReviewSubmissionSchema.Type { | |
| 1299 | return { | |
| 1175 | 1300 | event: REVIEW_EVENTS[input.verdict], |
| 1176 | 1301 | body: input.body, |
| 1177 | 1302 | comments: input.comments.map((comment) => ({ |
| ... | ... | @@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: { |
| 1179 | 1304 | ...gitHubReviewPosition(comment.position), |
| 1180 | 1305 | body: comment.body, |
| 1181 | 1306 | })), |
| 1182 | }); | |
| 1307 | }; | |
| 1183 | 1308 | } |
| 1184 | 1309 | |
| 1185 | 1310 | export interface GitHubPullRequestListItem { |
| ... | ... | @@ -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 | 1772 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); |
| 1656 | const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); | |
| 1657 | 1773 | const decodeSearch = decodeJsonResult(RawSearchSchema); |
| 1658 | 1774 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); |
| 1659 | 1775 | const decodeStats = decodeJsonResult(RawStatsSchema); |
| 1660 | 1776 | const decodeDetail = decodeJsonResult(RawDetailSchema); |
| 1661 | const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema)); | |
| 1662 | const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); | |
| 1777 | const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); | |
| 1778 | const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); | |
| 1663 | 1779 | const decodeActivity = decodeJsonResult(RawActivitySchema); |
| 1780 | const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); | |
| 1664 | 1781 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); |
| 1665 | 1782 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); |
| 1666 | 1783 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); |
| ... | ... | @@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>; |
| 1669 | 1786 | |
| 1670 | 1787 | export interface GitHubPullRequestListBatch { |
| 1671 | 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 | 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 | |
| 1677 | * must not blank the whole list. */ | |
| 1795 | /** | |
| 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 | 1800 | export function decodePullRequestListJson( |
| 1679 | 1801 | raw: string, |
| 1680 | 1802 | ): Result.Result<GitHubPullRequestListBatch, DecodeFailure> { |
| 1681 | const decoded = decodeUnknownList(raw); | |
| 1682 | if (!Result.isSuccess(decoded)) { | |
| 1683 | return Result.fail(decoded.failure); | |
| 1684 | } | |
| 1803 | const decoded = decodeRepositoryPullRequests(raw); | |
| 1804 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1805 | const connection = decoded.success.data.repository?.pullRequests; | |
| 1806 | const nodes = connection?.nodes ?? []; | |
| 1685 | 1807 | const items: GitHubPullRequestListItem[] = []; |
| 1686 | for (const entry of decoded.success) { | |
| 1687 | const item = decodeListEntry(entry); | |
| 1688 | if (Exit.isSuccess(item)) { | |
| 1689 | items.push(toListItem(item.value)); | |
| 1690 | } | |
| 1808 | for (const entry of nodes) { | |
| 1809 | const row = toRow(entry); | |
| 1810 | if (row !== null) items.push(row.item); | |
| 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 | 1863 | export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { |
| ... | ... | @@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch { |
| 1703 | 1871 | readonly rawCount: number; |
| 1704 | 1872 | /** More rows than this slice asked for, which is truncation for every repository in it. */ |
| 1705 | 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 | |
| 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. | |
| 1879 | * A search answers with the same row a repository's list does, each naming its repository. | |
| 1712 | 1880 | * |
| 1713 | 1881 | * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing |
| 1714 | 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 | 1888 | if (!Result.isSuccess(decoded)) { |
| 1721 | 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 | 1893 | const items: GitHubPullRequestSearchItem[] = []; |
| 1725 | 1894 | for (const entry of nodes) { |
| 1726 | const decodedNode = decodeSearchItem(entry); | |
| 1727 | if (!Exit.isSuccess(decodedNode)) continue; | |
| 1728 | const node = decodedNode.value; | |
| 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 | }); | |
| 1895 | const row = toRow(entry); | |
| 1896 | if (row === null || row.repository === null) continue; | |
| 1897 | items.push({ ...row.item, repository: row.repository }); | |
| 1753 | 1898 | } |
| 1754 | 1899 | return Result.succeed({ |
| 1755 | 1900 | items, |
| 1756 | 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 | 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 | 2056 | const RawSummarySchema = Schema.Struct({ |
| 1904 | 2057 | ...RawSearchItemSchema.fields, |
| 1905 | 2058 | changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), |
| ... | ... | @@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson( |
| 2147 | 2300 | requestedReviewer === null ? [] : [requestedReviewer], |
| 2148 | 2301 | ), |
| 2149 | 2302 | labels: pr.labels.nodes, |
| 2150 | statusCheckRollup: | |
| 2151 | contexts?.nodes.map((check) => ({ | |
| 2152 | ...check, | |
| 2153 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | |
| 2154 | })) ?? [], | |
| 2303 | statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), | |
| 2155 | 2304 | }), |
| 2156 | 2305 | viewerAccess: { |
| 2157 | 2306 | canWrite: toCanWrite(repository.viewerPermission), |
| ... | ... | @@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson( |
| 2183 | 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 | 2343 | raw: string, |
| 2188 | ): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> { | |
| 2189 | const decoded = decodeWorkflowRunApprovals(raw); | |
| 2344 | ): Result.Result<GitHubWorkflowRunPage, DecodeFailure> { | |
| 2345 | const decoded = decodeWorkflowRuns(raw); | |
| 2190 | 2346 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 2191 | return Result.succeed( | |
| 2192 | decoded.success.map((run) => ({ | |
| 2193 | id: run.databaseId, | |
| 2194 | name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`, | |
| 2195 | url: trimmed(run.url), | |
| 2347 | return Result.succeed({ | |
| 2348 | runs: decoded.success.workflow_runs.map((run) => ({ | |
| 2349 | id: run.id, | |
| 2350 | name: trimmed(run.name) ?? `Workflow run ${run.id}`, | |
| 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( | |
| 2201 | raw: string, | |
| 2202 | ): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> { | |
| 2357 | /** Open pull requests whose head branch has one name, from whichever repository it lives in. */ | |
| 2358 | export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { | |
| 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 | 2374 | const decoded = decodePullRequestHeads(raw); |
| 2204 | 2375 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 2205 | return Result.succeed( | |
| 2206 | decoded.success.map((pullRequest) => ({ | |
| 2376 | const connection = decoded.success.data.repository?.pullRequests; | |
| 2377 | return Result.succeed({ | |
| 2378 | heads: (connection?.nodes ?? []).map((pullRequest) => ({ | |
| 2207 | 2379 | number: pullRequest.number, |
| 2208 | 2380 | headSha: pullRequest.headRefOid, |
| 2209 | 2381 | ...(typeof pullRequest.isCrossRepository === "boolean" |
| ... | ... | @@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson( |
| 2211 | 2383 | : {}), |
| 2212 | 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 | 2401 | export function decodePullRequestActivityJson( |
| 2218 | 2402 | raw: string, |
| 2219 | ): Result.Result<GitHubPullRequestActivity, DecodeFailure> { | |
| 2403 | ): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> { | |
| 2220 | 2404 | const decoded = decodeActivity(raw); |
| 2221 | return Result.isSuccess(decoded) | |
| 2222 | ? Result.succeed(toActivity(decoded.success)) | |
| 2223 | : Result.fail(decoded.failure); | |
| 2405 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2406 | const pr = decoded.success.data.repository.pullRequest; | |
| 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 | 2527 | export interface GitHubReviewThreadComments { |
| ... | ... | @@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({ |
| 2778 | 3079 | team_reviewers: Schema.Array(Schema.String), |
| 2779 | 3080 | }); |
| 2780 | 3081 | |
| 2781 | const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema)); | |
| 2782 | ||
| 2783 | export function buildReviewerRequestJson( | |
| 3082 | export function buildReviewerRequest( | |
| 2784 | 3083 | reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, |
| 2785 | ): string { | |
| 2786 | return encodeReviewerRequest({ | |
| 3084 | ): typeof ReviewerRequestSchema.Type { | |
| 3085 | return { | |
| 2787 | 3086 | reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), |
| 2788 | 3087 | team_reviewers: reviewers.flatMap((reviewer) => |
| 2789 | 3088 | reviewer.kind === "team" ? [reviewer.id] : [], |
| 2790 | 3089 | ), |
| 2791 | }); | |
| 3090 | }; | |
| 2792 | 3091 | } |
| 2793 | 3092 | |
| 2794 | 3093 | export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { |
| ... | ... | @@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson( |
| 2876 | 3175 | } |
| 2877 | 3176 | |
| 2878 | 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) }); | |
| 2880 | ||
| 2881 | const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); | |
| 2882 | ||
| 2883 | export function buildLabelRequestJson(labels: ReadonlyArray<string>): string { | |
| 2884 | return encodeLabelRequest({ labels }); | |
| 3178 | export function buildLabelRequest(labels: ReadonlyArray<string>): { | |
| 3179 | readonly labels: ReadonlyArray<string>; | |
| 3180 | } { | |
| 3181 | return { labels }; | |
| 2885 | 3182 | } |
| 2886 | 3183 | |
| 2887 | 3184 | /** |
| ... | ... | @@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch { |
| 2965 | 3262 | export function decodePullRequestFilesJson( |
| 2966 | 3263 | raw: string, |
| 2967 | 3264 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { |
| 2968 | const decoded = decodeUnknownList(raw); | |
| 2969 | if (!Result.isSuccess(decoded)) { | |
| 2970 | return Result.fail(decoded.failure); | |
| 2971 | } | |
| 3265 | return Result.map(decodeUnknownList(raw), toFilesPatch); | |
| 3266 | } | |
| 3267 | ||
| 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 | 3283 | const sections: string[] = []; |
| 2973 | 3284 | const omittedFileStats: PullRequestOmittedFileStat[] = []; |
| 2974 | 3285 | let truncated = false; |
| 2975 | for (const entry of decoded.success) { | |
| 3286 | for (const entry of entries) { | |
| 2976 | 3287 | const file = decodeFileEntry(entry); |
| 2977 | 3288 | if (Exit.isFailure(file)) continue; |
| 2978 | 3289 | const value = file.value; |
| ... | ... | @@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson( |
| 3009 | 3320 | ].join("\n"); |
| 3010 | 3321 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); |
| 3011 | 3322 | } |
| 3012 | return Result.succeed({ | |
| 3323 | return { | |
| 3013 | 3324 | patch: sections.join(""), |
| 3014 | 3325 | truncated, |
| 3015 | rawCount: decoded.success.length, | |
| 3326 | rawCount: entries.length, | |
| 3016 | 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 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; | |
| 5 | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; | |
| 7 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 8 | 8 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; |
| 9 | 9 | |
| 10 | const runGitHubStackAction = ( | |
| 11 | execute: GitHubCli.GitHubCli["Service"]["execute"], | |
| 12 | input: Parameters<typeof runStackAction>[0], | |
| 13 | ) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); | |
| 10 | /** | |
| 11 | * One request as the fake saw it, flattened to words: the path or document, then each variable | |
| 12 | * or body field as `name=value`, so an assertion reads as "this request carried that value". | |
| 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 | 62 | const stack = [ |
| 16 | 63 | { |
| ... | ... | @@ -71,21 +118,13 @@ const rebased = { |
| 71 | 118 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; |
| 72 | 119 | |
| 73 | 120 | function fake(responses: readonly unknown[]) { |
| 74 | const calls: ReadonlyArray<string>[] = []; | |
| 75 | const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => | |
| 121 | const calls: Call[] = []; | |
| 122 | const execute: Send = (call) => | |
| 76 | 123 | Effect.sync(() => { |
| 77 | calls.push(request.args); | |
| 124 | calls.push(call); | |
| 78 | 125 | const value = responses[calls.length - 1]; |
| 79 | 126 | if (value === undefined) throw new Error("Unexpected GitHub request"); |
| 80 | return { | |
| 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 | }; | |
| 127 | return encodeJson(value); | |
| 89 | 128 | }); |
| 90 | 129 | return { execute, calls }; |
| 91 | 130 | } |
| ... | ... | @@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", () |
| 215 | 254 | Effect.gen(function* () { |
| 216 | 255 | const api = fake([stack, access, ...rebaseResponses]); |
| 217 | 256 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); |
| 218 | const mutations = api.calls.filter((args) => | |
| 219 | args.some((arg) => arg.startsWith("query=mutation")), | |
| 220 | ); | |
| 257 | const mutations = api.calls.filter(isMutation); | |
| 221 | 258 | expect(mutations).toHaveLength(2); |
| 222 | 259 | expect(mutations[0]).toContain("id=PR_2"); |
| 223 | 260 | expect(mutations[0]).toContain("sha=bbb"); |
| 224 | 261 | expect(mutations[1]).toContain("id=PR_3"); |
| 225 | 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 | 266 | it.effect("does not update later layers after a rebase failure", () => |
| 231 | 267 | Effect.gen(function* () { |
| 232 | 268 | const api = fake([stack, access, branch(2, "bbb")]); |
| 233 | const execute: typeof api.execute = (request) => | |
| 234 | !request.args.some((arg) => arg.startsWith("query=mutation")) | |
| 235 | ? api.execute(request) | |
| 269 | const execute: Send = (call, kind) => | |
| 270 | !isMutation(call) | |
| 271 | ? api.execute(call, kind) | |
| 236 | 272 | : Effect.fail( |
| 237 | new GitHubCli.GitHubCliAuthenticationError({ | |
| 238 | command: "gh", | |
| 239 | cwd: "/repo", | |
| 240 | cause: new Error("denied"), | |
| 273 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 274 | host: "github.com", | |
| 275 | operation: "runGitHubStackAction", | |
| 241 | 276 | }), |
| 242 | 277 | ); |
| 243 | 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 | 307 | failure: { _tag: "GitHubStackPermissionError" }, |
| 273 | 308 | }); |
| 274 | 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 | 367 | Effect.gen(function* () { |
| 334 | 368 | const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); |
| 335 | 369 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); |
| 336 | expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( | |
| 337 | false, | |
| 338 | ); | |
| 370 | expect(api.calls.some((args) => isMutation(args))).toBe(false); | |
| 339 | 371 | }), |
| 340 | 372 | ); |
| 341 | 373 | |
| ... | ... | @@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase" |
| 380 | 412 | if (result._tag === "Failure") { |
| 381 | 413 | expect(result.failure.message).toContain("Earlier updates remain on GitHub"); |
| 382 | 414 | } |
| 383 | expect( | |
| 384 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | |
| 385 | ).toHaveLength(1); | |
| 415 | expect(api.calls.filter((args) => isMutation(args))).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 | 433 | _tag: "Failure", |
| 404 | 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( | |
| 407 | true, | |
| 408 | ); | |
| 409 | expect( | |
| 410 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | |
| 411 | ).toHaveLength(rebasedParent ? 1 : 0); | |
| 436 | expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true); | |
| 437 | expect(api.calls.filter((args) => isMutation(args))).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 | 8 | import * as Result from "effect/Result"; |
| 9 | 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 | 12 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; |
| 13 | 13 | |
| 14 | 14 | const stackErrorIdentity = { |
| ... | ... | @@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang |
| 21 | 21 | "GitHubStackChangedError", |
| 22 | 22 | { ...stackErrorIdentity, completed: Schema.Int }, |
| 23 | 23 | ) { |
| 24 | get detail(): string { | |
| 25 | return this.message; | |
| 26 | } | |
| 27 | ||
| 28 | 24 | override get message(): string { |
| 29 | 25 | return this.completed > 0 |
| 30 | 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 | 32 | "GitHubStackUnsupportedError", |
| 37 | 33 | stackErrorIdentity, |
| 38 | 34 | ) { |
| 39 | get detail(): string { | |
| 40 | return this.message; | |
| 41 | } | |
| 42 | ||
| 43 | 35 | override get message(): string { |
| 44 | 36 | return "This operation is not supported for this stack."; |
| 45 | 37 | } |
| ... | ... | @@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt |
| 49 | 41 | "GitHubStackResponseInvalidError", |
| 50 | 42 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, |
| 51 | 43 | ) { |
| 52 | get detail(): string { | |
| 53 | return this.message; | |
| 54 | } | |
| 55 | ||
| 56 | 44 | override get message(): string { |
| 57 | 45 | return "GitHub returned an unreadable stack operation response."; |
| 58 | 46 | } |
| ... | ... | @@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac |
| 62 | 50 | "GitHubStackMergeRejectedError", |
| 63 | 51 | { ...stackErrorIdentity, cause: Schema.Defect() }, |
| 64 | 52 | ) { |
| 65 | get detail(): string { | |
| 66 | return this.message; | |
| 67 | } | |
| 68 | ||
| 69 | 53 | override get message(): string { |
| 70 | 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 | 59 | "GitHubStackMergePendingError", |
| 76 | 60 | stackErrorIdentity, |
| 77 | 61 | ) { |
| 78 | get detail(): string { | |
| 79 | return this.message; | |
| 80 | } | |
| 81 | ||
| 82 | 62 | override get message(): string { |
| 83 | 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 | 68 | "GitHubStackPermissionError", |
| 89 | 69 | stackErrorIdentity, |
| 90 | 70 | ) { |
| 91 | get detail(): string { | |
| 92 | return this.message; | |
| 93 | } | |
| 94 | ||
| 95 | 71 | override get message(): string { |
| 96 | 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 | 77 | "GitHubStackRebaseFailedError", |
| 102 | 78 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, |
| 103 | 79 | ) { |
| 104 | get detail(): string { | |
| 105 | return this.message; | |
| 106 | } | |
| 107 | ||
| 108 | 80 | override get message(): string { |
| 109 | 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 | 151 | ), |
| 180 | 152 | ); |
| 181 | 153 | |
| 182 | const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); | |
| 183 | ||
| 184 | 154 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); |
| 185 | 155 | |
| 186 | 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 | 164 | action: PullRequestAction; |
| 195 | 165 | mergeMethod?: PullRequestMergeMethod; |
| 196 | 166 | }) { |
| 197 | const github = yield* GitHubCli.GitHubCli; | |
| 167 | const api = yield* GitHubApi.GitHubApi; | |
| 198 | 168 | const identity = { |
| 199 | 169 | repository: input.repository, |
| 200 | 170 | number: input.number, |
| ... | ... | @@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 203 | 173 | if (input.action !== "merge" && input.action !== "update-branch") |
| 204 | 174 | return yield* new GitHubStackUnsupportedError({ ...identity }); |
| 205 | 175 | const endpoint = `repos/${input.repository}`; |
| 206 | const read = yield* github.execute({ | |
| 207 | cwd: input.cwd, | |
| 208 | args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], | |
| 176 | const read = yield* api.rest({ | |
| 177 | host: input.host, | |
| 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 | 182 | if (Result.isFailure(decoded)) |
| 212 | 183 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); |
| 213 | 184 | const stack = decoded.success; |
| ... | ... | @@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 243 | 214 | return yield* new GitHubStackUnsupportedError({ ...identity }); |
| 244 | 215 | if (input.action === "update-branch") { |
| 245 | 216 | const [owner, name] = input.repository.split("/"); |
| 246 | const permissions = yield* github.execute({ | |
| 247 | cwd: input.cwd, | |
| 248 | args: [ | |
| 249 | "api", | |
| 250 | "--hostname", | |
| 251 | input.host, | |
| 252 | "graphql", | |
| 253 | "-f", | |
| 254 | `owner=${owner}`, | |
| 255 | "-f", | |
| 256 | `name=${name}`, | |
| 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 | ], | |
| 217 | const permissions = yield* api.graphql({ | |
| 218 | host: input.host, | |
| 219 | operation: "runGitHubStackAction", | |
| 220 | allowReserve: true, | |
| 221 | variables: { owner, name }, | |
| 222 | query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open | |
| 223 | .map( | |
| 224 | (layer) => | |
| 225 | `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, | |
| 226 | ) | |
| 227 | .join(" ")}}}`, | |
| 265 | 228 | }); |
| 266 | const access = yield* decodeBranchAccess(permissions.stdout).pipe( | |
| 229 | const access = yield* decodeBranchAccess(permissions).pipe( | |
| 267 | 230 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), |
| 268 | 231 | ); |
| 269 | 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 | 245 | const processed: Array<{ id: string; number: number; headSha: string }> = []; |
| 283 | 246 | for (const [index, layer] of open.entries()) { |
| 284 | 247 | yield* Effect.gen(function* () { |
| 285 | const read = yield* github.execute({ | |
| 286 | cwd: input.cwd, | |
| 287 | args: [ | |
| 288 | "api", | |
| 289 | "--hostname", | |
| 290 | input.host, | |
| 291 | "graphql", | |
| 292 | "-f", | |
| 293 | `owner=${owner}`, | |
| 294 | "-f", | |
| 295 | `name=${name}`, | |
| 296 | "-F", | |
| 297 | `number=${layer.number}`, | |
| 298 | "-f", | |
| 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 | ], | |
| 248 | const read = yield* api.graphql({ | |
| 249 | host: input.host, | |
| 250 | operation: "runGitHubStackAction", | |
| 251 | allowReserve: true, | |
| 252 | variables: { | |
| 253 | owner, | |
| 254 | name, | |
| 255 | number: layer.number, | |
| 256 | sha: layer.headSha, | |
| 257 | ids: processed.map((head) => head.id), | |
| 258 | }, | |
| 259 | // GitHub rejects a declared variable the document never uses, so `$ids` is always | |
| 260 | // selected; an empty list asks for nothing. | |
| 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}}}}}`, | |
| 307 | 262 | }); |
| 308 | 263 | const { |
| 309 | 264 | data: { |
| 310 | 265 | processed: observed, |
| 311 | 266 | repository: { pullRequest: pr }, |
| 312 | 267 | }, |
| 313 | } = yield* decodeRebaseBranch(read.stdout); | |
| 268 | } = yield* decodeRebaseBranch(read); | |
| 314 | 269 | // A push to an earlier layer must not silently become the next layer's new base. |
| 315 | 270 | const changed = processed.find( |
| 316 | 271 | (head, index) => observed?.[index]?.headRefOid !== head.headSha, |
| ... | ... | @@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 332 | 287 | return; |
| 333 | 288 | } |
| 334 | 289 | // Pass the reviewed revision to GitHub, including when a push races this read. |
| 335 | const updated = yield* github.execute({ | |
| 336 | cwd: input.cwd, | |
| 337 | args: [ | |
| 338 | "api", | |
| 339 | "--hostname", | |
| 340 | input.host, | |
| 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 | ], | |
| 290 | const updated = yield* api.graphql({ | |
| 291 | host: input.host, | |
| 292 | operation: "runGitHubStackAction", | |
| 293 | variables: { id: pr.id, sha: layer.headSha }, | |
| 294 | query: | |
| 295 | "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", | |
| 349 | 296 | }); |
| 350 | const response = yield* decodeRebaseResponse(updated.stdout); | |
| 297 | const response = yield* decodeRebaseResponse(updated); | |
| 351 | 298 | processed.push({ |
| 352 | 299 | id: pr.id, |
| 353 | 300 | number: layer.number, |
| ... | ... | @@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 374 | 321 | decodeMergeResponse(raw).pipe( |
| 375 | 322 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), |
| 376 | 323 | ); |
| 377 | const request = yield* github.execute({ | |
| 378 | cwd: input.cwd, | |
| 379 | args: [ | |
| 380 | "api", | |
| 381 | "--hostname", | |
| 382 | input.host, | |
| 383 | "--method", | |
| 384 | "PUT", | |
| 385 | `${endpoint}/pulls/${input.number}/merge-async`, | |
| 386 | "-f", | |
| 387 | `merge_method=${input.mergeMethod ?? "merge"}`, | |
| 388 | "-f", | |
| 389 | "merge_action=default", | |
| 390 | "-f", | |
| 391 | `sha=${target.headSha}`, | |
| 392 | ], | |
| 324 | const request = yield* api.rest({ | |
| 325 | host: input.host, | |
| 326 | operation: "runGitHubStackAction", | |
| 327 | method: "PUT", | |
| 328 | path: `${endpoint}/pulls/${input.number}/merge-async`, | |
| 329 | body: { | |
| 330 | merge_method: input.mergeMethod ?? "merge", | |
| 331 | merge_action: "default", | |
| 332 | sha: target.headSha, | |
| 333 | }, | |
| 393 | 334 | }); |
| 394 | let result = yield* decode(request.stdout); | |
| 335 | let result = yield* decode(request.body); | |
| 395 | 336 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; |
| 396 | 337 | for ( |
| 397 | 338 | let attempt = 0; |
| ... | ... | @@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* |
| 401 | 342 | const uuid = result.details.uuid; |
| 402 | 343 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); |
| 403 | 344 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); |
| 404 | const poll = yield* github.execute({ | |
| 405 | cwd: input.cwd, | |
| 406 | args: [ | |
| 407 | "api", | |
| 408 | "--hostname", | |
| 409 | input.host, | |
| 410 | `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | |
| 411 | ], | |
| 345 | const poll = yield* api.rest({ | |
| 346 | host: input.host, | |
| 347 | operation: "runGitHubStackAction", | |
| 348 | path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | |
| 412 | 349 | }); |
| 413 | result = yield* decode(poll.stdout); | |
| 350 | result = yield* decode(poll.body); | |
| 414 | 351 | } |
| 415 | 352 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); |
| 416 | 353 | if (result.status === "failed") |
apps/server/src/sourceControl/GitHubApi.ts+8-2| ... | ... | @@ -117,6 +117,8 @@ export interface GitHubRestResponse { |
| 117 | 117 | readonly headers: Readonly<Record<string, string | undefined>>; |
| 118 | 118 | readonly body: string; |
| 119 | 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 | 124 | export interface GitHubRestInput { |
| ... | ... | @@ -131,6 +133,8 @@ export interface GitHubRestInput { |
| 131 | 133 | /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ |
| 132 | 134 | readonly ifNoneMatch?: string; |
| 133 | 135 | readonly maxResponseBytes?: number; |
| 136 | /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ | |
| 137 | readonly timeout?: Duration.Input; | |
| 134 | 138 | /** Overrides `AllowGitHubReserve` for this one request. */ |
| 135 | 139 | readonly allowReserve?: boolean; |
| 136 | 140 | } |
| ... | ... | @@ -381,11 +385,11 @@ export const make = Effect.gen(function* () { |
| 381 | 385 | readonly operation: string; |
| 382 | 386 | readonly request: HttpClientRequest.HttpClientRequest; |
| 383 | 387 | readonly maxResponseBytes: number; |
| 388 | readonly timeout?: Duration.Input | undefined; | |
| 384 | 389 | readonly allowReserve: boolean; |
| 385 | 390 | readonly acceptNotModified: boolean; |
| 386 | 391 | /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ |
| 387 | 392 | readonly graphql?: boolean; |
| 388 | readonly timeout?: Duration.Duration | undefined; | |
| 389 | 393 | }) { |
| 390 | 394 | const host = normalizeHost(input.host); |
| 391 | 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 | 439 | // 204, 304 and many refusals carry no body at all, which is an empty answer. |
| 436 | 440 | Effect.catchIf( |
| 437 | 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 | 445 | return { response, collected }; |
| ... | ... | @@ -468,6 +472,7 @@ export const make = Effect.gen(function* () { |
| 468 | 472 | headers, |
| 469 | 473 | body: collected.text, |
| 470 | 474 | truncated: collected.truncated, |
| 475 | invalidUtf8: collected.invalidUtf8, | |
| 471 | 476 | }), |
| 472 | 477 | ), |
| 473 | 478 | RateLimited: () => |
| ... | ... | @@ -526,6 +531,7 @@ export const make = Effect.gen(function* () { |
| 526 | 531 | operation: input.operation, |
| 527 | 532 | request, |
| 528 | 533 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, |
| 534 | timeout: input.timeout, | |
| 529 | 535 | allowReserve: input.allowReserve ?? interactive, |
| 530 | 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 | 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ |
| 39 | 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; |
| 40 | 40 | |
| 41 | export const AllowGitHubReserve = Context.Reference<boolean>( | |
| 42 | "t3/sourceControl/AllowGitHubReserve", | |
| 43 | { defaultValue: () => false }, | |
| 44 | ); | |
| 41 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; | |
| 45 | 42 | |
| 46 | 43 | function commandHosts(args: ReadonlyArray<string>): Array<string | null> { |
| 47 | 44 | const hosts: Array<string | null> = []; |