| author | |
| committer | |
| log | 507361bc7301982279b9f115bbae8f22268ce9d6 |
| tree | 8b877901476da6f09d6857e111804c433ef3e0d8 |
| parent | 8d1858d9d608d78b67268cdc52bceb4302ae8870 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>77 files changed, 21226 insertions(+), 21010 deletions(-)
apps/server/package.json+1| ... | ... | @@ -49,6 +49,7 @@ |
| 49 | 49 | "@t3tools/source-control-bitbucket": "workspace:*", |
| 50 | 50 | "@t3tools/source-control-core": "workspace:*", |
| 51 | 51 | "@t3tools/source-control-forgejo": "workspace:*", |
| 52 | "@t3tools/source-control-github": "workspace:*", | |
| 52 | 53 | "@t3tools/source-control-gitlab": "workspace:*", |
| 53 | 54 | "effect": "catalog:", |
| 54 | 55 | "jose": "catalog:", |
apps/server/scripts/evaluate-thread-titles.ts+1-4| ... | ... | @@ -23,7 +23,6 @@ import { |
| 23 | 23 | } from "@t3tools/provider-core/server/threadTitleContext"; |
| 24 | 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; | |
| 27 | 26 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; |
| 28 | 27 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 29 | 28 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| ... | ... | @@ -147,9 +146,7 @@ await Effect.runPromise( |
| 147 | 146 | Layer.mergeAll( |
| 148 | 147 | ProcessRunner.layer, |
| 149 | 148 | SourceControlProviderRegistry.layer.pipe( |
| 150 | Layer.provide( | |
| 151 | Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer), | |
| 152 | ), | |
| 149 | Layer.provide(SourceControlBuiltInDrivers.layer), | |
| 153 | 150 | // Default settings: no saved Bitbucket token, gh's own GitHub account choice. |
| 154 | 151 | Layer.provide(ServerSettings.layerTest()), |
| 155 | 152 | Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), |
apps/server/scripts/measure-pr-preview.ts+23-10| ... | ... | @@ -10,14 +10,19 @@ import * as Option from "effect/Option"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | import { FetchHttpClient } from "effect/http"; |
| 12 | 12 | |
| 13 | import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; | |
| 14 | import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; | |
| 15 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; | |
| 16 | import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; | |
| 13 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; | |
| 14 | import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; | |
| 15 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 16 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 17 | import * as ServerConfig from "../src/config.ts"; | |
| 17 | 18 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 18 | import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; | |
| 19 | import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; | |
| 19 | 20 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 21 | import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; | |
| 22 | import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; | |
| 23 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | |
| 20 | 24 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 25 | import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; | |
| 21 | 26 | |
| 22 | 27 | const [repository, ...numbers] = process.argv.slice(2); |
| 23 | 28 | if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { |
| ... | ... | @@ -41,6 +46,17 @@ function withRateLimit(query: string): string { |
| 41 | 46 | : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; |
| 42 | 47 | } |
| 43 | 48 | |
| 49 | // The server's host port over default settings: gh's own account choice, no saved token. | |
| 50 | const sourceControlHost = ServerSourceControlHost.layer.pipe( | |
| 51 | Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), | |
| 52 | Layer.provide(GitVcsDriver.layer), | |
| 53 | Layer.provide(ServerSettings.layerTest()), | |
| 54 | // A scratch state directory, so nothing here touches a real T3 home. | |
| 55 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })), | |
| 56 | Layer.provideMerge(VcsProcess.layer), | |
| 57 | Layer.provideMerge(NodeServices.layer), | |
| 58 | ); | |
| 59 | ||
| 44 | 60 | const measuredApi = Layer.effect( |
| 45 | 61 | GitHubApi.GitHubApi, |
| 46 | 62 | Effect.gen(function* () { |
| ... | ... | @@ -73,18 +89,15 @@ const measuredApi = Layer.effect( |
| 73 | 89 | }); |
| 74 | 90 | }), |
| 75 | 91 | ).pipe( |
| 76 | Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), | |
| 92 | Layer.provide(GitHubCredentials.layer), | |
| 77 | 93 | Layer.provide(GitHubQuota.layer), |
| 78 | 94 | Layer.provide(SourceControlRateLimit.layer), |
| 79 | 95 | Layer.provide(FetchHttpClient.layer), |
| 80 | Layer.provide(VcsProcess.layer), | |
| 81 | Layer.provide(NodeServices.layer), | |
| 82 | 96 | ); |
| 83 | 97 | |
| 84 | 98 | const services = GitHubPullRequestApi.layer.pipe( |
| 85 | 99 | Layer.provideMerge(measuredApi), |
| 86 | Layer.provideMerge(VcsProcess.layer), | |
| 87 | Layer.provideMerge(NodeServices.layer), | |
| 100 | Layer.provideMerge(sourceControlHost), | |
| 88 | 101 | ); |
| 89 | 102 | |
| 90 | 103 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
apps/server/src/assets/AssetAccess.test.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t |
| 35 | 35 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 36 | 36 | import { openMediaFile } from "./MediaFile.ts"; |
| 37 | 37 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 38 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 38 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 39 | 39 | import { githubMediaResponse } from "./GitHubMediaFetch.ts"; |
| 40 | 40 | |
| 41 | 41 | vi.mock("node:fs/promises", async (importOriginal) => { |
apps/server/src/assets/GitHubMediaFetch.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import { |
| 12 | 12 | type HttpClientResponse, |
| 13 | 13 | } from "effect/http"; |
| 14 | 14 | |
| 15 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 15 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | 18 | * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned |
apps/server/src/git/GitManager.test.ts+1-1| ... | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | ThreadId, |
| 44 | 44 | } from "@t3tools/contracts"; |
| 45 | 45 | import * as DateTime from "effect/DateTime"; |
| 46 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; | |
| 46 | import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests"; | |
| 47 | 47 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 48 | 48 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 49 | 49 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts deleted-4588| ... | ... | @@ -1,4588 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 14 | ||
| 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; | |
| 16 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 17 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 18 | import * as GitHubQuota from "../sourceControl/githubQuota.ts"; | |
| 19 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 21 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 22 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | |
| 23 | ||
| 24 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 25 | ||
| 26 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 27 | data: { | |
| 28 | repository: { | |
| 29 | mergeCommitAllowed: true, | |
| 30 | squashMergeAllowed: false, | |
| 31 | rebaseMergeAllowed: true, | |
| 32 | viewerPermission: "WRITE", | |
| 33 | pullRequest: { | |
| 34 | number: 7, | |
| 35 | title: "Pull request 7", | |
| 36 | url: "https://github.com/acme/web/pull/7", | |
| 37 | headRefName: "feature", | |
| 38 | baseRefName: "main", | |
| 39 | headRefOid: "abc123", | |
| 40 | state: "OPEN", | |
| 41 | createdAt: "2026-07-01T00:00:00Z", | |
| 42 | updatedAt: "2026-07-02T00:00:00Z", | |
| 43 | viewerCanUpdate: true, | |
| 44 | viewerDidAuthor: false, | |
| 45 | viewerCanUpdateBranch: true, | |
| 46 | baseRef: { compare: { behindBy: 2 } }, | |
| 47 | reviewRequests: { nodes: [] }, | |
| 48 | labels: { nodes: [] }, | |
| 49 | commits: { nodes: [] }, | |
| 50 | ...pullRequest, | |
| 51 | }, | |
| 52 | }, | |
| 53 | }, | |
| 54 | }); | |
| 55 | ||
| 56 | /** One request the CLI made, as the transport received it. */ | |
| 57 | type ApiCall = | |
| 58 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) | |
| 59 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); | |
| 60 | ||
| 61 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; | |
| 62 | ||
| 63 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); | |
| 64 | const defaultCredential = (host: string) => | |
| 65 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); | |
| 66 | const mockedCredential = vi.fn(defaultCredential); | |
| 67 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => | |
| 68 | Effect.succeed(output('{"data":{}}')), | |
| 69 | ); | |
| 70 | ||
| 71 | /** | |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL quota from each | |
| 73 | * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. | |
| 74 | */ | |
| 75 | const mockApi = Layer.effect( | |
| 76 | GitHubApi.GitHubApi, | |
| 77 | Effect.gen(function* () { | |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 79 | return GitHubApi.GitHubApi.of({ | |
| 80 | graphql: (input) => | |
| 81 | GitHubApi.AllowGitHubReserve.pipe( | |
| 82 | Effect.flatMap((interactive) => | |
| 83 | quota.admit(input.host, "graphql", { | |
| 84 | allowReserve: input.allowReserve ?? interactive, | |
| 85 | }), | |
| 86 | ), | |
| 87 | Effect.andThen(() => | |
| 88 | (input.query.includes("query PullRequestStackMemberships") | |
| 89 | ? mockedStackMemberships | |
| 90 | : mockedExecute)({ kind: "graphql", ...input }), | |
| 91 | ), | |
| 92 | Effect.tap((answer) => quota.observe(input.host, answer.headers)), | |
| 93 | Effect.map((answer) => answer.body), | |
| 94 | ), | |
| 95 | rest: (input) => mockedExecute({ kind: "rest", ...input }), | |
| 96 | credential: (host) => mockedCredential(host), | |
| 97 | }); | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | const layer = it.layer( | |
| 102 | GitHubPullRequestApi.layer.pipe( | |
| 103 | Layer.provideMerge(mockApi), | |
| 104 | Layer.provideMerge(GitHubQuota.layer), | |
| 105 | Layer.provide(NodeCrypto.layer), | |
| 106 | Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), | |
| 107 | ), | |
| 108 | ); | |
| 109 | ||
| 110 | /** A successful answer; GraphQL reads take its body. */ | |
| 111 | function output( | |
| 112 | body: string, | |
| 113 | truncated = false, | |
| 114 | invalidUtf8 = false, | |
| 115 | headers: Readonly<Record<string, string>> = {}, | |
| 116 | ): GitHubApi.GitHubRestResponse { | |
| 117 | return { status: 200, headers, body, truncated, invalidUtf8 }; | |
| 118 | } | |
| 119 | ||
| 120 | /** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ | |
| 121 | function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { | |
| 122 | return { | |
| 123 | "x-ratelimit-resource": "graphql", | |
| 124 | "x-ratelimit-limit": "5000", | |
| 125 | "x-ratelimit-remaining": String(remaining), | |
| 126 | "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most | |
| 132 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. | |
| 133 | */ | |
| 134 | function rows( | |
| 135 | count: number, | |
| 136 | firstNumber: number, | |
| 137 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), | |
| 138 | ): ReadonlyArray<Record<string, unknown>> { | |
| 139 | return Array.from({ length: count }, (_, index) => { | |
| 140 | const number = firstNumber + index; | |
| 141 | const { reviewRequests, checks, ...rest } = overrides(number) as { | |
| 142 | reviewRequests?: ReadonlyArray<Record<string, string>>; | |
| 143 | checks?: string; | |
| 144 | } & Record<string, unknown>; | |
| 145 | return { | |
| 146 | number, | |
| 147 | title: `Pull request ${number}`, | |
| 148 | url: `https://github.com/acme/web/pull/${number}`, | |
| 149 | headRefName: "feat/page", | |
| 150 | baseRefName: "main", | |
| 151 | createdAt: "2026-07-01T00:00:00Z", | |
| 152 | updatedAt: "2026-07-02T00:00:00Z", | |
| 153 | repository: { nameWithOwner: "acme/web" }, | |
| 154 | ...(reviewRequests === undefined | |
| 155 | ? {} | |
| 156 | : { | |
| 157 | reviewRequests: { | |
| 158 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), | |
| 159 | }, | |
| 160 | }), | |
| 161 | ...(checks === undefined | |
| 162 | ? {} | |
| 163 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 164 | ...rest, | |
| 165 | }; | |
| 166 | }); | |
| 167 | } | |
| 168 | ||
| 169 | /** A page of the searched listing. */ | |
| 170 | function pullRequests( | |
| 171 | count: number, | |
| 172 | firstNumber: number, | |
| 173 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 174 | ): string { | |
| 175 | return encodeJson({ | |
| 176 | data: { | |
| 177 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, | |
| 178 | }, | |
| 179 | }); | |
| 180 | } | |
| 181 | ||
| 182 | /** A page of a repository's own list, which is what the search-free fallback reads. */ | |
| 183 | function listedPullRequests( | |
| 184 | count: number, | |
| 185 | firstNumber: number, | |
| 186 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 187 | ): string { | |
| 188 | return encodeJson({ | |
| 189 | data: { | |
| 190 | repository: { | |
| 191 | pullRequests: { | |
| 192 | pageInfo: { hasNextPage: false }, | |
| 193 | nodes: rows(count, firstNumber, overrides), | |
| 194 | }, | |
| 195 | }, | |
| 196 | }, | |
| 197 | }); | |
| 198 | } | |
| 199 | ||
| 200 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ | |
| 201 | const emptySearch = () => output(pullRequests(0, 1)); | |
| 202 | const emptyList = () => output(listedPullRequests(0, 1)); | |
| 203 | ||
| 204 | function pullRequestFiles(count: number, firstIndex: number): string { | |
| 205 | return encodeJson( | |
| 206 | Array.from({ length: count }, (_, index) => ({ | |
| 207 | filename: `src/file${firstIndex + index}.ts`, | |
| 208 | status: "modified", | |
| 209 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 210 | })), | |
| 211 | ); | |
| 212 | } | |
| 213 | ||
| 214 | /** One thread's comments as the GraphQL read returns them, cursor and all. */ | |
| 215 | function threadComments( | |
| 216 | ids: ReadonlyArray<string>, | |
| 217 | endCursor: string | null, | |
| 218 | totalCount = ids.length, | |
| 219 | ) { | |
| 220 | return { | |
| 221 | totalCount, | |
| 222 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 223 | nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), | |
| 224 | }; | |
| 225 | } | |
| 226 | ||
| 227 | function thread(id: string, ...commentIds: ReadonlyArray<string>) { | |
| 228 | return { | |
| 229 | id, | |
| 230 | path: "src/a.ts", | |
| 231 | line: 1, | |
| 232 | diffSide: "RIGHT", | |
| 233 | isResolved: false, | |
| 234 | isOutdated: false, | |
| 235 | comments: threadComments(commentIds, null), | |
| 236 | }; | |
| 237 | } | |
| 238 | ||
| 239 | function reviewThreadsPage( | |
| 240 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 241 | endCursor: string | null, | |
| 242 | ): string { | |
| 243 | return encodeJson({ | |
| 244 | data: { | |
| 245 | repository: { | |
| 246 | pullRequest: { | |
| 247 | reviewThreads: { | |
| 248 | totalCount: nodes.length, | |
| 249 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 250 | nodes, | |
| 251 | }, | |
| 252 | }, | |
| 253 | }, | |
| 254 | }, | |
| 255 | }); | |
| 256 | } | |
| 257 | ||
| 258 | function threadCommentsPage( | |
| 259 | ids: ReadonlyArray<string>, | |
| 260 | endCursor: string | null, | |
| 261 | totalCount: number, | |
| 262 | pullRequestId = "PR_7", | |
| 263 | ): string { | |
| 264 | return encodeJson({ | |
| 265 | data: { | |
| 266 | repository: { pullRequest: { id: "PR_7" } }, | |
| 267 | node: { | |
| 268 | pullRequest: { id: pullRequestId }, | |
| 269 | comments: threadComments(ids, endCursor, totalCount), | |
| 270 | }, | |
| 271 | }, | |
| 272 | }); | |
| 273 | } | |
| 274 | ||
| 275 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ | |
| 276 | const diffRefused = new GitHubApi.GitHubApiResponseError({ | |
| 277 | host: "github.com", | |
| 278 | operation: "getPullRequestDiff", | |
| 279 | status: 406, | |
| 280 | }); | |
| 281 | ||
| 282 | /** The nth request the CLI made. */ | |
| 283 | function callAt(index: number): ApiCall { | |
| 284 | const call = mockedExecute.mock.calls[index]; | |
| 285 | assert.isDefined(call); | |
| 286 | return call[0]; | |
| 287 | } | |
| 288 | ||
| 289 | /** The GraphQL variables of the nth request. */ | |
| 290 | function varsAt(index: number): Readonly<Record<string, unknown>> { | |
| 291 | const call = callAt(index); | |
| 292 | assert.strictEqual(call.kind, "graphql"); | |
| 293 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; | |
| 294 | } | |
| 295 | ||
| 296 | /** The GraphQL document of the nth request. */ | |
| 297 | function queryAt(index: number): string { | |
| 298 | const call = callAt(index); | |
| 299 | return call.kind === "graphql" ? call.query : ""; | |
| 300 | } | |
| 301 | ||
| 302 | /** The REST path of the nth request. */ | |
| 303 | function pathAt(index: number): string { | |
| 304 | const call = callAt(index); | |
| 305 | return call.kind === "rest" ? call.path : ""; | |
| 306 | } | |
| 307 | ||
| 308 | /** The search the nth request carried, or undefined for a read that did not search. */ | |
| 309 | function searchOfCall(index: number): string | undefined { | |
| 310 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; | |
| 311 | return typeof q === "string" ? q : undefined; | |
| 312 | } | |
| 313 | ||
| 314 | /** One row as a search answers it, which is the listing's row one connection deeper. */ | |
| 315 | function searchItem(number: number, repository: string, updatedAt: string) { | |
| 316 | return { | |
| 317 | number, | |
| 318 | title: `Pull request ${number}`, | |
| 319 | url: `https://github.com/${repository}/pull/${number}`, | |
| 320 | author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, | |
| 321 | headRefName: "feat/page", | |
| 322 | baseRefName: "main", | |
| 323 | state: "OPEN", | |
| 324 | isDraft: false, | |
| 325 | mergeable: "MERGEABLE", | |
| 326 | createdAt: "2026-07-01T00:00:00Z", | |
| 327 | updatedAt, | |
| 328 | repository: { nameWithOwner: repository }, | |
| 329 | reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, | |
| 330 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 331 | }; | |
| 332 | } | |
| 333 | ||
| 334 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { | |
| 335 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); | |
| 336 | } | |
| 337 | ||
| 338 | const searchQueryOfCall = searchOfCall; | |
| 339 | ||
| 340 | /** | |
| 341 | * Answers every request with the body of the first route whose needle its GraphQL document or | |
| 342 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. | |
| 343 | */ | |
| 344 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { | |
| 345 | mockedExecute.mockImplementation((call) => { | |
| 346 | const target = call.kind === "graphql" ? call.query : call.path; | |
| 347 | const match = routes.find(([needle]) => target.includes(needle)); | |
| 348 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); | |
| 349 | }); | |
| 350 | } | |
| 351 | ||
| 352 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ | |
| 353 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { | |
| 354 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 355 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], | |
| 356 | ); | |
| 357 | } | |
| 358 | ||
| 359 | /** Every REST request whose path contains `needle`, in order. */ | |
| 360 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { | |
| 361 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 362 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], | |
| 363 | ); | |
| 364 | } | |
| 365 | ||
| 366 | /** | |
| 367 | * The check that a client-given node hangs off the pull request it names. Routed before the node | |
| 368 | * id lookup, whose document it contains. | |
| 369 | */ | |
| 370 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; | |
| 371 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ | |
| 372 | data: { | |
| 373 | repository: { pullRequest: { id: pullRequestId } }, | |
| 374 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, | |
| 375 | }, | |
| 376 | }); | |
| 377 | ||
| 378 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ | |
| 379 | const checkContextsPage = ( | |
| 380 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 381 | endCursor: string | null, | |
| 382 | headRefOid = "abc123", | |
| 383 | ) => | |
| 384 | encodeJson({ | |
| 385 | data: { | |
| 386 | repository: { | |
| 387 | pullRequest: { | |
| 388 | headRefOid, | |
| 389 | commits: { | |
| 390 | nodes: [ | |
| 391 | { | |
| 392 | commit: { | |
| 393 | statusCheckRollup: { | |
| 394 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, | |
| 395 | }, | |
| 396 | }, | |
| 397 | }, | |
| 398 | ], | |
| 399 | }, | |
| 400 | }, | |
| 401 | }, | |
| 402 | }, | |
| 403 | }); | |
| 404 | ||
| 405 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ | |
| 406 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); | |
| 407 | ||
| 408 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ | |
| 409 | const crossRepositoryDetail = (headRefOid = "abc123") => | |
| 410 | coreResponse({ | |
| 411 | headRefName: "feat/page", | |
| 412 | headRefOid, | |
| 413 | isCrossRepository: true, | |
| 414 | headRepositoryOwner: { login: "octocat" }, | |
| 415 | }); | |
| 416 | ||
| 417 | /** Open pull requests on one head branch, every one of them from the same fork head. */ | |
| 418 | const heads = (numbers: ReadonlyArray<number>) => ({ | |
| 419 | data: { | |
| 420 | repository: { | |
| 421 | pullRequests: { | |
| 422 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 423 | nodes: numbers.map((number) => ({ | |
| 424 | number, | |
| 425 | headRefOid: "abc123", | |
| 426 | isCrossRepository: true, | |
| 427 | headRepositoryOwner: { login: "octocat" }, | |
| 428 | })), | |
| 429 | }, | |
| 430 | }, | |
| 431 | }, | |
| 432 | }); | |
| 433 | ||
| 434 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ | |
| 435 | workflow_runs: ids.map((id) => ({ | |
| 436 | id, | |
| 437 | name: id === 10 ? "build" : `run ${id}`, | |
| 438 | html_url: `https://example.com/${id}`, | |
| 439 | })), | |
| 440 | }); | |
| 441 | ||
| 442 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ | |
| 443 | function workflowApprovalRoutes( | |
| 444 | detail: () => unknown, | |
| 445 | headsAnswer: unknown, | |
| 446 | runsAnswer: unknown, | |
| 447 | ): void { | |
| 448 | mockedExecute.mockImplementation((call) => | |
| 449 | Effect.sync(() => | |
| 450 | output( | |
| 451 | call.kind === "rest" | |
| 452 | ? call.path.endsWith("/approve") | |
| 453 | ? "" | |
| 454 | : encodeJson(runsAnswer) | |
| 455 | : call.query.includes("headRefName: $head") | |
| 456 | ? encodeJson(headsAnswer) | |
| 457 | : encodeJson(detail()), | |
| 458 | ), | |
| 459 | ), | |
| 460 | ); | |
| 461 | } | |
| 462 | ||
| 463 | /** The pull request node id lookup, which the layer caches for every test after the first. */ | |
| 464 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; | |
| 465 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); | |
| 466 | ||
| 467 | afterEach(() => { | |
| 468 | mockedExecute.mockReset(); | |
| 469 | mockedStackMemberships.mockReset(); | |
| 470 | mockedCredential.mockReset(); | |
| 471 | mockedCredential.mockImplementation(defaultCredential); | |
| 472 | }); | |
| 473 | ||
| 474 | it.effect( | |
| 475 | "keeps a verified credential through an auth switch and separates token fingerprints", | |
| 476 | () => | |
| 477 | Effect.gen(function* () { | |
| 478 | let activeToken = "broad-credential"; | |
| 479 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = | |
| 480 | []; | |
| 481 | const credentials = Layer.succeed( | |
| 482 | GitHubCredentials.GitHubCredentials, | |
| 483 | GitHubCredentials.GitHubCredentials.of({ | |
| 484 | get: (host) => | |
| 485 | Effect.sync(() => ({ | |
| 486 | host, | |
| 487 | token: Redacted.make(activeToken), | |
| 488 | source: "gh" as const, | |
| 489 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, | |
| 490 | })), | |
| 491 | invalidate: () => Effect.void, | |
| 492 | }), | |
| 493 | ); | |
| 494 | const http = Layer.succeed( | |
| 495 | HttpClient.HttpClient, | |
| 496 | HttpClient.make((request) => | |
| 497 | Effect.sync(() => { | |
| 498 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); | |
| 499 | const body = request.url.endsWith("/user") | |
| 500 | ? { id: 123, login: "same-account" } | |
| 501 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; | |
| 502 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); | |
| 503 | }), | |
| 504 | ), | |
| 505 | ); | |
| 506 | const cli = yield* GitHubPullRequestApi.make.pipe( | |
| 507 | Effect.provide( | |
| 508 | GitHubApi.layer.pipe( | |
| 509 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 510 | Layer.provide(GitHubQuota.layer), | |
| 511 | Layer.provide(SourceControlRateLimit.layer), | |
| 512 | Layer.merge(VcsProcess.layer), | |
| 513 | Layer.provideMerge(NodeServices.layer), | |
| 514 | ), | |
| 515 | ), | |
| 516 | ); | |
| 517 | const input = { cwd: "/repo", host: "github.com" }; | |
| 518 | const first = yield* cli.withVerifiedCredential(input, (identity) => | |
| 519 | Effect.gen(function* () { | |
| 520 | activeToken = "restricted-credential"; | |
| 521 | expect(yield* cli.getViewerLogin(input)).toBe("same-account"); | |
| 522 | yield* cli.commentOnPullRequest({ | |
| 523 | ...input, | |
| 524 | repository: "owner/repo", | |
| 525 | number: 1, | |
| 526 | body: "comment", | |
| 527 | }); | |
| 528 | return identity; | |
| 529 | }), | |
| 530 | ); | |
| 531 | const second = yield* cli.withVerifiedCredential(input, Effect.succeed); | |
| 532 | expect(first.accountId).toBe(second.accountId); | |
| 533 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); | |
| 534 | expect(encodeJson([first, second])).not.toContain("broad-credential"); | |
| 535 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); | |
| 536 | // The comment was written under the credential the page verified, not the switched one. | |
| 537 | expect( | |
| 538 | requests | |
| 539 | .filter((request) => request.url.endsWith("/graphql")) | |
| 540 | .map((request) => request.authorization), | |
| 541 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | |
| 542 | expect( | |
| 543 | requests | |
| 544 | .filter((request) => request.url.endsWith("/user")) | |
| 545 | .map((request) => request.authorization), | |
| 546 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); | |
| 547 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 548 | accountId: "123", | |
| 549 | viewer: "same-account", | |
| 550 | }); | |
| 551 | }), | |
| 552 | ); | |
| 553 | ||
| 554 | layer("GitHubPullRequestApi.layer", (it) => { | |
| 555 | it.effect("holds background previews once an answer reports the reserve, until it resets", () => | |
| 556 | Effect.gen(function* () { | |
| 557 | const now = yield* Clock.currentTimeMillis; | |
| 558 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 559 | const resetAt = "2099-08-13T14:00:00Z"; | |
| 560 | yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); | |
| 561 | // GitHub's count, not ours: the answer below reports the reserve has been reached. | |
| 562 | mockedExecute.mockReturnValue( | |
| 563 | Effect.succeed( | |
| 564 | output( | |
| 565 | encodeJson({ | |
| 566 | data: { | |
| 567 | repository: { | |
| 568 | pullRequest: { | |
| 569 | number: 7, | |
| 570 | title: "Fast previews", | |
| 571 | url: "https://preview-budget.example/acme/web/pull/7", | |
| 572 | state: "OPEN", | |
| 573 | isDraft: false, | |
| 574 | createdAt: "2026-07-01T00:00:00Z", | |
| 575 | author: null, | |
| 576 | }, | |
| 577 | }, | |
| 578 | }, | |
| 579 | }), | |
| 580 | false, | |
| 581 | false, | |
| 582 | graphqlQuota(499, resetAt), | |
| 583 | ), | |
| 584 | ), | |
| 585 | ); | |
| 586 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 587 | const input = { | |
| 588 | cwd: "/w", | |
| 589 | repository: "acme/web", | |
| 590 | host: "preview-budget.example", | |
| 591 | number: 7, | |
| 592 | }; | |
| 593 | yield* cli.getPullRequestPreview(input); | |
| 594 | const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); | |
| 595 | expect(held._tag).toBe("SourceControlRateLimitPausedError"); | |
| 596 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 597 | yield* TestClock.setTime(Date.parse(resetAt)); | |
| 598 | yield* cli.getPullRequestPreview(input); | |
| 599 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 600 | yield* TestClock.setTime(now); | |
| 601 | }), | |
| 602 | ); | |
| 603 | ||
| 604 | it.effect("loads the complete hover card with one GraphQL request", () => | |
| 605 | Effect.gen(function* () { | |
| 606 | mockedExecute.mockReturnValueOnce( | |
| 607 | Effect.succeed( | |
| 608 | output( | |
| 609 | encodeJson({ | |
| 610 | data: { | |
| 611 | repository: { | |
| 612 | pullRequest: { | |
| 613 | number: 7, | |
| 614 | title: "Fast previews", | |
| 615 | url: "https://github.example/acme/web/pull/7", | |
| 616 | state: "MERGED", | |
| 617 | isDraft: false, | |
| 618 | createdAt: "2026-07-01T00:00:00Z", | |
| 619 | author: { | |
| 620 | login: "octocat", | |
| 621 | name: "Octo Cat", | |
| 622 | avatarUrl: "https://github.example/avatar.png", | |
| 623 | }, | |
| 624 | }, | |
| 625 | }, | |
| 626 | }, | |
| 627 | }), | |
| 628 | ), | |
| 629 | ), | |
| 630 | ); | |
| 631 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 632 | const preview = yield* cli.getPullRequestPreview({ | |
| 633 | cwd: "/w", | |
| 634 | repository: "acme/web", | |
| 635 | host: "github.example", | |
| 636 | number: 7, | |
| 637 | }); | |
| 638 | expect(preview).toEqual({ | |
| 639 | number: 7, | |
| 640 | title: "Fast previews", | |
| 641 | url: "https://github.example/acme/web/pull/7", | |
| 642 | state: "merged", | |
| 643 | isDraft: false, | |
| 644 | createdAt: "2026-07-01T00:00:00Z", | |
| 645 | author: { | |
| 646 | login: "octocat", | |
| 647 | name: "Octo Cat", | |
| 648 | avatarUrl: "https://github.example/avatar.png", | |
| 649 | }, | |
| 650 | }); | |
| 651 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 652 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); | |
| 653 | }), | |
| 654 | ); | |
| 655 | ||
| 656 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | |
| 657 | Effect.gen(function* () { | |
| 658 | mockedExecute.mockImplementation(() => | |
| 659 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 660 | ); | |
| 661 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 662 | const results = yield* Effect.all( | |
| 663 | Array.from({ length: 4 }, () => | |
| 664 | cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), | |
| 665 | ), | |
| 666 | { concurrency: 4 }, | |
| 667 | ); | |
| 668 | expect(results).toEqual( | |
| 669 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), | |
| 670 | ); | |
| 671 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 672 | expect(pathAt(0)).toBe("user"); | |
| 673 | }), | |
| 674 | ); | |
| 675 | ||
| 676 | it.effect( | |
| 677 | "lets another identity reader continue when the first verification is interrupted", | |
| 678 | () => | |
| 679 | Effect.gen(function* () { | |
| 680 | const firstStarted = yield* Deferred.make<void>(); | |
| 681 | let verifications = 0; | |
| 682 | mockedExecute.mockImplementation(() => | |
| 683 | Effect.gen(function* () { | |
| 684 | if (++verifications === 1) { | |
| 685 | yield* Deferred.succeed(firstStarted, undefined); | |
| 686 | return yield* Effect.never; | |
| 687 | } | |
| 688 | return output('{"id":123,"login":"viewer"}'); | |
| 689 | }), | |
| 690 | ); | |
| 691 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 692 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; | |
| 693 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 694 | yield* Deferred.await(firstStarted); | |
| 695 | // The second reader is queued on the same credential's lock behind the first. | |
| 696 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 697 | yield* Effect.yieldNow; | |
| 698 | yield* Fiber.interrupt(first); | |
| 699 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); | |
| 700 | expect(verifications).toBe(2); | |
| 701 | }), | |
| 702 | ); | |
| 703 | ||
| 704 | it.effect("reads linked pull requests on one host together, filed back by position", () => | |
| 705 | Effect.gen(function* () { | |
| 706 | const node = (number: number) => ({ | |
| 707 | number, | |
| 708 | title: `Pull request ${number}`, | |
| 709 | url: `https://github.com/acme/web/pull/${number}`, | |
| 710 | author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 711 | baseRefName: "main", | |
| 712 | headRefName: `feat/${number}`, | |
| 713 | state: "OPEN", | |
| 714 | isDraft: false, | |
| 715 | mergeable: "MERGEABLE", | |
| 716 | reviewDecision: null, | |
| 717 | latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, | |
| 718 | additions: 12, | |
| 719 | deletions: 3, | |
| 720 | changedFiles: 2, | |
| 721 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 722 | mergedAt: null, | |
| 723 | closedAt: null, | |
| 724 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 725 | }); | |
| 726 | mockedExecute.mockReturnValueOnce( | |
| 727 | Effect.succeed( | |
| 728 | output( | |
| 729 | encodeJson({ | |
| 730 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, | |
| 731 | }), | |
| 732 | ), | |
| 733 | ), | |
| 734 | ); | |
| 735 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 736 | ||
| 737 | const reads = yield* Effect.forEach( | |
| 738 | [7, 8], | |
| 739 | (number) => | |
| 740 | cli.getPullRequestSummary({ | |
| 741 | cwd: "/w", | |
| 742 | repository: "acme/web", | |
| 743 | host: "github.com", | |
| 744 | number, | |
| 745 | }), | |
| 746 | { concurrency: "unbounded" }, | |
| 747 | ).pipe(Effect.forkChild); | |
| 748 | yield* TestClock.adjust("10 millis"); | |
| 749 | const [seven, eight] = yield* Fiber.join(reads); | |
| 750 | ||
| 751 | assert.deepStrictEqual( | |
| 752 | { | |
| 753 | number: seven?.number, | |
| 754 | state: seven?.state, | |
| 755 | headBranch: seven?.headBranch, | |
| 756 | author: seven?.author?.login, | |
| 757 | changedFiles: seven?.changedFiles, | |
| 758 | reviewDecision: seven?.reviewDecision, | |
| 759 | checksState: seven?.checksState, | |
| 760 | mergeability: seven?.mergeability, | |
| 761 | }, | |
| 762 | { | |
| 763 | number: 7, | |
| 764 | state: "open", | |
| 765 | headBranch: "feat/7", | |
| 766 | author: "octocat", | |
| 767 | changedFiles: 2, | |
| 768 | reviewDecision: "approved", | |
| 769 | checksState: "passing", | |
| 770 | mergeability: "mergeable", | |
| 771 | }, | |
| 772 | ); | |
| 773 | assert.strictEqual(eight?.headBranch, "feat/8"); | |
| 774 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 775 | expect(queryAt(0)).toContain( | |
| 776 | "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", | |
| 777 | ); | |
| 778 | expect(varsAt(0)).toMatchObject({ | |
| 779 | s0_owner: "acme", | |
| 780 | s0_name: "web", | |
| 781 | s0_number: 7, | |
| 782 | s1_number: 8, | |
| 783 | }); | |
| 784 | }), | |
| 785 | ); | |
| 786 | ||
| 787 | it.effect("fingerprints watched pull requests on one host in one read", () => | |
| 788 | Effect.gen(function* () { | |
| 789 | const node = (comments: number) => ({ | |
| 790 | state: "OPEN", | |
| 791 | mergeable: "MERGEABLE", | |
| 792 | headRefOid: "abc123", | |
| 793 | comments: { totalCount: comments, nodes: [] }, | |
| 794 | reviews: { totalCount: 0, nodes: [] }, | |
| 795 | reviewThreads: { totalCount: 0 }, | |
| 796 | commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, | |
| 797 | }); | |
| 798 | mockedExecute.mockReturnValueOnce( | |
| 799 | Effect.succeed( | |
| 800 | output( | |
| 801 | JSON.stringify({ | |
| 802 | data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, | |
| 803 | }), | |
| 804 | ), | |
| 805 | ), | |
| 806 | ); | |
| 807 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 808 | ||
| 809 | const reads = yield* Effect.forEach( | |
| 810 | [7, 8], | |
| 811 | (number) => | |
| 812 | cli.getPullRequestWatchFingerprint({ | |
| 813 | cwd: "/w", | |
| 814 | repository: "acme/web", | |
| 815 | host: "github.com", | |
| 816 | number, | |
| 817 | }), | |
| 818 | { concurrency: "unbounded" }, | |
| 819 | ).pipe(Effect.forkChild); | |
| 820 | yield* TestClock.adjust("10 millis"); | |
| 821 | const [seven, eight] = yield* Fiber.join(reads); | |
| 822 | ||
| 823 | expect(seven?.remarks.startsWith("1 ")).toBe(true); | |
| 824 | // GitHub had no answer for #8, so its watch reads it in full. | |
| 825 | expect(eight).toBeNull(); | |
| 826 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 827 | expect(queryAt(0)).toContain( | |
| 828 | "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", | |
| 829 | ); | |
| 830 | expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); | |
| 831 | }), | |
| 832 | ); | |
| 833 | ||
| 834 | it.effect("reads a pull request the batch said nothing about on its own", () => | |
| 835 | Effect.gen(function* () { | |
| 836 | mockedExecute | |
| 837 | .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) | |
| 838 | .mockReturnValueOnce( | |
| 839 | Effect.succeed( | |
| 840 | output( | |
| 841 | encodeJson({ | |
| 842 | data: { | |
| 843 | s0: { | |
| 844 | pullRequest: { | |
| 845 | number: 7, | |
| 846 | title: "Reuse the summary", | |
| 847 | url: "https://github.com/acme/web/pull/7", | |
| 848 | author: { login: "octocat", name: "Octo Cat" }, | |
| 849 | baseRefName: "main", | |
| 850 | headRefName: "feat/summary", | |
| 851 | state: "OPEN", | |
| 852 | isDraft: false, | |
| 853 | mergeable: "MERGEABLE", | |
| 854 | reviewDecision: "APPROVED", | |
| 855 | additions: 12, | |
| 856 | deletions: 3, | |
| 857 | changedFiles: 2, | |
| 858 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 859 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 860 | }, | |
| 861 | }, | |
| 862 | }, | |
| 863 | }), | |
| 864 | ), | |
| 865 | ), | |
| 866 | ); | |
| 867 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 868 | ||
| 869 | const read = yield* cli | |
| 870 | .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) | |
| 871 | .pipe(Effect.forkChild); | |
| 872 | yield* TestClock.adjust("10 millis"); | |
| 873 | const summary = yield* Fiber.join(read); | |
| 874 | ||
| 875 | assert.strictEqual(summary.headBranch, "feat/summary"); | |
| 876 | assert.strictEqual(summary.checksState, "passing"); | |
| 877 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 878 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); | |
| 879 | }), | |
| 880 | ); | |
| 881 | ||
| 882 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => | |
| 883 | Effect.gen(function* () { | |
| 884 | mockedExecute.mockReturnValueOnce( | |
| 885 | Effect.succeed( | |
| 886 | output( | |
| 887 | encodeJson([ | |
| 888 | { | |
| 889 | id: 42, | |
| 890 | number: 3, | |
| 891 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 892 | base: { ref: "main" }, | |
| 893 | pull_requests: [ | |
| 894 | { | |
| 895 | number: 6, | |
| 896 | head: { ref: "feat/one" }, | |
| 897 | state: "closed", | |
| 898 | merged_at: "2026-09-02", | |
| 899 | }, | |
| 900 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 901 | ], | |
| 902 | }, | |
| 903 | ]), | |
| 904 | ), | |
| 905 | ), | |
| 906 | ); | |
| 907 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 908 | ||
| 909 | const stack = yield* cli.getPullRequestStack({ | |
| 910 | cwd: "/w", | |
| 911 | repository: "acme/web", | |
| 912 | host: "ghe.example.com", | |
| 913 | number: 7, | |
| 914 | }); | |
| 915 | ||
| 916 | assert.deepStrictEqual(stack, { | |
| 917 | id: "42", | |
| 918 | number: 3, | |
| 919 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 920 | base: "main", | |
| 921 | layers: [ | |
| 922 | { number: 6, headBranch: "feat/one", state: "merged" }, | |
| 923 | { number: 7, headBranch: "feat/two", state: "open" }, | |
| 924 | ], | |
| 925 | }); | |
| 926 | expect(callAt(0)).toMatchObject({ | |
| 927 | kind: "rest", | |
| 928 | host: "ghe.example.com", | |
| 929 | path: "repos/acme/web/stacks?pull_request=7", | |
| 930 | }); | |
| 931 | }), | |
| 932 | ); | |
| 933 | ||
| 934 | it.effect("fetches layer titles only when the caller asks for stack details", () => | |
| 935 | Effect.gen(function* () { | |
| 936 | const minimal = { | |
| 937 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 938 | number: 3, | |
| 939 | base: { ref: "main" }, | |
| 940 | pull_requests: [ | |
| 941 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | |
| 942 | ], | |
| 943 | }; | |
| 944 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); | |
| 945 | mockedExecute.mockReturnValueOnce( | |
| 946 | Effect.succeed( | |
| 947 | output( | |
| 948 | encodeJson({ | |
| 949 | ...minimal, | |
| 950 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | |
| 951 | }), | |
| 952 | ), | |
| 953 | ), | |
| 954 | ); | |
| 955 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 956 | const stack = yield* cli.getPullRequestStack({ | |
| 957 | cwd: "/w", | |
| 958 | repository: "acme/web", | |
| 959 | host: "github.com", | |
| 960 | number: 7, | |
| 961 | includeDetails: true, | |
| 962 | }); | |
| 963 | expect(stack?.layers[0]).toMatchObject({ | |
| 964 | title: "Second layer", | |
| 965 | headSha: "abc123", | |
| 966 | isDraft: false, | |
| 967 | }); | |
| 968 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); | |
| 969 | }), | |
| 970 | ); | |
| 971 | ||
| 972 | it.effect("reads an empty stacks listing as not stacked", () => | |
| 973 | Effect.gen(function* () { | |
| 974 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 975 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 976 | ||
| 977 | const stack = yield* cli.getPullRequestStack({ | |
| 978 | cwd: "/w", | |
| 979 | repository: "acme/web", | |
| 980 | host: "github.com", | |
| 981 | number: 7, | |
| 982 | }); | |
| 983 | ||
| 984 | assert.isNull(stack); | |
| 985 | }), | |
| 986 | ); | |
| 987 | ||
| 988 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | |
| 989 | Effect.gen(function* () { | |
| 990 | mockedExecute.mockReturnValueOnce( | |
| 991 | Effect.fail( | |
| 992 | new GitHubApi.GitHubApiNotFoundError({ | |
| 993 | host: "github.com", | |
| 994 | operation: "getPullRequestStack", | |
| 995 | }), | |
| 996 | ), | |
| 997 | ); | |
| 998 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 999 | ||
| 1000 | const stack = yield* cli.getPullRequestStack({ | |
| 1001 | cwd: "/w", | |
| 1002 | repository: "acme/web", | |
| 1003 | host: "github.com", | |
| 1004 | number: 7, | |
| 1005 | }); | |
| 1006 | ||
| 1007 | assert.isNull(stack); | |
| 1008 | }), | |
| 1009 | ); | |
| 1010 | ||
| 1011 | it.effect("does not read a refused credential as an unstacked pull request", () => | |
| 1012 | Effect.gen(function* () { | |
| 1013 | mockedExecute.mockReturnValueOnce( | |
| 1014 | Effect.fail( | |
| 1015 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 1016 | host: "github.com", | |
| 1017 | operation: "getPullRequestStack", | |
| 1018 | }), | |
| 1019 | ), | |
| 1020 | ); | |
| 1021 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1022 | ||
| 1023 | const error = yield* Effect.flip( | |
| 1024 | cli.getPullRequestStack({ | |
| 1025 | cwd: "/w", | |
| 1026 | repository: "acme/web", | |
| 1027 | host: "github.com", | |
| 1028 | number: 7, | |
| 1029 | }), | |
| 1030 | ); | |
| 1031 | ||
| 1032 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); | |
| 1033 | }), | |
| 1034 | ); | |
| 1035 | ||
| 1036 | it.effect("preserves transient stack failures instead of reporting no stack", () => | |
| 1037 | Effect.gen(function* () { | |
| 1038 | const failure = new GitHubApi.GitHubApiResponseError({ | |
| 1039 | host: "github.com", | |
| 1040 | operation: "getPullRequestStack", | |
| 1041 | status: 503, | |
| 1042 | }); | |
| 1043 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | |
| 1044 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1045 | const error = yield* Effect.flip( | |
| 1046 | cli.getPullRequestStack({ | |
| 1047 | cwd: "/w", | |
| 1048 | repository: "acme/web", | |
| 1049 | host: "github.com", | |
| 1050 | number: 7, | |
| 1051 | }), | |
| 1052 | ); | |
| 1053 | assert.strictEqual(error, failure); | |
| 1054 | }), | |
| 1055 | ); | |
| 1056 | ||
| 1057 | it.effect("reports a stacks answer it cannot read against the stack read", () => | |
| 1058 | Effect.gen(function* () { | |
| 1059 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); | |
| 1060 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1061 | ||
| 1062 | const error = yield* Effect.flip( | |
| 1063 | cli.getPullRequestStack({ | |
| 1064 | cwd: "/w", | |
| 1065 | repository: "acme/web", | |
| 1066 | host: "github.com", | |
| 1067 | number: 7, | |
| 1068 | }), | |
| 1069 | ); | |
| 1070 | ||
| 1071 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 1072 | if (error._tag !== "GitHubPullRequestReadError") return; | |
| 1073 | assert.strictEqual(error.operation, "getPullRequestStack"); | |
| 1074 | }), | |
| 1075 | ); | |
| 1076 | ||
| 1077 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 1078 | Effect.gen(function* () { | |
| 1079 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1080 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1081 | ||
| 1082 | const batch = yield* cli.listPullRequests({ | |
| 1083 | cwd: "/w", | |
| 1084 | repository: "acme/web", | |
| 1085 | host: "github.com", | |
| 1086 | state: "open", | |
| 1087 | involvement: "all", | |
| 1088 | viewer: "bilal", | |
| 1089 | limit: 10, | |
| 1090 | }); | |
| 1091 | ||
| 1092 | assert.strictEqual(batch.items.length, 3); | |
| 1093 | assert.isFalse(batch.truncated); | |
| 1094 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); | |
| 1095 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1096 | expect(queryAt(0)).toContain("first: 11"); | |
| 1097 | }), | |
| 1098 | ); | |
| 1099 | ||
| 1100 | it.effect("reports truncation from the extra row, counted before decoding", () => | |
| 1101 | Effect.gen(function* () { | |
| 1102 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 1103 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1104 | ||
| 1105 | const batch = yield* cli.listPullRequests({ | |
| 1106 | cwd: "/w", | |
| 1107 | repository: "acme/web", | |
| 1108 | host: "github.com", | |
| 1109 | state: "open", | |
| 1110 | involvement: "all", | |
| 1111 | viewer: "bilal", | |
| 1112 | limit: 10, | |
| 1113 | }); | |
| 1114 | ||
| 1115 | assert.strictEqual(batch.items.length, 10); | |
| 1116 | assert.isTrue(batch.truncated); | |
| 1117 | }), | |
| 1118 | ); | |
| 1119 | ||
| 1120 | it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => | |
| 1121 | Effect.gen(function* () { | |
| 1122 | // Two pages of 100 that hand back the same cursor, for a page of 250. | |
| 1123 | const repeating = output( | |
| 1124 | encodeJson({ | |
| 1125 | data: { | |
| 1126 | search: { | |
| 1127 | pageInfo: { hasNextPage: true, endCursor: "same" }, | |
| 1128 | nodes: rows(100, 1), | |
| 1129 | }, | |
| 1130 | }, | |
| 1131 | }), | |
| 1132 | ); | |
| 1133 | mockedExecute.mockReturnValue(Effect.succeed(repeating)); | |
| 1134 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1135 | ||
| 1136 | const batch = yield* cli.listPullRequests({ | |
| 1137 | cwd: "/w", | |
| 1138 | repository: "acme/web", | |
| 1139 | host: "github.com", | |
| 1140 | state: "open", | |
| 1141 | involvement: "all", | |
| 1142 | viewer: "bilal", | |
| 1143 | limit: 250, | |
| 1144 | }); | |
| 1145 | ||
| 1146 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1147 | assert.isTrue(batch.truncated); | |
| 1148 | }), | |
| 1149 | ); | |
| 1150 | ||
| 1151 | it.effect("excludes merged pull requests from the Closed tab", () => | |
| 1152 | Effect.gen(function* () { | |
| 1153 | mockedExecute.mockImplementation((call) => | |
| 1154 | Effect.succeed( | |
| 1155 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1156 | ? emptyList() | |
| 1157 | : emptySearch(), | |
| 1158 | ), | |
| 1159 | ); | |
| 1160 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1161 | ||
| 1162 | yield* cli.listPullRequests({ | |
| 1163 | cwd: "/w", | |
| 1164 | repository: "acme/web", | |
| 1165 | host: "github.com", | |
| 1166 | state: "closed", | |
| 1167 | involvement: "all", | |
| 1168 | viewer: "bilal", | |
| 1169 | limit: 10, | |
| 1170 | }); | |
| 1171 | ||
| 1172 | // A closed pull request may also be a merged one, so the tab narrows through search. | |
| 1173 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); | |
| 1174 | }), | |
| 1175 | ); | |
| 1176 | ||
| 1177 | it.effect("narrows to the author on the authored tab", () => | |
| 1178 | Effect.gen(function* () { | |
| 1179 | mockedExecute.mockImplementation((call) => | |
| 1180 | Effect.succeed( | |
| 1181 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1182 | ? emptyList() | |
| 1183 | : emptySearch(), | |
| 1184 | ), | |
| 1185 | ); | |
| 1186 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1187 | ||
| 1188 | yield* cli.listPullRequests({ | |
| 1189 | cwd: "/w", | |
| 1190 | repository: "acme/web", | |
| 1191 | host: "github.com", | |
| 1192 | state: "open", | |
| 1193 | involvement: "authored", | |
| 1194 | viewer: "bilal", | |
| 1195 | limit: 10, | |
| 1196 | }); | |
| 1197 | ||
| 1198 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); | |
| 1199 | }), | |
| 1200 | ); | |
| 1201 | ||
| 1202 | it.effect("narrows through search on the reviewing tab", () => | |
| 1203 | Effect.gen(function* () { | |
| 1204 | mockedExecute.mockImplementation((call) => | |
| 1205 | Effect.succeed( | |
| 1206 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1207 | ? emptyList() | |
| 1208 | : emptySearch(), | |
| 1209 | ), | |
| 1210 | ); | |
| 1211 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1212 | ||
| 1213 | yield* cli.listPullRequests({ | |
| 1214 | cwd: "/w", | |
| 1215 | repository: "acme/web", | |
| 1216 | host: "github.com", | |
| 1217 | state: "open", | |
| 1218 | involvement: "reviewing", | |
| 1219 | viewer: "bilal", | |
| 1220 | limit: 10, | |
| 1221 | }); | |
| 1222 | ||
| 1223 | expect(searchOfCall(0)).toBe( | |
| 1224 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", | |
| 1225 | ); | |
| 1226 | }), | |
| 1227 | ); | |
| 1228 | ||
| 1229 | it.effect("carries every repository and every qualifier into one search", () => | |
| 1230 | Effect.gen(function* () { | |
| 1231 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1232 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1233 | ||
| 1234 | yield* cli.searchPullRequests({ | |
| 1235 | cwd: "/w", | |
| 1236 | host: "github.com", | |
| 1237 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1238 | state: "closed", | |
| 1239 | involvement: "reviewing", | |
| 1240 | viewer: "bilal", | |
| 1241 | limit: 10, | |
| 1242 | query: "pull requests page", | |
| 1243 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1244 | }); | |
| 1245 | ||
| 1246 | // One request for both repositories, carrying everything the per-repository read expresses | |
| 1247 | // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the | |
| 1248 | // order the page reads in. | |
| 1249 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1250 | assert.strictEqual( | |
| 1251 | searchQueryOfCall(0), | |
| 1252 | 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + | |
| 1253 | "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", | |
| 1254 | ); | |
| 1255 | }), | |
| 1256 | ); | |
| 1257 | ||
| 1258 | it.effect("narrows a search to the author, and to merged on the merged tab", () => | |
| 1259 | Effect.gen(function* () { | |
| 1260 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1261 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1262 | ||
| 1263 | yield* cli.searchPullRequests({ | |
| 1264 | cwd: "/w", | |
| 1265 | host: "github.com", | |
| 1266 | repositories: ["acme/web"], | |
| 1267 | state: "merged", | |
| 1268 | involvement: "authored", | |
| 1269 | viewer: "bilal", | |
| 1270 | limit: 10, | |
| 1271 | }); | |
| 1272 | ||
| 1273 | assert.strictEqual( | |
| 1274 | searchQueryOfCall(0), | |
| 1275 | "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", | |
| 1276 | ); | |
| 1277 | }), | |
| 1278 | ); | |
| 1279 | ||
| 1280 | it.effect("keeps a searched-for qualifier inside the phrase", () => | |
| 1281 | Effect.gen(function* () { | |
| 1282 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1283 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1284 | ||
| 1285 | yield* cli.searchPullRequests({ | |
| 1286 | cwd: "/w", | |
| 1287 | host: "github.com", | |
| 1288 | repositories: ["acme/web"], | |
| 1289 | state: "open", | |
| 1290 | involvement: "all", | |
| 1291 | viewer: "bilal", | |
| 1292 | limit: 10, | |
| 1293 | query: 'x" is:merged repo:evil/repo', | |
| 1294 | }); | |
| 1295 | ||
| 1296 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. | |
| 1297 | assert.strictEqual( | |
| 1298 | searchQueryOfCall(0), | |
| 1299 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', | |
| 1300 | ); | |
| 1301 | }), | |
| 1302 | ); | |
| 1303 | ||
| 1304 | it.effect("refuses to search for a repository GitHub cannot address", () => | |
| 1305 | Effect.gen(function* () { | |
| 1306 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1307 | ||
| 1308 | const failure = yield* Effect.flip( | |
| 1309 | cli.searchPullRequests({ | |
| 1310 | cwd: "/w", | |
| 1311 | host: "github.com", | |
| 1312 | repositories: ["acme/web", "acme/web is:merged"], | |
| 1313 | state: "open", | |
| 1314 | involvement: "all", | |
| 1315 | viewer: "bilal", | |
| 1316 | limit: 10, | |
| 1317 | }), | |
| 1318 | ); | |
| 1319 | ||
| 1320 | // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. | |
| 1321 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1322 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1323 | }), | |
| 1324 | ); | |
| 1325 | ||
| 1326 | it.effect("files each searched row under the repository it came from", () => | |
| 1327 | Effect.gen(function* () { | |
| 1328 | mockedExecute.mockReturnValue( | |
| 1329 | Effect.succeed( | |
| 1330 | searchPage([ | |
| 1331 | searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1332 | searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), | |
| 1333 | // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. | |
| 1334 | {}, | |
| 1335 | ]), | |
| 1336 | ), | |
| 1337 | ); | |
| 1338 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1339 | ||
| 1340 | const batch = yield* cli.searchPullRequests({ | |
| 1341 | cwd: "/w", | |
| 1342 | host: "github.com", | |
| 1343 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1344 | state: "open", | |
| 1345 | involvement: "all", | |
| 1346 | viewer: "bilal", | |
| 1347 | limit: 10, | |
| 1348 | }); | |
| 1349 | ||
| 1350 | assert.deepStrictEqual( | |
| 1351 | batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), | |
| 1352 | [ | |
| 1353 | ["acme/web", 7, "https://avatars/octocat"], | |
| 1354 | ["pingdotgg/t3code", 9, "https://avatars/octocat"], | |
| 1355 | ], | |
| 1356 | ); | |
| 1357 | // The listing leaves the line counts to a read of their own. | |
| 1358 | assert.deepStrictEqual( | |
| 1359 | batch.items.map((item) => [item.additions, item.deletions]), | |
| 1360 | [ | |
| 1361 | [0, 0], | |
| 1362 | [0, 0], | |
| 1363 | ], | |
| 1364 | ); | |
| 1365 | assert.isFalse(batch.truncated); | |
| 1366 | }), | |
| 1367 | ); | |
| 1368 | ||
| 1369 | it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => | |
| 1370 | Effect.gen(function* () { | |
| 1371 | mockedExecute | |
| 1372 | .mockReturnValueOnce( | |
| 1373 | Effect.succeed( | |
| 1374 | searchPage([ | |
| 1375 | searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1376 | searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), | |
| 1377 | searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), | |
| 1378 | ]), | |
| 1379 | ), | |
| 1380 | ) | |
| 1381 | .mockReturnValueOnce( | |
| 1382 | Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), | |
| 1383 | ); | |
| 1384 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1385 | const read = () => | |
| 1386 | cli.searchPullRequests({ | |
| 1387 | cwd: "/w", | |
| 1388 | host: "github.com", | |
| 1389 | repositories: ["acme/web"], | |
| 1390 | state: "open", | |
| 1391 | involvement: "all", | |
| 1392 | viewer: "bilal", | |
| 1393 | limit: 2, | |
| 1394 | }); | |
| 1395 | ||
| 1396 | const overflowing = yield* read(); | |
| 1397 | const capped = yield* read(); | |
| 1398 | ||
| 1399 | // The extra row is the probe, and it is not handed on. | |
| 1400 | assert.strictEqual(overflowing.items.length, 2); | |
| 1401 | assert.isTrue(overflowing.truncated); | |
| 1402 | // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. | |
| 1403 | assert.isTrue(capped.truncated); | |
| 1404 | }), | |
| 1405 | ); | |
| 1406 | ||
| 1407 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | |
| 1408 | Effect.gen(function* () { | |
| 1409 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1410 | mockedExecute.mockReturnValueOnce( | |
| 1411 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1412 | ); | |
| 1413 | mockedExecute.mockReturnValueOnce( | |
| 1414 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | |
| 1415 | ); | |
| 1416 | mockedStackMemberships.mockReturnValueOnce( | |
| 1417 | Effect.succeed( | |
| 1418 | output( | |
| 1419 | encodeJson({ | |
| 1420 | data: { | |
| 1421 | s0: { | |
| 1422 | pullRequest: { | |
| 1423 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1424 | stackEntry: { position: 1 }, | |
| 1425 | }, | |
| 1426 | }, | |
| 1427 | s1: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1428 | }, | |
| 1429 | }), | |
| 1430 | ), | |
| 1431 | ), | |
| 1432 | ); | |
| 1433 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1434 | const batch = yield* cli.listPullRequests({ | |
| 1435 | cwd: "/w", | |
| 1436 | repository: "acme/web", | |
| 1437 | host: "github.com", | |
| 1438 | state: "open", | |
| 1439 | involvement: "all", | |
| 1440 | viewer: "bilal", | |
| 1441 | limit: 2, | |
| 1442 | filters: { draft: "hide" }, | |
| 1443 | }); | |
| 1444 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); | |
| 1445 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1446 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 1447 | expect(batch.truncated).toBe(true); | |
| 1448 | expect(batch.continues).toBe(false); | |
| 1449 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | |
| 1450 | const membership = mockedStackMemberships.mock.calls[0]?.[0]; | |
| 1451 | const numbers = Object.entries( | |
| 1452 | membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, | |
| 1453 | ) | |
| 1454 | .filter(([name]) => name.endsWith("_number")) | |
| 1455 | .map(([, number]) => number); | |
| 1456 | expect(numbers).toEqual([4, 5]); | |
| 1457 | }), | |
| 1458 | ); | |
| 1459 | ||
| 1460 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => | |
| 1461 | Effect.gen(function* () { | |
| 1462 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | |
| 1463 | mockedStackMemberships.mockImplementation((input) => | |
| 1464 | input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) | |
| 1465 | ? Effect.fail( | |
| 1466 | new GitHubApi.GitHubApiResponseError({ | |
| 1467 | host: "github.com", | |
| 1468 | operation: "listPullRequestStackMemberships", | |
| 1469 | status: 502, | |
| 1470 | }), | |
| 1471 | ) | |
| 1472 | : Effect.succeed( | |
| 1473 | output( | |
| 1474 | encodeJson({ | |
| 1475 | data: { | |
| 1476 | s0: { | |
| 1477 | pullRequest: { | |
| 1478 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1479 | stackEntry: { position: 1 }, | |
| 1480 | }, | |
| 1481 | }, | |
| 1482 | }, | |
| 1483 | }), | |
| 1484 | ), | |
| 1485 | ), | |
| 1486 | ); | |
| 1487 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1488 | const batch = yield* cli.listPullRequests({ | |
| 1489 | cwd: "/w", | |
| 1490 | repository: "acme/web", | |
| 1491 | host: "github.com", | |
| 1492 | state: "open", | |
| 1493 | involvement: "all", | |
| 1494 | viewer: "bilal", | |
| 1495 | limit: 26, | |
| 1496 | }); | |
| 1497 | expect(batch.items.map((item) => item.number)).toEqual( | |
| 1498 | Array.from({ length: 26 }, (_, i) => i + 1), | |
| 1499 | ); | |
| 1500 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1501 | expect(batch.items[25]?.stack).toBeUndefined(); | |
| 1502 | expect(batch.truncated).toBe(true); | |
| 1503 | expect(batch.continues).toBe(true); | |
| 1504 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); | |
| 1505 | }), | |
| 1506 | ); | |
| 1507 | ||
| 1508 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | |
| 1509 | Effect.gen(function* () { | |
| 1510 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1511 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); | |
| 1512 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | |
| 1513 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1514 | const input = { | |
| 1515 | cwd: "/w", | |
| 1516 | repository: "acme/web", | |
| 1517 | state: "open" as const, | |
| 1518 | involvement: "all" as const, | |
| 1519 | viewer: "bilal", | |
| 1520 | limit: 2, | |
| 1521 | }; | |
| 1522 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); | |
| 1523 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); | |
| 1524 | expect(empty.items).toEqual([]); | |
| 1525 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); | |
| 1526 | expect(mockedStackMemberships).not.toHaveBeenCalled(); | |
| 1527 | }), | |
| 1528 | ); | |
| 1529 | ||
| 1530 | it.effect("reads the line counts in chunks, and files them back by position", () => | |
| 1531 | Effect.gen(function* () { | |
| 1532 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ | |
| 1533 | repository: "acme/web", | |
| 1534 | number: index + 1, | |
| 1535 | })); | |
| 1536 | mockedExecute.mockImplementation(() => | |
| 1537 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is | |
| 1538 | // dropped rather than shown as a change of no size. | |
| 1539 | Effect.succeed( | |
| 1540 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), | |
| 1541 | ), | |
| 1542 | ); | |
| 1543 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1544 | ||
| 1545 | const stats = yield* cli.listPullRequestStats({ | |
| 1546 | cwd: "/w", | |
| 1547 | host: "github.com", | |
| 1548 | changeRequests, | |
| 1549 | }); | |
| 1550 | ||
| 1551 | // Twenty-five aliases a request, so twenty-six rows are two requests. | |
| 1552 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1553 | assert.deepStrictEqual(stats, [ | |
| 1554 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, | |
| 1555 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, | |
| 1556 | ]); | |
| 1557 | expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); | |
| 1558 | expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); | |
| 1559 | }), | |
| 1560 | ); | |
| 1561 | ||
| 1562 | it.effect("refuses to look up counts for a repository GitHub cannot address", () => | |
| 1563 | Effect.gen(function* () { | |
| 1564 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1565 | ||
| 1566 | const failure = yield* Effect.flip( | |
| 1567 | cli.listPullRequestStats({ | |
| 1568 | cwd: "/w", | |
| 1569 | host: "github.com", | |
| 1570 | changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], | |
| 1571 | }), | |
| 1572 | ); | |
| 1573 | ||
| 1574 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1575 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1576 | }), | |
| 1577 | ); | |
| 1578 | ||
| 1579 | it.effect("hands a search to GitHub rather than to the rows already read", () => | |
| 1580 | Effect.gen(function* () { | |
| 1581 | mockedExecute.mockImplementation((call) => | |
| 1582 | Effect.succeed( | |
| 1583 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1584 | ? emptyList() | |
| 1585 | : emptySearch(), | |
| 1586 | ), | |
| 1587 | ); | |
| 1588 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1589 | ||
| 1590 | yield* cli.listPullRequests({ | |
| 1591 | cwd: "/w", | |
| 1592 | repository: "acme/web", | |
| 1593 | host: "github.com", | |
| 1594 | state: "open", | |
| 1595 | involvement: "all", | |
| 1596 | viewer: "bilal", | |
| 1597 | limit: 10, | |
| 1598 | query: "pull requests page", | |
| 1599 | }); | |
| 1600 | ||
| 1601 | // The recency qualifier rides along, because free text would otherwise reorder the page | |
| 1602 | // by relevance and truncation would drop the newest matches. | |
| 1603 | expect(searchOfCall(0)).toBe( | |
| 1604 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', | |
| 1605 | ); | |
| 1606 | }), | |
| 1607 | ); | |
| 1608 | ||
| 1609 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => | |
| 1610 | Effect.gen(function* () { | |
| 1611 | mockedExecute.mockImplementation((call) => | |
| 1612 | Effect.succeed( | |
| 1613 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1614 | ? emptyList() | |
| 1615 | : emptySearch(), | |
| 1616 | ), | |
| 1617 | ); | |
| 1618 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1619 | ||
| 1620 | yield* cli.listPullRequests({ | |
| 1621 | cwd: "/w", | |
| 1622 | repository: "acme/web", | |
| 1623 | host: "github.com", | |
| 1624 | state: "closed", | |
| 1625 | involvement: "reviewing", | |
| 1626 | viewer: "bilal", | |
| 1627 | limit: 10, | |
| 1628 | query: "page", | |
| 1629 | }); | |
| 1630 | ||
| 1631 | expect(searchOfCall(0)).toBe( | |
| 1632 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', | |
| 1633 | ); | |
| 1634 | }), | |
| 1635 | ); | |
| 1636 | ||
| 1637 | it.effect("carries the further narrowings into the search as qualifiers", () => | |
| 1638 | Effect.gen(function* () { | |
| 1639 | mockedExecute.mockImplementation((call) => | |
| 1640 | Effect.succeed( | |
| 1641 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1642 | ? emptyList() | |
| 1643 | : emptySearch(), | |
| 1644 | ), | |
| 1645 | ); | |
| 1646 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1647 | ||
| 1648 | yield* cli.listPullRequests({ | |
| 1649 | cwd: "/w", | |
| 1650 | repository: "acme/web", | |
| 1651 | host: "github.com", | |
| 1652 | state: "open", | |
| 1653 | involvement: "all", | |
| 1654 | viewer: "bilal", | |
| 1655 | limit: 10, | |
| 1656 | filters: { | |
| 1657 | draft: "hide", | |
| 1658 | review: "changes-requested", | |
| 1659 | checks: "failing", | |
| 1660 | labels: [["needs design"], ['quo"te']], | |
| 1661 | excludedLabels: ["wip"], | |
| 1662 | author: "octocat", | |
| 1663 | }, | |
| 1664 | }); | |
| 1665 | ||
| 1666 | // Quotes around anything a reader typed, and the one character that could end a quoted | |
| 1667 | // value early dropped rather than escaped. | |
| 1668 | expect(searchOfCall(0)).toBe( | |
| 1669 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + | |
| 1670 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", | |
| 1671 | ); | |
| 1672 | }), | |
| 1673 | ); | |
| 1674 | ||
| 1675 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => | |
| 1676 | Effect.gen(function* () { | |
| 1677 | mockedExecute.mockImplementation((call) => | |
| 1678 | Effect.succeed( | |
| 1679 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1680 | ? emptyList() | |
| 1681 | : emptySearch(), | |
| 1682 | ), | |
| 1683 | ); | |
| 1684 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1685 | ||
| 1686 | yield* cli.listPullRequests({ | |
| 1687 | cwd: "/w", | |
| 1688 | repository: "acme/web", | |
| 1689 | host: "github.com", | |
| 1690 | state: "open", | |
| 1691 | involvement: "all", | |
| 1692 | viewer: "bilal", | |
| 1693 | limit: 10, | |
| 1694 | filters: { author: "me" }, | |
| 1695 | }); | |
| 1696 | ||
| 1697 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); | |
| 1698 | }), | |
| 1699 | ); | |
| 1700 | ||
| 1701 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => | |
| 1702 | Effect.gen(function* () { | |
| 1703 | mockedExecute.mockImplementation((call) => | |
| 1704 | Effect.succeed( | |
| 1705 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1706 | ? emptyList() | |
| 1707 | : emptySearch(), | |
| 1708 | ), | |
| 1709 | ); | |
| 1710 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1711 | ||
| 1712 | yield* cli.listPullRequests({ | |
| 1713 | cwd: "/w", | |
| 1714 | repository: "acme/web", | |
| 1715 | host: "github.com", | |
| 1716 | state: "open", | |
| 1717 | involvement: "all", | |
| 1718 | viewer: "bilal", | |
| 1719 | limit: 10, | |
| 1720 | filters: { labels: [["size:S", "size:XS"], ["bug"]] }, | |
| 1721 | }); | |
| 1722 | ||
| 1723 | // One qualifier satisfied by either size, and a second one that must hold as well. | |
| 1724 | expect(searchOfCall(0)).toBe( | |
| 1725 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', | |
| 1726 | ); | |
| 1727 | }), | |
| 1728 | ); | |
| 1729 | ||
| 1730 | it.effect( | |
| 1731 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", | |
| 1732 | () => | |
| 1733 | Effect.gen(function* () { | |
| 1734 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1735 | mockedExecute.mockReturnValueOnce( | |
| 1736 | Effect.succeed( | |
| 1737 | output( | |
| 1738 | listedPullRequests(2, 1, (number) => ({ | |
| 1739 | checks: number === 1 ? "SUCCESS" : "FAILURE", | |
| 1740 | })), | |
| 1741 | ), | |
| 1742 | ), | |
| 1743 | ); | |
| 1744 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1745 | ||
| 1746 | const batch = yield* cli.listPullRequests({ | |
| 1747 | cwd: "/w", | |
| 1748 | repository: "acme/web", | |
| 1749 | host: "github.com", | |
| 1750 | state: "open", | |
| 1751 | involvement: "all", | |
| 1752 | viewer: "bilal", | |
| 1753 | limit: 10, | |
| 1754 | filters: { checks: "passing" }, | |
| 1755 | }); | |
| 1756 | ||
| 1757 | // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is | |
| 1758 | // now a filter the fallback judges itself, the same as `draft`: an empty search answer | |
| 1759 | // under it is still ambiguous, and the row picked out afterwards is the one whose own | |
| 1760 | // `checksState` reads "passing". | |
| 1761 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1762 | assert.deepStrictEqual( | |
| 1763 | batch.items.map((item) => item.number), | |
| 1764 | [1], | |
| 1765 | ); | |
| 1766 | }), | |
| 1767 | ); | |
| 1768 | ||
| 1769 | it.effect("fails a checks filter for a row whose checks are still pending", () => | |
| 1770 | Effect.gen(function* () { | |
| 1771 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1772 | mockedExecute.mockReturnValueOnce( | |
| 1773 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), | |
| 1774 | ); | |
| 1775 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1776 | ||
| 1777 | const batch = yield* cli.listPullRequests({ | |
| 1778 | cwd: "/w", | |
| 1779 | repository: "acme/web", | |
| 1780 | host: "github.com", | |
| 1781 | state: "open", | |
| 1782 | involvement: "all", | |
| 1783 | viewer: "bilal", | |
| 1784 | limit: 10, | |
| 1785 | filters: { checks: "passing" }, | |
| 1786 | }); | |
| 1787 | ||
| 1788 | // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — | |
| 1789 | // the same row would also be dropped by `checks: "failing"`. | |
| 1790 | assert.deepStrictEqual(batch.items, []); | |
| 1791 | }), | |
| 1792 | ); | |
| 1793 | ||
| 1794 | it.effect( | |
| 1795 | "falls back for a repository the index does not cover even under a judgeable filter", | |
| 1796 | () => | |
| 1797 | Effect.gen(function* () { | |
| 1798 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1799 | mockedExecute.mockReturnValueOnce( | |
| 1800 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), | |
| 1801 | ); | |
| 1802 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1803 | ||
| 1804 | const batch = yield* cli.listPullRequests({ | |
| 1805 | cwd: "/w", | |
| 1806 | repository: "acme/web", | |
| 1807 | host: "github.com", | |
| 1808 | state: "open", | |
| 1809 | involvement: "all", | |
| 1810 | viewer: "bilal", | |
| 1811 | limit: 10, | |
| 1812 | filters: { draft: "hide" }, | |
| 1813 | }); | |
| 1814 | ||
| 1815 | // `draft` is a filter the fallback can judge over its own rows just as search judges it, | |
| 1816 | // so an empty search answer under it alone is still ambiguous between "nothing matches" | |
| 1817 | // and "this repository is not indexed" — and the fallback applies the filter itself, | |
| 1818 | // keeping only the non-draft row. | |
| 1819 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1820 | expect(batch.items.map((item) => item.number)).toEqual([2]); | |
| 1821 | }), | |
| 1822 | ); | |
| 1823 | ||
| 1824 | it.effect("carries the further narrowings into a batched search", () => | |
| 1825 | Effect.gen(function* () { | |
| 1826 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1827 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1828 | ||
| 1829 | yield* cli.searchPullRequests({ | |
| 1830 | cwd: "/w", | |
| 1831 | host: "github.com", | |
| 1832 | repositories: ["acme/web"], | |
| 1833 | state: "open", | |
| 1834 | involvement: "all", | |
| 1835 | viewer: "bilal", | |
| 1836 | limit: 10, | |
| 1837 | filters: { draft: "only", review: "none", labels: [["bug"]] }, | |
| 1838 | }); | |
| 1839 | ||
| 1840 | assert.strictEqual( | |
| 1841 | searchQueryOfCall(0), | |
| 1842 | 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', | |
| 1843 | ); | |
| 1844 | }), | |
| 1845 | ); | |
| 1846 | ||
| 1847 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => | |
| 1848 | Effect.gen(function* () { | |
| 1849 | mockedExecute.mockImplementation((call) => | |
| 1850 | Effect.succeed( | |
| 1851 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1852 | ? emptyList() | |
| 1853 | : emptySearch(), | |
| 1854 | ), | |
| 1855 | ); | |
| 1856 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1857 | ||
| 1858 | yield* cli.listPullRequests({ | |
| 1859 | cwd: "/w", | |
| 1860 | repository: "acme/web", | |
| 1861 | host: "github.com", | |
| 1862 | state: "open", | |
| 1863 | involvement: "all", | |
| 1864 | viewer: "bilal", | |
| 1865 | limit: 10, | |
| 1866 | query: '-- is:merged label:secret "widen me"', | |
| 1867 | }); | |
| 1868 | ||
| 1869 | // Every word stays inside one phrase: nothing before it, nothing after it, and the | |
| 1870 | // leading dashes are text rather than the start of another argument. | |
| 1871 | expect(searchOfCall(0)).toBe( | |
| 1872 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, | |
| 1873 | ); | |
| 1874 | }), | |
| 1875 | ); | |
| 1876 | ||
| 1877 | it.effect("escapes a backslash before the quote it would otherwise let out", () => | |
| 1878 | Effect.gen(function* () { | |
| 1879 | mockedExecute.mockImplementation((call) => | |
| 1880 | Effect.succeed( | |
| 1881 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1882 | ? emptyList() | |
| 1883 | : emptySearch(), | |
| 1884 | ), | |
| 1885 | ); | |
| 1886 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1887 | ||
| 1888 | yield* cli.listPullRequests({ | |
| 1889 | cwd: "/w", | |
| 1890 | repository: "acme/web", | |
| 1891 | host: "github.com", | |
| 1892 | state: "open", | |
| 1893 | involvement: "all", | |
| 1894 | viewer: "bilal", | |
| 1895 | limit: 10, | |
| 1896 | query: String.raw`a\" is:merged`, | |
| 1897 | }); | |
| 1898 | ||
| 1899 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where | |
| 1900 | // this says it does; escaping the quote alone would have closed it early. | |
| 1901 | expect(searchOfCall(0)).toBe( | |
| 1902 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, | |
| 1903 | ); | |
| 1904 | }), | |
| 1905 | ); | |
| 1906 | ||
| 1907 | it.effect("asks for nothing but the order when the reader typed only spaces", () => | |
| 1908 | Effect.gen(function* () { | |
| 1909 | mockedExecute.mockImplementation((call) => | |
| 1910 | Effect.succeed( | |
| 1911 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1912 | ? emptyList() | |
| 1913 | : emptySearch(), | |
| 1914 | ), | |
| 1915 | ); | |
| 1916 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1917 | ||
| 1918 | yield* cli.listPullRequests({ | |
| 1919 | cwd: "/w", | |
| 1920 | repository: "acme/web", | |
| 1921 | host: "github.com", | |
| 1922 | state: "open", | |
| 1923 | involvement: "all", | |
| 1924 | viewer: "bilal", | |
| 1925 | limit: 10, | |
| 1926 | query: " ", | |
| 1927 | }); | |
| 1928 | ||
| 1929 | // An empty phrase would match nothing rather than everything, so it is left out; the | |
| 1930 | // order the page reads rows in is asked for whether or not anything was typed. | |
| 1931 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1932 | }), | |
| 1933 | ); | |
| 1934 | ||
| 1935 | it.effect("carries on from the instant the last slice ended on", () => | |
| 1936 | Effect.gen(function* () { | |
| 1937 | mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1938 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1939 | ||
| 1940 | const batch = yield* cli.listPullRequests({ | |
| 1941 | cwd: "/w", | |
| 1942 | repository: "acme/web", | |
| 1943 | host: "github.com", | |
| 1944 | state: "open", | |
| 1945 | involvement: "all", | |
| 1946 | viewer: "bilal", | |
| 1947 | limit: 10, | |
| 1948 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1949 | }); | |
| 1950 | ||
| 1951 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — | |
| 1952 | // which is what keeps the ones beside them from being skipped. | |
| 1953 | expect(searchOfCall(0)).toBe( | |
| 1954 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", | |
| 1955 | ); | |
| 1956 | assert.isTrue(batch.continues); | |
| 1957 | }), | |
| 1958 | ); | |
| 1959 | ||
| 1960 | it.effect("answers a search that found nothing with nothing, not with the whole repository", () => | |
| 1961 | Effect.gen(function* () { | |
| 1962 | // The fallback is for a repository the index does not cover. Under a text search an empty | |
| 1963 | // answer means the text matched nothing, and listing everything instead would fill the | |
| 1964 | // page with rows the reader did not search for. | |
| 1965 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1966 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1967 | ||
| 1968 | const batch = yield* cli.listPullRequests({ | |
| 1969 | cwd: "/w", | |
| 1970 | repository: "acme/web", | |
| 1971 | host: "github.com", | |
| 1972 | state: "open", | |
| 1973 | involvement: "all", | |
| 1974 | viewer: "bilal", | |
| 1975 | limit: 10, | |
| 1976 | query: "fdsfklj", | |
| 1977 | }); | |
| 1978 | ||
| 1979 | assert.strictEqual(batch.items.length, 0); | |
| 1980 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1981 | }), | |
| 1982 | ); | |
| 1983 | ||
| 1984 | it.effect("reads a repository GitHub will not search from its own list", () => | |
| 1985 | Effect.gen(function* () { | |
| 1986 | // GitHub answers for a repository outside its search index with no rows and no error. | |
| 1987 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1988 | mockedExecute.mockReturnValueOnce( | |
| 1989 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1990 | ); | |
| 1991 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1992 | ||
| 1993 | const batch = yield* cli.listPullRequests({ | |
| 1994 | cwd: "/w", | |
| 1995 | repository: "acme/web", | |
| 1996 | host: "github.com", | |
| 1997 | state: "closed", | |
| 1998 | involvement: "all", | |
| 1999 | viewer: "bilal", | |
| 2000 | limit: 10, | |
| 2001 | }); | |
| 2002 | ||
| 2003 | assert.strictEqual(batch.items.length, 3); | |
| 2004 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive | |
| 2005 | // newest-created first, so nothing can carry on from them. | |
| 2006 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2007 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | |
| 2008 | assert.isFalse(batch.continues); | |
| 2009 | }), | |
| 2010 | ); | |
| 2011 | ||
| 2012 | it.effect("keeps state and involvement filters on the search-free fallback", () => | |
| 2013 | Effect.gen(function* () { | |
| 2014 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2015 | mockedExecute.mockReturnValueOnce( | |
| 2016 | Effect.succeed( | |
| 2017 | output( | |
| 2018 | listedPullRequests(4, 1, (number) => ({ | |
| 2019 | state: number === 4 ? "OPEN" : "CLOSED", | |
| 2020 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), | |
| 2021 | reviewRequests: | |
| 2022 | number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], | |
| 2023 | })), | |
| 2024 | ), | |
| 2025 | ), | |
| 2026 | ); | |
| 2027 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2028 | ||
| 2029 | const batch = yield* cli.listPullRequests({ | |
| 2030 | cwd: "/w", | |
| 2031 | repository: "acme/web", | |
| 2032 | host: "github.com", | |
| 2033 | state: "closed", | |
| 2034 | involvement: "reviewing", | |
| 2035 | viewer: "bilal", | |
| 2036 | limit: 10, | |
| 2037 | }); | |
| 2038 | ||
| 2039 | // Individual requests for this viewer and team requests survive. The fallback cannot | |
| 2040 | // resolve team membership, so dropping team-routed reviews would hide legitimate work. | |
| 2041 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 2042 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2043 | assert.isFalse(batch.continues); | |
| 2044 | }), | |
| 2045 | ); | |
| 2046 | ||
| 2047 | it.effect("grows the search-free fallback until it fills the filtered page", () => | |
| 2048 | Effect.gen(function* () { | |
| 2049 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); | |
| 2050 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2051 | mockedExecute.mockReturnValueOnce( | |
| 2052 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | |
| 2053 | ); | |
| 2054 | mockedExecute.mockReturnValueOnce( | |
| 2055 | Effect.succeed( | |
| 2056 | output( | |
| 2057 | listedPullRequests(4, 1, (number) => | |
| 2058 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), | |
| 2059 | ), | |
| 2060 | ), | |
| 2061 | ), | |
| 2062 | ); | |
| 2063 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2064 | ||
| 2065 | const batch = yield* cli.listPullRequests({ | |
| 2066 | cwd: "/w", | |
| 2067 | repository: "acme/web", | |
| 2068 | host: "github.com", | |
| 2069 | state: "open", | |
| 2070 | involvement: "reviewing", | |
| 2071 | viewer: "bilal", | |
| 2072 | limit: 2, | |
| 2073 | }); | |
| 2074 | ||
| 2075 | expect(batch.items.map((item) => item.number)).toEqual([4]); | |
| 2076 | expect(queryAt(1)).toContain("first: 3,"); | |
| 2077 | expect(queryAt(2)).toContain("first: 6,"); | |
| 2078 | assert.isFalse(batch.truncated); | |
| 2079 | }), | |
| 2080 | ); | |
| 2081 | ||
| 2082 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => | |
| 2083 | Effect.gen(function* () { | |
| 2084 | // A repository with far more pull requests than the bound, none of them for this reader. | |
| 2085 | let listed = 0; | |
| 2086 | mockedExecute.mockImplementation((call) => { | |
| 2087 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { | |
| 2088 | return Effect.succeed(emptySearch()); | |
| 2089 | } | |
| 2090 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); | |
| 2091 | const nodes = rows(first, listed + 1, () => ({ | |
| 2092 | reviewRequests: [{ login: "somebody-else" }], | |
| 2093 | })); | |
| 2094 | listed += first; | |
| 2095 | return Effect.succeed( | |
| 2096 | output( | |
| 2097 | encodeJson({ | |
| 2098 | data: { | |
| 2099 | repository: { | |
| 2100 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, | |
| 2101 | }, | |
| 2102 | }, | |
| 2103 | }), | |
| 2104 | ), | |
| 2105 | ); | |
| 2106 | }); | |
| 2107 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2108 | ||
| 2109 | const batch = yield* cli.listPullRequests({ | |
| 2110 | cwd: "/w", | |
| 2111 | repository: "acme/web", | |
| 2112 | host: "github.com", | |
| 2113 | state: "open", | |
| 2114 | involvement: "reviewing", | |
| 2115 | viewer: "bilal", | |
| 2116 | limit: 2, | |
| 2117 | }); | |
| 2118 | ||
| 2119 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. | |
| 2120 | const last = mockedExecute.mock.calls.length - 1; | |
| 2121 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); | |
| 2122 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); | |
| 2123 | assert.strictEqual(batch.items.length, 0); | |
| 2124 | assert.isTrue(batch.truncated); | |
| 2125 | }), | |
| 2126 | ); | |
| 2127 | ||
| 2128 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => | |
| 2129 | Effect.gen(function* () { | |
| 2130 | mockedExecute.mockImplementation((call) => | |
| 2131 | Effect.succeed( | |
| 2132 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2133 | ? emptyList() | |
| 2134 | : emptySearch(), | |
| 2135 | ), | |
| 2136 | ); | |
| 2137 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2138 | ||
| 2139 | yield* cli.listPullRequests({ | |
| 2140 | cwd: "/w", | |
| 2141 | repository: "acme/web", | |
| 2142 | host: "github.com", | |
| 2143 | state: "open", | |
| 2144 | involvement: "all", | |
| 2145 | viewer: "bilal", | |
| 2146 | limit: 10, | |
| 2147 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 2148 | }); | |
| 2149 | ||
| 2150 | // A repository that answered the search once answers it again, so an empty slice under a | |
| 2151 | // cursor is the end of it rather than a repository search cannot reach. | |
| 2152 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2153 | }), | |
| 2154 | ); | |
| 2155 | ||
| 2156 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ | |
| 2157 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 2158 | data: { | |
| 2159 | repository: { | |
| 2160 | pullRequest: { | |
| 2161 | id: "PR_7", | |
| 2162 | headRefOid: "abc123", | |
| 2163 | isMergeQueueEnabled: false, | |
| 2164 | mergeStateStatus: "BLOCKED", | |
| 2165 | baseRef: { compare: { behindBy: 2 } }, | |
| 2166 | ...pullRequest, | |
| 2167 | }, | |
| 2168 | }, | |
| 2169 | }, | |
| 2170 | }); | |
| 2171 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ | |
| 2172 | data: { | |
| 2173 | repository: { | |
| 2174 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, | |
| 2175 | }, | |
| 2176 | }, | |
| 2177 | }); | |
| 2178 | ||
| 2179 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => | |
| 2180 | Effect.gen(function* () { | |
| 2181 | route(["query PullRequestActionState", actionState()]); | |
| 2182 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2183 | ||
| 2184 | yield* cli.runPullRequestAction({ | |
| 2185 | cwd: "/w", | |
| 2186 | repository: "acme/web", | |
| 2187 | host: "github.com", | |
| 2188 | number: 7, | |
| 2189 | action: "update-branch", | |
| 2190 | }); | |
| 2191 | yield* cli.runPullRequestAction({ | |
| 2192 | cwd: "/w", | |
| 2193 | repository: "acme/web", | |
| 2194 | host: "github.com", | |
| 2195 | number: 7, | |
| 2196 | action: "update-branch", | |
| 2197 | updateMethod: "rebase", | |
| 2198 | }); | |
| 2199 | ||
| 2200 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. | |
| 2201 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ | |
| 2202 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, | |
| 2203 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, | |
| 2204 | ]); | |
| 2205 | }), | |
| 2206 | ); | |
| 2207 | ||
| 2208 | it.effect("merges with the strategy it was asked for", () => | |
| 2209 | Effect.gen(function* () { | |
| 2210 | route(["query PullRequestActionState", actionState()]); | |
| 2211 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2212 | ||
| 2213 | yield* cli.runPullRequestAction({ | |
| 2214 | cwd: "/w", | |
| 2215 | repository: "acme/web", | |
| 2216 | host: "github.com", | |
| 2217 | number: 7, | |
| 2218 | action: "merge", | |
| 2219 | mergeMethod: "squash", | |
| 2220 | }); | |
| 2221 | ||
| 2222 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2223 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2224 | ]); | |
| 2225 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2226 | }), | |
| 2227 | ); | |
| 2228 | ||
| 2229 | it.effect.each(["merge", "enable-auto-merge"] as const)( | |
| 2230 | "removes agent credits from the proposed message for %s", | |
| 2231 | (action) => | |
| 2232 | Effect.gen(function* () { | |
| 2233 | route( | |
| 2234 | [ | |
| 2235 | "query PullRequestMergeMessage", | |
| 2236 | mergeMessage( | |
| 2237 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | |
| 2238 | ), | |
| 2239 | ], | |
| 2240 | ["query PullRequestActionState", actionState()], | |
| 2241 | ); | |
| 2242 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2243 | yield* cli.runPullRequestAction({ | |
| 2244 | cwd: "/w", | |
| 2245 | repository: "acme/web", | |
| 2246 | host: "github.com", | |
| 2247 | number: 7, | |
| 2248 | action, | |
| 2249 | mergeMethod: "squash", | |
| 2250 | removeAgentCreditsOnMerge: true, | |
| 2251 | }); | |
| 2252 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); | |
| 2253 | expect( | |
| 2254 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), | |
| 2255 | ).toEqual([ | |
| 2256 | { | |
| 2257 | input: { | |
| 2258 | pullRequestId: "PR_7", | |
| 2259 | mergeMethod: "SQUASH", | |
| 2260 | // Pinned to the head the message was read from, so it cannot describe other commits. | |
| 2261 | expectedHeadOid: "abc123", | |
| 2262 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2263 | }, | |
| 2264 | }, | |
| 2265 | ]); | |
| 2266 | }), | |
| 2267 | ); | |
| 2268 | ||
| 2269 | it.effect.each([ | |
| 2270 | { | |
| 2271 | description: "an unchanged message", | |
| 2272 | body: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2273 | queued: false, | |
| 2274 | }, | |
| 2275 | { | |
| 2276 | description: "a merge queue", | |
| 2277 | body: "Co-authored-by: Claude <noreply@anthropic.com>", | |
| 2278 | queued: true, | |
| 2279 | }, | |
| 2280 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => | |
| 2281 | Effect.gen(function* () { | |
| 2282 | route( | |
| 2283 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], | |
| 2284 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], | |
| 2285 | ); | |
| 2286 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2287 | yield* cli.runPullRequestAction({ | |
| 2288 | cwd: "/w", | |
| 2289 | repository: "acme/web", | |
| 2290 | host: "github.com", | |
| 2291 | number: 7, | |
| 2292 | action: "merge", | |
| 2293 | removeAgentCreditsOnMerge: true, | |
| 2294 | }); | |
| 2295 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); | |
| 2296 | // A merge queue takes the pull request through auto-merge, with its own message. | |
| 2297 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ | |
| 2298 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2299 | ]); | |
| 2300 | }), | |
| 2301 | ); | |
| 2302 | ||
| 2303 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => | |
| 2304 | Effect.gen(function* () { | |
| 2305 | route( | |
| 2306 | [ | |
| 2307 | "query PullRequestMergeMessage", | |
| 2308 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), | |
| 2309 | ], | |
| 2310 | ["query PullRequestActionState", actionState()], | |
| 2311 | ); | |
| 2312 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2313 | yield* cli.runPullRequestAction({ | |
| 2314 | cwd: "/w", | |
| 2315 | repository: "acme/web", | |
| 2316 | host: "github.com", | |
| 2317 | number: 7, | |
| 2318 | action: "merge", | |
| 2319 | removeAgentCreditsOnMerge: true, | |
| 2320 | }); | |
| 2321 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2322 | { | |
| 2323 | input: { | |
| 2324 | pullRequestId: "PR_7", | |
| 2325 | mergeMethod: "MERGE", | |
| 2326 | expectedHeadOid: "abc123", | |
| 2327 | commitBody: "", | |
| 2328 | }, | |
| 2329 | }, | |
| 2330 | ]); | |
| 2331 | }), | |
| 2332 | ); | |
| 2333 | ||
| 2334 | it.effect("does not fetch a message for rebase merges", () => | |
| 2335 | Effect.gen(function* () { | |
| 2336 | route(["query PullRequestActionState", actionState()]); | |
| 2337 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2338 | yield* cli.runPullRequestAction({ | |
| 2339 | cwd: "/w", | |
| 2340 | repository: "acme/web", | |
| 2341 | host: "github.com", | |
| 2342 | number: 7, | |
| 2343 | action: "merge", | |
| 2344 | mergeMethod: "rebase", | |
| 2345 | removeAgentCreditsOnMerge: true, | |
| 2346 | }); | |
| 2347 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); | |
| 2348 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2349 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | |
| 2350 | ]); | |
| 2351 | }), | |
| 2352 | ); | |
| 2353 | ||
| 2354 | it.effect("refuses to merge when the proposed message cannot be read", () => | |
| 2355 | Effect.gen(function* () { | |
| 2356 | route( | |
| 2357 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], | |
| 2358 | ["query PullRequestActionState", actionState()], | |
| 2359 | ); | |
| 2360 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2361 | const result = yield* Effect.result( | |
| 2362 | cli.runPullRequestAction({ | |
| 2363 | cwd: "/w", | |
| 2364 | repository: "acme/web", | |
| 2365 | host: "github.com", | |
| 2366 | number: 7, | |
| 2367 | action: "merge", | |
| 2368 | removeAgentCreditsOnMerge: true, | |
| 2369 | }), | |
| 2370 | ); | |
| 2371 | expect(result._tag).toBe("Failure"); | |
| 2372 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2373 | }), | |
| 2374 | ); | |
| 2375 | ||
| 2376 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | |
| 2377 | Effect.gen(function* () { | |
| 2378 | route(["query PullRequestActionState", actionState()]); | |
| 2379 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2380 | ||
| 2381 | yield* cli.runPullRequestAction({ | |
| 2382 | cwd: "/w", | |
| 2383 | repository: "acme/web", | |
| 2384 | host: "github.com", | |
| 2385 | number: 7, | |
| 2386 | action: "enable-auto-merge", | |
| 2387 | mergeMethod: "squash", | |
| 2388 | }); | |
| 2389 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | |
| 2390 | yield* cli.runPullRequestAction({ | |
| 2391 | cwd: "/w", | |
| 2392 | repository: "acme/web", | |
| 2393 | host: "github.com", | |
| 2394 | number: 7, | |
| 2395 | action: "enable-auto-merge", | |
| 2396 | }); | |
| 2397 | ||
| 2398 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ | |
| 2399 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2400 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2401 | ]); | |
| 2402 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2403 | }), | |
| 2404 | ); | |
| 2405 | ||
| 2406 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => | |
| 2407 | Effect.gen(function* () { | |
| 2408 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); | |
| 2409 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2410 | ||
| 2411 | yield* cli.runPullRequestAction({ | |
| 2412 | cwd: "/w", | |
| 2413 | repository: "acme/web", | |
| 2414 | host: "github.com", | |
| 2415 | number: 7, | |
| 2416 | action: "enable-auto-merge", | |
| 2417 | mergeMethod: "squash", | |
| 2418 | }); | |
| 2419 | ||
| 2420 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2421 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2422 | ]); | |
| 2423 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2424 | }), | |
| 2425 | ); | |
| 2426 | ||
| 2427 | it.effect("takes auto-merge back off without naming a strategy", () => | |
| 2428 | Effect.gen(function* () { | |
| 2429 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2430 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2431 | ||
| 2432 | yield* cli.runPullRequestAction({ | |
| 2433 | cwd: "/w", | |
| 2434 | repository: "acme/web", | |
| 2435 | host: "github.com", | |
| 2436 | number: 7, | |
| 2437 | action: "disable-auto-merge", | |
| 2438 | mergeMethod: "squash", | |
| 2439 | }); | |
| 2440 | ||
| 2441 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2442 | }), | |
| 2443 | ); | |
| 2444 | ||
| 2445 | it.effect("opens a pull request that reverts a merged pull request", () => | |
| 2446 | Effect.gen(function* () { | |
| 2447 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2448 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2449 | ||
| 2450 | yield* cli.runPullRequestAction({ | |
| 2451 | cwd: "/w", | |
| 2452 | repository: "acme/web", | |
| 2453 | host: "github.com", | |
| 2454 | number: 7, | |
| 2455 | action: "revert", | |
| 2456 | }); | |
| 2457 | ||
| 2458 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2459 | }), | |
| 2460 | ); | |
| 2461 | ||
| 2462 | it.effect("does not approve action-required runs for a same-repository pull request", () => | |
| 2463 | Effect.gen(function* () { | |
| 2464 | mockedExecute.mockReturnValueOnce( | |
| 2465 | Effect.succeed( | |
| 2466 | output( | |
| 2467 | encodeJson( | |
| 2468 | coreResponse({ | |
| 2469 | number: 7, | |
| 2470 | title: "Pull request 7", | |
| 2471 | url: "https://github.com/acme/web/pull/7", | |
| 2472 | headRefName: "feat/page", | |
| 2473 | headRefOid: "abc123", | |
| 2474 | isCrossRepository: false, | |
| 2475 | headRepositoryOwner: { login: "acme" }, | |
| 2476 | baseRefName: "main", | |
| 2477 | createdAt: "2026-07-01T00:00:00Z", | |
| 2478 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2479 | }), | |
| 2480 | ), | |
| 2481 | ), | |
| 2482 | ), | |
| 2483 | ); | |
| 2484 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2485 | ||
| 2486 | yield* cli.runPullRequestAction({ | |
| 2487 | cwd: "/w", | |
| 2488 | repository: "acme/web", | |
| 2489 | host: "github.com", | |
| 2490 | number: 7, | |
| 2491 | action: "approve-workflows", | |
| 2492 | }); | |
| 2493 | ||
| 2494 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2495 | }), | |
| 2496 | ); | |
| 2497 | ||
| 2498 | it.effect("finds and approves every workflow waiting on a maintainer", () => | |
| 2499 | Effect.gen(function* () { | |
| 2500 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); | |
| 2501 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2502 | ||
| 2503 | yield* cli.runPullRequestAction({ | |
| 2504 | cwd: "/w", | |
| 2505 | repository: "acme/web", | |
| 2506 | host: "github.com", | |
| 2507 | number: 7, | |
| 2508 | action: "approve-workflows", | |
| 2509 | }); | |
| 2510 | ||
| 2511 | // Open pull requests on this head branch, and runs scoped to this exact head. | |
| 2512 | expect(variablesOf("headRefName: $head")[0]).toEqual({ | |
| 2513 | owner: "acme", | |
| 2514 | name: "web", | |
| 2515 | head: "feat/page", | |
| 2516 | after: null, | |
| 2517 | }); | |
| 2518 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( | |
| 2519 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", | |
| 2520 | ); | |
| 2521 | // Each run is approved only after the head is read again and still lists it. | |
| 2522 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ | |
| 2523 | ["POST", "repos/acme/web/actions/runs/10/approve"], | |
| 2524 | ["POST", "repos/acme/web/actions/runs/11/approve"], | |
| 2525 | ]); | |
| 2526 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); | |
| 2527 | }), | |
| 2528 | ); | |
| 2529 | ||
| 2530 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => | |
| 2531 | Effect.gen(function* () { | |
| 2532 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); | |
| 2533 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2534 | ||
| 2535 | const runs = yield* cli | |
| 2536 | .listWorkflowRunsRequiringApproval({ | |
| 2537 | cwd: "/w", | |
| 2538 | repository: "acme/web", | |
| 2539 | host: "github.com", | |
| 2540 | number: 7, | |
| 2541 | headSha: "abc123", | |
| 2542 | headBranch: "feat/page", | |
| 2543 | headRepositoryOwner: "octocat", | |
| 2544 | isCrossRepository: true, | |
| 2545 | }) | |
| 2546 | .pipe( | |
| 2547 | Effect.provideService(KnownWorkflowRuns, { | |
| 2548 | headSha: "abc123", | |
| 2549 | runs: [ | |
| 2550 | // Live shape: waiting on approval is completed + action_required. | |
| 2551 | { | |
| 2552 | id: 10, | |
| 2553 | status: "completed", | |
| 2554 | conclusion: "action_required", | |
| 2555 | head_branch: "feat/page", | |
| 2556 | }, | |
| 2557 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, | |
| 2558 | ], | |
| 2559 | }), | |
| 2560 | ); | |
| 2561 | ||
| 2562 | expect(runs.map((run) => run.id)).toEqual([10]); | |
| 2563 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); | |
| 2564 | }), | |
| 2565 | ); | |
| 2566 | ||
| 2567 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | |
| 2568 | Effect.gen(function* () { | |
| 2569 | let detailReads = 0; | |
| 2570 | workflowApprovalRoutes( | |
| 2571 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), | |
| 2572 | heads([7]), | |
| 2573 | workflowRuns([10]), | |
| 2574 | ); | |
| 2575 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2576 | ||
| 2577 | const error = yield* Effect.flip( | |
| 2578 | cli.runPullRequestAction({ | |
| 2579 | cwd: "/w", | |
| 2580 | repository: "acme/web", | |
| 2581 | host: "github.com", | |
| 2582 | number: 7, | |
| 2583 | action: "approve-workflows", | |
| 2584 | }), | |
| 2585 | ); | |
| 2586 | ||
| 2587 | expect(error).toMatchObject({ | |
| 2588 | _tag: "GitHubWorkflowApprovalHeadChangedError", | |
| 2589 | number: 7, | |
| 2590 | }); | |
| 2591 | assert.strictEqual(detailReads, 2); | |
| 2592 | expect(restCallsTo("/approve")).toEqual([]); | |
| 2593 | }), | |
| 2594 | ); | |
| 2595 | ||
| 2596 | it.effect("reads workflow runs and their pull request scope concurrently", () => | |
| 2597 | Effect.gen(function* () { | |
| 2598 | const headsStarted = yield* Deferred.make<void>(); | |
| 2599 | const runsStarted = yield* Deferred.make<void>(); | |
| 2600 | mockedExecute.mockImplementation((call) => | |
| 2601 | call.kind === "graphql" | |
| 2602 | ? Deferred.succeed(headsStarted, undefined).pipe( | |
| 2603 | Effect.andThen(Deferred.await(runsStarted)), | |
| 2604 | Effect.as(output(encodeJson(heads([7])))), | |
| 2605 | ) | |
| 2606 | : Deferred.succeed(runsStarted, undefined).pipe( | |
| 2607 | Effect.andThen(Deferred.await(headsStarted)), | |
| 2608 | Effect.as(output(encodeJson(workflowRuns([10])))), | |
| 2609 | ), | |
| 2610 | ); | |
| 2611 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2612 | ||
| 2613 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ | |
| 2614 | cwd: "/w", | |
| 2615 | repository: "acme/web", | |
| 2616 | host: "github.com", | |
| 2617 | number: 7, | |
| 2618 | headSha: "abc123", | |
| 2619 | headBranch: "feat/page", | |
| 2620 | headRepositoryOwner: "octocat", | |
| 2621 | isCrossRepository: true, | |
| 2622 | }); | |
| 2623 | ||
| 2624 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); | |
| 2625 | }), | |
| 2626 | ); | |
| 2627 | ||
| 2628 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | |
| 2629 | Effect.gen(function* () { | |
| 2630 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); | |
| 2631 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2632 | ||
| 2633 | const error = yield* Effect.flip( | |
| 2634 | cli.listWorkflowRunsRequiringApproval({ | |
| 2635 | cwd: "/w", | |
| 2636 | repository: "acme/web", | |
| 2637 | host: "github.com", | |
| 2638 | number: 7, | |
| 2639 | headSha: "abc123", | |
| 2640 | headBranch: "feat/page", | |
| 2641 | headRepositoryOwner: "octocat", | |
| 2642 | isCrossRepository: true, | |
| 2643 | }), | |
| 2644 | ); | |
| 2645 | ||
| 2646 | expect(error).toMatchObject({ | |
| 2647 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2648 | reason: "head-not-unique", | |
| 2649 | number: 7, | |
| 2650 | observedCount: 2, | |
| 2651 | limit: 1_000, | |
| 2652 | }); | |
| 2653 | expect(error.message).toContain("instead of uniquely matching #7"); | |
| 2654 | }), | |
| 2655 | ); | |
| 2656 | ||
| 2657 | it.effect("refuses workflow approval when the head list stops before its end", () => | |
| 2658 | Effect.gen(function* () { | |
| 2659 | // GitHub hands the same cursor back, so the second page is never read past. #7 looks | |
| 2660 | // unique on what was read, and an unread page could still hold another head like it. | |
| 2661 | const repeating = heads([7]); | |
| 2662 | repeating.data.repository.pullRequests.pageInfo = { | |
| 2663 | hasNextPage: true, | |
| 2664 | endCursor: "same", | |
| 2665 | } as never; | |
| 2666 | workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); | |
| 2667 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2668 | ||
| 2669 | const error = yield* Effect.flip( | |
| 2670 | cli.listWorkflowRunsRequiringApproval({ | |
| 2671 | cwd: "/w", | |
| 2672 | repository: "acme/web", | |
| 2673 | host: "github.com", | |
| 2674 | number: 7, | |
| 2675 | headSha: "abc123", | |
| 2676 | headBranch: "feat/page", | |
| 2677 | headRepositoryOwner: "octocat", | |
| 2678 | isCrossRepository: true, | |
| 2679 | }), | |
| 2680 | ); | |
| 2681 | ||
| 2682 | expect(error).toMatchObject({ | |
| 2683 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2684 | reason: "head-list-truncated", | |
| 2685 | }); | |
| 2686 | }), | |
| 2687 | ); | |
| 2688 | ||
| 2689 | it.effect("refuses workflow approval when GitHub omits the head repository", () => | |
| 2690 | Effect.gen(function* () { | |
| 2691 | mockedExecute.mockReturnValueOnce( | |
| 2692 | Effect.succeed( | |
| 2693 | output( | |
| 2694 | encodeJson( | |
| 2695 | coreResponse({ | |
| 2696 | number: 7, | |
| 2697 | title: "Pull request 7", | |
| 2698 | url: "https://github.com/acme/web/pull/7", | |
| 2699 | headRefName: "feat/page", | |
| 2700 | headRefOid: "abc123", | |
| 2701 | isCrossRepository: true, | |
| 2702 | headRepositoryOwner: null, | |
| 2703 | baseRefName: "main", | |
| 2704 | createdAt: "2026-07-01T00:00:00Z", | |
| 2705 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2706 | }), | |
| 2707 | ), | |
| 2708 | ), | |
| 2709 | ), | |
| 2710 | ); | |
| 2711 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2712 | ||
| 2713 | const error = yield* Effect.flip( | |
| 2714 | cli.runPullRequestAction({ | |
| 2715 | cwd: "/w", | |
| 2716 | repository: "acme/web", | |
| 2717 | host: "github.com", | |
| 2718 | number: 7, | |
| 2719 | action: "approve-workflows", | |
| 2720 | }), | |
| 2721 | ); | |
| 2722 | ||
| 2723 | expect(error).toMatchObject({ | |
| 2724 | _tag: "GitHubWorkflowApprovalHeadUnavailableError", | |
| 2725 | number: 7, | |
| 2726 | }); | |
| 2727 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2728 | }), | |
| 2729 | ); | |
| 2730 | ||
| 2731 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => | |
| 2732 | Effect.gen(function* () { | |
| 2733 | let runPages = 0; | |
| 2734 | mockedExecute.mockImplementation((call) => { | |
| 2735 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); | |
| 2736 | const page = runPages++; | |
| 2737 | return Effect.succeed( | |
| 2738 | output( | |
| 2739 | encodeJson( | |
| 2740 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), | |
| 2741 | ), | |
| 2742 | ), | |
| 2743 | ); | |
| 2744 | }); | |
| 2745 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2746 | ||
| 2747 | const error = yield* Effect.flip( | |
| 2748 | cli.listWorkflowRunsRequiringApproval({ | |
| 2749 | cwd: "/w", | |
| 2750 | repository: "acme/web", | |
| 2751 | host: "github.com", | |
| 2752 | number: 7, | |
| 2753 | headSha: "abc123", | |
| 2754 | headBranch: "feat/page", | |
| 2755 | headRepositoryOwner: "octocat", | |
| 2756 | isCrossRepository: true, | |
| 2757 | }), | |
| 2758 | ); | |
| 2759 | ||
| 2760 | // Paged a hundred at a time, it stops at the first page past the bound. | |
| 2761 | expect(error).toMatchObject({ | |
| 2762 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2763 | reason: "run-list-truncated", | |
| 2764 | number: 7, | |
| 2765 | observedCount: 1_100, | |
| 2766 | limit: 1_000, | |
| 2767 | }); | |
| 2768 | expect(error.message).toContain("more than 1000 workflow runs"); | |
| 2769 | assert.strictEqual(runPages, 11); | |
| 2770 | }), | |
| 2771 | ); | |
| 2772 | ||
| 2773 | it.effect("returns a pull request to draft by converting it", () => | |
| 2774 | Effect.gen(function* () { | |
| 2775 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2776 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2777 | ||
| 2778 | yield* cli.runPullRequestAction({ | |
| 2779 | cwd: "/w", | |
| 2780 | repository: "acme/web", | |
| 2781 | host: "github.com", | |
| 2782 | number: 7, | |
| 2783 | action: "draft", | |
| 2784 | }); | |
| 2785 | ||
| 2786 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2787 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); | |
| 2788 | }), | |
| 2789 | ); | |
| 2790 | ||
| 2791 | it.effect("sends a comment body as a variable, never inside the document", () => | |
| 2792 | Effect.gen(function* () { | |
| 2793 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2794 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2795 | ||
| 2796 | yield* cli.commentOnPullRequest({ | |
| 2797 | cwd: "/w", | |
| 2798 | repository: "acme/web", | |
| 2799 | host: "github.com", | |
| 2800 | number: 7, | |
| 2801 | body: "Looks good.", | |
| 2802 | }); | |
| 2803 | ||
| 2804 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); | |
| 2805 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); | |
| 2806 | }), | |
| 2807 | ); | |
| 2808 | ||
| 2809 | it.effect("names the host on every repository it addresses", () => | |
| 2810 | Effect.gen(function* () { | |
| 2811 | mockedExecute.mockImplementation((call) => | |
| 2812 | Effect.succeed( | |
| 2813 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2814 | ? emptyList() | |
| 2815 | : emptySearch(), | |
| 2816 | ), | |
| 2817 | ); | |
| 2818 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2819 | ||
| 2820 | yield* cli.listPullRequests({ | |
| 2821 | cwd: "/w", | |
| 2822 | repository: "acme/web", | |
| 2823 | host: "github.acme.dev", | |
| 2824 | state: "open", | |
| 2825 | involvement: "all", | |
| 2826 | viewer: "bilal", | |
| 2827 | limit: 10, | |
| 2828 | }); | |
| 2829 | ||
| 2830 | // A request sent to github.com would read a different repository of the same name. | |
| 2831 | assert.isAbove(mockedExecute.mock.calls.length, 0); | |
| 2832 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( | |
| 2833 | new Set(["github.acme.dev"]), | |
| 2834 | ); | |
| 2835 | expect(searchOfCall(0)).toContain("repo:acme/web"); | |
| 2836 | }), | |
| 2837 | ); | |
| 2838 | ||
| 2839 | it.effect("asks a GitHub Enterprise host for its own review threads", () => | |
| 2840 | Effect.gen(function* () { | |
| 2841 | mockedExecute.mockReturnValueOnce( | |
| 2842 | Effect.succeed( | |
| 2843 | output( | |
| 2844 | encodeJson({ | |
| 2845 | data: { | |
| 2846 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, | |
| 2847 | }, | |
| 2848 | }), | |
| 2849 | ), | |
| 2850 | ), | |
| 2851 | ); | |
| 2852 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2853 | ||
| 2854 | yield* cli.listReviewThreadComments({ | |
| 2855 | cwd: "/w", | |
| 2856 | repository: "acme/web", | |
| 2857 | host: "github.acme.dev", | |
| 2858 | number: 7, | |
| 2859 | }); | |
| 2860 | ||
| 2861 | expect(callAt(0).host).toBe("github.acme.dev"); | |
| 2862 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 2863 | }), | |
| 2864 | ); | |
| 2865 | ||
| 2866 | it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => | |
| 2867 | Effect.gen(function* () { | |
| 2868 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); | |
| 2869 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2870 | ||
| 2871 | const diff = yield* cli.getPullRequestDiff({ | |
| 2872 | cwd: "/w", | |
| 2873 | repository: "acme/web", | |
| 2874 | host: "github.com", | |
| 2875 | number: 7, | |
| 2876 | }); | |
| 2877 | ||
| 2878 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | |
| 2879 | assert.isNull(diff.nextCursor); | |
| 2880 | assert.isFalse(diff.truncated); | |
| 2881 | // The common case pays for one request and not the files API on top of it. | |
| 2882 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2883 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. | |
| 2884 | const call = callAt(0); | |
| 2885 | assert.strictEqual(call.kind, "rest"); | |
| 2886 | if (call.kind === "rest") { | |
| 2887 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); | |
| 2888 | assert.strictEqual(call.accept, "application/vnd.github.diff"); | |
| 2889 | } | |
| 2890 | }), | |
| 2891 | ); | |
| 2892 | ||
| 2893 | it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => | |
| 2894 | Effect.gen(function* () { | |
| 2895 | // GitHub answers 406 rather than a diff past 300 changed files. | |
| 2896 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2897 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); | |
| 2898 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2899 | ||
| 2900 | const diff = yield* cli.getPullRequestDiff({ | |
| 2901 | cwd: "/w", | |
| 2902 | repository: "acme/web", | |
| 2903 | host: "github.acme.dev", | |
| 2904 | number: 7, | |
| 2905 | }); | |
| 2906 | ||
| 2907 | assert.isFalse(diff.truncated); | |
| 2908 | // A short page is the end of the change set, so there is nothing to carry on from. | |
| 2909 | assert.isNull(diff.nextCursor); | |
| 2910 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2911 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); | |
| 2912 | assert.strictEqual(callAt(1).host, "github.acme.dev"); | |
| 2913 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 2914 | }), | |
| 2915 | ); | |
| 2916 | ||
| 2917 | it.effect("hands back a cursor for the next page rather than walking on by itself", () => | |
| 2918 | Effect.gen(function* () { | |
| 2919 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2920 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2921 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2922 | ||
| 2923 | const diff = yield* cli.getPullRequestDiff({ | |
| 2924 | cwd: "/w", | |
| 2925 | repository: "acme/web", | |
| 2926 | host: "github.com", | |
| 2927 | number: 7, | |
| 2928 | }); | |
| 2929 | ||
| 2930 | // A full page means more files, which the reader asks for; it is not a truncated slice. | |
| 2931 | assert.isFalse(diff.truncated); | |
| 2932 | assert.isNotNull(diff.nextCursor); | |
| 2933 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2934 | }), | |
| 2935 | ); | |
| 2936 | ||
| 2937 | it.effect("carries on from a cursor without asking for the whole diff again", () => | |
| 2938 | Effect.gen(function* () { | |
| 2939 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2940 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2941 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2942 | const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; | |
| 2943 | ||
| 2944 | const first = yield* cli.getPullRequestDiff(target); | |
| 2945 | assert.isNotNull(first.nextCursor); | |
| 2946 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); | |
| 2947 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2948 | ||
| 2949 | assert.isNull(second.nextCursor); | |
| 2950 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); | |
| 2951 | // The second slice is one request: the cursor already says where to read. | |
| 2952 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 2953 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); | |
| 2954 | }), | |
| 2955 | ); | |
| 2956 | ||
| 2957 | it.effect("refuses a cursor it never handed out rather than reading it into a request", () => | |
| 2958 | Effect.gen(function* () { | |
| 2959 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2960 | ||
| 2961 | const error = yield* Effect.flip( | |
| 2962 | cli.getPullRequestDiff({ | |
| 2963 | cwd: "/w", | |
| 2964 | repository: "acme/web", | |
| 2965 | host: "github.com", | |
| 2966 | number: 7, | |
| 2967 | cursor: "1&per_page=1", | |
| 2968 | }), | |
| 2969 | ); | |
| 2970 | ||
| 2971 | assert.strictEqual(error._tag, "GitHubDiffCursorError"); | |
| 2972 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2973 | }), | |
| 2974 | ); | |
| 2975 | ||
| 2976 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => | |
| 2977 | Effect.gen(function* () { | |
| 2978 | mockedExecute.mockReturnValueOnce( | |
| 2979 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), | |
| 2980 | ); | |
| 2981 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2982 | ||
| 2983 | const diff = yield* cli.getPullRequestDiff({ | |
| 2984 | cwd: "/w", | |
| 2985 | repository: "acme/web", | |
| 2986 | host: "github.com", | |
| 2987 | number: 7, | |
| 2988 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 2989 | }); | |
| 2990 | ||
| 2991 | // One request: the commit's own changes never take the whole-diff road. | |
| 2992 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2993 | assert.isNull(diff.nextCursor); | |
| 2994 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | |
| 2995 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2996 | assert.strictEqual( | |
| 2997 | pathAt(0), | |
| 2998 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", | |
| 2999 | ); | |
| 3000 | }), | |
| 3001 | ); | |
| 3002 | ||
| 3003 | it.effect("pages inside a commit the way it pages the pull request's own files", () => | |
| 3004 | Effect.gen(function* () { | |
| 3005 | mockedExecute.mockReturnValueOnce( | |
| 3006 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), | |
| 3007 | ); | |
| 3008 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3009 | const target = { | |
| 3010 | cwd: "/w", | |
| 3011 | repository: "acme/web", | |
| 3012 | host: "github.com", | |
| 3013 | number: 7, | |
| 3014 | commit: "a1b2c3d", | |
| 3015 | }; | |
| 3016 | ||
| 3017 | const first = yield* cli.getPullRequestDiff(target); | |
| 3018 | assert.isNotNull(first.nextCursor); | |
| 3019 | mockedExecute.mockReturnValueOnce( | |
| 3020 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), | |
| 3021 | ); | |
| 3022 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 3023 | ||
| 3024 | assert.isNull(second.nextCursor); | |
| 3025 | expect(second.patch).toContain("src/file100.ts"); | |
| 3026 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | |
| 3027 | }), | |
| 3028 | ); | |
| 3029 | ||
| 3030 | it.effect("refuses a commit that is not a sha rather than reading it into a request", () => | |
| 3031 | Effect.gen(function* () { | |
| 3032 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3033 | ||
| 3034 | const error = yield* Effect.flip( | |
| 3035 | cli.getPullRequestDiff({ | |
| 3036 | cwd: "/w", | |
| 3037 | repository: "acme/web", | |
| 3038 | host: "github.com", | |
| 3039 | number: 7, | |
| 3040 | commit: "../../pulls/8/files", | |
| 3041 | }), | |
| 3042 | ); | |
| 3043 | ||
| 3044 | assert.strictEqual(error._tag, "GitHubDiffCommitError"); | |
| 3045 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3046 | }), | |
| 3047 | ); | |
| 3048 | ||
| 3049 | it.effect("expands a new file from a root commit without requiring a parent", () => | |
| 3050 | Effect.gen(function* () { | |
| 3051 | mockedExecute.mockReturnValueOnce( | |
| 3052 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), | |
| 3053 | ); | |
| 3054 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); | |
| 3055 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3056 | ||
| 3057 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3058 | cwd: "/w", | |
| 3059 | repository: "acme/web", | |
| 3060 | host: "github.com", | |
| 3061 | number: 7, | |
| 3062 | commit: "a1b2c3d", | |
| 3063 | changeType: "new", | |
| 3064 | oldPath: "src/root.ts", | |
| 3065 | newPath: "src/root.ts", | |
| 3066 | }); | |
| 3067 | ||
| 3068 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); | |
| 3069 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3070 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); | |
| 3071 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); | |
| 3072 | }), | |
| 3073 | ); | |
| 3074 | ||
| 3075 | it.effect("reports unusable diff revisions as a structured error", () => | |
| 3076 | Effect.gen(function* () { | |
| 3077 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); | |
| 3078 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3079 | ||
| 3080 | const error = yield* Effect.flip( | |
| 3081 | cli.getPullRequestDiffFileContents({ | |
| 3082 | cwd: "/w", | |
| 3083 | repository: "acme/web", | |
| 3084 | host: "github.com", | |
| 3085 | number: 7, | |
| 3086 | commit: "a1b2c3d", | |
| 3087 | changeType: "change", | |
| 3088 | oldPath: "src/a.ts", | |
| 3089 | newPath: "src/a.ts", | |
| 3090 | }), | |
| 3091 | ); | |
| 3092 | ||
| 3093 | assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); | |
| 3094 | if (error._tag === "GitHubDiffRevisionsUnavailableError") { | |
| 3095 | assert.strictEqual(error.number, 7); | |
| 3096 | assert.strictEqual(error.commit, "a1b2c3d"); | |
| 3097 | } | |
| 3098 | }), | |
| 3099 | ); | |
| 3100 | ||
| 3101 | it.effect("reports an oversized diff file with its path and reason", () => | |
| 3102 | Effect.gen(function* () { | |
| 3103 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3104 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | |
| 3105 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3106 | ||
| 3107 | const error = yield* Effect.flip( | |
| 3108 | cli.getPullRequestDiffFileContents({ | |
| 3109 | cwd: "/w", | |
| 3110 | repository: "acme/web", | |
| 3111 | host: "github.com", | |
| 3112 | number: 7, | |
| 3113 | changeType: "deleted", | |
| 3114 | oldPath: "src/large.ts", | |
| 3115 | newPath: "src/large.ts", | |
| 3116 | }), | |
| 3117 | ); | |
| 3118 | ||
| 3119 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3120 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3121 | assert.strictEqual(error.path, "src/large.ts"); | |
| 3122 | assert.strictEqual(error.reason, "oversized"); | |
| 3123 | } | |
| 3124 | // A deleted file is read at the base revision only. | |
| 3125 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); | |
| 3126 | }), | |
| 3127 | ); | |
| 3128 | ||
| 3129 | it.effect("reports undecodable diff file contents as binary", () => | |
| 3130 | Effect.gen(function* () { | |
| 3131 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3132 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); | |
| 3133 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3134 | ||
| 3135 | const error = yield* Effect.flip( | |
| 3136 | cli.getPullRequestDiffFileContents({ | |
| 3137 | cwd: "/w", | |
| 3138 | repository: "acme/web", | |
| 3139 | host: "github.com", | |
| 3140 | number: 7, | |
| 3141 | changeType: "deleted", | |
| 3142 | oldPath: "assets/logo.png", | |
| 3143 | newPath: "assets/logo.png", | |
| 3144 | }), | |
| 3145 | ); | |
| 3146 | ||
| 3147 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3148 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3149 | assert.strictEqual(error.path, "assets/logo.png"); | |
| 3150 | assert.strictEqual(error.reason, "binary"); | |
| 3151 | } | |
| 3152 | }), | |
| 3153 | ); | |
| 3154 | ||
| 3155 | it.effect("returns valid text containing a literal replacement character", () => | |
| 3156 | Effect.gen(function* () { | |
| 3157 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3158 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); | |
| 3159 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3160 | ||
| 3161 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3162 | cwd: "/w", | |
| 3163 | repository: "acme/web", | |
| 3164 | host: "github.com", | |
| 3165 | number: 7, | |
| 3166 | changeType: "deleted", | |
| 3167 | oldPath: "docs/encoding.md", | |
| 3168 | newPath: "docs/encoding.md", | |
| 3169 | }); | |
| 3170 | ||
| 3171 | assert.strictEqual(contents.oldContents, "before�after"); | |
| 3172 | }), | |
| 3173 | ); | |
| 3174 | ||
| 3175 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | |
| 3176 | Effect.gen(function* () { | |
| 3177 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 3178 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3179 | ||
| 3180 | const diff = yield* cli.getPullRequestDiff({ | |
| 3181 | cwd: "/w", | |
| 3182 | repository: "acme/web", | |
| 3183 | host: "github.com", | |
| 3184 | number: 7, | |
| 3185 | cursor: "4", | |
| 3186 | }); | |
| 3187 | ||
| 3188 | assert.strictEqual(diff.patch, ""); | |
| 3189 | assert.isNull(diff.nextCursor); | |
| 3190 | }), | |
| 3191 | ); | |
| 3192 | ||
| 3193 | it.effect("reports the refused diff when the files API cannot answer either", () => | |
| 3194 | Effect.gen(function* () { | |
| 3195 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3196 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); | |
| 3197 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3198 | ||
| 3199 | const error = yield* Effect.flip( | |
| 3200 | cli.getPullRequestDiff({ | |
| 3201 | cwd: "/w", | |
| 3202 | repository: "acme/web", | |
| 3203 | host: "github.com", | |
| 3204 | number: 7, | |
| 3205 | }), | |
| 3206 | ); | |
| 3207 | ||
| 3208 | assert.strictEqual(error, diffRefused); | |
| 3209 | }), | |
| 3210 | ); | |
| 3211 | ||
| 3212 | it.effect("skips the avatar lookup when a listing named nobody", () => | |
| 3213 | Effect.gen(function* () { | |
| 3214 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3215 | ||
| 3216 | const avatars = yield* cli.listActorAvatars({ | |
| 3217 | cwd: "/w", | |
| 3218 | repository: "acme/web", | |
| 3219 | host: "github.com", | |
| 3220 | ids: [], | |
| 3221 | }); | |
| 3222 | ||
| 3223 | assert.strictEqual(avatars.size, 0); | |
| 3224 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3225 | }), | |
| 3226 | ); | |
| 3227 | ||
| 3228 | it.effect("looks up avatars by node id in one GraphQL read", () => | |
| 3229 | Effect.gen(function* () { | |
| 3230 | mockedExecute.mockReturnValueOnce( | |
| 3231 | Effect.succeed( | |
| 3232 | output( | |
| 3233 | encodeJson({ | |
| 3234 | data: { | |
| 3235 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], | |
| 3236 | }, | |
| 3237 | }), | |
| 3238 | ), | |
| 3239 | ), | |
| 3240 | ); | |
| 3241 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3242 | ||
| 3243 | const avatars = yield* cli.listActorAvatars({ | |
| 3244 | cwd: "/w", | |
| 3245 | repository: "acme/web", | |
| 3246 | host: "github.com", | |
| 3247 | ids: ["MDQ6VXNlcjE="], | |
| 3248 | }); | |
| 3249 | ||
| 3250 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); | |
| 3251 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); | |
| 3252 | }), | |
| 3253 | ); | |
| 3254 | ||
| 3255 | it.effect("fails when the authenticated account has no login", () => | |
| 3256 | Effect.gen(function* () { | |
| 3257 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); | |
| 3258 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3259 | ||
| 3260 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); | |
| 3261 | ||
| 3262 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); | |
| 3263 | }), | |
| 3264 | ); | |
| 3265 | ||
| 3266 | it.effect("looks up the authenticated account on the requested enterprise host", () => | |
| 3267 | Effect.gen(function* () { | |
| 3268 | mockedExecute.mockReturnValueOnce( | |
| 3269 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), | |
| 3270 | ); | |
| 3271 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3272 | ||
| 3273 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); | |
| 3274 | ||
| 3275 | expect(login).toBe("enterprise-user"); | |
| 3276 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); | |
| 3277 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); | |
| 3278 | }), | |
| 3279 | ); | |
| 3280 | ||
| 3281 | it.effect("reuses verified credentials offline and refuses an unverified replacement", () => | |
| 3282 | Effect.gen(function* () { | |
| 3283 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3284 | const input = { cwd: "/w", host: "github.identity-cache.test" }; | |
| 3285 | mockedCredential.mockImplementation((host) => | |
| 3286 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), | |
| 3287 | ); | |
| 3288 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3289 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3290 | accountId: "123", | |
| 3291 | viewer: "maria-rcks", | |
| 3292 | }); | |
| 3293 | ||
| 3294 | // The same credential again is answered from what was verified, without asking GitHub. | |
| 3295 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3296 | accountId: "123", | |
| 3297 | viewer: "maria-rcks", | |
| 3298 | }); | |
| 3299 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 3300 | ||
| 3301 | // A switched credential is not trusted on the strength of the old one's answer. | |
| 3302 | mockedCredential.mockImplementation((host) => | |
| 3303 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), | |
| 3304 | ); | |
| 3305 | mockedExecute.mockReturnValueOnce( | |
| 3306 | Effect.fail( | |
| 3307 | new GitHubApi.GitHubApiResponseError({ | |
| 3308 | host: "github.com", | |
| 3309 | operation: "getRoutingIdentity", | |
| 3310 | status: 502, | |
| 3311 | }), | |
| 3312 | ), | |
| 3313 | ); | |
| 3314 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | |
| 3315 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); | |
| 3316 | expect(failure._tag).toBe("GitHubApiResponseError"); | |
| 3317 | expect(String(failure)).not.toContain("test-credential-b"); | |
| 3318 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3319 | ||
| 3320 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3321 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3322 | accountId: "456", | |
| 3323 | viewer: "maria-rcks", | |
| 3324 | }); | |
| 3325 | }), | |
| 3326 | ); | |
| 3327 | ||
| 3328 | it.effect("sends a whole review as one request", () => | |
| 3329 | Effect.gen(function* () { | |
| 3330 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3331 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3332 | ||
| 3333 | yield* cli.submitReview({ | |
| 3334 | cwd: "/w", | |
| 3335 | repository: "acme/web", | |
| 3336 | host: "github.com", | |
| 3337 | number: 7, | |
| 3338 | verdict: "approve", | |
| 3339 | body: "Looks right.", | |
| 3340 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | |
| 3341 | }); | |
| 3342 | ||
| 3343 | // One request, so nothing is on the pull request until the verdict is. | |
| 3344 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3345 | expect(restCallsTo("/reviews")).toMatchObject([ | |
| 3346 | { | |
| 3347 | method: "POST", | |
| 3348 | path: "repos/acme/web/pulls/7/reviews", | |
| 3349 | body: { | |
| 3350 | event: "APPROVE", | |
| 3351 | body: "Looks right.", | |
| 3352 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | |
| 3353 | }, | |
| 3354 | }, | |
| 3355 | ]); | |
| 3356 | }), | |
| 3357 | ); | |
| 3358 | ||
| 3359 | it.effect("sends a reply body as a variable, never inside the document", () => | |
| 3360 | Effect.gen(function* () { | |
| 3361 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3362 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3363 | ||
| 3364 | yield* cli.replyToReviewThread({ | |
| 3365 | cwd: "/w", | |
| 3366 | repository: "acme/web", | |
| 3367 | host: "github.com", | |
| 3368 | threadId: "PRRT_1", | |
| 3369 | body: "Fixed in 42ff8ec.", | |
| 3370 | }); | |
| 3371 | ||
| 3372 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ | |
| 3373 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, | |
| 3374 | ]); | |
| 3375 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); | |
| 3376 | }), | |
| 3377 | ); | |
| 3378 | ||
| 3379 | it.effect("resolves and unresolves through the mutation each one needs", () => | |
| 3380 | Effect.gen(function* () { | |
| 3381 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3382 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3383 | ||
| 3384 | yield* cli.setReviewThreadResolution({ | |
| 3385 | cwd: "/w", | |
| 3386 | repository: "acme/web", | |
| 3387 | host: "github.acme.dev", | |
| 3388 | threadId: "PRRT_1", | |
| 3389 | resolved: true, | |
| 3390 | }); | |
| 3391 | yield* cli.setReviewThreadResolution({ | |
| 3392 | cwd: "/w", | |
| 3393 | repository: "acme/web", | |
| 3394 | host: "github.acme.dev", | |
| 3395 | threadId: "PRRT_1", | |
| 3396 | resolved: false, | |
| 3397 | }); | |
| 3398 | ||
| 3399 | expect(queryAt(0)).toContain("resolveReviewThread("); | |
| 3400 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); | |
| 3401 | expect(queryAt(1)).toContain("unresolveReviewThread("); | |
| 3402 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | |
| 3403 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. | |
| 3404 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); | |
| 3405 | }), | |
| 3406 | ); | |
| 3407 | ||
| 3408 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => | |
| 3409 | Effect.gen(function* () { | |
| 3410 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3411 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3412 | ||
| 3413 | yield* cli.setReaction({ | |
| 3414 | cwd: "/w", | |
| 3415 | repository: "acme/web", | |
| 3416 | host: "github.com", | |
| 3417 | number: 7, | |
| 3418 | subjectId: "IC_1", | |
| 3419 | content: "heart", | |
| 3420 | reacted: true, | |
| 3421 | }); | |
| 3422 | ||
| 3423 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3424 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ | |
| 3425 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, | |
| 3426 | ]); | |
| 3427 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3428 | }), | |
| 3429 | ); | |
| 3430 | ||
| 3431 | it.effect("refuses a given subject that belongs to a different pull request", () => | |
| 3432 | Effect.gen(function* () { | |
| 3433 | mockedExecute.mockReturnValueOnce( | |
| 3434 | Effect.succeed( | |
| 3435 | output( | |
| 3436 | encodeJson({ | |
| 3437 | data: { | |
| 3438 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3439 | // A comment on pull request #99 of a different repository, named as though it | |
| 3440 | // belonged to #7 here. | |
| 3441 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3442 | }, | |
| 3443 | }), | |
| 3444 | ), | |
| 3445 | ), | |
| 3446 | ); | |
| 3447 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3448 | ||
| 3449 | const error = yield* Effect.flip( | |
| 3450 | cli.setReaction({ | |
| 3451 | cwd: "/w", | |
| 3452 | repository: "acme/web", | |
| 3453 | host: "github.com", | |
| 3454 | number: 7, | |
| 3455 | subjectId: "IC_99", | |
| 3456 | content: "heart", | |
| 3457 | reacted: true, | |
| 3458 | }), | |
| 3459 | ); | |
| 3460 | ||
| 3461 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3462 | // Refused before any mutation was sent. | |
| 3463 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3464 | }), | |
| 3465 | ); | |
| 3466 | ||
| 3467 | it.effect("looks up the pull request's own node id when no subject was given", () => | |
| 3468 | Effect.gen(function* () { | |
| 3469 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3470 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3471 | ||
| 3472 | // Its own pull request: a node id looked up once is remembered for the life of the service. | |
| 3473 | yield* cli.setReaction({ | |
| 3474 | cwd: "/w", | |
| 3475 | repository: "acme/web", | |
| 3476 | host: "github.com", | |
| 3477 | number: 21, | |
| 3478 | content: "rocket", | |
| 3479 | reacted: true, | |
| 3480 | }); | |
| 3481 | ||
| 3482 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3483 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); | |
| 3484 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); | |
| 3485 | }), | |
| 3486 | ); | |
| 3487 | ||
| 3488 | it.effect("takes a reaction back through the remove mutation", () => | |
| 3489 | Effect.gen(function* () { | |
| 3490 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3491 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3492 | ||
| 3493 | yield* cli.setReaction({ | |
| 3494 | cwd: "/w", | |
| 3495 | repository: "acme/web", | |
| 3496 | host: "github.com", | |
| 3497 | number: 7, | |
| 3498 | subjectId: "IC_1", | |
| 3499 | content: "heart", | |
| 3500 | reacted: false, | |
| 3501 | }); | |
| 3502 | ||
| 3503 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3504 | expect(variablesOf("addReaction(")).toEqual([]); | |
| 3505 | }), | |
| 3506 | ); | |
| 3507 | ||
| 3508 | it.effect("rewrites only the words a request named", () => | |
| 3509 | Effect.gen(function* () { | |
| 3510 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3511 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3512 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => | |
| 3513 | cli.updatePullRequest({ | |
| 3514 | cwd: "/w", | |
| 3515 | repository: "acme/web", | |
| 3516 | host: "github.com", | |
| 3517 | number: 22, | |
| 3518 | ...fields, | |
| 3519 | }); | |
| 3520 | ||
| 3521 | yield* rewrite({ title: "A better title" }); | |
| 3522 | yield* rewrite({ body: "A better description." }); | |
| 3523 | yield* rewrite({ title: "Both", body: "at once." }); | |
| 3524 | ||
| 3525 | // One node id lookup for the pull request, then a mutation per rewrite. | |
| 3526 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); | |
| 3527 | expect(variablesOf("updatePullRequest(")).toEqual([ | |
| 3528 | { pullRequestId: "PR_kwDOA", title: "A better title" }, | |
| 3529 | { pullRequestId: "PR_kwDOA", body: "A better description." }, | |
| 3530 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, | |
| 3531 | ]); | |
| 3532 | }), | |
| 3533 | ); | |
| 3534 | ||
| 3535 | it.effect("rewrites a remark through the mutation its kind needs", () => | |
| 3536 | Effect.gen(function* () { | |
| 3537 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3538 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3539 | const rewrite = (kind: "issue-comment" | "review-comment") => | |
| 3540 | cli.updateComment({ | |
| 3541 | cwd: "/w", | |
| 3542 | repository: "acme/web", | |
| 3543 | host: "github.com", | |
| 3544 | number: 7, | |
| 3545 | commentId: "IC_1", | |
| 3546 | kind, | |
| 3547 | body: "Reworded.", | |
| 3548 | }); | |
| 3549 | ||
| 3550 | yield* rewrite("issue-comment"); | |
| 3551 | yield* rewrite("review-comment"); | |
| 3552 | ||
| 3553 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ | |
| 3554 | "IC_1", | |
| 3555 | "IC_1", | |
| 3556 | ]); | |
| 3557 | expect(variablesOf("updateIssueComment(")).toEqual([ | |
| 3558 | { commentId: "IC_1", body: "Reworded." }, | |
| 3559 | ]); | |
| 3560 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ | |
| 3561 | { commentId: "IC_1", body: "Reworded." }, | |
| 3562 | ]); | |
| 3563 | }), | |
| 3564 | ); | |
| 3565 | ||
| 3566 | it.effect("refuses a comment that belongs to a different pull request", () => | |
| 3567 | Effect.gen(function* () { | |
| 3568 | mockedExecute.mockReturnValueOnce( | |
| 3569 | Effect.succeed( | |
| 3570 | output( | |
| 3571 | encodeJson({ | |
| 3572 | data: { | |
| 3573 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3574 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3575 | }, | |
| 3576 | }), | |
| 3577 | ), | |
| 3578 | ), | |
| 3579 | ); | |
| 3580 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3581 | ||
| 3582 | const error = yield* Effect.flip( | |
| 3583 | cli.updateComment({ | |
| 3584 | cwd: "/w", | |
| 3585 | repository: "acme/web", | |
| 3586 | host: "github.com", | |
| 3587 | number: 7, | |
| 3588 | commentId: "IC_99", | |
| 3589 | kind: "issue-comment", | |
| 3590 | body: "Reworded.", | |
| 3591 | }), | |
| 3592 | ); | |
| 3593 | ||
| 3594 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3595 | if (error._tag === "GitHubSubjectScopeError") | |
| 3596 | assert.strictEqual(error.operation, "updateComment"); | |
| 3597 | // Refused before any mutation was sent. | |
| 3598 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3599 | }), | |
| 3600 | ); | |
| 3601 | ||
| 3602 | it.effect("fails the read when gh returns something unreadable", () => | |
| 3603 | Effect.gen(function* () { | |
| 3604 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 3605 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3606 | ||
| 3607 | const error = yield* Effect.flip( | |
| 3608 | cli.getPullRequestDetail({ | |
| 3609 | cwd: "/w", | |
| 3610 | repository: "acme/web", | |
| 3611 | host: "github.com", | |
| 3612 | number: 7, | |
| 3613 | }), | |
| 3614 | ); | |
| 3615 | ||
| 3616 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 3617 | }), | |
| 3618 | ); | |
| 3619 | ||
| 3620 | it.effect("keeps the core detail read separate from conversation activity", () => | |
| 3621 | Effect.gen(function* () { | |
| 3622 | mockedExecute.mockReturnValueOnce( | |
| 3623 | Effect.succeed( | |
| 3624 | output( | |
| 3625 | encodeJson( | |
| 3626 | coreResponse({ | |
| 3627 | title: "Progressive detail", | |
| 3628 | author: { login: "octocat" }, | |
| 3629 | body: "Core body", | |
| 3630 | changedFiles: 2, | |
| 3631 | }), | |
| 3632 | ), | |
| 3633 | ), | |
| 3634 | ), | |
| 3635 | ); | |
| 3636 | mockedExecute.mockReturnValueOnce( | |
| 3637 | Effect.succeed( | |
| 3638 | output( | |
| 3639 | encodeJson({ | |
| 3640 | data: { | |
| 3641 | repository: { | |
| 3642 | pullRequest: { | |
| 3643 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, | |
| 3644 | commits: { nodes: [] }, | |
| 3645 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3646 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3647 | }, | |
| 3648 | }, | |
| 3649 | }, | |
| 3650 | }), | |
| 3651 | ), | |
| 3652 | ), | |
| 3653 | ); | |
| 3654 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3655 | const input = { | |
| 3656 | cwd: "/w", | |
| 3657 | repository: "acme/web", | |
| 3658 | host: "github.com", | |
| 3659 | number: 7, | |
| 3660 | } as const; | |
| 3661 | ||
| 3662 | const detail = yield* cli.getPullRequestDetail(input); | |
| 3663 | const activity = yield* cli.getPullRequestActivity(input); | |
| 3664 | ||
| 3665 | expect(detail.body).toBe("Core body"); | |
| 3666 | expect(activity.author?.login).toBe("octocat"); | |
| 3667 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); | |
| 3668 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); | |
| 3669 | expect(queryAt(0)).not.toContain("reviews("); | |
| 3670 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ | |
| 3671 | merge: true, | |
| 3672 | squash: false, | |
| 3673 | rebase: true, | |
| 3674 | }); | |
| 3675 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | |
| 3676 | // Conversation activity is its own read, and asks for the head of the conversation once. | |
| 3677 | expect(queryAt(1)).toContain("reviews("); | |
| 3678 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); | |
| 3679 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3680 | }), | |
| 3681 | ); | |
| 3682 | ||
| 3683 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => | |
| 3684 | Effect.gen(function* () { | |
| 3685 | mockedExecute.mockReturnValueOnce( | |
| 3686 | Effect.succeed( | |
| 3687 | output( | |
| 3688 | encodeJson( | |
| 3689 | coreResponse({ | |
| 3690 | baseRef: null, | |
| 3691 | reviewRequests: { | |
| 3692 | nodes: [ | |
| 3693 | { requestedReviewer: { login: "reviewer" } }, | |
| 3694 | { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, | |
| 3695 | ], | |
| 3696 | }, | |
| 3697 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 3698 | commits: { | |
| 3699 | nodes: [ | |
| 3700 | { | |
| 3701 | commit: { | |
| 3702 | statusCheckRollup: { | |
| 3703 | contexts: { | |
| 3704 | nodes: [ | |
| 3705 | { | |
| 3706 | __typename: "CheckRun", | |
| 3707 | name: "build", | |
| 3708 | status: "COMPLETED", | |
| 3709 | conclusion: "SUCCESS", | |
| 3710 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, | |
| 3711 | }, | |
| 3712 | { | |
| 3713 | __typename: "CheckRun", | |
| 3714 | name: "build", | |
| 3715 | status: "COMPLETED", | |
| 3716 | conclusion: "FAILURE", | |
| 3717 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, | |
| 3718 | }, | |
| 3719 | ], | |
| 3720 | pageInfo: { hasNextPage: false }, | |
| 3721 | }, | |
| 3722 | }, | |
| 3723 | }, | |
| 3724 | }, | |
| 3725 | ], | |
| 3726 | }, | |
| 3727 | }), | |
| 3728 | ), | |
| 3729 | ), | |
| 3730 | ), | |
| 3731 | ); | |
| 3732 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3733 | const detail = yield* cli.getPullRequestDetail({ | |
| 3734 | cwd: "/w", | |
| 3735 | repository: "acme/web", | |
| 3736 | host: "github.com", | |
| 3737 | number: 7, | |
| 3738 | }); | |
| 3739 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3740 | expect(detail.comparison).toBeNull(); | |
| 3741 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); | |
| 3742 | expect(detail.hasTeamReviewRequest).toBe(true); | |
| 3743 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); | |
| 3744 | expect(detail.checks).toHaveLength(2); | |
| 3745 | expect(detail.checksState).toBe("failing"); | |
| 3746 | }), | |
| 3747 | ); | |
| 3748 | ||
| 3749 | it.effect("reads every check when the combined response has another page", () => | |
| 3750 | Effect.gen(function* () { | |
| 3751 | mockedExecute.mockReturnValueOnce( | |
| 3752 | Effect.succeed( | |
| 3753 | output( | |
| 3754 | encodeJson( | |
| 3755 | coreResponse({ | |
| 3756 | commits: { | |
| 3757 | nodes: [ | |
| 3758 | { | |
| 3759 | commit: { | |
| 3760 | statusCheckRollup: { | |
| 3761 | contexts: { | |
| 3762 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | |
| 3763 | pageInfo: { hasNextPage: true, endCursor: "c1" }, | |
| 3764 | }, | |
| 3765 | }, | |
| 3766 | }, | |
| 3767 | }, | |
| 3768 | ], | |
| 3769 | }, | |
| 3770 | }), | |
| 3771 | ), | |
| 3772 | ), | |
| 3773 | ), | |
| 3774 | ); | |
| 3775 | mockedExecute.mockReturnValueOnce( | |
| 3776 | Effect.succeed( | |
| 3777 | output( | |
| 3778 | checkContextsPage( | |
| 3779 | [ | |
| 3780 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 3781 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 3782 | ], | |
| 3783 | null, | |
| 3784 | ), | |
| 3785 | ), | |
| 3786 | ), | |
| 3787 | ); | |
| 3788 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3789 | const detail = yield* cli.getPullRequestDetail({ | |
| 3790 | cwd: "/w", | |
| 3791 | repository: "acme/web", | |
| 3792 | host: "github.com", | |
| 3793 | number: 7, | |
| 3794 | }); | |
| 3795 | expect(detail.checks).toHaveLength(2); | |
| 3796 | expect(detail.checksState).toBe("failing"); | |
| 3797 | expect(detail.checksTruncated).toBe(false); | |
| 3798 | // The whole rollup is walked from its start, so no check is counted twice or skipped. | |
| 3799 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); | |
| 3800 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3801 | }), | |
| 3802 | ); | |
| 3803 | ||
| 3804 | it.effect("refuses to combine checks from different head revisions", () => | |
| 3805 | Effect.gen(function* () { | |
| 3806 | const response = coreResponse({ | |
| 3807 | commits: { | |
| 3808 | nodes: [ | |
| 3809 | { | |
| 3810 | commit: { | |
| 3811 | statusCheckRollup: { | |
| 3812 | contexts: { | |
| 3813 | nodes: [], | |
| 3814 | pageInfo: { hasNextPage: true }, | |
| 3815 | }, | |
| 3816 | }, | |
| 3817 | }, | |
| 3818 | }, | |
| 3819 | ], | |
| 3820 | }, | |
| 3821 | }); | |
| 3822 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | |
| 3823 | mockedExecute.mockReturnValueOnce( | |
| 3824 | Effect.succeed( | |
| 3825 | output( | |
| 3826 | encodeJson({ | |
| 3827 | ...response.data.repository.pullRequest, | |
| 3828 | headRefOid: "new-head", | |
| 3829 | reviewRequests: [], | |
| 3830 | labels: [], | |
| 3831 | statusCheckRollup: [], | |
| 3832 | }), | |
| 3833 | ), | |
| 3834 | ), | |
| 3835 | ); | |
| 3836 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3837 | const error = yield* Effect.flip( | |
| 3838 | cli.getPullRequestDetail({ | |
| 3839 | cwd: "/w", | |
| 3840 | repository: "acme/web", | |
| 3841 | host: "github.com", | |
| 3842 | number: 7, | |
| 3843 | }), | |
| 3844 | ); | |
| 3845 | expect(error._tag).toBe("GitHubPullRequestReadError"); | |
| 3846 | }), | |
| 3847 | ); | |
| 3848 | ||
| 3849 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => | |
| 3850 | Effect.gen(function* () { | |
| 3851 | const response = coreResponse(); | |
| 3852 | mockedExecute.mockReturnValue( | |
| 3853 | Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), | |
| 3854 | ); | |
| 3855 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3856 | const input = { | |
| 3857 | cwd: "/w", | |
| 3858 | repository: "acme/web", | |
| 3859 | host: "github.core-reserve.test", | |
| 3860 | number: 7, | |
| 3861 | }; | |
| 3862 | yield* cli.getPullRequestDetail(input); | |
| 3863 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | |
| 3864 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | |
| 3865 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3866 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3867 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3868 | }), | |
| 3869 | ); | |
| 3870 | ||
| 3871 | it.effect("fails a files page too large to read rather than calling the diff whole", () => | |
| 3872 | Effect.gen(function* () { | |
| 3873 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3874 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); | |
| 3875 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3876 | ||
| 3877 | const error = yield* Effect.flip( | |
| 3878 | cli.getPullRequestDiff({ | |
| 3879 | cwd: "/w", | |
| 3880 | repository: "acme/web", | |
| 3881 | host: "github.com", | |
| 3882 | number: 7, | |
| 3883 | }), | |
| 3884 | ); | |
| 3885 | ||
| 3886 | // What matters is that it fails at all: an empty patch with no cursor would render as a | |
| 3887 | // change with no files and report the rest of it as already read. The refusal that sent | |
| 3888 | // the read down this road is the one reported, by design. | |
| 3889 | assert.strictEqual(error, diffRefused); | |
| 3890 | }), | |
| 3891 | ); | |
| 3892 | ||
| 3893 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => | |
| 3894 | Effect.gen(function* () { | |
| 3895 | // The whole diff came back, but cut at a byte, which lands mid-file. | |
| 3896 | mockedExecute.mockReturnValueOnce( | |
| 3897 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), | |
| 3898 | ); | |
| 3899 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); | |
| 3900 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3901 | ||
| 3902 | const slice = yield* cli.getPullRequestDiff({ | |
| 3903 | cwd: "/w", | |
| 3904 | repository: "acme/web", | |
| 3905 | host: "github.com", | |
| 3906 | number: 7, | |
| 3907 | }); | |
| 3908 | ||
| 3909 | // The severed patch is thrown away; what comes back is assembled from whole files. | |
| 3910 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 3911 | expect(slice.patch).toContain("src/file1.ts"); | |
| 3912 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | |
| 3913 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3914 | }), | |
| 3915 | ); | |
| 3916 | ||
| 3917 | it.effect("follows the cursor to the review threads the first page left behind", () => | |
| 3918 | Effect.gen(function* () { | |
| 3919 | mockedExecute.mockReturnValueOnce( | |
| 3920 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), | |
| 3921 | ); | |
| 3922 | mockedExecute.mockReturnValueOnce( | |
| 3923 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), | |
| 3924 | ); | |
| 3925 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3926 | ||
| 3927 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3928 | cwd: "/w", | |
| 3929 | repository: "acme/web", | |
| 3930 | host: "github.com", | |
| 3931 | number: 7, | |
| 3932 | }); | |
| 3933 | ||
| 3934 | // The first page asks from the beginning; the next carries on from where it stopped. | |
| 3935 | expect(varsAt(0)["cursor"]).toBeNull(); | |
| 3936 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); | |
| 3937 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); | |
| 3938 | assert.isFalse(conversation.truncated); | |
| 3939 | }), | |
| 3940 | ); | |
| 3941 | ||
| 3942 | it.effect("stops at the thread bound and says the conversation was cut short", () => | |
| 3943 | Effect.gen(function* () { | |
| 3944 | // A host that never runs out of pages: the walk has to end itself. | |
| 3945 | mockedExecute.mockReturnValue( | |
| 3946 | Effect.succeed( | |
| 3947 | output( | |
| 3948 | reviewThreadsPage( | |
| 3949 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3950 | "Y3Vyc29yOjE", | |
| 3951 | ), | |
| 3952 | ), | |
| 3953 | ), | |
| 3954 | ); | |
| 3955 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3956 | ||
| 3957 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3958 | cwd: "/w", | |
| 3959 | repository: "acme/web", | |
| 3960 | host: "github.com", | |
| 3961 | number: 7, | |
| 3962 | }); | |
| 3963 | ||
| 3964 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | |
| 3965 | assert.isTrue(conversation.truncated); | |
| 3966 | assert.isTrue(conversation.reviewThreadsTruncated); | |
| 3967 | }), | |
| 3968 | ); | |
| 3969 | ||
| 3970 | it.effect("leaves a long thread paged until the reader asks for more", () => | |
| 3971 | Effect.gen(function* () { | |
| 3972 | mockedExecute.mockReturnValueOnce( | |
| 3973 | Effect.succeed( | |
| 3974 | output( | |
| 3975 | reviewThreadsPage( | |
| 3976 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3977 | null, | |
| 3978 | ), | |
| 3979 | ), | |
| 3980 | ), | |
| 3981 | ); | |
| 3982 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3983 | ||
| 3984 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3985 | cwd: "/w", | |
| 3986 | repository: "acme/web", | |
| 3987 | host: "github.com", | |
| 3988 | number: 7, | |
| 3989 | }); | |
| 3990 | ||
| 3991 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3992 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 3993 | expect(conversation.reviewThreads[0]).toMatchObject({ | |
| 3994 | commentCount: 3, | |
| 3995 | nextCommentsCursor: "Y3Vyc29yOjI", | |
| 3996 | }); | |
| 3997 | assert.isTrue(conversation.truncated); | |
| 3998 | assert.isFalse(conversation.reviewThreadsTruncated); | |
| 3999 | }), | |
| 4000 | ); | |
| 4001 | ||
| 4002 | it.effect("reads one requested page from a review thread cursor", () => | |
| 4003 | Effect.gen(function* () { | |
| 4004 | mockedExecute.mockReturnValueOnce( | |
| 4005 | Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), | |
| 4006 | ); | |
| 4007 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4008 | ||
| 4009 | const page = yield* cli.getReviewThreadComments({ | |
| 4010 | cwd: "/w", | |
| 4011 | repository: "acme/web", | |
| 4012 | host: "github.com", | |
| 4013 | number: 7, | |
| 4014 | threadId: "PRRT_1", | |
| 4015 | cursor: "Y3Vyc29yOjI", | |
| 4016 | }); | |
| 4017 | ||
| 4018 | expect(varsAt(0)).toMatchObject({ | |
| 4019 | owner: "acme", | |
| 4020 | name: "web", | |
| 4021 | number: 7, | |
| 4022 | threadId: "PRRT_1", | |
| 4023 | cursor: "Y3Vyc29yOjI", | |
| 4024 | }); | |
| 4025 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4026 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); | |
| 4027 | expect(page.nextCursor).toBeNull(); | |
| 4028 | }), | |
| 4029 | ); | |
| 4030 | ||
| 4031 | it.effect("refuses a review thread from another pull request", () => | |
| 4032 | Effect.gen(function* () { | |
| 4033 | mockedExecute.mockReturnValueOnce( | |
| 4034 | Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), | |
| 4035 | ); | |
| 4036 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4037 | ||
| 4038 | const error = yield* Effect.flip( | |
| 4039 | cli.getReviewThreadComments({ | |
| 4040 | cwd: "/w", | |
| 4041 | repository: "acme/web", | |
| 4042 | host: "github.com", | |
| 4043 | number: 7, | |
| 4044 | threadId: "PRRT_FOREIGN", | |
| 4045 | cursor: "Y3Vyc29yOjI", | |
| 4046 | }), | |
| 4047 | ); | |
| 4048 | ||
| 4049 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 4050 | }), | |
| 4051 | ); | |
| 4052 | ||
| 4053 | it.effect( | |
| 4054 | "asks for the reader's standing on the repository and on the pull request at once", | |
| 4055 | () => | |
| 4056 | Effect.gen(function* () { | |
| 4057 | mockedExecute.mockReturnValue( | |
| 4058 | Effect.succeed( | |
| 4059 | output( | |
| 4060 | encodeJson({ | |
| 4061 | data: { | |
| 4062 | repository: { | |
| 4063 | mergeCommitAllowed: true, | |
| 4064 | squashMergeAllowed: false, | |
| 4065 | rebaseMergeAllowed: true, | |
| 4066 | viewerPermission: "READ", | |
| 4067 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4068 | }, | |
| 4069 | }, | |
| 4070 | }), | |
| 4071 | ), | |
| 4072 | ), | |
| 4073 | ); | |
| 4074 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4075 | ||
| 4076 | const access = yield* cli.getViewerAccess({ | |
| 4077 | cwd: "/w", | |
| 4078 | repository: "acme/web", | |
| 4079 | host: "github.com", | |
| 4080 | number: 7, | |
| 4081 | }); | |
| 4082 | ||
| 4083 | // One request, because both answers hang off the same repository object. | |
| 4084 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4085 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4086 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); | |
| 4087 | expect(access).toEqual({ | |
| 4088 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4089 | canWrite: false, | |
| 4090 | canTriage: false, | |
| 4091 | canUpdate: true, | |
| 4092 | didAuthor: true, | |
| 4093 | }); | |
| 4094 | }), | |
| 4095 | ); | |
| 4096 | ||
| 4097 | it.effect("stops GraphQL reads at the protected reserve until reset", () => | |
| 4098 | Effect.gen(function* () { | |
| 4099 | mockedExecute.mockReturnValue( | |
| 4100 | Effect.succeed( | |
| 4101 | output( | |
| 4102 | encodeJson({ | |
| 4103 | data: { | |
| 4104 | repository: { | |
| 4105 | mergeCommitAllowed: true, | |
| 4106 | squashMergeAllowed: false, | |
| 4107 | rebaseMergeAllowed: true, | |
| 4108 | viewerPermission: "READ", | |
| 4109 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4110 | }, | |
| 4111 | }, | |
| 4112 | }), | |
| 4113 | false, | |
| 4114 | false, | |
| 4115 | graphqlQuota(499), | |
| 4116 | ), | |
| 4117 | ), | |
| 4118 | ); | |
| 4119 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4120 | const input = { | |
| 4121 | cwd: "/w", | |
| 4122 | repository: "acme/web", | |
| 4123 | host: "github.com", | |
| 4124 | number: 7, | |
| 4125 | } as const; | |
| 4126 | ||
| 4127 | yield* cli.getViewerAccess(input); | |
| 4128 | ||
| 4129 | const error = yield* Effect.flip(cli.getViewerAccess(input)); | |
| 4130 | ||
| 4131 | assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); | |
| 4132 | if (error._tag !== "SourceControlRateLimitPausedError") return; | |
| 4133 | assert.strictEqual(error.host, "github.com"); | |
| 4134 | assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); | |
| 4135 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4136 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4137 | }), | |
| 4138 | ); | |
| 4139 | ||
| 4140 | it.effect("lets an interactive permission read use the protected reserve", () => | |
| 4141 | Effect.gen(function* () { | |
| 4142 | mockedExecute | |
| 4143 | .mockReturnValueOnce( | |
| 4144 | Effect.succeed( | |
| 4145 | output( | |
| 4146 | encodeJson({ | |
| 4147 | data: { | |
| 4148 | repository: { | |
| 4149 | mergeCommitAllowed: true, | |
| 4150 | squashMergeAllowed: false, | |
| 4151 | rebaseMergeAllowed: true, | |
| 4152 | viewerPermission: "READ", | |
| 4153 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4154 | }, | |
| 4155 | }, | |
| 4156 | }), | |
| 4157 | false, | |
| 4158 | false, | |
| 4159 | graphqlQuota(499), | |
| 4160 | ), | |
| 4161 | ), | |
| 4162 | ) | |
| 4163 | .mockReturnValueOnce( | |
| 4164 | Effect.succeed( | |
| 4165 | output( | |
| 4166 | encodeJson({ | |
| 4167 | data: { | |
| 4168 | repository: { | |
| 4169 | mergeCommitAllowed: true, | |
| 4170 | squashMergeAllowed: false, | |
| 4171 | rebaseMergeAllowed: true, | |
| 4172 | viewerPermission: "READ", | |
| 4173 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4174 | }, | |
| 4175 | }, | |
| 4176 | }), | |
| 4177 | ), | |
| 4178 | ), | |
| 4179 | ); | |
| 4180 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4181 | ||
| 4182 | yield* cli.getViewerAccess({ | |
| 4183 | cwd: "/w", | |
| 4184 | repository: "acme/web", | |
| 4185 | host: "github.com", | |
| 4186 | number: 7, | |
| 4187 | }); | |
| 4188 | const access = yield* cli.getViewerAccess({ | |
| 4189 | cwd: "/w", | |
| 4190 | repository: "acme/web", | |
| 4191 | host: "github.com", | |
| 4192 | number: 7, | |
| 4193 | allowReserve: true, | |
| 4194 | }); | |
| 4195 | ||
| 4196 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4197 | expect(access).toEqual({ | |
| 4198 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4199 | canWrite: false, | |
| 4200 | canTriage: false, | |
| 4201 | canUpdate: true, | |
| 4202 | didAuthor: true, | |
| 4203 | }); | |
| 4204 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4205 | }), | |
| 4206 | ); | |
| 4207 | ||
| 4208 | it.effect("asks GitHub to review, naming the collection a request is added to", () => | |
| 4209 | Effect.gen(function* () { | |
| 4210 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4211 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4212 | ||
| 4213 | yield* cli.setReviewerRequest({ | |
| 4214 | cwd: "/w", | |
| 4215 | repository: "acme/web", | |
| 4216 | host: "github.com", | |
| 4217 | number: 7, | |
| 4218 | reviewers: [ | |
| 4219 | { id: "octocat", kind: "user" }, | |
| 4220 | { id: "reviewers", kind: "team" }, | |
| 4221 | ], | |
| 4222 | requested: true, | |
| 4223 | }); | |
| 4224 | ||
| 4225 | expect(callAt(0)).toMatchObject({ | |
| 4226 | kind: "rest", | |
| 4227 | method: "POST", | |
| 4228 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4229 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, | |
| 4230 | }); | |
| 4231 | }), | |
| 4232 | ); | |
| 4233 | ||
| 4234 | it.effect("takes a request back by deleting from the same collection it was added to", () => | |
| 4235 | Effect.gen(function* () { | |
| 4236 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4237 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4238 | ||
| 4239 | yield* cli.setReviewerRequest({ | |
| 4240 | cwd: "/w", | |
| 4241 | repository: "acme/web", | |
| 4242 | host: "github.com", | |
| 4243 | number: 7, | |
| 4244 | reviewers: [{ id: "octocat", kind: "user" }], | |
| 4245 | requested: false, | |
| 4246 | }); | |
| 4247 | ||
| 4248 | expect(callAt(0)).toMatchObject({ | |
| 4249 | kind: "rest", | |
| 4250 | method: "DELETE", | |
| 4251 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4252 | body: { reviewers: ["octocat"], team_reviewers: [] }, | |
| 4253 | }); | |
| 4254 | }), | |
| 4255 | ); | |
| 4256 | ||
| 4257 | it.effect("reads who may review and who already has in one request", () => | |
| 4258 | Effect.gen(function* () { | |
| 4259 | mockedExecute.mockReturnValue( | |
| 4260 | Effect.succeed( | |
| 4261 | output( | |
| 4262 | encodeJson({ | |
| 4263 | data: { | |
| 4264 | repository: { | |
| 4265 | assignableUsers: { | |
| 4266 | pageInfo: { hasNextPage: false }, | |
| 4267 | nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], | |
| 4268 | }, | |
| 4269 | pullRequest: { | |
| 4270 | author: { login: "bilal" }, | |
| 4271 | reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, | |
| 4272 | }, | |
| 4273 | }, | |
| 4274 | }, | |
| 4275 | }), | |
| 4276 | ), | |
| 4277 | ), | |
| 4278 | ); | |
| 4279 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4280 | ||
| 4281 | const list = yield* cli.listReviewerCandidates({ | |
| 4282 | cwd: "/w", | |
| 4283 | repository: "acme/web", | |
| 4284 | host: "github.com", | |
| 4285 | number: 7, | |
| 4286 | }); | |
| 4287 | ||
| 4288 | // The people, who has been asked and who opened the pull request all hang off the same | |
| 4289 | // repository object, so the menu costs one request. | |
| 4290 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4291 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4292 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 4293 | ["octocat", true], | |
| 4294 | ["hubot", false], | |
| 4295 | ]); | |
| 4296 | }), | |
| 4297 | ); | |
| 4298 | ||
| 4299 | it.effect("puts labels on by posting to the issue's own collection, all at once", () => | |
| 4300 | Effect.gen(function* () { | |
| 4301 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4302 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4303 | ||
| 4304 | yield* cli.setLabels({ | |
| 4305 | cwd: "/w", | |
| 4306 | repository: "acme/web", | |
| 4307 | host: "github.com", | |
| 4308 | number: 7, | |
| 4309 | labels: ["bug", "size:XL"], | |
| 4310 | applied: true, | |
| 4311 | }); | |
| 4312 | ||
| 4313 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4314 | expect(callAt(0)).toMatchObject({ | |
| 4315 | kind: "rest", | |
| 4316 | method: "POST", | |
| 4317 | path: "repos/acme/web/issues/7/labels", | |
| 4318 | body: { labels: ["bug", "size:XL"] }, | |
| 4319 | }); | |
| 4320 | }), | |
| 4321 | ); | |
| 4322 | ||
| 4323 | it.effect("takes labels off one at a time, naming each in the path encoded", () => | |
| 4324 | Effect.gen(function* () { | |
| 4325 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4326 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4327 | ||
| 4328 | yield* cli.setLabels({ | |
| 4329 | cwd: "/w", | |
| 4330 | repository: "acme/web", | |
| 4331 | host: "github.com", | |
| 4332 | number: 7, | |
| 4333 | labels: ["good first issue", "area/web"], | |
| 4334 | applied: false, | |
| 4335 | }); | |
| 4336 | ||
| 4337 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4338 | expect(callAt(0)).toMatchObject({ | |
| 4339 | method: "DELETE", | |
| 4340 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", | |
| 4341 | }); | |
| 4342 | expect(callAt(1)).toMatchObject({ | |
| 4343 | method: "DELETE", | |
| 4344 | path: "repos/acme/web/issues/7/labels/area%2Fweb", | |
| 4345 | }); | |
| 4346 | }), | |
| 4347 | ); | |
| 4348 | ||
| 4349 | it.effect("reads every page of viewed files, and says so when there are too many", () => | |
| 4350 | Effect.gen(function* () { | |
| 4351 | const page = (index: number, hasNextPage: boolean) => | |
| 4352 | Effect.succeed( | |
| 4353 | output( | |
| 4354 | encodeJson({ | |
| 4355 | data: { | |
| 4356 | repository: { | |
| 4357 | pullRequest: { | |
| 4358 | files: { | |
| 4359 | pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, | |
| 4360 | nodes: [ | |
| 4361 | { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, | |
| 4362 | { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, | |
| 4363 | ], | |
| 4364 | }, | |
| 4365 | }, | |
| 4366 | }, | |
| 4367 | }, | |
| 4368 | }), | |
| 4369 | ), | |
| 4370 | ); | |
| 4371 | mockedExecute | |
| 4372 | .mockReturnValueOnce(page(0, true)) | |
| 4373 | .mockReturnValueOnce(page(1, true)) | |
| 4374 | .mockReturnValueOnce(page(2, false)); | |
| 4375 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4376 | ||
| 4377 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4378 | cwd: "/w", | |
| 4379 | repository: "acme/web", | |
| 4380 | host: "github.com", | |
| 4381 | number: 7, | |
| 4382 | }); | |
| 4383 | ||
| 4384 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4385 | // The first page asks from the start; each one after it carries the cursor before it. | |
| 4386 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ | |
| 4387 | null, | |
| 4388 | "cursor-0", | |
| 4389 | "cursor-1", | |
| 4390 | ]); | |
| 4391 | assert.isFalse(viewed.truncated); | |
| 4392 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ | |
| 4393 | ["src/file0.ts", "viewed"], | |
| 4394 | ["src/other0.ts", "unviewed"], | |
| 4395 | ["src/file1.ts", "viewed"], | |
| 4396 | ["src/other1.ts", "unviewed"], | |
| 4397 | ["src/file2.ts", "viewed"], | |
| 4398 | ["src/other2.ts", "unviewed"], | |
| 4399 | ]); | |
| 4400 | }), | |
| 4401 | ); | |
| 4402 | ||
| 4403 | it.effect("stops paging viewed files rather than following a change without end", () => | |
| 4404 | Effect.gen(function* () { | |
| 4405 | mockedExecute.mockReturnValue( | |
| 4406 | Effect.succeed( | |
| 4407 | output( | |
| 4408 | encodeJson({ | |
| 4409 | data: { | |
| 4410 | repository: { | |
| 4411 | pullRequest: { | |
| 4412 | files: { | |
| 4413 | pageInfo: { hasNextPage: true, endCursor: "cursor" }, | |
| 4414 | nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], | |
| 4415 | }, | |
| 4416 | }, | |
| 4417 | }, | |
| 4418 | }, | |
| 4419 | }), | |
| 4420 | ), | |
| 4421 | ), | |
| 4422 | ); | |
| 4423 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4424 | ||
| 4425 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4426 | cwd: "/w", | |
| 4427 | repository: "acme/web", | |
| 4428 | host: "github.com", | |
| 4429 | number: 7, | |
| 4430 | }); | |
| 4431 | ||
| 4432 | // The same cursor twice is a page GitHub already gave, so reading stops there. | |
| 4433 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4434 | assert.isTrue(viewed.truncated); | |
| 4435 | }), | |
| 4436 | ); | |
| 4437 | ||
| 4438 | it.effect("clears and restores a burst of files in one request", () => | |
| 4439 | Effect.gen(function* () { | |
| 4440 | mockedExecute | |
| 4441 | .mockReturnValueOnce( | |
| 4442 | Effect.succeed( | |
| 4443 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4444 | ), | |
| 4445 | ) | |
| 4446 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4447 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4448 | ||
| 4449 | yield* cli.setPullRequestFilesViewed({ | |
| 4450 | cwd: "/w", | |
| 4451 | repository: "acme/web", | |
| 4452 | host: "github.com", | |
| 4453 | number: 23, | |
| 4454 | files: [ | |
| 4455 | { path: "src/a.ts", viewed: true }, | |
| 4456 | { path: "src/b.ts", viewed: false }, | |
| 4457 | ], | |
| 4458 | }); | |
| 4459 | ||
| 4460 | // One request to learn the pull request's node id, one for every press together. | |
| 4461 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4462 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); | |
| 4463 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); | |
| 4464 | expect(varsAt(1)).toEqual({ | |
| 4465 | pullRequestId: "PR_1", | |
| 4466 | f0_path: "src/a.ts", | |
| 4467 | f1_path: "src/b.ts", | |
| 4468 | }); | |
| 4469 | }), | |
| 4470 | ); | |
| 4471 | ||
| 4472 | it.effect("asks the host nothing when nothing was pressed", () => | |
| 4473 | Effect.gen(function* () { | |
| 4474 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4475 | ||
| 4476 | yield* cli.setPullRequestFilesViewed({ | |
| 4477 | cwd: "/w", | |
| 4478 | repository: "acme/web", | |
| 4479 | host: "github.com", | |
| 4480 | number: 7, | |
| 4481 | files: [], | |
| 4482 | }); | |
| 4483 | ||
| 4484 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 4485 | }), | |
| 4486 | ); | |
| 4487 | ||
| 4488 | it.effect("looks a pull request's node id up once, however often it is written to", () => | |
| 4489 | Effect.gen(function* () { | |
| 4490 | mockedExecute | |
| 4491 | .mockReturnValueOnce( | |
| 4492 | Effect.succeed( | |
| 4493 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4494 | ), | |
| 4495 | ) | |
| 4496 | .mockReturnValue(Effect.succeed(output("{}"))); | |
| 4497 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4498 | const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; | |
| 4499 | ||
| 4500 | yield* cli.setPullRequestFilesViewed({ | |
| 4501 | ...pullRequest, | |
| 4502 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4503 | }); | |
| 4504 | yield* cli.setPullRequestFilesViewed({ | |
| 4505 | ...pullRequest, | |
| 4506 | files: [{ path: "src/b.ts", viewed: true }], | |
| 4507 | }); | |
| 4508 | yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); | |
| 4509 | ||
| 4510 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. | |
| 4511 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 4512 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); | |
| 4513 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ | |
| 4514 | "PR_24", | |
| 4515 | "PR_24", | |
| 4516 | "PR_24", | |
| 4517 | ]); | |
| 4518 | }), | |
| 4519 | ); | |
| 4520 | ||
| 4521 | it.effect("does not remember a node id lookup that failed", () => | |
| 4522 | Effect.gen(function* () { | |
| 4523 | mockedExecute | |
| 4524 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) | |
| 4525 | .mockReturnValueOnce( | |
| 4526 | Effect.succeed( | |
| 4527 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4528 | ), | |
| 4529 | ) | |
| 4530 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4531 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4532 | const write = () => | |
| 4533 | cli.setPullRequestFilesViewed({ | |
| 4534 | cwd: "/w", | |
| 4535 | repository: "acme/web", | |
| 4536 | host: "github.com", | |
| 4537 | number: 25, | |
| 4538 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4539 | }); | |
| 4540 | ||
| 4541 | const error = yield* Effect.flip(write()); | |
| 4542 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 4543 | ||
| 4544 | yield* write(); | |
| 4545 | ||
| 4546 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4547 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); | |
| 4548 | }), | |
| 4549 | ); | |
| 4550 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => | |
| 4551 | Effect.gen(function* () { | |
| 4552 | // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is | |
| 4553 | // working down is the first thing evicted once a listing has walked a cache's worth of cold | |
| 4554 | // pull requests, and every press after that pays a round trip again. | |
| 4555 | // This block shares one cache, so these numbers are its own and it runs last. | |
| 4556 | const HOT = 9_000; | |
| 4557 | const lookupsOf = new Map<number, number>(); | |
| 4558 | mockedExecute.mockImplementation((input) => { | |
| 4559 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { | |
| 4560 | return Effect.succeed(output("{}")); | |
| 4561 | } | |
| 4562 | const number = Number(input.variables?.["number"]); | |
| 4563 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); | |
| 4564 | return Effect.succeed( | |
| 4565 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), | |
| 4566 | ); | |
| 4567 | }); | |
| 4568 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4569 | const tick = (number: number) => | |
| 4570 | cli.setPullRequestFilesViewed({ | |
| 4571 | cwd: "/w", | |
| 4572 | repository: "acme/web", | |
| 4573 | host: "github.com", | |
| 4574 | number, | |
| 4575 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4576 | }); | |
| 4577 | ||
| 4578 | yield* tick(HOT); | |
| 4579 | // A cache's worth of cold pull requests, with the open one pressed in between each of them. | |
| 4580 | for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { | |
| 4581 | yield* tick(HOT + 1 + filled); | |
| 4582 | yield* tick(HOT); | |
| 4583 | } | |
| 4584 | ||
| 4585 | assert.strictEqual(lookupsOf.get(HOT), 1); | |
| 4586 | }), | |
| 4587 | ); | |
| 4588 | }); |
apps/server/src/pullRequest/GitHubPullRequestApi.ts deleted-2857| ... | ... | @@ -1,2857 +0,0 @@ |
| 1 | import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; | |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Clock from "effect/Clock"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Request from "effect/Request"; | |
| 14 | import * as RequestResolver from "effect/RequestResolver"; | |
| 15 | import * as Result from "effect/Result"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import { | |
| 19 | resolvePullRequestAuthorFilter, | |
| 20 | PositiveInt, | |
| 21 | TrimmedNonEmptyString, | |
| 22 | type PullRequestAction, | |
| 23 | type PullRequestStackHead, | |
| 24 | type PullRequestActor, | |
| 25 | type PullRequestComment, | |
| 26 | type PullRequestCommit, | |
| 27 | type PullRequestFileViewed, | |
| 28 | type PullRequestInvolvement, | |
| 29 | type PullRequestListFilters, | |
| 30 | type PullRequestListState, | |
| 31 | type PullRequestMergeMethod, | |
| 32 | type PullRequestOmittedFileStat, | |
| 33 | type PullRequestReaction, | |
| 34 | type PullRequestReactionContent, | |
| 35 | type PullRequestReviewCommentDraft, | |
| 36 | type PullRequestReviewVerdict, | |
| 37 | type PullRequestReviewerCandidateList, | |
| 38 | type PullRequestReviewerKind, | |
| 39 | type PullRequestLabelCandidateList, | |
| 40 | type PullRequestThreadCommentsResult, | |
| 41 | type PullRequestUpdateMethod, | |
| 42 | type PullRequestPreview, | |
| 43 | } from "@t3tools/contracts"; | |
| 44 | ||
| 45 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 46 | import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; | |
| 47 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 48 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 49 | import { | |
| 50 | ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 51 | ADD_REACTION_GRAPHQL_MUTATION, | |
| 52 | buildReviewSubmission, | |
| 53 | buildReviewerRequest, | |
| 54 | buildSetFilesViewedGraphQlMutation, | |
| 55 | decodeActorAvatarsJson, | |
| 56 | decodePullRequestActivityJson, | |
| 57 | decodePullRequestCheckContextsJson, | |
| 58 | decodePullRequestCoreJson, | |
| 59 | decodeCommitFilesJson, | |
| 60 | pullRequestCheckContextsGraphQlQuery, | |
| 61 | pullRequestChecksFromContexts, | |
| 62 | pullRequestListGraphQlQuery, | |
| 63 | pullRequestSummaryGraphQlQuery, | |
| 64 | PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 65 | PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 66 | decodeWorkflowRunsJson, | |
| 67 | pullRequestCoreGraphQlQuery, | |
| 68 | type GitHubPullRequestCore, | |
| 69 | type GitHubPullRequestSummary, | |
| 70 | type GitHubPullRequestWatchFingerprint, | |
| 71 | decodePullRequestPreviewJson, | |
| 72 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 73 | decodePullRequestFilesJson, | |
| 74 | decodePullRequestFilesViewedJson, | |
| 75 | decodePullRequestHeadsJson, | |
| 76 | decodePullRequestListJson, | |
| 77 | decodePullRequestNodeIdJson, | |
| 78 | decodePullRequestSearchJson, | |
| 79 | decodePullRequestStacksJson, | |
| 80 | decodePullRequestStatsJson, | |
| 81 | decodePullRequestSummariesJson, | |
| 82 | decodePullRequestWatchFingerprintsJson, | |
| 83 | decodeReactionSubjectScopeJson, | |
| 84 | decodeReviewerCandidatesJson, | |
| 85 | decodeLabelCandidatesJson, | |
| 86 | buildLabelRequest, | |
| 87 | LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 88 | decodeReviewDismissalsJson, | |
| 89 | decodeReviewThreadCommentsJson, | |
| 90 | decodeReviewThreadsJson, | |
| 91 | buildPullRequestStatsGraphQlQuery, | |
| 92 | buildPullRequestSummariesGraphQlQuery, | |
| 93 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 94 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 95 | decodePullRequestStackMembershipsJson, | |
| 96 | pullRequestSearchGraphQlQuery, | |
| 97 | PULL_REQUEST_SEARCH_MAX_ROWS, | |
| 98 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 99 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 100 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 101 | REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 102 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 103 | gitHubReactionContent, | |
| 104 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 105 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 106 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 107 | REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 108 | REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 109 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 110 | reviewThreadConversation, | |
| 111 | UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 112 | UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, | |
| 113 | UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 114 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 115 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 116 | decodeViewerPermissionsJson, | |
| 117 | type GitHubPullRequestActivity, | |
| 118 | type GitHubPullRequestActivityPage, | |
| 119 | type GitHubWorkflowRunPage, | |
| 120 | type GitHubPullRequestHead, | |
| 121 | type GitHubPullRequestListItem, | |
| 122 | type GitHubPullRequestSearchItem, | |
| 123 | type GitHubPullRequestStack, | |
| 124 | type GitHubReviewThreadComments, | |
| 125 | type GitHubRepositoryAccess, | |
| 126 | type GitHubWorkflowRunApproval, | |
| 127 | type GitHubReviewThreadEntry, | |
| 128 | type GitHubReviewThreadPage, | |
| 129 | type GitHubViewerAccess, | |
| 130 | } from "./gitHubPullRequestJson.ts"; | |
| 131 | import type { | |
| 132 | ProviderChangeRequestSummary, | |
| 133 | ProviderListCursor, | |
| 134 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 135 | ||
| 136 | /** | |
| 137 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 138 | * rather than borrowing another operation's message. | |
| 139 | */ | |
| 140 | export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullRequestReadError>()( | |
| 141 | "GitHubPullRequestReadError", | |
| 142 | { | |
| 143 | cwd: Schema.String, | |
| 144 | operation: Schema.String, | |
| 145 | cause: Schema.Defect(), | |
| 146 | }, | |
| 147 | ) { | |
| 148 | override get message(): string { | |
| 149 | return `GitHub returned an unreadable ${this.operation} response.`; | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | /** Not a decode failure: GitHub answered, the account it answered for just has no login. */ | |
| 154 | export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHubViewerLoginUnavailableError>()( | |
| 155 | "GitHubViewerLoginUnavailableError", | |
| 156 | { | |
| 157 | cwd: Schema.String, | |
| 158 | }, | |
| 159 | ) { | |
| 160 | override get message(): string { | |
| 161 | return "GitHub returned no login for the authenticated account."; | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | /** Not a decode failure: GitHub answered, but the pull request carried no update time. */ | |
| 166 | export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError<GitHubPullRequestUpdatedAtUnavailableError>()( | |
| 167 | "GitHubPullRequestUpdatedAtUnavailableError", | |
| 168 | { | |
| 169 | cwd: Schema.String, | |
| 170 | repository: Schema.String, | |
| 171 | number: Schema.Int, | |
| 172 | }, | |
| 173 | ) { | |
| 174 | override get message(): string { | |
| 175 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | |
| 180 | export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorError>()( | |
| 181 | "GitHubDiffCursorError", | |
| 182 | { | |
| 183 | cwd: Schema.String, | |
| 184 | }, | |
| 185 | ) { | |
| 186 | override get message(): string { | |
| 187 | return "The diff cursor was not one this pull request handed out."; | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 192 | export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitError>()( | |
| 193 | "GitHubDiffCommitError", | |
| 194 | { | |
| 195 | cwd: Schema.String, | |
| 196 | }, | |
| 197 | ) { | |
| 198 | override get message(): string { | |
| 199 | return "The named commit was not a commit sha."; | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | /** The revisions read successfully, but cannot name both sides this file needs. */ | |
| 204 | export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitHubDiffRevisionsUnavailableError>()( | |
| 205 | "GitHubDiffRevisionsUnavailableError", | |
| 206 | { | |
| 207 | cwd: Schema.String, | |
| 208 | number: Schema.Int, | |
| 209 | commit: Schema.optional(Schema.String), | |
| 210 | }, | |
| 211 | ) { | |
| 212 | override get message(): string { | |
| 213 | return this.commit === undefined | |
| 214 | ? `Pull request #${this.number} reported no usable base and head revisions.` | |
| 215 | : `Commit ${this.commit} reported no usable revisions for this file.`; | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | |
| 220 | export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<GitHubDiffFileContentsUnavailableError>()( | |
| 221 | "GitHubDiffFileContentsUnavailableError", | |
| 222 | { | |
| 223 | cwd: Schema.String, | |
| 224 | path: Schema.String, | |
| 225 | reason: Schema.Literals(["oversized", "binary"]), | |
| 226 | }, | |
| 227 | ) { | |
| 228 | override get message(): string { | |
| 229 | return this.reason === "oversized" | |
| 230 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | |
| 231 | : `The diff file '${this.path}' is binary.`; | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL | |
| 237 | * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a | |
| 238 | * name that is not one is refused here rather than escaped into something GitHub might read as a | |
| 239 | * qualifier of its own. | |
| 240 | */ | |
| 241 | export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepositorySelectorError>()( | |
| 242 | "GitHubRepositorySelectorError", | |
| 243 | { | |
| 244 | cwd: Schema.String, | |
| 245 | operation: Schema.String, | |
| 246 | }, | |
| 247 | ) { | |
| 248 | override get message(): string { | |
| 249 | return "A repository was named that GitHub cannot address."; | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /** Not a decode failure: the reader named a subject this pull request never handed out. */ | |
| 254 | export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectScopeError>()( | |
| 255 | "GitHubSubjectScopeError", | |
| 256 | { | |
| 257 | cwd: Schema.String, | |
| 258 | operation: Schema.String, | |
| 259 | }, | |
| 260 | ) { | |
| 261 | override get message(): string { | |
| 262 | return "The named subject did not belong to the named pull request."; | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | /** GitHub answered successfully, but approving every returned workflow would be unsafe. */ | |
| 267 | export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHubWorkflowApprovalRefusedError>()( | |
| 268 | "GitHubWorkflowApprovalRefusedError", | |
| 269 | { | |
| 270 | cwd: Schema.String, | |
| 271 | number: Schema.Int, | |
| 272 | reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), | |
| 273 | observedCount: Schema.Int, | |
| 274 | limit: Schema.Int, | |
| 275 | }, | |
| 276 | ) { | |
| 277 | override get message(): string { | |
| 278 | if (this.reason === "head-list-truncated") { | |
| 279 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | |
| 280 | } | |
| 281 | if (this.reason === "head-not-unique") { | |
| 282 | return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; | |
| 283 | } | |
| 284 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ | |
| 289 | export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError<GitHubWorkflowApprovalHeadUnavailableError>()( | |
| 290 | "GitHubWorkflowApprovalHeadUnavailableError", | |
| 291 | { | |
| 292 | cwd: Schema.String, | |
| 293 | number: Schema.Int, | |
| 294 | }, | |
| 295 | ) { | |
| 296 | override get message(): string { | |
| 297 | return `GitHub did not report a complete head revision for #${this.number}.`; | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** The pull request moved after its approval candidates were read. */ | |
| 302 | export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<GitHubWorkflowApprovalHeadChangedError>()( | |
| 303 | "GitHubWorkflowApprovalHeadChangedError", | |
| 304 | { | |
| 305 | cwd: Schema.String, | |
| 306 | number: Schema.Int, | |
| 307 | }, | |
| 308 | ) { | |
| 309 | override get message(): string { | |
| 310 | return `The head revision of #${this.number} changed before its workflows could be approved.`; | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | export type GitHubPullRequestApiError = | |
| 315 | | GitHubStackActionError | |
| 316 | | GitHubApi.GitHubApiError | |
| 317 | | GitHubPullRequestReadError | |
| 318 | | GitHubDiffCursorError | |
| 319 | | GitHubDiffCommitError | |
| 320 | | GitHubDiffRevisionsUnavailableError | |
| 321 | | GitHubDiffFileContentsUnavailableError | |
| 322 | | GitHubRepositorySelectorError | |
| 323 | | GitHubSubjectScopeError | |
| 324 | | GitHubWorkflowApprovalRefusedError | |
| 325 | | GitHubWorkflowApprovalHeadUnavailableError | |
| 326 | | GitHubWorkflowApprovalHeadChangedError | |
| 327 | | SourceControlRateLimit.SourceControlRateLimitPausedError | |
| 328 | | GitHubViewerLoginUnavailableError | |
| 329 | | GitHubPullRequestUpdatedAtUnavailableError; | |
| 330 | ||
| 331 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ | |
| 332 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 333 | const DIFF_TIMEOUT = Duration.seconds(60); | |
| 334 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ | |
| 335 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | |
| 336 | ||
| 337 | /** What `gh pr list --state` asked a repository's own list for, without search. */ | |
| 338 | const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = { | |
| 339 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 340 | open: ["OPEN"], | |
| 341 | // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out | |
| 342 | // locally by `matchesUnsortedListing`. | |
| 343 | closed: ["CLOSED", "MERGED"], | |
| 344 | merged: ["MERGED"], | |
| 345 | }; | |
| 346 | ||
| 347 | interface GitHubPullRequestListPage { | |
| 348 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 349 | readonly rawCount: number; | |
| 350 | readonly endCursor: string | null; | |
| 351 | } | |
| 352 | ||
| 353 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ | |
| 354 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; | |
| 355 | ||
| 356 | /** What the files API serves at most in one response, which is what one slice is made of. */ | |
| 357 | const DIFF_FILES_PAGE_SIZE = 100; | |
| 358 | /** | |
| 359 | * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL | |
| 360 | * budget per page, against a change request nobody reviews in one sitting past the first few | |
| 361 | * hundred files: beyond this the read stops and says it was cut short. | |
| 362 | */ | |
| 363 | const FILES_VIEWED_MAX_PAGES = 5; | |
| 364 | ||
| 365 | /** | |
| 366 | * How many pull requests' node ids are remembered at once. A long-lived server sees far more of | |
| 367 | * them than a reader ever has open, and least recently used rather than first in: a listing | |
| 368 | * walking cold pull requests must not evict the review being ticked through. | |
| 369 | */ | |
| 370 | export const NODE_ID_CACHE_CAPACITY = 128; | |
| 371 | ||
| 372 | /** | |
| 373 | * Pages of review threads to follow before the conversation is reported as truncated. GitHub | |
| 374 | * serves a hundred threads a page, so this is a thousand threads — past anything a pull request | |
| 375 | * a person is reading has, and short of walking a repository-sized conversation forever. | |
| 376 | */ | |
| 377 | const REVIEW_THREAD_PAGES = 10; | |
| 378 | ||
| 379 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ | |
| 380 | const CHECK_CONTEXT_PAGES = 10; | |
| 381 | ||
| 382 | export interface GitHubPullRequestListBatch { | |
| 383 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 384 | readonly truncated: boolean; | |
| 385 | /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ | |
| 386 | readonly continues: boolean; | |
| 387 | } | |
| 388 | ||
| 389 | export interface GitHubPullRequestStat { | |
| 390 | readonly repository: string; | |
| 391 | readonly number: number; | |
| 392 | readonly additions: number; | |
| 393 | readonly deletions: number; | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request | |
| 398 | * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. | |
| 399 | */ | |
| 400 | const STAT_ALIASES_PER_REQUEST = 25; | |
| 401 | const STAT_REQUEST_CONCURRENCY = 4; | |
| 402 | /** | |
| 403 | * How long a summary read waits for company. The background sync asks for every linked pull | |
| 404 | * request at once, and each read reaches the resolver after its own cache check, so a batch | |
| 405 | * needs a moment longer than one scheduler tick to gather them. | |
| 406 | */ | |
| 407 | const SUMMARY_BATCH_WINDOW = "10 millis"; | |
| 408 | ||
| 409 | class PullRequestSummaryRead extends Request.Class< | |
| 410 | { | |
| 411 | readonly cwd: string; | |
| 412 | readonly repository: string; | |
| 413 | readonly host: string; | |
| 414 | readonly number: number; | |
| 415 | }, | |
| 416 | ProviderChangeRequestSummary, | |
| 417 | GitHubPullRequestApiError | |
| 418 | > {} | |
| 419 | ||
| 420 | class PullRequestWatchFingerprintRead extends Request.Class< | |
| 421 | { | |
| 422 | readonly cwd: string; | |
| 423 | readonly repository: string; | |
| 424 | readonly host: string; | |
| 425 | readonly number: number; | |
| 426 | }, | |
| 427 | GitHubPullRequestWatchFingerprint | null, | |
| 428 | GitHubPullRequestApiError | |
| 429 | > {} | |
| 430 | ||
| 431 | export interface GitHubPullRequestSearchBatch { | |
| 432 | /** Rows across every repository asked for, newest update first, each naming its own. */ | |
| 433 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 434 | readonly truncated: boolean; | |
| 435 | } | |
| 436 | ||
| 437 | export interface GitHubPullRequestDiffSlice { | |
| 438 | readonly patch: string; | |
| 439 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | |
| 440 | readonly truncated: boolean; | |
| 441 | /** Where the next slice starts, or null once the patch is whole. */ | |
| 442 | readonly nextCursor: string | null; | |
| 443 | /** GitHub's own counts for the files whose hunks it withheld from this slice. */ | |
| 444 | readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 445 | } | |
| 446 | ||
| 447 | export interface GitHubPullRequestFilesViewed { | |
| 448 | readonly files: ReadonlyArray<PullRequestFileViewed>; | |
| 449 | /** GitHub had more files than the page budget below would read. */ | |
| 450 | readonly truncated: boolean; | |
| 451 | } | |
| 452 | ||
| 453 | export class GitHubPullRequestApi extends Context.Service< | |
| 454 | GitHubPullRequestApi, | |
| 455 | { | |
| 456 | readonly withVerifiedCredential: <A, E, R>( | |
| 457 | input: { readonly cwd: string; readonly host: string }, | |
| 458 | use: (identity: { | |
| 459 | readonly accountId: string; | |
| 460 | readonly viewer: string; | |
| 461 | readonly credentialFingerprint: string; | |
| 462 | }) => Effect.Effect<A, E, R>, | |
| 463 | ) => Effect.Effect<A, E | GitHubPullRequestApiError, R>; | |
| 464 | readonly getRoutingIdentity: (input: { | |
| 465 | readonly cwd: string; | |
| 466 | readonly host: string; | |
| 467 | }) => Effect.Effect< | |
| 468 | { readonly accountId: string; readonly viewer: string }, | |
| 469 | GitHubPullRequestApiError | |
| 470 | >; | |
| 471 | readonly getViewerLogin: (input: { | |
| 472 | readonly cwd: string; | |
| 473 | readonly host: string; | |
| 474 | }) => Effect.Effect<string, GitHubPullRequestApiError>; | |
| 475 | ||
| 476 | readonly listPullRequests: (input: { | |
| 477 | readonly cwd: string; | |
| 478 | readonly repository: string; | |
| 479 | readonly host: string; | |
| 480 | readonly state: PullRequestListState; | |
| 481 | readonly involvement: PullRequestInvolvement; | |
| 482 | readonly viewer: string; | |
| 483 | readonly limit: number; | |
| 484 | /** Free text for `--search`, matched as one literal phrase. */ | |
| 485 | readonly query?: string | undefined; | |
| 486 | /** Where to carry on from, as a `updated:` qualifier on the same search. */ | |
| 487 | readonly cursor?: ProviderListCursor | undefined; | |
| 488 | /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ | |
| 489 | readonly filters?: PullRequestListFilters | undefined; | |
| 490 | }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError>; | |
| 491 | ||
| 492 | /** | |
| 493 | * The same listing for a whole host in one search. `limit` is the size of the slice across | |
| 494 | * all of the repositories rather than per repository, because that is what a search answers: | |
| 495 | * the newest rows of the lot, which is exactly the page. | |
| 496 | */ | |
| 497 | readonly searchPullRequests: (input: { | |
| 498 | /** Any checkout on the host; the search names its repositories itself. */ | |
| 499 | readonly cwd: string; | |
| 500 | readonly host: string; | |
| 501 | readonly repositories: ReadonlyArray<string>; | |
| 502 | readonly state: PullRequestListState; | |
| 503 | readonly involvement: PullRequestInvolvement; | |
| 504 | readonly viewer: string; | |
| 505 | readonly limit: number; | |
| 506 | readonly query?: string | undefined; | |
| 507 | readonly cursor?: ProviderListCursor | undefined; | |
| 508 | readonly filters?: PullRequestListFilters | undefined; | |
| 509 | }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestApiError>; | |
| 510 | ||
| 511 | /** The line counts the search leaves out, for rows already on the page. */ | |
| 512 | readonly listPullRequestStats: (input: { | |
| 513 | readonly cwd: string; | |
| 514 | readonly host: string; | |
| 515 | readonly changeRequests: ReadonlyArray<{ | |
| 516 | readonly repository: string; | |
| 517 | readonly number: number; | |
| 518 | }>; | |
| 519 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestApiError>; | |
| 520 | ||
| 521 | readonly getPullRequestSummary: (input: { | |
| 522 | readonly cwd: string; | |
| 523 | readonly repository: string; | |
| 524 | readonly host: string; | |
| 525 | readonly number: number; | |
| 526 | }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestApiError>; | |
| 527 | /** | |
| 528 | * What a watch compares between passes, batched like summaries. Null when GitHub gave no | |
| 529 | * answer for this pull request, so the watch reads it in full instead. | |
| 530 | */ | |
| 531 | readonly getPullRequestWatchFingerprint: (input: { | |
| 532 | readonly cwd: string; | |
| 533 | readonly repository: string; | |
| 534 | readonly host: string; | |
| 535 | readonly number: number; | |
| 536 | }) => Effect.Effect<GitHubPullRequestWatchFingerprint | null, GitHubPullRequestApiError>; | |
| 537 | ||
| 538 | readonly revalidateChecks: Effect.Success<typeof makeChecksRevalidator>; | |
| 539 | ||
| 540 | readonly getPullRequestDetail: (input: { | |
| 541 | readonly cwd: string; | |
| 542 | readonly repository: string; | |
| 543 | readonly host: string; | |
| 544 | readonly number: number; | |
| 545 | }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApiError>; | |
| 546 | ||
| 547 | readonly getPullRequestPreview: (input: { | |
| 548 | readonly cwd: string; | |
| 549 | readonly repository: string; | |
| 550 | readonly host: string; | |
| 551 | readonly number: number; | |
| 552 | }) => Effect.Effect< | |
| 553 | Omit<PullRequestPreview, "projectId" | "repository">, | |
| 554 | GitHubPullRequestApiError | |
| 555 | >; | |
| 556 | ||
| 557 | readonly listWorkflowRunsRequiringApproval: (input: { | |
| 558 | readonly cwd: string; | |
| 559 | readonly repository: string; | |
| 560 | readonly host: string; | |
| 561 | readonly number: number; | |
| 562 | readonly headSha: string; | |
| 563 | readonly headBranch: string; | |
| 564 | readonly headRepositoryOwner: string; | |
| 565 | readonly isCrossRepository: true; | |
| 566 | }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestApiError>; | |
| 567 | ||
| 568 | /** | |
| 569 | * The host-native stack this pull request is in, or null when it is in none — which is also | |
| 570 | * the answer for a host that refuses the stacks preview altogether. | |
| 571 | */ | |
| 572 | readonly getPullRequestStack: (input: { | |
| 573 | readonly cwd: string; | |
| 574 | readonly includeDetails?: boolean; | |
| 575 | readonly repository: string; | |
| 576 | readonly host: string; | |
| 577 | readonly number: number; | |
| 578 | }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestApiError>; | |
| 579 | ||
| 580 | readonly getPullRequestActivity: (input: { | |
| 581 | readonly cwd: string; | |
| 582 | readonly repository: string; | |
| 583 | readonly host: string; | |
| 584 | readonly number: number; | |
| 585 | }) => Effect.Effect<GitHubPullRequestActivity, GitHubPullRequestApiError>; | |
| 586 | ||
| 587 | readonly getPullRequestDiff: (input: { | |
| 588 | readonly cwd: string; | |
| 589 | readonly repository: string; | |
| 590 | readonly host: string; | |
| 591 | readonly number: number; | |
| 592 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | |
| 593 | readonly cursor?: string | undefined; | |
| 594 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 595 | readonly commit?: string | undefined; | |
| 596 | }) => Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError>; | |
| 597 | ||
| 598 | readonly getPullRequestDiffFileContents: (input: { | |
| 599 | readonly cwd: string; | |
| 600 | readonly repository: string; | |
| 601 | readonly host: string; | |
| 602 | readonly number: number; | |
| 603 | readonly commit?: string | undefined; | |
| 604 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | |
| 605 | readonly oldPath: string; | |
| 606 | readonly newPath: string; | |
| 607 | }) => Effect.Effect< | |
| 608 | { readonly oldContents: string; readonly newContents: string }, | |
| 609 | GitHubPullRequestApiError | |
| 610 | >; | |
| 611 | ||
| 612 | /** | |
| 613 | * Which files of the pull request the signed-in account has cleared, and which of those have | |
| 614 | * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. | |
| 615 | */ | |
| 616 | readonly getPullRequestFilesViewed: (input: { | |
| 617 | readonly cwd: string; | |
| 618 | readonly repository: string; | |
| 619 | readonly host: string; | |
| 620 | readonly number: number; | |
| 621 | }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestApiError>; | |
| 622 | ||
| 623 | /** | |
| 624 | * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, | |
| 625 | * so a burst is batched with aliases into one document rather than one subprocess per press. | |
| 626 | */ | |
| 627 | readonly setPullRequestFilesViewed: (input: { | |
| 628 | readonly cwd: string; | |
| 629 | readonly repository: string; | |
| 630 | readonly host: string; | |
| 631 | readonly number: number; | |
| 632 | readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; | |
| 633 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 634 | ||
| 635 | readonly listReviewThreadComments: (input: { | |
| 636 | readonly cwd: string; | |
| 637 | readonly repository: string; | |
| 638 | readonly host: string; | |
| 639 | readonly number: number; | |
| 640 | }) => Effect.Effect<GitHubReviewThreadComments, GitHubPullRequestApiError>; | |
| 641 | ||
| 642 | /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ | |
| 643 | readonly listActorAvatars: (input: { | |
| 644 | readonly cwd: string; | |
| 645 | readonly repository: string; | |
| 646 | readonly host: string; | |
| 647 | readonly ids: ReadonlyArray<string>; | |
| 648 | }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestApiError>; | |
| 649 | ||
| 650 | readonly getReviewThreadComments: (input: { | |
| 651 | readonly cwd: string; | |
| 652 | readonly repository: string; | |
| 653 | readonly host: string; | |
| 654 | readonly number: number; | |
| 655 | readonly threadId: string; | |
| 656 | readonly cursor: string; | |
| 657 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestApiError>; | |
| 658 | ||
| 659 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ | |
| 660 | readonly getViewerAccess: (input: { | |
| 661 | readonly cwd: string; | |
| 662 | readonly repository: string; | |
| 663 | readonly host: string; | |
| 664 | readonly number: number; | |
| 665 | /** Manual action checks may use the quota held back from automatic reads. */ | |
| 666 | readonly allowReserve?: boolean | undefined; | |
| 667 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestApiError>; | |
| 668 | ||
| 669 | /** Who this pull request may be sent to, and who it has already been sent to. */ | |
| 670 | readonly listReviewerCandidates: (input: { | |
| 671 | readonly cwd: string; | |
| 672 | readonly repository: string; | |
| 673 | readonly host: string; | |
| 674 | readonly number: number; | |
| 675 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitHubPullRequestApiError>; | |
| 676 | ||
| 677 | readonly setReviewerRequest: (input: { | |
| 678 | readonly cwd: string; | |
| 679 | readonly repository: string; | |
| 680 | readonly host: string; | |
| 681 | readonly number: number; | |
| 682 | readonly reviewers: ReadonlyArray<{ | |
| 683 | readonly id: string; | |
| 684 | readonly kind: PullRequestReviewerKind; | |
| 685 | }>; | |
| 686 | /** False deletes the same collection a request posts to, which takes the request back. */ | |
| 687 | readonly requested: boolean; | |
| 688 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 689 | ||
| 690 | /** The repository's labels, and which of them this pull request already wears. */ | |
| 691 | readonly listLabelCandidates: (input: { | |
| 692 | readonly cwd: string; | |
| 693 | readonly repository: string; | |
| 694 | readonly host: string; | |
| 695 | readonly number: number; | |
| 696 | }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestApiError>; | |
| 697 | ||
| 698 | readonly setLabels: (input: { | |
| 699 | readonly cwd: string; | |
| 700 | readonly repository: string; | |
| 701 | readonly host: string; | |
| 702 | readonly number: number; | |
| 703 | readonly labels: ReadonlyArray<string>; | |
| 704 | /** False takes each label off; true adds each to whatever is already there. */ | |
| 705 | readonly applied: boolean; | |
| 706 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 707 | ||
| 708 | readonly runPullRequestAction: (input: { | |
| 709 | readonly cwd: string; | |
| 710 | readonly repository: string; | |
| 711 | readonly host: string; | |
| 712 | readonly number: number; | |
| 713 | readonly action: PullRequestAction; | |
| 714 | readonly stackNumber?: number; | |
| 715 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 716 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 717 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 718 | readonly updateMethod?: PullRequestUpdateMethod; | |
| 719 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 720 | ||
| 721 | readonly commentOnPullRequest: (input: { | |
| 722 | readonly cwd: string; | |
| 723 | readonly repository: string; | |
| 724 | readonly host: string; | |
| 725 | readonly number: number; | |
| 726 | readonly body: string; | |
| 727 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 728 | ||
| 729 | readonly submitReview: (input: { | |
| 730 | readonly cwd: string; | |
| 731 | readonly repository: string; | |
| 732 | readonly host: string; | |
| 733 | readonly number: number; | |
| 734 | readonly verdict: PullRequestReviewVerdict; | |
| 735 | readonly body: string; | |
| 736 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 737 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 738 | ||
| 739 | readonly replyToReviewThread: (input: { | |
| 740 | readonly cwd: string; | |
| 741 | readonly repository: string; | |
| 742 | readonly host: string; | |
| 743 | readonly threadId: string; | |
| 744 | readonly body: string; | |
| 745 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 746 | ||
| 747 | readonly setReviewThreadResolution: (input: { | |
| 748 | readonly cwd: string; | |
| 749 | readonly repository: string; | |
| 750 | readonly host: string; | |
| 751 | readonly threadId: string; | |
| 752 | readonly resolved: boolean; | |
| 753 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 754 | ||
| 755 | /** | |
| 756 | * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls | |
| 757 | * reactable — a comment, a review, or the pull request itself, which is looked up here | |
| 758 | * because nothing in the conversation names it. A given `subjectId` is confirmed to belong | |
| 759 | * to this pull request before the mutation runs, since nothing else ties the two together. | |
| 760 | */ | |
| 761 | readonly setReaction: (input: { | |
| 762 | readonly cwd: string; | |
| 763 | readonly repository: string; | |
| 764 | readonly host: string; | |
| 765 | readonly number: number; | |
| 766 | readonly subjectId?: string | undefined; | |
| 767 | readonly content: PullRequestReactionContent; | |
| 768 | readonly reacted: boolean; | |
| 769 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 770 | ||
| 771 | /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ | |
| 772 | readonly updatePullRequest: (input: { | |
| 773 | readonly cwd: string; | |
| 774 | readonly repository: string; | |
| 775 | readonly host: string; | |
| 776 | readonly number: number; | |
| 777 | readonly title?: string | undefined; | |
| 778 | readonly body?: string | undefined; | |
| 779 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 780 | ||
| 781 | /** | |
| 782 | * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed | |
| 783 | * to belong to this pull request before the mutation runs, the way a reaction subject is. | |
| 784 | * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. | |
| 785 | */ | |
| 786 | readonly updateComment: (input: { | |
| 787 | readonly cwd: string; | |
| 788 | readonly repository: string; | |
| 789 | readonly host: string; | |
| 790 | readonly number: number; | |
| 791 | readonly commentId: string; | |
| 792 | readonly kind: "issue-comment" | "review-comment"; | |
| 793 | readonly body: string; | |
| 794 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 795 | } | |
| 796 | >()("t3/pullRequest/GitHubPullRequestApi") {} | |
| 797 | ||
| 798 | /** | |
| 799 | * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. | |
| 800 | * The host is not read off the identity: it travels alongside it, because the identity a | |
| 801 | * project records is the path below its host and never names the host itself. | |
| 802 | */ | |
| 803 | function parseRepositorySelector(value: string): { | |
| 804 | readonly owner: string; | |
| 805 | readonly name: string; | |
| 806 | } { | |
| 807 | const parts = value.trim().split("/").filter(Boolean); | |
| 808 | return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; | |
| 809 | } | |
| 810 | ||
| 811 | /** | |
| 812 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | |
| 813 | * arrives from the reader as a string and goes straight into a request path, so it is parsed | |
| 814 | * rather than trusted; the length bound keeps a page number out of exponential notation. | |
| 815 | */ | |
| 816 | function diffCursorPage(cursor: string): number | null { | |
| 817 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | |
| 818 | } | |
| 819 | ||
| 820 | /** | |
| 821 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 822 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 823 | * whole sha. | |
| 824 | */ | |
| 825 | function isCommitSha(value: string): boolean { | |
| 826 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 827 | } | |
| 828 | ||
| 829 | /** | |
| 830 | * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole | |
| 831 | * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so | |
| 832 | * text typed into a search box could widen the very listing it is meant to narrow — inside them | |
| 833 | * it is only text. The two characters that could end the phrase early are therefore escaped | |
| 834 | * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which | |
| 835 | * would let everything after it out of the phrase. | |
| 836 | * | |
| 837 | * The phrase is one argv element, so nothing in it can become a flag of its own either. | |
| 838 | */ | |
| 839 | function searchPhrase(query: string): string { | |
| 840 | return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; | |
| 841 | } | |
| 842 | ||
| 843 | /** GitHub's own spelling of a review state, which is not the contract's. */ | |
| 844 | const REVIEW_QUALIFIERS = { | |
| 845 | approved: "approved", | |
| 846 | "changes-requested": "changes_requested", | |
| 847 | "review-required": "required", | |
| 848 | none: "none", | |
| 849 | } as const; | |
| 850 | ||
| 851 | /** | |
| 852 | * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the | |
| 853 | * one character that could end the quoted value early is dropped rather than escaped: no GitHub | |
| 854 | * label or login holds a double quote, so there is nothing to preserve and everything to lose. | |
| 855 | */ | |
| 856 | function qualifierValue(value: string): string { | |
| 857 | return `"${value.replaceAll('"', "").trim()}"`; | |
| 858 | } | |
| 859 | ||
| 860 | function filterQualifiers( | |
| 861 | filters: PullRequestListFilters | undefined, | |
| 862 | viewer: string, | |
| 863 | ): ReadonlyArray<string> { | |
| 864 | if (filters === undefined) return []; | |
| 865 | return [ | |
| 866 | // One qualifier per group, its names joined by commas — GitHub's own OR. | |
| 867 | ...(filters.labels ?? []).flatMap((group) => | |
| 868 | group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], | |
| 869 | ), | |
| 870 | ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), | |
| 871 | ...(filters.author === undefined | |
| 872 | ? [] | |
| 873 | : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), | |
| 874 | ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), | |
| 875 | ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), | |
| 876 | ...(filters.checks === undefined | |
| 877 | ? [] | |
| 878 | : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), | |
| 879 | ]; | |
| 880 | } | |
| 881 | ||
| 882 | /** | |
| 883 | * The same narrowings over a row that has already arrived, for the search-free fallback. Every | |
| 884 | * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by | |
| 885 | * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an | |
| 886 | * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, | |
| 887 | * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would | |
| 888 | * not have surfaced for `status:success` or `status:failure`. | |
| 889 | */ | |
| 890 | function matchesFilters( | |
| 891 | item: GitHubPullRequestListItem, | |
| 892 | filters: PullRequestListFilters | undefined, | |
| 893 | viewer: string, | |
| 894 | ): boolean { | |
| 895 | if (filters === undefined) return true; | |
| 896 | const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 897 | const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 898 | return ( | |
| 899 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && | |
| 900 | (filters.review === undefined || | |
| 901 | (filters.review === "none" | |
| 902 | ? item.reviewDecision === null | |
| 903 | : item.reviewDecision === filters.review)) && | |
| 904 | (filters.checks === undefined || item.checksState === filters.checks) && | |
| 905 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && | |
| 906 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && | |
| 907 | (filters.author === undefined || | |
| 908 | item.author?.login.toLowerCase() === | |
| 909 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) | |
| 910 | ); | |
| 911 | } | |
| 912 | ||
| 913 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ | |
| 914 | function matchesUnsortedListing( | |
| 915 | item: GitHubPullRequestListItem, | |
| 916 | input: { | |
| 917 | readonly state: PullRequestListState; | |
| 918 | readonly involvement: PullRequestInvolvement; | |
| 919 | readonly viewer: string; | |
| 920 | readonly filters?: PullRequestListFilters | undefined; | |
| 921 | }, | |
| 922 | ): boolean { | |
| 923 | const matchesState = input.state === "all" || item.state === input.state; | |
| 924 | const viewer = input.viewer.toLowerCase(); | |
| 925 | const matchesInvolvement = | |
| 926 | input.involvement === "all" || | |
| 927 | (input.involvement === "authored" | |
| 928 | ? item.author?.login.toLowerCase() === viewer | |
| 929 | : item.hasTeamReviewRequest || | |
| 930 | item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); | |
| 931 | return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); | |
| 932 | } | |
| 933 | ||
| 934 | /** What a repository selector may hold before it goes into a search as itself. */ | |
| 935 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | |
| 936 | ||
| 937 | /** | |
| 938 | * A listing as one GitHub search, across one repository or several: the only way to read a | |
| 939 | * whole host in one request, and the only order (`updated`) a continuation can carry on from. | |
| 940 | * | |
| 941 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is | |
| 942 | * `is:closed is:unmerged` here. | |
| 943 | * | |
| 944 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a | |
| 945 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its | |
| 946 | * own — so an unaddressable one refuses the whole read rather than being escaped into something | |
| 947 | * GitHub might still read. | |
| 948 | */ | |
| 949 | function searchQuery(input: { | |
| 950 | readonly repositories: ReadonlyArray<string>; | |
| 951 | readonly state: PullRequestListState; | |
| 952 | readonly involvement: PullRequestInvolvement; | |
| 953 | readonly viewer: string; | |
| 954 | readonly query?: string | undefined; | |
| 955 | readonly cursor?: ProviderListCursor | undefined; | |
| 956 | readonly filters?: PullRequestListFilters | undefined; | |
| 957 | }): string | null { | |
| 958 | if (input.repositories.length === 0) return null; | |
| 959 | const repositories = input.repositories.map((repository) => repository.trim()); | |
| 960 | if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; | |
| 961 | const query = input.query?.trim() ?? ""; | |
| 962 | return [ | |
| 963 | "is:pr", | |
| 964 | // "all" is every state, which `is:pr` already is. | |
| 965 | ...(input.state === "open" ? ["is:open"] : []), | |
| 966 | ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), | |
| 967 | ...(input.state === "merged" ? ["is:merged"] : []), | |
| 968 | ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), | |
| 969 | ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), | |
| 970 | ...(query.length === 0 ? [] : [searchPhrase(query)]), | |
| 971 | // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. | |
| 972 | ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), | |
| 973 | ...filterQualifiers(input.filters, input.viewer), | |
| 974 | // The order the page reads its rows in, and the only order a continuation can carry on from. | |
| 975 | "sort:updated-desc", | |
| 976 | ...repositories.map((repository) => `repo:${repository}`), | |
| 977 | ].join(" "); | |
| 978 | } | |
| 979 | ||
| 980 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | |
| 981 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { | |
| 982 | repository(owner: $owner, name: $name) { | |
| 983 | pullRequest(number: $number) { | |
| 984 | isMergeQueueEnabled | |
| 985 | headRefOid | |
| 986 | viewerMergeBodyText(mergeType: $method) | |
| 987 | } | |
| 988 | } | |
| 989 | }`; | |
| 990 | ||
| 991 | /** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ | |
| 992 | const decodeRevisionRefs = Schema.decodeUnknownOption( | |
| 993 | Schema.fromJsonString( | |
| 994 | Schema.Struct({ | |
| 995 | sha: Schema.optional(Schema.String), | |
| 996 | parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), | |
| 997 | base: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 998 | head: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 999 | }), | |
| 1000 | ), | |
| 1001 | ); | |
| 1002 | ||
| 1003 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( | |
| 1004 | Schema.fromJsonString( | |
| 1005 | Schema.Struct({ | |
| 1006 | data: Schema.Struct({ | |
| 1007 | repository: Schema.Struct({ | |
| 1008 | pullRequest: Schema.Struct({ | |
| 1009 | isMergeQueueEnabled: Schema.Boolean, | |
| 1010 | headRefOid: Schema.String, | |
| 1011 | viewerMergeBodyText: Schema.String, | |
| 1012 | }), | |
| 1013 | }), | |
| 1014 | }), | |
| 1015 | }), | |
| 1016 | ), | |
| 1017 | ); | |
| 1018 | const decodeMergeMessage = (raw: string) => | |
| 1019 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1020 | ||
| 1021 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ | |
| 1022 | const ACTION_STATE_GRAPHQL_QUERY = ` | |
| 1023 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 1024 | repository(owner: $owner, name: $name) { | |
| 1025 | pullRequest(number: $number) { | |
| 1026 | id | |
| 1027 | headRefOid | |
| 1028 | isMergeQueueEnabled | |
| 1029 | mergeStateStatus | |
| 1030 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 1031 | } | |
| 1032 | } | |
| 1033 | }`; | |
| 1034 | ||
| 1035 | const decodeActionStateResponse = Schema.decodeUnknownResult( | |
| 1036 | Schema.fromJsonString( | |
| 1037 | Schema.Struct({ | |
| 1038 | data: Schema.Struct({ | |
| 1039 | repository: Schema.Struct({ | |
| 1040 | pullRequest: Schema.Struct({ | |
| 1041 | id: Schema.String, | |
| 1042 | headRefOid: Schema.String, | |
| 1043 | isMergeQueueEnabled: Schema.optional(Schema.Boolean), | |
| 1044 | mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 1045 | baseRef: Schema.optional( | |
| 1046 | Schema.NullOr( | |
| 1047 | Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), | |
| 1048 | ), | |
| 1049 | ), | |
| 1050 | }), | |
| 1051 | }), | |
| 1052 | }), | |
| 1053 | }), | |
| 1054 | ), | |
| 1055 | ); | |
| 1056 | const decodeActionState = (raw: string) => | |
| 1057 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1058 | ||
| 1059 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { | |
| 1060 | mergePullRequest(input: $input) { clientMutationId } | |
| 1061 | }`; | |
| 1062 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { | |
| 1063 | enablePullRequestAutoMerge(input: $input) { clientMutationId } | |
| 1064 | }`; | |
| 1065 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1066 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1067 | }`; | |
| 1068 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { | |
| 1069 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } | |
| 1070 | }`; | |
| 1071 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1072 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1073 | }`; | |
| 1074 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1075 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1076 | }`; | |
| 1077 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1078 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1079 | }`; | |
| 1080 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1081 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1082 | }`; | |
| 1083 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { | |
| 1084 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } | |
| 1085 | }`; | |
| 1086 | ||
| 1087 | const GRAPHQL_MERGE_METHODS = { | |
| 1088 | merge: "MERGE", | |
| 1089 | squash: "SQUASH", | |
| 1090 | rebase: "REBASE", | |
| 1091 | } as const satisfies Record<PullRequestMergeMethod, string>; | |
| 1092 | ||
| 1093 | /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ | |
| 1094 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); | |
| 1095 | ||
| 1096 | /** The mutations that only need the pull request's node id. */ | |
| 1097 | const SIMPLE_ACTION_MUTATIONS = { | |
| 1098 | "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, | |
| 1099 | ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, | |
| 1100 | draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, | |
| 1101 | close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1102 | reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1103 | } as const satisfies Partial<Record<PullRequestAction, string>>; | |
| 1104 | ||
| 1105 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 1106 | export const make = Effect.gen(function* () { | |
| 1107 | const api = yield* GitHubApi.GitHubApi; | |
| 1108 | const vcsProcess = yield* VcsProcess.VcsProcess; | |
| 1109 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1110 | const revalidateChecks = yield* makeChecksRevalidator; | |
| 1111 | const routingIdentities = new Map< | |
| 1112 | string, | |
| 1113 | { | |
| 1114 | at: number; | |
| 1115 | value: { accountId: string; viewer: string }; | |
| 1116 | } | |
| 1117 | >(); | |
| 1118 | const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>(); | |
| 1119 | const decodeRoutingIdentity = Schema.decodeUnknownEffect( | |
| 1120 | Schema.fromJsonString( | |
| 1121 | Schema.Struct({ | |
| 1122 | id: PositiveInt, | |
| 1123 | login: TrimmedNonEmptyString, | |
| 1124 | }), | |
| 1125 | ), | |
| 1126 | ); | |
| 1127 | const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( | |
| 1128 | function* (input: { readonly cwd: string; readonly host: string }) { | |
| 1129 | const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); | |
| 1130 | const host = input.host.toLowerCase(); | |
| 1131 | // A missing or signed-out credential keeps its own error, so the page can say which. | |
| 1132 | const { token, fingerprint: key } = yield* api.credential(host); | |
| 1133 | const credential = { host, token, credentialFingerprint: key }; | |
| 1134 | // A cold page may ask several times. Wait per credential and check again after the | |
| 1135 | // first verification; cancellation releases the next waiter without losing its request. | |
| 1136 | return yield* Effect.acquireUseRelease( | |
| 1137 | Effect.sync(() => { | |
| 1138 | const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; | |
| 1139 | lock.users++; | |
| 1140 | identityLocks.set(key, lock); | |
| 1141 | return lock; | |
| 1142 | }), | |
| 1143 | (lock) => | |
| 1144 | lock.gate.withPermit( | |
| 1145 | Effect.gen(function* () { | |
| 1146 | const now = yield* Clock.currentTimeMillis; | |
| 1147 | const cached = routingIdentities.get(key); | |
| 1148 | if (cached !== undefined && now - cached.at < 10 * 60_000) | |
| 1149 | return { ...credential, ...cached.value }; | |
| 1150 | // Pin this read so an auth switch cannot poison its cache entry. | |
| 1151 | const response = yield* api | |
| 1152 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) | |
| 1153 | .pipe( | |
| 1154 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 1155 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), | |
| 1156 | ); | |
| 1157 | const identity = yield* decodeRoutingIdentity(response.body).pipe( | |
| 1158 | Effect.mapError(unavailable), | |
| 1159 | ); | |
| 1160 | const value = { accountId: String(identity.id), viewer: identity.login }; | |
| 1161 | if (routingIdentities.size >= 128) | |
| 1162 | routingIdentities.delete(routingIdentities.keys().next().value!); | |
| 1163 | routingIdentities.set(key, { at: now, value }); | |
| 1164 | return { ...credential, ...value }; | |
| 1165 | }), | |
| 1166 | ), | |
| 1167 | (lock) => | |
| 1168 | Effect.sync(() => { | |
| 1169 | lock.users--; | |
| 1170 | if (lock.users === 0) identityLocks.delete(key); | |
| 1171 | }), | |
| 1172 | ); | |
| 1173 | }, | |
| 1174 | ); | |
| 1175 | const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( | |
| 1176 | input, | |
| 1177 | use, | |
| 1178 | ) => | |
| 1179 | captureVerifiedCredential(input).pipe( | |
| 1180 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => | |
| 1181 | use({ accountId, viewer, credentialFingerprint }).pipe( | |
| 1182 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), | |
| 1183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 1184 | host, | |
| 1185 | token, | |
| 1186 | credentialFingerprint, | |
| 1187 | }), | |
| 1188 | ), | |
| 1189 | ), | |
| 1190 | ); | |
| 1191 | const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => | |
| 1192 | captureVerifiedCredential(input).pipe( | |
| 1193 | Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), | |
| 1194 | ); | |
| 1195 | ||
| 1196 | /** | |
| 1197 | * The pull request's own node id, which is what a mutation against the pull request itself is | |
| 1198 | * addressed by: a reaction on its description, or a rewrite of its words. | |
| 1199 | * | |
| 1200 | * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader | |
| 1201 | * ticking files viewed would otherwise pay a GraphQL round trip per press. | |
| 1202 | */ | |
| 1203 | const nodeIds = new Map<string, string>(); | |
| 1204 | ||
| 1205 | const pullRequestNodeId = (input: { | |
| 1206 | readonly cwd: string; | |
| 1207 | readonly repository: string; | |
| 1208 | readonly host: string; | |
| 1209 | readonly number: number; | |
| 1210 | readonly operation: string; | |
| 1211 | }): Effect.Effect<string, GitHubPullRequestApiError> => { | |
| 1212 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1213 | const key = `${input.host} ${owner}/${name} ${input.number}`; | |
| 1214 | const held = nodeIds.get(key); | |
| 1215 | if (held !== undefined) { | |
| 1216 | // Put back at the end on every hit, so what falls out is the pull request nobody has looked | |
| 1217 | // at rather than the one being ticked through: a run of cold reads would otherwise evict the | |
| 1218 | // open review and make it pay a round trip per press. | |
| 1219 | nodeIds.delete(key); | |
| 1220 | nodeIds.set(key, held); | |
| 1221 | return Effect.succeed(held); | |
| 1222 | } | |
| 1223 | return graphqlRead({ | |
| 1224 | cwd: input.cwd, | |
| 1225 | host: input.host, | |
| 1226 | operation: input.operation, | |
| 1227 | allowReserve: true, | |
| 1228 | variables: { owner, name, number: input.number }, | |
| 1229 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 1230 | decode: decodePullRequestNodeIdJson, | |
| 1231 | }).pipe( | |
| 1232 | Effect.tap((nodeId) => | |
| 1233 | Effect.sync(() => { | |
| 1234 | if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { | |
| 1235 | const oldest = nodeIds.keys().next().value; | |
| 1236 | if (oldest !== undefined) nodeIds.delete(oldest); | |
| 1237 | } | |
| 1238 | nodeIds.set(key, nodeId); | |
| 1239 | }), | |
| 1240 | ), | |
| 1241 | ); | |
| 1242 | }; | |
| 1243 | ||
| 1244 | /** | |
| 1245 | * Whether a client-given subject actually belongs to the pull request the request names. A | |
| 1246 | * subject id is trusted to be whatever node it names, and that node can hang off any pull | |
| 1247 | * request on the host — so the mutation itself would write wherever the id actually belongs, | |
| 1248 | * not wherever the request says it does, unless this confirms the two agree first. | |
| 1249 | */ | |
| 1250 | const subjectBelongsToPullRequest = (input: { | |
| 1251 | readonly cwd: string; | |
| 1252 | readonly repository: string; | |
| 1253 | readonly host: string; | |
| 1254 | readonly number: number; | |
| 1255 | readonly subjectId: string; | |
| 1256 | readonly operation: string; | |
| 1257 | }) => { | |
| 1258 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1259 | return graphqlRead({ | |
| 1260 | cwd: input.cwd, | |
| 1261 | host: input.host, | |
| 1262 | operation: input.operation, | |
| 1263 | allowReserve: true, | |
| 1264 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, | |
| 1265 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 1266 | decode: decodeReactionSubjectScopeJson, | |
| 1267 | }); | |
| 1268 | }; | |
| 1269 | ||
| 1270 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ | |
| 1271 | const graphql = (input: { | |
| 1272 | readonly host: string; | |
| 1273 | readonly operation: string; | |
| 1274 | readonly query: string; | |
| 1275 | readonly variables: Readonly<Record<string, unknown>>; | |
| 1276 | }) => api.graphql(input).pipe(Effect.asVoid); | |
| 1277 | ||
| 1278 | const readError = (cwd: string, operation: string, cause: unknown) => | |
| 1279 | new GitHubPullRequestReadError({ cwd, operation, cause }); | |
| 1280 | ||
| 1281 | /** Decodes an answer, reporting a failure against the read that made it. */ | |
| 1282 | const decodeWith = <A>( | |
| 1283 | cwd: string, | |
| 1284 | operation: string, | |
| 1285 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 1286 | raw: string, | |
| 1287 | ): Effect.Effect<A, GitHubPullRequestReadError> => { | |
| 1288 | const decoded = decode(raw.trim()); | |
| 1289 | return Result.isSuccess(decoded) | |
| 1290 | ? Effect.succeed(decoded.success) | |
| 1291 | : Effect.fail(readError(cwd, operation, decoded.failure)); | |
| 1292 | }; | |
| 1293 | ||
| 1294 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ | |
| 1295 | const graphqlRead = <A>(input: { | |
| 1296 | readonly cwd: string; | |
| 1297 | readonly host: string; | |
| 1298 | readonly operation: string; | |
| 1299 | readonly allowReserve?: boolean | undefined; | |
| 1300 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 1301 | readonly query: string; | |
| 1302 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1303 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1304 | api | |
| 1305 | .graphql({ | |
| 1306 | host: input.host, | |
| 1307 | operation: input.operation, | |
| 1308 | query: input.query, | |
| 1309 | ...(input.variables === undefined ? {} : { variables: input.variables }), | |
| 1310 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 1311 | }) | |
| 1312 | .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); | |
| 1313 | ||
| 1314 | /** A REST read whose JSON answer is decoded the same way. */ | |
| 1315 | const restRead = <A>(input: { | |
| 1316 | readonly cwd: string; | |
| 1317 | readonly host: string; | |
| 1318 | readonly operation: string; | |
| 1319 | readonly path: string; | |
| 1320 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1321 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1322 | api | |
| 1323 | .rest({ host: input.host, operation: input.operation, path: input.path }) | |
| 1324 | .pipe( | |
| 1325 | Effect.flatMap((response) => | |
| 1326 | decodeWith(input.cwd, input.operation, input.decode, response.body), | |
| 1327 | ), | |
| 1328 | ); | |
| 1329 | ||
| 1330 | /** | |
| 1331 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed | |
| 1332 | * files, and still serves those files' hunks here. | |
| 1333 | * | |
| 1334 | * A page is a whole number of files, so each one parses on its own; the caller carries on from | |
| 1335 | * `nextCursor` for as long as GitHub keeps handing pages back. | |
| 1336 | * | |
| 1337 | * A named commit is read from the commit endpoint, which lists the same file entries and pages | |
| 1338 | * them the same way, only wrapped in an object. | |
| 1339 | */ | |
| 1340 | const diffFilesPage = (input: { | |
| 1341 | readonly cwd: string; | |
| 1342 | readonly repository: string; | |
| 1343 | readonly host: string; | |
| 1344 | readonly number: number; | |
| 1345 | readonly page: number; | |
| 1346 | readonly commit?: string | undefined; | |
| 1347 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError> => { | |
| 1348 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1349 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; | |
| 1350 | return api | |
| 1351 | .rest({ | |
| 1352 | host: input.host, | |
| 1353 | operation: "getPullRequestDiff", | |
| 1354 | path: | |
| 1355 | input.commit === undefined | |
| 1356 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` | |
| 1357 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, | |
| 1358 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1359 | timeout: DIFF_TIMEOUT, | |
| 1360 | }) | |
| 1361 | .pipe( | |
| 1362 | Effect.flatMap((response) => { | |
| 1363 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would | |
| 1364 | // fail to parse. Nothing of this page can be shown, and an empty patch would render | |
| 1365 | // as a change with no files rather than as the failure it is; slices already handed | |
| 1366 | // over stay with the reader either way. | |
| 1367 | if (response.truncated) { | |
| 1368 | return Effect.fail( | |
| 1369 | readError( | |
| 1370 | input.cwd, | |
| 1371 | "getPullRequestDiff", | |
| 1372 | new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 1373 | ), | |
| 1374 | ); | |
| 1375 | } | |
| 1376 | return decodeWith( | |
| 1377 | input.cwd, | |
| 1378 | "getPullRequestDiff", | |
| 1379 | input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, | |
| 1380 | response.body, | |
| 1381 | ); | |
| 1382 | }), | |
| 1383 | Effect.map((files) => { | |
| 1384 | // Counted before decoding, so a page whose files all failed to decode still moves on | |
| 1385 | // rather than pointing the reader back at the page it just read. | |
| 1386 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1387 | return { | |
| 1388 | patch: files.patch, | |
| 1389 | truncated: files.truncated, | |
| 1390 | nextCursor: morePages ? String(input.page + 1) : null, | |
| 1391 | ...(files.omittedFileStats.length === 0 | |
| 1392 | ? {} | |
| 1393 | : { omittedFileStats: files.omittedFileStats }), | |
| 1394 | }; | |
| 1395 | }), | |
| 1396 | ); | |
| 1397 | }; | |
| 1398 | ||
| 1399 | const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = | |
| 1400 | (input) => | |
| 1401 | Effect.gen(function* () { | |
| 1402 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 1403 | return yield* new GitHubDiffCommitError({ cwd: input.cwd }); | |
| 1404 | } | |
| 1405 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1406 | const refsResponse = yield* api.rest({ | |
| 1407 | host: input.host, | |
| 1408 | operation: "getPullRequestDiffFileContents", | |
| 1409 | path: | |
| 1410 | input.commit === undefined | |
| 1411 | ? `repos/${owner}/${name}/pulls/${input.number}` | |
| 1412 | : `repos/${owner}/${name}/commits/${input.commit}`, | |
| 1413 | timeout: DIFF_TIMEOUT, | |
| 1414 | }); | |
| 1415 | const refs = decodeRevisionRefs(refsResponse.body); | |
| 1416 | // A root commit has no parent, which is an absent old revision. Every file in it is new, | |
| 1417 | // so that is a usable answer whenever the caller does not need the old side. | |
| 1418 | const baseRef = Option.isSome(refs) | |
| 1419 | ? input.commit === undefined | |
| 1420 | ? refs.value.base?.sha | |
| 1421 | : (refs.value.parents?.[0]?.sha ?? "") | |
| 1422 | : undefined; | |
| 1423 | const headRef = Option.isSome(refs) | |
| 1424 | ? input.commit === undefined | |
| 1425 | ? refs.value.head?.sha | |
| 1426 | : refs.value.sha | |
| 1427 | : undefined; | |
| 1428 | const rootCommitNewFile = | |
| 1429 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; | |
| 1430 | if ( | |
| 1431 | headRef === undefined || | |
| 1432 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || | |
| 1433 | !isCommitSha(headRef) | |
| 1434 | ) { | |
| 1435 | return yield* new GitHubDiffRevisionsUnavailableError({ | |
| 1436 | cwd: input.cwd, | |
| 1437 | number: input.number, | |
| 1438 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 1439 | }); | |
| 1440 | } | |
| 1441 | ||
| 1442 | const readFile = (revision: string, filePath: string) => | |
| 1443 | api | |
| 1444 | .rest({ | |
| 1445 | host: input.host, | |
| 1446 | operation: "getPullRequestDiffFileContents", | |
| 1447 | accept: "application/vnd.github.raw+json", | |
| 1448 | path: `repos/${owner}/${name}/contents/${filePath | |
| 1449 | .split("/") | |
| 1450 | .map(encodeURIComponent) | |
| 1451 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1452 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1453 | timeout: DIFF_TIMEOUT, | |
| 1454 | }) | |
| 1455 | .pipe( | |
| 1456 | Effect.flatMap((response) => | |
| 1457 | response.truncated || response.body.includes("\0") || response.invalidUtf8 | |
| 1458 | ? Effect.fail( | |
| 1459 | new GitHubDiffFileContentsUnavailableError({ | |
| 1460 | cwd: input.cwd, | |
| 1461 | path: filePath, | |
| 1462 | reason: response.truncated ? "oversized" : "binary", | |
| 1463 | }), | |
| 1464 | ) | |
| 1465 | : Effect.succeed(response.body), | |
| 1466 | ), | |
| 1467 | ); | |
| 1468 | ||
| 1469 | const [oldContents, newContents] = yield* Effect.all( | |
| 1470 | [ | |
| 1471 | input.changeType === "new" | |
| 1472 | ? Effect.succeed("") | |
| 1473 | : readFile(baseRef ?? "", input.oldPath), | |
| 1474 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), | |
| 1475 | ], | |
| 1476 | { concurrency: 2 }, | |
| 1477 | ); | |
| 1478 | return { oldContents, newContents }; | |
| 1479 | }); | |
| 1480 | ||
| 1481 | /** | |
| 1482 | * Every check context of the head commit, a page at a time, for a rollup the detail read cut at | |
| 1483 | * its first hundred. Each page names the head it read, so a push mid-walk fails rather than | |
| 1484 | * mixing two revisions' checks. | |
| 1485 | */ | |
| 1486 | type CheckContextsPage = Result.Result.Success< | |
| 1487 | ReturnType<typeof decodePullRequestCheckContextsJson> | |
| 1488 | >; | |
| 1489 | const readAllCheckContexts = ( | |
| 1490 | input: Parameters<GitHubPullRequestApi["Service"]["getPullRequestDetail"]>[0], | |
| 1491 | allowReserve: boolean, | |
| 1492 | ) => | |
| 1493 | Effect.gen(function* () { | |
| 1494 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1495 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1496 | (after, previous: ReadonlyArray<CheckContextsPage>) => | |
| 1497 | graphqlRead({ | |
| 1498 | cwd: input.cwd, | |
| 1499 | host: input.host, | |
| 1500 | operation: "getPullRequestDetail", | |
| 1501 | allowReserve, | |
| 1502 | variables: { owner, name, number: input.number, after }, | |
| 1503 | query: pullRequestCheckContextsGraphQlQuery(input.host), | |
| 1504 | decode: decodePullRequestCheckContextsJson, | |
| 1505 | }).pipe( | |
| 1506 | // Each page names the head it read, so a push mid-walk fails rather than mixing | |
| 1507 | // two revisions' checks. | |
| 1508 | Effect.filterOrFail( | |
| 1509 | (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, | |
| 1510 | () => | |
| 1511 | readError( | |
| 1512 | input.cwd, | |
| 1513 | "getPullRequestDetail", | |
| 1514 | new Error("Pull request head changed while reading checks."), | |
| 1515 | ), | |
| 1516 | ), | |
| 1517 | ), | |
| 1518 | { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, | |
| 1519 | ); | |
| 1520 | return { | |
| 1521 | headSha: pages[0]?.headSha ?? null, | |
| 1522 | contexts: pages.flatMap((page) => page.contexts), | |
| 1523 | truncated, | |
| 1524 | }; | |
| 1525 | }); | |
| 1526 | ||
| 1527 | const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { | |
| 1528 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1529 | return GitHubApi.AllowGitHubReserve.pipe( | |
| 1530 | Effect.flatMap((allowReserve) => | |
| 1531 | graphqlRead({ | |
| 1532 | allowReserve, | |
| 1533 | cwd: input.cwd, | |
| 1534 | host: input.host, | |
| 1535 | operation: "getPullRequestDetail", | |
| 1536 | variables: { | |
| 1537 | owner, | |
| 1538 | name, | |
| 1539 | number: input.number, | |
| 1540 | headRef: `refs/pull/${input.number}/head`, | |
| 1541 | }, | |
| 1542 | query: pullRequestCoreGraphQlQuery(input.host), | |
| 1543 | decode: decodePullRequestCoreJson, | |
| 1544 | }).pipe( | |
| 1545 | // Past a hundred checks the first page would let the rest imply success, so the whole | |
| 1546 | // rollup is walked instead. | |
| 1547 | Effect.filterOrElse( | |
| 1548 | (core) => !core.checksTruncated, | |
| 1549 | (core) => | |
| 1550 | readAllCheckContexts(input, allowReserve).pipe( | |
| 1551 | Effect.filterOrFail( | |
| 1552 | (all) => all.headSha === core.headSha, | |
| 1553 | () => | |
| 1554 | readError( | |
| 1555 | input.cwd, | |
| 1556 | "getPullRequestDetail", | |
| 1557 | new Error("Pull request head changed while reading checks."), | |
| 1558 | ), | |
| 1559 | ), | |
| 1560 | Effect.map((all) => ({ | |
| 1561 | ...core, | |
| 1562 | ...pullRequestChecksFromContexts(all.contexts), | |
| 1563 | checksTruncated: all.truncated, | |
| 1564 | })), | |
| 1565 | ), | |
| 1566 | ), | |
| 1567 | ), | |
| 1568 | ), | |
| 1569 | ); | |
| 1570 | }; | |
| 1571 | ||
| 1572 | const workflowApprovalLimit = 1_000; | |
| 1573 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | |
| 1574 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); | |
| 1575 | ||
| 1576 | /** | |
| 1577 | * Every open pull request whose head branch carries this name, up to one past the limit. | |
| 1578 | * `truncated` says some were never read, which is never enough to call a head unique. | |
| 1579 | */ | |
| 1580 | const listHeadsByBranch = (input: { | |
| 1581 | readonly cwd: string; | |
| 1582 | readonly repository: string; | |
| 1583 | readonly host: string; | |
| 1584 | readonly headBranch: string; | |
| 1585 | }) => | |
| 1586 | Effect.gen(function* () { | |
| 1587 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1588 | const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray<unknown> }>) => | |
| 1589 | pages.reduce((total, page) => total + page.heads.length, 0); | |
| 1590 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1591 | (after) => | |
| 1592 | graphqlRead({ | |
| 1593 | cwd: input.cwd, | |
| 1594 | host: input.host, | |
| 1595 | operation: "listWorkflowRunsRequiringApproval", | |
| 1596 | allowReserve: true, | |
| 1597 | variables: { owner, name, head: input.headBranch, after }, | |
| 1598 | query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 1599 | decode: decodePullRequestHeadsJson, | |
| 1600 | }), | |
| 1601 | { | |
| 1602 | nextCursor: (page) => page.nextCursor, | |
| 1603 | until: (pages) => countHeads(pages) > workflowApprovalLimit, | |
| 1604 | }, | |
| 1605 | ); | |
| 1606 | return { heads: pages.flatMap((page) => page.heads), truncated }; | |
| 1607 | }); | |
| 1608 | ||
| 1609 | /** | |
| 1610 | * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is | |
| 1611 | * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. | |
| 1612 | */ | |
| 1613 | const listActionRequiredRuns = (input: { | |
| 1614 | readonly cwd: string; | |
| 1615 | readonly repository: string; | |
| 1616 | readonly host: string; | |
| 1617 | readonly headSha: string; | |
| 1618 | readonly headBranch: string; | |
| 1619 | }) => | |
| 1620 | Effect.gen(function* () { | |
| 1621 | // The checks revalidator may have just confirmed every run of this head as current. | |
| 1622 | const known = yield* KnownWorkflowRuns; | |
| 1623 | if (known !== null && known.headSha === input.headSha) { | |
| 1624 | return known.runs.flatMap((run) => | |
| 1625 | // A run waiting on a maintainer reports `completed` with an `action_required` | |
| 1626 | // conclusion; the `status=action_required` query matches either. | |
| 1627 | (run.conclusion === "action_required" || run.status === "action_required") && | |
| 1628 | run.head_branch === input.headBranch | |
| 1629 | ? [ | |
| 1630 | { | |
| 1631 | id: run.id, | |
| 1632 | name: run.name?.trim() || `Workflow run ${run.id}`, | |
| 1633 | url: run.html_url?.trim() || null, | |
| 1634 | }, | |
| 1635 | ] | |
| 1636 | : [], | |
| 1637 | ); | |
| 1638 | } | |
| 1639 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1640 | const runs: GitHubWorkflowRunApproval[] = []; | |
| 1641 | for (let page = 1; runs.length <= workflowApprovalLimit; page++) { | |
| 1642 | const read: GitHubWorkflowRunPage = yield* restRead({ | |
| 1643 | cwd: input.cwd, | |
| 1644 | host: input.host, | |
| 1645 | operation: "listWorkflowRunsRequiringApproval", | |
| 1646 | 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}`, | |
| 1647 | decode: decodeWorkflowRunsJson, | |
| 1648 | }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); | |
| 1649 | runs.push(...read.runs); | |
| 1650 | if (read.rawCount < 100) break; | |
| 1651 | } | |
| 1652 | return runs; | |
| 1653 | }); | |
| 1654 | ||
| 1655 | const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = | |
| 1656 | (input) => | |
| 1657 | Effect.all( | |
| 1658 | [ | |
| 1659 | listHeadsByBranch(input).pipe( | |
| 1660 | Effect.flatMap( | |
| 1661 | ({ | |
| 1662 | heads, | |
| 1663 | truncated, | |
| 1664 | }): Effect.Effect< | |
| 1665 | GitHubPullRequestHead, | |
| 1666 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1667 | > => { | |
| 1668 | const exactHeads = heads.filter( | |
| 1669 | (pullRequest) => | |
| 1670 | pullRequest.headSha === input.headSha && | |
| 1671 | pullRequest.isCrossRepository === true && | |
| 1672 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1673 | input.headRepositoryOwner.toLowerCase(), | |
| 1674 | ); | |
| 1675 | if (truncated || heads.length > workflowApprovalLimit) { | |
| 1676 | return Effect.fail( | |
| 1677 | new GitHubWorkflowApprovalRefusedError({ | |
| 1678 | cwd: input.cwd, | |
| 1679 | number: input.number, | |
| 1680 | reason: "head-list-truncated", | |
| 1681 | observedCount: heads.length, | |
| 1682 | limit: workflowApprovalLimit, | |
| 1683 | }), | |
| 1684 | ); | |
| 1685 | } | |
| 1686 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1687 | return Effect.fail( | |
| 1688 | new GitHubWorkflowApprovalRefusedError({ | |
| 1689 | cwd: input.cwd, | |
| 1690 | number: input.number, | |
| 1691 | reason: "head-not-unique", | |
| 1692 | observedCount: exactHeads.length, | |
| 1693 | limit: workflowApprovalLimit, | |
| 1694 | }), | |
| 1695 | ); | |
| 1696 | } | |
| 1697 | return Effect.succeed(exactHeads[0]); | |
| 1698 | }, | |
| 1699 | ), | |
| 1700 | ), | |
| 1701 | listActionRequiredRuns(input).pipe( | |
| 1702 | Effect.flatMap((runs) => | |
| 1703 | runs.length > workflowApprovalLimit | |
| 1704 | ? Effect.fail( | |
| 1705 | new GitHubWorkflowApprovalRefusedError({ | |
| 1706 | cwd: input.cwd, | |
| 1707 | number: input.number, | |
| 1708 | reason: "run-list-truncated", | |
| 1709 | observedCount: runs.length, | |
| 1710 | limit: workflowApprovalLimit, | |
| 1711 | }), | |
| 1712 | ) | |
| 1713 | : Effect.succeed(runs), | |
| 1714 | ), | |
| 1715 | ), | |
| 1716 | ], | |
| 1717 | { concurrency: 2 }, | |
| 1718 | ).pipe(Effect.map(([, runs]) => runs)); | |
| 1719 | ||
| 1720 | /** One pull request's summary, through the same aliased query the batch uses. */ | |
| 1721 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { | |
| 1722 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1723 | return graphqlRead({ | |
| 1724 | cwd: input.cwd, | |
| 1725 | host: input.host, | |
| 1726 | operation: "getPullRequestSummary", | |
| 1727 | variables: { owner, name, number: input.number }, | |
| 1728 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), | |
| 1729 | decode: decodePullRequestSummariesJson, | |
| 1730 | }).pipe( | |
| 1731 | Effect.flatMap((summaries) => { | |
| 1732 | const summary = summaries.get(0); | |
| 1733 | return summary === undefined | |
| 1734 | ? Effect.fail( | |
| 1735 | readError( | |
| 1736 | input.cwd, | |
| 1737 | "getPullRequestSummary", | |
| 1738 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), | |
| 1739 | ), | |
| 1740 | ) | |
| 1741 | : Effect.succeed(summary); | |
| 1742 | }), | |
| 1743 | ); | |
| 1744 | }; | |
| 1745 | ||
| 1746 | /** | |
| 1747 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads | |
| 1748 | * of twenty-five: the background sync reads every linked pull request each minute, and one | |
| 1749 | * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector | |
| 1750 | * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. | |
| 1751 | */ | |
| 1752 | const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({ | |
| 1753 | key: ({ request, context }) => | |
| 1754 | JSON.stringify([ | |
| 1755 | request.host.toLowerCase(), | |
| 1756 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1757 | ?.credentialFingerprint ?? null, | |
| 1758 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1759 | ]), | |
| 1760 | resolver: (entries) => { | |
| 1761 | const [first] = entries; | |
| 1762 | const batchable = entries.filter( | |
| 1763 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, | |
| 1764 | ); | |
| 1765 | // Stack membership rides along where GitHub serves stacks, so the background sync can skip | |
| 1766 | // the REST stack read for pull requests that are in none. | |
| 1767 | const document = buildPullRequestSummariesGraphQlQuery( | |
| 1768 | batchable.map((entry) => entry.request), | |
| 1769 | first.request.host === "github.com", | |
| 1770 | ); | |
| 1771 | const batched = | |
| 1772 | document === null | |
| 1773 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) | |
| 1774 | : graphqlRead({ | |
| 1775 | cwd: first.request.cwd, | |
| 1776 | host: first.request.host, | |
| 1777 | operation: "getPullRequestSummary", | |
| 1778 | ...document, | |
| 1779 | decode: decodePullRequestSummariesJson, | |
| 1780 | }); | |
| 1781 | return batched.pipe( | |
| 1782 | // A GraphQL error anywhere fails the whole document — one repository gone or out of | |
| 1783 | // reach — so a batch that could not be read leaves every entry to its own read. A paused | |
| 1784 | // budget is the exception: reading one at a time would only spend what is being saved. | |
| 1785 | Effect.catchCauseIf( | |
| 1786 | (cause) => | |
| 1787 | !Cause.hasInterruptsOnly(cause) && | |
| 1788 | !Cause.findErrorOption(cause).pipe( | |
| 1789 | Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), | |
| 1790 | ), | |
| 1791 | (cause) => | |
| 1792 | Effect.logDebug("batched pull request summary read failed", { cause }).pipe( | |
| 1793 | Effect.as(new Map<number, GitHubPullRequestSummary>()), | |
| 1794 | ), | |
| 1795 | ), | |
| 1796 | Effect.flatMap((summaries) => { | |
| 1797 | const unanswered = entries.filter((entry) => { | |
| 1798 | const summary = summaries.get(batchable.indexOf(entry)); | |
| 1799 | if (summary === undefined) return true; | |
| 1800 | entry.completeUnsafe(Exit.succeed(summary)); | |
| 1801 | return false; | |
| 1802 | }); | |
| 1803 | return Effect.forEach( | |
| 1804 | unanswered, | |
| 1805 | (entry) => | |
| 1806 | viewPullRequestSummary(entry.request).pipe( | |
| 1807 | Effect.exit, | |
| 1808 | Effect.map((exit) => entry.completeUnsafe(exit)), | |
| 1809 | ), | |
| 1810 | { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, | |
| 1811 | ); | |
| 1812 | }), | |
| 1813 | Effect.catchCause((cause) => | |
| 1814 | Effect.sync(() => { | |
| 1815 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1816 | }), | |
| 1817 | ), | |
| 1818 | ); | |
| 1819 | }, | |
| 1820 | }).pipe( | |
| 1821 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1822 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1823 | ); | |
| 1824 | const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => | |
| 1825 | Effect.request(new PullRequestSummaryRead(input), summaryResolver); | |
| 1826 | ||
| 1827 | // Every watched pull request on a host in one read per pass. A pull request the batch has no | |
| 1828 | // answer for gets null, and its watch reads it in full; a failed batch fails every entry. | |
| 1829 | const watchFingerprintResolver = RequestResolver.makeGrouped< | |
| 1830 | PullRequestWatchFingerprintRead, | |
| 1831 | string | |
| 1832 | >({ | |
| 1833 | key: ({ request, context }) => | |
| 1834 | JSON.stringify([ | |
| 1835 | request.host.toLowerCase(), | |
| 1836 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1837 | ?.credentialFingerprint ?? null, | |
| 1838 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1839 | ]), | |
| 1840 | resolver: (entries) => { | |
| 1841 | const [first] = entries; | |
| 1842 | const batchable = entries.filter( | |
| 1843 | (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, | |
| 1844 | ); | |
| 1845 | const document = buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 1846 | batchable.map((entry) => entry.request), | |
| 1847 | ); | |
| 1848 | const read = | |
| 1849 | document === null | |
| 1850 | ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>()) | |
| 1851 | : graphqlRead({ | |
| 1852 | cwd: first.request.cwd, | |
| 1853 | host: first.request.host, | |
| 1854 | operation: "getPullRequestWatchFingerprint", | |
| 1855 | ...document, | |
| 1856 | decode: decodePullRequestWatchFingerprintsJson, | |
| 1857 | }); | |
| 1858 | return read.pipe( | |
| 1859 | Effect.map((fingerprints) => { | |
| 1860 | for (const entry of entries) { | |
| 1861 | const index = batchable.indexOf(entry); | |
| 1862 | entry.completeUnsafe( | |
| 1863 | Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), | |
| 1864 | ); | |
| 1865 | } | |
| 1866 | }), | |
| 1867 | Effect.catchCause((cause) => | |
| 1868 | Effect.sync(() => { | |
| 1869 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1870 | }), | |
| 1871 | ), | |
| 1872 | ); | |
| 1873 | }, | |
| 1874 | }).pipe( | |
| 1875 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1876 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1877 | ); | |
| 1878 | const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = | |
| 1879 | (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); | |
| 1880 | ||
| 1881 | return GitHubPullRequestApi.of({ | |
| 1882 | withVerifiedCredential, | |
| 1883 | revalidateChecks, | |
| 1884 | getRoutingIdentity, | |
| 1885 | getViewerLogin: (input) => | |
| 1886 | getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), | |
| 1887 | ||
| 1888 | listPullRequests: (input) => { | |
| 1889 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); | |
| 1890 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1891 | const query = searchQuery({ ...input, repositories: [input.repository] }); | |
| 1892 | if (query === null) { | |
| 1893 | return Effect.fail( | |
| 1894 | new GitHubRepositorySelectorError({ | |
| 1895 | cwd: input.cwd, | |
| 1896 | operation: "listPullRequests", | |
| 1897 | }), | |
| 1898 | ); | |
| 1899 | } | |
| 1900 | /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ | |
| 1901 | const collect = ( | |
| 1902 | rows: number, | |
| 1903 | page: ( | |
| 1904 | after: string | null, | |
| 1905 | rows: number, | |
| 1906 | ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestApiError>, | |
| 1907 | ) => | |
| 1908 | Effect.gen(function* () { | |
| 1909 | const countRows = (pages: ReadonlyArray<GitHubPullRequestListPage>) => | |
| 1910 | pages.reduce((total, read) => total + read.rawCount, 0); | |
| 1911 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1912 | (after, pages: ReadonlyArray<GitHubPullRequestListPage>) => | |
| 1913 | page(after, rows - countRows(pages)), | |
| 1914 | { | |
| 1915 | nextCursor: (read) => read.endCursor, | |
| 1916 | until: (pages) => countRows(pages) >= rows, | |
| 1917 | }, | |
| 1918 | ); | |
| 1919 | // `truncated` also covers a cursor GitHub repeated before `rows` arrived. | |
| 1920 | return { | |
| 1921 | items: pages.flatMap((read) => read.items), | |
| 1922 | rawCount: countRows(pages), | |
| 1923 | truncated, | |
| 1924 | }; | |
| 1925 | }); | |
| 1926 | const read = ( | |
| 1927 | continues: boolean, | |
| 1928 | requestedRows = input.limit + 1, | |
| 1929 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError> => | |
| 1930 | collect(requestedRows, (after, rows) => | |
| 1931 | continues | |
| 1932 | ? graphqlRead({ | |
| 1933 | cwd: input.cwd, | |
| 1934 | host: input.host, | |
| 1935 | operation: "listPullRequests", | |
| 1936 | // The reader's own words are in the query. | |
| 1937 | variables: { q: query, after }, | |
| 1938 | query: pullRequestSearchGraphQlQuery(rows, false, true), | |
| 1939 | decode: decodePullRequestSearchJson, | |
| 1940 | }) | |
| 1941 | : graphqlRead({ | |
| 1942 | cwd: input.cwd, | |
| 1943 | host: input.host, | |
| 1944 | operation: "listPullRequests", | |
| 1945 | variables: { owner, name, states: LIST_STATES[input.state], after }, | |
| 1946 | query: pullRequestListGraphQlQuery(rows), | |
| 1947 | decode: decodePullRequestListJson, | |
| 1948 | }), | |
| 1949 | ).pipe( | |
| 1950 | Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { | |
| 1951 | const items = continues | |
| 1952 | ? rawItems | |
| 1953 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); | |
| 1954 | if ( | |
| 1955 | !continues && | |
| 1956 | items.length < input.limit && | |
| 1957 | rawCount >= requestedRows && | |
| 1958 | requestedRows < fallbackMaxRows | |
| 1959 | ) { | |
| 1960 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 1961 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 1962 | } | |
| 1963 | return Effect.succeed({ | |
| 1964 | items: items.slice(0, input.limit), | |
| 1965 | // One row over the page size is the probe for a next page, and it is | |
| 1966 | // counted before decoding: a skipped malformed row must not end paging. | |
| 1967 | truncated: | |
| 1968 | cutShort || | |
| 1969 | (continues | |
| 1970 | ? rawCount > input.limit | |
| 1971 | : items.length > input.limit || rawCount >= requestedRows), | |
| 1972 | continues, | |
| 1973 | }); | |
| 1974 | }), | |
| 1975 | ); | |
| 1976 | // GitHub does not index every repository for search, and one it will not search answers | |
| 1977 | // with no rows rather than with an error — so an empty listing is read again from the | |
| 1978 | // repository's own list. Those rows come back newest-created first, an order no `updated:` | |
| 1979 | // qualifier can carry on from, so that page says it cannot be continued and the reader | |
| 1980 | // reaches the rest of it by asking for a larger page, as every listing used to. | |
| 1981 | // | |
| 1982 | // Only ever the first slice: a repository that answered the search once will answer it | |
| 1983 | // again, so an empty slice under a cursor is a repository that has run out. | |
| 1984 | // A text search that finds nothing has found nothing: falling back would answer it with the | |
| 1985 | // repository's whole list, which is every row the reader did not search for. The fallback | |
| 1986 | // is for a repository the index does not cover, and a listing with no text to match is the | |
| 1987 | // only place an empty answer can mean that. | |
| 1988 | // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just | |
| 1989 | // as well as search judges them over its own, so carrying them into the fallback answers | |
| 1990 | // the same read rather than a wider one. Free text is the one thing the fallback cannot | |
| 1991 | // judge locally — it lists rows, it does not search their text — so a query still rules | |
| 1992 | // the fallback out: an empty answer under one is already the answer. | |
| 1993 | const hasQuery = (input.query?.trim().length ?? 0) > 0; | |
| 1994 | return read(true).pipe( | |
| 1995 | Effect.filterOrElse( | |
| 1996 | (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, | |
| 1997 | () => read(false), | |
| 1998 | ), | |
| 1999 | Effect.flatMap((batch) => { | |
| 2000 | // Match the search query's host support, and enrich only rows that survived paging. | |
| 2001 | if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); | |
| 2002 | const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = []; | |
| 2003 | for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2004 | chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2005 | } | |
| 2006 | return Effect.forEach( | |
| 2007 | chunks, | |
| 2008 | (chunk) => { | |
| 2009 | const document = buildPullRequestStackMembershipsGraphQlQuery( | |
| 2010 | input.repository, | |
| 2011 | chunk.map((item) => item.number), | |
| 2012 | ); | |
| 2013 | if (document === null) return Effect.succeed(chunk); | |
| 2014 | return graphqlRead({ | |
| 2015 | cwd: input.cwd, | |
| 2016 | host: input.host, | |
| 2017 | operation: "listPullRequestStackMemberships", | |
| 2018 | ...document, | |
| 2019 | decode: decodePullRequestStackMembershipsJson, | |
| 2020 | }).pipe( | |
| 2021 | Effect.map((memberships) => | |
| 2022 | chunk.map((item, index) => { | |
| 2023 | const stack = memberships.get(index); | |
| 2024 | return stack === undefined ? item : { ...item, stack }; | |
| 2025 | }), | |
| 2026 | ), | |
| 2027 | // Optional badges must not take down a listing that already read successfully. | |
| 2028 | Effect.catch(() => | |
| 2029 | Effect.logWarning("Pull request stack membership enrichment failed", { | |
| 2030 | operation: "listPullRequestStackMemberships", | |
| 2031 | host: input.host, | |
| 2032 | rows: chunk.length, | |
| 2033 | }).pipe(Effect.as(chunk)), | |
| 2034 | ), | |
| 2035 | ); | |
| 2036 | }, | |
| 2037 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2038 | ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); | |
| 2039 | }), | |
| 2040 | ); | |
| 2041 | }, | |
| 2042 | ||
| 2043 | searchPullRequests: (input) => { | |
| 2044 | const query = searchQuery(input); | |
| 2045 | if (query === null) { | |
| 2046 | return Effect.fail( | |
| 2047 | new GitHubRepositorySelectorError({ | |
| 2048 | cwd: input.cwd, | |
| 2049 | operation: "searchPullRequests", | |
| 2050 | }), | |
| 2051 | ); | |
| 2052 | } | |
| 2053 | // One extra row reveals that the host has more than the slice shows, the way the | |
| 2054 | // per-repository read does — up to GitHub's own ceiling on a search page, past which | |
| 2055 | // `hasNextPage` is what says there is more. | |
| 2056 | const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 2057 | return graphqlRead({ | |
| 2058 | cwd: input.cwd, | |
| 2059 | host: input.host, | |
| 2060 | operation: "searchPullRequests", | |
| 2061 | variables: { q: query, after: null }, | |
| 2062 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | |
| 2063 | decode: decodePullRequestSearchJson, | |
| 2064 | }).pipe( | |
| 2065 | Effect.map((batch) => ({ | |
| 2066 | items: batch.items.slice(0, input.limit), | |
| 2067 | truncated: batch.rawCount > input.limit || batch.hasNextPage, | |
| 2068 | })), | |
| 2069 | ); | |
| 2070 | }, | |
| 2071 | ||
| 2072 | listPullRequestStats: (input) => { | |
| 2073 | const chunks: Array<ReadonlyArray<{ readonly repository: string; readonly number: number }>> = | |
| 2074 | []; | |
| 2075 | for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2076 | chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2077 | } | |
| 2078 | return Effect.forEach( | |
| 2079 | chunks, | |
| 2080 | (chunk) => { | |
| 2081 | const document = buildPullRequestStatsGraphQlQuery(chunk); | |
| 2082 | if (document === null) { | |
| 2083 | return Effect.fail( | |
| 2084 | new GitHubRepositorySelectorError({ | |
| 2085 | cwd: input.cwd, | |
| 2086 | operation: "listPullRequestStats", | |
| 2087 | }), | |
| 2088 | ); | |
| 2089 | } | |
| 2090 | return graphqlRead({ | |
| 2091 | cwd: input.cwd, | |
| 2092 | host: input.host, | |
| 2093 | operation: "listPullRequestStats", | |
| 2094 | ...document, | |
| 2095 | decode: decodePullRequestStatsJson, | |
| 2096 | }).pipe( | |
| 2097 | Effect.map((stats) => | |
| 2098 | chunk.flatMap((changeRequest, index) => { | |
| 2099 | const stat = stats.get(index); | |
| 2100 | return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; | |
| 2101 | }), | |
| 2102 | ), | |
| 2103 | ); | |
| 2104 | }, | |
| 2105 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2106 | ).pipe(Effect.map((results) => results.flat())); | |
| 2107 | }, | |
| 2108 | ||
| 2109 | getPullRequestSummary, | |
| 2110 | getPullRequestWatchFingerprint, | |
| 2111 | ||
| 2112 | getPullRequestDetail, | |
| 2113 | getPullRequestPreview: (input) => { | |
| 2114 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2115 | return graphqlRead({ | |
| 2116 | cwd: input.cwd, | |
| 2117 | host: input.host, | |
| 2118 | operation: "getPullRequestPreview", | |
| 2119 | variables: { owner, name, number: input.number }, | |
| 2120 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 2121 | decode: decodePullRequestPreviewJson, | |
| 2122 | }); | |
| 2123 | }, | |
| 2124 | listWorkflowRunsRequiringApproval, | |
| 2125 | ||
| 2126 | getPullRequestStack: (input) => { | |
| 2127 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2128 | return restRead({ | |
| 2129 | cwd: input.cwd, | |
| 2130 | host: input.host, | |
| 2131 | operation: "getPullRequestStack", | |
| 2132 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2133 | decode: decodePullRequestStacksJson, | |
| 2134 | }).pipe( | |
| 2135 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | |
| 2136 | Effect.flatMap((stack) => { | |
| 2137 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | |
| 2138 | return restRead({ | |
| 2139 | cwd: input.cwd, | |
| 2140 | host: input.host, | |
| 2141 | operation: "getPullRequestStack", | |
| 2142 | path: `repos/${owner}/${name}/stacks/${stack.number}`, | |
| 2143 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), | |
| 2144 | }); | |
| 2145 | }), | |
| 2146 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 2147 | // previously synced stack and let the caller retry. | |
| 2148 | Effect.catchTags({ | |
| 2149 | GitHubApiNotFoundError: () => Effect.succeed(null), | |
| 2150 | }), | |
| 2151 | ); | |
| 2152 | }, | |
| 2153 | ||
| 2154 | getPullRequestActivity: (input) => | |
| 2155 | Effect.gen(function* () { | |
| 2156 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2157 | let author: PullRequestActor | null = null; | |
| 2158 | let commits: ReadonlyArray<PullRequestCommit> = []; | |
| 2159 | const remarks: PullRequestComment[] = []; | |
| 2160 | let commentsAfter: string | null = null; | |
| 2161 | let reviewsAfter: string | null = null; | |
| 2162 | let withComments = true; | |
| 2163 | let withReviews = true; | |
| 2164 | // Both remark lists page on their own; a page asks only for the ones with more to give. | |
| 2165 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { | |
| 2166 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ | |
| 2167 | cwd: input.cwd, | |
| 2168 | host: input.host, | |
| 2169 | operation: "getPullRequestActivity", | |
| 2170 | variables: { | |
| 2171 | owner, | |
| 2172 | name, | |
| 2173 | number: input.number, | |
| 2174 | head: page === 0, | |
| 2175 | withComments, | |
| 2176 | commentsAfter, | |
| 2177 | withReviews, | |
| 2178 | reviewsAfter, | |
| 2179 | }, | |
| 2180 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 2181 | decode: decodePullRequestActivityJson, | |
| 2182 | }); | |
| 2183 | if (page === 0) { | |
| 2184 | author = read.author ?? null; | |
| 2185 | commits = read.commits ?? []; | |
| 2186 | } | |
| 2187 | remarks.push(...read.remarks); | |
| 2188 | commentsAfter = read.nextCommentsCursor; | |
| 2189 | reviewsAfter = read.nextReviewsCursor; | |
| 2190 | withComments = withComments && commentsAfter !== null; | |
| 2191 | withReviews = withReviews && reviewsAfter !== null; | |
| 2192 | } | |
| 2193 | return { | |
| 2194 | author, | |
| 2195 | comments: remarks.toSorted((left, right) => | |
| 2196 | left.createdAt.localeCompare(right.createdAt), | |
| 2197 | ), | |
| 2198 | commits, | |
| 2199 | } satisfies GitHubPullRequestActivity; | |
| 2200 | }), | |
| 2201 | ||
| 2202 | getPullRequestDiff: (input) => { | |
| 2203 | const filesPage = (page: number) => | |
| 2204 | diffFilesPage({ | |
| 2205 | cwd: input.cwd, | |
| 2206 | repository: input.repository, | |
| 2207 | host: input.host, | |
| 2208 | number: input.number, | |
| 2209 | page, | |
| 2210 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 2211 | }); | |
| 2212 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 2213 | return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); | |
| 2214 | } | |
| 2215 | // A cursor only ever comes from the files walk, so a reader carrying one is already past | |
| 2216 | // the point where `gh pr diff` had anything to say. | |
| 2217 | if (input.cursor !== undefined) { | |
| 2218 | const page = diffCursorPage(input.cursor); | |
| 2219 | return page === null | |
| 2220 | ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) | |
| 2221 | : filesPage(page); | |
| 2222 | } | |
| 2223 | // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. | |
| 2224 | if (input.commit !== undefined) { | |
| 2225 | return filesPage(1); | |
| 2226 | } | |
| 2227 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2228 | return api | |
| 2229 | .rest({ | |
| 2230 | host: input.host, | |
| 2231 | operation: "getPullRequestDiff", | |
| 2232 | path: `repos/${owner}/${name}/pulls/${input.number}`, | |
| 2233 | accept: "application/vnd.github.diff", | |
| 2234 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 2235 | timeout: DIFF_TIMEOUT, | |
| 2236 | }) | |
| 2237 | .pipe( | |
| 2238 | Effect.flatMap((response) => | |
| 2239 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor | |
| 2240 | // something the reader can carry on from. The files API can serve the same change a | |
| 2241 | // whole number of files at a time, so an oversized patch takes that road as well. | |
| 2242 | response.truncated | |
| 2243 | ? filesPage(1) | |
| 2244 | : // One read served the whole patch, so there is no next slice to ask for. | |
| 2245 | Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), | |
| 2246 | ), | |
| 2247 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read | |
| 2248 | // from the files API instead, a page per call. Only once the direct read has failed: a | |
| 2249 | // pull request GitHub will serve a diff for must not pay for a second request. A | |
| 2250 | // fallback that fails too reports the original refusal, which is the one that explains | |
| 2251 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or | |
| 2252 | // a rate limit fails the same way for every request. | |
| 2253 | Effect.catchTags({ | |
| 2254 | GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), | |
| 2255 | }), | |
| 2256 | ); | |
| 2257 | }, | |
| 2258 | ||
| 2259 | getPullRequestDiffFileContents, | |
| 2260 | ||
| 2261 | getReviewThreadComments: (input) => { | |
| 2262 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2263 | return graphqlRead({ | |
| 2264 | cwd: input.cwd, | |
| 2265 | host: input.host, | |
| 2266 | operation: "getReviewThreadComments", | |
| 2267 | variables: { | |
| 2268 | owner, | |
| 2269 | name, | |
| 2270 | number: input.number, | |
| 2271 | threadId: input.threadId, | |
| 2272 | cursor: input.cursor, | |
| 2273 | }, | |
| 2274 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 2275 | decode: decodeReviewThreadCommentsJson, | |
| 2276 | }).pipe( | |
| 2277 | Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => | |
| 2278 | belongsToPullRequest | |
| 2279 | ? Effect.succeed({ comments, nextCursor }) | |
| 2280 | : Effect.fail( | |
| 2281 | new GitHubSubjectScopeError({ | |
| 2282 | cwd: input.cwd, | |
| 2283 | operation: "getReviewThreadComments", | |
| 2284 | }), | |
| 2285 | ), | |
| 2286 | ), | |
| 2287 | ); | |
| 2288 | }, | |
| 2289 | ||
| 2290 | listReviewThreadComments: (input) => | |
| 2291 | Effect.gen(function* () { | |
| 2292 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2293 | const threadPage = ( | |
| 2294 | cursor: string | null, | |
| 2295 | ): Effect.Effect<GitHubReviewThreadPage, GitHubPullRequestApiError> => | |
| 2296 | graphqlRead({ | |
| 2297 | cwd: input.cwd, | |
| 2298 | host: input.host, | |
| 2299 | operation: "listReviewThreadComments", | |
| 2300 | variables: { owner, name, number: input.number, cursor }, | |
| 2301 | query: REVIEW_THREADS_GRAPHQL_QUERY, | |
| 2302 | decode: decodeReviewThreadsJson, | |
| 2303 | }); | |
| 2304 | const entries: GitHubReviewThreadEntry[] = []; | |
| 2305 | const avatarsByLogin = new Map<string, string>(); | |
| 2306 | const botLogins = new Set<string>(); | |
| 2307 | const commitStats = new Map< | |
| 2308 | string, | |
| 2309 | { readonly additions: number; readonly deletions: number } | |
| 2310 | >(); | |
| 2311 | let reviewers: ReadonlyArray<PullRequestActor> = []; | |
| 2312 | let reactions: GitHubReviewThreadPage["reactions"] = []; | |
| 2313 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2314 | const editedAtById = new Map<string, string>(); | |
| 2315 | let commits: GitHubReviewThreadPage["commits"] = []; | |
| 2316 | let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; | |
| 2317 | const dismissalsByReviewId = new Map<string, string>(); | |
| 2318 | let dismissalCursor: string | null = null; | |
| 2319 | let cursor: string | null = null; | |
| 2320 | let page = 0; | |
| 2321 | do { | |
| 2322 | const read: GitHubReviewThreadPage = yield* threadPage(cursor); | |
| 2323 | entries.push(...read.threads); | |
| 2324 | for (const login of read.botLogins) botLogins.add(login); | |
| 2325 | for (const [login, avatarUrl] of read.avatarsByLogin) | |
| 2326 | avatarsByLogin.set(login, avatarUrl); | |
| 2327 | // The roster, the commits and the viewer's standing travel with every page, and the | |
| 2328 | // first one already carries all of them. | |
| 2329 | if (page === 0) { | |
| 2330 | reviewers = read.reviewers; | |
| 2331 | reactions = read.reactions; | |
| 2332 | for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); | |
| 2333 | for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); | |
| 2334 | commits = read.commits; | |
| 2335 | viewer = read.viewer; | |
| 2336 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2337 | dismissalsByReviewId.set(id, message); | |
| 2338 | dismissalCursor = read.nextDismissalCursor; | |
| 2339 | for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); | |
| 2340 | } | |
| 2341 | cursor = read.nextCursor; | |
| 2342 | page += 1; | |
| 2343 | } while (cursor !== null && page < REVIEW_THREAD_PAGES); | |
| 2344 | ||
| 2345 | // Almost never entered: the embedded page already holds every dismissal a pull request | |
| 2346 | // ordinarily accrues. Followed so a review whose event fell past that page still finds | |
| 2347 | // its reason. | |
| 2348 | if (dismissalCursor !== null) { | |
| 2349 | const { pages } = yield* readGraphQlPages( | |
| 2350 | (cursor) => | |
| 2351 | graphqlRead({ | |
| 2352 | cwd: input.cwd, | |
| 2353 | host: input.host, | |
| 2354 | operation: "listReviewThreadComments", | |
| 2355 | variables: { owner, name, number: input.number, cursor }, | |
| 2356 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 2357 | decode: decodeReviewDismissalsJson, | |
| 2358 | }), | |
| 2359 | { | |
| 2360 | from: dismissalCursor, | |
| 2361 | maxPages: REVIEW_THREAD_PAGES, | |
| 2362 | nextCursor: (read) => read.nextCursor, | |
| 2363 | }, | |
| 2364 | ); | |
| 2365 | for (const read of pages) | |
| 2366 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2367 | dismissalsByReviewId.set(id, message); | |
| 2368 | } | |
| 2369 | ||
| 2370 | const reviewThreads = entries.map((entry) => ({ | |
| 2371 | ...entry.thread, | |
| 2372 | commentCount: entry.commentCount, | |
| 2373 | ...(entry.nextCommentCursor === null | |
| 2374 | ? {} | |
| 2375 | : { nextCommentsCursor: entry.nextCommentCursor }), | |
| 2376 | })); | |
| 2377 | return { | |
| 2378 | comments: reviewThreadConversation(reviewThreads), | |
| 2379 | dismissalsByReviewId, | |
| 2380 | reviewThreads, | |
| 2381 | // GitHub's own count of each thread, so the number the page shows is the host's even | |
| 2382 | // where a bound kept some of the words on GitHub. | |
| 2383 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), | |
| 2384 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), | |
| 2385 | reviewThreadsTruncated: cursor !== null, | |
| 2386 | reactions, | |
| 2387 | reactionsById, | |
| 2388 | editedAtById, | |
| 2389 | reviewers, | |
| 2390 | avatarsByLogin, | |
| 2391 | botLogins, | |
| 2392 | commitStats, | |
| 2393 | commits, | |
| 2394 | viewer, | |
| 2395 | }; | |
| 2396 | }), | |
| 2397 | ||
| 2398 | listActorAvatars: (input) => { | |
| 2399 | if (input.ids.length === 0) { | |
| 2400 | return Effect.succeed(new Map<string, string>()); | |
| 2401 | } | |
| 2402 | return graphqlRead({ | |
| 2403 | cwd: input.cwd, | |
| 2404 | host: input.host, | |
| 2405 | operation: "listActorAvatars", | |
| 2406 | variables: { ids: input.ids }, | |
| 2407 | query: ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 2408 | decode: decodeActorAvatarsJson, | |
| 2409 | }); | |
| 2410 | }, | |
| 2411 | ||
| 2412 | getViewerAccess: (input) => { | |
| 2413 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2414 | return graphqlRead({ | |
| 2415 | cwd: input.cwd, | |
| 2416 | host: input.host, | |
| 2417 | operation: "getViewerAccess", | |
| 2418 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 2419 | variables: { owner, name, number: input.number }, | |
| 2420 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 2421 | decode: decodeViewerPermissionsJson, | |
| 2422 | }); | |
| 2423 | }, | |
| 2424 | ||
| 2425 | listReviewerCandidates: (input) => { | |
| 2426 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2427 | return graphqlRead({ | |
| 2428 | cwd: input.cwd, | |
| 2429 | host: input.host, | |
| 2430 | operation: "listReviewerCandidates", | |
| 2431 | allowReserve: true, | |
| 2432 | variables: { owner, name, number: input.number }, | |
| 2433 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 2434 | decode: decodeReviewerCandidatesJson, | |
| 2435 | }); | |
| 2436 | }, | |
| 2437 | ||
| 2438 | setReviewerRequest: (input) => { | |
| 2439 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2440 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2441 | // there is nothing to say here about somebody who has reviewed once already. | |
| 2442 | return api | |
| 2443 | .rest({ | |
| 2444 | host: input.host, | |
| 2445 | operation: "setReviewerRequest", | |
| 2446 | method: input.requested ? "POST" : "DELETE", | |
| 2447 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | |
| 2448 | body: buildReviewerRequest(input.reviewers), | |
| 2449 | }) | |
| 2450 | .pipe(Effect.asVoid); | |
| 2451 | }, | |
| 2452 | ||
| 2453 | listLabelCandidates: (input) => { | |
| 2454 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2455 | return graphqlRead({ | |
| 2456 | cwd: input.cwd, | |
| 2457 | host: input.host, | |
| 2458 | operation: "listLabelCandidates", | |
| 2459 | allowReserve: true, | |
| 2460 | variables: { owner, name, number: input.number }, | |
| 2461 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 2462 | decode: decodeLabelCandidatesJson, | |
| 2463 | }); | |
| 2464 | }, | |
| 2465 | ||
| 2466 | setLabels: (input) => { | |
| 2467 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2468 | // A pull request is an issue to the labels API. Adding posts a list and leaves what was | |
| 2469 | // already there; taking off is one delete per label, since the endpoint names one in its | |
| 2470 | // path. The name goes into the path encoded, because a label may carry a space or a slash. | |
| 2471 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; | |
| 2472 | if (input.applied) { | |
| 2473 | return api | |
| 2474 | .rest({ | |
| 2475 | host: input.host, | |
| 2476 | operation: "setLabels", | |
| 2477 | method: "POST", | |
| 2478 | path: issue, | |
| 2479 | body: buildLabelRequest(input.labels), | |
| 2480 | }) | |
| 2481 | .pipe(Effect.asVoid); | |
| 2482 | } | |
| 2483 | return Effect.forEach( | |
| 2484 | input.labels, | |
| 2485 | (label) => | |
| 2486 | api.rest({ | |
| 2487 | host: input.host, | |
| 2488 | operation: "setLabels", | |
| 2489 | method: "DELETE", | |
| 2490 | path: `${issue}/${encodeURIComponent(label)}`, | |
| 2491 | }), | |
| 2492 | { concurrency: 1, discard: true }, | |
| 2493 | ); | |
| 2494 | }, | |
| 2495 | ||
| 2496 | runPullRequestAction: (input) => { | |
| 2497 | if (input.stackNumber !== undefined) | |
| 2498 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | |
| 2499 | Effect.provideService(GitHubApi.GitHubApi, api), | |
| 2500 | Effect.provideService(VcsProcess.VcsProcess, vcsProcess), | |
| 2501 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2502 | ); | |
| 2503 | if (input.action === "revert") { | |
| 2504 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | |
| 2505 | Effect.flatMap((pullRequestId) => | |
| 2506 | graphql({ | |
| 2507 | host: input.host, | |
| 2508 | operation: "revertPullRequest", | |
| 2509 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2510 | variables: { pullRequestId }, | |
| 2511 | }), | |
| 2512 | ), | |
| 2513 | ); | |
| 2514 | } | |
| 2515 | if (input.action === "approve-workflows") { | |
| 2516 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2517 | return getPullRequestDetail(input).pipe( | |
| 2518 | Effect.flatMap((detail) => { | |
| 2519 | if (detail.isCrossRepository !== true) return Effect.void; | |
| 2520 | if (detail.headSha == null || detail.headRepositoryOwner == null) { | |
| 2521 | return Effect.fail( | |
| 2522 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2523 | cwd: input.cwd, | |
| 2524 | number: input.number, | |
| 2525 | }), | |
| 2526 | ); | |
| 2527 | } | |
| 2528 | const expectedHeadSha = detail.headSha; | |
| 2529 | const expectedHeadBranch = detail.headBranch; | |
| 2530 | const expectedHeadRepositoryOwner = detail.headRepositoryOwner; | |
| 2531 | return listWorkflowRunsRequiringApproval({ | |
| 2532 | ...input, | |
| 2533 | headSha: expectedHeadSha, | |
| 2534 | headBranch: expectedHeadBranch, | |
| 2535 | headRepositoryOwner: expectedHeadRepositoryOwner, | |
| 2536 | isCrossRepository: true, | |
| 2537 | }).pipe( | |
| 2538 | Effect.flatMap((runs) => | |
| 2539 | Effect.forEach( | |
| 2540 | runs, | |
| 2541 | (run) => | |
| 2542 | getPullRequestDetail(input).pipe( | |
| 2543 | Effect.flatMap((current) => { | |
| 2544 | if (current.headSha == null || current.headRepositoryOwner == null) { | |
| 2545 | return Effect.fail( | |
| 2546 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2547 | cwd: input.cwd, | |
| 2548 | number: input.number, | |
| 2549 | }), | |
| 2550 | ); | |
| 2551 | } | |
| 2552 | if ( | |
| 2553 | current.isCrossRepository !== true || | |
| 2554 | current.headSha !== expectedHeadSha || | |
| 2555 | current.headBranch !== expectedHeadBranch || | |
| 2556 | current.headRepositoryOwner.toLowerCase() !== | |
| 2557 | expectedHeadRepositoryOwner.toLowerCase() | |
| 2558 | ) { | |
| 2559 | return Effect.fail( | |
| 2560 | new GitHubWorkflowApprovalHeadChangedError({ | |
| 2561 | cwd: input.cwd, | |
| 2562 | number: input.number, | |
| 2563 | }), | |
| 2564 | ); | |
| 2565 | } | |
| 2566 | return listWorkflowRunsRequiringApproval({ | |
| 2567 | ...input, | |
| 2568 | headSha: current.headSha, | |
| 2569 | headBranch: current.headBranch, | |
| 2570 | headRepositoryOwner: current.headRepositoryOwner, | |
| 2571 | isCrossRepository: true, | |
| 2572 | }); | |
| 2573 | }), | |
| 2574 | Effect.flatMap((currentRuns) => | |
| 2575 | currentRuns.some((current) => current.id === run.id) | |
| 2576 | ? api | |
| 2577 | .rest({ | |
| 2578 | host: input.host, | |
| 2579 | operation: "approveWorkflowRun", | |
| 2580 | method: "POST", | |
| 2581 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2582 | }) | |
| 2583 | .pipe(Effect.asVoid) | |
| 2584 | : Effect.void, | |
| 2585 | ), | |
| 2586 | ), | |
| 2587 | { concurrency: 1, discard: true }, | |
| 2588 | ), | |
| 2589 | ), | |
| 2590 | ); | |
| 2591 | }), | |
| 2592 | ); | |
| 2593 | } | |
| 2594 | const action = input.action; | |
| 2595 | if (action in SIMPLE_ACTION_MUTATIONS) { | |
| 2596 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( | |
| 2597 | Effect.flatMap((pullRequestId) => | |
| 2598 | graphql({ | |
| 2599 | host: input.host, | |
| 2600 | operation: "runPullRequestAction", | |
| 2601 | query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], | |
| 2602 | variables: { pullRequestId }, | |
| 2603 | }), | |
| 2604 | ), | |
| 2605 | ); | |
| 2606 | } | |
| 2607 | return Effect.gen(function* () { | |
| 2608 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2609 | // Read fresh rather than from the node id cache: merging and updating act on the head | |
| 2610 | // as it stands now, and the merge queue decides which mutation a merge is. | |
| 2611 | const state = yield* graphqlRead({ | |
| 2612 | cwd: input.cwd, | |
| 2613 | host: input.host, | |
| 2614 | operation: "runPullRequestAction", | |
| 2615 | allowReserve: true, | |
| 2616 | query: ACTION_STATE_GRAPHQL_QUERY, | |
| 2617 | variables: { | |
| 2618 | owner, | |
| 2619 | name, | |
| 2620 | number: input.number, | |
| 2621 | headRef: `refs/pull/${input.number}/head`, | |
| 2622 | }, | |
| 2623 | decode: decodeActionState, | |
| 2624 | }); | |
| 2625 | if (action === "update-branch") { | |
| 2626 | // Already current is done, the way `gh pr update-branch` reports it. | |
| 2627 | if (state.baseRef?.compare?.behindBy === 0) return; | |
| 2628 | // GitHub updates with a merge commit unless asked to rebase, which is its own default. | |
| 2629 | return yield* graphql({ | |
| 2630 | host: input.host, | |
| 2631 | operation: "runPullRequestAction", | |
| 2632 | query: UPDATE_BRANCH_GRAPHQL_MUTATION, | |
| 2633 | variables: { | |
| 2634 | pullRequestId: state.id, | |
| 2635 | expectedHeadOid: state.headRefOid, | |
| 2636 | updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", | |
| 2637 | }, | |
| 2638 | }); | |
| 2639 | } | |
| 2640 | let body: string | undefined; | |
| 2641 | let expectedHead: string | undefined; | |
| 2642 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { | |
| 2643 | const message = yield* graphqlRead({ | |
| 2644 | cwd: input.cwd, | |
| 2645 | host: input.host, | |
| 2646 | operation: "runPullRequestAction", | |
| 2647 | allowReserve: true, | |
| 2648 | query: MERGE_MESSAGE_GRAPHQL_QUERY, | |
| 2649 | variables: { | |
| 2650 | owner, | |
| 2651 | name, | |
| 2652 | number: input.number, | |
| 2653 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", | |
| 2654 | }, | |
| 2655 | decode: decodeMergeMessage, | |
| 2656 | }); | |
| 2657 | // GitHub's merge queue chooses its own message and ignores custom text. | |
| 2658 | if (!message.isMergeQueueEnabled) { | |
| 2659 | const cleaned = removeAgentCredits(message.viewerMergeBodyText); | |
| 2660 | if (cleaned !== message.viewerMergeBodyText) { | |
| 2661 | body = cleaned; | |
| 2662 | expectedHead = message.headRefOid; | |
| 2663 | } | |
| 2664 | } | |
| 2665 | } | |
| 2666 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and | |
| 2667 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. | |
| 2668 | // GitHub stores the strategy with a standing instruction rather than choosing one at | |
| 2669 | // merge time, so arming still names it. | |
| 2670 | const auto = | |
| 2671 | state.isMergeQueueEnabled === true || | |
| 2672 | (action === "enable-auto-merge" && | |
| 2673 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); | |
| 2674 | yield* graphql({ | |
| 2675 | host: input.host, | |
| 2676 | operation: "runPullRequestAction", | |
| 2677 | query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2678 | variables: { | |
| 2679 | input: { | |
| 2680 | pullRequestId: state.id, | |
| 2681 | mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], | |
| 2682 | ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), | |
| 2683 | ...(body === undefined ? {} : { commitBody: body }), | |
| 2684 | }, | |
| 2685 | }, | |
| 2686 | }); | |
| 2687 | }); | |
| 2688 | }, | |
| 2689 | ||
| 2690 | commentOnPullRequest: (input) => | |
| 2691 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( | |
| 2692 | Effect.flatMap((subjectId) => | |
| 2693 | graphql({ | |
| 2694 | host: input.host, | |
| 2695 | operation: "commentOnPullRequest", | |
| 2696 | query: ADD_COMMENT_GRAPHQL_MUTATION, | |
| 2697 | variables: { subjectId, body: input.body }, | |
| 2698 | }), | |
| 2699 | ), | |
| 2700 | ), | |
| 2701 | ||
| 2702 | submitReview: (input) => { | |
| 2703 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2704 | // The whole review is one request, so nothing is visible to anyone else until the verdict | |
| 2705 | // is sent. | |
| 2706 | return api | |
| 2707 | .rest({ | |
| 2708 | host: input.host, | |
| 2709 | operation: "submitReview", | |
| 2710 | method: "POST", | |
| 2711 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2712 | body: buildReviewSubmission({ | |
| 2713 | verdict: input.verdict, | |
| 2714 | body: input.body, | |
| 2715 | comments: input.comments, | |
| 2716 | }), | |
| 2717 | }) | |
| 2718 | .pipe(Effect.asVoid); | |
| 2719 | }, | |
| 2720 | ||
| 2721 | replyToReviewThread: (input) => | |
| 2722 | graphql({ | |
| 2723 | host: input.host, | |
| 2724 | operation: "replyToReviewThread", | |
| 2725 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 2726 | variables: { threadId: input.threadId, body: input.body }, | |
| 2727 | }), | |
| 2728 | ||
| 2729 | getPullRequestFilesViewed: (input) => { | |
| 2730 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2731 | return readGraphQlPages( | |
| 2732 | (after) => | |
| 2733 | graphqlRead({ | |
| 2734 | cwd: input.cwd, | |
| 2735 | host: input.host, | |
| 2736 | operation: "getPullRequestFilesViewed", | |
| 2737 | variables: { owner, name, number: input.number, after }, | |
| 2738 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 2739 | decode: decodePullRequestFilesViewedJson, | |
| 2740 | }), | |
| 2741 | { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, | |
| 2742 | ).pipe( | |
| 2743 | Effect.map(({ pages, truncated }) => ({ | |
| 2744 | files: pages.flatMap((page) => page.files), | |
| 2745 | truncated, | |
| 2746 | })), | |
| 2747 | ); | |
| 2748 | }, | |
| 2749 | ||
| 2750 | setPullRequestFilesViewed: (input) => { | |
| 2751 | if (input.files.length === 0) return Effect.void; | |
| 2752 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( | |
| 2753 | Effect.flatMap((pullRequestId) => { | |
| 2754 | const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); | |
| 2755 | return mutation === null | |
| 2756 | ? Effect.void | |
| 2757 | : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); | |
| 2758 | }), | |
| 2759 | ); | |
| 2760 | }, | |
| 2761 | ||
| 2762 | setReviewThreadResolution: (input) => | |
| 2763 | graphql({ | |
| 2764 | host: input.host, | |
| 2765 | operation: "setReviewThreadResolution", | |
| 2766 | query: input.resolved | |
| 2767 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION | |
| 2768 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 2769 | variables: { threadId: input.threadId }, | |
| 2770 | }), | |
| 2771 | ||
| 2772 | setReaction: (input) => { | |
| 2773 | const givenSubjectId = input.subjectId; | |
| 2774 | const subjectId = | |
| 2775 | givenSubjectId === undefined | |
| 2776 | ? pullRequestNodeId({ ...input, operation: "setReaction" }) | |
| 2777 | : subjectBelongsToPullRequest({ | |
| 2778 | ...input, | |
| 2779 | subjectId: givenSubjectId, | |
| 2780 | operation: "setReaction", | |
| 2781 | }).pipe( | |
| 2782 | Effect.flatMap((belongs) => | |
| 2783 | belongs | |
| 2784 | ? Effect.succeed(givenSubjectId) | |
| 2785 | : Effect.fail( | |
| 2786 | new GitHubSubjectScopeError({ | |
| 2787 | cwd: input.cwd, | |
| 2788 | operation: "setReaction", | |
| 2789 | }), | |
| 2790 | ), | |
| 2791 | ), | |
| 2792 | ); | |
| 2793 | return subjectId.pipe( | |
| 2794 | Effect.flatMap((subjectId) => | |
| 2795 | graphql({ | |
| 2796 | host: input.host, | |
| 2797 | operation: "setReaction", | |
| 2798 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 2799 | variables: { subjectId, content: gitHubReactionContent(input.content) }, | |
| 2800 | }), | |
| 2801 | ), | |
| 2802 | ); | |
| 2803 | }, | |
| 2804 | ||
| 2805 | updatePullRequest: (input) => | |
| 2806 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( | |
| 2807 | Effect.flatMap((pullRequestId) => | |
| 2808 | graphql({ | |
| 2809 | host: input.host, | |
| 2810 | operation: "updatePullRequest", | |
| 2811 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2812 | // A field the caller did not name is left out of the request entirely, so GitHub | |
| 2813 | // keeps the words that are there rather than being asked for an empty one. | |
| 2814 | variables: { | |
| 2815 | pullRequestId, | |
| 2816 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 2817 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 2818 | }, | |
| 2819 | }), | |
| 2820 | ), | |
| 2821 | ), | |
| 2822 | ||
| 2823 | updateComment: (input) => | |
| 2824 | subjectBelongsToPullRequest({ | |
| 2825 | cwd: input.cwd, | |
| 2826 | repository: input.repository, | |
| 2827 | host: input.host, | |
| 2828 | number: input.number, | |
| 2829 | subjectId: input.commentId, | |
| 2830 | operation: "updateComment", | |
| 2831 | }).pipe( | |
| 2832 | Effect.flatMap((belongs) => | |
| 2833 | belongs | |
| 2834 | ? Effect.succeed(input.commentId) | |
| 2835 | : Effect.fail( | |
| 2836 | new GitHubSubjectScopeError({ | |
| 2837 | cwd: input.cwd, | |
| 2838 | operation: "updateComment", | |
| 2839 | }), | |
| 2840 | ), | |
| 2841 | ), | |
| 2842 | Effect.flatMap((commentId) => | |
| 2843 | graphql({ | |
| 2844 | host: input.host, | |
| 2845 | operation: "updateComment", | |
| 2846 | query: | |
| 2847 | input.kind === "issue-comment" | |
| 2848 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION | |
| 2849 | : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 2850 | variables: { commentId, body: input.body }, | |
| 2851 | }), | |
| 2852 | ), | |
| 2853 | ), | |
| 2854 | }); | |
| 2855 | }); | |
| 2856 | ||
| 2857 | export const layer = Layer.effect(GitHubPullRequestApi, make); |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts deleted-1067| ... | ... | @@ -1,1067 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Redacted from "effect/Redacted"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import type { PullRequestReaction } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; | |
| 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 10 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 11 | import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; | |
| 12 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | |
| 13 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; | |
| 14 | ||
| 15 | const coreFields = { | |
| 16 | checksTruncated: false, | |
| 17 | comparison: { behindBy: 0, viewerCanUpdate: true }, | |
| 18 | viewerAccess: { | |
| 19 | canWrite: true, | |
| 20 | canTriage: true, | |
| 21 | canUpdate: true, | |
| 22 | didAuthor: false, | |
| 23 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 24 | }, | |
| 25 | }; | |
| 26 | ||
| 27 | it.effect("maps credential verification failures without relabeling operation failures", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | let verificationFails = true; | |
| 30 | let operations = 0; | |
| 31 | const provider = yield* make.pipe( | |
| 32 | Effect.provide( | |
| 33 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 34 | revalidateChecks: (_input, read) => read, | |
| 35 | withVerifiedCredential: (_input, use) => | |
| 36 | verificationFails | |
| 37 | ? Effect.fail( | |
| 38 | new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ | |
| 39 | cwd: "/w", | |
| 40 | }), | |
| 41 | ) | |
| 42 | : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }), | |
| 43 | }), | |
| 44 | ), | |
| 45 | ); | |
| 46 | const verify = provider.withVerifiedCredential; | |
| 47 | if (verify === undefined) | |
| 48 | return yield* Effect.die("credential verification was not implemented"); | |
| 49 | const input = { cwd: "/w", host: "github.com" }; | |
| 50 | const operation = () => | |
| 51 | Effect.sync(() => { | |
| 52 | operations++; | |
| 53 | }).pipe(Effect.andThen(Effect.fail("operation-failed"))); | |
| 54 | expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({ | |
| 55 | _tag: "PullRequestProviderError", | |
| 56 | operation: "routeIdentity", | |
| 57 | }); | |
| 58 | expect(operations).toBe(0); | |
| 59 | verificationFails = false; | |
| 60 | expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed"); | |
| 61 | expect(operations).toBe(1); | |
| 62 | }), | |
| 63 | ); | |
| 64 | ||
| 65 | it.effect("refreshes checks without permissions or comparison reads", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | let reads = 0; | |
| 68 | const snapshot = Result.getOrThrow( | |
| 69 | decodePullRequestDetailJson(`{ | |
| 70 | "number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7", | |
| 71 | "headRefName": "feature", "baseRefName": "main", "state": "OPEN", | |
| 72 | "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z" | |
| 73 | }`), | |
| 74 | ); | |
| 75 | const provider = yield* make.pipe( | |
| 76 | Effect.provide( | |
| 77 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 78 | revalidateChecks: (_input, read) => read, | |
| 79 | getPullRequestDetail: () => | |
| 80 | Effect.sync(() => { | |
| 81 | reads++; | |
| 82 | return { | |
| 83 | ...snapshot, | |
| 84 | ...coreFields, | |
| 85 | state: reads === 3 ? ("merged" as const) : ("open" as const), | |
| 86 | checks: [ | |
| 87 | { | |
| 88 | name: "build", | |
| 89 | status: reads === 1 ? ("pending" as const) : ("success" as const), | |
| 90 | description: null, | |
| 91 | url: null, | |
| 92 | }, | |
| 93 | ], | |
| 94 | }; | |
| 95 | }), | |
| 96 | }), | |
| 97 | ), | |
| 98 | ); | |
| 99 | const read = provider.getChangeRequestChecks; | |
| 100 | if (read === undefined) return yield* Effect.die("checks read missing"); | |
| 101 | for (let tick = 1; tick <= 3; tick++) { | |
| 102 | const result = yield* read({ | |
| 103 | cwd: "/w", | |
| 104 | repository: "acme/web", | |
| 105 | host: "github.com", | |
| 106 | number: 7, | |
| 107 | }); | |
| 108 | expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success"); | |
| 109 | expect(result.state).toBe(tick === 3 ? "merged" : "open"); | |
| 110 | } | |
| 111 | expect(reads).toBe(3); | |
| 112 | }), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("uses one narrow read for a linked pull request summary", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | let summaryReads = 0; | |
| 118 | const provider = yield* make.pipe( | |
| 119 | Effect.provide( | |
| 120 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 121 | revalidateChecks: (_input, read) => read, | |
| 122 | getPullRequestSummary: () => | |
| 123 | Effect.sync(() => { | |
| 124 | summaryReads += 1; | |
| 125 | return { | |
| 126 | number: 7, | |
| 127 | title: "Summary", | |
| 128 | url: "https://github.com/acme/web/pull/7", | |
| 129 | headBranch: "feat/summary", | |
| 130 | baseBranch: "main", | |
| 131 | state: "open" as const, | |
| 132 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 133 | author: { login: "octocat", name: null, avatarUrl: null }, | |
| 134 | }; | |
| 135 | }), | |
| 136 | }), | |
| 137 | ), | |
| 138 | ); | |
| 139 | ||
| 140 | const readSummary = provider.getChangeRequestSummary; | |
| 141 | if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); | |
| 142 | const summary = yield* readSummary({ | |
| 143 | cwd: "/w", | |
| 144 | repository: "acme/web", | |
| 145 | host: "github.com", | |
| 146 | number: 7, | |
| 147 | }); | |
| 148 | ||
| 149 | expect(summary.state).toBe("open"); | |
| 150 | expect(summaryReads).toBe(1); | |
| 151 | // The author's avatar comes from the login-shaped URL, not a second request. | |
| 152 | expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); | |
| 153 | }), | |
| 154 | ); | |
| 155 | ||
| 156 | it.effect("declares host-native stacks and passes the one the CLI reads through", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const stack = { | |
| 159 | id: "42", | |
| 160 | number: 3, | |
| 161 | url: "https://github.com/acme/web/stacks/3", | |
| 162 | base: "main", | |
| 163 | layers: [ | |
| 164 | { number: 6, headBranch: "feat/one", state: "merged" as const }, | |
| 165 | { number: 7, headBranch: "feat/two", state: "open" as const }, | |
| 166 | ], | |
| 167 | }; | |
| 168 | const provider = yield* make.pipe( | |
| 169 | Effect.provide( | |
| 170 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 171 | revalidateChecks: (_input, read) => read, | |
| 172 | getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), | |
| 173 | }), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | expect(provider.capabilities.stacks).toBe(true); | |
| 178 | const readStack = provider.getChangeRequestStack; | |
| 179 | if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); | |
| 180 | const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; | |
| 181 | expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); | |
| 182 | expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); | |
| 183 | }), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("reports a failed stack read against its own operation", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const provider = yield* make.pipe( | |
| 189 | Effect.provide( | |
| 190 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 191 | revalidateChecks: (_input, read) => read, | |
| 192 | getPullRequestStack: () => | |
| 193 | Effect.fail( | |
| 194 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 195 | cwd: "/w", | |
| 196 | operation: "getPullRequestStack", | |
| 197 | cause: new Error("unreadable"), | |
| 198 | }), | |
| 199 | ), | |
| 200 | }), | |
| 201 | ), | |
| 202 | ); | |
| 203 | ||
| 204 | const readStack = provider.getChangeRequestStack; | |
| 205 | if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); | |
| 206 | const error = yield* Effect.flip( | |
| 207 | readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), | |
| 208 | ); | |
| 209 | ||
| 210 | expect(error.operation).toBe("getChangeRequestStack"); | |
| 211 | expect(error.reason).toBe("failed"); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | describe("gitHubViewerPermissions", () => { | |
| 216 | it("offers everything to a viewer who can write to the repository", () => { | |
| 217 | expect( | |
| 218 | gitHubViewerPermissions({ | |
| 219 | canWrite: true, | |
| 220 | canTriage: true, | |
| 221 | canUpdate: true, | |
| 222 | didAuthor: false, | |
| 223 | }), | |
| 224 | ).toEqual({ | |
| 225 | // Arming a merge for later is the merge, so it travels with it. | |
| 226 | actions: [ | |
| 227 | "merge", | |
| 228 | "enable-auto-merge", | |
| 229 | "disable-auto-merge", | |
| 230 | "revert", | |
| 231 | "approve-workflows", | |
| 232 | "ready", | |
| 233 | "draft", | |
| 234 | "close", | |
| 235 | "reopen", | |
| 236 | ], | |
| 237 | comment: true, | |
| 238 | resolve: true, | |
| 239 | stackRebase: true, | |
| 240 | verdicts: ["comment", "approve", "request-changes"], | |
| 241 | requestReviewers: true, | |
| 242 | labels: true, | |
| 243 | }); | |
| 244 | }); | |
| 245 | ||
| 246 | it("leaves a passer-by on a repository they can only read nothing but the review", () => { | |
| 247 | // Every open-source pull request somebody else opened: GitHub says no to all five actions | |
| 248 | // and to resolving, and yes to commenting and to every verdict. | |
| 249 | expect( | |
| 250 | gitHubViewerPermissions({ | |
| 251 | canWrite: false, | |
| 252 | canTriage: false, | |
| 253 | canUpdate: false, | |
| 254 | didAuthor: false, | |
| 255 | }), | |
| 256 | ).toEqual({ | |
| 257 | actions: [], | |
| 258 | comment: true, | |
| 259 | resolve: false, | |
| 260 | verdicts: ["comment", "approve", "request-changes"], | |
| 261 | // Asking somebody else to review is the one thing read access never stretches to. | |
| 262 | requestReviewers: false, | |
| 263 | labels: false, | |
| 264 | }); | |
| 265 | }); | |
| 266 | ||
| 267 | it("lets a triager label without letting them merge or ask for a review", () => { | |
| 268 | const permissions = gitHubViewerPermissions({ | |
| 269 | canWrite: false, | |
| 270 | canTriage: true, | |
| 271 | canUpdate: false, | |
| 272 | didAuthor: false, | |
| 273 | }); | |
| 274 | expect(permissions.labels).toBe(true); | |
| 275 | expect(permissions.requestReviewers).toBe(false); | |
| 276 | expect(permissions.actions).toEqual([]); | |
| 277 | }); | |
| 278 | ||
| 279 | it("keeps an author's own pull request theirs to close, with read access and no more", () => { | |
| 280 | expect( | |
| 281 | gitHubViewerPermissions({ | |
| 282 | canWrite: false, | |
| 283 | canTriage: false, | |
| 284 | canUpdate: true, | |
| 285 | didAuthor: true, | |
| 286 | }), | |
| 287 | ).toEqual({ | |
| 288 | // Merging is the one thing writing is needed for, now or later; the rest an author may do. | |
| 289 | actions: ["ready", "draft", "close", "reopen"], | |
| 290 | comment: true, | |
| 291 | resolve: true, | |
| 292 | // GitHub refuses an author's approval of their own change, so the page does not offer one. | |
| 293 | verdicts: ["comment"], | |
| 294 | requestReviewers: false, | |
| 295 | labels: false, | |
| 296 | }); | |
| 297 | }); | |
| 298 | ||
| 299 | it.effect("uses the permissions carried by the core read without a second request", () => | |
| 300 | Effect.gen(function* () { | |
| 301 | const provider = yield* make; | |
| 302 | const detail = yield* provider.getChangeRequest({ | |
| 303 | cwd: "/w", | |
| 304 | repository: "acme/web", | |
| 305 | host: "github.com", | |
| 306 | number: 7, | |
| 307 | }); | |
| 308 | ||
| 309 | expect(detail.viewerPermissions).toEqual({ | |
| 310 | actions: ["ready", "draft", "close", "reopen"], | |
| 311 | comment: true, | |
| 312 | resolve: false, | |
| 313 | verdicts: ["comment", "approve", "request-changes"], | |
| 314 | requestReviewers: false, | |
| 315 | labels: false, | |
| 316 | }); | |
| 317 | expect(detail.workflowApprovalsRequired).toBeUndefined(); | |
| 318 | expect(detail.checks).toContainEqual({ | |
| 319 | name: "Workflow approval status", | |
| 320 | status: "action-required", | |
| 321 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 322 | url: null, | |
| 323 | }); | |
| 324 | for (const fingerprint of ["broad", "restricted", "broad"]) { | |
| 325 | const scoped = yield* provider | |
| 326 | .getChangeRequest({ | |
| 327 | cwd: "/w", | |
| 328 | repository: "acme/web", | |
| 329 | host: "github.com", | |
| 330 | number: 7, | |
| 331 | }) | |
| 332 | .pipe( | |
| 333 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 334 | host: "github.com", | |
| 335 | token: Redacted.make("credential"), | |
| 336 | credentialFingerprint: fingerprint, | |
| 337 | }), | |
| 338 | ); | |
| 339 | expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted"); | |
| 340 | } | |
| 341 | }).pipe( | |
| 342 | Effect.provide( | |
| 343 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 344 | revalidateChecks: (_input, read) => read, | |
| 345 | getPullRequestDetail: () => | |
| 346 | Effect.succeed({ | |
| 347 | ...coreFields, | |
| 348 | comparison: null, | |
| 349 | viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, | |
| 350 | authorId: null, | |
| 351 | number: 7, | |
| 352 | title: "Pull request 7", | |
| 353 | url: "https://github.com/acme/web/pull/7", | |
| 354 | author: null, | |
| 355 | isCrossRepository: true, | |
| 356 | headRepositoryOwner: null, | |
| 357 | headBranch: "feat/page", | |
| 358 | baseBranch: "main", | |
| 359 | state: "open" as const, | |
| 360 | isDraft: false, | |
| 361 | mergeability: "mergeable" as const, | |
| 362 | reviewDecision: null, | |
| 363 | additions: 1, | |
| 364 | deletions: 1, | |
| 365 | createdAt: "2026-07-01T00:00:00Z", | |
| 366 | updatedAt: "2026-07-02T00:00:00Z", | |
| 367 | reviewRequestLogins: [], | |
| 368 | hasTeamReviewRequest: false, | |
| 369 | checksState: null, | |
| 370 | labels: [], | |
| 371 | body: "", | |
| 372 | changedFiles: 1, | |
| 373 | mergedAt: null, | |
| 374 | closedAt: null, | |
| 375 | checks: [], | |
| 376 | comments: [], | |
| 377 | commits: [], | |
| 378 | }).pipe( | |
| 379 | Effect.flatMap((detail) => | |
| 380 | GitHubApi.PinnedGitHubCredential.pipe( | |
| 381 | Effect.map((credential) => ({ | |
| 382 | ...detail, | |
| 383 | viewerAccess: { | |
| 384 | ...detail.viewerAccess, | |
| 385 | mergeCapabilities: { | |
| 386 | ...detail.viewerAccess.mergeCapabilities, | |
| 387 | squash: credential?.credentialFingerprint !== "restricted", | |
| 388 | }, | |
| 389 | }, | |
| 390 | })), | |
| 391 | ), | |
| 392 | ), | |
| 393 | ), | |
| 394 | }), | |
| 395 | ), | |
| 396 | ), | |
| 397 | ); | |
| 398 | ||
| 399 | it.effect("keeps fork workflows awaiting approval out of the passing state", () => | |
| 400 | Effect.gen(function* () { | |
| 401 | const provider = yield* make; | |
| 402 | const detail = yield* provider.getChangeRequest({ | |
| 403 | cwd: "/w", | |
| 404 | repository: "acme/web", | |
| 405 | host: "github.com", | |
| 406 | number: 7, | |
| 407 | }); | |
| 408 | ||
| 409 | const readChecks = provider.getChangeRequestChecks; | |
| 410 | if (readChecks === undefined) return yield* Effect.die("checks read missing"); | |
| 411 | expect( | |
| 412 | yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), | |
| 413 | ).toEqual({ state: detail.state, checks: detail.checks }); | |
| 414 | expect(detail.workflowApprovalsRequired).toBe(1); | |
| 415 | expect(detail.checks).toEqual([ | |
| 416 | { | |
| 417 | name: "manual gate", | |
| 418 | status: "action-required", | |
| 419 | description: null, | |
| 420 | url: "https://example.com/manual-gate", | |
| 421 | }, | |
| 422 | { | |
| 423 | name: "build", | |
| 424 | status: "success", | |
| 425 | description: null, | |
| 426 | url: null, | |
| 427 | }, | |
| 428 | { | |
| 429 | name: "contributor tests", | |
| 430 | status: "action-required", | |
| 431 | description: "A maintainer must approve this workflow before it can run.", | |
| 432 | url: "https://github.com/acme/web/actions/runs/123", | |
| 433 | }, | |
| 434 | ]); | |
| 435 | }).pipe( | |
| 436 | Effect.provide( | |
| 437 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 438 | revalidateChecks: (_input, read) => read, | |
| 439 | getPullRequestDetail: () => | |
| 440 | Effect.succeed({ | |
| 441 | ...coreFields, | |
| 442 | authorId: null, | |
| 443 | number: 7, | |
| 444 | title: "Pull request 7", | |
| 445 | url: "https://github.com/acme/web/pull/7", | |
| 446 | author: null, | |
| 447 | isCrossRepository: true, | |
| 448 | headRepositoryOwner: "octocat", | |
| 449 | headSha: "abc123", | |
| 450 | headBranch: "feat/page", | |
| 451 | baseBranch: "main", | |
| 452 | state: "open", | |
| 453 | isDraft: false, | |
| 454 | mergeability: "mergeable", | |
| 455 | reviewDecision: null, | |
| 456 | additions: 1, | |
| 457 | deletions: 1, | |
| 458 | createdAt: "2026-07-01T00:00:00Z", | |
| 459 | updatedAt: "2026-07-02T00:00:00Z", | |
| 460 | reviewRequestLogins: [], | |
| 461 | hasTeamReviewRequest: false, | |
| 462 | checksState: "passing", | |
| 463 | labels: [], | |
| 464 | body: "", | |
| 465 | changedFiles: 1, | |
| 466 | mergedAt: null, | |
| 467 | closedAt: null, | |
| 468 | checks: [ | |
| 469 | { | |
| 470 | name: "manual gate", | |
| 471 | status: "action-required", | |
| 472 | description: null, | |
| 473 | url: "https://example.com/manual-gate", | |
| 474 | }, | |
| 475 | { name: "build", status: "success", description: null, url: null }, | |
| 476 | ], | |
| 477 | comments: [], | |
| 478 | commits: [], | |
| 479 | }), | |
| 480 | listWorkflowRunsRequiringApproval: () => | |
| 481 | Effect.succeed([ | |
| 482 | { | |
| 483 | id: 123, | |
| 484 | name: "contributor tests", | |
| 485 | url: "https://github.com/acme/web/actions/runs/123", | |
| 486 | }, | |
| 487 | ]), | |
| 488 | getViewerAccess: () => | |
| 489 | Effect.succeed({ | |
| 490 | canWrite: true, | |
| 491 | canTriage: true, | |
| 492 | canUpdate: true, | |
| 493 | didAuthor: false, | |
| 494 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 495 | }), | |
| 496 | }), | |
| 497 | ), | |
| 498 | ), | |
| 499 | ); | |
| 500 | }); | |
| 501 | ||
| 502 | const openDetail = { | |
| 503 | ...coreFields, | |
| 504 | authorId: null, | |
| 505 | number: 7, | |
| 506 | title: "Pull request 7", | |
| 507 | url: "https://github.com/acme/web/pull/7", | |
| 508 | author: null, | |
| 509 | isCrossRepository: true, | |
| 510 | headRepositoryOwner: "acme", | |
| 511 | headSha: "abc123", | |
| 512 | headBranch: "feat/page", | |
| 513 | baseBranch: "main", | |
| 514 | state: "open" as const, | |
| 515 | isDraft: false, | |
| 516 | mergeability: "mergeable" as const, | |
| 517 | reviewDecision: null, | |
| 518 | additions: 1, | |
| 519 | deletions: 1, | |
| 520 | createdAt: "2026-07-01T00:00:00Z", | |
| 521 | updatedAt: "2026-07-02T00:00:00Z", | |
| 522 | reviewRequestLogins: [], | |
| 523 | hasTeamReviewRequest: false, | |
| 524 | checksState: null, | |
| 525 | labels: [], | |
| 526 | body: "", | |
| 527 | changedFiles: 1, | |
| 528 | mergedAt: null, | |
| 529 | closedAt: null, | |
| 530 | checks: [], | |
| 531 | comments: [], | |
| 532 | commits: [], | |
| 533 | }; | |
| 534 | ||
| 535 | it.effect( | |
| 536 | "uses the core comparison and permissions while preserving workflow approval checks", | |
| 537 | () => | |
| 538 | Effect.gen(function* () { | |
| 539 | const provider = yield* make.pipe( | |
| 540 | Effect.provide( | |
| 541 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 542 | getPullRequestDetail: () => | |
| 543 | Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), | |
| 544 | listWorkflowRunsRequiringApproval: () => | |
| 545 | Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), | |
| 546 | }), | |
| 547 | ), | |
| 548 | ); | |
| 549 | ||
| 550 | const detail = yield* provider.getChangeRequest({ | |
| 551 | cwd: "/w", | |
| 552 | repository: "acme/web", | |
| 553 | host: "github.com", | |
| 554 | number: 7, | |
| 555 | }); | |
| 556 | ||
| 557 | expect(detail.behindBy).toBe(2); | |
| 558 | expect(detail.workflowApprovalsRequired).toBe(1); | |
| 559 | expect(detail.checks).toContainEqual({ | |
| 560 | name: "tests", | |
| 561 | status: "action-required", | |
| 562 | description: "A maintainer must approve this workflow before it can run.", | |
| 563 | url: "https://example.com/runs/123", | |
| 564 | }); | |
| 565 | }), | |
| 566 | ); | |
| 567 | ||
| 568 | it.effect("does not classify same-repository gates as fork workflow approvals", () => | |
| 569 | Effect.gen(function* () { | |
| 570 | const provider = yield* make; | |
| 571 | const detail = yield* provider.getChangeRequest({ | |
| 572 | cwd: "/w", | |
| 573 | repository: "acme/web", | |
| 574 | host: "github.com", | |
| 575 | number: 7, | |
| 576 | }); | |
| 577 | ||
| 578 | expect(detail.workflowApprovalsRequired).toBe(0); | |
| 579 | expect(detail.checks).toEqual([]); | |
| 580 | }).pipe( | |
| 581 | Effect.provide( | |
| 582 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 583 | revalidateChecks: (_input, read) => read, | |
| 584 | getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), | |
| 585 | listWorkflowRunsRequiringApproval: () => | |
| 586 | Effect.die("same-repository pull requests must not probe fork workflow approvals"), | |
| 587 | getViewerAccess: () => | |
| 588 | Effect.succeed({ | |
| 589 | canWrite: true, | |
| 590 | canTriage: true, | |
| 591 | canUpdate: true, | |
| 592 | didAuthor: false, | |
| 593 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 594 | }), | |
| 595 | }), | |
| 596 | ), | |
| 597 | ), | |
| 598 | ); | |
| 599 | ||
| 600 | it.effect("keeps an unsafe workflow approval scope visible as unknown", () => | |
| 601 | Effect.gen(function* () { | |
| 602 | const provider = yield* make; | |
| 603 | const detail = yield* provider.getChangeRequest({ | |
| 604 | cwd: "/w", | |
| 605 | repository: "acme/web", | |
| 606 | host: "github.com", | |
| 607 | number: 7, | |
| 608 | }); | |
| 609 | ||
| 610 | expect(detail.workflowApprovalsRequired).toBeUndefined(); | |
| 611 | expect(detail.checks).toEqual([ | |
| 612 | { | |
| 613 | name: "Workflow approval status", | |
| 614 | status: "action-required", | |
| 615 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 616 | url: null, | |
| 617 | }, | |
| 618 | ]); | |
| 619 | }).pipe( | |
| 620 | Effect.provide( | |
| 621 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 622 | revalidateChecks: (_input, read) => read, | |
| 623 | getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 624 | listWorkflowRunsRequiringApproval: () => | |
| 625 | Effect.fail( | |
| 626 | new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ | |
| 627 | cwd: "/w", | |
| 628 | number: 7, | |
| 629 | reason: "head-not-unique", | |
| 630 | observedCount: 2, | |
| 631 | limit: 1_000, | |
| 632 | }), | |
| 633 | ), | |
| 634 | getViewerAccess: () => | |
| 635 | Effect.succeed({ | |
| 636 | canWrite: true, | |
| 637 | canTriage: true, | |
| 638 | canUpdate: true, | |
| 639 | didAuthor: false, | |
| 640 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 641 | }), | |
| 642 | }), | |
| 643 | ), | |
| 644 | ), | |
| 645 | ); | |
| 646 | ||
| 647 | it.effect("propagates workflow discovery rate limits", () => | |
| 648 | Effect.gen(function* () { | |
| 649 | const provider = yield* make; | |
| 650 | const error = yield* provider | |
| 651 | .getChangeRequest({ | |
| 652 | cwd: "/w", | |
| 653 | repository: "acme/web", | |
| 654 | host: "github.com", | |
| 655 | number: 7, | |
| 656 | }) | |
| 657 | .pipe(Effect.flip); | |
| 658 | ||
| 659 | expect(error.operation).toBe("getChangeRequest"); | |
| 660 | expect(error.reason).toBe("rate-limited"); | |
| 661 | }).pipe( | |
| 662 | Effect.provide( | |
| 663 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 664 | revalidateChecks: (_input, read) => read, | |
| 665 | getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 666 | listWorkflowRunsRequiringApproval: () => | |
| 667 | Effect.fail( | |
| 668 | new GitHubApi.GitHubApiRateLimitError({ | |
| 669 | host: "github.com", | |
| 670 | operation: "listWorkflowRunsRequiringApproval", | |
| 671 | }), | |
| 672 | ), | |
| 673 | getViewerAccess: () => | |
| 674 | Effect.succeed({ | |
| 675 | canWrite: true, | |
| 676 | canTriage: true, | |
| 677 | canUpdate: true, | |
| 678 | didAuthor: false, | |
| 679 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 680 | }), | |
| 681 | }), | |
| 682 | ), | |
| 683 | ), | |
| 684 | ); | |
| 685 | ||
| 686 | describe("getViewerPermissions", () => { | |
| 687 | const access = { | |
| 688 | canWrite: true, | |
| 689 | canTriage: true, | |
| 690 | canUpdate: true, | |
| 691 | didAuthor: false, | |
| 692 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 693 | }; | |
| 694 | ||
| 695 | it.effect("checks fresh access without reading branch details for unrelated operations", () => { | |
| 696 | let accessReads = 0; | |
| 697 | return Effect.gen(function* () { | |
| 698 | const provider = yield* make; | |
| 699 | const permissions = yield* provider.getViewerPermissions({ | |
| 700 | cwd: "/w", | |
| 701 | repository: "acme/web", | |
| 702 | host: "github.com", | |
| 703 | number: 7, | |
| 704 | includeUpdateBranch: false, | |
| 705 | }); | |
| 706 | ||
| 707 | expect(accessReads).toBe(1); | |
| 708 | expect(permissions.actions).toContain("merge"); | |
| 709 | expect(permissions.actions).not.toContain("update-branch"); | |
| 710 | }).pipe( | |
| 711 | Effect.provide( | |
| 712 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 713 | revalidateChecks: (_input, read) => read, | |
| 714 | getPullRequestDetail: () => Effect.die("Unexpected detail read"), | |
| 715 | getViewerAccess: () => Effect.sync(() => (accessReads++, access)), | |
| 716 | }), | |
| 717 | ), | |
| 718 | ); | |
| 719 | }); | |
| 720 | ||
| 721 | const layerWithDetail = ( | |
| 722 | detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApi.GitHubPullRequestApiError>, | |
| 723 | onDetail: (allowReserve: boolean) => void = () => {}, | |
| 724 | ) => | |
| 725 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 726 | revalidateChecks: (_input, read) => read, | |
| 727 | getPullRequestDetail: () => | |
| 728 | GitHubApi.AllowGitHubReserve.pipe( | |
| 729 | Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), | |
| 730 | Effect.flatMap(() => detail), | |
| 731 | ), | |
| 732 | getViewerAccess: () => Effect.die("Unexpected access read"), | |
| 733 | }); | |
| 734 | ||
| 735 | it.effect("answers access and update-branch from the one detail read", () => | |
| 736 | Effect.gen(function* () { | |
| 737 | const provider = yield* make; | |
| 738 | const permissions = yield* provider.getViewerPermissions({ | |
| 739 | cwd: "/w", | |
| 740 | repository: "acme/web", | |
| 741 | host: "github.com", | |
| 742 | number: 7, | |
| 743 | }); | |
| 744 | ||
| 745 | expect(permissions.actions).toContain("merge"); | |
| 746 | expect(permissions.actions).toContain("update-branch"); | |
| 747 | expect(permissions.updateMethods).toEqual(["merge", "rebase"]); | |
| 748 | }).pipe( | |
| 749 | Effect.provide( | |
| 750 | layerWithDetail( | |
| 751 | Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }), | |
| 752 | ), | |
| 753 | ), | |
| 754 | ), | |
| 755 | ); | |
| 756 | ||
| 757 | it.effect("uses the GraphQL reserve for manual permission checks", () => { | |
| 758 | let allowed: boolean | undefined; | |
| 759 | return Effect.gen(function* () { | |
| 760 | const provider = yield* make; | |
| 761 | yield* provider.getViewerPermissions({ | |
| 762 | cwd: "/w", | |
| 763 | repository: "acme/web", | |
| 764 | host: "github.com", | |
| 765 | number: 7, | |
| 766 | }); | |
| 767 | expect(allowed).toBe(true); | |
| 768 | }).pipe( | |
| 769 | Effect.provide( | |
| 770 | layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)), | |
| 771 | ), | |
| 772 | ); | |
| 773 | }); | |
| 774 | ||
| 775 | it.effect("still answers from the light access read when the detail read fails", () => | |
| 776 | Effect.gen(function* () { | |
| 777 | const provider = yield* make; | |
| 778 | const permissions = yield* provider.getViewerPermissions({ | |
| 779 | cwd: "/w", | |
| 780 | repository: "acme/web", | |
| 781 | host: "github.com", | |
| 782 | number: 7, | |
| 783 | }); | |
| 784 | expect(permissions.actions).toContain("merge"); | |
| 785 | expect(permissions.actions).not.toContain("update-branch"); | |
| 786 | }).pipe( | |
| 787 | Effect.provide( | |
| 788 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 789 | revalidateChecks: (_input, read) => read, | |
| 790 | getPullRequestDetail: () => | |
| 791 | Effect.fail( | |
| 792 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 793 | cwd: "/w", | |
| 794 | operation: "getPullRequestDetail", | |
| 795 | cause: new Error("head changed"), | |
| 796 | }), | |
| 797 | ), | |
| 798 | getViewerAccess: () => Effect.succeed(access), | |
| 799 | }), | |
| 800 | ), | |
| 801 | ), | |
| 802 | ); | |
| 803 | ||
| 804 | it.effect("withholds update-branch where GitHub could not compare the branch", () => | |
| 805 | Effect.gen(function* () { | |
| 806 | const provider = yield* make; | |
| 807 | const permissions = yield* provider.getViewerPermissions({ | |
| 808 | cwd: "/w", | |
| 809 | repository: "acme/web", | |
| 810 | host: "github.com", | |
| 811 | number: 7, | |
| 812 | }); | |
| 813 | ||
| 814 | expect(permissions.actions).not.toContain("update-branch"); | |
| 815 | expect(permissions.updateMethods).toBeUndefined(); | |
| 816 | expect(permissions.actions).toContain("merge"); | |
| 817 | }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))), | |
| 818 | ); | |
| 819 | }); | |
| 820 | ||
| 821 | describe("getChangeRequest commits", () => { | |
| 822 | const baseDetail = { | |
| 823 | authorId: null, | |
| 824 | number: 7, | |
| 825 | title: "Pull request 7", | |
| 826 | url: "https://github.com/acme/web/pull/7", | |
| 827 | author: null, | |
| 828 | headBranch: "feat/page", | |
| 829 | baseBranch: "main", | |
| 830 | state: "open" as const, | |
| 831 | isDraft: false, | |
| 832 | mergeability: "mergeable" as const, | |
| 833 | additions: 1, | |
| 834 | deletions: 1, | |
| 835 | createdAt: "2026-07-01T00:00:00Z", | |
| 836 | updatedAt: "2026-07-02T00:00:00Z", | |
| 837 | reviewRequestLogins: [], | |
| 838 | hasTeamReviewRequest: false, | |
| 839 | checksState: null, | |
| 840 | labels: [], | |
| 841 | body: "", | |
| 842 | changedFiles: 1, | |
| 843 | mergedAt: null, | |
| 844 | closedAt: null, | |
| 845 | checks: [], | |
| 846 | comments: [], | |
| 847 | }; | |
| 848 | ||
| 849 | const baseThreadComments = { | |
| 850 | comments: [], | |
| 851 | dismissalsByReviewId: new Map<string, string>(), | |
| 852 | reviewThreads: [], | |
| 853 | commentCount: 0, | |
| 854 | truncated: false, | |
| 855 | reviewThreadsTruncated: false, | |
| 856 | reactions: [], | |
| 857 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), | |
| 858 | editedAtById: new Map<string, string>(), | |
| 859 | reviewers: [], | |
| 860 | avatarsByLogin: new Map<string, string>(), | |
| 861 | botLogins: new Set<string>(), | |
| 862 | commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(), | |
| 863 | viewer: { canUpdate: true, didAuthor: false }, | |
| 864 | }; | |
| 865 | ||
| 866 | const layerWith = (commits: GitHubReviewThreadComments["commits"]) => | |
| 867 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 868 | revalidateChecks: (_input, read) => read, | |
| 869 | getPullRequestActivity: () => | |
| 870 | Effect.succeed({ | |
| 871 | author: baseDetail.author, | |
| 872 | comments: baseDetail.comments, | |
| 873 | commits: [ | |
| 874 | { | |
| 875 | oid: "view-oldest", | |
| 876 | messageHeadline: "gh pr view's oldest commit", | |
| 877 | committedDate: "2026-01-01T00:00:00Z", | |
| 878 | authors: [], | |
| 879 | }, | |
| 880 | ], | |
| 881 | }), | |
| 882 | listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }), | |
| 883 | }); | |
| 884 | ||
| 885 | it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () => | |
| 886 | Effect.gen(function* () { | |
| 887 | const provider = yield* make; | |
| 888 | const detail = yield* provider.getChangeRequestActivity({ | |
| 889 | cwd: "/w", | |
| 890 | repository: "acme/web", | |
| 891 | host: "github.com", | |
| 892 | number: 7, | |
| 893 | }); | |
| 894 | ||
| 895 | expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]); | |
| 896 | }).pipe( | |
| 897 | Effect.provide( | |
| 898 | layerWith([ | |
| 899 | { | |
| 900 | oid: "graphql-newest", | |
| 901 | messageHeadline: "the newest commit gh pr view drops", | |
| 902 | committedDate: "2026-07-06T00:00:00Z", | |
| 903 | authors: [], | |
| 904 | }, | |
| 905 | ]), | |
| 906 | ), | |
| 907 | ), | |
| 908 | ); | |
| 909 | ||
| 910 | it.effect("falls back to the gh view list when the GraphQL read has no commits", () => | |
| 911 | Effect.gen(function* () { | |
| 912 | const provider = yield* make; | |
| 913 | const detail = yield* provider.getChangeRequestActivity({ | |
| 914 | cwd: "/w", | |
| 915 | repository: "acme/web", | |
| 916 | host: "github.com", | |
| 917 | number: 7, | |
| 918 | }); | |
| 919 | ||
| 920 | expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]); | |
| 921 | }).pipe(Effect.provide(layerWith([]))), | |
| 922 | ); | |
| 923 | }); | |
| 924 | ||
| 925 | describe("getChangeRequestActivity dismissed reviews", () => { | |
| 926 | const dismissedReview = (body: string) => ({ | |
| 927 | id: "PRR_1", | |
| 928 | kind: "review" as const, | |
| 929 | author: { login: "macroscopeapp", name: null, avatarUrl: null }, | |
| 930 | body, | |
| 931 | createdAt: "2026-07-03T00:00:00Z", | |
| 932 | url: null, | |
| 933 | path: null, | |
| 934 | reviewState: "DISMISSED", | |
| 935 | }); | |
| 936 | const threadComments: GitHubReviewThreadComments = { | |
| 937 | comments: [], | |
| 938 | dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), | |
| 939 | reviewThreads: [], | |
| 940 | commentCount: 0, | |
| 941 | truncated: false, | |
| 942 | reviewThreadsTruncated: false, | |
| 943 | reactions: [], | |
| 944 | reactionsById: new Map(), | |
| 945 | editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]), | |
| 946 | reviewers: [], | |
| 947 | avatarsByLogin: new Map(), | |
| 948 | botLogins: new Set(["macroscopeapp"]), | |
| 949 | commitStats: new Map(), | |
| 950 | commits: [], | |
| 951 | viewer: { canUpdate: true, didAuthor: false }, | |
| 952 | }; | |
| 953 | const layerFor = (body: string) => | |
| 954 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 955 | revalidateChecks: (_input, read) => read, | |
| 956 | getPullRequestActivity: () => | |
| 957 | Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), | |
| 958 | listReviewThreadComments: () => Effect.succeed(threadComments), | |
| 959 | }); | |
| 960 | const readActivity = Effect.gen(function* () { | |
| 961 | const provider = yield* make; | |
| 962 | return yield* provider.getChangeRequestActivity({ | |
| 963 | cwd: "/w", | |
| 964 | repository: "acme/web", | |
| 965 | host: "github.com", | |
| 966 | number: 7, | |
| 967 | }); | |
| 968 | }); | |
| 969 | ||
| 970 | it.effect("fills a marker-only dismissed review with the timeline's reason", () => | |
| 971 | // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — | |
| 972 | // an empty-string check misses them and the card opens onto nothing. | |
| 973 | readActivity.pipe( | |
| 974 | Effect.map((activity) => { | |
| 975 | expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); | |
| 976 | expect(activity.comments[0]?.author?.isBot).toBe(true); | |
| 977 | expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z"); | |
| 978 | }), | |
| 979 | Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")), | |
| 980 | ), | |
| 981 | ); | |
| 982 | ||
| 983 | it.effect("keeps the words of a dismissed review that has its own", () => | |
| 984 | readActivity.pipe( | |
| 985 | Effect.map((activity) => { | |
| 986 | expect(activity.comments[0]?.body).toBe("These findings still stand."); | |
| 987 | }), | |
| 988 | Effect.provide(layerFor("These findings still stand.")), | |
| 989 | ), | |
| 990 | ); | |
| 991 | }); | |
| 992 | ||
| 993 | describe("editing", () => { | |
| 994 | const rewrites: Array<unknown> = []; | |
| 995 | ||
| 996 | it.effect("hands a rewrite to the CLI as the request named it", () => | |
| 997 | Effect.gen(function* () { | |
| 998 | const provider = yield* make; | |
| 999 | ||
| 1000 | expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); | |
| 1001 | yield* provider.updateChangeRequest!({ | |
| 1002 | cwd: "/w", | |
| 1003 | repository: "acme/web", | |
| 1004 | host: "github.com", | |
| 1005 | number: 7, | |
| 1006 | title: "A better title", | |
| 1007 | }); | |
| 1008 | yield* provider.updateComment!({ | |
| 1009 | cwd: "/w", | |
| 1010 | repository: "acme/web", | |
| 1011 | host: "github.com", | |
| 1012 | number: 7, | |
| 1013 | commentId: "IC_1", | |
| 1014 | kind: "review-comment", | |
| 1015 | body: "Reworded.", | |
| 1016 | }); | |
| 1017 | ||
| 1018 | expect(rewrites).toEqual([ | |
| 1019 | { | |
| 1020 | cwd: "/w", | |
| 1021 | repository: "acme/web", | |
| 1022 | host: "github.com", | |
| 1023 | number: 7, | |
| 1024 | title: "A better title", | |
| 1025 | }, | |
| 1026 | { | |
| 1027 | cwd: "/w", | |
| 1028 | repository: "acme/web", | |
| 1029 | host: "github.com", | |
| 1030 | number: 7, | |
| 1031 | commentId: "IC_1", | |
| 1032 | kind: "review-comment", | |
| 1033 | body: "Reworded.", | |
| 1034 | }, | |
| 1035 | ]); | |
| 1036 | }).pipe( | |
| 1037 | Effect.provide( | |
| 1038 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 1039 | revalidateChecks: (_input, read) => read, | |
| 1040 | updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), | |
| 1041 | updateComment: (input) => Effect.sync(() => void rewrites.push(input)), | |
| 1042 | }), | |
| 1043 | ), | |
| 1044 | ), | |
| 1045 | ); | |
| 1046 | }); | |
| 1047 | ||
| 1048 | describe("loginAvatarUrl", () => { | |
| 1049 | it("serves a user's picture from the host they belong to", () => { | |
| 1050 | expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); | |
| 1051 | expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( | |
| 1052 | "https://ghe.example.com/octocat.png?size=80", | |
| 1053 | ); | |
| 1054 | }); | |
| 1055 | ||
| 1056 | it("has nothing for an app, which names no page", () => { | |
| 1057 | // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is | |
| 1058 | // worse than the initials it would replace. | |
| 1059 | expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); | |
| 1060 | }); | |
| 1061 | ||
| 1062 | it("refuses anything that is not a login, rather than building a URL out of it", () => { | |
| 1063 | for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { | |
| 1064 | expect(loginAvatarUrl(login, "github.com")).toBeNull(); | |
| 1065 | } | |
| 1066 | }); | |
| 1067 | }); |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts deleted-672| ... | ... | @@ -1,672 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type { | |
| 3 | PullRequestActor, | |
| 4 | PullRequestCapabilities, | |
| 5 | PullRequestCheck, | |
| 6 | PullRequestReaction, | |
| 7 | PullRequestViewerPermissions, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 12 | import { | |
| 13 | PullRequestProviderError, | |
| 14 | type PullRequestProviderFailure, | |
| 15 | type ProviderChangeRequestActivity, | |
| 16 | type ProviderChangeRequestDetail, | |
| 17 | type PullRequestProviderApi, | |
| 18 | type ProviderRepositoryRef, | |
| 19 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 20 | import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; | |
| 21 | ||
| 22 | const CAPABILITIES: PullRequestCapabilities = { | |
| 23 | diff: true, | |
| 24 | comment: true, | |
| 25 | actions: [ | |
| 26 | "merge", | |
| 27 | "ready", | |
| 28 | "draft", | |
| 29 | "close", | |
| 30 | "reopen", | |
| 31 | "update-branch", | |
| 32 | "enable-auto-merge", | |
| 33 | "disable-auto-merge", | |
| 34 | "revert", | |
| 35 | "approve-workflows", | |
| 36 | ], | |
| 37 | mergeMethods: ["merge", "squash", "rebase"], | |
| 38 | updateMethods: ["merge", "rebase"], | |
| 39 | search: true, | |
| 40 | reactions: true, | |
| 41 | viewedFiles: "host", | |
| 42 | review: { | |
| 43 | inlineComment: true, | |
| 44 | reply: true, | |
| 45 | resolve: true, | |
| 46 | verdicts: ["comment", "approve", "request-changes"], | |
| 47 | }, | |
| 48 | reviewers: { request: true, listCandidates: true }, | |
| 49 | edit: { changeRequest: true, comment: true }, | |
| 50 | stacks: true, | |
| 51 | stackActions: true, | |
| 52 | labels: true, | |
| 53 | }; | |
| 54 | ||
| 55 | /** | |
| 56 | * What the signed-in account may do here, from the three things GitHub says about it. | |
| 57 | * | |
| 58 | * Merging needs a role that can push, which is the one thing a stranger on an open-source | |
| 59 | * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a | |
| 60 | * pull request may close it, reopen it and move it in and out of draft with no more than read | |
| 61 | * access on the repository it was opened against. | |
| 62 | * | |
| 63 | * Commenting and reviewing are not gated at all: read access is enough to say something and | |
| 64 | * enough to approve or ask for changes, which is what open-source review consists of. Resolving a | |
| 65 | * conversation is the exception — GitHub allows it to whoever can write, and to the author of the | |
| 66 | * pull request the conversation is on. | |
| 67 | * | |
| 68 | * Asking somebody else for a review needs write access, which is the one thing here an author | |
| 69 | * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control | |
| 70 | * and refuses the request behind it. | |
| 71 | */ | |
| 72 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { | |
| 73 | return { | |
| 74 | ...(access.canWrite ? { stackRebase: true } : {}), | |
| 75 | actions: [ | |
| 76 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not | |
| 77 | // merge here may not leave an instruction to merge later either. | |
| 78 | ...(access.canWrite | |
| 79 | ? ([ | |
| 80 | "merge", | |
| 81 | "enable-auto-merge", | |
| 82 | "disable-auto-merge", | |
| 83 | "revert", | |
| 84 | "approve-workflows", | |
| 85 | ] as const) | |
| 86 | : []), | |
| 87 | ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), | |
| 88 | // Whether this viewer may update the branch is GitHub's own answer, read with the | |
| 89 | // comparison; without it the action is offered to nobody rather than to everybody. | |
| 90 | ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), | |
| 91 | ], | |
| 92 | comment: true, | |
| 93 | resolve: access.canWrite || access.didAuthor, | |
| 94 | // Anyone may review a pull request they can see, except their own: GitHub refuses an author's | |
| 95 | // approval and their request for changes ("Can not approve your own pull request"), and | |
| 96 | // leaves them commenting, which is what an author has to say about their own change anyway. | |
| 97 | verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, | |
| 98 | requestReviewers: access.canWrite, | |
| 99 | ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), | |
| 100 | // Triage is the one role that labels without writing, which is what triage is for. | |
| 101 | labels: access.canTriage, | |
| 102 | }; | |
| 103 | } | |
| 104 | ||
| 105 | /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ | |
| 106 | export function gitHubProviderFailure( | |
| 107 | error: GitHubPullRequestApi.GitHubPullRequestApiError, | |
| 108 | ): PullRequestProviderFailure { | |
| 109 | switch (error._tag) { | |
| 110 | case "GitHubCliMissingError": | |
| 111 | return { reason: "missing-tool" }; | |
| 112 | case "GitHubNotSignedInError": | |
| 113 | case "GitHubHostDisabledError": | |
| 114 | case "GitHubApiAuthenticationError": | |
| 115 | return { reason: "unauthenticated" }; | |
| 116 | case "GitHubApiRateLimitError": | |
| 117 | return { | |
| 118 | reason: "rate-limited", | |
| 119 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 120 | }; | |
| 121 | case "SourceControlRateLimitPausedError": | |
| 122 | return { reason: "rate-limited", retryAt: error.retryAt }; | |
| 123 | case "GitHubApiNotFoundError": | |
| 124 | return { reason: "not-found" }; | |
| 125 | default: | |
| 126 | return { reason: "failed" }; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are | |
| 132 | * applied here by login. An actor already carrying one keeps it. | |
| 133 | * | |
| 134 | * A login GitHub did not answer for falls back to the picture every GitHub install serves at | |
| 135 | * `/<login>.png`. The lookup is one more request per repository and can be refused — a rate | |
| 136 | * limit, a slow host — and a face that comes and goes between two loads of the same page reads | |
| 137 | * as a bug in the page rather than as a request that failed quietly. | |
| 138 | */ | |
| 139 | function withAvatar( | |
| 140 | actor: PullRequestActor | null, | |
| 141 | avatarsByLogin: ReadonlyMap<string, string>, | |
| 142 | host: string, | |
| 143 | botLogins?: ReadonlySet<string>, | |
| 144 | ): PullRequestActor | null { | |
| 145 | if (actor === null) return actor; | |
| 146 | if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; | |
| 147 | if (actor.avatarUrl !== null) return actor; | |
| 148 | const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); | |
| 149 | return avatarUrl === null ? actor : { ...actor, avatarUrl }; | |
| 150 | } | |
| 151 | ||
| 152 | function withWorkflowApprovals( | |
| 153 | checks: ReadonlyArray<PullRequestCheck>, | |
| 154 | runs: ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 155 | unavailable: boolean, | |
| 156 | ): ReadonlyArray<PullRequestCheck> { | |
| 157 | const representedRunIds = new Set<number>(); | |
| 158 | for (const check of checks) { | |
| 159 | if (check.status !== "action-required" || check.url === null) continue; | |
| 160 | const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; | |
| 161 | if (id !== undefined) representedRunIds.add(Number(id)); | |
| 162 | } | |
| 163 | const approvalChecks = runs | |
| 164 | .filter((run) => !representedRunIds.has(run.id)) | |
| 165 | .map((run): PullRequestCheck => ({ | |
| 166 | name: run.name, | |
| 167 | status: "action-required", | |
| 168 | description: "A maintainer must approve this workflow before it can run.", | |
| 169 | url: run.url, | |
| 170 | })); | |
| 171 | return [ | |
| 172 | ...checks, | |
| 173 | ...approvalChecks, | |
| 174 | ...(unavailable | |
| 175 | ? [ | |
| 176 | { | |
| 177 | name: "Workflow approval status", | |
| 178 | status: "action-required" as const, | |
| 179 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 180 | url: null, | |
| 181 | }, | |
| 182 | ] | |
| 183 | : []), | |
| 184 | ]; | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which | |
| 189 | * names no page, and a guessed URL that 404s is worse than the initials it would replace. | |
| 190 | */ | |
| 191 | export function loginAvatarUrl(login: string, host: string): string | null { | |
| 192 | return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; | |
| 193 | } | |
| 194 | ||
| 195 | /** True where markdown would render nothing: whitespace, or only HTML comments. */ | |
| 196 | const rendersEmpty = (body: string): boolean => | |
| 197 | body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0; | |
| 198 | ||
| 199 | export const make = Effect.gen(function* () { | |
| 200 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 201 | ||
| 202 | const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => | |
| 203 | new PullRequestProviderError({ | |
| 204 | provider: "github", | |
| 205 | operation, | |
| 206 | ...gitHubProviderFailure(error), | |
| 207 | detail: error.message, | |
| 208 | cause: error, | |
| 209 | }); | |
| 210 | ||
| 211 | const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) => | |
| 212 | cli.getPullRequestDetail(input).pipe( | |
| 213 | Effect.flatMap((pullRequest) => | |
| 214 | (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true | |
| 215 | ? Effect.succeed({ | |
| 216 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 217 | unavailable: false, | |
| 218 | }) | |
| 219 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null | |
| 220 | ? Effect.succeed({ | |
| 221 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 222 | unavailable: true, | |
| 223 | }) | |
| 224 | : cli | |
| 225 | .listWorkflowRunsRequiringApproval({ | |
| 226 | ...input, | |
| 227 | headSha: pullRequest.headSha, | |
| 228 | headBranch: pullRequest.headBranch, | |
| 229 | headRepositoryOwner: pullRequest.headRepositoryOwner, | |
| 230 | isCrossRepository: true, | |
| 231 | }) | |
| 232 | .pipe( | |
| 233 | Effect.matchEffect({ | |
| 234 | onFailure: (error) => | |
| 235 | error._tag === "GitHubApiRateLimitError" || | |
| 236 | error._tag === "SourceControlRateLimitPausedError" | |
| 237 | ? Effect.fail(error) | |
| 238 | : Effect.succeed({ | |
| 239 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 240 | unavailable: true, | |
| 241 | }), | |
| 242 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), | |
| 243 | }), | |
| 244 | ) | |
| 245 | ).pipe( | |
| 246 | Effect.map((workflowApprovals) => ({ | |
| 247 | ...pullRequest, | |
| 248 | author: withAvatar(pullRequest.author, new Map(), input.host), | |
| 249 | checks: withWorkflowApprovals( | |
| 250 | pullRequest.checks, | |
| 251 | workflowApprovals.runs, | |
| 252 | workflowApprovals.unavailable, | |
| 253 | ), | |
| 254 | ...(workflowApprovals.unavailable | |
| 255 | ? {} | |
| 256 | : { workflowApprovalsRequired: workflowApprovals.runs.length }), | |
| 257 | })), | |
| 258 | ), | |
| 259 | ), | |
| 260 | ); | |
| 261 | ||
| 262 | const provider: PullRequestProviderApi = { | |
| 263 | kind: "github", | |
| 264 | capabilities: CAPABILITIES, | |
| 265 | getRoutingIdentity: (input) => | |
| 266 | cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), | |
| 267 | withVerifiedCredential: (input, use) => | |
| 268 | cli | |
| 269 | .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) | |
| 270 | .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), | |
| 271 | ||
| 272 | getViewer: (input) => | |
| 273 | cli | |
| 274 | .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) | |
| 275 | .pipe(Effect.mapError(fail("getViewer"))), | |
| 276 | ||
| 277 | listChangeRequests: (input) => | |
| 278 | cli | |
| 279 | .listPullRequests({ | |
| 280 | cwd: input.cwd, | |
| 281 | repository: input.repository, | |
| 282 | host: input.host, | |
| 283 | state: input.state, | |
| 284 | involvement: input.involvement, | |
| 285 | viewer: input.viewer, | |
| 286 | limit: input.limit, | |
| 287 | query: input.query, | |
| 288 | cursor: input.cursor, | |
| 289 | filters: input.filters, | |
| 290 | }) | |
| 291 | .pipe( | |
| 292 | Effect.mapError(fail("listChangeRequests")), | |
| 293 | Effect.flatMap((page) => | |
| 294 | cli | |
| 295 | .listActorAvatars({ | |
| 296 | cwd: input.cwd, | |
| 297 | repository: input.repository, | |
| 298 | host: input.host, | |
| 299 | ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], | |
| 300 | }) | |
| 301 | // A listing without faces is still a listing, so a failed lookup falls back to | |
| 302 | // the initials rather than taking the rows down with it. | |
| 303 | .pipe( | |
| 304 | Effect.orElseSucceed(() => new Map<string, string>()), | |
| 305 | Effect.map((avatarsByLogin) => ({ | |
| 306 | ...page, | |
| 307 | items: page.items.map((item) => ({ | |
| 308 | ...item, | |
| 309 | author: withAvatar(item.author, avatarsByLogin, input.host), | |
| 310 | })), | |
| 311 | })), | |
| 312 | ), | |
| 313 | ), | |
| 314 | ), | |
| 315 | ||
| 316 | /** | |
| 317 | * The same listing for a whole host in one search. The avatar lookup the per-repository read | |
| 318 | * needs is not here: a search reports an author's picture itself, so a face costs no request | |
| 319 | * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. | |
| 320 | */ | |
| 321 | listChangeRequestsAcross: (input) => | |
| 322 | cli | |
| 323 | .searchPullRequests({ | |
| 324 | cwd: input.cwd, | |
| 325 | host: input.host, | |
| 326 | repositories: input.repositories, | |
| 327 | state: input.state, | |
| 328 | involvement: input.involvement, | |
| 329 | viewer: input.viewer, | |
| 330 | limit: input.limit, | |
| 331 | query: input.query, | |
| 332 | cursor: input.cursor, | |
| 333 | filters: input.filters, | |
| 334 | }) | |
| 335 | .pipe( | |
| 336 | Effect.mapError(fail("listChangeRequestsAcross")), | |
| 337 | Effect.map((batch) => ({ | |
| 338 | truncated: batch.truncated, | |
| 339 | items: batch.items.map((item) => ({ | |
| 340 | ...item, | |
| 341 | author: withAvatar(item.author, new Map<string, string>(), input.host), | |
| 342 | })), | |
| 343 | })), | |
| 344 | ), | |
| 345 | ||
| 346 | listChangeRequestStats: (input) => | |
| 347 | cli | |
| 348 | .listPullRequestStats({ | |
| 349 | cwd: input.cwd, | |
| 350 | host: input.host, | |
| 351 | changeRequests: input.changeRequests, | |
| 352 | }) | |
| 353 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), | |
| 354 | ||
| 355 | getChangeRequestSummary: (input) => | |
| 356 | cli.getPullRequestSummary(input).pipe( | |
| 357 | // `gh pr view` names the author without an avatar; the login-shaped URL every user | |
| 358 | // has stands in, without the second request the listing spends on it. | |
| 359 | Effect.map((summary) => ({ | |
| 360 | ...summary, | |
| 361 | ...(summary.author === undefined | |
| 362 | ? {} | |
| 363 | : { author: withAvatar(summary.author, new Map(), input.host) }), | |
| 364 | })), | |
| 365 | Effect.mapError(fail("getChangeRequestSummary")), | |
| 366 | ), | |
| 367 | ||
| 368 | getChangeRequestStack: (input) => | |
| 369 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), | |
| 370 | ||
| 371 | getChangeRequestWatchFingerprint: (input) => | |
| 372 | cli | |
| 373 | .getPullRequestWatchFingerprint(input) | |
| 374 | .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), | |
| 375 | ||
| 376 | getChangeRequestPreview: (input) => | |
| 377 | cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), | |
| 378 | ||
| 379 | getChangeRequestChecks: (input) => | |
| 380 | cli.revalidateChecks(input, readChecks(input)).pipe( | |
| 381 | Effect.map(({ state, checks }) => ({ state, checks })), | |
| 382 | Effect.mapError(fail("getChangeRequestChecks")), | |
| 383 | ), | |
| 384 | ||
| 385 | getChangeRequest: (input) => | |
| 386 | readChecks(input).pipe( | |
| 387 | Effect.map((pullRequest): ProviderChangeRequestDetail => ({ | |
| 388 | ...pullRequest, | |
| 389 | author: withAvatar(pullRequest.author, new Map<string, string>(), input.host), | |
| 390 | reviewers: pullRequest.reviewRequestLogins.map((login) => ({ | |
| 391 | login, | |
| 392 | name: null, | |
| 393 | avatarUrl: null, | |
| 394 | })), | |
| 395 | mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, | |
| 396 | viewerPermissions: gitHubViewerPermissions({ | |
| 397 | ...pullRequest.viewerAccess, | |
| 398 | canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, | |
| 399 | }), | |
| 400 | baseComparison: | |
| 401 | pullRequest.comparison === null || pullRequest.comparison.behindBy === null | |
| 402 | ? "unknown" | |
| 403 | : pullRequest.comparison.behindBy > 0 | |
| 404 | ? "behind" | |
| 405 | : "up-to-date", | |
| 406 | ...(pullRequest.comparison?.behindBy == null | |
| 407 | ? {} | |
| 408 | : { behindBy: pullRequest.comparison.behindBy }), | |
| 409 | })), | |
| 410 | Effect.mapError(fail("getChangeRequest")), | |
| 411 | ), | |
| 412 | ||
| 413 | getChangeRequestActivity: (input) => | |
| 414 | Effect.all( | |
| 415 | [ | |
| 416 | cli.getPullRequestActivity(input), | |
| 417 | // Line comments live on review threads, which `gh pr view --json` cannot reach. A | |
| 418 | // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. | |
| 419 | cli.listReviewThreadComments(input).pipe( | |
| 420 | Effect.orElseSucceed(() => ({ | |
| 421 | comments: [], | |
| 422 | dismissalsByReviewId: new Map<string, string>(), | |
| 423 | reactions: [], | |
| 424 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), | |
| 425 | editedAtById: new Map<string, string>(), | |
| 426 | reviewThreads: [], | |
| 427 | commentCount: 0, | |
| 428 | truncated: true, | |
| 429 | reviewThreadsTruncated: true, | |
| 430 | reviewers: [], | |
| 431 | avatarsByLogin: new Map<string, string>(), | |
| 432 | botLogins: new Set<string>(), | |
| 433 | commitStats: new Map< | |
| 434 | string, | |
| 435 | { readonly additions: number; readonly deletions: number } | |
| 436 | >(), | |
| 437 | commits: [], | |
| 438 | viewer: { canUpdate: true, didAuthor: false }, | |
| 439 | })), | |
| 440 | ), | |
| 441 | ], | |
| 442 | { concurrency: 2 }, | |
| 443 | ).pipe( | |
| 444 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 445 | Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ | |
| 446 | author: withAvatar( | |
| 447 | pullRequest.author, | |
| 448 | reviewThreads.avatarsByLogin, | |
| 449 | input.host, | |
| 450 | reviewThreads.botLogins, | |
| 451 | ), | |
| 452 | reviewers: reviewThreads.reviewers, | |
| 453 | reactions: reviewThreads.reactions, | |
| 454 | commits: (reviewThreads.commits.length > 0 | |
| 455 | ? reviewThreads.commits | |
| 456 | : pullRequest.commits | |
| 457 | ).map((commit) => ({ | |
| 458 | ...commit, | |
| 459 | ...reviewThreads.commitStats.get(commit.oid), | |
| 460 | authors: commit.authors?.map( | |
| 461 | (author) => | |
| 462 | withAvatar( | |
| 463 | author, | |
| 464 | reviewThreads.avatarsByLogin, | |
| 465 | input.host, | |
| 466 | reviewThreads.botLogins, | |
| 467 | ) ?? author, | |
| 468 | ), | |
| 469 | })), | |
| 470 | comments: [...pullRequest.comments, ...reviewThreads.comments] | |
| 471 | .map((comment) => ({ | |
| 472 | ...comment, | |
| 473 | // GitHub keeps the dismissal reason on the timeline event, not on the review, | |
| 474 | // so a dismissed review with nothing visible of its own reads its words from | |
| 475 | // there. "Visible" and not "empty": bot reviews often carry only an HTML | |
| 476 | // marker comment, which markdown renders as nothing. | |
| 477 | body: | |
| 478 | comment.kind === "review" && | |
| 479 | comment.reviewState?.toUpperCase() === "DISMISSED" && | |
| 480 | rendersEmpty(comment.body) | |
| 481 | ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) | |
| 482 | : comment.body, | |
| 483 | author: withAvatar( | |
| 484 | comment.author, | |
| 485 | reviewThreads.avatarsByLogin, | |
| 486 | input.host, | |
| 487 | reviewThreads.botLogins, | |
| 488 | ), | |
| 489 | // A comment out of `gh pr view --json` carries none of its own: that read | |
| 490 | // reports no reaction at all, so they arrive from the GraphQL page by node id. | |
| 491 | reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], | |
| 492 | editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, | |
| 493 | })) | |
| 494 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), | |
| 495 | // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two | |
| 496 | // are always whole and only the thread walk can stop short of the host. | |
| 497 | commentCount: pullRequest.comments.length + reviewThreads.commentCount, | |
| 498 | commentsTruncated: reviewThreads.truncated, | |
| 499 | reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, | |
| 500 | reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ | |
| 501 | ...thread, | |
| 502 | comments: thread.comments.map((comment) => ({ | |
| 503 | ...comment, | |
| 504 | author: withAvatar( | |
| 505 | comment.author, | |
| 506 | reviewThreads.avatarsByLogin, | |
| 507 | input.host, | |
| 508 | reviewThreads.botLogins, | |
| 509 | ), | |
| 510 | })), | |
| 511 | })), | |
| 512 | })), | |
| 513 | ), | |
| 514 | ||
| 515 | getReviewThreadComments: (input) => | |
| 516 | cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), | |
| 517 | ||
| 518 | getViewerPermissions: (input) => { | |
| 519 | const lightAccess = cli | |
| 520 | .getViewerAccess({ ...input, allowReserve: true }) | |
| 521 | .pipe(Effect.map((access) => gitHubViewerPermissions(access))); | |
| 522 | if (input.includeUpdateBranch === false) { | |
| 523 | return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); | |
| 524 | } | |
| 525 | // The core detail already carries the viewer's access, the merge settings, and the base | |
| 526 | // comparison, so one read usually answers what used to take three. When that heavier read | |
| 527 | // fails, the light access read still answers, withholding only update-branch. | |
| 528 | return cli.getPullRequestDetail(input).pipe( | |
| 529 | Effect.provideService(GitHubApi.AllowGitHubReserve, true), | |
| 530 | Effect.map((pullRequest) => | |
| 531 | gitHubViewerPermissions({ | |
| 532 | ...pullRequest.viewerAccess, | |
| 533 | canUpdateBranch: | |
| 534 | pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, | |
| 535 | }), | |
| 536 | ), | |
| 537 | Effect.catchIf( | |
| 538 | (error) => | |
| 539 | error._tag !== "GitHubApiRateLimitError" && | |
| 540 | error._tag !== "SourceControlRateLimitPausedError", | |
| 541 | () => lightAccess, | |
| 542 | ), | |
| 543 | Effect.mapError(fail("getViewerPermissions")), | |
| 544 | ); | |
| 545 | }, | |
| 546 | ||
| 547 | getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), | |
| 548 | ||
| 549 | getDiffFileContents: (input) => | |
| 550 | cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), | |
| 551 | ||
| 552 | getFilesViewed: (input) => | |
| 553 | cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), | |
| 554 | ||
| 555 | setFilesViewed: (input) => | |
| 556 | cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), | |
| 557 | ||
| 558 | listReviewerCandidates: (input) => | |
| 559 | cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), | |
| 560 | ||
| 561 | setReviewerRequest: (input) => | |
| 562 | cli | |
| 563 | .setReviewerRequest({ | |
| 564 | cwd: input.cwd, | |
| 565 | repository: input.repository, | |
| 566 | host: input.host, | |
| 567 | number: input.number, | |
| 568 | reviewers: input.reviewers, | |
| 569 | requested: input.requested, | |
| 570 | }) | |
| 571 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 572 | ||
| 573 | listLabelCandidates: (input) => | |
| 574 | cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), | |
| 575 | ||
| 576 | setLabels: (input) => | |
| 577 | cli | |
| 578 | .setLabels({ | |
| 579 | cwd: input.cwd, | |
| 580 | repository: input.repository, | |
| 581 | host: input.host, | |
| 582 | number: input.number, | |
| 583 | labels: input.labels, | |
| 584 | applied: input.applied, | |
| 585 | }) | |
| 586 | .pipe(Effect.mapError(fail("setLabels"))), | |
| 587 | ||
| 588 | runAction: (input) => | |
| 589 | cli | |
| 590 | .runPullRequestAction({ | |
| 591 | cwd: input.cwd, | |
| 592 | repository: input.repository, | |
| 593 | host: input.host, | |
| 594 | number: input.number, | |
| 595 | action: input.action, | |
| 596 | ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), | |
| 597 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | |
| 598 | ...(input.expectedStackHeads === undefined | |
| 599 | ? {} | |
| 600 | : { expectedStackHeads: input.expectedStackHeads }), | |
| 601 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 602 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), | |
| 603 | }) | |
| 604 | .pipe(Effect.mapError(fail("runAction"))), | |
| 605 | ||
| 606 | updateChangeRequest: (input) => | |
| 607 | cli | |
| 608 | .updatePullRequest({ | |
| 609 | cwd: input.cwd, | |
| 610 | repository: input.repository, | |
| 611 | host: input.host, | |
| 612 | number: input.number, | |
| 613 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 614 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 615 | }) | |
| 616 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 617 | ||
| 618 | comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), | |
| 619 | ||
| 620 | updateComment: (input) => | |
| 621 | cli | |
| 622 | .updateComment({ | |
| 623 | cwd: input.cwd, | |
| 624 | repository: input.repository, | |
| 625 | host: input.host, | |
| 626 | number: input.number, | |
| 627 | commentId: input.commentId, | |
| 628 | kind: input.kind, | |
| 629 | body: input.body, | |
| 630 | }) | |
| 631 | .pipe(Effect.mapError(fail("updateComment"))), | |
| 632 | ||
| 633 | submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), | |
| 634 | ||
| 635 | replyToThread: (input) => | |
| 636 | cli | |
| 637 | .replyToReviewThread({ | |
| 638 | cwd: input.cwd, | |
| 639 | repository: input.repository, | |
| 640 | host: input.host, | |
| 641 | threadId: input.threadId, | |
| 642 | body: input.body, | |
| 643 | }) | |
| 644 | .pipe(Effect.mapError(fail("replyToThread"))), | |
| 645 | ||
| 646 | setReaction: (input) => | |
| 647 | cli | |
| 648 | .setReaction({ | |
| 649 | cwd: input.cwd, | |
| 650 | repository: input.repository, | |
| 651 | host: input.host, | |
| 652 | number: input.number, | |
| 653 | ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), | |
| 654 | content: input.content, | |
| 655 | reacted: input.reacted, | |
| 656 | }) | |
| 657 | .pipe(Effect.mapError(fail("setReaction"))), | |
| 658 | ||
| 659 | setThreadResolution: (input) => | |
| 660 | cli | |
| 661 | .setReviewThreadResolution({ | |
| 662 | cwd: input.cwd, | |
| 663 | repository: input.repository, | |
| 664 | host: input.host, | |
| 665 | threadId: input.threadId, | |
| 666 | resolved: input.resolved, | |
| 667 | }) | |
| 668 | .pipe(Effect.mapError(fail("setThreadResolution"))), | |
| 669 | }; | |
| 670 | ||
| 671 | return provider; | |
| 672 | }); |
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | ... | @@ -2,12 +2,12 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 4 | 4 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 5 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 6 | 6 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 7 | 7 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 8 | 8 | import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; |
| 9 | 9 | import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; |
| 10 | import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; | |
| 10 | import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; | |
| 11 | 11 | import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; |
| 12 | 12 | |
| 13 | 13 | const cause = new Error("redacted provider failure"); |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+3-16| ... | ... | @@ -3,9 +3,6 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | |
| 6 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 7 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 8 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; | |
| 9 | 6 | import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; |
| 10 | 7 | import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 11 | 8 | |
| ... | ... | @@ -36,24 +33,14 @@ export function fromProviders( |
| 36 | 33 | * @public Service construction is part of the canonical Effect module API. |
| 37 | 34 | */ |
| 38 | 35 | export const make = Effect.gen(function* () { |
| 39 | const providers = yield* Effect.all([GitHubPullRequestProvider.make]); | |
| 40 | 36 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 41 | 37 | driver.make.pipe(Effect.map((instance) => instance.pullRequests)), |
| 42 | 38 | ); |
| 43 | return fromProviders([ | |
| 44 | ...providers, | |
| 45 | ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), | |
| 46 | ]); | |
| 39 | return fromProviders( | |
| 40 | drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), | |
| 41 | ); | |
| 47 | 42 | }); |
| 48 | 43 | |
| 49 | 44 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 50 | Layer.provide( | |
| 51 | GitHubPullRequestApi.layer.pipe( | |
| 52 | Layer.provide( | |
| 53 | // Built here too, so a registry built alone still has one budget and pause per host. | |
| 54 | GitHubApi.layerWithDependencies, | |
| 55 | ), | |
| 56 | ), | |
| 57 | ), | |
| 58 | 45 | Layer.provide(BuiltInDrivers.layer), |
| 59 | 46 | ); |
apps/server/src/pullRequest/PullRequestService.ts+1-1| ... | ... | @@ -77,7 +77,7 @@ import { |
| 77 | 77 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 78 | 78 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 79 | 79 | |
| 80 | import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; | |
| 80 | import { AllowGitHubReserve } from "@t3tools/source-control-github/server/GitHubApi"; | |
| 81 | 81 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | 82 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | 83 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
apps/server/src/pullRequest/gitHubConditionalChecks.test.ts deleted-245| ... | ... | @@ -1,245 +0,0 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as TestClock from "effect/testing/TestClock"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | import type { PullRequestCheck } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 10 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 11 | ||
| 12 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 13 | ||
| 14 | const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 }; | |
| 15 | const credential = { | |
| 16 | host: "github.com", | |
| 17 | token: Redacted.make("token"), | |
| 18 | credentialFingerprint: "one", | |
| 19 | }; | |
| 20 | ||
| 21 | it.effect( | |
| 22 | "reuses unchanged checks and catches reruns, later pages, fork approvals, pushes and account changes", | |
| 23 | () => | |
| 24 | Effect.gen(function* () { | |
| 25 | let sha = "a".repeat(40); | |
| 26 | let changed = ""; | |
| 27 | let reads = 0; | |
| 28 | const requests: string[] = []; | |
| 29 | const known: Array<unknown> = []; | |
| 30 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 31 | Effect.provide( | |
| 32 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 33 | rest: ({ path, ifNoneMatch }) => | |
| 34 | Effect.sync(() => { | |
| 35 | requests.push(path); | |
| 36 | const head = path.endsWith("/pulls/1"); | |
| 37 | const modified = | |
| 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 | return { | |
| 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, | |
| 62 | }; | |
| 63 | }), | |
| 64 | }), | |
| 65 | ), | |
| 66 | ); | |
| 67 | const read = Effect.gen(function* () { | |
| 68 | known.push(yield* KnownWorkflowRuns); | |
| 69 | reads++; | |
| 70 | return { | |
| 71 | state: "open" as const, | |
| 72 | checks: [ | |
| 73 | { name: "build", status: "success", description: null, url: null }, | |
| 74 | ] satisfies PullRequestCheck[], | |
| 75 | headSha: sha, | |
| 76 | workflowApprovalsRequired: 0, | |
| 77 | }; | |
| 78 | }); | |
| 79 | const poll = (identity = credential) => | |
| 80 | revalidate(reference, read).pipe( | |
| 81 | Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), | |
| 82 | ); | |
| 83 | yield* poll(); | |
| 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 | }); | |
| 90 | requests.length = 0; | |
| 91 | yield* poll(); | |
| 92 | expect(reads).toBe(1); | |
| 93 | expect(requests).toHaveLength(5); | |
| 94 | for (const endpoint of [ | |
| 95 | "check-runs?filter=all&per_page=100&page=2", | |
| 96 | "/status", | |
| 97 | "/actions/runs", | |
| 98 | "/pulls/1", | |
| 99 | ]) { | |
| 100 | changed = endpoint; | |
| 101 | yield* poll(); | |
| 102 | changed = ""; | |
| 103 | yield* poll(); | |
| 104 | } | |
| 105 | expect(reads).toBe(5); | |
| 106 | sha = "b".repeat(40); | |
| 107 | changed = "/pulls/1"; | |
| 108 | requests.length = 0; | |
| 109 | yield* poll(); | |
| 110 | expect(reads).toBe(6); | |
| 111 | expect( | |
| 112 | requests | |
| 113 | .filter((endpoint) => endpoint.includes("/commits/")) | |
| 114 | .every((endpoint) => endpoint.includes(sha)), | |
| 115 | ).toBe(true); | |
| 116 | changed = ""; | |
| 117 | yield* poll({ ...credential, credentialFingerprint: "two" }); | |
| 118 | expect(reads).toBe(7); | |
| 119 | yield* TestClock.adjust("5 minutes"); | |
| 120 | yield* poll(); | |
| 121 | expect(reads).toBe(8); | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect("does not retain failed or incomplete reads, and supports hosts without ETags", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const sha = "a".repeat(40); | |
| 128 | let etags = true; | |
| 129 | let unavailable = false; | |
| 130 | let fail = true; | |
| 131 | let complete = true; | |
| 132 | let reads = 0; | |
| 133 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 134 | Effect.provide( | |
| 135 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 136 | rest: ({ path, ifNoneMatch }) => | |
| 137 | unavailable | |
| 138 | ? Effect.fail( | |
| 139 | new GitHubApi.GitHubApiResponseError({ | |
| 140 | host: "github.com", | |
| 141 | operation: "revalidateChecks", | |
| 142 | status: 502, | |
| 143 | }), | |
| 144 | ) | |
| 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 | ), | |
| 161 | }), | |
| 162 | ), | |
| 163 | ); | |
| 164 | const read = Effect.suspend(() => { | |
| 165 | reads++; | |
| 166 | return fail | |
| 167 | ? Effect.fail( | |
| 168 | new GitHubApi.GitHubApiResponseError({ | |
| 169 | host: "github.com", | |
| 170 | operation: "read", | |
| 171 | status: 500, | |
| 172 | }), | |
| 173 | ) | |
| 174 | : Effect.succeed({ | |
| 175 | state: "open" as const, | |
| 176 | checks: [], | |
| 177 | headSha: sha, | |
| 178 | ...(complete ? { workflowApprovalsRequired: 0 } : {}), | |
| 179 | }); | |
| 180 | }); | |
| 181 | const poll = () => | |
| 182 | revalidate(reference, read).pipe( | |
| 183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 184 | ); | |
| 185 | yield* poll().pipe(Effect.flip); | |
| 186 | fail = false; | |
| 187 | complete = false; | |
| 188 | yield* poll(); | |
| 189 | yield* poll(); | |
| 190 | expect(reads).toBe(3); | |
| 191 | complete = true; | |
| 192 | yield* poll(); | |
| 193 | yield* poll(); | |
| 194 | expect(reads).toBe(4); | |
| 195 | yield* TestClock.adjust("5 minutes"); | |
| 196 | complete = false; | |
| 197 | yield* poll(); | |
| 198 | yield* poll(); | |
| 199 | expect(reads).toBe(6); | |
| 200 | complete = true; | |
| 201 | unavailable = true; | |
| 202 | yield* poll(); | |
| 203 | expect(reads).toBe(7); | |
| 204 | unavailable = false; | |
| 205 | yield* poll(); | |
| 206 | yield* poll(); | |
| 207 | expect(reads).toBe(8); | |
| 208 | etags = false; | |
| 209 | yield* poll(); | |
| 210 | yield* poll(); | |
| 211 | expect(reads).toBe(10); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | it.effect("falls back when REST checks are unavailable without retrying unsupported probes", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | let probes = 0; | |
| 218 | let reads = 0; | |
| 219 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 220 | Effect.provide( | |
| 221 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 222 | rest: () => { | |
| 223 | probes++; | |
| 224 | return Effect.fail( | |
| 225 | new GitHubApi.GitHubApiNotFoundError({ | |
| 226 | host: "github.com", | |
| 227 | operation: "revalidateChecks", | |
| 228 | }), | |
| 229 | ); | |
| 230 | }, | |
| 231 | }), | |
| 232 | ), | |
| 233 | ); | |
| 234 | const read = Effect.sync(() => { | |
| 235 | reads++; | |
| 236 | return { state: "open" as const, checks: [] }; | |
| 237 | }); | |
| 238 | for (let tick = 0; tick < 2; tick++) | |
| 239 | yield* revalidate(reference, read).pipe( | |
| 240 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 241 | ); | |
| 242 | expect(probes).toBe(1); | |
| 243 | expect(reads).toBe(2); | |
| 244 | }), | |
| 245 | ); |
apps/server/src/pullRequest/gitHubConditionalChecks.ts deleted-193| ... | ... | @@ -1,193 +0,0 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Cache from "effect/Cache"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 11 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; | |
| 12 | import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; | |
| 13 | import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 14 | ||
| 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 | }), | |
| 21 | base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), | |
| 22 | }); | |
| 23 | const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); | |
| 24 | ||
| 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]); | |
| 52 | ||
| 53 | export const makeChecksRevalidator = Effect.gen(function* () { | |
| 54 | const api = yield* GitHubApi.GitHubApi; | |
| 55 | const entries = yield* Cache.makeWith( | |
| 56 | (_key: string) => | |
| 57 | Effect.sync(() => ({ | |
| 58 | gate: Semaphore.makeUnsafe(1), | |
| 59 | validators: new Map<string, Validator>(), | |
| 60 | head: null as typeof HeadSchema.Type | null, | |
| 61 | value: null as PullRequestChecks | null, | |
| 62 | readAt: 0, | |
| 63 | supported: true, | |
| 64 | })), | |
| 65 | { capacity: 128, timeToLive: () => "30 minutes" }, | |
| 66 | ); | |
| 67 | return ( | |
| 68 | input: ProviderRepositoryRef & { readonly number: number }, | |
| 69 | read: Effect.Effect< | |
| 70 | Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & { | |
| 71 | workflowApprovalsRequired?: number; | |
| 72 | }, | |
| 73 | GitHubPullRequestApiError | |
| 74 | >, | |
| 75 | ) => | |
| 76 | Effect.gen(function* () { | |
| 77 | const credential = yield* GitHubApi.PinnedGitHubCredential; | |
| 78 | if (credential === null) return yield* read; | |
| 79 | const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; | |
| 80 | const entry = yield* Cache.get(entries, key); | |
| 81 | return yield* entry.gate.withPermit( | |
| 82 | Effect.gen(function* () { | |
| 83 | if (!entry.supported) return yield* read; | |
| 84 | const fail = (status: number) => | |
| 85 | new GitHubApi.GitHubApiResponseError({ | |
| 86 | host: input.host, | |
| 87 | operation: "revalidateChecks", | |
| 88 | status, | |
| 89 | }); | |
| 90 | /** Whether the endpoint has a next page, or null where the host cannot say. */ | |
| 91 | const get = (endpoint: string, head = false) => | |
| 92 | Effect.gen(function* () { | |
| 93 | const previous = entry.validators.get(endpoint); | |
| 94 | const response = yield* api | |
| 95 | .rest({ | |
| 96 | host: input.host, | |
| 97 | operation: "revalidateChecks", | |
| 98 | path: endpoint, | |
| 99 | ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), | |
| 100 | }) | |
| 101 | .pipe( | |
| 102 | Effect.catchTags({ | |
| 103 | GitHubApiNotFoundError: () => | |
| 104 | Effect.sync(() => { | |
| 105 | entry.supported = false; | |
| 106 | return null; | |
| 107 | }), | |
| 108 | GitHubApiResponseError: (error) => | |
| 109 | Effect.sync(() => { | |
| 110 | entry.supported = !UNSUPPORTED_STATUSES.has(error.status); | |
| 111 | return null; | |
| 112 | }), | |
| 113 | }), | |
| 114 | ); | |
| 115 | if (response === null) { | |
| 116 | entry.value = null; | |
| 117 | return null; | |
| 118 | } | |
| 119 | if (response.status === 304 && previous) return previous.next; | |
| 120 | if (response.status !== 200 || response.truncated) { | |
| 121 | return yield* fail(response.status); | |
| 122 | } | |
| 123 | entry.value = null; | |
| 124 | if (head) { | |
| 125 | const decoded = yield* decodeHead(response.body).pipe( | |
| 126 | Effect.mapError(() => fail(response.status)), | |
| 127 | ); | |
| 128 | if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); | |
| 129 | entry.head = decoded; | |
| 130 | } | |
| 131 | const validator = { | |
| 132 | etag: response.headers["etag"]?.trim(), | |
| 133 | next: /rel="next"/.test(response.headers["link"] ?? ""), | |
| 134 | body: response.body, | |
| 135 | }; | |
| 136 | entry.validators.set(endpoint, validator); | |
| 137 | return validator.next; | |
| 138 | }); | |
| 139 | const root = `repos/${input.repository}`; | |
| 140 | if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; | |
| 141 | const head = entry.head; | |
| 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; | |
| 147 | const endpoints = [ | |
| 148 | `${root}/commits/${head.head.sha}/check-runs?filter=all`, | |
| 149 | `${root}/commits/${head.head.sha}/status`, | |
| 150 | ...(runsEndpoint === null ? [] : [runsEndpoint]), | |
| 151 | ]; | |
| 152 | const runPages: Array<string> = []; | |
| 153 | for (const endpoint of endpoints) { | |
| 154 | for (let page = 1; ; page++) { | |
| 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); | |
| 158 | if (next === null) return yield* read; | |
| 159 | if (endpoint === runsEndpoint) runPages.push(paged); | |
| 160 | if (!next) break; | |
| 161 | } | |
| 162 | } | |
| 163 | const now = yield* Clock.currentTimeMillis; | |
| 164 | if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; | |
| 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)); | |
| 180 | const value = { | |
| 181 | state: fresh.state, | |
| 182 | checks: fresh.checks, | |
| 183 | }; | |
| 184 | entry.value = | |
| 185 | fresh.workflowApprovalsRequired !== undefined && fresh.headSha === head.head.sha | |
| 186 | ? value | |
| 187 | : null; | |
| 188 | entry.readAt = now; | |
| 189 | return value; | |
| 190 | }), | |
| 191 | ); | |
| 192 | }); | |
| 193 | }); |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts deleted-2111| ... | ... | @@ -1,2111 +0,0 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | buildPullRequestSummariesGraphQlQuery, | |
| 6 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 7 | decodePullRequestSummariesJson, | |
| 8 | decodePullRequestWatchFingerprintsJson, | |
| 9 | buildReviewSubmission, | |
| 10 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 11 | decodePullRequestStackMembershipsJson, | |
| 12 | buildReviewerRequest, | |
| 13 | buildSetFilesViewedGraphQlMutation, | |
| 14 | decodePullRequestActivityJson, | |
| 15 | decodePullRequestDetailJson, | |
| 16 | decodePullRequestFilesJson, | |
| 17 | decodePullRequestFilesViewedJson, | |
| 18 | decodePullRequestListJson, | |
| 19 | decodePullRequestNodeIdJson, | |
| 20 | decodePullRequestSearchJson, | |
| 21 | decodePullRequestStacksJson, | |
| 22 | decodeLabelCandidatesJson, | |
| 23 | decodeReviewerCandidatesJson, | |
| 24 | decodeReviewThreadCommentsJson, | |
| 25 | decodeReviewThreadsJson, | |
| 26 | decodeViewerPermissionsJson, | |
| 27 | decodeWorkflowRunsJson, | |
| 28 | reviewThreadConversation, | |
| 29 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 30 | pullRequestCoreGraphQlQuery, | |
| 31 | pullRequestSearchGraphQlQuery, | |
| 32 | } from "./gitHubPullRequestJson.ts"; | |
| 33 | ||
| 34 | /** | |
| 35 | * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: | |
| 36 | * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. | |
| 37 | */ | |
| 38 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | |
| 39 | return JSON.stringify({ | |
| 40 | data: { | |
| 41 | repository: { | |
| 42 | pullRequests: { | |
| 43 | pageInfo: { hasNextPage: false }, | |
| 44 | nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ | |
| 45 | number: 1, | |
| 46 | title: "Add the pull requests page", | |
| 47 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 48 | headRefName: "feat/page", | |
| 49 | baseRefName: "main", | |
| 50 | createdAt: "2026-07-01T00:00:00Z", | |
| 51 | updatedAt: "2026-07-02T00:00:00Z", | |
| 52 | ...(reviewRequests === undefined | |
| 53 | ? {} | |
| 54 | : { | |
| 55 | reviewRequests: { | |
| 56 | nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({ | |
| 57 | requestedReviewer, | |
| 58 | })), | |
| 59 | }, | |
| 60 | }), | |
| 61 | ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), | |
| 62 | ...(checks === undefined | |
| 63 | ? {} | |
| 64 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 65 | ...entry, | |
| 66 | })), | |
| 67 | }, | |
| 68 | }, | |
| 69 | }, | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 73 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 74 | expect(Result.isSuccess(result)).toBe(true); | |
| 75 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 76 | return result.success; | |
| 77 | } | |
| 78 | ||
| 79 | describe("pull request list decoding", () => { | |
| 80 | it("treats a merge timestamp as merged even when the state still says closed", () => { | |
| 81 | const [entry] = expectSuccess( | |
| 82 | decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), | |
| 83 | ).items; | |
| 84 | expect(entry?.state).toBe("merged"); | |
| 85 | }); | |
| 86 | ||
| 87 | it("normalizes mergeability and defaults unknown values", () => { | |
| 88 | const batch = expectSuccess( | |
| 89 | decodePullRequestListJson( | |
| 90 | listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), | |
| 91 | ), | |
| 92 | ); | |
| 93 | expect(batch.items.map((entry) => entry.mergeability)).toEqual([ | |
| 94 | "conflicting", | |
| 95 | "unknown", | |
| 96 | "unknown", | |
| 97 | ]); | |
| 98 | }); | |
| 99 | ||
| 100 | it("keeps user review requests and drops team ones, which are not logins", () => { | |
| 101 | const [entry] = expectSuccess( | |
| 102 | decodePullRequestListJson( | |
| 103 | listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), | |
| 104 | ), | |
| 105 | ).items; | |
| 106 | expect(entry?.reviewRequestLogins).toEqual(["octocat"]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { | |
| 110 | const batch = expectSuccess( | |
| 111 | decodePullRequestListJson( | |
| 112 | listJson([ | |
| 113 | { reviewDecision: "APPROVED" }, | |
| 114 | { reviewDecision: "CHANGES_REQUESTED" }, | |
| 115 | { reviewDecision: "REVIEW_REQUIRED" }, | |
| 116 | { reviewDecision: null }, | |
| 117 | ]), | |
| 118 | ), | |
| 119 | ); | |
| 120 | expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ | |
| 121 | "approved", | |
| 122 | "changes-requested", | |
| 123 | "review-required", | |
| 124 | null, | |
| 125 | ]); | |
| 126 | }); | |
| 127 | ||
| 128 | it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { | |
| 129 | const batch = expectSuccess( | |
| 130 | decodePullRequestListJson( | |
| 131 | listJson([ | |
| 132 | { | |
| 133 | reviewDecision: null, | |
| 134 | latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], | |
| 135 | }, | |
| 136 | { | |
| 137 | reviewDecision: "REVIEW_REQUIRED", | |
| 138 | latestReviews: [ | |
| 139 | { author: { login: "octocat" }, state: "APPROVED" }, | |
| 140 | { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, | |
| 141 | ], | |
| 142 | }, | |
| 143 | { | |
| 144 | reviewDecision: "APPROVED", | |
| 145 | latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], | |
| 146 | }, | |
| 147 | { | |
| 148 | reviewDecision: null, | |
| 149 | latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], | |
| 150 | }, | |
| 151 | ]), | |
| 152 | ), | |
| 153 | ); | |
| 154 | expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ | |
| 155 | "approved", | |
| 156 | "changes-requested", | |
| 157 | "approved", | |
| 158 | null, | |
| 159 | ]); | |
| 160 | }); | |
| 161 | ||
| 162 | it("rolls the head commit's rollup up to the one word a row has space for", () => { | |
| 163 | const batch = expectSuccess( | |
| 164 | decodePullRequestListJson( | |
| 165 | listJson([ | |
| 166 | { checks: "FAILURE" }, | |
| 167 | { checks: "PENDING" }, | |
| 168 | { checks: "SUCCESS" }, | |
| 169 | { checks: "ERROR" }, | |
| 170 | { checks: "EXPECTED" }, | |
| 171 | {}, | |
| 172 | ]), | |
| 173 | ), | |
| 174 | ); | |
| 175 | expect(batch.items.map((entry) => entry.checksState)).toEqual([ | |
| 176 | "failing", | |
| 177 | "pending", | |
| 178 | "passing", | |
| 179 | "failing", | |
| 180 | "pending", | |
| 181 | null, | |
| 182 | ]); | |
| 183 | }); | |
| 184 | ||
| 185 | it("skips malformed entries but still counts them, so paging does not stop early", () => { | |
| 186 | const batch = expectSuccess( | |
| 187 | decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), | |
| 188 | ); | |
| 189 | expect(batch.items).toHaveLength(1); | |
| 190 | expect(batch.rawCount).toBe(2); | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | describe("pull request search decoding", () => { | |
| 195 | function searchJson(rollupStates: ReadonlyArray<string | null>): string { | |
| 196 | return JSON.stringify({ | |
| 197 | data: { | |
| 198 | search: { | |
| 199 | pageInfo: { hasNextPage: false }, | |
| 200 | nodes: rollupStates.map((state, index) => ({ | |
| 201 | number: index + 1, | |
| 202 | title: "Add the pull requests page", | |
| 203 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 204 | headRefName: "feat/page", | |
| 205 | baseRefName: "main", | |
| 206 | createdAt: "2026-07-01T00:00:00Z", | |
| 207 | updatedAt: "2026-07-02T00:00:00Z", | |
| 208 | repository: { nameWithOwner: "pingdotgg/t3code" }, | |
| 209 | commits: { | |
| 210 | nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], | |
| 211 | }, | |
| 212 | })), | |
| 213 | }, | |
| 214 | }, | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | it("keeps stack membership beside search results without extra per-PR reads", () => { | |
| 219 | const raw = JSON.parse(searchJson(["SUCCESS", null])); | |
| 220 | raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; | |
| 221 | raw.data.search.nodes[0].stackEntry = { position: 1 }; | |
| 222 | const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); | |
| 223 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); | |
| 224 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 225 | expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); | |
| 226 | expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); | |
| 227 | }); | |
| 228 | ||
| 229 | it("maps the rollup enum the search answers with onto the same three words", () => { | |
| 230 | // The search asks GitHub for the verdict rather than the checks behind it, so this path sees | |
| 231 | // one enum where the listing sees an array. | |
| 232 | const batch = expectSuccess( | |
| 233 | decodePullRequestSearchJson( | |
| 234 | searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), | |
| 235 | ), | |
| 236 | ); | |
| 237 | expect(batch.items.map((entry) => entry.checksState)).toEqual([ | |
| 238 | "passing", | |
| 239 | "failing", | |
| 240 | "failing", | |
| 241 | "pending", | |
| 242 | "pending", | |
| 243 | null, | |
| 244 | ]); | |
| 245 | }); | |
| 246 | }); | |
| 247 | ||
| 248 | describe("pull request detail decoding", () => { | |
| 249 | const detailJson = JSON.stringify({ | |
| 250 | number: 7, | |
| 251 | title: "Detail", | |
| 252 | url: "https://github.com/pingdotgg/t3code/pull/7", | |
| 253 | headRefName: "feat/detail", | |
| 254 | baseRefName: "main", | |
| 255 | createdAt: "2026-07-01T00:00:00Z", | |
| 256 | updatedAt: "2026-07-05T00:00:00Z", | |
| 257 | body: "Body", | |
| 258 | statusCheckRollup: [ | |
| 259 | { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, | |
| 260 | { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 261 | { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, | |
| 262 | ], | |
| 263 | comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], | |
| 264 | reviews: [ | |
| 265 | { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, | |
| 266 | { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, | |
| 267 | ], | |
| 268 | commits: [ | |
| 269 | { | |
| 270 | oid: "abc1234", | |
| 271 | messageHeadline: "Ship the timeline", | |
| 272 | committedDate: "2026-07-05T00:00:00Z", | |
| 273 | authors: [ | |
| 274 | { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, | |
| 275 | { name: "Pair Author", email: "pair@example.com" }, | |
| 276 | ], | |
| 277 | }, | |
| 278 | ], | |
| 279 | }); | |
| 280 | ||
| 281 | /** The same conversation as the GraphQL activity query answers it. */ | |
| 282 | const activityJson = (raw: Record<string, unknown>) => | |
| 283 | JSON.stringify({ | |
| 284 | data: { | |
| 285 | repository: { | |
| 286 | pullRequest: { | |
| 287 | comments: { nodes: raw["comments"] ?? [] }, | |
| 288 | reviews: { nodes: raw["reviews"] ?? [] }, | |
| 289 | commits: { | |
| 290 | nodes: ((raw["commits"] ?? []) as ReadonlyArray<Record<string, unknown>>).map( | |
| 291 | ({ authors, ...commit }) => ({ | |
| 292 | commit: { | |
| 293 | ...commit, | |
| 294 | authors: { | |
| 295 | nodes: ((authors ?? []) as ReadonlyArray<Record<string, unknown>>).map( | |
| 296 | ({ login, ...author }) => ({ | |
| 297 | ...author, | |
| 298 | ...(login === undefined ? {} : { user: { login } }), | |
| 299 | }), | |
| 300 | ), | |
| 301 | }, | |
| 302 | }, | |
| 303 | }), | |
| 304 | ), | |
| 305 | }, | |
| 306 | }, | |
| 307 | }, | |
| 308 | }, | |
| 309 | }); | |
| 310 | const activity = (raw: Record<string, unknown>) => | |
| 311 | Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ | |
| 312 | comments: page.remarks.toSorted((left, right) => | |
| 313 | left.createdAt.localeCompare(right.createdAt), | |
| 314 | ), | |
| 315 | commits: page.commits ?? [], | |
| 316 | })); | |
| 317 | ||
| 318 | it("maps check-run status and commit-status state onto one vocabulary", () => { | |
| 319 | const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); | |
| 320 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ | |
| 321 | ["build", "pending"], | |
| 322 | ["test", "failure"], | |
| 323 | ["ci/legacy", "success"], | |
| 324 | ]); | |
| 325 | }); | |
| 326 | ||
| 327 | it("keeps what branch protection requires, and asks for it on github.com only", () => { | |
| 328 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 329 | const detail = expectSuccess( | |
| 330 | decodePullRequestDetailJson( | |
| 331 | JSON.stringify({ | |
| 332 | ...raw, | |
| 333 | statusCheckRollup: [ | |
| 334 | { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, | |
| 335 | { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, | |
| 336 | { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, | |
| 337 | ], | |
| 338 | }), | |
| 339 | ), | |
| 340 | ); | |
| 341 | expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); | |
| 342 | expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); | |
| 343 | expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); | |
| 344 | }); | |
| 345 | ||
| 346 | it("keeps a workflow waiting for approval out of the passing state", () => { | |
| 347 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 348 | const detail = expectSuccess( | |
| 349 | decodePullRequestDetailJson( | |
| 350 | JSON.stringify({ | |
| 351 | ...raw, | |
| 352 | statusCheckRollup: [ | |
| 353 | { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 354 | { | |
| 355 | __typename: "CheckRun", | |
| 356 | name: "contributor tests", | |
| 357 | status: "COMPLETED", | |
| 358 | conclusion: "ACTION_REQUIRED", | |
| 359 | }, | |
| 360 | ], | |
| 361 | }), | |
| 362 | ), | |
| 363 | ); | |
| 364 | ||
| 365 | expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]); | |
| 366 | expect(detail.checksState).toBe("pending"); | |
| 367 | }); | |
| 368 | ||
| 369 | it("decodes workflow runs that can be approved", () => { | |
| 370 | expect( | |
| 371 | expectSuccess( | |
| 372 | decodeWorkflowRunsJson( | |
| 373 | JSON.stringify({ | |
| 374 | workflow_runs: [ | |
| 375 | { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, | |
| 376 | { id: 11, name: null, html_url: null }, | |
| 377 | ], | |
| 378 | }), | |
| 379 | ), | |
| 380 | ).runs, | |
| 381 | ).toEqual([ | |
| 382 | { id: 10, name: "contributor tests", url: "https://example.com/10" }, | |
| 383 | { id: 11, name: "Workflow run 11", url: null }, | |
| 384 | ]); | |
| 385 | }); | |
| 386 | ||
| 387 | it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => { | |
| 388 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 389 | const armed = (entry: Record<string, unknown>) => | |
| 390 | expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); | |
| 391 | ||
| 392 | expect( | |
| 393 | armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), | |
| 394 | ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | |
| 395 | expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false); | |
| 396 | // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. | |
| 397 | expect(armed({}).autoMergeEnabled).toBeUndefined(); | |
| 398 | }); | |
| 399 | ||
| 400 | it("shows a re-running check once, as the run that is happening now", () => { | |
| 401 | // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, | |
| 402 | // the finished run and the one that replaced it, with no id to tell them apart. | |
| 403 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 404 | const detail = expectSuccess( | |
| 405 | decodePullRequestDetailJson( | |
| 406 | JSON.stringify({ | |
| 407 | ...raw, | |
| 408 | statusCheckRollup: [ | |
| 409 | { | |
| 410 | __typename: "CheckRun", | |
| 411 | name: "Prepare PR size config", | |
| 412 | workflowName: "PR Size", | |
| 413 | status: "COMPLETED", | |
| 414 | conclusion: "SUCCESS", | |
| 415 | startedAt: "2026-08-11T16:06:20Z", | |
| 416 | completedAt: "2026-08-11T16:06:25Z", | |
| 417 | }, | |
| 418 | { | |
| 419 | __typename: "CheckRun", | |
| 420 | name: "Prepare PR size config", | |
| 421 | workflowName: "PR Size", | |
| 422 | status: "IN_PROGRESS", | |
| 423 | conclusion: "", | |
| 424 | startedAt: "2026-08-11T17:01:04Z", | |
| 425 | completedAt: "0001-01-01T00:00:00Z", | |
| 426 | }, | |
| 427 | ], | |
| 428 | }), | |
| 429 | ), | |
| 430 | ); | |
| 431 | ||
| 432 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ | |
| 433 | ["Prepare PR size config", "pending"], | |
| 434 | ]); | |
| 435 | expect(detail.checksState).toBe("pending"); | |
| 436 | }); | |
| 437 | ||
| 438 | it("merges reviews with comments in time order and keeps a bodyless approval", () => { | |
| 439 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); | |
| 440 | // r2 approved without writing anything, which is still the event worth seeing. | |
| 441 | expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); | |
| 442 | expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); | |
| 443 | }); | |
| 444 | ||
| 445 | it("keeps every attributed commit author, including an unlinked signature", () => { | |
| 446 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); | |
| 447 | expect(detail.commits[0]?.authors).toEqual([ | |
| 448 | { login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 449 | { login: "Pair Author", name: "Pair Author", avatarUrl: null }, | |
| 450 | ]); | |
| 451 | }); | |
| 452 | ||
| 453 | it("drops the bodyless review GitHub opens to hold line comments", () => { | |
| 454 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 455 | const detail = expectSuccess( | |
| 456 | activity({ | |
| 457 | ...raw, | |
| 458 | reviews: [ | |
| 459 | // What a reviewer leaving inline comments produces: a container with a state but | |
| 460 | // nothing to read. Its comments come from the review threads instead. | |
| 461 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, | |
| 462 | { | |
| 463 | id: "r5", | |
| 464 | body: "Looks good.", | |
| 465 | state: "COMMENTED", | |
| 466 | submittedAt: "2026-07-08T00:00:00Z", | |
| 467 | }, | |
| 468 | ], | |
| 469 | }), | |
| 470 | ); | |
| 471 | ||
| 472 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); | |
| 473 | }); | |
| 474 | ||
| 475 | it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( | |
| 476 | "keeps a bodyless %s review, which is the event itself", | |
| 477 | (state) => { | |
| 478 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 479 | const detail = expectSuccess( | |
| 480 | activity({ | |
| 481 | ...raw, | |
| 482 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], | |
| 483 | }), | |
| 484 | ); | |
| 485 | ||
| 486 | expect(detail.comments.map((comment) => comment.id)).toContain("r6"); | |
| 487 | }, | |
| 488 | ); | |
| 489 | ||
| 490 | it("drops a review that carries neither a body nor a state", () => { | |
| 491 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 492 | const detail = expectSuccess( | |
| 493 | activity({ | |
| 494 | ...raw, | |
| 495 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], | |
| 496 | }), | |
| 497 | ); | |
| 498 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 499 | }); | |
| 500 | }); | |
| 501 | ||
| 502 | describe("review thread decoding", () => { | |
| 503 | const threadsJson = ( | |
| 504 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 505 | totalCount = nodes.length, | |
| 506 | pageInfo: Record<string, unknown> = { hasNextPage: false, endCursor: null }, | |
| 507 | ): string => | |
| 508 | JSON.stringify({ | |
| 509 | data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, | |
| 510 | }); | |
| 511 | ||
| 512 | /** The same query carries the review roster, so it is built alongside the threads. */ | |
| 513 | const reviewJson = (input: { | |
| 514 | readonly requested?: ReadonlyArray<unknown>; | |
| 515 | readonly reviewed?: ReadonlyArray<unknown>; | |
| 516 | }): string => | |
| 517 | JSON.stringify({ | |
| 518 | data: { | |
| 519 | repository: { | |
| 520 | pullRequest: { | |
| 521 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 522 | reviewRequests: { | |
| 523 | nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), | |
| 524 | }, | |
| 525 | latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, | |
| 526 | }, | |
| 527 | }, | |
| 528 | }, | |
| 529 | }); | |
| 530 | ||
| 531 | it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { | |
| 532 | const result = expectSuccess( | |
| 533 | decodeReviewThreadsJson( | |
| 534 | reviewJson({ | |
| 535 | requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], | |
| 536 | // An app that has reviewed is no longer an outstanding request, which is why asking | |
| 537 | // only for requests reported nobody on a pull request a bot had reviewed. | |
| 538 | reviewed: [ | |
| 539 | { | |
| 540 | __typename: "Bot", | |
| 541 | login: "macroscopeapp", | |
| 542 | avatarUrl: "https://avatars/in/900172.png", | |
| 543 | }, | |
| 544 | ], | |
| 545 | }), | |
| 546 | ), | |
| 547 | ); | |
| 548 | ||
| 549 | expect(result.botLogins).toEqual(new Set(["macroscopeapp"])); | |
| 550 | expect(result.reviewers).toEqual([ | |
| 551 | { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, | |
| 552 | { | |
| 553 | login: "macroscopeapp", | |
| 554 | name: null, | |
| 555 | avatarUrl: "https://avatars/in/900172.png", | |
| 556 | isBot: true, | |
| 557 | }, | |
| 558 | ]); | |
| 559 | }); | |
| 560 | ||
| 561 | it("carries per-commit line counts from the pull-request connection", () => { | |
| 562 | const result = expectSuccess( | |
| 563 | decodeReviewThreadsJson( | |
| 564 | JSON.stringify({ | |
| 565 | data: { | |
| 566 | repository: { | |
| 567 | pullRequest: { | |
| 568 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 569 | commits: { | |
| 570 | nodes: [ | |
| 571 | { commit: { oid: "abc123", additions: 18, deletions: 7 } }, | |
| 572 | { commit: { oid: "def456", additions: 3, deletions: 0 } }, | |
| 573 | ], | |
| 574 | }, | |
| 575 | }, | |
| 576 | }, | |
| 577 | }, | |
| 578 | }), | |
| 579 | ), | |
| 580 | ); | |
| 581 | ||
| 582 | expect([...result.commitStats]).toEqual([ | |
| 583 | ["abc123", { additions: 18, deletions: 7 }], | |
| 584 | ["def456", { additions: 3, deletions: 0 }], | |
| 585 | ]); | |
| 586 | }); | |
| 587 | ||
| 588 | it("omits misleading line counts from merge commits", () => { | |
| 589 | const result = expectSuccess( | |
| 590 | decodeReviewThreadsJson( | |
| 591 | JSON.stringify({ | |
| 592 | data: { | |
| 593 | repository: { | |
| 594 | pullRequest: { | |
| 595 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 596 | commits: { | |
| 597 | nodes: [ | |
| 598 | { | |
| 599 | commit: { | |
| 600 | oid: "merge123", | |
| 601 | additions: 36_858, | |
| 602 | deletions: 12_928, | |
| 603 | parents: { totalCount: 2 }, | |
| 604 | }, | |
| 605 | }, | |
| 606 | ], | |
| 607 | }, | |
| 608 | }, | |
| 609 | }, | |
| 610 | }, | |
| 611 | }), | |
| 612 | ), | |
| 613 | ); | |
| 614 | ||
| 615 | expect([...result.commitStats]).toEqual([]); | |
| 616 | }); | |
| 617 | ||
| 618 | it("decodes the newest commits off the same connection, oldest to newest", () => { | |
| 619 | const result = expectSuccess( | |
| 620 | decodeReviewThreadsJson( | |
| 621 | JSON.stringify({ | |
| 622 | data: { | |
| 623 | repository: { | |
| 624 | pullRequest: { | |
| 625 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 626 | commits: { | |
| 627 | nodes: [ | |
| 628 | { | |
| 629 | commit: { | |
| 630 | oid: "abc123", | |
| 631 | messageHeadline: "Ship the timeline", | |
| 632 | committedDate: "2026-07-05T00:00:00Z", | |
| 633 | additions: 18, | |
| 634 | deletions: 7, | |
| 635 | authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] }, | |
| 636 | }, | |
| 637 | }, | |
| 638 | { | |
| 639 | commit: { | |
| 640 | oid: "def456", | |
| 641 | messageHeadline: "Fix the flaky test", | |
| 642 | committedDate: "2026-07-06T00:00:00Z", | |
| 643 | }, | |
| 644 | }, | |
| 645 | ], | |
| 646 | }, | |
| 647 | }, | |
| 648 | }, | |
| 649 | }, | |
| 650 | }), | |
| 651 | ), | |
| 652 | ); | |
| 653 | ||
| 654 | expect(result.commits).toEqual([ | |
| 655 | { | |
| 656 | oid: "abc123", | |
| 657 | messageHeadline: "Ship the timeline", | |
| 658 | committedDate: "2026-07-05T00:00:00Z", | |
| 659 | authors: [{ login: "julius", name: "Julius", avatarUrl: null }], | |
| 660 | }, | |
| 661 | { | |
| 662 | oid: "def456", | |
| 663 | messageHeadline: "Fix the flaky test", | |
| 664 | committedDate: "2026-07-06T00:00:00Z", | |
| 665 | authors: [], | |
| 666 | }, | |
| 667 | ]); | |
| 668 | }); | |
| 669 | ||
| 670 | it("lists someone who was asked and then answered only once", () => { | |
| 671 | const result = expectSuccess( | |
| 672 | decodeReviewThreadsJson( | |
| 673 | reviewJson({ | |
| 674 | requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], | |
| 675 | reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], | |
| 676 | }), | |
| 677 | ), | |
| 678 | ); | |
| 679 | ||
| 680 | expect(result.reviewers).toHaveLength(1); | |
| 681 | }); | |
| 682 | ||
| 683 | it("skips a team request, which names nobody to show", () => { | |
| 684 | const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); | |
| 685 | ||
| 686 | expect(result.reviewers).toEqual([]); | |
| 687 | }); | |
| 688 | ||
| 689 | it("keeps the conversation when a request is from a team, which has no login", () => { | |
| 690 | // GraphQL answers with an empty object for a union member the query has no fragment for. | |
| 691 | // Failing on it would take the whole response down, comments included. | |
| 692 | const result = expectSuccess( | |
| 693 | decodeReviewThreadsJson( | |
| 694 | reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), | |
| 695 | ), | |
| 696 | ); | |
| 697 | ||
| 698 | expect(result.reviewers).toEqual([ | |
| 699 | { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, | |
| 700 | ]); | |
| 701 | }); | |
| 702 | ||
| 703 | it("carries a resolved thread into the conversation, which was still said", () => { | |
| 704 | const result = expectSuccess( | |
| 705 | decodeReviewThreadsJson( | |
| 706 | threadsJson([ | |
| 707 | { | |
| 708 | id: "PRRT_a", | |
| 709 | isResolved: false, | |
| 710 | path: "apps/server/src/ws.ts", | |
| 711 | comments: { | |
| 712 | nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], | |
| 713 | }, | |
| 714 | }, | |
| 715 | { | |
| 716 | id: "PRRT_b", | |
| 717 | isResolved: true, | |
| 718 | path: "apps/web/src/main.tsx", | |
| 719 | comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, | |
| 720 | }, | |
| 721 | ]), | |
| 722 | ), | |
| 723 | ); | |
| 724 | const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); | |
| 725 | expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); | |
| 726 | expect(comments[0]).toMatchObject({ | |
| 727 | id: "t1", | |
| 728 | kind: "review-comment", | |
| 729 | path: "apps/server/src/ws.ts", | |
| 730 | }); | |
| 731 | }); | |
| 732 | ||
| 733 | it("carries every reply, not only the remark each thread opened with", () => { | |
| 734 | const result = expectSuccess( | |
| 735 | decodeReviewThreadsJson( | |
| 736 | threadsJson([ | |
| 737 | { | |
| 738 | id: "PRRT_c", | |
| 739 | isResolved: false, | |
| 740 | path: "apps/server/src/ws.ts", | |
| 741 | comments: { | |
| 742 | nodes: [ | |
| 743 | { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, | |
| 744 | { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, | |
| 745 | ], | |
| 746 | }, | |
| 747 | }, | |
| 748 | ]), | |
| 749 | ), | |
| 750 | ); | |
| 751 | const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); | |
| 752 | expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); | |
| 753 | }); | |
| 754 | ||
| 755 | it("hands back the cursor the next page of threads carries on from", () => { | |
| 756 | const result = expectSuccess( | |
| 757 | decodeReviewThreadsJson( | |
| 758 | threadsJson( | |
| 759 | [ | |
| 760 | { | |
| 761 | id: "PRRT_d", | |
| 762 | path: "apps/server/src/ws.ts", | |
| 763 | isResolved: false, | |
| 764 | comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, | |
| 765 | }, | |
| 766 | ], | |
| 767 | 80, | |
| 768 | { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, | |
| 769 | ), | |
| 770 | ), | |
| 771 | ); | |
| 772 | expect(result.nextCursor).toBe("Y3Vyc29yOjE"); | |
| 773 | }); | |
| 774 | ||
| 775 | it("keeps GitHub's own count of a thread whose comments were not all read", () => { | |
| 776 | const result = expectSuccess( | |
| 777 | decodeReviewThreadsJson( | |
| 778 | threadsJson([ | |
| 779 | { | |
| 780 | id: "PRRT_e", | |
| 781 | path: "apps/server/src/ws.ts", | |
| 782 | isResolved: false, | |
| 783 | comments: { | |
| 784 | totalCount: 140, | |
| 785 | pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, | |
| 786 | nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], | |
| 787 | }, | |
| 788 | }, | |
| 789 | ]), | |
| 790 | ), | |
| 791 | ); | |
| 792 | expect(result.threads[0]).toMatchObject({ | |
| 793 | commentCount: 140, | |
| 794 | nextCommentCursor: "Y3Vyc29yOjI", | |
| 795 | }); | |
| 796 | }); | |
| 797 | ||
| 798 | it("ends a thread's walk on the last page, which still names a cursor", () => { | |
| 799 | const decoded = expectSuccess( | |
| 800 | decodeReviewThreadCommentsJson( | |
| 801 | JSON.stringify({ | |
| 802 | data: { | |
| 803 | repository: { pullRequest: { id: "PR_1" } }, | |
| 804 | node: { | |
| 805 | pullRequest: { id: "PR_1" }, | |
| 806 | comments: { | |
| 807 | pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, | |
| 808 | nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], | |
| 809 | }, | |
| 810 | }, | |
| 811 | }, | |
| 812 | }), | |
| 813 | ), | |
| 814 | ); | |
| 815 | expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); | |
| 816 | expect(decoded.nextCursor).toBeNull(); | |
| 817 | }); | |
| 818 | }); | |
| 819 | ||
| 820 | describe("reaction decoding", () => { | |
| 821 | const commentWithGroups = (reactionGroups: ReadonlyArray<Record<string, unknown>>) => | |
| 822 | JSON.stringify({ | |
| 823 | data: { | |
| 824 | repository: { pullRequest: { id: "PR_1" } }, | |
| 825 | node: { | |
| 826 | pullRequest: { id: "PR_1" }, | |
| 827 | comments: { | |
| 828 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 829 | nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], | |
| 830 | }, | |
| 831 | }, | |
| 832 | }, | |
| 833 | }); | |
| 834 | ||
| 835 | it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { | |
| 836 | const decoded = expectSuccess( | |
| 837 | decodeReviewThreadCommentsJson( | |
| 838 | commentWithGroups([ | |
| 839 | { | |
| 840 | content: "THUMBS_UP", | |
| 841 | viewerHasReacted: true, | |
| 842 | reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, | |
| 843 | }, | |
| 844 | // Not one of the eight the contract carries. | |
| 845 | { | |
| 846 | content: "PARTY_PARROT", | |
| 847 | reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, | |
| 848 | }, | |
| 849 | // Nobody behind it, which GitHub still answers a group for. | |
| 850 | { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, | |
| 851 | // More reactors than the bounded read named, and no `viewerHasReacted` at all. | |
| 852 | { | |
| 853 | content: "ROCKET", | |
| 854 | reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, | |
| 855 | }, | |
| 856 | ]), | |
| 857 | ), | |
| 858 | ); | |
| 859 | ||
| 860 | expect(decoded.comments[0]?.reactions).toEqual([ | |
| 861 | { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, | |
| 862 | { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, | |
| 863 | ]); | |
| 864 | }); | |
| 865 | ||
| 866 | it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { | |
| 867 | const decoded = expectSuccess( | |
| 868 | decodeReviewThreadCommentsJson( | |
| 869 | JSON.stringify({ | |
| 870 | data: { | |
| 871 | viewer: { login: "Bilal" }, | |
| 872 | repository: { pullRequest: { id: "PR_1" } }, | |
| 873 | node: { | |
| 874 | pullRequest: { id: "PR_1" }, | |
| 875 | comments: { | |
| 876 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 877 | nodes: [ | |
| 878 | { | |
| 879 | id: "t1", | |
| 880 | body: "nice", | |
| 881 | createdAt: "2026-07-01T00:00:00Z", | |
| 882 | reactionGroups: [ | |
| 883 | { | |
| 884 | content: "HEART", | |
| 885 | viewerHasReacted: true, | |
| 886 | reactors: { | |
| 887 | totalCount: 2, | |
| 888 | nodes: [{ login: "bilal" }, { login: "julius" }], | |
| 889 | }, | |
| 890 | }, | |
| 891 | ], | |
| 892 | }, | |
| 893 | ], | |
| 894 | }, | |
| 895 | }, | |
| 896 | }, | |
| 897 | }), | |
| 898 | ), | |
| 899 | ); | |
| 900 | ||
| 901 | expect(decoded.comments[0]?.reactions).toEqual([ | |
| 902 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | |
| 903 | ]); | |
| 904 | }); | |
| 905 | }); | |
| 906 | ||
| 907 | describe("repository access decoding", () => { | |
| 908 | const repositoryJson = (viewerPermission?: string | null) => | |
| 909 | JSON.stringify({ | |
| 910 | data: { | |
| 911 | repository: { | |
| 912 | pullRequest: null, | |
| 913 | mergeCommitAllowed: true, | |
| 914 | squashMergeAllowed: false, | |
| 915 | rebaseMergeAllowed: true, | |
| 916 | ...(viewerPermission === undefined ? {} : { viewerPermission }), | |
| 917 | }, | |
| 918 | }, | |
| 919 | }); | |
| 920 | ||
| 921 | it("reads merge settings with viewer permissions", () => { | |
| 922 | expect( | |
| 923 | expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, | |
| 924 | ).toEqual({ merge: true, squash: false, rebase: true }); | |
| 925 | }); | |
| 926 | ||
| 927 | it("fails rather than defaulting open when a setting is missing", () => { | |
| 928 | const decoded = decodeViewerPermissionsJson( | |
| 929 | JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), | |
| 930 | ); | |
| 931 | expect(Result.isSuccess(decoded)).toBe(false); | |
| 932 | }); | |
| 933 | ||
| 934 | it("counts the roles that can push as write, and the ones that cannot as read", () => { | |
| 935 | for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { | |
| 936 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( | |
| 937 | true, | |
| 938 | ); | |
| 939 | } | |
| 940 | for (const permission of ["TRIAGE", "READ", "NONE"]) { | |
| 941 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( | |
| 942 | false, | |
| 943 | ); | |
| 944 | } | |
| 945 | }); | |
| 946 | ||
| 947 | it("withholds write where gh names no permission, which is not a standing it gave", () => { | |
| 948 | // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes | |
| 949 | // the press, where a missing one still leaves the pull request open on its host. | |
| 950 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); | |
| 951 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); | |
| 952 | }); | |
| 953 | }); | |
| 954 | ||
| 955 | describe("viewer permission decoding", () => { | |
| 956 | const viewerJson = (repository: Record<string, unknown>) => | |
| 957 | JSON.stringify({ | |
| 958 | data: { | |
| 959 | repository: { | |
| 960 | mergeCommitAllowed: true, | |
| 961 | squashMergeAllowed: false, | |
| 962 | rebaseMergeAllowed: true, | |
| 963 | ...repository, | |
| 964 | }, | |
| 965 | }, | |
| 966 | }); | |
| 967 | ||
| 968 | it("reads the repository's role and the pull request's own viewer fields together", () => { | |
| 969 | expect( | |
| 970 | expectSuccess( | |
| 971 | decodeViewerPermissionsJson( | |
| 972 | viewerJson({ | |
| 973 | viewerPermission: "READ", | |
| 974 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 975 | }), | |
| 976 | ), | |
| 977 | ), | |
| 978 | ).toEqual({ | |
| 979 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 980 | canWrite: false, | |
| 981 | canTriage: false, | |
| 982 | canUpdate: true, | |
| 983 | didAuthor: true, | |
| 984 | }); | |
| 985 | }); | |
| 986 | ||
| 987 | it("says no to a passer-by on a repository they can only read", () => { | |
| 988 | expect( | |
| 989 | expectSuccess( | |
| 990 | decodeViewerPermissionsJson( | |
| 991 | viewerJson({ | |
| 992 | viewerPermission: "READ", | |
| 993 | pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, | |
| 994 | }), | |
| 995 | ), | |
| 996 | ), | |
| 997 | ).toEqual({ | |
| 998 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 999 | canWrite: false, | |
| 1000 | canTriage: false, | |
| 1001 | canUpdate: false, | |
| 1002 | didAuthor: false, | |
| 1003 | }); | |
| 1004 | }); | |
| 1005 | ||
| 1006 | it("reads silence as permission, but not as authorship", () => { | |
| 1007 | // A node the viewer cannot see comes back null. Updating is a permission, so an unknown | |
| 1008 | // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, | |
| 1009 | // and claiming it for someone who did not is how an author's own rules get handed out. | |
| 1010 | expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ | |
| 1011 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 1012 | canWrite: false, | |
| 1013 | canTriage: false, | |
| 1014 | canUpdate: true, | |
| 1015 | didAuthor: false, | |
| 1016 | }); | |
| 1017 | }); | |
| 1018 | ||
| 1019 | it("reads triage as enough to label, and not enough to write", () => { | |
| 1020 | const access = expectSuccess( | |
| 1021 | decodeViewerPermissionsJson( | |
| 1022 | viewerJson({ | |
| 1023 | viewerPermission: "TRIAGE", | |
| 1024 | pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, | |
| 1025 | }), | |
| 1026 | ), | |
| 1027 | ); | |
| 1028 | expect(access.canTriage).toBe(true); | |
| 1029 | expect(access.canWrite).toBe(false); | |
| 1030 | }); | |
| 1031 | }); | |
| 1032 | ||
| 1033 | describe("label candidate decoding", () => { | |
| 1034 | const labelsJson = (input: { | |
| 1035 | readonly defined: ReadonlyArray<Record<string, unknown>>; | |
| 1036 | readonly applied?: ReadonlyArray<string>; | |
| 1037 | readonly hasNextPage?: boolean; | |
| 1038 | }) => | |
| 1039 | JSON.stringify({ | |
| 1040 | data: { | |
| 1041 | repository: { | |
| 1042 | labels: { | |
| 1043 | pageInfo: { hasNextPage: input.hasNextPage ?? false }, | |
| 1044 | nodes: input.defined, | |
| 1045 | }, | |
| 1046 | pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, | |
| 1047 | }, | |
| 1048 | }, | |
| 1049 | }); | |
| 1050 | ||
| 1051 | it("marks the labels the pull request already wears", () => { | |
| 1052 | const list = expectSuccess( | |
| 1053 | decodeLabelCandidatesJson( | |
| 1054 | labelsJson({ | |
| 1055 | defined: [ | |
| 1056 | { name: "bug", color: "d73a4a", description: "Something is broken" }, | |
| 1057 | { name: "size:XL", color: "e4572e", description: null }, | |
| 1058 | ], | |
| 1059 | applied: ["size:XL"], | |
| 1060 | }), | |
| 1061 | ), | |
| 1062 | ); | |
| 1063 | expect(list.candidates).toEqual([ | |
| 1064 | { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, | |
| 1065 | { name: "size:XL", color: "e4572e", description: null, isApplied: true }, | |
| 1066 | ]); | |
| 1067 | expect(list.truncated).toBe(false); | |
| 1068 | }); | |
| 1069 | ||
| 1070 | it("keeps a worn label the repository no longer defines, so it can be taken off", () => { | |
| 1071 | const list = expectSuccess( | |
| 1072 | decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), | |
| 1073 | ); | |
| 1074 | expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ | |
| 1075 | ["legacy", true], | |
| 1076 | ["bug", false], | |
| 1077 | ]); | |
| 1078 | }); | |
| 1079 | ||
| 1080 | it("says so when the repository defines more labels than the read asked for", () => { | |
| 1081 | expect( | |
| 1082 | expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) | |
| 1083 | .truncated, | |
| 1084 | ).toBe(true); | |
| 1085 | }); | |
| 1086 | }); | |
| 1087 | ||
| 1088 | describe("review thread decoding", () => { | |
| 1089 | const threadsJson = ( | |
| 1090 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 1091 | pullRequest: Record<string, unknown> = {}, | |
| 1092 | ) => | |
| 1093 | JSON.stringify({ | |
| 1094 | data: { | |
| 1095 | repository: { | |
| 1096 | pullRequest: { | |
| 1097 | reviewThreads: { totalCount: nodes.length, nodes }, | |
| 1098 | author: null, | |
| 1099 | comments: { nodes: [] }, | |
| 1100 | reviewRequests: { nodes: [] }, | |
| 1101 | latestReviews: { nodes: [] }, | |
| 1102 | ...pullRequest, | |
| 1103 | }, | |
| 1104 | }, | |
| 1105 | }, | |
| 1106 | }); | |
| 1107 | ||
| 1108 | it("carries what the reader may do with the pull request, off the conversation read", () => { | |
| 1109 | // The same response the threads arrive in, so knowing this costs no request of its own. | |
| 1110 | expect( | |
| 1111 | expectSuccess( | |
| 1112 | decodeReviewThreadsJson( | |
| 1113 | threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), | |
| 1114 | ), | |
| 1115 | ).viewer, | |
| 1116 | ).toEqual({ canUpdate: false, didAuthor: false }); | |
| 1117 | expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ | |
| 1118 | canUpdate: true, | |
| 1119 | didAuthor: false, | |
| 1120 | }); | |
| 1121 | }); | |
| 1122 | ||
| 1123 | const comment = (id: string, body: string) => ({ | |
| 1124 | id, | |
| 1125 | author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, | |
| 1126 | body, | |
| 1127 | createdAt: "2026-07-01T00:00:00Z", | |
| 1128 | url: `https://github.com/acme/web/pull/1#discussion_r${id}`, | |
| 1129 | }); | |
| 1130 | ||
| 1131 | it("anchors a thread to its line and side, keeping the whole conversation", () => { | |
| 1132 | const reviewThreads = expectSuccess( | |
| 1133 | decodeReviewThreadsJson( | |
| 1134 | threadsJson([ | |
| 1135 | { | |
| 1136 | id: "PRRT_1", | |
| 1137 | isResolved: false, | |
| 1138 | isOutdated: false, | |
| 1139 | path: "src/a.ts", | |
| 1140 | line: 42, | |
| 1141 | diffSide: "LEFT", | |
| 1142 | comments: { | |
| 1143 | totalCount: 2, | |
| 1144 | nodes: [ | |
| 1145 | { ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" }, | |
| 1146 | comment("c2", "second"), | |
| 1147 | ], | |
| 1148 | }, | |
| 1149 | }, | |
| 1150 | ]), | |
| 1151 | ), | |
| 1152 | ); | |
| 1153 | expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ | |
| 1154 | { | |
| 1155 | id: "PRRT_1", | |
| 1156 | path: "src/a.ts", | |
| 1157 | line: 42, | |
| 1158 | side: "left", | |
| 1159 | isResolved: false, | |
| 1160 | isOutdated: false, | |
| 1161 | comments: [ | |
| 1162 | { | |
| 1163 | id: "c1", | |
| 1164 | author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, | |
| 1165 | body: "first", | |
| 1166 | createdAt: "2026-07-01T00:00:00Z", | |
| 1167 | editedAt: "2026-07-02T00:00:00Z", | |
| 1168 | url: "https://github.com/acme/web/pull/1#discussion_rc1", | |
| 1169 | reactions: [], | |
| 1170 | }, | |
| 1171 | { | |
| 1172 | id: "c2", | |
| 1173 | author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, | |
| 1174 | body: "second", | |
| 1175 | createdAt: "2026-07-01T00:00:00Z", | |
| 1176 | editedAt: null, | |
| 1177 | url: "https://github.com/acme/web/pull/1#discussion_rc2", | |
| 1178 | reactions: [], | |
| 1179 | }, | |
| 1180 | ], | |
| 1181 | }, | |
| 1182 | ]); | |
| 1183 | expect( | |
| 1184 | reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt, | |
| 1185 | ).toBe("2026-07-02T00:00:00Z"); | |
| 1186 | }); | |
| 1187 | ||
| 1188 | it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { | |
| 1189 | const reviewThreads = expectSuccess( | |
| 1190 | decodeReviewThreadsJson( | |
| 1191 | threadsJson([ | |
| 1192 | { | |
| 1193 | id: "PRRT_2", | |
| 1194 | isResolved: true, | |
| 1195 | isOutdated: true, | |
| 1196 | path: "src/a.ts", | |
| 1197 | // GitHub reports no current line once the thread has fallen off the diff. | |
| 1198 | line: null, | |
| 1199 | diffSide: "RIGHT", | |
| 1200 | comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, | |
| 1201 | }, | |
| 1202 | ]), | |
| 1203 | ), | |
| 1204 | ); | |
| 1205 | expect(reviewThreads.threads[0]?.thread).toMatchObject({ | |
| 1206 | line: null, | |
| 1207 | isOutdated: true, | |
| 1208 | isResolved: true, | |
| 1209 | }); | |
| 1210 | }); | |
| 1211 | ||
| 1212 | it("keeps a resolved thread in the conversation as well as against its line", () => { | |
| 1213 | const decoded = expectSuccess( | |
| 1214 | decodeReviewThreadsJson( | |
| 1215 | threadsJson([ | |
| 1216 | { | |
| 1217 | id: "PRRT_3", | |
| 1218 | isResolved: true, | |
| 1219 | path: "src/a.ts", | |
| 1220 | line: 7, | |
| 1221 | diffSide: "RIGHT", | |
| 1222 | comments: { totalCount: 1, nodes: [comment("c4", "done")] }, | |
| 1223 | }, | |
| 1224 | ]), | |
| 1225 | ), | |
| 1226 | ); | |
| 1227 | // A resolved conversation is finished work, not unsaid work: the timeline reads it and the | |
| 1228 | // diff pins it to its line, the same as any other. | |
| 1229 | const threads = decoded.threads.map((entry) => entry.thread); | |
| 1230 | expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); | |
| 1231 | expect(threads).toHaveLength(1); | |
| 1232 | }); | |
| 1233 | ||
| 1234 | it("reads edit times for issue comments and reviews without using reaction updates", () => { | |
| 1235 | const editedAt = "2026-10-02T12:06:00Z"; | |
| 1236 | const result = expectSuccess( | |
| 1237 | decodeReviewThreadsJson( | |
| 1238 | threadsJson([], { | |
| 1239 | comments: { | |
| 1240 | nodes: [ | |
| 1241 | { id: "edited", lastEditedAt: editedAt }, | |
| 1242 | { id: "reaction-only", lastEditedAt: null, updatedAt: editedAt }, | |
| 1243 | ], | |
| 1244 | }, | |
| 1245 | reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] }, | |
| 1246 | }), | |
| 1247 | ), | |
| 1248 | ); | |
| 1249 | expect([...result.editedAtById]).toEqual([ | |
| 1250 | ["edited", editedAt], | |
| 1251 | ["review", editedAt], | |
| 1252 | ]); | |
| 1253 | }); | |
| 1254 | ||
| 1255 | it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { | |
| 1256 | const result = expectSuccess( | |
| 1257 | decodeReviewThreadsJson( | |
| 1258 | threadsJson([], { | |
| 1259 | reactionGroups: [ | |
| 1260 | { | |
| 1261 | content: "HEART", | |
| 1262 | viewerHasReacted: true, | |
| 1263 | reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, | |
| 1264 | }, | |
| 1265 | ], | |
| 1266 | comments: { | |
| 1267 | nodes: [ | |
| 1268 | { | |
| 1269 | id: "c1", | |
| 1270 | reactionGroups: [ | |
| 1271 | { | |
| 1272 | content: "THUMBS_UP", | |
| 1273 | reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, | |
| 1274 | }, | |
| 1275 | ], | |
| 1276 | }, | |
| 1277 | ], | |
| 1278 | }, | |
| 1279 | reviews: { | |
| 1280 | nodes: [ | |
| 1281 | { | |
| 1282 | id: "r1", | |
| 1283 | reactionGroups: [ | |
| 1284 | { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, | |
| 1285 | ], | |
| 1286 | }, | |
| 1287 | ], | |
| 1288 | }, | |
| 1289 | }), | |
| 1290 | ), | |
| 1291 | ); | |
| 1292 | ||
| 1293 | expect(result.reactions).toEqual([ | |
| 1294 | { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, | |
| 1295 | ]); | |
| 1296 | expect([...result.reactionsById]).toEqual([ | |
| 1297 | ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], | |
| 1298 | ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], | |
| 1299 | ]); | |
| 1300 | }); | |
| 1301 | ||
| 1302 | it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { | |
| 1303 | const result = expectSuccess( | |
| 1304 | decodeReviewThreadsJson( | |
| 1305 | JSON.stringify({ | |
| 1306 | data: { | |
| 1307 | viewer: { login: "Bilal" }, | |
| 1308 | repository: { | |
| 1309 | pullRequest: { | |
| 1310 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 1311 | reactionGroups: [ | |
| 1312 | { | |
| 1313 | content: "HEART", | |
| 1314 | viewerHasReacted: true, | |
| 1315 | reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, | |
| 1316 | }, | |
| 1317 | ], | |
| 1318 | }, | |
| 1319 | }, | |
| 1320 | }, | |
| 1321 | }), | |
| 1322 | ), | |
| 1323 | ); | |
| 1324 | ||
| 1325 | expect(result.reactions).toEqual([ | |
| 1326 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | |
| 1327 | ]); | |
| 1328 | }); | |
| 1329 | }); | |
| 1330 | ||
| 1331 | describe("decodePullRequestNodeIdJson", () => { | |
| 1332 | it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { | |
| 1333 | expect( | |
| 1334 | expectSuccess( | |
| 1335 | decodePullRequestNodeIdJson( | |
| 1336 | JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), | |
| 1337 | ), | |
| 1338 | ), | |
| 1339 | ).toBe("PR_kwDOA"); | |
| 1340 | }); | |
| 1341 | }); | |
| 1342 | ||
| 1343 | describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { | |
| 1344 | it("caps the initial query after the 104-point rate-limit regression", () => { | |
| 1345 | const match = REVIEW_THREADS_GRAPHQL_QUERY.match( | |
| 1346 | /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u, | |
| 1347 | ); | |
| 1348 | ||
| 1349 | expect(match).not.toBeNull(); | |
| 1350 | if (match === null) throw new Error("expected review-thread connections"); | |
| 1351 | expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000); | |
| 1352 | }); | |
| 1353 | ||
| 1354 | it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { | |
| 1355 | expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); | |
| 1356 | // The reviews connection is new: only reactions were ever wanted off it. | |
| 1357 | expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); | |
| 1358 | }); | |
| 1359 | }); | |
| 1360 | ||
| 1361 | describe("reviewer candidate decoding", () => { | |
| 1362 | const candidatesJson = (input: { | |
| 1363 | readonly assignable: ReadonlyArray<Record<string, unknown> | null>; | |
| 1364 | readonly requested?: ReadonlyArray<Record<string, unknown> | null>; | |
| 1365 | readonly author?: string; | |
| 1366 | readonly hasNextPage?: boolean; | |
| 1367 | }) => | |
| 1368 | JSON.stringify({ | |
| 1369 | data: { | |
| 1370 | repository: { | |
| 1371 | assignableUsers: { | |
| 1372 | pageInfo: { hasNextPage: input.hasNextPage ?? false }, | |
| 1373 | nodes: input.assignable, | |
| 1374 | }, | |
| 1375 | pullRequest: { | |
| 1376 | author: input.author === undefined ? null : { login: input.author }, | |
| 1377 | reviewRequests: { | |
| 1378 | nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), | |
| 1379 | }, | |
| 1380 | }, | |
| 1381 | }, | |
| 1382 | }, | |
| 1383 | }); | |
| 1384 | ||
| 1385 | it("leaves the author out of the people their own pull request can be sent to", () => { | |
| 1386 | const list = expectSuccess( | |
| 1387 | decodeReviewerCandidatesJson( | |
| 1388 | candidatesJson({ | |
| 1389 | assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], | |
| 1390 | author: "bilal", | |
| 1391 | }), | |
| 1392 | ), | |
| 1393 | ); | |
| 1394 | expect(list.candidates).toEqual([ | |
| 1395 | { | |
| 1396 | id: "octocat", | |
| 1397 | kind: "user", | |
| 1398 | login: "octocat", | |
| 1399 | name: "The Octocat", | |
| 1400 | avatarUrl: null, | |
| 1401 | isRequested: false, | |
| 1402 | }, | |
| 1403 | ]); | |
| 1404 | expect(list.truncated).toBe(false); | |
| 1405 | }); | |
| 1406 | ||
| 1407 | it("marks whoever has already been asked, and leaves the rest to be asked", () => { | |
| 1408 | const list = expectSuccess( | |
| 1409 | decodeReviewerCandidatesJson( | |
| 1410 | candidatesJson({ | |
| 1411 | assignable: [{ login: "octocat" }, { login: "hubot" }], | |
| 1412 | requested: [{ login: "octocat" }], | |
| 1413 | }), | |
| 1414 | ), | |
| 1415 | ); | |
| 1416 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 1417 | ["octocat", true], | |
| 1418 | ["hubot", false], | |
| 1419 | ]); | |
| 1420 | }); | |
| 1421 | ||
| 1422 | it("keeps a requested team apart from the people, so the request can be taken back", () => { | |
| 1423 | // A team is never among the assignable users, and a request that cannot be seen cannot be | |
| 1424 | // undone — so the ones GitHub reports are carried, marked as the teams they are. | |
| 1425 | const list = expectSuccess( | |
| 1426 | decodeReviewerCandidatesJson( | |
| 1427 | candidatesJson({ | |
| 1428 | assignable: [{ login: "octocat" }], | |
| 1429 | requested: [{ slug: "reviewers", name: "Reviewers" }], | |
| 1430 | }), | |
| 1431 | ), | |
| 1432 | ); | |
| 1433 | expect(list.candidates).toEqual([ | |
| 1434 | { | |
| 1435 | id: "reviewers", | |
| 1436 | kind: "team", | |
| 1437 | login: "reviewers", | |
| 1438 | name: "Reviewers", | |
| 1439 | avatarUrl: null, | |
| 1440 | isRequested: true, | |
| 1441 | }, | |
| 1442 | { | |
| 1443 | id: "octocat", | |
| 1444 | kind: "user", | |
| 1445 | login: "octocat", | |
| 1446 | name: null, | |
| 1447 | avatarUrl: null, | |
| 1448 | isRequested: false, | |
| 1449 | }, | |
| 1450 | ]); | |
| 1451 | }); | |
| 1452 | ||
| 1453 | it("says so when the repository has more people than the read asked for", () => { | |
| 1454 | expect( | |
| 1455 | expectSuccess( | |
| 1456 | decodeReviewerCandidatesJson( | |
| 1457 | candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), | |
| 1458 | ), | |
| 1459 | ).truncated, | |
| 1460 | ).toBe(true); | |
| 1461 | }); | |
| 1462 | }); | |
| 1463 | ||
| 1464 | describe("reviewer request payload", () => { | |
| 1465 | it("sends people and teams in the two lists GitHub keeps them in", () => { | |
| 1466 | expect( | |
| 1467 | buildReviewerRequest([ | |
| 1468 | { id: "octocat", kind: "user" }, | |
| 1469 | { id: "reviewers", kind: "team" }, | |
| 1470 | { id: "hubot", kind: "user" }, | |
| 1471 | ]), | |
| 1472 | ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); | |
| 1473 | }); | |
| 1474 | ||
| 1475 | it("sends both lists even where one of them is empty, which is what GitHub reads", () => { | |
| 1476 | expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ | |
| 1477 | reviewers: ["octocat"], | |
| 1478 | team_reviewers: [], | |
| 1479 | }); | |
| 1480 | }); | |
| 1481 | }); | |
| 1482 | ||
| 1483 | describe("review submission payload", () => { | |
| 1484 | it("sends the verdict, the summary and every line comment in one body", () => { | |
| 1485 | const payload = buildReviewSubmission({ | |
| 1486 | verdict: "request-changes", | |
| 1487 | body: "Two things.", | |
| 1488 | comments: [ | |
| 1489 | { | |
| 1490 | path: "src/a.ts", | |
| 1491 | position: { kind: "added", newLine: 12 }, | |
| 1492 | body: "rename this", | |
| 1493 | }, | |
| 1494 | { | |
| 1495 | path: "src/b.ts", | |
| 1496 | position: { kind: "deleted", oldLine: 3 }, | |
| 1497 | body: "why remove?", | |
| 1498 | }, | |
| 1499 | ], | |
| 1500 | }); | |
| 1501 | expect(payload).toEqual({ | |
| 1502 | event: "REQUEST_CHANGES", | |
| 1503 | body: "Two things.", | |
| 1504 | comments: [ | |
| 1505 | { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, | |
| 1506 | { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, | |
| 1507 | ], | |
| 1508 | }); | |
| 1509 | }); | |
| 1510 | ||
| 1511 | it("sends an approval with no words and no comments", () => { | |
| 1512 | expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ | |
| 1513 | event: "APPROVE", | |
| 1514 | body: "", | |
| 1515 | comments: [], | |
| 1516 | }); | |
| 1517 | }); | |
| 1518 | }); | |
| 1519 | ||
| 1520 | describe("decodePullRequestFilesJson", () => { | |
| 1521 | it("quotes literal backslashes without interpreting them as escapes", () => { | |
| 1522 | const result = expectSuccess( | |
| 1523 | decodePullRequestFilesJson( | |
| 1524 | JSON.stringify([ | |
| 1525 | { | |
| 1526 | filename: String.raw`src\notes.ts`, | |
| 1527 | status: "modified", | |
| 1528 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1529 | }, | |
| 1530 | ]), | |
| 1531 | ), | |
| 1532 | ); | |
| 1533 | ||
| 1534 | expect(result.patch).toBe( | |
| 1535 | [ | |
| 1536 | String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, | |
| 1537 | String.raw`--- "a/src\\notes.ts"`, | |
| 1538 | String.raw`+++ "b/src\\notes.ts"`, | |
| 1539 | "@@ -1 +1 @@", | |
| 1540 | "-old", | |
| 1541 | "+new", | |
| 1542 | "", | |
| 1543 | ].join("\n"), | |
| 1544 | ); | |
| 1545 | }); | |
| 1546 | ||
| 1547 | it("preserves spaces and literal backslashes in both rename paths", () => { | |
| 1548 | const result = expectSuccess( | |
| 1549 | decodePullRequestFilesJson( | |
| 1550 | JSON.stringify([ | |
| 1551 | { | |
| 1552 | previous_filename: String.raw` old\name.ts `, | |
| 1553 | filename: String.raw` new\name.ts `, | |
| 1554 | status: "renamed", | |
| 1555 | }, | |
| 1556 | ]), | |
| 1557 | ), | |
| 1558 | ); | |
| 1559 | ||
| 1560 | expect(result.patch).toBe( | |
| 1561 | [ | |
| 1562 | String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, | |
| 1563 | String.raw`rename from " old\\name.ts "`, | |
| 1564 | String.raw`rename to " new\\name.ts "`, | |
| 1565 | String.raw`--- "a/ old\\name.ts "`, | |
| 1566 | String.raw`+++ "b/ new\\name.ts "`, | |
| 1567 | "", | |
| 1568 | ].join("\n"), | |
| 1569 | ); | |
| 1570 | }); | |
| 1571 | ||
| 1572 | it("assembles a unified patch the files API does not return", () => { | |
| 1573 | const result = expectSuccess( | |
| 1574 | decodePullRequestFilesJson( | |
| 1575 | JSON.stringify([ | |
| 1576 | { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, | |
| 1577 | ]), | |
| 1578 | ), | |
| 1579 | ); | |
| 1580 | ||
| 1581 | expect(result.patch).toBe( | |
| 1582 | [ | |
| 1583 | "diff --git a/src/app.ts b/src/app.ts", | |
| 1584 | "--- a/src/app.ts", | |
| 1585 | "+++ b/src/app.ts", | |
| 1586 | "@@ -1 +1 @@", | |
| 1587 | "-old", | |
| 1588 | "+new", | |
| 1589 | "", | |
| 1590 | ].join("\n"), | |
| 1591 | ); | |
| 1592 | expect(result.truncated).toBe(false); | |
| 1593 | expect(result.rawCount).toBe(1); | |
| 1594 | }); | |
| 1595 | ||
| 1596 | it("points an added file at /dev/null on the left and a removed one on the right", () => { | |
| 1597 | const result = expectSuccess( | |
| 1598 | decodePullRequestFilesJson( | |
| 1599 | JSON.stringify([ | |
| 1600 | { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, | |
| 1601 | { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, | |
| 1602 | ]), | |
| 1603 | ), | |
| 1604 | ); | |
| 1605 | ||
| 1606 | expect(result.patch).toBe( | |
| 1607 | [ | |
| 1608 | "diff --git a/src/new.ts b/src/new.ts", | |
| 1609 | "new file mode 100644", | |
| 1610 | "--- /dev/null", | |
| 1611 | "+++ b/src/new.ts", | |
| 1612 | "@@ -0,0 +1 @@", | |
| 1613 | "+hello", | |
| 1614 | "diff --git a/src/gone.ts b/src/gone.ts", | |
| 1615 | "deleted file mode 100644", | |
| 1616 | "--- a/src/gone.ts", | |
| 1617 | "+++ /dev/null", | |
| 1618 | "@@ -1 +0,0 @@", | |
| 1619 | "-bye", | |
| 1620 | "", | |
| 1621 | ].join("\n"), | |
| 1622 | ); | |
| 1623 | }); | |
| 1624 | ||
| 1625 | it("names both paths of a rename, counting its hunks against the old one", () => { | |
| 1626 | const result = expectSuccess( | |
| 1627 | decodePullRequestFilesJson( | |
| 1628 | JSON.stringify([ | |
| 1629 | { | |
| 1630 | filename: "src/new.ts", | |
| 1631 | status: "renamed", | |
| 1632 | previous_filename: "src/old.ts", | |
| 1633 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1634 | }, | |
| 1635 | ]), | |
| 1636 | ), | |
| 1637 | ); | |
| 1638 | ||
| 1639 | expect(result.patch).toBe( | |
| 1640 | [ | |
| 1641 | "diff --git a/src/old.ts b/src/new.ts", | |
| 1642 | "rename from src/old.ts", | |
| 1643 | "rename to src/new.ts", | |
| 1644 | "--- a/src/old.ts", | |
| 1645 | "+++ b/src/new.ts", | |
| 1646 | "@@ -1 +1 @@", | |
| 1647 | "-old", | |
| 1648 | "+new", | |
| 1649 | "", | |
| 1650 | ].join("\n"), | |
| 1651 | ); | |
| 1652 | }); | |
| 1653 | ||
| 1654 | it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { | |
| 1655 | const result = expectSuccess( | |
| 1656 | decodePullRequestFilesJson( | |
| 1657 | JSON.stringify([ | |
| 1658 | // Binary: it changed, and none of it can be shown. | |
| 1659 | { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, | |
| 1660 | { | |
| 1661 | filename: "src/app.ts", | |
| 1662 | status: "modified", | |
| 1663 | additions: 1, | |
| 1664 | deletions: 1, | |
| 1665 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1666 | }, | |
| 1667 | ]), | |
| 1668 | ), | |
| 1669 | ); | |
| 1670 | ||
| 1671 | // Dropping it would take the file out of the change altogether, not just its contents. | |
| 1672 | expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); | |
| 1673 | expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); | |
| 1674 | expect(result.truncated).toBe(true); | |
| 1675 | expect(result.rawCount).toBe(2); | |
| 1676 | }); | |
| 1677 | ||
| 1678 | it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { | |
| 1679 | const result = expectSuccess( | |
| 1680 | decodePullRequestFilesJson( | |
| 1681 | JSON.stringify([ | |
| 1682 | { | |
| 1683 | filename: "src/new.ts", | |
| 1684 | previous_filename: "src/old.ts", | |
| 1685 | status: "renamed", | |
| 1686 | additions: 0, | |
| 1687 | deletions: 0, | |
| 1688 | }, | |
| 1689 | ]), | |
| 1690 | ), | |
| 1691 | ); | |
| 1692 | ||
| 1693 | expect(result.patch).toContain("rename from src/old.ts"); | |
| 1694 | expect(result.truncated).toBe(false); | |
| 1695 | }); | |
| 1696 | }); | |
| 1697 | ||
| 1698 | describe("decodePullRequestFilesViewedJson", () => { | |
| 1699 | const page = ( | |
| 1700 | nodes: ReadonlyArray<unknown>, | |
| 1701 | pageInfo: { hasNextPage: boolean; endCursor: string | null }, | |
| 1702 | ) => | |
| 1703 | JSON.stringify({ | |
| 1704 | data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, | |
| 1705 | }); | |
| 1706 | ||
| 1707 | it("reads each file's state and where the next page carries on", () => { | |
| 1708 | const decoded = decodePullRequestFilesViewedJson( | |
| 1709 | page( | |
| 1710 | [ | |
| 1711 | { path: "src/a.ts", viewerViewedState: "VIEWED" }, | |
| 1712 | { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, | |
| 1713 | { path: "src/c.ts", viewerViewedState: "DISMISSED" }, | |
| 1714 | ], | |
| 1715 | { hasNextPage: true, endCursor: "cursor-2" }, | |
| 1716 | ), | |
| 1717 | ); | |
| 1718 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1719 | if (!Result.isSuccess(decoded)) return; | |
| 1720 | expect(decoded.success).toEqual({ | |
| 1721 | files: [ | |
| 1722 | { path: "src/a.ts", state: "viewed" }, | |
| 1723 | { path: "src/b.ts", state: "unviewed" }, | |
| 1724 | { path: "src/c.ts", state: "dismissed" }, | |
| 1725 | ], | |
| 1726 | nextCursor: "cursor-2", | |
| 1727 | }); | |
| 1728 | }); | |
| 1729 | ||
| 1730 | it("treats a state it has never heard of as unread rather than failing the page", () => { | |
| 1731 | const decoded = decodePullRequestFilesViewedJson( | |
| 1732 | page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { | |
| 1733 | hasNextPage: false, | |
| 1734 | endCursor: null, | |
| 1735 | }), | |
| 1736 | ); | |
| 1737 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1738 | if (!Result.isSuccess(decoded)) return; | |
| 1739 | expect(decoded.success).toEqual({ | |
| 1740 | files: [{ path: "src/a.ts", state: "unviewed" }], | |
| 1741 | nextCursor: null, | |
| 1742 | }); | |
| 1743 | }); | |
| 1744 | ||
| 1745 | it("answers empty for a pull request the host has nothing to say about", () => { | |
| 1746 | const decoded = decodePullRequestFilesViewedJson( | |
| 1747 | JSON.stringify({ data: { repository: { pullRequest: null } } }), | |
| 1748 | ); | |
| 1749 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1750 | if (!Result.isSuccess(decoded)) return; | |
| 1751 | expect(decoded.success).toEqual({ files: [], nextCursor: null }); | |
| 1752 | }); | |
| 1753 | }); | |
| 1754 | ||
| 1755 | describe("buildSetFilesViewedGraphQlMutation", () => { | |
| 1756 | it("asks for nothing when nothing was pressed", () => { | |
| 1757 | expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); | |
| 1758 | }); | |
| 1759 | ||
| 1760 | it("clears and restores in one document, each file under its own alias", () => { | |
| 1761 | const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ | |
| 1762 | { path: "src/a.ts", viewed: true }, | |
| 1763 | { path: "src/b.ts", viewed: false }, | |
| 1764 | ]); | |
| 1765 | expect(mutation).not.toBeNull(); | |
| 1766 | if (mutation === null) return; | |
| 1767 | expect(mutation.query).toContain( | |
| 1768 | "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", | |
| 1769 | ); | |
| 1770 | expect(mutation.query).toContain( | |
| 1771 | "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", | |
| 1772 | ); | |
| 1773 | expect(mutation.query).toContain( | |
| 1774 | "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", | |
| 1775 | ); | |
| 1776 | expect(mutation.variables).toEqual({ | |
| 1777 | pullRequestId: "PR_1", | |
| 1778 | f0_path: "src/a.ts", | |
| 1779 | f1_path: "src/b.ts", | |
| 1780 | }); | |
| 1781 | }); | |
| 1782 | ||
| 1783 | it("keeps a path out of the document, so one cannot be read as part of it", () => { | |
| 1784 | const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ | |
| 1785 | { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, | |
| 1786 | ]); | |
| 1787 | expect(mutation).not.toBeNull(); | |
| 1788 | if (mutation === null) return; | |
| 1789 | expect(mutation.query).not.toContain("evil"); | |
| 1790 | expect(mutation.variables.f0_path).toBe( | |
| 1791 | '") { __typename } evil: markFileAsViewed(input: { path: "x', | |
| 1792 | ); | |
| 1793 | }); | |
| 1794 | }); | |
| 1795 | ||
| 1796 | describe("host-native stack decoding", () => { | |
| 1797 | /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ | |
| 1798 | function stack(overrides: Record<string, unknown> = {}) { | |
| 1799 | return { | |
| 1800 | id: 42, | |
| 1801 | number: 3, | |
| 1802 | node_id: "STK_kwDO", | |
| 1803 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1804 | base: { ref: "main", sha: "abc" }, | |
| 1805 | open: true, | |
| 1806 | created_at: "2026-09-01T00:00:00Z", | |
| 1807 | pull_requests: [ | |
| 1808 | { | |
| 1809 | number: 10, | |
| 1810 | head: { ref: "feat/one" }, | |
| 1811 | state: "closed", | |
| 1812 | merged_at: "2026-09-02T00:00:00Z", | |
| 1813 | }, | |
| 1814 | { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 1815 | { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, | |
| 1816 | ], | |
| 1817 | ...overrides, | |
| 1818 | }; | |
| 1819 | } | |
| 1820 | ||
| 1821 | /** The one stack a listing answered with, which these reads all expect to find. */ | |
| 1822 | function expectStack(overrides: Record<string, unknown> = {}) { | |
| 1823 | const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); | |
| 1824 | if (decoded === null) throw new Error("expected a stack"); | |
| 1825 | return decoded; | |
| 1826 | } | |
| 1827 | ||
| 1828 | it("reads the first stack, bottom to top, with merged_at outranking state", () => { | |
| 1829 | expect(expectStack()).toEqual({ | |
| 1830 | id: "42", | |
| 1831 | number: 3, | |
| 1832 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1833 | base: "main", | |
| 1834 | layers: [ | |
| 1835 | { number: 10, headBranch: "feat/one", state: "merged" }, | |
| 1836 | { number: 11, headBranch: "feat/two", state: "open" }, | |
| 1837 | { number: 12, headBranch: "feat/three", state: "closed" }, | |
| 1838 | ], | |
| 1839 | }); | |
| 1840 | }); | |
| 1841 | ||
| 1842 | it("retains the detailed layer titles, draft state and expected revision", () => { | |
| 1843 | expect( | |
| 1844 | expectStack({ | |
| 1845 | pull_requests: [ | |
| 1846 | { | |
| 1847 | number: 11, | |
| 1848 | title: "Second layer", | |
| 1849 | draft: true, | |
| 1850 | head: { ref: "feat/two", sha: "abc123" }, | |
| 1851 | state: "open", | |
| 1852 | merged_at: null, | |
| 1853 | }, | |
| 1854 | ], | |
| 1855 | }).layers, | |
| 1856 | ).toEqual([ | |
| 1857 | { | |
| 1858 | number: 11, | |
| 1859 | title: "Second layer", | |
| 1860 | isDraft: true, | |
| 1861 | headSha: "abc123", | |
| 1862 | headBranch: "feat/two", | |
| 1863 | state: "open", | |
| 1864 | }, | |
| 1865 | ]); | |
| 1866 | }); | |
| 1867 | ||
| 1868 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { | |
| 1869 | expect(expectStack({ base: "develop" }).base).toBe("develop"); | |
| 1870 | }); | |
| 1871 | ||
| 1872 | it("prefers the page a person opens over the API URL, where the host reports one", () => { | |
| 1873 | expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( | |
| 1874 | "https://github.com/acme/web/stacks/3", | |
| 1875 | ); | |
| 1876 | }); | |
| 1877 | ||
| 1878 | it("falls back to the node id, then the number, for a stack without an id", () => { | |
| 1879 | expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); | |
| 1880 | expect(expectStack({ id: null, node_id: null }).id).toBe("3"); | |
| 1881 | }); | |
| 1882 | ||
| 1883 | it("reads an empty listing as not stacked", () => { | |
| 1884 | expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); | |
| 1885 | }); | |
| 1886 | ||
| 1887 | it("refuses a stack without a number or without its pull requests", () => { | |
| 1888 | expect( | |
| 1889 | Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), | |
| 1890 | ).toBe(false); | |
| 1891 | expect( | |
| 1892 | Result.isSuccess( | |
| 1893 | decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), | |
| 1894 | ), | |
| 1895 | ).toBe(false); | |
| 1896 | expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); | |
| 1897 | }); | |
| 1898 | }); | |
| 1899 | ||
| 1900 | describe("pull request stack membership batches", () => { | |
| 1901 | it("maps aliases while skipping missing pull requests and incomplete memberships", () => { | |
| 1902 | const memberships = expectSuccess( | |
| 1903 | decodePullRequestStackMembershipsJson( | |
| 1904 | JSON.stringify({ | |
| 1905 | data: { | |
| 1906 | s0: { | |
| 1907 | pullRequest: { | |
| 1908 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1909 | stackEntry: { position: 1 }, | |
| 1910 | }, | |
| 1911 | }, | |
| 1912 | s1: null, | |
| 1913 | s2: { pullRequest: null }, | |
| 1914 | s3: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1915 | s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, | |
| 1916 | }, | |
| 1917 | }), | |
| 1918 | ), | |
| 1919 | ); | |
| 1920 | expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); | |
| 1921 | }); | |
| 1922 | ||
| 1923 | it("refuses malformed responses and unsafe query selectors", () => { | |
| 1924 | expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); | |
| 1925 | expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); | |
| 1926 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); | |
| 1927 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); | |
| 1928 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); | |
| 1929 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ | |
| 1930 | s0_owner: "acme", | |
| 1931 | s0_name: "web", | |
| 1932 | s0_number: 7, | |
| 1933 | s1_owner: "acme", | |
| 1934 | s1_name: "web", | |
| 1935 | s1_number: 8, | |
| 1936 | }); | |
| 1937 | }); | |
| 1938 | }); | |
| 1939 | ||
| 1940 | describe("batched pull request summaries", () => { | |
| 1941 | it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { | |
| 1942 | expect( | |
| 1943 | buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), | |
| 1944 | ).toBeNull(); | |
| 1945 | expect( | |
| 1946 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), | |
| 1947 | ).toBeNull(); | |
| 1948 | expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); | |
| 1949 | }); | |
| 1950 | ||
| 1951 | it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { | |
| 1952 | const decoded = decodePullRequestSummariesJson( | |
| 1953 | JSON.stringify({ | |
| 1954 | data: { | |
| 1955 | s0: { | |
| 1956 | pullRequest: { | |
| 1957 | number: 7, | |
| 1958 | title: "Merged", | |
| 1959 | url: "https://github.com/acme/web/pull/7", | |
| 1960 | author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, | |
| 1961 | headRefName: "feat/seven", | |
| 1962 | baseRefName: "main", | |
| 1963 | state: "MERGED", | |
| 1964 | mergedAt: "2026-08-24T00:00:00Z", | |
| 1965 | closedAt: "2026-08-24T00:00:00Z", | |
| 1966 | updatedAt: "2026-08-24T00:00:00Z", | |
| 1967 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, | |
| 1968 | }, | |
| 1969 | }, | |
| 1970 | s1: { pullRequest: null }, | |
| 1971 | s2: { pullRequest: { number: 9 } }, | |
| 1972 | rateLimit: { cost: 1 }, | |
| 1973 | }, | |
| 1974 | }), | |
| 1975 | ); | |
| 1976 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1977 | if (!Result.isSuccess(decoded)) return; | |
| 1978 | expect([...decoded.success.keys()]).toEqual([0]); | |
| 1979 | expect(decoded.success.get(0)).toMatchObject({ | |
| 1980 | number: 7, | |
| 1981 | state: "merged", | |
| 1982 | mergedAt: "2026-08-24T00:00:00Z", | |
| 1983 | author: { login: "renovate", isBot: true }, | |
| 1984 | checksState: "failing", | |
| 1985 | mergeability: "unknown", | |
| 1986 | additions: 0, | |
| 1987 | }); | |
| 1988 | // The document did not ask about stacks, so the summary does not claim an answer. | |
| 1989 | expect(decoded.success.get(0)).not.toHaveProperty("stack"); | |
| 1990 | }); | |
| 1991 | ||
| 1992 | it("reads stack membership only where the document asked for it", () => { | |
| 1993 | expect( | |
| 1994 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, | |
| 1995 | ).toContain("stack { number size baseRefName } stackEntry { position }"); | |
| 1996 | expect( | |
| 1997 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, | |
| 1998 | ).not.toContain("stack {"); | |
| 1999 | const pullRequest = (number: number, stack: Record<string, unknown>) => ({ | |
| 2000 | pullRequest: { | |
| 2001 | number, | |
| 2002 | title: "Stacked", | |
| 2003 | url: `https://github.com/acme/web/pull/${number}`, | |
| 2004 | headRefName: `feat/${number}`, | |
| 2005 | baseRefName: "main", | |
| 2006 | state: "OPEN", | |
| 2007 | updatedAt: "2026-08-24T00:00:00Z", | |
| 2008 | ...stack, | |
| 2009 | }, | |
| 2010 | }); | |
| 2011 | const decoded = expectSuccess( | |
| 2012 | decodePullRequestSummariesJson( | |
| 2013 | JSON.stringify({ | |
| 2014 | data: { | |
| 2015 | s0: pullRequest(7, { stack: null, stackEntry: null }), | |
| 2016 | s1: pullRequest(8, { | |
| 2017 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 2018 | stackEntry: { position: 2 }, | |
| 2019 | }), | |
| 2020 | }, | |
| 2021 | }), | |
| 2022 | ), | |
| 2023 | ); | |
| 2024 | expect(decoded.get(0)?.stack).toBeNull(); | |
| 2025 | expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 }); | |
| 2026 | }); | |
| 2027 | }); | |
| 2028 | ||
| 2029 | describe("pull request watch fingerprints", () => { | |
| 2030 | it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { | |
| 2031 | const document = buildPullRequestWatchFingerprintsGraphQlQuery([ | |
| 2032 | { repository: "pingdotgg/t3code", number: 7 }, | |
| 2033 | { repository: "pingdotgg/lakebed", number: 8 }, | |
| 2034 | ]); | |
| 2035 | expect(document?.query).toContain( | |
| 2036 | "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", | |
| 2037 | ); | |
| 2038 | expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); | |
| 2039 | expect(document?.variables).toEqual({ | |
| 2040 | w0_owner: "pingdotgg", | |
| 2041 | w0_name: "t3code", | |
| 2042 | w0_number: 7, | |
| 2043 | w1_owner: "pingdotgg", | |
| 2044 | w1_name: "lakebed", | |
| 2045 | w1_number: 8, | |
| 2046 | }); | |
| 2047 | expect( | |
| 2048 | buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), | |
| 2049 | ).toBeNull(); | |
| 2050 | }); | |
| 2051 | ||
| 2052 | it("moves status with checks and remarks with comments, each without the other", () => { | |
| 2053 | const pullRequest = (input: { | |
| 2054 | readonly running: number; | |
| 2055 | readonly failed: number; | |
| 2056 | readonly commentEditedAt: string | null; | |
| 2057 | readonly reviews: number; | |
| 2058 | }) => ({ | |
| 2059 | state: "OPEN", | |
| 2060 | mergeable: "MERGEABLE", | |
| 2061 | headRefOid: "abc123", | |
| 2062 | comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] }, | |
| 2063 | reviews: { totalCount: input.reviews, nodes: [] }, | |
| 2064 | reviewThreads: { totalCount: 0 }, | |
| 2065 | commits: { | |
| 2066 | nodes: [ | |
| 2067 | { | |
| 2068 | commit: { | |
| 2069 | statusCheckRollup: { | |
| 2070 | contexts: { | |
| 2071 | checkRunCountsByState: [ | |
| 2072 | { state: "IN_PROGRESS", count: input.running }, | |
| 2073 | { state: "FAILURE", count: input.failed }, | |
| 2074 | { state: "SUCCESS", count: 0 }, | |
| 2075 | ], | |
| 2076 | statusContextCountsByState: null, | |
| 2077 | }, | |
| 2078 | }, | |
| 2079 | }, | |
| 2080 | }, | |
| 2081 | ], | |
| 2082 | }, | |
| 2083 | }); | |
| 2084 | const decode = (...pullRequests: ReadonlyArray<object | null>) => { | |
| 2085 | const decoded = decodePullRequestWatchFingerprintsJson( | |
| 2086 | JSON.stringify({ | |
| 2087 | data: Object.fromEntries( | |
| 2088 | pullRequests.map((value, index) => [`w${index}`, { pullRequest: value }]), | |
| 2089 | ), | |
| 2090 | }), | |
| 2091 | ); | |
| 2092 | if (!Result.isSuccess(decoded)) throw new Error("fingerprints did not decode"); | |
| 2093 | return decoded.success; | |
| 2094 | }; | |
| 2095 | const base = { running: 2, failed: 0, commentEditedAt: null, reviews: 1 }; | |
| 2096 | const fingerprint = (input: Parameters<typeof pullRequest>[0]) => | |
| 2097 | decode(pullRequest(input)).get(0)!; | |
| 2098 | const before = fingerprint(base); | |
| 2099 | const checkFinished = fingerprint({ ...base, running: 1, failed: 1 }); | |
| 2100 | const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" }); | |
| 2101 | const replied = fingerprint({ ...base, reviews: 2 }); | |
| 2102 | ||
| 2103 | expect(checkFinished.status).not.toBe(before.status); | |
| 2104 | expect(checkFinished.remarks).toBe(before.remarks); | |
| 2105 | expect(summaryEdited.remarks).not.toBe(before.remarks); | |
| 2106 | expect(summaryEdited.status).toBe(before.status); | |
| 2107 | expect(replied.remarks).not.toBe(before.remarks); | |
| 2108 | // A pull request GitHub had no answer for is left for a full read. | |
| 2109 | expect(decode(null, pullRequest(base)).has(0)).toBe(false); | |
| 2110 | }); | |
| 2111 | }); |
apps/server/src/pullRequest/gitHubPullRequestJson.ts deleted-3462| ... | ... | @@ -1,3462 +0,0 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Result from "effect/Result"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { | |
| 6 | PullRequestStackMembership, | |
| 7 | PullRequestActor, | |
| 8 | PullRequestPreview, | |
| 9 | PullRequestCheck, | |
| 10 | PullRequestCheckStatus, | |
| 11 | PullRequestChecksState, | |
| 12 | PullRequestComment, | |
| 13 | PullRequestCommit, | |
| 14 | PullRequestFileViewedState, | |
| 15 | PullRequestLabel, | |
| 16 | PullRequestMergeCapabilities, | |
| 17 | PullRequestMergeMethod, | |
| 18 | PullRequestOmittedFileStat, | |
| 19 | PullRequestMergeability, | |
| 20 | PullRequestReaction, | |
| 21 | PullRequestReactionContent, | |
| 22 | PullRequestReviewCommentDraft, | |
| 23 | PullRequestReviewDecision, | |
| 24 | PullRequestReviewPosition, | |
| 25 | PullRequestReviewThread, | |
| 26 | PullRequestReviewVerdict, | |
| 27 | PullRequestReviewerCandidate, | |
| 28 | PullRequestReviewerCandidateList, | |
| 29 | PullRequestReviewerKind, | |
| 30 | PullRequestLabelCandidate, | |
| 31 | PullRequestLabelCandidateList, | |
| 32 | PullRequestState, | |
| 33 | PullRequestThreadComment, | |
| 34 | } from "@t3tools/contracts"; | |
| 35 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 36 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 37 | ||
| 38 | import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; | |
| 39 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | |
| 40 | ||
| 41 | /** | |
| 42 | * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` | |
| 43 | * release that adds a conclusion or a review state must not fail the whole payload. | |
| 44 | */ | |
| 45 | const RawActorSchema = Schema.Struct({ | |
| 46 | __typename: Schema.optional(Schema.String), | |
| 47 | is_bot: Schema.optional(Schema.Boolean), | |
| 48 | /** | |
| 49 | * Optional because a review can be requested from a team or a mannequin, which the query has | |
| 50 | * no fragment for and GraphQL answers with an empty object. A reviewer with no login names | |
| 51 | * nobody to show, and must not fail the response the conversation travels in. | |
| 52 | */ | |
| 53 | login: Schema.optional(Schema.String), | |
| 54 | /** The node id, which is how a listing's authors are resolved to avatars in one request. */ | |
| 55 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 56 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 57 | /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ | |
| 58 | avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 59 | }); | |
| 60 | ||
| 61 | const RawLabelSchema = Schema.Struct({ | |
| 62 | name: Schema.String, | |
| 63 | color: Schema.optional(Schema.NullOr(Schema.String)), | |
| 64 | }); | |
| 65 | ||
| 66 | const RawReviewRequestSchema = Schema.Struct({ | |
| 67 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 68 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 69 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 70 | }); | |
| 71 | ||
| 72 | /** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ | |
| 73 | const RawLatestReviewSchema = Schema.Struct({ | |
| 74 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 75 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 76 | }); | |
| 77 | ||
| 78 | const RawCheckSchema = Schema.Struct({ | |
| 79 | __typename: Schema.optional(Schema.String), | |
| 80 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 81 | context: Schema.optional(Schema.NullOr(Schema.String)), | |
| 82 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 83 | conclusion: Schema.optional(Schema.NullOr(Schema.String)), | |
| 84 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 85 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 86 | detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 87 | targetUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 88 | /** | |
| 89 | * What tells two same-named checks apart, and which run of one is the newest. All three ride | |
| 90 | * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them | |
| 91 | * costs no request. Empty for an app-provided check run, which belongs to no workflow, and | |
| 92 | * absent entirely on a commit status, which is not a run at all. | |
| 93 | */ | |
| 94 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 95 | startedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 96 | completedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 97 | /** Branch protection requires this check; read by the detail query on github.com only. */ | |
| 98 | isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 99 | }); | |
| 100 | ||
| 101 | const RawListItemSchema = Schema.Struct({ | |
| 102 | number: Schema.Int, | |
| 103 | title: Schema.String, | |
| 104 | url: Schema.String, | |
| 105 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 106 | headRefName: Schema.String, | |
| 107 | baseRefName: Schema.String, | |
| 108 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 109 | isDraft: Schema.optional(Schema.Boolean), | |
| 110 | mergeable: Schema.optional(Schema.NullOr(Schema.String)), | |
| 111 | reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), | |
| 112 | additions: Schema.optional(Schema.Int), | |
| 113 | deletions: Schema.optional(Schema.Int), | |
| 114 | createdAt: Schema.String, | |
| 115 | updatedAt: Schema.String, | |
| 116 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 117 | reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), | |
| 118 | latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), | |
| 119 | labels: Schema.optional(Schema.Array(RawLabelSchema)), | |
| 120 | /** | |
| 121 | * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there | |
| 122 | * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs | |
| 123 | * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing | |
| 124 | * reduces to one word. The listing pays it because the alternative is a request per row; the | |
| 125 | * cross-repository search below asks GitHub for the verdict itself instead. | |
| 126 | */ | |
| 127 | statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), | |
| 128 | }); | |
| 129 | ||
| 130 | const RawStackMembershipSchema = Schema.Struct({ | |
| 131 | stack: Schema.optional( | |
| 132 | Schema.NullOr( | |
| 133 | Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), | |
| 134 | ), | |
| 135 | ), | |
| 136 | stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), | |
| 137 | }); | |
| 138 | ||
| 139 | /** | |
| 140 | * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` | |
| 141 | * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional | |
| 142 | * because a node that is not a pull request decodes as an empty object, which is skipped. | |
| 143 | */ | |
| 144 | const RawSearchItemSchema = Schema.Struct({ | |
| 145 | ...RawStackMembershipSchema.fields, | |
| 146 | number: Schema.Int, | |
| 147 | title: Schema.String, | |
| 148 | url: Schema.String, | |
| 149 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 150 | headRefName: Schema.String, | |
| 151 | baseRefName: Schema.String, | |
| 152 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 153 | isDraft: Schema.optional(Schema.Boolean), | |
| 154 | mergeable: Schema.optional(Schema.NullOr(Schema.String)), | |
| 155 | reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), | |
| 156 | latestReviews: Schema.optional( | |
| 157 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), | |
| 158 | ), | |
| 159 | /** Asked for by the per-repository listing, and left out of the cross-repository search. */ | |
| 160 | additions: Schema.optional(Schema.Int), | |
| 161 | deletions: Schema.optional(Schema.Int), | |
| 162 | createdAt: Schema.String, | |
| 163 | updatedAt: Schema.String, | |
| 164 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 165 | repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), | |
| 166 | reviewRequests: Schema.optional( | |
| 167 | Schema.NullOr( | |
| 168 | Schema.Struct({ | |
| 169 | nodes: Schema.optional( | |
| 170 | Schema.NullOr( | |
| 171 | Schema.Array( | |
| 172 | Schema.NullOr( | |
| 173 | Schema.Struct({ | |
| 174 | requestedReviewer: Schema.optional( | |
| 175 | Schema.NullOr( | |
| 176 | Schema.Struct({ | |
| 177 | ...RawActorSchema.fields, | |
| 178 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 179 | }), | |
| 180 | ), | |
| 181 | ), | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | ), | |
| 186 | ), | |
| 187 | }), | |
| 188 | ), | |
| 189 | ), | |
| 190 | labels: Schema.optional( | |
| 191 | Schema.NullOr( | |
| 192 | Schema.Struct({ | |
| 193 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), | |
| 194 | }), | |
| 195 | ), | |
| 196 | ), | |
| 197 | /** | |
| 198 | * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather | |
| 199 | * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across | |
| 200 | * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. | |
| 201 | */ | |
| 202 | commits: Schema.optional( | |
| 203 | Schema.NullOr( | |
| 204 | Schema.Struct({ | |
| 205 | nodes: Schema.optional( | |
| 206 | Schema.NullOr( | |
| 207 | Schema.Array( | |
| 208 | Schema.NullOr( | |
| 209 | Schema.Struct({ | |
| 210 | commit: Schema.optional( | |
| 211 | Schema.NullOr( | |
| 212 | Schema.Struct({ | |
| 213 | statusCheckRollup: Schema.optional( | |
| 214 | Schema.NullOr(Schema.Struct({ state: Schema.String })), | |
| 215 | ), | |
| 216 | }), | |
| 217 | ), | |
| 218 | ), | |
| 219 | }), | |
| 220 | ), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ), | |
| 224 | }), | |
| 225 | ), | |
| 226 | ), | |
| 227 | }); | |
| 228 | ||
| 229 | const RawRowConnectionSchema = Schema.Struct({ | |
| 230 | pageInfo: Schema.optional( | |
| 231 | Schema.NullOr( | |
| 232 | Schema.Struct({ | |
| 233 | hasNextPage: Schema.Boolean, | |
| 234 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 235 | }), | |
| 236 | ), | |
| 237 | ), | |
| 238 | // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped | |
| 239 | // rather than blanking every repository at once. | |
| 240 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), | |
| 241 | }); | |
| 242 | ||
| 243 | const RawSearchSchema = Schema.Struct({ | |
| 244 | data: Schema.Struct({ search: RawRowConnectionSchema }), | |
| 245 | }); | |
| 246 | ||
| 247 | const RawRepositoryPullRequestsSchema = Schema.Struct({ | |
| 248 | data: Schema.Struct({ | |
| 249 | repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), | |
| 250 | }), | |
| 251 | }); | |
| 252 | ||
| 253 | /** One aliased lookup per row, so the response is keyed by the position it was asked in. */ | |
| 254 | const RawStatsSchema = Schema.Struct({ | |
| 255 | data: Schema.optional( | |
| 256 | Schema.NullOr( | |
| 257 | Schema.Record( | |
| 258 | Schema.String, | |
| 259 | Schema.NullOr( | |
| 260 | Schema.Struct({ | |
| 261 | pullRequest: Schema.optional( | |
| 262 | Schema.NullOr( | |
| 263 | Schema.Struct({ | |
| 264 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 265 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 266 | }), | |
| 267 | ), | |
| 268 | ), | |
| 269 | }), | |
| 270 | ), | |
| 271 | ), | |
| 272 | ), | |
| 273 | ), | |
| 274 | }); | |
| 275 | ||
| 276 | const RawStackMembershipsSchema = Schema.Struct({ | |
| 277 | data: Schema.Record( | |
| 278 | Schema.String, | |
| 279 | Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), | |
| 280 | ), | |
| 281 | }); | |
| 282 | ||
| 283 | /** How many of a reaction's people the hover names before it counts the rest. */ | |
| 284 | const REACTORS_PER_GROUP = 10; | |
| 285 | ||
| 286 | /** | |
| 287 | * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: | |
| 288 | * a hover says who reacted, and a hundred and forty names is a count, not a sentence. | |
| 289 | */ | |
| 290 | const REACTION_GROUPS_FIELDS = `reactionGroups { | |
| 291 | content | |
| 292 | viewerHasReacted | |
| 293 | reactors(first: ${REACTORS_PER_GROUP}) { | |
| 294 | totalCount | |
| 295 | nodes { | |
| 296 | ... on User { login } | |
| 297 | ... on Bot { login } | |
| 298 | ... on Organization { login } | |
| 299 | ... on Mannequin { login } | |
| 300 | } | |
| 301 | } | |
| 302 | }`; | |
| 303 | ||
| 304 | /** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ | |
| 305 | const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = { | |
| 306 | THUMBS_UP: "thumbs-up", | |
| 307 | THUMBS_DOWN: "thumbs-down", | |
| 308 | LAUGH: "laugh", | |
| 309 | HOORAY: "hooray", | |
| 310 | CONFUSED: "confused", | |
| 311 | HEART: "heart", | |
| 312 | ROCKET: "rocket", | |
| 313 | EYES: "eyes", | |
| 314 | }; | |
| 315 | ||
| 316 | const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { | |
| 317 | "thumbs-up": "THUMBS_UP", | |
| 318 | "thumbs-down": "THUMBS_DOWN", | |
| 319 | laugh: "LAUGH", | |
| 320 | hooray: "HOORAY", | |
| 321 | confused: "CONFUSED", | |
| 322 | heart: "HEART", | |
| 323 | rocket: "ROCKET", | |
| 324 | eyes: "EYES", | |
| 325 | }; | |
| 326 | ||
| 327 | export function gitHubReactionContent(content: PullRequestReactionContent): string { | |
| 328 | return GITHUB_REACTION_BY_CONTENT[content]; | |
| 329 | } | |
| 330 | ||
| 331 | const RawReactionGroupsSchema = Schema.optional( | |
| 332 | Schema.NullOr( | |
| 333 | Schema.Array( | |
| 334 | Schema.Struct({ | |
| 335 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 336 | viewerHasReacted: Schema.optional(Schema.Boolean), | |
| 337 | reactors: Schema.optional( | |
| 338 | Schema.NullOr( | |
| 339 | Schema.Struct({ | |
| 340 | totalCount: Schema.optional(Schema.Int), | |
| 341 | nodes: Schema.optional( | |
| 342 | Schema.NullOr( | |
| 343 | Schema.Array( | |
| 344 | Schema.NullOr( | |
| 345 | Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 346 | ), | |
| 347 | ), | |
| 348 | ), | |
| 349 | ), | |
| 350 | }), | |
| 351 | ), | |
| 352 | ), | |
| 353 | }), | |
| 354 | ), | |
| 355 | ), | |
| 356 | ); | |
| 357 | ||
| 358 | type RawReactionGroups = typeof RawReactionGroupsSchema.Type; | |
| 359 | ||
| 360 | /** | |
| 361 | * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is | |
| 362 | * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The | |
| 363 | * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it | |
| 364 | * in would name them twice — but `count` still counts them along with everyone else. | |
| 365 | */ | |
| 366 | function toReactions( | |
| 367 | groups: RawReactionGroups, | |
| 368 | viewer: string | null, | |
| 369 | ): ReadonlyArray<PullRequestReaction> { | |
| 370 | const normalizedViewer = viewer?.toLowerCase() ?? null; | |
| 371 | const reactions: PullRequestReaction[] = []; | |
| 372 | for (const group of groups ?? []) { | |
| 373 | const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; | |
| 374 | if (content === undefined) continue; | |
| 375 | const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); | |
| 376 | const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); | |
| 377 | if (count <= 0) continue; | |
| 378 | const actors = | |
| 379 | normalizedViewer === null | |
| 380 | ? logins | |
| 381 | : logins.filter((login) => login.toLowerCase() !== normalizedViewer); | |
| 382 | reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); | |
| 383 | } | |
| 384 | return reactions; | |
| 385 | } | |
| 386 | ||
| 387 | const RawCommentSchema = Schema.Struct({ | |
| 388 | id: Schema.String, | |
| 389 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 390 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 391 | body: Schema.optional(Schema.String), | |
| 392 | createdAt: Schema.String, | |
| 393 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 394 | /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ | |
| 395 | reactionGroups: RawReactionGroupsSchema, | |
| 396 | }); | |
| 397 | ||
| 398 | const RawReviewSchema = Schema.Struct({ | |
| 399 | id: Schema.String, | |
| 400 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 401 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 402 | body: Schema.optional(Schema.String), | |
| 403 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 404 | submittedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 405 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 406 | }); | |
| 407 | ||
| 408 | const RawCommitSchema = Schema.Struct({ | |
| 409 | oid: Schema.String, | |
| 410 | messageHeadline: Schema.optional(Schema.String), | |
| 411 | committedDate: Schema.String, | |
| 412 | authors: Schema.optional( | |
| 413 | Schema.Array( | |
| 414 | Schema.Struct({ | |
| 415 | email: Schema.optional(Schema.NullOr(Schema.String)), | |
| 416 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 417 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 418 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 419 | }), | |
| 420 | ), | |
| 421 | ), | |
| 422 | }); | |
| 423 | ||
| 424 | const RawDetailSchema = Schema.Struct({ | |
| 425 | ...RawListItemSchema.fields, | |
| 426 | /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ | |
| 427 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 428 | /** Names the fork a pull request came from, which is what qualifies its head ref. */ | |
| 429 | headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), | |
| 430 | /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ | |
| 431 | headRefOid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 432 | body: Schema.optional(Schema.String), | |
| 433 | changedFiles: Schema.optional(Schema.Int), | |
| 434 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 435 | /** The standing instruction and strategy GitHub will use once its requirements are met. */ | |
| 436 | autoMergeRequest: Schema.optional( | |
| 437 | Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 438 | ), | |
| 439 | }); | |
| 440 | ||
| 441 | /** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ | |
| 442 | const RawWorkflowRunsSchema = Schema.Struct({ | |
| 443 | workflow_runs: Schema.Array( | |
| 444 | Schema.Struct({ | |
| 445 | id: Schema.Int, | |
| 446 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 447 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 448 | }), | |
| 449 | ), | |
| 450 | }); | |
| 451 | ||
| 452 | const RawPullRequestHeadsSchema = Schema.Struct({ | |
| 453 | data: Schema.Struct({ | |
| 454 | repository: Schema.NullOr( | |
| 455 | Schema.Struct({ | |
| 456 | pullRequests: Schema.Struct({ | |
| 457 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | |
| 458 | nodes: Schema.Array( | |
| 459 | Schema.Struct({ | |
| 460 | number: Schema.Int, | |
| 461 | headRefOid: Schema.String, | |
| 462 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 463 | headRepositoryOwner: Schema.optional( | |
| 464 | Schema.NullOr(Schema.Struct({ login: Schema.String })), | |
| 465 | ), | |
| 466 | }), | |
| 467 | ), | |
| 468 | }), | |
| 469 | }), | |
| 470 | ), | |
| 471 | }), | |
| 472 | }); | |
| 473 | ||
| 474 | const RawActivityConnection = <S extends Schema.Top>(node: S) => | |
| 475 | Schema.optional( | |
| 476 | Schema.NullOr( | |
| 477 | Schema.Struct({ | |
| 478 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | |
| 479 | nodes: Schema.Array(node), | |
| 480 | }), | |
| 481 | ), | |
| 482 | ); | |
| 483 | ||
| 484 | const RawActivitySchema = Schema.Struct({ | |
| 485 | data: Schema.Struct({ | |
| 486 | repository: Schema.Struct({ | |
| 487 | pullRequest: Schema.Struct({ | |
| 488 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 489 | comments: RawActivityConnection(RawCommentSchema), | |
| 490 | reviews: RawActivityConnection(RawReviewSchema), | |
| 491 | commits: Schema.optional( | |
| 492 | Schema.NullOr( | |
| 493 | Schema.Struct({ | |
| 494 | nodes: Schema.Array( | |
| 495 | Schema.Struct({ | |
| 496 | commit: Schema.Struct({ | |
| 497 | oid: Schema.String, | |
| 498 | messageHeadline: Schema.optional(Schema.String), | |
| 499 | committedDate: Schema.String, | |
| 500 | authors: Schema.optional( | |
| 501 | Schema.NullOr( | |
| 502 | Schema.Struct({ | |
| 503 | nodes: Schema.Array( | |
| 504 | Schema.Struct({ | |
| 505 | email: Schema.optional(Schema.NullOr(Schema.String)), | |
| 506 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 507 | user: Schema.optional( | |
| 508 | Schema.NullOr( | |
| 509 | Schema.Struct({ | |
| 510 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 511 | }), | |
| 512 | ), | |
| 513 | ), | |
| 514 | }), | |
| 515 | ), | |
| 516 | }), | |
| 517 | ), | |
| 518 | ), | |
| 519 | }), | |
| 520 | }), | |
| 521 | ), | |
| 522 | }), | |
| 523 | ), | |
| 524 | ), | |
| 525 | }), | |
| 526 | }), | |
| 527 | }), | |
| 528 | }); | |
| 529 | ||
| 530 | /** Where a connection carries on from, which is what every paged read below follows. */ | |
| 531 | function RawPageInfoSchemaFields() { | |
| 532 | return Schema.Struct({ | |
| 533 | hasNextPage: Schema.optional(Schema.Boolean), | |
| 534 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 535 | }); | |
| 536 | } | |
| 537 | const RawPageInfoSchema = RawPageInfoSchemaFields(); | |
| 538 | ||
| 539 | /** | |
| 540 | * What GitHub says the viewer may do with a pull request. Both are optional so that an install | |
| 541 | * that answers without them still delivers the conversation they travel with; an absent field | |
| 542 | * reads as granted, which is what an unknown permission is. | |
| 543 | */ | |
| 544 | const RawViewerFieldsSchema = Schema.Struct({ | |
| 545 | viewerCanUpdate: Schema.optional(Schema.Boolean), | |
| 546 | viewerDidAuthor: Schema.optional(Schema.Boolean), | |
| 547 | }); | |
| 548 | ||
| 549 | const RawThreadCommentsSchema = Schema.Struct({ | |
| 550 | totalCount: Schema.optional(Schema.Int), | |
| 551 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 552 | nodes: Schema.Array(RawCommentSchema), | |
| 553 | }); | |
| 554 | ||
| 555 | /** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ | |
| 556 | const RawReviewThreadsSchema = Schema.Struct({ | |
| 557 | data: Schema.Struct({ | |
| 558 | // Rides along in the same request: GitHub names who reacted but never says whether that is | |
| 559 | // the reader, so the comparison is made here rather than paid for with a request of its own. | |
| 560 | viewer: Schema.optional( | |
| 561 | Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 562 | ), | |
| 563 | repository: Schema.Struct({ | |
| 564 | pullRequest: Schema.Struct({ | |
| 565 | reviewThreads: Schema.Struct({ | |
| 566 | totalCount: Schema.optional(Schema.Int), | |
| 567 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 568 | nodes: Schema.Array( | |
| 569 | Schema.Struct({ | |
| 570 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 571 | isResolved: Schema.optional(Schema.Boolean), | |
| 572 | isOutdated: Schema.optional(Schema.Boolean), | |
| 573 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 574 | /** Null once the thread's line has left the diff, which `isOutdated` reports. */ | |
| 575 | line: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 576 | diffSide: Schema.optional(Schema.NullOr(Schema.String)), | |
| 577 | comments: RawThreadCommentsSchema, | |
| 578 | }), | |
| 579 | ), | |
| 580 | }), | |
| 581 | ...RawViewerFieldsSchema.fields, | |
| 582 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 583 | reactionGroups: RawReactionGroupsSchema, | |
| 584 | comments: Schema.optional( | |
| 585 | Schema.NullOr( | |
| 586 | Schema.Struct({ | |
| 587 | nodes: Schema.Array( | |
| 588 | Schema.Struct({ | |
| 589 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 590 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 591 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 592 | reactionGroups: RawReactionGroupsSchema, | |
| 593 | }), | |
| 594 | ), | |
| 595 | }), | |
| 596 | ), | |
| 597 | ), | |
| 598 | /** | |
| 599 | * Reviews for their reactions and actor identity: the words and the verdict arrive with | |
| 600 | * `gh pr view --json reviews`, which reports no reaction of any kind. | |
| 601 | */ | |
| 602 | reviews: Schema.optional( | |
| 603 | Schema.NullOr( | |
| 604 | Schema.Struct({ | |
| 605 | nodes: Schema.Array( | |
| 606 | Schema.Struct({ | |
| 607 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 608 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 609 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 610 | reactionGroups: RawReactionGroupsSchema, | |
| 611 | }), | |
| 612 | ), | |
| 613 | }), | |
| 614 | ), | |
| 615 | ), | |
| 616 | reviewRequests: Schema.optional( | |
| 617 | Schema.NullOr( | |
| 618 | Schema.Struct({ | |
| 619 | nodes: Schema.Array( | |
| 620 | Schema.Struct({ | |
| 621 | // Null for a team, which is a request nobody in particular owns. | |
| 622 | requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 623 | }), | |
| 624 | ), | |
| 625 | }), | |
| 626 | ), | |
| 627 | ), | |
| 628 | latestReviews: Schema.optional( | |
| 629 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), | |
| 630 | ), | |
| 631 | reviewDismissals: Schema.optional( | |
| 632 | Schema.NullOr( | |
| 633 | Schema.Struct({ | |
| 634 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 635 | nodes: Schema.Array( | |
| 636 | Schema.Struct({ | |
| 637 | dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), | |
| 638 | review: Schema.optional( | |
| 639 | Schema.NullOr( | |
| 640 | Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 641 | ), | |
| 642 | ), | |
| 643 | }), | |
| 644 | ), | |
| 645 | }), | |
| 646 | ), | |
| 647 | ), | |
| 648 | commits: Schema.optional( | |
| 649 | Schema.NullOr( | |
| 650 | Schema.Struct({ | |
| 651 | nodes: Schema.Array( | |
| 652 | Schema.Struct({ | |
| 653 | commit: Schema.Struct({ | |
| 654 | oid: Schema.String, | |
| 655 | messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), | |
| 656 | committedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 657 | additions: Schema.optional(Schema.Int), | |
| 658 | deletions: Schema.optional(Schema.Int), | |
| 659 | parents: Schema.optional( | |
| 660 | Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), | |
| 661 | ), | |
| 662 | authors: Schema.optional( | |
| 663 | Schema.NullOr( | |
| 664 | Schema.Struct({ | |
| 665 | nodes: Schema.Array( | |
| 666 | Schema.Struct({ | |
| 667 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 668 | avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 669 | user: Schema.optional( | |
| 670 | Schema.NullOr( | |
| 671 | Schema.Struct({ | |
| 672 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 673 | }), | |
| 674 | ), | |
| 675 | ), | |
| 676 | }), | |
| 677 | ), | |
| 678 | }), | |
| 679 | ), | |
| 680 | ), | |
| 681 | }), | |
| 682 | }), | |
| 683 | ), | |
| 684 | }), | |
| 685 | ), | |
| 686 | ), | |
| 687 | }), | |
| 688 | }), | |
| 689 | }), | |
| 690 | }); | |
| 691 | ||
| 692 | /** Requested together, so a response missing any of them fails rather than defaulting open: | |
| 693 | * guessing `true` would offer a merge method the repository forbids. */ | |
| 694 | const RawRepositoryAccessSchema = Schema.Struct({ | |
| 695 | mergeCommitAllowed: Schema.Boolean, | |
| 696 | squashMergeAllowed: Schema.Boolean, | |
| 697 | rebaseMergeAllowed: Schema.Boolean, | |
| 698 | /** | |
| 699 | * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the | |
| 700 | * three above: an install that does not report it leaves the viewer's standing unknown, which | |
| 701 | * is answered by granting rather than by failing the whole detail read. | |
| 702 | */ | |
| 703 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), | |
| 704 | }); | |
| 705 | ||
| 706 | const RawCoreSchema = Schema.Struct({ | |
| 707 | data: Schema.Struct({ | |
| 708 | repository: Schema.Struct({ | |
| 709 | ...RawRepositoryAccessSchema.fields, | |
| 710 | pullRequest: Schema.Struct({ | |
| 711 | ...RawDetailSchema.fields, | |
| 712 | ...RawViewerFieldsSchema.fields, | |
| 713 | viewerCanUpdateBranch: Schema.Boolean, | |
| 714 | baseRef: Schema.NullOr( | |
| 715 | Schema.Struct({ | |
| 716 | compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), | |
| 717 | }), | |
| 718 | ), | |
| 719 | reviewRequests: Schema.Struct({ | |
| 720 | nodes: Schema.Array( | |
| 721 | Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), | |
| 722 | ), | |
| 723 | }), | |
| 724 | labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), | |
| 725 | commits: Schema.Struct({ | |
| 726 | nodes: Schema.Array( | |
| 727 | Schema.Struct({ | |
| 728 | commit: Schema.Struct({ | |
| 729 | statusCheckRollup: Schema.NullOr( | |
| 730 | Schema.Struct({ | |
| 731 | contexts: Schema.Struct({ | |
| 732 | nodes: Schema.Array( | |
| 733 | Schema.Struct({ | |
| 734 | ...RawCheckSchema.fields, | |
| 735 | checkSuite: Schema.optional( | |
| 736 | Schema.NullOr( | |
| 737 | Schema.Struct({ | |
| 738 | workflowRun: Schema.NullOr( | |
| 739 | Schema.Struct({ | |
| 740 | workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), | |
| 741 | }), | |
| 742 | ), | |
| 743 | }), | |
| 744 | ), | |
| 745 | ), | |
| 746 | }), | |
| 747 | ), | |
| 748 | pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), | |
| 749 | }), | |
| 750 | }), | |
| 751 | ), | |
| 752 | }), | |
| 753 | }), | |
| 754 | ), | |
| 755 | }), | |
| 756 | }), | |
| 757 | }), | |
| 758 | }), | |
| 759 | }); | |
| 760 | const decodeCore = decodeJsonResult(RawCoreSchema); | |
| 761 | ||
| 762 | const RawPullRequestFileSchema = Schema.Struct({ | |
| 763 | filename: Schema.String, | |
| 764 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 765 | /** Only on a rename, where it names the file the hunks are counted against. */ | |
| 766 | previous_filename: Schema.optional(Schema.NullOr(Schema.String)), | |
| 767 | /** Absent for a binary file, and for one whose diff GitHub considers too large. */ | |
| 768 | patch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 769 | /** Whether anything was withheld is the difference between a binary file and a pure rename. */ | |
| 770 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 771 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 772 | }); | |
| 773 | ||
| 774 | /** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ | |
| 775 | export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { | |
| 776 | nodes(ids: $ids) { | |
| 777 | ... on User { login avatarUrl } | |
| 778 | ... on Bot { login avatarUrl } | |
| 779 | } | |
| 780 | }`; | |
| 781 | ||
| 782 | const RawActorAvatarsSchema = Schema.Struct({ | |
| 783 | data: Schema.Struct({ | |
| 784 | nodes: Schema.Array(Schema.NullOr(RawActorSchema)), | |
| 785 | }), | |
| 786 | }); | |
| 787 | ||
| 788 | const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); | |
| 789 | ||
| 790 | export function decodeActorAvatarsJson( | |
| 791 | raw: string, | |
| 792 | ): Result.Result<ReadonlyMap<string, string>, DecodeFailure> { | |
| 793 | const decoded = decodeActorAvatars(raw); | |
| 794 | if (!Result.isSuccess(decoded)) { | |
| 795 | return Result.fail(decoded.failure); | |
| 796 | } | |
| 797 | const avatarsByLogin = new Map<string, string>(); | |
| 798 | for (const node of decoded.success.data.nodes) { | |
| 799 | const login = trimmed(node?.login); | |
| 800 | const avatarUrl = trimmed(node?.avatarUrl); | |
| 801 | if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); | |
| 802 | } | |
| 803 | return Result.succeed(avatarsByLogin); | |
| 804 | } | |
| 805 | ||
| 806 | /** | |
| 807 | * Pull refs let the comparison share the detail read without first resolving a fork branch. | |
| 808 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and | |
| 809 | * an unknown field fails the whole read. | |
| 810 | */ | |
| 811 | function checkContextNodesSelection(host: string): string { | |
| 812 | const required = | |
| 813 | host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; | |
| 814 | return `nodes { | |
| 815 | __typename | |
| 816 | ... on StatusContext { context state targetUrl createdAt description${required} } | |
| 817 | ... on CheckRun { | |
| 818 | name status conclusion startedAt completedAt detailsUrl${required} | |
| 819 | checkSuite { workflowRun { workflow { name } } } | |
| 820 | } | |
| 821 | }`; | |
| 822 | } | |
| 823 | ||
| 824 | export const pullRequestCoreGraphQlQuery = (host: string) => { | |
| 825 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 826 | repository(owner: $owner, name: $name) { | |
| 827 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | |
| 828 | pullRequest(number: $number) { | |
| 829 | number title url body state isDraft mergeable reviewDecision | |
| 830 | additions deletions changedFiles createdAt updatedAt mergedAt closedAt | |
| 831 | headRefName baseRefName headRefOid isCrossRepository | |
| 832 | headRepositoryOwner { login } | |
| 833 | author { login avatarUrl ... on User { id name } } | |
| 834 | autoMergeRequest { mergeMethod } | |
| 835 | viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch | |
| 836 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 837 | reviewRequests(first: 100) { | |
| 838 | nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } | |
| 839 | } | |
| 840 | labels(first: 100) { nodes { name color } } | |
| 841 | commits(last: 1) { | |
| 842 | nodes { commit { statusCheckRollup { contexts(first: 100) { | |
| 843 | ${checkContextNodesSelection(host)} | |
| 844 | pageInfo { hasNextPage } | |
| 845 | } } } } | |
| 846 | } | |
| 847 | } | |
| 848 | } | |
| 849 | }`; | |
| 850 | }; | |
| 851 | ||
| 852 | export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 853 | repository(owner: $owner, name: $name) { | |
| 854 | pullRequest(number: $number) { | |
| 855 | number title url state isDraft createdAt | |
| 856 | author { login avatarUrl ... on User { name } } | |
| 857 | } | |
| 858 | } | |
| 859 | }`; | |
| 860 | ||
| 861 | const decodePullRequestPreview = decodeJsonResult( | |
| 862 | Schema.Struct({ | |
| 863 | data: Schema.Struct({ | |
| 864 | repository: Schema.Struct({ | |
| 865 | pullRequest: Schema.Struct({ | |
| 866 | number: Schema.Int, | |
| 867 | title: Schema.String, | |
| 868 | url: Schema.String, | |
| 869 | state: Schema.String, | |
| 870 | isDraft: Schema.Boolean, | |
| 871 | createdAt: Schema.String, | |
| 872 | author: Schema.NullOr(RawActorSchema), | |
| 873 | }), | |
| 874 | }), | |
| 875 | }), | |
| 876 | }), | |
| 877 | ); | |
| 878 | ||
| 879 | export function decodePullRequestPreviewJson( | |
| 880 | raw: string, | |
| 881 | ): Result.Result<Omit<PullRequestPreview, "projectId" | "repository">, DecodeFailure> { | |
| 882 | return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ | |
| 883 | ...data.repository.pullRequest, | |
| 884 | author: toActor(data.repository.pullRequest.author), | |
| 885 | state: toState(data.repository.pullRequest), | |
| 886 | })); | |
| 887 | } | |
| 888 | ||
| 889 | /** | |
| 890 | * The conversation a pull request carries outside its review threads: who opened it, its issue | |
| 891 | * comments, its reviews and its newest commits. The two remark connections page independently, so | |
| 892 | * each is switched on only while it has more to give; the first read asks for everything. | |
| 893 | */ | |
| 894 | export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { | |
| 895 | repository(owner: $owner, name: $name) { | |
| 896 | pullRequest(number: $number) { | |
| 897 | author @include(if: $head) { __typename login avatarUrl ... on User { name } } | |
| 898 | commits(last: 100) @include(if: $head) { | |
| 899 | nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } | |
| 900 | } | |
| 901 | comments(first: 100, after: $commentsAfter) @include(if: $withComments) { | |
| 902 | pageInfo { hasNextPage endCursor } | |
| 903 | nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | |
| 904 | } | |
| 905 | reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { | |
| 906 | pageInfo { hasNextPage endCursor } | |
| 907 | nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | |
| 908 | } | |
| 909 | } | |
| 910 | } | |
| 911 | }`; | |
| 912 | ||
| 913 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ | |
| 914 | const GRAPHQL_PAGE_SIZE = 100; | |
| 915 | ||
| 916 | /** | |
| 917 | * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: | |
| 918 | * `first: 101` is an error, `first: 100` is not). | |
| 919 | */ | |
| 920 | export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; | |
| 921 | ||
| 922 | /** | |
| 923 | * Every repository of a host in one read, which is what makes a listing one request rather than | |
| 924 | * one process per repository. | |
| 925 | * | |
| 926 | * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a | |
| 927 | * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two | |
| 928 | * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. | |
| 929 | * | |
| 930 | * The row count is written into the document rather than sent as a variable because every | |
| 931 | * variable here travels as a string — and it is this module's own number, clamped by the caller, | |
| 932 | * never a reader's. | |
| 933 | * | |
| 934 | * `first` on the two inner connections is a bound rather than a page: a pull request with more | |
| 935 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review | |
| 936 | * is already past what a row can say. | |
| 937 | */ | |
| 938 | export function pullRequestSearchGraphQlQuery( | |
| 939 | rows: number, | |
| 940 | includeStacks = false, | |
| 941 | includeStats = false, | |
| 942 | ): string { | |
| 943 | return `query($q: String!, $after: String) { | |
| 944 | search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { | |
| 945 | pageInfo { hasNextPage endCursor } | |
| 946 | nodes { | |
| 947 | ... on PullRequest { | |
| 948 | ${pullRequestRowSelection(includeStacks, includeStats)} | |
| 949 | } | |
| 950 | } | |
| 951 | } | |
| 952 | }`; | |
| 953 | } | |
| 954 | ||
| 955 | /** | |
| 956 | * A repository's pull requests without search, newest created first: the order `gh pr list` lists | |
| 957 | * in when it does not search, for a repository GitHub's search index does not cover. | |
| 958 | */ | |
| 959 | export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { | |
| 960 | return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { | |
| 961 | repository(owner: $owner, name: $name) { | |
| 962 | pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { | |
| 963 | pageInfo { hasNextPage endCursor } | |
| 964 | nodes { ${pullRequestRowSelection(includeStacks, true)} } | |
| 965 | } | |
| 966 | } | |
| 967 | }`; | |
| 968 | } | |
| 969 | ||
| 970 | function pageRows(rows: number): number { | |
| 971 | return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 972 | } | |
| 973 | ||
| 974 | /** One listing row, the same whether it came from a search or from a repository's own list. */ | |
| 975 | function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { | |
| 976 | return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | |
| 977 | number | |
| 978 | title | |
| 979 | url | |
| 980 | author { __typename login avatarUrl ... on User { name } } | |
| 981 | headRefName | |
| 982 | baseRefName | |
| 983 | state | |
| 984 | isDraft | |
| 985 | mergeable | |
| 986 | reviewDecision | |
| 987 | latestReviews(first: 20) { nodes { state author { login } } } | |
| 988 | ${includeStats ? "additions deletions" : ""} | |
| 989 | createdAt | |
| 990 | updatedAt | |
| 991 | mergedAt | |
| 992 | repository { nameWithOwner } | |
| 993 | reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } | |
| 994 | labels(first: 20) { nodes { name color } } | |
| 995 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; | |
| 996 | } | |
| 997 | ||
| 998 | /** | |
| 999 | * One page of review threads with their comments, and the people on the review. `$cursor` is | |
| 1000 | * null for the first page and the last page's `endCursor` after that, so a pull request with | |
| 1001 | * more threads than one page holds is walked rather than cut off at the first fifty. | |
| 1002 | * | |
| 1003 | * Only ten comments ride with each thread. A hundred threads times a hundred comments made | |
| 1004 | * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from | |
| 1005 | * their own cursor below. | |
| 1006 | * | |
| 1007 | * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: | |
| 1008 | * that field holds only requests still outstanding, so anyone who has already reviewed drops off | |
| 1009 | * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person | |
| 1010 | * or an app, and both are asked for by name because they are different GraphQL types. | |
| 1011 | * | |
| 1012 | * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the | |
| 1013 | * pull request this query is already standing on, so what the reader may do with it arrives with | |
| 1014 | * the conversation rather than costing a request of its own. | |
| 1015 | * | |
| 1016 | * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from | |
| 1017 | * the start, so a pull request with more than a hundred commits loses the newest ones from its | |
| 1018 | * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff | |
| 1019 | * wants, and stands in for the `gh` list wherever it came back non-empty. | |
| 1020 | */ | |
| 1021 | export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { | |
| 1022 | viewer { login } | |
| 1023 | repository(owner: $owner, name: $name) { | |
| 1024 | pullRequest(number: $number) { | |
| 1025 | reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1026 | totalCount | |
| 1027 | pageInfo { hasNextPage endCursor } | |
| 1028 | nodes { | |
| 1029 | id | |
| 1030 | isResolved | |
| 1031 | isOutdated | |
| 1032 | path | |
| 1033 | line | |
| 1034 | diffSide | |
| 1035 | comments(first: 10) { | |
| 1036 | totalCount | |
| 1037 | pageInfo { hasNextPage endCursor } | |
| 1038 | nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } | |
| 1039 | } | |
| 1040 | } | |
| 1041 | } | |
| 1042 | viewerCanUpdate | |
| 1043 | viewerDidAuthor | |
| 1044 | author { __typename login avatarUrl } | |
| 1045 | ${REACTION_GROUPS_FIELDS} | |
| 1046 | comments(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 1047 | nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } | |
| 1048 | } | |
| 1049 | reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } } | |
| 1050 | reviewRequests(first: 50) { | |
| 1051 | nodes { | |
| 1052 | requestedReviewer { | |
| 1053 | ... on User { login name avatarUrl } | |
| 1054 | ... on Bot { __typename login avatarUrl } | |
| 1055 | } | |
| 1056 | } | |
| 1057 | } | |
| 1058 | latestReviews(first: 50) { | |
| 1059 | nodes { state author { __typename login avatarUrl } } | |
| 1060 | } | |
| 1061 | reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { | |
| 1062 | pageInfo { hasNextPage endCursor } | |
| 1063 | nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } | |
| 1064 | } | |
| 1065 | commits(last: ${GRAPHQL_PAGE_SIZE}) { | |
| 1066 | nodes { | |
| 1067 | commit { | |
| 1068 | oid | |
| 1069 | messageHeadline | |
| 1070 | committedDate | |
| 1071 | additions | |
| 1072 | deletions | |
| 1073 | parents(first: 1) { totalCount } | |
| 1074 | authors(first: 3) { nodes { name avatarUrl user { login } } } | |
| 1075 | } | |
| 1076 | } | |
| 1077 | } | |
| 1078 | } | |
| 1079 | } | |
| 1080 | }`; | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * The rest of one thread's conversation. GraphQL pages a connection nested inside another only | |
| 1084 | * from the inner node itself, so a thread longer than a page is followed on its own — a request | |
| 1085 | * GitHub makes necessary, and one no ordinary pull request ever provokes. | |
| 1086 | */ | |
| 1087 | export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) { | |
| 1088 | viewer { login } | |
| 1089 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1090 | node(id: $threadId) { | |
| 1091 | ... on PullRequestReviewThread { | |
| 1092 | pullRequest { id } | |
| 1093 | comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1094 | pageInfo { hasNextPage endCursor } | |
| 1095 | nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } | |
| 1096 | } | |
| 1097 | } | |
| 1098 | } | |
| 1099 | }`; | |
| 1100 | ||
| 1101 | const RawReviewThreadCommentsSchema = Schema.Struct({ | |
| 1102 | data: Schema.Struct({ | |
| 1103 | viewer: Schema.optional( | |
| 1104 | Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 1105 | ), | |
| 1106 | repository: Schema.NullOr( | |
| 1107 | Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), | |
| 1108 | ), | |
| 1109 | /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ | |
| 1110 | node: Schema.NullOr( | |
| 1111 | Schema.Struct({ | |
| 1112 | pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), | |
| 1113 | comments: Schema.optional(RawThreadCommentsSchema), | |
| 1114 | }), | |
| 1115 | ), | |
| 1116 | }), | |
| 1117 | }); | |
| 1118 | ||
| 1119 | export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { | |
| 1120 | addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { | |
| 1121 | comment { id } | |
| 1122 | } | |
| 1123 | }`; | |
| 1124 | ||
| 1125 | /** | |
| 1126 | * The pull request's own node id, which is what a reaction on its description is addressed by. | |
| 1127 | * Read only when one is being written: the conversation carries an id for every remark in it, and | |
| 1128 | * the pull request is the one subject nothing in it names. | |
| 1129 | */ | |
| 1130 | export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 1131 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1132 | }`; | |
| 1133 | ||
| 1134 | const RawPullRequestNodeIdSchema = Schema.Struct({ | |
| 1135 | data: Schema.Struct({ | |
| 1136 | repository: Schema.Struct({ | |
| 1137 | pullRequest: Schema.Struct({ id: Schema.String }), | |
| 1138 | }), | |
| 1139 | }), | |
| 1140 | }); | |
| 1141 | ||
| 1142 | const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); | |
| 1143 | ||
| 1144 | export function decodePullRequestNodeIdJson(raw: string): Result.Result<string, DecodeFailure> { | |
| 1145 | const decoded = decodePullRequestNodeId(raw); | |
| 1146 | return Result.isSuccess(decoded) | |
| 1147 | ? Result.succeed(decoded.success.data.repository.pullRequest.id) | |
| 1148 | : Result.fail(decoded.failure); | |
| 1149 | } | |
| 1150 | ||
| 1151 | /** | |
| 1152 | * Where a client-given reaction subject actually hangs: the pull request itself, or the pull | |
| 1153 | * request an issue comment, a review comment, or a review belongs to. Read before a mutation | |
| 1154 | * reaches it, so a subject named for one pull request cannot react on another's behalf. | |
| 1155 | */ | |
| 1156 | export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { | |
| 1157 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1158 | node(id: $subjectId) { | |
| 1159 | id | |
| 1160 | ... on IssueComment { pullRequest { id } } | |
| 1161 | ... on PullRequestReviewComment { pullRequest { id } } | |
| 1162 | ... on PullRequestReview { pullRequest { id } } | |
| 1163 | } | |
| 1164 | }`; | |
| 1165 | ||
| 1166 | const RawReactionSubjectScopeSchema = Schema.Struct({ | |
| 1167 | data: Schema.Struct({ | |
| 1168 | repository: Schema.NullOr( | |
| 1169 | Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), | |
| 1170 | ), | |
| 1171 | node: Schema.NullOr( | |
| 1172 | Schema.Struct({ | |
| 1173 | id: Schema.String, | |
| 1174 | pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), | |
| 1175 | }), | |
| 1176 | ), | |
| 1177 | }), | |
| 1178 | }); | |
| 1179 | ||
| 1180 | const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); | |
| 1181 | ||
| 1182 | /** | |
| 1183 | * True when the subject named is the pull request itself, or hangs off it — false for anything | |
| 1184 | * else, including a subject or a pull request this host could not find. | |
| 1185 | */ | |
| 1186 | export function decodeReactionSubjectScopeJson(raw: string): Result.Result<boolean, DecodeFailure> { | |
| 1187 | const decoded = decodeReactionSubjectScope(raw); | |
| 1188 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1189 | const expected = decoded.success.data.repository?.pullRequest?.id ?? null; | |
| 1190 | const node = decoded.success.data.node; | |
| 1191 | const actual = node === null ? null : (node.pullRequest?.id ?? node.id); | |
| 1192 | return Result.succeed(expected !== null && actual !== null && expected === actual); | |
| 1193 | } | |
| 1194 | ||
| 1195 | export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | |
| 1196 | addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | |
| 1197 | }`; | |
| 1198 | ||
| 1199 | export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | |
| 1200 | removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | |
| 1201 | }`; | |
| 1202 | ||
| 1203 | export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { | |
| 1204 | resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } | |
| 1205 | }`; | |
| 1206 | ||
| 1207 | export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { | |
| 1208 | unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } | |
| 1209 | }`; | |
| 1210 | ||
| 1211 | /** | |
| 1212 | * Rewrites the pull request's own words. Both are nullable so that one document serves a change | |
| 1213 | * to the title, to the description, or to the two together: a variable the request does not send | |
| 1214 | * puts no entry in the input at all, which leaves that field as it was rather than clearing it. | |
| 1215 | */ | |
| 1216 | export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { | |
| 1217 | updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { | |
| 1218 | pullRequest { id } | |
| 1219 | } | |
| 1220 | }`; | |
| 1221 | ||
| 1222 | /** Creates a new pull request that reverses a merged pull request. */ | |
| 1223 | export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1224 | revertPullRequest(input: { pullRequestId: $pullRequestId }) { | |
| 1225 | revertPullRequest { id } | |
| 1226 | } | |
| 1227 | }`; | |
| 1228 | ||
| 1229 | /** | |
| 1230 | * The two comment mutations name their comment differently. The variable is spelled the same in | |
| 1231 | * both, so a rewrite sends one set of variables whichever kind of remark it is. | |
| 1232 | */ | |
| 1233 | export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { | |
| 1234 | updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } | |
| 1235 | }`; | |
| 1236 | ||
| 1237 | export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { | |
| 1238 | updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { | |
| 1239 | pullRequestReviewComment { id } | |
| 1240 | } | |
| 1241 | }`; | |
| 1242 | ||
| 1243 | /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ | |
| 1244 | const ReviewSubmissionSchema = Schema.Struct({ | |
| 1245 | event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), | |
| 1246 | body: Schema.String, | |
| 1247 | comments: Schema.Array( | |
| 1248 | Schema.Struct({ | |
| 1249 | path: Schema.String, | |
| 1250 | line: Schema.Int, | |
| 1251 | side: Schema.Literals(["LEFT", "RIGHT"]), | |
| 1252 | body: Schema.String, | |
| 1253 | }), | |
| 1254 | ), | |
| 1255 | }); | |
| 1256 | ||
| 1257 | const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = { | |
| 1258 | comment: "COMMENT", | |
| 1259 | approve: "APPROVE", | |
| 1260 | "request-changes": "REQUEST_CHANGES", | |
| 1261 | }; | |
| 1262 | ||
| 1263 | /** | |
| 1264 | * The dismissal events past the page the thread read carries. A pull request rarely has any: | |
| 1265 | * this is followed only while the embedded page reports more. | |
| 1266 | */ | |
| 1267 | export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { | |
| 1268 | repository(owner: $owner, name: $name) { | |
| 1269 | pullRequest(number: $number) { | |
| 1270 | timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1271 | pageInfo { hasNextPage endCursor } | |
| 1272 | nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } | |
| 1273 | } | |
| 1274 | } | |
| 1275 | } | |
| 1276 | }`; | |
| 1277 | ||
| 1278 | function gitHubReviewPosition(position: PullRequestReviewPosition): { | |
| 1279 | readonly line: number; | |
| 1280 | readonly side: "LEFT" | "RIGHT"; | |
| 1281 | } { | |
| 1282 | switch (position.kind) { | |
| 1283 | case "added": | |
| 1284 | return { line: position.newLine, side: "RIGHT" }; | |
| 1285 | case "deleted": | |
| 1286 | return { line: position.oldLine, side: "LEFT" }; | |
| 1287 | case "context": | |
| 1288 | return position.side === "left" | |
| 1289 | ? { line: position.oldLine, side: "LEFT" } | |
| 1290 | : { line: position.newLine, side: "RIGHT" }; | |
| 1291 | } | |
| 1292 | } | |
| 1293 | ||
| 1294 | /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ | |
| 1295 | export function buildReviewSubmission(input: { | |
| 1296 | readonly verdict: PullRequestReviewVerdict; | |
| 1297 | readonly body: string; | |
| 1298 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 1299 | }): typeof ReviewSubmissionSchema.Type { | |
| 1300 | return { | |
| 1301 | event: REVIEW_EVENTS[input.verdict], | |
| 1302 | body: input.body, | |
| 1303 | comments: input.comments.map((comment) => ({ | |
| 1304 | path: comment.path, | |
| 1305 | ...gitHubReviewPosition(comment.position), | |
| 1306 | body: comment.body, | |
| 1307 | })), | |
| 1308 | }; | |
| 1309 | } | |
| 1310 | ||
| 1311 | export interface GitHubPullRequestListItem { | |
| 1312 | readonly stack?: PullRequestStackMembership; | |
| 1313 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ | |
| 1314 | readonly authorId: string | null; | |
| 1315 | readonly number: number; | |
| 1316 | readonly title: string; | |
| 1317 | readonly url: string; | |
| 1318 | readonly author: PullRequestActor | null; | |
| 1319 | readonly headBranch: string; | |
| 1320 | readonly baseBranch: string; | |
| 1321 | readonly state: PullRequestState; | |
| 1322 | readonly isDraft: boolean; | |
| 1323 | readonly mergeability: PullRequestMergeability; | |
| 1324 | /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ | |
| 1325 | readonly reviewDecision: PullRequestReviewDecision | null; | |
| 1326 | readonly additions: number; | |
| 1327 | readonly deletions: number; | |
| 1328 | readonly createdAt: string; | |
| 1329 | readonly updatedAt: string; | |
| 1330 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 1331 | /** At least one outstanding request targets a team rather than an individual login. */ | |
| 1332 | readonly hasTeamReviewRequest: boolean; | |
| 1333 | readonly labels: ReadonlyArray<PullRequestLabel>; | |
| 1334 | /** Null where the head commit reported no checks, which is not the same as passing none. */ | |
| 1335 | readonly checksState: PullRequestChecksState | null; | |
| 1336 | } | |
| 1337 | ||
| 1338 | export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { | |
| 1339 | /** True only when GitHub says the head belongs to another repository. */ | |
| 1340 | readonly isCrossRepository?: boolean; | |
| 1341 | /** The owner of the head branch's repository; null where `gh` did not say. */ | |
| 1342 | readonly headRepositoryOwner: string | null; | |
| 1343 | readonly headSha?: string | null; | |
| 1344 | readonly body: string; | |
| 1345 | readonly changedFiles: number; | |
| 1346 | readonly mergedAt: string | null; | |
| 1347 | readonly closedAt: string | null; | |
| 1348 | readonly checks: ReadonlyArray<PullRequestCheck>; | |
| 1349 | /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ | |
| 1350 | readonly autoMergeEnabled?: boolean; | |
| 1351 | /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ | |
| 1352 | readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 1353 | } | |
| 1354 | ||
| 1355 | export interface GitHubWorkflowRunApproval { | |
| 1356 | readonly id: number; | |
| 1357 | readonly name: string; | |
| 1358 | readonly url: string | null; | |
| 1359 | } | |
| 1360 | ||
| 1361 | export interface GitHubPullRequestHead { | |
| 1362 | readonly number: number; | |
| 1363 | readonly headSha: string; | |
| 1364 | readonly isCrossRepository?: boolean; | |
| 1365 | readonly headRepositoryOwner: string | null; | |
| 1366 | } | |
| 1367 | ||
| 1368 | export interface GitHubPullRequestActivity { | |
| 1369 | readonly author: PullRequestActor | null; | |
| 1370 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 1371 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 1372 | } | |
| 1373 | ||
| 1374 | function trimmed(value: string | null | undefined): string | null { | |
| 1375 | const text = value?.trim() ?? ""; | |
| 1376 | return text.length > 0 ? text : null; | |
| 1377 | } | |
| 1378 | ||
| 1379 | /** | |
| 1380 | * Null once a connection has nothing further, which is what ends every walk below. GitHub sends | |
| 1381 | * an `endCursor` on a page that is also the last one, so the flag is what decides, not the | |
| 1382 | * cursor's presence. | |
| 1383 | */ | |
| 1384 | function nextCursorOf( | |
| 1385 | pageInfo: Schema.Schema.Type<typeof RawPageInfoSchema> | undefined, | |
| 1386 | ): string | null { | |
| 1387 | return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; | |
| 1388 | } | |
| 1389 | ||
| 1390 | /** | |
| 1391 | * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. | |
| 1392 | * | |
| 1393 | * Updating is a permission, so an install that does not report it grants it and lets the host's | |
| 1394 | * own refusal explain anything that fails. Authorship is not a permission but a fact about who | |
| 1395 | * wrote the thing, and it is read to decide what an author may do to their own change — so an | |
| 1396 | * unknown answer is "not the author", which grants nothing it should not. | |
| 1397 | */ | |
| 1398 | function toPullRequestViewerFields( | |
| 1399 | raw: Schema.Schema.Type<typeof RawViewerFieldsSchema> | null | undefined, | |
| 1400 | ): { readonly canUpdate: boolean; readonly didAuthor: boolean } { | |
| 1401 | return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; | |
| 1402 | } | |
| 1403 | ||
| 1404 | function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefined) { | |
| 1405 | const login = trimmed(raw?.login); | |
| 1406 | return login === null | |
| 1407 | ? null | |
| 1408 | : { | |
| 1409 | login, | |
| 1410 | name: trimmed(raw?.name), | |
| 1411 | avatarUrl: trimmed(raw?.avatarUrl), | |
| 1412 | ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}), | |
| 1413 | }; | |
| 1414 | } | |
| 1415 | ||
| 1416 | function toCommitActor( | |
| 1417 | raw: NonNullable<Schema.Schema.Type<typeof RawCommitSchema>["authors"]>[number], | |
| 1418 | ): PullRequestActor | null { | |
| 1419 | // An email-linked GitHub account has a login; an unlinked signature only has a name or email. | |
| 1420 | // Keep that signature visible instead of silently turning a co-authored commit into one author. | |
| 1421 | const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); | |
| 1422 | return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; | |
| 1423 | } | |
| 1424 | ||
| 1425 | /** An author off the GraphQL commits connection, which names an account by `user.login` where | |
| 1426 | * `gh pr view --json commits` names it by a flat `login` copied off the signature. */ | |
| 1427 | function toGraphqlCommitActor(raw: { | |
| 1428 | readonly name?: string | null | undefined; | |
| 1429 | readonly avatarUrl?: string | null | undefined; | |
| 1430 | readonly user?: { readonly login?: string | null | undefined } | null | undefined; | |
| 1431 | }): PullRequestActor | null { | |
| 1432 | const login = trimmed(raw.user?.login) ?? trimmed(raw.name); | |
| 1433 | return login === null | |
| 1434 | ? null | |
| 1435 | : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) }; | |
| 1436 | } | |
| 1437 | ||
| 1438 | function toState(raw: { | |
| 1439 | readonly state?: string | null | undefined; | |
| 1440 | readonly mergedAt?: string | null | undefined; | |
| 1441 | }): PullRequestState { | |
| 1442 | if (trimmed(raw.mergedAt) !== null) return "merged"; | |
| 1443 | const state = raw.state?.trim().toUpperCase(); | |
| 1444 | if (state === "MERGED") return "merged"; | |
| 1445 | if (state === "CLOSED") return "closed"; | |
| 1446 | return "open"; | |
| 1447 | } | |
| 1448 | ||
| 1449 | function toMergeability(value: string | null | undefined): PullRequestMergeability { | |
| 1450 | switch (value?.trim().toUpperCase()) { | |
| 1451 | case "MERGEABLE": | |
| 1452 | return "mergeable"; | |
| 1453 | case "CONFLICTING": | |
| 1454 | return "conflicting"; | |
| 1455 | default: | |
| 1456 | return "unknown"; | |
| 1457 | } | |
| 1458 | } | |
| 1459 | ||
| 1460 | function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { | |
| 1461 | switch (value?.trim().toUpperCase()) { | |
| 1462 | case "MERGE": | |
| 1463 | return "merge"; | |
| 1464 | case "SQUASH": | |
| 1465 | return "squash"; | |
| 1466 | case "REBASE": | |
| 1467 | return "rebase"; | |
| 1468 | default: | |
| 1469 | return undefined; | |
| 1470 | } | |
| 1471 | } | |
| 1472 | ||
| 1473 | /** | |
| 1474 | * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an | |
| 1475 | * approval from an app (a review bot) or from anyone without the required permission leaves it | |
| 1476 | * empty. The reviewers still said something, and a row should show it: when GitHub reports no | |
| 1477 | * verdict, the latest review per reviewer decides, changes requested outranking approval. | |
| 1478 | */ | |
| 1479 | function toReviewDecisionWithReviews( | |
| 1480 | value: string | null | undefined, | |
| 1481 | // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. | |
| 1482 | latestReviews: | |
| 1483 | | ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>> | |
| 1484 | | { readonly nodes: ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>> } | |
| 1485 | | null | |
| 1486 | | undefined, | |
| 1487 | ): PullRequestReviewDecision | null { | |
| 1488 | const summarized = toReviewDecision(value); | |
| 1489 | if (summarized === "approved" || summarized === "changes-requested") return summarized; | |
| 1490 | const reviews = | |
| 1491 | latestReviews === null || latestReviews === undefined | |
| 1492 | ? [] | |
| 1493 | : "nodes" in latestReviews | |
| 1494 | ? latestReviews.nodes | |
| 1495 | : latestReviews; | |
| 1496 | const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); | |
| 1497 | if (states.has("CHANGES_REQUESTED")) return "changes-requested"; | |
| 1498 | if (states.has("APPROVED")) return "approved"; | |
| 1499 | return summarized; | |
| 1500 | } | |
| 1501 | ||
| 1502 | function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { | |
| 1503 | switch (value?.trim().toUpperCase()) { | |
| 1504 | case "APPROVED": | |
| 1505 | return "approved"; | |
| 1506 | case "CHANGES_REQUESTED": | |
| 1507 | return "changes-requested"; | |
| 1508 | case "REVIEW_REQUIRED": | |
| 1509 | return "review-required"; | |
| 1510 | default: | |
| 1511 | return null; | |
| 1512 | } | |
| 1513 | } | |
| 1514 | ||
| 1515 | function toLabels( | |
| 1516 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawLabelSchema>> | undefined, | |
| 1517 | ): ReadonlyArray<PullRequestLabel> { | |
| 1518 | return (raw ?? []).flatMap((label) => { | |
| 1519 | const name = trimmed(label.name); | |
| 1520 | return name === null ? [] : [{ name, color: trimmed(label.color) }]; | |
| 1521 | }); | |
| 1522 | } | |
| 1523 | ||
| 1524 | /** | |
| 1525 | * User review requests only. Team requests are tracked separately because a slug cannot be | |
| 1526 | * compared with the viewer's login. | |
| 1527 | */ | |
| 1528 | function toReviewRequestLogins( | |
| 1529 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined, | |
| 1530 | ): ReadonlyArray<string> { | |
| 1531 | return (raw ?? []).flatMap((request) => { | |
| 1532 | const login = trimmed(request.login); | |
| 1533 | return login === null ? [] : [login]; | |
| 1534 | }); | |
| 1535 | } | |
| 1536 | ||
| 1537 | function hasTeamReviewRequest( | |
| 1538 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined, | |
| 1539 | ): boolean { | |
| 1540 | return (raw ?? []).some( | |
| 1541 | (request) => | |
| 1542 | trimmed(request.login) === null && | |
| 1543 | (trimmed(request.slug) !== null || trimmed(request.name) !== null), | |
| 1544 | ); | |
| 1545 | } | |
| 1546 | ||
| 1547 | function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequestCheckStatus { | |
| 1548 | // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` | |
| 1549 | // that only exists once the run has completed. | |
| 1550 | const status = raw.status?.trim().toUpperCase(); | |
| 1551 | if (status !== undefined && status !== "COMPLETED" && status !== "") { | |
| 1552 | return "pending"; | |
| 1553 | } | |
| 1554 | switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { | |
| 1555 | case "SUCCESS": | |
| 1556 | return "success"; | |
| 1557 | case "ACTION_REQUIRED": | |
| 1558 | return "action-required"; | |
| 1559 | case "FAILURE": | |
| 1560 | case "ERROR": | |
| 1561 | case "TIMED_OUT": | |
| 1562 | case "STARTUP_FAILURE": | |
| 1563 | return "failure"; | |
| 1564 | case "CANCELLED": | |
| 1565 | return "cancelled"; | |
| 1566 | case "SKIPPED": | |
| 1567 | return "skipped"; | |
| 1568 | case "PENDING": | |
| 1569 | case "EXPECTED": | |
| 1570 | return "pending"; | |
| 1571 | default: | |
| 1572 | return "neutral"; | |
| 1573 | } | |
| 1574 | } | |
| 1575 | ||
| 1576 | /** What GitHub writes where a run has not reached that moment yet, which is not a time. */ | |
| 1577 | const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; | |
| 1578 | ||
| 1579 | function realTimestamp(value: string | null | undefined): string | null { | |
| 1580 | const at = trimmed(value); | |
| 1581 | return at === null || at === UNSET_TIMESTAMP ? null : at; | |
| 1582 | } | |
| 1583 | ||
| 1584 | /** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ | |
| 1585 | function isNamelessCheck(raw: Schema.Schema.Type<typeof RawCheckSchema>): boolean { | |
| 1586 | return trimmed(raw.name) === null && trimmed(raw.context) === null; | |
| 1587 | } | |
| 1588 | ||
| 1589 | /** | |
| 1590 | * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last | |
| 1591 | * had something to say. A queued run reports a completion time it has not reached, so the start | |
| 1592 | * stands in for it rather than sorting the newest run to the bottom. | |
| 1593 | */ | |
| 1594 | function toCheckEntries( | |
| 1595 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1596 | ): ReadonlyArray<{ | |
| 1597 | readonly check: PullRequestCheck; | |
| 1598 | readonly workflowName: string | null; | |
| 1599 | readonly at: string | null; | |
| 1600 | }> { | |
| 1601 | return (raw ?? []).flatMap((check) => { | |
| 1602 | const name = trimmed(check.name) ?? trimmed(check.context); | |
| 1603 | if (name === null) return []; | |
| 1604 | return [ | |
| 1605 | { | |
| 1606 | check: { | |
| 1607 | name, | |
| 1608 | status: toCheckStatus(check), | |
| 1609 | description: trimmed(check.description), | |
| 1610 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), | |
| 1611 | ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), | |
| 1612 | }, | |
| 1613 | workflowName: trimmed(check.workflowName), | |
| 1614 | at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), | |
| 1615 | }, | |
| 1616 | ]; | |
| 1617 | }); | |
| 1618 | } | |
| 1619 | ||
| 1620 | /** | |
| 1621 | * The one word a listing row has space for. A failure outranks anything still running, the way | |
| 1622 | * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. | |
| 1623 | * | |
| 1624 | * Null rather than "passing" for a head commit with no checks at all, so a repository that runs | |
| 1625 | * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as | |
| 1626 | * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count | |
| 1627 | * towards neither, so the row and the detail header never disagree about one head commit. | |
| 1628 | * | |
| 1629 | * Counted off the deduped checks rather than the raw rollup, so the word and the list under it | |
| 1630 | * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is | |
| 1631 | * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one | |
| 1632 | * nameless row, and nothing nameless can collide with anything. | |
| 1633 | */ | |
| 1634 | function rollupChecksState( | |
| 1635 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1636 | ): PullRequestChecksState | null { | |
| 1637 | const statuses = [ | |
| 1638 | ...toChecks(raw).map((check) => check.status), | |
| 1639 | ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), | |
| 1640 | ]; | |
| 1641 | if (statuses.length === 0) return null; | |
| 1642 | if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; | |
| 1643 | if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; | |
| 1644 | return statuses.includes("success") ? "passing" : null; | |
| 1645 | } | |
| 1646 | ||
| 1647 | function toChecks( | |
| 1648 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1649 | ): ReadonlyArray<PullRequestCheck> { | |
| 1650 | return dedupeChecks(toCheckEntries(raw)); | |
| 1651 | } | |
| 1652 | ||
| 1653 | /** The states that are a verdict in themselves, rather than a wrapper around line comments. */ | |
| 1654 | function isReviewVerdict(reviewState: string | null): boolean { | |
| 1655 | switch (reviewState?.toUpperCase()) { | |
| 1656 | case "APPROVED": | |
| 1657 | case "CHANGES_REQUESTED": | |
| 1658 | case "DISMISSED": | |
| 1659 | return true; | |
| 1660 | default: | |
| 1661 | return false; | |
| 1662 | } | |
| 1663 | } | |
| 1664 | ||
| 1665 | function toComments(raw: { | |
| 1666 | readonly comments?: ReadonlyArray<Schema.Schema.Type<typeof RawCommentSchema>> | undefined; | |
| 1667 | readonly reviews?: ReadonlyArray<Schema.Schema.Type<typeof RawReviewSchema>> | undefined; | |
| 1668 | }): ReadonlyArray<PullRequestComment> { | |
| 1669 | const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ | |
| 1670 | id: comment.id, | |
| 1671 | kind: "issue-comment", | |
| 1672 | author: toActor(comment.author), | |
| 1673 | body: comment.body ?? "", | |
| 1674 | createdAt: comment.createdAt, | |
| 1675 | editedAt: comment.lastEditedAt ?? null, | |
| 1676 | url: trimmed(comment.url), | |
| 1677 | path: null, | |
| 1678 | reviewState: null, | |
| 1679 | })); | |
| 1680 | // A review with no body is kept only when its state is the event itself — an approval, a | |
| 1681 | // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the | |
| 1682 | // container for line comments, and those comments are read from the review threads, so | |
| 1683 | // keeping the container too would show a row with a name and nothing under it. | |
| 1684 | const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray<PullRequestComment> => { | |
| 1685 | const submittedAt = trimmed(review.submittedAt); | |
| 1686 | const reviewState = trimmed(review.state); | |
| 1687 | if ( | |
| 1688 | submittedAt === null || | |
| 1689 | ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) | |
| 1690 | ) { | |
| 1691 | return []; | |
| 1692 | } | |
| 1693 | return [ | |
| 1694 | { | |
| 1695 | id: review.id, | |
| 1696 | kind: "review", | |
| 1697 | author: toActor(review.author), | |
| 1698 | body: review.body ?? "", | |
| 1699 | createdAt: submittedAt, | |
| 1700 | editedAt: review.lastEditedAt ?? null, | |
| 1701 | url: trimmed(review.url), | |
| 1702 | path: null, | |
| 1703 | reviewState, | |
| 1704 | }, | |
| 1705 | ]; | |
| 1706 | }); | |
| 1707 | return [...issueComments, ...reviews].toSorted((left, right) => | |
| 1708 | left.createdAt.localeCompare(right.createdAt), | |
| 1709 | ); | |
| 1710 | } | |
| 1711 | ||
| 1712 | function toCommits( | |
| 1713 | commits: ReadonlyArray<Schema.Schema.Type<typeof RawCommitSchema>> | undefined, | |
| 1714 | ): ReadonlyArray<PullRequestCommit> { | |
| 1715 | return (commits ?? []).map((commit) => ({ | |
| 1716 | oid: commit.oid, | |
| 1717 | messageHeadline: commit.messageHeadline ?? "", | |
| 1718 | committedDate: commit.committedDate, | |
| 1719 | authors: (commit.authors ?? []).flatMap((author) => { | |
| 1720 | const actor = toCommitActor(author); | |
| 1721 | return actor === null ? [] : [actor]; | |
| 1722 | }), | |
| 1723 | })); | |
| 1724 | } | |
| 1725 | ||
| 1726 | function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPullRequestListItem { | |
| 1727 | return { | |
| 1728 | authorId: trimmed(raw.author?.id), | |
| 1729 | number: raw.number, | |
| 1730 | title: raw.title, | |
| 1731 | url: raw.url, | |
| 1732 | author: toActor(raw.author), | |
| 1733 | headBranch: raw.headRefName, | |
| 1734 | baseBranch: raw.baseRefName, | |
| 1735 | state: toState(raw), | |
| 1736 | isDraft: raw.isDraft ?? false, | |
| 1737 | mergeability: toMergeability(raw.mergeable), | |
| 1738 | reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), | |
| 1739 | additions: raw.additions ?? 0, | |
| 1740 | deletions: raw.deletions ?? 0, | |
| 1741 | createdAt: raw.createdAt, | |
| 1742 | updatedAt: raw.updatedAt, | |
| 1743 | reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), | |
| 1744 | hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), | |
| 1745 | labels: toLabels(raw.labels), | |
| 1746 | checksState: rollupChecksState(raw.statusCheckRollup), | |
| 1747 | }; | |
| 1748 | } | |
| 1749 | ||
| 1750 | function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail { | |
| 1751 | const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); | |
| 1752 | return { | |
| 1753 | ...toListItem(raw), | |
| 1754 | ...(typeof raw.isCrossRepository === "boolean" | |
| 1755 | ? { isCrossRepository: raw.isCrossRepository } | |
| 1756 | : {}), | |
| 1757 | headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), | |
| 1758 | headSha: trimmed(raw.headRefOid), | |
| 1759 | body: raw.body ?? "", | |
| 1760 | changedFiles: raw.changedFiles ?? 0, | |
| 1761 | mergedAt: trimmed(raw.mergedAt), | |
| 1762 | closedAt: trimmed(raw.closedAt), | |
| 1763 | checks: toChecks(raw.statusCheckRollup), | |
| 1764 | // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and | |
| 1765 | // the difference survives here rather than being flattened into false. | |
| 1766 | ...(raw.autoMergeRequest === undefined | |
| 1767 | ? {} | |
| 1768 | : { autoMergeEnabled: raw.autoMergeRequest !== null }), | |
| 1769 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 1770 | }; | |
| 1771 | } | |
| 1772 | ||
| 1773 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 1774 | const decodeSearch = decodeJsonResult(RawSearchSchema); | |
| 1775 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); | |
| 1776 | const decodeStats = decodeJsonResult(RawStatsSchema); | |
| 1777 | const decodeDetail = decodeJsonResult(RawDetailSchema); | |
| 1778 | const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); | |
| 1779 | const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); | |
| 1780 | const decodeActivity = decodeJsonResult(RawActivitySchema); | |
| 1781 | const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); | |
| 1782 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); | |
| 1783 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); | |
| 1784 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); | |
| 1785 | ||
| 1786 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 1787 | ||
| 1788 | export interface GitHubPullRequestListBatch { | |
| 1789 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 1790 | /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 1791 | readonly rawCount: number; | |
| 1792 | /** Where the next page starts, or null once GitHub has handed over every row. */ | |
| 1793 | readonly endCursor: string | null; | |
| 1794 | } | |
| 1795 | ||
| 1796 | /** | |
| 1797 | * One page of a repository's own pull request list. Malformed rows are skipped rather than | |
| 1798 | * failing the page: one unexpected pull request must not blank the whole list. A repository the | |
| 1799 | * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. | |
| 1800 | */ | |
| 1801 | export function decodePullRequestListJson( | |
| 1802 | raw: string, | |
| 1803 | ): Result.Result<GitHubPullRequestListBatch, DecodeFailure> { | |
| 1804 | const decoded = decodeRepositoryPullRequests(raw); | |
| 1805 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1806 | const connection = decoded.success.data.repository?.pullRequests; | |
| 1807 | const nodes = connection?.nodes ?? []; | |
| 1808 | const items: GitHubPullRequestListItem[] = []; | |
| 1809 | for (const entry of nodes) { | |
| 1810 | const row = toRow(entry); | |
| 1811 | if (row !== null) items.push(row.item); | |
| 1812 | } | |
| 1813 | return Result.succeed({ | |
| 1814 | items, | |
| 1815 | rawCount: nodes.length, | |
| 1816 | endCursor: | |
| 1817 | connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, | |
| 1818 | }); | |
| 1819 | } | |
| 1820 | ||
| 1821 | /** | |
| 1822 | * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as | |
| 1823 | * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull | |
| 1824 | * request, or one whose fields no longer decode. | |
| 1825 | */ | |
| 1826 | function toRow( | |
| 1827 | entry: unknown, | |
| 1828 | ): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { | |
| 1829 | const decodedNode = decodeSearchItem(entry); | |
| 1830 | if (!Exit.isSuccess(decodedNode)) return null; | |
| 1831 | const node = decodedNode.value; | |
| 1832 | const stack = toStackMembership(node); | |
| 1833 | const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( | |
| 1834 | (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { | |
| 1835 | const reviewer = request?.requestedReviewer; | |
| 1836 | const login = trimmed(reviewer?.login); | |
| 1837 | if (login !== null) return [{ login }]; | |
| 1838 | const slug = trimmed(reviewer?.slug); | |
| 1839 | return slug === null ? [] : [{ slug }]; | |
| 1840 | }, | |
| 1841 | ); | |
| 1842 | return { | |
| 1843 | item: { | |
| 1844 | ...toListItem({ | |
| 1845 | ...node, | |
| 1846 | latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => | |
| 1847 | review === null ? [] : [review], | |
| 1848 | ), | |
| 1849 | reviewRequests, | |
| 1850 | labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), | |
| 1851 | // The rollup arrives as one enum. Dressed as a single check here so it is read the same | |
| 1852 | // way the detail's checks are. | |
| 1853 | statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { | |
| 1854 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | |
| 1855 | return state === null ? [] : [{ state }]; | |
| 1856 | }), | |
| 1857 | }), | |
| 1858 | ...(stack === undefined ? {} : { stack }), | |
| 1859 | }, | |
| 1860 | repository: trimmed(node.repository?.nameWithOwner), | |
| 1861 | }; | |
| 1862 | } | |
| 1863 | ||
| 1864 | export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { | |
| 1865 | /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ | |
| 1866 | readonly repository: string; | |
| 1867 | } | |
| 1868 | ||
| 1869 | export interface GitHubPullRequestSearchBatch { | |
| 1870 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 1871 | /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 1872 | readonly rawCount: number; | |
| 1873 | /** More rows than this slice asked for, which is truncation for every repository in it. */ | |
| 1874 | readonly hasNextPage: boolean; | |
| 1875 | /** Where the next page of the same search starts. */ | |
| 1876 | readonly endCursor: string | null; | |
| 1877 | } | |
| 1878 | ||
| 1879 | /** | |
| 1880 | * A search answers with the same row a repository's list does, each naming its repository. | |
| 1881 | * | |
| 1882 | * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing | |
| 1883 | * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. | |
| 1884 | */ | |
| 1885 | export function decodePullRequestSearchJson( | |
| 1886 | raw: string, | |
| 1887 | ): Result.Result<GitHubPullRequestSearchBatch, DecodeFailure> { | |
| 1888 | const decoded = decodeSearch(raw); | |
| 1889 | if (!Result.isSuccess(decoded)) { | |
| 1890 | return Result.fail(decoded.failure); | |
| 1891 | } | |
| 1892 | const search = decoded.success.data.search; | |
| 1893 | const nodes = search.nodes ?? []; | |
| 1894 | const items: GitHubPullRequestSearchItem[] = []; | |
| 1895 | for (const entry of nodes) { | |
| 1896 | const row = toRow(entry); | |
| 1897 | if (row === null || row.repository === null) continue; | |
| 1898 | items.push({ ...row.item, repository: row.repository }); | |
| 1899 | } | |
| 1900 | return Result.succeed({ | |
| 1901 | items, | |
| 1902 | rawCount: nodes.length, | |
| 1903 | hasNextPage: search.pageInfo?.hasNextPage ?? false, | |
| 1904 | endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, | |
| 1905 | }); | |
| 1906 | } | |
| 1907 | ||
| 1908 | function toStackMembership( | |
| 1909 | raw: Schema.Schema.Type<typeof RawStackMembershipSchema>, | |
| 1910 | ): PullRequestStackMembership | undefined { | |
| 1911 | return raw.stack && raw.stackEntry | |
| 1912 | ? { | |
| 1913 | number: raw.stack.number, | |
| 1914 | size: raw.stack.size, | |
| 1915 | base: raw.stack.baseRefName, | |
| 1916 | position: raw.stackEntry.position, | |
| 1917 | } | |
| 1918 | : undefined; | |
| 1919 | } | |
| 1920 | ||
| 1921 | /** What GitHub allows in an owner or repository name. */ | |
| 1922 | const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; | |
| 1923 | ||
| 1924 | /** | |
| 1925 | * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is | |
| 1926 | * one document, and a single unanswerable alias fails all of it, so these are refused up front. | |
| 1927 | */ | |
| 1928 | function repositoryParts( | |
| 1929 | repository: string, | |
| 1930 | ): { readonly owner: string; readonly name: string } | null { | |
| 1931 | const [owner, name, ...rest] = repository.trim().split("/"); | |
| 1932 | if (rest.length > 0 || owner === undefined || name === undefined) return null; | |
| 1933 | return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; | |
| 1934 | } | |
| 1935 | ||
| 1936 | const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; | |
| 1937 | ||
| 1938 | /** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ | |
| 1939 | function aliasedPullRequestsDocument( | |
| 1940 | name: string, | |
| 1941 | alias: string, | |
| 1942 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 1943 | selection: string, | |
| 1944 | ): GraphQlDocument | null { | |
| 1945 | const addressed = changeRequests.map((changeRequest) => ({ | |
| 1946 | parts: repositoryParts(changeRequest.repository), | |
| 1947 | number: changeRequest.number, | |
| 1948 | })); | |
| 1949 | if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { | |
| 1950 | return null; | |
| 1951 | } | |
| 1952 | return aliasedGraphQlDocument({ | |
| 1953 | operation: "query", | |
| 1954 | name, | |
| 1955 | alias, | |
| 1956 | items: addressed, | |
| 1957 | variables: ({ parts, number }) => ({ | |
| 1958 | owner: ["String!", parts!.owner], | |
| 1959 | name: ["String!", parts!.name], | |
| 1960 | number: ["Int!", number], | |
| 1961 | }), | |
| 1962 | field: ({ owner, name, number }) => | |
| 1963 | `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, | |
| 1964 | }); | |
| 1965 | } | |
| 1966 | ||
| 1967 | /** | |
| 1968 | * The line counts for rows a listing already handed over, as one aliased lookup each. | |
| 1969 | * | |
| 1970 | * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a | |
| 1971 | * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, | |
| 1972 | * which the caller reports rather than sends, and for an empty request. | |
| 1973 | */ | |
| 1974 | export function buildPullRequestStatsGraphQlQuery( | |
| 1975 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 1976 | ): GraphQlDocument | null { | |
| 1977 | return aliasedPullRequestsDocument( | |
| 1978 | "PullRequestStats", | |
| 1979 | "s", | |
| 1980 | changeRequests, | |
| 1981 | "additions deletions", | |
| 1982 | ); | |
| 1983 | } | |
| 1984 | ||
| 1985 | /** Stack membership for the visible rows of a per-repository listing. */ | |
| 1986 | export function buildPullRequestStackMembershipsGraphQlQuery( | |
| 1987 | repository: string, | |
| 1988 | numbers: ReadonlyArray<number>, | |
| 1989 | ): GraphQlDocument | null { | |
| 1990 | return aliasedPullRequestsDocument( | |
| 1991 | "PullRequestStackMemberships", | |
| 1992 | "s", | |
| 1993 | numbers.map((number) => ({ repository, number })), | |
| 1994 | STACK_MEMBERSHIP_SELECTION, | |
| 1995 | ); | |
| 1996 | } | |
| 1997 | ||
| 1998 | const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); | |
| 1999 | ||
| 2000 | export function decodePullRequestStackMembershipsJson( | |
| 2001 | raw: string, | |
| 2002 | ): Result.Result<ReadonlyMap<number, PullRequestStackMembership>, DecodeFailure> { | |
| 2003 | const decoded = decodeStackMemberships(raw); | |
| 2004 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2005 | const memberships = new Map<number, PullRequestStackMembership>(); | |
| 2006 | for (const [alias, value] of Object.entries(decoded.success.data)) { | |
| 2007 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2008 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2009 | const stack = toStackMembership(value.pullRequest); | |
| 2010 | if (stack !== undefined) memberships.set(Number(index), stack); | |
| 2011 | } | |
| 2012 | return Result.succeed(memberships); | |
| 2013 | } | |
| 2014 | ||
| 2015 | /** | |
| 2016 | * The counts by the position they were asked in. A repository or a pull request GitHub answered | |
| 2017 | * nothing for is simply absent, which leaves the row with whatever it already had. | |
| 2018 | */ | |
| 2019 | export function decodePullRequestStatsJson( | |
| 2020 | raw: string, | |
| 2021 | ): Result.Result< | |
| 2022 | ReadonlyMap<number, { readonly additions: number; readonly deletions: number }>, | |
| 2023 | DecodeFailure | |
| 2024 | > { | |
| 2025 | const decoded = decodeStats(raw); | |
| 2026 | if (!Result.isSuccess(decoded)) { | |
| 2027 | return Result.fail(decoded.failure); | |
| 2028 | } | |
| 2029 | const stats = new Map<number, { readonly additions: number; readonly deletions: number }>(); | |
| 2030 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2031 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2032 | const pullRequest = value?.pullRequest; | |
| 2033 | if (index === undefined || pullRequest == null) continue; | |
| 2034 | stats.set(Number(index), { | |
| 2035 | additions: pullRequest.additions ?? 0, | |
| 2036 | deletions: pullRequest.deletions ?? 0, | |
| 2037 | }); | |
| 2038 | } | |
| 2039 | return Result.succeed(stats); | |
| 2040 | } | |
| 2041 | ||
| 2042 | /** | |
| 2043 | * The fields a linked thread keeps current, for many pull requests in one aliased read. Same | |
| 2044 | * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup | |
| 2045 | * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. | |
| 2046 | */ | |
| 2047 | const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }"; | |
| 2048 | ||
| 2049 | const PULL_REQUEST_SUMMARY_SELECTION = | |
| 2050 | "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + | |
| 2051 | "updatedAt mergedAt closedAt headRefName baseRefName " + | |
| 2052 | "author { __typename login avatarUrl ... on User { name } } " + | |
| 2053 | "latestReviews(first: 20) { nodes { state author { login } } } " + | |
| 2054 | "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; | |
| 2055 | ||
| 2056 | /** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ | |
| 2057 | export function buildPullRequestSummariesGraphQlQuery( | |
| 2058 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 2059 | includeStacks = false, | |
| 2060 | ): GraphQlDocument | null { | |
| 2061 | return aliasedPullRequestsDocument( | |
| 2062 | "PullRequestSummaries", | |
| 2063 | "s", | |
| 2064 | changeRequests, | |
| 2065 | `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, | |
| 2066 | ); | |
| 2067 | } | |
| 2068 | ||
| 2069 | /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ | |
| 2070 | export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { | |
| 2071 | return `query($owner: String!, $name: String!, $number: Int!) { | |
| 2072 | s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } | |
| 2073 | }`; | |
| 2074 | } | |
| 2075 | ||
| 2076 | const RawSummarySchema = Schema.Struct({ | |
| 2077 | ...RawSearchItemSchema.fields, | |
| 2078 | changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2079 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2080 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2081 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2082 | createdAt: Schema.optional(Schema.String), | |
| 2083 | }); | |
| 2084 | const decodeSummaries = decodeJsonResult( | |
| 2085 | Schema.Struct({ | |
| 2086 | data: Schema.optional( | |
| 2087 | Schema.NullOr( | |
| 2088 | Schema.Record( | |
| 2089 | Schema.String, | |
| 2090 | Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), | |
| 2091 | ), | |
| 2092 | ), | |
| 2093 | ), | |
| 2094 | }), | |
| 2095 | ); | |
| 2096 | const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); | |
| 2097 | ||
| 2098 | export interface GitHubPullRequestSummary { | |
| 2099 | readonly number: number; | |
| 2100 | readonly title: string; | |
| 2101 | readonly url: string; | |
| 2102 | readonly headBranch: string; | |
| 2103 | readonly baseBranch: string; | |
| 2104 | readonly state: PullRequestState; | |
| 2105 | readonly isDraft: boolean; | |
| 2106 | readonly closedAt: string | null; | |
| 2107 | readonly mergedAt: string | null; | |
| 2108 | readonly updatedAt: string; | |
| 2109 | readonly author: PullRequestActor | null; | |
| 2110 | readonly additions: number; | |
| 2111 | readonly deletions: number; | |
| 2112 | readonly changedFiles: number; | |
| 2113 | readonly reviewDecision: PullRequestReviewDecision | null; | |
| 2114 | readonly checksState: PullRequestChecksState | null; | |
| 2115 | readonly mergeability: PullRequestMergeability; | |
| 2116 | /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ | |
| 2117 | readonly stack?: PullRequestStackMembership | null; | |
| 2118 | } | |
| 2119 | ||
| 2120 | /** | |
| 2121 | * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or | |
| 2122 | * one whose fields no longer decode, is absent rather than failing the rest of the batch. | |
| 2123 | */ | |
| 2124 | export function decodePullRequestSummariesJson( | |
| 2125 | raw: string, | |
| 2126 | ): Result.Result<ReadonlyMap<number, GitHubPullRequestSummary>, DecodeFailure> { | |
| 2127 | const decoded = decodeSummaries(raw); | |
| 2128 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2129 | const summaries = new Map<number, GitHubPullRequestSummary>(); | |
| 2130 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2131 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2132 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2133 | const entry = decodeSummaryEntry(value.pullRequest); | |
| 2134 | if (!Exit.isSuccess(entry)) continue; | |
| 2135 | const pr = entry.value; | |
| 2136 | summaries.set(Number(index), { | |
| 2137 | number: pr.number, | |
| 2138 | title: pr.title, | |
| 2139 | url: pr.url, | |
| 2140 | headBranch: pr.headRefName, | |
| 2141 | baseBranch: pr.baseRefName, | |
| 2142 | state: toState(pr), | |
| 2143 | isDraft: pr.isDraft ?? false, | |
| 2144 | closedAt: trimmed(pr.closedAt), | |
| 2145 | mergedAt: trimmed(pr.mergedAt), | |
| 2146 | updatedAt: pr.updatedAt, | |
| 2147 | author: toActor(pr.author), | |
| 2148 | additions: pr.additions ?? 0, | |
| 2149 | deletions: pr.deletions ?? 0, | |
| 2150 | changedFiles: pr.changedFiles ?? 0, | |
| 2151 | reviewDecision: toReviewDecisionWithReviews( | |
| 2152 | pr.reviewDecision, | |
| 2153 | (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), | |
| 2154 | ), | |
| 2155 | // One enum for the head commit, dressed as a single check like the search row's. | |
| 2156 | checksState: rollupChecksState( | |
| 2157 | (pr.commits?.nodes ?? []).flatMap((commitNode) => { | |
| 2158 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | |
| 2159 | return state === null ? [] : [{ state }]; | |
| 2160 | }), | |
| 2161 | ), | |
| 2162 | mergeability: toMergeability(pr.mergeable), | |
| 2163 | ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), | |
| 2164 | }); | |
| 2165 | } | |
| 2166 | return Result.succeed(summaries); | |
| 2167 | } | |
| 2168 | ||
| 2169 | const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }"; | |
| 2170 | const WATCH_FINGERPRINT_CHECK_COUNTS = | |
| 2171 | "checkRunCountsByState { state count } statusContextCountsByState { state count }"; | |
| 2172 | ||
| 2173 | /** | |
| 2174 | * What a pull request watch needs to notice, priced by GitHub at one point for twenty-five pull | |
| 2175 | * requests, where the detail and activity reads it gates cost sixteen for one. Counts and the | |
| 2176 | * newest edit cover new comments, reviews (a reply in a review thread is a review), threads, and | |
| 2177 | * a bot rewriting its summary; check counts by state move whenever a check starts or finishes. | |
| 2178 | * Comments come most recently updated first, since an edit moves `updatedAt`, so an edit to an | |
| 2179 | * old comment on a long pull request is still in the page. Reviews have no such order: an edit | |
| 2180 | * to a review older than the last hundred waits for the watch's periodic full read, as do edits | |
| 2181 | * to comments inside review threads, which cost a point per pull request to ask for. | |
| 2182 | */ | |
| 2183 | const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = | |
| 2184 | "state mergeable headRefOid " + | |
| 2185 | `comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { ${WATCH_FINGERPRINT_EDITS} } ` + | |
| 2186 | `reviews(last: 100) { ${WATCH_FINGERPRINT_EDITS} } ` + | |
| 2187 | "reviewThreads { totalCount } " + | |
| 2188 | `commits(last: 1) { nodes { commit { statusCheckRollup { contexts { ${WATCH_FINGERPRINT_CHECK_COUNTS} } } } } }`; | |
| 2189 | ||
| 2190 | /** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ | |
| 2191 | export function buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 2192 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 2193 | ): GraphQlDocument | null { | |
| 2194 | return aliasedPullRequestsDocument( | |
| 2195 | "PullRequestWatchFingerprints", | |
| 2196 | "w", | |
| 2197 | changeRequests, | |
| 2198 | PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, | |
| 2199 | ); | |
| 2200 | } | |
| 2201 | ||
| 2202 | /** | |
| 2203 | * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks) | |
| 2204 | * needs the detail read, and `remarks` also needs the far costlier activity read. | |
| 2205 | */ | |
| 2206 | export interface GitHubPullRequestWatchFingerprint { | |
| 2207 | readonly status: string; | |
| 2208 | readonly remarks: string; | |
| 2209 | } | |
| 2210 | ||
| 2211 | const RawEditedSchema = Schema.Struct({ | |
| 2212 | totalCount: Schema.Int, | |
| 2213 | nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))), | |
| 2214 | }); | |
| 2215 | const RawStateCountsSchema = Schema.NullOr( | |
| 2216 | Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })), | |
| 2217 | ); | |
| 2218 | const decodeWatchFingerprintEntry = Schema.decodeUnknownExit( | |
| 2219 | Schema.Struct({ | |
| 2220 | state: Schema.String, | |
| 2221 | mergeable: Schema.NullOr(Schema.String), | |
| 2222 | headRefOid: Schema.String, | |
| 2223 | comments: RawEditedSchema, | |
| 2224 | reviews: RawEditedSchema, | |
| 2225 | reviewThreads: Schema.Struct({ totalCount: Schema.Int }), | |
| 2226 | commits: Schema.Struct({ | |
| 2227 | nodes: Schema.Array( | |
| 2228 | Schema.NullOr( | |
| 2229 | Schema.Struct({ | |
| 2230 | commit: Schema.Struct({ | |
| 2231 | statusCheckRollup: Schema.NullOr( | |
| 2232 | Schema.Struct({ | |
| 2233 | contexts: Schema.Struct({ | |
| 2234 | checkRunCountsByState: RawStateCountsSchema, | |
| 2235 | statusContextCountsByState: RawStateCountsSchema, | |
| 2236 | }), | |
| 2237 | }), | |
| 2238 | ), | |
| 2239 | }), | |
| 2240 | }), | |
| 2241 | ), | |
| 2242 | ), | |
| 2243 | }), | |
| 2244 | }), | |
| 2245 | ); | |
| 2246 | ||
| 2247 | // An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all. | |
| 2248 | const newestEdit = (connection: typeof RawEditedSchema.Type) => | |
| 2249 | connection.nodes.reduce( | |
| 2250 | (newest, node) => | |
| 2251 | node?.lastEditedAt != null && node.lastEditedAt > newest ? node.lastEditedAt : newest, | |
| 2252 | "", | |
| 2253 | ); | |
| 2254 | ||
| 2255 | const stateCounts = (counts: typeof RawStateCountsSchema.Type) => | |
| 2256 | (counts ?? []) | |
| 2257 | .filter(({ count }) => count > 0) | |
| 2258 | .map(({ state, count }) => `${state}:${count}`) | |
| 2259 | .toSorted() | |
| 2260 | .join(","); | |
| 2261 | ||
| 2262 | /** Fingerprints by alias index; a pull request GitHub returned nothing for is left out. */ | |
| 2263 | export function decodePullRequestWatchFingerprintsJson( | |
| 2264 | raw: string, | |
| 2265 | ): Result.Result<ReadonlyMap<number, GitHubPullRequestWatchFingerprint>, DecodeFailure> { | |
| 2266 | const decoded = decodeSummaries(raw); | |
| 2267 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2268 | const fingerprints = new Map<number, GitHubPullRequestWatchFingerprint>(); | |
| 2269 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2270 | const index = /^w(\d+)$/.exec(alias)?.[1]; | |
| 2271 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2272 | const entry = decodeWatchFingerprintEntry(value.pullRequest); | |
| 2273 | if (!Exit.isSuccess(entry)) continue; | |
| 2274 | const pr = entry.value; | |
| 2275 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2276 | fingerprints.set(Number(index), { | |
| 2277 | status: [ | |
| 2278 | pr.state, | |
| 2279 | pr.mergeable ?? "", | |
| 2280 | pr.headRefOid, | |
| 2281 | stateCounts(contexts?.checkRunCountsByState ?? null), | |
| 2282 | stateCounts(contexts?.statusContextCountsByState ?? null), | |
| 2283 | ].join(" "), | |
| 2284 | remarks: [ | |
| 2285 | pr.comments.totalCount, | |
| 2286 | newestEdit(pr.comments), | |
| 2287 | pr.reviews.totalCount, | |
| 2288 | newestEdit(pr.reviews), | |
| 2289 | pr.reviewThreads.totalCount, | |
| 2290 | ].join(" "), | |
| 2291 | }); | |
| 2292 | } | |
| 2293 | return Result.succeed(fingerprints); | |
| 2294 | } | |
| 2295 | ||
| 2296 | export interface GitHubPullRequestCore extends GitHubPullRequestDetail { | |
| 2297 | readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; | |
| 2298 | readonly comparison: GitHubBaseComparison | null; | |
| 2299 | readonly checksTruncated: boolean; | |
| 2300 | } | |
| 2301 | ||
| 2302 | export function decodePullRequestCoreJson( | |
| 2303 | raw: string, | |
| 2304 | ): Result.Result<GitHubPullRequestCore, DecodeFailure> { | |
| 2305 | const decoded = decodeCore(raw); | |
| 2306 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2307 | const repository = decoded.success.data.repository; | |
| 2308 | const pr = repository.pullRequest; | |
| 2309 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2310 | return Result.succeed({ | |
| 2311 | ...toDetail({ | |
| 2312 | ...pr, | |
| 2313 | reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => | |
| 2314 | requestedReviewer === null ? [] : [requestedReviewer], | |
| 2315 | ), | |
| 2316 | labels: pr.labels.nodes, | |
| 2317 | statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), | |
| 2318 | }), | |
| 2319 | viewerAccess: { | |
| 2320 | canWrite: toCanWrite(repository.viewerPermission), | |
| 2321 | canTriage: toCanTriage(repository.viewerPermission), | |
| 2322 | ...toPullRequestViewerFields(pr), | |
| 2323 | mergeCapabilities: { | |
| 2324 | merge: repository.mergeCommitAllowed, | |
| 2325 | squash: repository.squashMergeAllowed, | |
| 2326 | rebase: repository.rebaseMergeAllowed, | |
| 2327 | }, | |
| 2328 | }, | |
| 2329 | comparison: | |
| 2330 | pr.state !== "OPEN" || pr.baseRef?.compare == null | |
| 2331 | ? null | |
| 2332 | : { | |
| 2333 | behindBy: pr.baseRef.compare.behindBy, | |
| 2334 | viewerCanUpdate: pr.viewerCanUpdateBranch, | |
| 2335 | }, | |
| 2336 | checksTruncated: contexts?.pageInfo.hasNextPage === true, | |
| 2337 | }); | |
| 2338 | } | |
| 2339 | ||
| 2340 | export function decodePullRequestDetailJson( | |
| 2341 | raw: string, | |
| 2342 | ): Result.Result<GitHubPullRequestDetail, DecodeFailure> { | |
| 2343 | const decoded = decodeDetail(raw); | |
| 2344 | return Result.isSuccess(decoded) | |
| 2345 | ? Result.succeed(toDetail(decoded.success)) | |
| 2346 | : Result.fail(decoded.failure); | |
| 2347 | } | |
| 2348 | ||
| 2349 | export interface GitHubWorkflowRunPage { | |
| 2350 | readonly runs: ReadonlyArray<GitHubWorkflowRunApproval>; | |
| 2351 | /** Runs on the page, counted before decoding, which is what decides whether to page on. */ | |
| 2352 | readonly rawCount: number; | |
| 2353 | } | |
| 2354 | ||
| 2355 | /** One page of `actions/runs`. */ | |
| 2356 | export function decodeWorkflowRunsJson( | |
| 2357 | raw: string, | |
| 2358 | ): Result.Result<GitHubWorkflowRunPage, DecodeFailure> { | |
| 2359 | const decoded = decodeWorkflowRuns(raw); | |
| 2360 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2361 | return Result.succeed({ | |
| 2362 | runs: decoded.success.workflow_runs.map((run) => ({ | |
| 2363 | id: run.id, | |
| 2364 | name: trimmed(run.name) ?? `Workflow run ${run.id}`, | |
| 2365 | url: trimmed(run.html_url), | |
| 2366 | })), | |
| 2367 | rawCount: decoded.success.workflow_runs.length, | |
| 2368 | }); | |
| 2369 | } | |
| 2370 | ||
| 2371 | /** Open pull requests whose head branch has one name, from whichever repository it lives in. */ | |
| 2372 | export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { | |
| 2373 | repository(owner: $owner, name: $name) { | |
| 2374 | pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { | |
| 2375 | pageInfo { hasNextPage endCursor } | |
| 2376 | nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } | |
| 2377 | } | |
| 2378 | } | |
| 2379 | }`; | |
| 2380 | ||
| 2381 | export function decodePullRequestHeadsJson(raw: string): Result.Result< | |
| 2382 | { | |
| 2383 | readonly heads: ReadonlyArray<GitHubPullRequestHead>; | |
| 2384 | readonly nextCursor: string | null; | |
| 2385 | }, | |
| 2386 | DecodeFailure | |
| 2387 | > { | |
| 2388 | const decoded = decodePullRequestHeads(raw); | |
| 2389 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2390 | const connection = decoded.success.data.repository?.pullRequests; | |
| 2391 | return Result.succeed({ | |
| 2392 | heads: (connection?.nodes ?? []).map((pullRequest) => ({ | |
| 2393 | number: pullRequest.number, | |
| 2394 | headSha: pullRequest.headRefOid, | |
| 2395 | ...(typeof pullRequest.isCrossRepository === "boolean" | |
| 2396 | ? { isCrossRepository: pullRequest.isCrossRepository } | |
| 2397 | : {}), | |
| 2398 | headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), | |
| 2399 | })), | |
| 2400 | nextCursor: nextCursorOf(connection?.pageInfo), | |
| 2401 | }); | |
| 2402 | } | |
| 2403 | ||
| 2404 | export interface GitHubPullRequestActivityPage { | |
| 2405 | /** Only on the first page, which is the one that asks for them. */ | |
| 2406 | readonly author?: PullRequestActor | null; | |
| 2407 | readonly commits?: ReadonlyArray<PullRequestCommit>; | |
| 2408 | /** Issue comments and reviews, each list unsorted. */ | |
| 2409 | readonly remarks: ReadonlyArray<PullRequestComment>; | |
| 2410 | readonly nextCommentsCursor: string | null; | |
| 2411 | readonly nextReviewsCursor: string | null; | |
| 2412 | } | |
| 2413 | ||
| 2414 | /** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ | |
| 2415 | export function decodePullRequestActivityJson( | |
| 2416 | raw: string, | |
| 2417 | ): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> { | |
| 2418 | const decoded = decodeActivity(raw); | |
| 2419 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2420 | const pr = decoded.success.data.repository.pullRequest; | |
| 2421 | return Result.succeed({ | |
| 2422 | ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), | |
| 2423 | ...(pr.commits === undefined | |
| 2424 | ? {} | |
| 2425 | : { | |
| 2426 | commits: toCommits( | |
| 2427 | (pr.commits?.nodes ?? []).map(({ commit }) => ({ | |
| 2428 | ...commit, | |
| 2429 | authors: (commit.authors?.nodes ?? []).map((author) => ({ | |
| 2430 | ...author, | |
| 2431 | login: author.user?.login ?? null, | |
| 2432 | })), | |
| 2433 | })), | |
| 2434 | ), | |
| 2435 | }), | |
| 2436 | remarks: toComments({ | |
| 2437 | comments: pr.comments?.nodes ?? [], | |
| 2438 | reviews: pr.reviews?.nodes ?? [], | |
| 2439 | }), | |
| 2440 | nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), | |
| 2441 | nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), | |
| 2442 | }); | |
| 2443 | } | |
| 2444 | ||
| 2445 | /** Every check context of the head commit, a page at a time, for a rollup past one page. */ | |
| 2446 | export const pullRequestCheckContextsGraphQlQuery = (host: string) => | |
| 2447 | `query($owner: String!, $name: String!, $number: Int!, $after: String) { | |
| 2448 | repository(owner: $owner, name: $name) { | |
| 2449 | pullRequest(number: $number) { | |
| 2450 | headRefOid | |
| 2451 | commits(last: 1) { | |
| 2452 | nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { | |
| 2453 | ${checkContextNodesSelection(host)} | |
| 2454 | pageInfo { hasNextPage endCursor } | |
| 2455 | } } } } | |
| 2456 | } | |
| 2457 | } | |
| 2458 | } | |
| 2459 | }`; | |
| 2460 | ||
| 2461 | const RawCheckContextNodeSchema = Schema.Struct({ | |
| 2462 | ...RawCheckSchema.fields, | |
| 2463 | checkSuite: Schema.optional( | |
| 2464 | Schema.NullOr( | |
| 2465 | Schema.Struct({ | |
| 2466 | workflowRun: Schema.NullOr( | |
| 2467 | Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), | |
| 2468 | ), | |
| 2469 | }), | |
| 2470 | ), | |
| 2471 | ), | |
| 2472 | }); | |
| 2473 | ||
| 2474 | const decodeCheckContexts = decodeJsonResult( | |
| 2475 | Schema.Struct({ | |
| 2476 | data: Schema.Struct({ | |
| 2477 | repository: Schema.Struct({ | |
| 2478 | pullRequest: Schema.Struct({ | |
| 2479 | headRefOid: Schema.String, | |
| 2480 | commits: Schema.Struct({ | |
| 2481 | nodes: Schema.Array( | |
| 2482 | Schema.Struct({ | |
| 2483 | commit: Schema.Struct({ | |
| 2484 | statusCheckRollup: Schema.NullOr( | |
| 2485 | Schema.Struct({ | |
| 2486 | contexts: Schema.Struct({ | |
| 2487 | nodes: Schema.Array(RawCheckContextNodeSchema), | |
| 2488 | pageInfo: RawPageInfoSchema, | |
| 2489 | }), | |
| 2490 | }), | |
| 2491 | ), | |
| 2492 | }), | |
| 2493 | }), | |
| 2494 | ), | |
| 2495 | }), | |
| 2496 | }), | |
| 2497 | }), | |
| 2498 | }), | |
| 2499 | }), | |
| 2500 | ); | |
| 2501 | ||
| 2502 | /** A check context as the rollup reports it; opaque outside this module. */ | |
| 2503 | export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>; | |
| 2504 | ||
| 2505 | function toCheckContexts( | |
| 2506 | nodes: ReadonlyArray<Schema.Schema.Type<typeof RawCheckContextNodeSchema>>, | |
| 2507 | ): ReadonlyArray<GitHubCheckContext> { | |
| 2508 | return nodes.map((check) => ({ | |
| 2509 | ...check, | |
| 2510 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | |
| 2511 | })); | |
| 2512 | } | |
| 2513 | ||
| 2514 | export function decodePullRequestCheckContextsJson(raw: string): Result.Result< | |
| 2515 | { | |
| 2516 | readonly headSha: string; | |
| 2517 | readonly contexts: ReadonlyArray<GitHubCheckContext>; | |
| 2518 | readonly nextCursor: string | null; | |
| 2519 | }, | |
| 2520 | DecodeFailure | |
| 2521 | > { | |
| 2522 | const decoded = decodeCheckContexts(raw); | |
| 2523 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2524 | const pr = decoded.success.data.repository.pullRequest; | |
| 2525 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2526 | return Result.succeed({ | |
| 2527 | headSha: pr.headRefOid, | |
| 2528 | contexts: toCheckContexts(contexts?.nodes ?? []), | |
| 2529 | nextCursor: nextCursorOf(contexts?.pageInfo), | |
| 2530 | }); | |
| 2531 | } | |
| 2532 | ||
| 2533 | /** The checks and their one-word verdict, from every context of the head commit. */ | |
| 2534 | export function pullRequestChecksFromContexts(contexts: ReadonlyArray<GitHubCheckContext>): { | |
| 2535 | readonly checks: ReadonlyArray<PullRequestCheck>; | |
| 2536 | readonly checksState: PullRequestChecksState | null; | |
| 2537 | } { | |
| 2538 | return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; | |
| 2539 | } | |
| 2540 | ||
| 2541 | export interface GitHubReviewThreadComments { | |
| 2542 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 2543 | /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ | |
| 2544 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2545 | /** Whole conversations, kept anchored so the diff can pin them to their line. */ | |
| 2546 | readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>; | |
| 2547 | /** The host's own count of the conversation, which a bounded read can fall short of. */ | |
| 2548 | readonly commentCount: number; | |
| 2549 | readonly truncated: boolean; | |
| 2550 | readonly reviewThreadsTruncated: boolean; | |
| 2551 | /** The pull request's own reactions, which sit on its description. */ | |
| 2552 | readonly reactions: ReadonlyArray<PullRequestReaction>; | |
| 2553 | /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ | |
| 2554 | readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | |
| 2555 | readonly editedAtById: ReadonlyMap<string, string>; | |
| 2556 | /** | |
| 2557 | * Everyone on the review: those still asked and those who have already answered. Whoever has | |
| 2558 | * reviewed is no longer an outstanding request, so asking only for requests reports nobody on | |
| 2559 | * a pull request that has in fact been reviewed. | |
| 2560 | */ | |
| 2561 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 2562 | /** | |
| 2563 | * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of | |
| 2564 | * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, | |
| 2565 | * so an app's avatar arrives the same way a person's does. | |
| 2566 | */ | |
| 2567 | readonly avatarsByLogin: ReadonlyMap<string, string>; | |
| 2568 | readonly botLogins: ReadonlySet<string>; | |
| 2569 | /** Per-commit line counts carried by the same bounded pull-request query. */ | |
| 2570 | readonly commitStats: ReadonlyMap< | |
| 2571 | string, | |
| 2572 | { readonly additions: number; readonly deletions: number } | |
| 2573 | >; | |
| 2574 | /** | |
| 2575 | * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. | |
| 2576 | * Empty wherever the read never happened (an install too old for the field, a degraded page), | |
| 2577 | * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has | |
| 2578 | * no commits". | |
| 2579 | */ | |
| 2580 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 2581 | /** What GitHub says the reader may do with this pull request, read off the same response. */ | |
| 2582 | readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; | |
| 2583 | } | |
| 2584 | ||
| 2585 | /** One thread as this page found it, with what it takes to finish reading it. */ | |
| 2586 | export interface GitHubReviewThreadEntry { | |
| 2587 | readonly thread: PullRequestReviewThread; | |
| 2588 | /** How many comments GitHub says the thread holds, read or not. */ | |
| 2589 | readonly commentCount: number; | |
| 2590 | /** Where the rest of this thread's comments carry on from, or null once it is whole. */ | |
| 2591 | readonly nextCommentCursor: string | null; | |
| 2592 | } | |
| 2593 | ||
| 2594 | export interface GitHubReviewThreadPage { | |
| 2595 | readonly threads: ReadonlyArray<GitHubReviewThreadEntry>; | |
| 2596 | /** Where the next page of threads starts, or null once the host has handed them all over. */ | |
| 2597 | readonly nextCursor: string | null; | |
| 2598 | /** The pull request's own reactions, which sit on its description. */ | |
| 2599 | readonly reactions: ReadonlyArray<PullRequestReaction>; | |
| 2600 | /** | |
| 2601 | * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers | |
| 2602 | * for without any. Only ids with a reaction are here; the rest carry none. | |
| 2603 | */ | |
| 2604 | readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | |
| 2605 | readonly editedAtById: ReadonlyMap<string, string>; | |
| 2606 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 2607 | readonly avatarsByLogin: ReadonlyMap<string, string>; | |
| 2608 | readonly botLogins: ReadonlySet<string>; | |
| 2609 | readonly commitStats: ReadonlyMap< | |
| 2610 | string, | |
| 2611 | { readonly additions: number; readonly deletions: number } | |
| 2612 | >; | |
| 2613 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 2614 | readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; | |
| 2615 | /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ | |
| 2616 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2617 | /** Where the rest of the dismissal events start, or null once this page carried them all. */ | |
| 2618 | readonly nextDismissalCursor: string | null; | |
| 2619 | } | |
| 2620 | ||
| 2621 | /** | |
| 2622 | * The threads as one flat conversation, which is what the timeline reads. Every comment of | |
| 2623 | * every thread, resolved or not: a resolved conversation is still what was said, and a reply is | |
| 2624 | * as much of it as the remark it answers. | |
| 2625 | */ | |
| 2626 | export function reviewThreadConversation( | |
| 2627 | threads: ReadonlyArray<PullRequestReviewThread>, | |
| 2628 | ): ReadonlyArray<PullRequestComment> { | |
| 2629 | return threads.flatMap((thread) => | |
| 2630 | thread.comments.map((comment): PullRequestComment => ({ | |
| 2631 | id: comment.id, | |
| 2632 | kind: "review-comment", | |
| 2633 | author: comment.author, | |
| 2634 | body: comment.body, | |
| 2635 | createdAt: comment.createdAt, | |
| 2636 | editedAt: comment.editedAt ?? null, | |
| 2637 | url: comment.url, | |
| 2638 | path: thread.path, | |
| 2639 | reviewState: null, | |
| 2640 | reactions: comment.reactions ?? [], | |
| 2641 | })), | |
| 2642 | ); | |
| 2643 | } | |
| 2644 | ||
| 2645 | /** One page of review threads. Following the cursors it hands back is the caller's job. */ | |
| 2646 | function toDismissalEntries( | |
| 2647 | nodes: | |
| 2648 | | ReadonlyArray<{ | |
| 2649 | readonly dismissalMessage?: string | null | undefined; | |
| 2650 | readonly review?: { readonly id?: string | null | undefined } | null | undefined; | |
| 2651 | }> | |
| 2652 | | undefined, | |
| 2653 | ): Map<string, string> { | |
| 2654 | const entries = new Map<string, string>(); | |
| 2655 | for (const node of nodes ?? []) { | |
| 2656 | const reviewId = trimmed(node.review?.id); | |
| 2657 | const message = trimmed(node.dismissalMessage); | |
| 2658 | if (reviewId !== null && message !== null) entries.set(reviewId, message); | |
| 2659 | } | |
| 2660 | return entries; | |
| 2661 | } | |
| 2662 | ||
| 2663 | const RawReviewDismissalsSchema = Schema.Struct({ | |
| 2664 | data: Schema.Struct({ | |
| 2665 | repository: Schema.Struct({ | |
| 2666 | pullRequest: Schema.Struct({ | |
| 2667 | timelineItems: Schema.Struct({ | |
| 2668 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 2669 | nodes: Schema.Array( | |
| 2670 | Schema.Struct({ | |
| 2671 | dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2672 | review: Schema.optional( | |
| 2673 | Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 2674 | ), | |
| 2675 | }), | |
| 2676 | ), | |
| 2677 | }), | |
| 2678 | }), | |
| 2679 | }), | |
| 2680 | }), | |
| 2681 | }); | |
| 2682 | ||
| 2683 | const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); | |
| 2684 | ||
| 2685 | /** One further page of dismissal events, in the shape the thread read's own page carries. */ | |
| 2686 | export function decodeReviewDismissalsJson(raw: string): Result.Result< | |
| 2687 | { | |
| 2688 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2689 | readonly nextCursor: string | null; | |
| 2690 | }, | |
| 2691 | DecodeFailure | |
| 2692 | > { | |
| 2693 | const decoded = decodeReviewDismissals(raw); | |
| 2694 | if (!Result.isSuccess(decoded)) { | |
| 2695 | return Result.fail(decoded.failure); | |
| 2696 | } | |
| 2697 | const items = decoded.success.data.repository.pullRequest.timelineItems; | |
| 2698 | return Result.succeed({ | |
| 2699 | dismissalsByReviewId: toDismissalEntries(items.nodes), | |
| 2700 | nextCursor: nextCursorOf(items.pageInfo), | |
| 2701 | }); | |
| 2702 | } | |
| 2703 | ||
| 2704 | export function decodeReviewThreadsJson( | |
| 2705 | raw: string, | |
| 2706 | ): Result.Result<GitHubReviewThreadPage, DecodeFailure> { | |
| 2707 | const decoded = decodeReviewThreads(raw); | |
| 2708 | if (!Result.isSuccess(decoded)) { | |
| 2709 | return Result.fail(decoded.failure); | |
| 2710 | } | |
| 2711 | const viewer = trimmed(decoded.success.data.viewer?.login); | |
| 2712 | const threads = decoded.success.data.repository.pullRequest.reviewThreads; | |
| 2713 | const entries = threads.nodes.flatMap((thread): ReadonlyArray<GitHubReviewThreadEntry> => { | |
| 2714 | const path = trimmed(thread.path); | |
| 2715 | const id = trimmed(thread.id); | |
| 2716 | if (path === null || id === null || thread.comments.nodes.length === 0) return []; | |
| 2717 | return [ | |
| 2718 | { | |
| 2719 | thread: { | |
| 2720 | id, | |
| 2721 | path, | |
| 2722 | // Null once the thread's line has left the diff, which is exactly when GitHub reports | |
| 2723 | // it outdated. Such a thread is listed rather than pinned to a line it no longer has. | |
| 2724 | line: | |
| 2725 | thread.line !== null && thread.line !== undefined && thread.line > 0 | |
| 2726 | ? thread.line | |
| 2727 | : null, | |
| 2728 | side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", | |
| 2729 | isResolved: thread.isResolved === true, | |
| 2730 | isOutdated: thread.isOutdated === true, | |
| 2731 | comments: thread.comments.nodes.map((comment) => ({ | |
| 2732 | id: comment.id, | |
| 2733 | author: toActor(comment.author), | |
| 2734 | body: comment.body ?? "", | |
| 2735 | createdAt: comment.createdAt, | |
| 2736 | editedAt: comment.lastEditedAt ?? null, | |
| 2737 | url: trimmed(comment.url), | |
| 2738 | reactions: toReactions(comment.reactionGroups, viewer), | |
| 2739 | })), | |
| 2740 | }, | |
| 2741 | commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, | |
| 2742 | nextCommentCursor: nextCursorOf(thread.comments.pageInfo), | |
| 2743 | }, | |
| 2744 | ]; | |
| 2745 | }); | |
| 2746 | const pullRequest = decoded.success.data.repository.pullRequest; | |
| 2747 | const avatarsByLogin = new Map<string, string>(); | |
| 2748 | const botLogins = new Set<string>(); | |
| 2749 | for (const raw of [ | |
| 2750 | pullRequest.author, | |
| 2751 | ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), | |
| 2752 | ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author), | |
| 2753 | ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), | |
| 2754 | ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), | |
| 2755 | ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), | |
| 2756 | ]) { | |
| 2757 | const login = trimmed(raw?.login); | |
| 2758 | const avatarUrl = trimmed(raw?.avatarUrl); | |
| 2759 | if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); | |
| 2760 | if (login !== null && toActor(raw)?.isBot) botLogins.add(login); | |
| 2761 | } | |
| 2762 | const reviewers = new Map<string, PullRequestActor>(); | |
| 2763 | for (const raw of [ | |
| 2764 | ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), | |
| 2765 | ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), | |
| 2766 | ]) { | |
| 2767 | const actor = toActor(raw); | |
| 2768 | // Keyed by login, so someone who was asked and then answered appears once. | |
| 2769 | if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); | |
| 2770 | } | |
| 2771 | const commitStats = new Map<string, { readonly additions: number; readonly deletions: number }>(); | |
| 2772 | const commits: PullRequestCommit[] = []; | |
| 2773 | for (const node of pullRequest.commits?.nodes ?? []) { | |
| 2774 | const commit = node.commit; | |
| 2775 | const oid = trimmed(commit.oid); | |
| 2776 | if (oid === null) continue; | |
| 2777 | // GitHub measures a merge commit against its first parent, so merging the base into the head | |
| 2778 | // reports every upstream change as if it belonged to the pull request. There is no useful | |
| 2779 | // per-commit stat to show for that integration commit without another comparison request. | |
| 2780 | if ( | |
| 2781 | (commit.parents?.totalCount ?? 1) <= 1 && | |
| 2782 | commit.additions !== undefined && | |
| 2783 | commit.deletions !== undefined | |
| 2784 | ) { | |
| 2785 | commitStats.set(oid, { | |
| 2786 | additions: Math.max(0, commit.additions), | |
| 2787 | deletions: Math.max(0, commit.deletions), | |
| 2788 | }); | |
| 2789 | } | |
| 2790 | const committedDate = trimmed(commit.committedDate); | |
| 2791 | if (committedDate === null) continue; | |
| 2792 | commits.push({ | |
| 2793 | oid, | |
| 2794 | messageHeadline: commit.messageHeadline ?? "", | |
| 2795 | committedDate, | |
| 2796 | authors: (commit.authors?.nodes ?? []).flatMap((author) => { | |
| 2797 | const actor = toGraphqlCommitActor(author); | |
| 2798 | return actor === null ? [] : [actor]; | |
| 2799 | }), | |
| 2800 | }); | |
| 2801 | } | |
| 2802 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2803 | const editedAtById = new Map<string, string>(); | |
| 2804 | for (const node of [ | |
| 2805 | ...(pullRequest.comments?.nodes ?? []), | |
| 2806 | ...(pullRequest.reviews?.nodes ?? []), | |
| 2807 | ]) { | |
| 2808 | const id = trimmed(node.id); | |
| 2809 | if (id === null) continue; | |
| 2810 | const editedAt = trimmed(node.lastEditedAt); | |
| 2811 | if (editedAt !== null) editedAtById.set(id, editedAt); | |
| 2812 | const reactions = toReactions(node.reactionGroups, viewer); | |
| 2813 | if (reactions.length > 0) reactionsById.set(id, reactions); | |
| 2814 | } | |
| 2815 | return Result.succeed({ | |
| 2816 | threads: entries, | |
| 2817 | nextCursor: nextCursorOf(threads.pageInfo), | |
| 2818 | reactions: toReactions(pullRequest.reactionGroups, viewer), | |
| 2819 | reactionsById, | |
| 2820 | editedAtById, | |
| 2821 | reviewers: [...reviewers.values()], | |
| 2822 | avatarsByLogin, | |
| 2823 | botLogins, | |
| 2824 | commitStats, | |
| 2825 | commits, | |
| 2826 | viewer: toPullRequestViewerFields(pullRequest), | |
| 2827 | dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), | |
| 2828 | nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), | |
| 2829 | }); | |
| 2830 | } | |
| 2831 | ||
| 2832 | /** The rest of one thread's comments, in the shape the first page already delivered them. */ | |
| 2833 | export function decodeReviewThreadCommentsJson(raw: string): Result.Result< | |
| 2834 | { | |
| 2835 | readonly belongsToPullRequest: boolean; | |
| 2836 | readonly comments: ReadonlyArray<PullRequestThreadComment>; | |
| 2837 | readonly nextCursor: string | null; | |
| 2838 | }, | |
| 2839 | DecodeFailure | |
| 2840 | > { | |
| 2841 | const decoded = decodeReviewThreadComments(raw); | |
| 2842 | if (!Result.isSuccess(decoded)) { | |
| 2843 | return Result.fail(decoded.failure); | |
| 2844 | } | |
| 2845 | const viewer = trimmed(decoded.success.data.viewer?.login); | |
| 2846 | const comments = decoded.success.data.node?.comments; | |
| 2847 | return Result.succeed({ | |
| 2848 | belongsToPullRequest: | |
| 2849 | decoded.success.data.repository?.pullRequest?.id !== undefined && | |
| 2850 | decoded.success.data.repository?.pullRequest?.id === | |
| 2851 | decoded.success.data.node?.pullRequest?.id, | |
| 2852 | comments: (comments?.nodes ?? []).map((comment) => ({ | |
| 2853 | id: comment.id, | |
| 2854 | author: toActor(comment.author), | |
| 2855 | body: comment.body ?? "", | |
| 2856 | createdAt: comment.createdAt, | |
| 2857 | editedAt: comment.lastEditedAt ?? null, | |
| 2858 | url: trimmed(comment.url), | |
| 2859 | reactions: toReactions(comment.reactionGroups, viewer), | |
| 2860 | })), | |
| 2861 | nextCursor: nextCursorOf(comments?.pageInfo), | |
| 2862 | }); | |
| 2863 | } | |
| 2864 | ||
| 2865 | /** Repository settings returned alongside the pull request viewer permissions. */ | |
| 2866 | export interface GitHubRepositoryAccess { | |
| 2867 | readonly mergeCapabilities: PullRequestMergeCapabilities; | |
| 2868 | readonly canWrite: boolean; | |
| 2869 | } | |
| 2870 | ||
| 2871 | /** | |
| 2872 | * Whether the viewer's role on the repository is one that can push, which is what merging needs. | |
| 2873 | * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. | |
| 2874 | * | |
| 2875 | * An install that reports no permission at all does not count as write. This is the exception to | |
| 2876 | * "an unknown permission is granted": write is what merging and closing somebody else's change | |
| 2877 | * need, and offering those to a reader who cannot use them wastes the press and reads as the app | |
| 2878 | * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, | |
| 2879 | * because being unable to say something is the worse failure there. | |
| 2880 | */ | |
| 2881 | function toCanWrite(viewerPermission: string | null | undefined): boolean { | |
| 2882 | switch (viewerPermission?.trim().toUpperCase()) { | |
| 2883 | case "ADMIN": | |
| 2884 | case "MAINTAIN": | |
| 2885 | case "WRITE": | |
| 2886 | return true; | |
| 2887 | default: | |
| 2888 | return false; | |
| 2889 | } | |
| 2890 | } | |
| 2891 | ||
| 2892 | /** Triage is the least role GitHub lets label a pull request; it is not a write. */ | |
| 2893 | function toCanTriage(viewerPermission: string | null | undefined): boolean { | |
| 2894 | return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); | |
| 2895 | } | |
| 2896 | ||
| 2897 | /** | |
| 2898 | * Who a review may be asked of, and who it has already been asked of, in one read. | |
| 2899 | * | |
| 2900 | * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access | |
| 2901 | * to the repository — rather than `collaborators`, which the REST API refuses to anyone without | |
| 2902 | * push access and which would therefore be empty for exactly the reader most likely to be looking. | |
| 2903 | * | |
| 2904 | * Teams are asked for only where one has already been requested, so a request to a team can be | |
| 2905 | * taken back. The teams a repository could newly be sent to live on the owning organization and | |
| 2906 | * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses | |
| 2907 | * fails whole, taking the people down with the teams. | |
| 2908 | */ | |
| 2909 | export interface GitHubBaseComparison { | |
| 2910 | /** Null where the host could not compare, which the page reads as "unknown". */ | |
| 2911 | readonly behindBy: number | null; | |
| 2912 | readonly viewerCanUpdate: boolean; | |
| 2913 | } | |
| 2914 | ||
| 2915 | export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 2916 | repository(owner: $owner, name: $name) { | |
| 2917 | assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 2918 | pageInfo { hasNextPage } | |
| 2919 | nodes { login name avatarUrl } | |
| 2920 | } | |
| 2921 | pullRequest(number: $number) { | |
| 2922 | author { login } | |
| 2923 | reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 2924 | nodes { | |
| 2925 | requestedReviewer { | |
| 2926 | ... on User { login name avatarUrl } | |
| 2927 | ... on Team { slug name avatarUrl } | |
| 2928 | ... on Bot { login avatarUrl } | |
| 2929 | } | |
| 2930 | } | |
| 2931 | } | |
| 2932 | } | |
| 2933 | } | |
| 2934 | }`; | |
| 2935 | ||
| 2936 | /** A team answers with a slug where a user answers with a login, and nothing else differs. */ | |
| 2937 | const RawRequestedReviewerSchema = Schema.Struct({ | |
| 2938 | ...RawActorSchema.fields, | |
| 2939 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2940 | }); | |
| 2941 | ||
| 2942 | const RawReviewerCandidatesSchema = Schema.Struct({ | |
| 2943 | data: Schema.Struct({ | |
| 2944 | repository: Schema.Struct({ | |
| 2945 | assignableUsers: Schema.Struct({ | |
| 2946 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 2947 | nodes: Schema.Array(Schema.NullOr(RawActorSchema)), | |
| 2948 | }), | |
| 2949 | /** Null for a number that names no pull request the viewer can see. */ | |
| 2950 | pullRequest: Schema.NullOr( | |
| 2951 | Schema.Struct({ | |
| 2952 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 2953 | reviewRequests: Schema.optional( | |
| 2954 | Schema.NullOr( | |
| 2955 | Schema.Struct({ | |
| 2956 | nodes: Schema.Array( | |
| 2957 | Schema.Struct({ | |
| 2958 | requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), | |
| 2959 | }), | |
| 2960 | ), | |
| 2961 | }), | |
| 2962 | ), | |
| 2963 | ), | |
| 2964 | }), | |
| 2965 | ), | |
| 2966 | }), | |
| 2967 | }), | |
| 2968 | }); | |
| 2969 | ||
| 2970 | const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); | |
| 2971 | ||
| 2972 | /** | |
| 2973 | * The people this pull request may be sent to, with whoever is already on it marked. The author is | |
| 2974 | * dropped rather than shown as an unusable row: GitHub refuses a review request from the person | |
| 2975 | * who opened the pull request, so offering them is offering a failure. | |
| 2976 | * | |
| 2977 | * Whoever has been asked leads the list even where GitHub does not count them assignable — an | |
| 2978 | * outside collaborator, an app — because a request that cannot be seen cannot be taken back. | |
| 2979 | */ | |
| 2980 | export function decodeReviewerCandidatesJson( | |
| 2981 | raw: string, | |
| 2982 | ): Result.Result<PullRequestReviewerCandidateList, DecodeFailure> { | |
| 2983 | const decoded = decodeReviewerCandidates(raw); | |
| 2984 | if (!Result.isSuccess(decoded)) { | |
| 2985 | return Result.fail(decoded.failure); | |
| 2986 | } | |
| 2987 | const repository = decoded.success.data.repository; | |
| 2988 | const pullRequest = repository.pullRequest; | |
| 2989 | const author = trimmed(pullRequest?.author?.login); | |
| 2990 | const candidates = new Map<string, PullRequestReviewerCandidate>(); | |
| 2991 | for (const node of pullRequest?.reviewRequests?.nodes ?? []) { | |
| 2992 | const raw = node.requestedReviewer; | |
| 2993 | const slug = trimmed(raw?.slug); | |
| 2994 | const id = slug ?? trimmed(raw?.login); | |
| 2995 | if (id === null) continue; | |
| 2996 | candidates.set(`${slug === null ? "user" : "team"} ${id}`, { | |
| 2997 | id, | |
| 2998 | kind: slug === null ? "user" : "team", | |
| 2999 | login: id, | |
| 3000 | name: trimmed(raw?.name), | |
| 3001 | avatarUrl: trimmed(raw?.avatarUrl), | |
| 3002 | isRequested: true, | |
| 3003 | }); | |
| 3004 | } | |
| 3005 | for (const node of repository.assignableUsers.nodes) { | |
| 3006 | const login = trimmed(node?.login); | |
| 3007 | if (login === null || login === author || candidates.has(`user ${login}`)) continue; | |
| 3008 | candidates.set(`user ${login}`, { | |
| 3009 | id: login, | |
| 3010 | kind: "user", | |
| 3011 | login, | |
| 3012 | name: trimmed(node?.name), | |
| 3013 | avatarUrl: trimmed(node?.avatarUrl), | |
| 3014 | isRequested: false, | |
| 3015 | }); | |
| 3016 | } | |
| 3017 | return Result.succeed({ | |
| 3018 | candidates: [...candidates.values()], | |
| 3019 | truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, | |
| 3020 | }); | |
| 3021 | } | |
| 3022 | ||
| 3023 | /** | |
| 3024 | * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which | |
| 3025 | * takes people and teams in two lists of its own. The same body serves both methods, because | |
| 3026 | * GitHub takes a request back from exactly whoever it was made of. | |
| 3027 | */ | |
| 3028 | const ReviewerRequestSchema = Schema.Struct({ | |
| 3029 | reviewers: Schema.Array(Schema.String), | |
| 3030 | team_reviewers: Schema.Array(Schema.String), | |
| 3031 | }); | |
| 3032 | ||
| 3033 | export function buildReviewerRequest( | |
| 3034 | reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, | |
| 3035 | ): typeof ReviewerRequestSchema.Type { | |
| 3036 | return { | |
| 3037 | reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), | |
| 3038 | team_reviewers: reviewers.flatMap((reviewer) => | |
| 3039 | reviewer.kind === "team" ? [reviewer.id] : [], | |
| 3040 | ), | |
| 3041 | }; | |
| 3042 | } | |
| 3043 | ||
| 3044 | export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 3045 | repository(owner: $owner, name: $name) { | |
| 3046 | labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { | |
| 3047 | pageInfo { hasNextPage } | |
| 3048 | nodes { name color description } | |
| 3049 | } | |
| 3050 | pullRequest(number: $number) { | |
| 3051 | labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } | |
| 3052 | } | |
| 3053 | } | |
| 3054 | }`; | |
| 3055 | ||
| 3056 | const RawLabelCandidatesSchema = Schema.Struct({ | |
| 3057 | data: Schema.Struct({ | |
| 3058 | repository: Schema.Struct({ | |
| 3059 | labels: Schema.optional( | |
| 3060 | Schema.NullOr( | |
| 3061 | Schema.Struct({ | |
| 3062 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 3063 | nodes: Schema.Array( | |
| 3064 | Schema.NullOr( | |
| 3065 | Schema.Struct({ | |
| 3066 | ...RawLabelSchema.fields, | |
| 3067 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3068 | }), | |
| 3069 | ), | |
| 3070 | ), | |
| 3071 | }), | |
| 3072 | ), | |
| 3073 | ), | |
| 3074 | /** Null for a number that names no pull request the viewer can see. */ | |
| 3075 | pullRequest: Schema.NullOr( | |
| 3076 | Schema.Struct({ | |
| 3077 | labels: Schema.optional( | |
| 3078 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), | |
| 3079 | ), | |
| 3080 | }), | |
| 3081 | ), | |
| 3082 | }), | |
| 3083 | }), | |
| 3084 | }); | |
| 3085 | ||
| 3086 | const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); | |
| 3087 | ||
| 3088 | /** | |
| 3089 | * The repository's labels, with the ones already on this pull request marked. A label the pull | |
| 3090 | * request wears that the repository no longer defines — deleted since, or past the page — leads | |
| 3091 | * the list anyway, because a label that cannot be seen cannot be taken off. | |
| 3092 | */ | |
| 3093 | export function decodeLabelCandidatesJson( | |
| 3094 | raw: string, | |
| 3095 | ): Result.Result<PullRequestLabelCandidateList, DecodeFailure> { | |
| 3096 | const decoded = decodeLabelCandidates(raw); | |
| 3097 | if (!Result.isSuccess(decoded)) { | |
| 3098 | return Result.fail(decoded.failure); | |
| 3099 | } | |
| 3100 | const repository = decoded.success.data.repository; | |
| 3101 | const applied = new Set( | |
| 3102 | (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { | |
| 3103 | const name = trimmed(label?.name); | |
| 3104 | return name === null ? [] : [name]; | |
| 3105 | }), | |
| 3106 | ); | |
| 3107 | const candidates = new Map<string, PullRequestLabelCandidate>(); | |
| 3108 | for (const node of repository.labels?.nodes ?? []) { | |
| 3109 | const name = trimmed(node?.name); | |
| 3110 | if (name === null) continue; | |
| 3111 | candidates.set(name, { | |
| 3112 | name, | |
| 3113 | color: trimmed(node?.color), | |
| 3114 | description: trimmed(node?.description), | |
| 3115 | isApplied: applied.has(name), | |
| 3116 | }); | |
| 3117 | } | |
| 3118 | const missing = [...applied].filter((name) => !candidates.has(name)); | |
| 3119 | return Result.succeed({ | |
| 3120 | candidates: [ | |
| 3121 | ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), | |
| 3122 | ...candidates.values(), | |
| 3123 | ], | |
| 3124 | truncated: repository.labels?.pageInfo?.hasNextPage === true, | |
| 3125 | }); | |
| 3126 | } | |
| 3127 | ||
| 3128 | /** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ | |
| 3129 | export function buildLabelRequest(labels: ReadonlyArray<string>): { | |
| 3130 | readonly labels: ReadonlyArray<string>; | |
| 3131 | } { | |
| 3132 | return { labels }; | |
| 3133 | } | |
| 3134 | ||
| 3135 | /** | |
| 3136 | * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the | |
| 3137 | * repository, the other two about this pull request in particular — which is why an author with | |
| 3138 | * only read access can still be told apart from a passer-by. | |
| 3139 | */ | |
| 3140 | export interface GitHubViewerAccess { | |
| 3141 | readonly canWrite: boolean; | |
| 3142 | /** | |
| 3143 | * The viewer's role reaches triage, which is the least that may label. Everyone who can write | |
| 3144 | * can triage; a triager is the one role that can label without being able to merge. | |
| 3145 | */ | |
| 3146 | readonly canTriage: boolean; | |
| 3147 | /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ | |
| 3148 | readonly canUpdate: boolean; | |
| 3149 | readonly didAuthor: boolean; | |
| 3150 | /** | |
| 3151 | * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is | |
| 3152 | * false for a branch that is already current, so it answers "may update, and there is | |
| 3153 | * something to update" at once. Absent where the comparison was not read. | |
| 3154 | */ | |
| 3155 | readonly canUpdateBranch?: boolean; | |
| 3156 | } | |
| 3157 | ||
| 3158 | /** Core detail and write checks share one read of permissions and merge settings. */ | |
| 3159 | export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 3160 | repository(owner: $owner, name: $name) { | |
| 3161 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | |
| 3162 | pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } | |
| 3163 | } | |
| 3164 | }`; | |
| 3165 | ||
| 3166 | const RawViewerPermissionsSchema = Schema.Struct({ | |
| 3167 | data: Schema.Struct({ | |
| 3168 | repository: Schema.Struct({ | |
| 3169 | ...RawRepositoryAccessSchema.fields, | |
| 3170 | /** Null for a number that names no pull request the viewer can see. */ | |
| 3171 | pullRequest: Schema.NullOr(RawViewerFieldsSchema), | |
| 3172 | }), | |
| 3173 | }), | |
| 3174 | }); | |
| 3175 | ||
| 3176 | const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); | |
| 3177 | ||
| 3178 | export function decodeViewerPermissionsJson( | |
| 3179 | raw: string, | |
| 3180 | ): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> { | |
| 3181 | const decoded = decodeViewerPermissions(raw); | |
| 3182 | if (!Result.isSuccess(decoded)) { | |
| 3183 | return Result.fail(decoded.failure); | |
| 3184 | } | |
| 3185 | const repository = decoded.success.data.repository; | |
| 3186 | return Result.succeed({ | |
| 3187 | mergeCapabilities: { | |
| 3188 | merge: repository.mergeCommitAllowed, | |
| 3189 | squash: repository.squashMergeAllowed, | |
| 3190 | rebase: repository.rebaseMergeAllowed, | |
| 3191 | }, | |
| 3192 | canWrite: toCanWrite(repository.viewerPermission), | |
| 3193 | canTriage: toCanTriage(repository.viewerPermission), | |
| 3194 | ...toPullRequestViewerFields(repository.pullRequest), | |
| 3195 | }); | |
| 3196 | } | |
| 3197 | ||
| 3198 | export interface GitHubPullRequestFilesPatch { | |
| 3199 | readonly patch: string; | |
| 3200 | /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ | |
| 3201 | readonly truncated: boolean; | |
| 3202 | /** Files GitHub returned, counted before decoding, so the caller can page. */ | |
| 3203 | readonly rawCount: number; | |
| 3204 | /** GitHub's own counts for the files whose hunks it withheld. */ | |
| 3205 | readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 3206 | } | |
| 3207 | ||
| 3208 | /** | |
| 3209 | * The files API returns hunks per file with no `diff --git` header, so the unified patch every | |
| 3210 | * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's | |
| 3211 | * job, which is why the raw file count comes back with the patch. | |
| 3212 | */ | |
| 3213 | export function decodePullRequestFilesJson( | |
| 3214 | raw: string, | |
| 3215 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | |
| 3216 | return Result.map(decodeUnknownList(raw), toFilesPatch); | |
| 3217 | } | |
| 3218 | ||
| 3219 | /** | |
| 3220 | * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped | |
| 3221 | * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. | |
| 3222 | */ | |
| 3223 | export function decodeCommitFilesJson( | |
| 3224 | raw: string, | |
| 3225 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | |
| 3226 | return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); | |
| 3227 | } | |
| 3228 | ||
| 3229 | const decodeCommitFiles = decodeJsonResult( | |
| 3230 | Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), | |
| 3231 | ); | |
| 3232 | ||
| 3233 | function toFilesPatch(entries: ReadonlyArray<unknown>): GitHubPullRequestFilesPatch { | |
| 3234 | const sections: string[] = []; | |
| 3235 | const omittedFileStats: PullRequestOmittedFileStat[] = []; | |
| 3236 | let truncated = false; | |
| 3237 | for (const entry of entries) { | |
| 3238 | const file = decodeFileEntry(entry); | |
| 3239 | if (Exit.isFailure(file)) continue; | |
| 3240 | const value = file.value; | |
| 3241 | const hunks = value.patch ?? ""; | |
| 3242 | const status = value.status?.trim().toLowerCase(); | |
| 3243 | if (hunks.length === 0) { | |
| 3244 | // A file with no hunks is still a file that changed: a pure rename has none to give, and | |
| 3245 | // a binary one has none that can be shown. Both are listed, and only the second is a hole | |
| 3246 | // in the patch — leaving them out entirely would drop them from the change altogether. | |
| 3247 | const additions = value.additions ?? 0; | |
| 3248 | const deletions = value.deletions ?? 0; | |
| 3249 | if (additions + deletions > 0) { | |
| 3250 | truncated = true; | |
| 3251 | omittedFileStats.push({ path: value.filename, additions, deletions }); | |
| 3252 | } | |
| 3253 | } | |
| 3254 | // A rename counts its hunks against the old path, which is the only place it is named. | |
| 3255 | const oldPath = | |
| 3256 | status === "renamed" ? value.previous_filename || value.filename : value.filename; | |
| 3257 | const header = [ | |
| 3258 | `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, | |
| 3259 | // The files API reports no file mode, so the ordinary one stands in: the viewer reads | |
| 3260 | // these lines as "added" and "removed" rather than for the mode they carry. | |
| 3261 | ...(status === "added" ? ["new file mode 100644"] : []), | |
| 3262 | ...(status === "removed" ? ["deleted file mode 100644"] : []), | |
| 3263 | ...(status === "renamed" | |
| 3264 | ? [ | |
| 3265 | `rename from ${quoteGitPatchPath(oldPath)}`, | |
| 3266 | `rename to ${quoteGitPatchPath(value.filename)}`, | |
| 3267 | ] | |
| 3268 | : []), | |
| 3269 | `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, | |
| 3270 | `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, | |
| 3271 | ].join("\n"); | |
| 3272 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); | |
| 3273 | } | |
| 3274 | return { | |
| 3275 | patch: sections.join(""), | |
| 3276 | truncated, | |
| 3277 | rawCount: entries.length, | |
| 3278 | omittedFileStats, | |
| 3279 | }; | |
| 3280 | } | |
| 3281 | ||
| 3282 | /** | |
| 3283 | * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST | |
| 3284 | * files endpoint the patch is read from carries no viewed state, so this is a second read rather | |
| 3285 | * than a wider version of the first. | |
| 3286 | */ | |
| 3287 | export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { | |
| 3288 | repository(owner: $owner, name: $name) { | |
| 3289 | pullRequest(number: $number) { | |
| 3290 | files(first: 100, after: $after) { | |
| 3291 | pageInfo { hasNextPage endCursor } | |
| 3292 | nodes { path viewerViewedState } | |
| 3293 | } | |
| 3294 | } | |
| 3295 | } | |
| 3296 | }`; | |
| 3297 | ||
| 3298 | const RawPullRequestFilesViewedSchema = Schema.Struct({ | |
| 3299 | data: Schema.Struct({ | |
| 3300 | repository: Schema.NullOr( | |
| 3301 | Schema.Struct({ | |
| 3302 | pullRequest: Schema.NullOr( | |
| 3303 | Schema.Struct({ | |
| 3304 | files: Schema.Struct({ | |
| 3305 | pageInfo: Schema.Struct({ | |
| 3306 | hasNextPage: Schema.Boolean, | |
| 3307 | endCursor: Schema.NullOr(Schema.String), | |
| 3308 | }), | |
| 3309 | nodes: Schema.NullOr( | |
| 3310 | Schema.Array( | |
| 3311 | Schema.NullOr( | |
| 3312 | Schema.Struct({ | |
| 3313 | path: Schema.String, | |
| 3314 | // Decoded as a plain string and narrowed below: a GitHub release that adds | |
| 3315 | // a fourth state must not fail the whole page. | |
| 3316 | viewerViewedState: Schema.String, | |
| 3317 | }), | |
| 3318 | ), | |
| 3319 | ), | |
| 3320 | ), | |
| 3321 | }), | |
| 3322 | }), | |
| 3323 | ), | |
| 3324 | }), | |
| 3325 | ), | |
| 3326 | }), | |
| 3327 | }); | |
| 3328 | ||
| 3329 | const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); | |
| 3330 | ||
| 3331 | export interface GitHubPullRequestFilesViewedPage { | |
| 3332 | readonly files: ReadonlyArray<{ | |
| 3333 | readonly path: string; | |
| 3334 | readonly state: PullRequestFileViewedState; | |
| 3335 | }>; | |
| 3336 | /** Where the next page carries on, or null once the host has no more to give. */ | |
| 3337 | readonly nextCursor: string | null; | |
| 3338 | } | |
| 3339 | ||
| 3340 | /** Anything this host does not name is treated as unread, which is the state that asks for least. */ | |
| 3341 | function toFileViewedState(raw: string): PullRequestFileViewedState { | |
| 3342 | switch (raw.trim().toUpperCase()) { | |
| 3343 | case "VIEWED": | |
| 3344 | return "viewed"; | |
| 3345 | case "DISMISSED": | |
| 3346 | return "dismissed"; | |
| 3347 | default: | |
| 3348 | return "unviewed"; | |
| 3349 | } | |
| 3350 | } | |
| 3351 | ||
| 3352 | export function decodePullRequestFilesViewedJson( | |
| 3353 | raw: string, | |
| 3354 | ): Result.Result<GitHubPullRequestFilesViewedPage, DecodeFailure> { | |
| 3355 | const decoded = decodePullRequestFilesViewed(raw); | |
| 3356 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 3357 | const files = decoded.success.data.repository?.pullRequest?.files; | |
| 3358 | if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); | |
| 3359 | return Result.succeed({ | |
| 3360 | files: (files.nodes ?? []).flatMap((node) => | |
| 3361 | node === null || node.path.length === 0 | |
| 3362 | ? [] | |
| 3363 | : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], | |
| 3364 | ), | |
| 3365 | nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, | |
| 3366 | }); | |
| 3367 | } | |
| 3368 | ||
| 3369 | /** | |
| 3370 | * One document that clears and restores as many files as the reader ticked, rather than one | |
| 3371 | * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each | |
| 3372 | * take a single path, so the batching is done with aliases; top-level mutation fields run in | |
| 3373 | * write order, so the last word about a path is the one that sticks. | |
| 3374 | */ | |
| 3375 | export function buildSetFilesViewedGraphQlMutation( | |
| 3376 | pullRequestId: string, | |
| 3377 | files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, | |
| 3378 | ): GraphQlDocument | null { | |
| 3379 | return aliasedGraphQlDocument({ | |
| 3380 | operation: "mutation", | |
| 3381 | alias: "f", | |
| 3382 | items: files, | |
| 3383 | shared: { pullRequestId: ["ID!", pullRequestId] }, | |
| 3384 | variables: (file) => ({ path: ["String!", file.path] }), | |
| 3385 | field: ({ path }, file) => | |
| 3386 | `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, | |
| 3387 | }); | |
| 3388 | } | |
| 3389 | ||
| 3390 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ | |
| 3391 | const RawStackPullRequestSchema = Schema.Struct({ | |
| 3392 | title: Schema.optional(Schema.String), | |
| 3393 | draft: Schema.optional(Schema.Boolean), | |
| 3394 | number: Schema.Int, | |
| 3395 | head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), | |
| 3396 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3397 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3398 | }); | |
| 3399 | ||
| 3400 | /** | |
| 3401 | * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may | |
| 3402 | * still move. Only what a stack is made of is required — where it lives, what it stands on, and | |
| 3403 | * its pull requests — and `base` is accepted both as the ref object the preview sends today and | |
| 3404 | * as the bare branch name it started out as. | |
| 3405 | */ | |
| 3406 | const RawStackSchema = Schema.Struct({ | |
| 3407 | id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), | |
| 3408 | number: Schema.Int, | |
| 3409 | node_id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3410 | url: Schema.String, | |
| 3411 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3412 | base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), | |
| 3413 | pull_requests: Schema.Array(RawStackPullRequestSchema), | |
| 3414 | }); | |
| 3415 | ||
| 3416 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); | |
| 3417 | ||
| 3418 | export interface GitHubPullRequestStackLayer { | |
| 3419 | readonly title?: string; | |
| 3420 | readonly isDraft?: boolean; | |
| 3421 | readonly headSha?: string; | |
| 3422 | readonly number: number; | |
| 3423 | readonly headBranch: string; | |
| 3424 | readonly state: PullRequestState; | |
| 3425 | } | |
| 3426 | ||
| 3427 | export interface GitHubPullRequestStack { | |
| 3428 | readonly id: string; | |
| 3429 | readonly number: number; | |
| 3430 | readonly url: string; | |
| 3431 | readonly base: string; | |
| 3432 | /** Bottom to top, which is the order GitHub lists them in. */ | |
| 3433 | readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>; | |
| 3434 | } | |
| 3435 | ||
| 3436 | /** | |
| 3437 | * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in | |
| 3438 | * at most one stack, so the array is GitHub's way of saying "none" rather than a page. | |
| 3439 | */ | |
| 3440 | export function decodePullRequestStacksJson( | |
| 3441 | raw: string, | |
| 3442 | ): Result.Result<GitHubPullRequestStack | null, DecodeFailure> { | |
| 3443 | const decoded = decodeStacks(raw); | |
| 3444 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 3445 | const stack = decoded.success[0]; | |
| 3446 | if (stack === undefined) return Result.succeed(null); | |
| 3447 | return Result.succeed({ | |
| 3448 | id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), | |
| 3449 | number: stack.number, | |
| 3450 | // The page a person opens where the preview reports one; the API URL is what it always has. | |
| 3451 | url: trimmed(stack.html_url) ?? stack.url, | |
| 3452 | base: typeof stack.base === "string" ? stack.base : stack.base.ref, | |
| 3453 | layers: stack.pull_requests.map((pullRequest) => ({ | |
| 3454 | ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), | |
| 3455 | ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), | |
| 3456 | ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), | |
| 3457 | number: pullRequest.number, | |
| 3458 | headBranch: pullRequest.head.ref, | |
| 3459 | state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), | |
| 3460 | })), | |
| 3461 | }); | |
| 3462 | } |
apps/server/src/pullRequest/githubStackActions.test.ts deleted-393| ... | ... | @@ -1,393 +0,0 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as Layer from "effect/Layer"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 12 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; | |
| 13 | ||
| 14 | /** | |
| 15 | * One request as the fake saw it, flattened to words: the path or document, then each variable | |
| 16 | * or body field as `name=value`, so an assertion reads as "this request carried that value". | |
| 17 | */ | |
| 18 | type Call = ReadonlyArray<string>; | |
| 19 | type Send = ( | |
| 20 | call: Call, | |
| 21 | kind: "graphql" | "rest", | |
| 22 | ) => Effect.Effect<string, GitHubApi.GitHubApiError>; | |
| 23 | ||
| 24 | /** Every git command the cascade ran, in order; none actually runs. */ | |
| 25 | let gitCalls: Array<ReadonlyArray<string>> = []; | |
| 26 | const fakeGit = Layer.mergeAll( | |
| 27 | Layer.mock(VcsProcess.VcsProcess)({ | |
| 28 | run: (input) => | |
| 29 | Effect.sync(() => { | |
| 30 | gitCalls.push(input.args); | |
| 31 | const stdout = | |
| 32 | input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; | |
| 33 | return { | |
| 34 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 35 | stdout, | |
| 36 | stderr: "", | |
| 37 | stdoutTruncated: false, | |
| 38 | stderrTruncated: false, | |
| 39 | }; | |
| 40 | }), | |
| 41 | }), | |
| 42 | FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), | |
| 43 | ); | |
| 44 | ||
| 45 | const runGitHubStackAction = (send: Send, input: Parameters<typeof runStackAction>[0]) => | |
| 46 | runStackAction(input).pipe( | |
| 47 | Effect.provide( | |
| 48 | Layer.mergeAll( | |
| 49 | fakeGit, | |
| 50 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 51 | credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), | |
| 52 | graphql: (request) => { | |
| 53 | // GitHub refuses a document that declares a variable it never uses. | |
| 54 | const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); | |
| 55 | const unused = declared.filter( | |
| 56 | (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), | |
| 57 | ); | |
| 58 | if (unused.length > 0) { | |
| 59 | return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); | |
| 60 | } | |
| 61 | return send(words(request.query, request.variables), "graphql"); | |
| 62 | }, | |
| 63 | rest: (request) => | |
| 64 | send(words(request.path, request.body), "rest").pipe( | |
| 65 | Effect.map((body) => ({ | |
| 66 | status: 200, | |
| 67 | headers: {}, | |
| 68 | body, | |
| 69 | truncated: false, | |
| 70 | invalidUtf8: false, | |
| 71 | })), | |
| 72 | ), | |
| 73 | }), | |
| 74 | ), | |
| 75 | ), | |
| 76 | ); | |
| 77 | ||
| 78 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 79 | ||
| 80 | function words(head: string, fields: unknown): Call { | |
| 81 | return [ | |
| 82 | head, | |
| 83 | ...Object.entries((fields ?? {}) as Record<string, unknown>).map( | |
| 84 | ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, | |
| 85 | ), | |
| 86 | ]; | |
| 87 | } | |
| 88 | ||
| 89 | const isMutation = (call: Call) => call[0]!.startsWith("mutation"); | |
| 90 | ||
| 91 | const stack = [ | |
| 92 | { | |
| 93 | number: 50, | |
| 94 | url: "https://api.github.com/repos/acme/web/stacks/50", | |
| 95 | base: { ref: "main" }, | |
| 96 | pull_requests: [ | |
| 97 | { | |
| 98 | number: 1, | |
| 99 | title: "Base", | |
| 100 | head: { ref: "base", sha: "aaa" }, | |
| 101 | state: "closed", | |
| 102 | merged_at: "2026-01-01T00:00:00Z", | |
| 103 | }, | |
| 104 | { | |
| 105 | number: 2, | |
| 106 | title: "Middle", | |
| 107 | head: { ref: "middle", sha: "bbb" }, | |
| 108 | state: "open", | |
| 109 | draft: false, | |
| 110 | }, | |
| 111 | { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, | |
| 112 | ], | |
| 113 | }, | |
| 114 | ]; | |
| 115 | const input = { | |
| 116 | cwd: "/repo", | |
| 117 | repository: "acme/web", | |
| 118 | host: "github.com", | |
| 119 | number: 3, | |
| 120 | stackNumber: 50, | |
| 121 | expectedStackHeads: [ | |
| 122 | { number: 2, headSha: "bbb" }, | |
| 123 | { number: 3, headSha: "ccc" }, | |
| 124 | ], | |
| 125 | action: "merge" as const, | |
| 126 | }; | |
| 127 | const access = { | |
| 128 | data: { | |
| 129 | repository: { | |
| 130 | pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 131 | pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 132 | }, | |
| 133 | }, | |
| 134 | }; | |
| 135 | ||
| 136 | const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ | |
| 137 | data: { | |
| 138 | processed: processed.map((headRefOid) => ({ headRefOid })), | |
| 139 | repository: { | |
| 140 | pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, | |
| 141 | }, | |
| 142 | }, | |
| 143 | }); | |
| 144 | const rebased = { | |
| 145 | data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, | |
| 146 | }; | |
| 147 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; | |
| 148 | ||
| 149 | function fake(responses: readonly unknown[]) { | |
| 150 | const calls: Call[] = []; | |
| 151 | const execute: Send = (call) => | |
| 152 | Effect.sync(() => { | |
| 153 | calls.push(call); | |
| 154 | const value = responses[calls.length - 1]; | |
| 155 | if (value === undefined) throw new Error("Unexpected GitHub request"); | |
| 156 | return encodeJson(value); | |
| 157 | }); | |
| 158 | return { execute, calls }; | |
| 159 | } | |
| 160 | ||
| 161 | it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => | |
| 162 | Effect.gen(function* () { | |
| 163 | const api = fake([stack, { status: "enqueued", details: {} }]); | |
| 164 | yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); | |
| 165 | expect(api.calls).toHaveLength(2); | |
| 166 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 167 | expect(api.calls[1]).toContain("sha=ccc"); | |
| 168 | expect(api.calls[1]).toContain("merge_action=default"); | |
| 169 | expect(api.calls[1]).toContain("merge_method=squash"); | |
| 170 | }), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("merges through the selected layer without including later draft layers", () => | |
| 174 | Effect.gen(function* () { | |
| 175 | const fiveLayers = [ | |
| 176 | { | |
| 177 | ...stack[0], | |
| 178 | pull_requests: Array.from({ length: 5 }, (_, index) => ({ | |
| 179 | number: index + 1, | |
| 180 | head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, | |
| 181 | state: "open", | |
| 182 | draft: index >= 3, | |
| 183 | })), | |
| 184 | }, | |
| 185 | ]; | |
| 186 | const api = fake([fiveLayers, { status: "merged", details: {} }]); | |
| 187 | yield* runGitHubStackAction(api.execute, { | |
| 188 | ...input, | |
| 189 | number: 3, | |
| 190 | expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), | |
| 191 | }); | |
| 192 | expect(api.calls).toHaveLength(2); | |
| 193 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 194 | expect(api.calls[1]).toContain("sha=sha-3"); | |
| 195 | expect(api.calls[1]).toContain("merge_action=default"); | |
| 196 | }), | |
| 197 | ); | |
| 198 | ||
| 199 | it.effect("rejects stale reviewed heads below a selected middle layer", () => | |
| 200 | Effect.gen(function* () { | |
| 201 | const api = fake([stack]); | |
| 202 | const result = yield* runGitHubStackAction(api.execute, { | |
| 203 | ...input, | |
| 204 | number: 2, | |
| 205 | expectedStackHeads: [{ number: 2, headSha: "old-head" }], | |
| 206 | }).pipe(Effect.result); | |
| 207 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | |
| 208 | expect(api.calls).toHaveLength(1); | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | it.effect("does not merge from an already merged layer", () => | |
| 213 | Effect.gen(function* () { | |
| 214 | const api = fake([stack]); | |
| 215 | const result = yield* runGitHubStackAction(api.execute, { | |
| 216 | ...input, | |
| 217 | number: 1, | |
| 218 | expectedStackHeads: [], | |
| 219 | }).pipe(Effect.result); | |
| 220 | expect(result).toMatchObject({ | |
| 221 | _tag: "Failure", | |
| 222 | failure: { _tag: "GitHubStackUnsupportedError" }, | |
| 223 | }); | |
| 224 | expect(api.calls).toHaveLength(1); | |
| 225 | }), | |
| 226 | ); | |
| 227 | ||
| 228 | it.effect("polls an accepted merge and reports a later rule rejection", () => | |
| 229 | Effect.gen(function* () { | |
| 230 | const api = fake([ | |
| 231 | stack, | |
| 232 | { status: "pending", details: { uuid: "operation" } }, | |
| 233 | { status: "failed", details: { message: "Required checks have not passed" } }, | |
| 234 | ]); | |
| 235 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 236 | Effect.result, | |
| 237 | Effect.forkChild, | |
| 238 | ); | |
| 239 | yield* TestClock.adjust("1 second"); | |
| 240 | const result = yield* Fiber.join(fiber); | |
| 241 | expect(result).toMatchObject({ | |
| 242 | _tag: "Failure", | |
| 243 | failure: { _tag: "GitHubStackMergeRejectedError" }, | |
| 244 | }); | |
| 245 | expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); | |
| 246 | }), | |
| 247 | ); | |
| 248 | ||
| 249 | it.effect("retains stack identity and a rejection response without a message", () => | |
| 250 | Effect.gen(function* () { | |
| 251 | const rejection = { status: "failed", details: {} }; | |
| 252 | const api = fake([stack, rejection]); | |
| 253 | const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); | |
| 254 | expect(result).toMatchObject({ | |
| 255 | _tag: "Failure", | |
| 256 | failure: { | |
| 257 | _tag: "GitHubStackMergeRejectedError", | |
| 258 | repository: input.repository, | |
| 259 | number: input.number, | |
| 260 | stackNumber: input.stackNumber, | |
| 261 | cause: rejection, | |
| 262 | }, | |
| 263 | }); | |
| 264 | }), | |
| 265 | ); | |
| 266 | ||
| 267 | it.effect("refuses a changed stack before performing any mutation", () => | |
| 268 | Effect.gen(function* () { | |
| 269 | const api = fake([stack]); | |
| 270 | const result = yield* runGitHubStackAction(api.execute, { | |
| 271 | ...input, | |
| 272 | expectedStackHeads: [ | |
| 273 | { number: 2, headSha: "old" }, | |
| 274 | { number: 3, headSha: "ccc" }, | |
| 275 | ], | |
| 276 | }).pipe(Effect.result); | |
| 277 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | |
| 278 | expect(api.calls).toHaveLength(1); | |
| 279 | }), | |
| 280 | ); | |
| 281 | ||
| 282 | it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => | |
| 283 | Effect.gen(function* () { | |
| 284 | const api = fake([ | |
| 285 | stack, | |
| 286 | { | |
| 287 | data: { | |
| 288 | repository: { | |
| 289 | ...access.data.repository, | |
| 290 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | |
| 291 | }, | |
| 292 | }, | |
| 293 | }, | |
| 294 | ]); | |
| 295 | const result = yield* runGitHubStackAction(api.execute, { | |
| 296 | ...input, | |
| 297 | action: "update-branch", | |
| 298 | }).pipe(Effect.result); | |
| 299 | expect(result).toMatchObject({ | |
| 300 | _tag: "Failure", | |
| 301 | failure: { _tag: "GitHubStackPermissionError" }, | |
| 302 | }); | |
| 303 | expect(api.calls).toHaveLength(2); | |
| 304 | }), | |
| 305 | ); | |
| 306 | ||
| 307 | it.effect("allows a fork that explicitly permits maintainer updates", () => | |
| 308 | Effect.gen(function* () { | |
| 309 | const api = fake([ | |
| 310 | stack, | |
| 311 | { | |
| 312 | data: { | |
| 313 | repository: { | |
| 314 | ...access.data.repository, | |
| 315 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, | |
| 316 | }, | |
| 317 | }, | |
| 318 | }, | |
| 319 | ...rebaseResponses, | |
| 320 | ]); | |
| 321 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 322 | expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); | |
| 323 | }), | |
| 324 | ); | |
| 325 | ||
| 326 | it.effect("bounds polling and reports a still-running merge without claiming success", () => | |
| 327 | Effect.gen(function* () { | |
| 328 | const api = fake([ | |
| 329 | stack, | |
| 330 | ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), | |
| 331 | ]); | |
| 332 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 333 | Effect.result, | |
| 334 | Effect.forkChild, | |
| 335 | ); | |
| 336 | yield* TestClock.adjust("6 minutes"); | |
| 337 | expect(yield* Fiber.join(fiber)).toMatchObject({ | |
| 338 | _tag: "Failure", | |
| 339 | failure: { _tag: "GitHubStackMergePendingError" }, | |
| 340 | }); | |
| 341 | expect(api.calls.length).toBeLessThan(40); | |
| 342 | }), | |
| 343 | ); | |
| 344 | ||
| 345 | it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => | |
| 346 | Effect.gen(function* () { | |
| 347 | gitCalls = []; | |
| 348 | const api = fake([stack, access]); | |
| 349 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 350 | // GitHub is only read; every change is a git push with a lease on the reviewed head. | |
| 351 | expect(api.calls.some(isMutation)).toBe(false); | |
| 352 | const rebases = gitCalls.filter((args) => args[0] === "rebase"); | |
| 353 | expect(rebases).toEqual([ | |
| 354 | ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], | |
| 355 | ["rebase", "--quiet", "--onto", "new-sha", "bbb"], | |
| 356 | ]); | |
| 357 | expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ | |
| 358 | [ | |
| 359 | "push", | |
| 360 | "--quiet", | |
| 361 | "--force-with-lease=refs/heads/middle:bbb", | |
| 362 | "origin", | |
| 363 | "new-sha:refs/heads/middle", | |
| 364 | ], | |
| 365 | [ | |
| 366 | "push", | |
| 367 | "--quiet", | |
| 368 | "--force-with-lease=refs/heads/top:ccc", | |
| 369 | "origin", | |
| 370 | "new-sha:refs/heads/top", | |
| 371 | ], | |
| 372 | ]); | |
| 373 | }), | |
| 374 | ); | |
| 375 | ||
| 376 | it.effect("checks every layer's write access before any git runs", () => | |
| 377 | Effect.gen(function* () { | |
| 378 | gitCalls = []; | |
| 379 | const api = fake([ | |
| 380 | stack, | |
| 381 | { | |
| 382 | data: { | |
| 383 | repository: { | |
| 384 | ...access.data.repository, | |
| 385 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | |
| 386 | }, | |
| 387 | }, | |
| 388 | }, | |
| 389 | ]); | |
| 390 | yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); | |
| 391 | expect(gitCalls).toEqual([]); | |
| 392 | }), | |
| 393 | ); |
apps/server/src/pullRequest/githubStackActions.ts deleted-288| ... | ... | @@ -1,288 +0,0 @@ |
| 1 | import type { | |
| 2 | PullRequestAction, | |
| 3 | PullRequestMergeMethod, | |
| 4 | PullRequestStackHead, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | ||
| 11 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 12 | import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts"; | |
| 13 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; | |
| 14 | import { cascadeRebaseStack } from "./githubStackRebase.ts"; | |
| 15 | ||
| 16 | const stackErrorIdentity = { | |
| 17 | repository: Schema.String, | |
| 18 | number: Schema.Int, | |
| 19 | stackNumber: Schema.Int, | |
| 20 | }; | |
| 21 | ||
| 22 | export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()( | |
| 23 | "GitHubStackChangedError", | |
| 24 | { ...stackErrorIdentity, completed: Schema.Int }, | |
| 25 | ) { | |
| 26 | override get message(): string { | |
| 27 | return this.completed > 0 | |
| 28 | ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` | |
| 29 | : "The stack changed. Refresh it before trying again."; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()( | |
| 34 | "GitHubStackUnsupportedError", | |
| 35 | stackErrorIdentity, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return "This operation is not supported for this stack."; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()( | |
| 43 | "GitHubStackResponseInvalidError", | |
| 44 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, | |
| 45 | ) { | |
| 46 | override get message(): string { | |
| 47 | return "GitHub returned an unreadable stack operation response."; | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()( | |
| 52 | "GitHubStackMergeRejectedError", | |
| 53 | { ...stackErrorIdentity, cause: Schema.Defect() }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()( | |
| 61 | "GitHubStackMergePendingError", | |
| 62 | stackErrorIdentity, | |
| 63 | ) { | |
| 64 | override get message(): string { | |
| 65 | return "The merge is still running on GitHub. Check its status there before submitting another request."; | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()( | |
| 70 | "GitHubStackPermissionError", | |
| 71 | stackErrorIdentity, | |
| 72 | ) { | |
| 73 | override get message(): string { | |
| 74 | return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()( | |
| 79 | "GitHubStackRebaseFailedError", | |
| 80 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, | |
| 81 | ) { | |
| 82 | override get message(): string { | |
| 83 | // A conflict or a refused push already says which layer and what to do about it. | |
| 84 | return this.cause instanceof Error && this.cause.message !== "" | |
| 85 | ? this.cause.message | |
| 86 | : `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | export type GitHubStackActionError = | |
| 91 | | GitHubStackChangedError | |
| 92 | | GitHubStackUnsupportedError | |
| 93 | | GitHubStackResponseInvalidError | |
| 94 | | GitHubStackMergeRejectedError | |
| 95 | | GitHubStackMergePendingError | |
| 96 | | GitHubStackPermissionError | |
| 97 | | GitHubStackRebaseFailedError; | |
| 98 | ||
| 99 | const MergeResponse = Schema.Struct({ | |
| 100 | status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), | |
| 101 | details: Schema.Struct({ | |
| 102 | uuid: Schema.optional(Schema.String), | |
| 103 | message: Schema.optional(Schema.String), | |
| 104 | }), | |
| 105 | }); | |
| 106 | ||
| 107 | const decodeBranchAccess = Schema.decodeEffect( | |
| 108 | Schema.fromJsonString( | |
| 109 | Schema.Struct({ | |
| 110 | data: Schema.Struct({ | |
| 111 | repository: Schema.NullOr( | |
| 112 | Schema.Record( | |
| 113 | Schema.String, | |
| 114 | Schema.NullOr( | |
| 115 | Schema.Struct({ | |
| 116 | headRepository: Schema.NullOr( | |
| 117 | Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), | |
| 118 | ), | |
| 119 | maintainerCanModify: Schema.Boolean, | |
| 120 | }), | |
| 121 | ), | |
| 122 | ), | |
| 123 | ), | |
| 124 | }), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ); | |
| 128 | ||
| 129 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); | |
| 130 | ||
| 131 | /** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */ | |
| 132 | export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { | |
| 133 | cwd: string; | |
| 134 | repository: string; | |
| 135 | host: string; | |
| 136 | number: number; | |
| 137 | stackNumber: number; | |
| 138 | expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 139 | action: PullRequestAction; | |
| 140 | mergeMethod?: PullRequestMergeMethod; | |
| 141 | }) { | |
| 142 | const api = yield* GitHubApi.GitHubApi; | |
| 143 | const identity = { | |
| 144 | repository: input.repository, | |
| 145 | number: input.number, | |
| 146 | stackNumber: input.stackNumber, | |
| 147 | }; | |
| 148 | if (input.action !== "merge" && input.action !== "update-branch") | |
| 149 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 150 | const endpoint = `repos/${input.repository}`; | |
| 151 | const read = yield* api.rest({ | |
| 152 | host: input.host, | |
| 153 | operation: "runGitHubStackAction", | |
| 154 | path: `${endpoint}/stacks?pull_request=${input.number}`, | |
| 155 | }); | |
| 156 | const decoded = decodePullRequestStacksJson(read.body); | |
| 157 | if (Result.isFailure(decoded)) | |
| 158 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); | |
| 159 | const stack = decoded.success; | |
| 160 | const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; | |
| 161 | const target = stack?.layers[targetIndex]; | |
| 162 | if ( | |
| 163 | stack?.number !== input.stackNumber || | |
| 164 | target === undefined || | |
| 165 | (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) | |
| 166 | ) { | |
| 167 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | |
| 168 | } | |
| 169 | const affectedLayers = | |
| 170 | input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; | |
| 171 | const open = affectedLayers.filter((layer) => layer.state !== "merged"); | |
| 172 | if (input.action === "merge" && target.state !== "open") | |
| 173 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 174 | if ( | |
| 175 | !input.expectedStackHeads || | |
| 176 | input.expectedStackHeads.length !== open.length || | |
| 177 | new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || | |
| 178 | open.some( | |
| 179 | (layer) => | |
| 180 | !layer.headSha || | |
| 181 | !input.expectedStackHeads?.some( | |
| 182 | (expected) => expected.number === layer.number && expected.headSha === layer.headSha, | |
| 183 | ), | |
| 184 | ) | |
| 185 | ) { | |
| 186 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | |
| 187 | } | |
| 188 | if (open.length === 0 || open.some((layer) => layer.state !== "open")) | |
| 189 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 190 | if (input.action === "update-branch") { | |
| 191 | const [owner, name] = input.repository.split("/"); | |
| 192 | const permissions = yield* api.graphql({ | |
| 193 | host: input.host, | |
| 194 | operation: "runGitHubStackAction", | |
| 195 | allowReserve: true, | |
| 196 | // `open` is non-empty here, so there is always a document. | |
| 197 | ...aliasedGraphQlDocument({ | |
| 198 | operation: "query", | |
| 199 | alias: "pr", | |
| 200 | key: (layer) => layer.number, | |
| 201 | items: open, | |
| 202 | shared: { owner: ["String!", owner], name: ["String!", name] }, | |
| 203 | variables: (layer) => ({ number: ["Int!", layer.number] }), | |
| 204 | field: ({ number }) => | |
| 205 | `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, | |
| 206 | within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, | |
| 207 | })!, | |
| 208 | }); | |
| 209 | const access = yield* decodeBranchAccess(permissions).pipe( | |
| 210 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | |
| 211 | ); | |
| 212 | // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent | |
| 213 | // will make it stale. Check branch write access separately before touching any layer. | |
| 214 | if ( | |
| 215 | open.some((layer) => { | |
| 216 | const pr = access.data.repository?.[`pr${layer.number}`]; | |
| 217 | return ( | |
| 218 | !pr?.headRepository || | |
| 219 | (!pr.maintainerCanModify && | |
| 220 | !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) | |
| 221 | ); | |
| 222 | }) | |
| 223 | ) | |
| 224 | return yield* new GitHubStackPermissionError({ ...identity }); | |
| 225 | // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old | |
| 226 | // copy of every lower layer into the one above it. The cascade moves each layer's own commits. | |
| 227 | yield* cascadeRebaseStack({ | |
| 228 | host: input.host, | |
| 229 | repository: input.repository, | |
| 230 | base: stack.base, | |
| 231 | layers: open.map((layer) => ({ | |
| 232 | number: layer.number, | |
| 233 | headBranch: layer.headBranch, | |
| 234 | headSha: layer.headSha!, | |
| 235 | })), | |
| 236 | }).pipe( | |
| 237 | Effect.mapError((cause) => | |
| 238 | cause._tag === "GitHubStackRebaseConflictError" || | |
| 239 | cause._tag === "GitHubStackRebaseGitError" | |
| 240 | ? new GitHubStackRebaseFailedError({ | |
| 241 | ...identity, | |
| 242 | number: cause.number, | |
| 243 | completed: cause.completed, | |
| 244 | cause, | |
| 245 | }) | |
| 246 | : cause, | |
| 247 | ), | |
| 248 | ); | |
| 249 | return; | |
| 250 | } | |
| 251 | if (open.some((layer) => layer.isDraft)) | |
| 252 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 253 | const decode = (raw: string) => | |
| 254 | decodeMergeResponse(raw).pipe( | |
| 255 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | |
| 256 | ); | |
| 257 | const request = yield* api.rest({ | |
| 258 | host: input.host, | |
| 259 | operation: "runGitHubStackAction", | |
| 260 | method: "PUT", | |
| 261 | path: `${endpoint}/pulls/${input.number}/merge-async`, | |
| 262 | body: { | |
| 263 | merge_method: input.mergeMethod ?? "merge", | |
| 264 | merge_action: "default", | |
| 265 | sha: target.headSha, | |
| 266 | }, | |
| 267 | }); | |
| 268 | let result = yield* decode(request.body); | |
| 269 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; | |
| 270 | for ( | |
| 271 | let attempt = 0; | |
| 272 | result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; | |
| 273 | attempt++ | |
| 274 | ) { | |
| 275 | const uuid = result.details.uuid; | |
| 276 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); | |
| 277 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); | |
| 278 | const poll = yield* api.rest({ | |
| 279 | host: input.host, | |
| 280 | operation: "runGitHubStackAction", | |
| 281 | path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | |
| 282 | }); | |
| 283 | result = yield* decode(poll.body); | |
| 284 | } | |
| 285 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); | |
| 286 | if (result.status === "failed") | |
| 287 | return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); | |
| 288 | }); |
apps/server/src/pullRequest/githubStackRebase.test.ts deleted-162| ... | ... | @@ -1,162 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | ||
| 8 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 10 | import { cascadeRebaseStack } from "./githubStackRebase.ts"; | |
| 11 | ||
| 12 | const layer = Layer.mergeAll( | |
| 13 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 14 | credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), | |
| 15 | }), | |
| 16 | VcsProcess.layer, | |
| 17 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 18 | ||
| 19 | /** | |
| 20 | * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a | |
| 21 | * second layer sits on the first. Returns each branch's head. | |
| 22 | */ | |
| 23 | const setup = Effect.gen(function* () { | |
| 24 | const fs = yield* FileSystem.FileSystem; | |
| 25 | const process = yield* VcsProcess.VcsProcess; | |
| 26 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); | |
| 27 | const remote = `${root}/remote.git`; | |
| 28 | const work = `${root}/work`; | |
| 29 | const git = (cwd: string, ...args: string[]) => | |
| 30 | process | |
| 31 | .run({ | |
| 32 | operation: "test", | |
| 33 | command: "git", | |
| 34 | args, | |
| 35 | cwd, | |
| 36 | env: { | |
| 37 | GIT_AUTHOR_NAME: "t", | |
| 38 | GIT_AUTHOR_EMAIL: "t@t", | |
| 39 | GIT_COMMITTER_NAME: "t", | |
| 40 | GIT_COMMITTER_EMAIL: "t@t", | |
| 41 | }, | |
| 42 | }) | |
| 43 | .pipe(Effect.map((out) => out.stdout.trim())); | |
| 44 | const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); | |
| 45 | yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); | |
| 46 | yield* git(root, "clone", "--quiet", remote, work); | |
| 47 | yield* write("index.ts", "export const answer = 41;\n"); | |
| 48 | yield* git(work, "add", "-A"); | |
| 49 | yield* git(work, "commit", "--quiet", "-m", "Initial"); | |
| 50 | yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); | |
| 51 | yield* write("index.ts", "export const answer = 42;\n"); | |
| 52 | yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); | |
| 53 | yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); | |
| 54 | yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); | |
| 55 | yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); | |
| 56 | yield* git(work, "switch", "--quiet", "main"); | |
| 57 | yield* write("README.md", "main moved ahead\n"); | |
| 58 | yield* git(work, "add", "-A"); | |
| 59 | yield* git(work, "commit", "--quiet", "-m", "Docs"); | |
| 60 | yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); | |
| 61 | const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); | |
| 62 | return { | |
| 63 | remote, | |
| 64 | head, | |
| 65 | git: (...args: string[]) => git(remote, ...args), | |
| 66 | layers: [ | |
| 67 | { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, | |
| 68 | { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, | |
| 69 | ], | |
| 70 | }; | |
| 71 | }); | |
| 72 | ||
| 73 | it.layer(layer)("cascadeRebaseStack", (it) => { | |
| 74 | it.effect("moves each layer's own commits onto the rebased layer below it", () => | |
| 75 | Effect.gen(function* () { | |
| 76 | const repo = yield* setup; | |
| 77 | const completed = yield* cascadeRebaseStack({ | |
| 78 | host: "github.com", | |
| 79 | repository: "acme/web", | |
| 80 | base: "main", | |
| 81 | layers: repo.layers, | |
| 82 | remote: repo.remote, | |
| 83 | }); | |
| 84 | assert.strictEqual(completed, 2); | |
| 85 | ||
| 86 | const main = yield* repo.head("main"); | |
| 87 | const bottom = yield* repo.head("feat/answer-42"); | |
| 88 | const top = yield* repo.head("feat/answer-doc"); | |
| 89 | // Bottom sits on the new main; top sits on the new bottom with only its own commit. | |
| 90 | expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); | |
| 91 | expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); | |
| 92 | expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( | |
| 93 | "B: document the answer", | |
| 94 | ); | |
| 95 | }).pipe(Effect.scoped), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("refuses to overwrite a layer pushed after it was reviewed", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | const repo = yield* setup; | |
| 101 | const stale = [ | |
| 102 | repo.layers[0]!, | |
| 103 | // Reviewed at the bottom layer's head, but the branch is really at its own commit. | |
| 104 | { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, | |
| 105 | ]; | |
| 106 | const error = yield* Effect.flip( | |
| 107 | cascadeRebaseStack({ | |
| 108 | host: "github.com", | |
| 109 | repository: "acme/web", | |
| 110 | base: "main", | |
| 111 | layers: stale, | |
| 112 | remote: repo.remote, | |
| 113 | }), | |
| 114 | ); | |
| 115 | expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); | |
| 116 | expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); | |
| 117 | }).pipe(Effect.scoped), | |
| 118 | ); | |
| 119 | ||
| 120 | it.effect("stops at a conflicting layer and leaves it untouched", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const repo = yield* setup; | |
| 123 | // Make main conflict with the bottom layer's change to the same line. | |
| 124 | const fs = yield* FileSystem.FileSystem; | |
| 125 | const process = yield* VcsProcess.VcsProcess; | |
| 126 | const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); | |
| 127 | const run = (...args: string[]) => | |
| 128 | process.run({ | |
| 129 | operation: "test", | |
| 130 | command: "git", | |
| 131 | args, | |
| 132 | cwd: scratch, | |
| 133 | env: { | |
| 134 | GIT_AUTHOR_NAME: "t", | |
| 135 | GIT_AUTHOR_EMAIL: "t@t", | |
| 136 | GIT_COMMITTER_NAME: "t", | |
| 137 | GIT_COMMITTER_EMAIL: "t@t", | |
| 138 | }, | |
| 139 | }); | |
| 140 | yield* run("clone", "--quiet", repo.remote, "."); | |
| 141 | yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); | |
| 142 | yield* run("commit", "--quiet", "-am", "Conflict"); | |
| 143 | yield* run("push", "--quiet", "origin", "main"); | |
| 144 | ||
| 145 | const error = yield* Effect.flip( | |
| 146 | cascadeRebaseStack({ | |
| 147 | host: "github.com", | |
| 148 | repository: "acme/web", | |
| 149 | base: "main", | |
| 150 | layers: repo.layers, | |
| 151 | remote: repo.remote, | |
| 152 | }), | |
| 153 | ); | |
| 154 | expect(error).toMatchObject({ | |
| 155 | _tag: "GitHubStackRebaseConflictError", | |
| 156 | number: 1, | |
| 157 | completed: 0, | |
| 158 | }); | |
| 159 | expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); | |
| 160 | }).pipe(Effect.scoped), | |
| 161 | ); | |
| 162 | }); |
apps/server/src/pullRequest/githubStackRebase.ts deleted-155| ... | ... | @@ -1,155 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Redacted from "effect/Redacted"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as Base64 from "effect/encoding/Base64"; | |
| 6 | ||
| 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 9 | ||
| 10 | /** One open layer of a stack, bottom to top, with the head the reader reviewed. */ | |
| 11 | export interface CascadeLayer { | |
| 12 | readonly number: number; | |
| 13 | readonly headBranch: string; | |
| 14 | readonly headSha: string; | |
| 15 | } | |
| 16 | ||
| 17 | /** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ | |
| 18 | export class GitHubStackRebaseConflictError extends Schema.TaggedError<GitHubStackRebaseConflictError>()( | |
| 19 | "GitHubStackRebaseConflictError", | |
| 20 | { number: Schema.Int, completed: Schema.Int }, | |
| 21 | ) { | |
| 22 | override get message(): string { | |
| 23 | return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | /** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ | |
| 28 | export class GitHubStackRebaseGitError extends Schema.TaggedError<GitHubStackRebaseGitError>()( | |
| 29 | "GitHubStackRebaseGitError", | |
| 30 | { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, | |
| 31 | ) { | |
| 32 | override get message(): string { | |
| 33 | return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | /** The git URL of a repository on a GitHub host, which is also where its token is good. */ | |
| 38 | function remoteUrl(host: string, repository: string): string { | |
| 39 | return `https://${host.trim().toLowerCase()}/${repository}.git`; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer | |
| 44 | * below it (the bottom one onto the stack's base). Each layer moves only its own commits — | |
| 45 | * `git rebase --onto <new parent> <old parent>` — so a rebased lower layer is never replayed into | |
| 46 | * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. | |
| 47 | * | |
| 48 | * It works in a throwaway clone, so the environment's checkout never moves, and pushes each | |
| 49 | * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused | |
| 50 | * rather than overwritten. The stacks API has no rebase endpoint to call instead. | |
| 51 | */ | |
| 52 | export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { | |
| 53 | readonly host: string; | |
| 54 | readonly repository: string; | |
| 55 | readonly base: string; | |
| 56 | readonly layers: ReadonlyArray<CascadeLayer>; | |
| 57 | /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ | |
| 58 | readonly remote?: string; | |
| 59 | }) { | |
| 60 | const api = yield* GitHubApi.GitHubApi; | |
| 61 | const process = yield* VcsProcess.VcsProcess; | |
| 62 | const fileSystem = yield* FileSystem.FileSystem; | |
| 63 | const { token } = yield* api.credential(input.host); | |
| 64 | const first = input.layers[0]; | |
| 65 | if (first === undefined) return 0; | |
| 66 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( | |
| 67 | Effect.mapError( | |
| 68 | (cause) => | |
| 69 | new GitHubStackRebaseGitError({ | |
| 70 | step: "preparing", | |
| 71 | number: first.number, | |
| 72 | completed: 0, | |
| 73 | cause, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ); | |
| 77 | // The token rides in a header for this process only: never in the URL, argv, or git config. | |
| 78 | const remote = input.remote ?? remoteUrl(input.host, input.repository); | |
| 79 | const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; | |
| 80 | const env = { | |
| 81 | GIT_TERMINAL_PROMPT: "0", | |
| 82 | GIT_CONFIG_COUNT: "3", | |
| 83 | GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, | |
| 84 | GIT_CONFIG_VALUE_0: authorization, | |
| 85 | GIT_CONFIG_KEY_1: "user.name", | |
| 86 | GIT_CONFIG_VALUE_1: "T3 Code", | |
| 87 | GIT_CONFIG_KEY_2: "user.email", | |
| 88 | GIT_CONFIG_VALUE_2: "noreply@t3.codes", | |
| 89 | }; | |
| 90 | const git = (args: ReadonlyArray<string>, allowNonZeroExit = false) => | |
| 91 | process.run({ | |
| 92 | operation: "cascadeRebaseStack", | |
| 93 | command: "git", | |
| 94 | args, | |
| 95 | cwd: directory, | |
| 96 | env, | |
| 97 | timeoutMs: 120_000, | |
| 98 | ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), | |
| 99 | }); | |
| 100 | const failed = (step: string, number: number, completed: number) => (cause: unknown) => | |
| 101 | new GitHubStackRebaseGitError({ step, number, completed, cause }); | |
| 102 | ||
| 103 | yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); | |
| 104 | yield* git(["remote", "add", "origin", remote]).pipe( | |
| 105 | Effect.mapError(failed("preparing", first.number, 0)), | |
| 106 | ); | |
| 107 | yield* git([ | |
| 108 | "fetch", | |
| 109 | "--quiet", | |
| 110 | "--no-tags", | |
| 111 | "origin", | |
| 112 | `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, | |
| 113 | ...input.layers.map( | |
| 114 | (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, | |
| 115 | ), | |
| 116 | ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); | |
| 117 | ||
| 118 | let parentOld = `origin/${input.base}`; | |
| 119 | let parentNew = `origin/${input.base}`; | |
| 120 | for (const [index, layer] of input.layers.entries()) { | |
| 121 | // The bottom layer's old parent is where it forked from the base; above that it is the | |
| 122 | // reviewed head of the layer below, which is exactly the commits this layer must not replay. | |
| 123 | const upstream = | |
| 124 | index === 0 | |
| 125 | ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( | |
| 126 | Effect.mapError(failed("reading the fork point", layer.number, index)), | |
| 127 | )).stdout.trim() | |
| 128 | : parentOld; | |
| 129 | yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( | |
| 130 | Effect.mapError(failed("checking out", layer.number, index)), | |
| 131 | ); | |
| 132 | const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( | |
| 133 | Effect.mapError(failed("rebasing", layer.number, index)), | |
| 134 | ); | |
| 135 | if (rebase.exitCode !== 0) { | |
| 136 | yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); | |
| 137 | return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); | |
| 138 | } | |
| 139 | const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( | |
| 140 | Effect.mapError(failed("rebasing", layer.number, index)), | |
| 141 | )).stdout.trim(); | |
| 142 | if (rebased !== layer.headSha) { | |
| 143 | yield* git([ | |
| 144 | "push", | |
| 145 | "--quiet", | |
| 146 | `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, | |
| 147 | "origin", | |
| 148 | `${rebased}:refs/heads/${layer.headBranch}`, | |
| 149 | ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); | |
| 150 | } | |
| 151 | parentOld = layer.headSha; | |
| 152 | parentNew = rebased; | |
| 153 | } | |
| 154 | return input.layers.length; | |
| 155 | }, Effect.scoped); |
apps/server/src/server.ts+3-4| ... | ... | @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; |
| 52 | 52 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; |
| 53 | 53 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 54 | 54 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 55 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | |
| 56 | 55 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 57 | 56 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 58 | 57 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| ... | ... | @@ -296,7 +295,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l |
| 296 | 295 | const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)); |
| 297 | 296 | |
| 298 | 297 | const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( |
| 299 | Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)), | |
| 298 | Layer.provideMerge(SourceControlBuiltInDrivers.layer), | |
| 300 | 299 | Layer.provideMerge(GitVcsDriver.layer), |
| 301 | 300 | Layer.provideMerge(layerVcsDriverRegistry), |
| 302 | 301 | ); |
| ... | ... | @@ -585,9 +584,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 585 | 584 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), |
| 586 | 585 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), |
| 587 | 586 | Layer.provideMerge(layerServerSettings), |
| 588 | // The asset route uses the registry's GitHub credential for private PR media. | |
| 587 | // The asset route uses the registry's GitHub credential for private PR media, which the | |
| 588 | // built-in drivers' layer provides alongside the registry. | |
| 589 | 589 | Layer.provideMerge(layerSourceControlProviderRegistry), |
| 590 | Layer.provideMerge(GitHubApi.layerWithDependencies), | |
| 591 | 590 | Layer.provideMerge(layerGit), |
| 592 | 591 | Layer.provideMerge(layerVcs), |
| 593 | 592 | Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), |
apps/server/src/sourceControl/GitHubApi.test.ts deleted-525| ... | ... | @@ -1,525 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Redacted from "effect/Redacted"; | |
| 6 | import * as PlatformError from "effect/PlatformError"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import * as Tracer from "effect/Tracer"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; | |
| 11 | import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; | |
| 12 | ||
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 15 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 16 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 18 | import * as ServerSettings from "../serverSettings.ts"; | |
| 19 | ||
| 20 | const NOW = Date.parse("2026-10-05T12:00:00.000Z"); | |
| 21 | ||
| 22 | function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { | |
| 23 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 24 | let tokens = ["first", "second"]; | |
| 25 | let invalidations = 0; | |
| 26 | const credentials = Layer.succeed( | |
| 27 | GitHubCredentials.GitHubCredentials, | |
| 28 | GitHubCredentials.GitHubCredentials.of({ | |
| 29 | get: (host) => | |
| 30 | Effect.sync(() => ({ | |
| 31 | host, | |
| 32 | token: Redacted.make(tokens[0]!), | |
| 33 | source: "gh" as const, | |
| 34 | fingerprint: `${host}:${tokens[0]}`, | |
| 35 | })), | |
| 36 | invalidate: () => | |
| 37 | Effect.sync(() => { | |
| 38 | invalidations++; | |
| 39 | tokens = tokens.slice(1); | |
| 40 | }), | |
| 41 | }), | |
| 42 | ); | |
| 43 | const http = Layer.succeed( | |
| 44 | HttpClient.HttpClient, | |
| 45 | HttpClient.make((request) => { | |
| 46 | requests.push(request); | |
| 47 | return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); | |
| 48 | }), | |
| 49 | ); | |
| 50 | const layer = GitHubApi.layer.pipe( | |
| 51 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 52 | Layer.provideMerge(GitHubQuota.layer), | |
| 53 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 54 | ); | |
| 55 | return { layer, requests, invalidations: () => invalidations }; | |
| 56 | } | |
| 57 | ||
| 58 | const json = (body: unknown, init?: ResponseInit) => | |
| 59 | new Response(JSON.stringify(body), { | |
| 60 | ...init, | |
| 61 | headers: { "content-type": "application/json", ...init?.headers }, | |
| 62 | }); | |
| 63 | ||
| 64 | describe("gitHubApiUrls", () => { | |
| 65 | it("maps github.com, GHE.com and GitHub Enterprise Server", () => { | |
| 66 | expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ | |
| 67 | rest: "https://api.github.com", | |
| 68 | graphql: "https://api.github.com/graphql", | |
| 69 | }); | |
| 70 | expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( | |
| 71 | "https://api.acme.ghe.com/graphql", | |
| 72 | ); | |
| 73 | expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ | |
| 74 | rest: "https://git.acme.internal/api/v3", | |
| 75 | graphql: "https://git.acme.internal/api/graphql", | |
| 76 | }); | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 80 | describe("environmentToken", () => { | |
| 81 | it("follows gh's precedence per kind of host", () => { | |
| 82 | const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; | |
| 83 | expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); | |
| 84 | expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( | |
| 85 | "github", | |
| 86 | ); | |
| 87 | // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. | |
| 88 | expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); | |
| 89 | expect( | |
| 90 | GitHubCredentials.environmentToken("git.acme.internal", { | |
| 91 | ...env, | |
| 92 | GH_HOST: "git.acme.internal", | |
| 93 | }), | |
| 94 | ).toBe("ghe"); | |
| 95 | expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); | |
| 96 | }); | |
| 97 | }); | |
| 98 | ||
| 99 | describe("GitHubApi", () => { | |
| 100 | it.effect("sends GraphQL with the token, and the document as written", () => { | |
| 101 | const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); | |
| 102 | return Effect.gen(function* () { | |
| 103 | yield* TestClock.setTime(NOW); | |
| 104 | const api = yield* GitHubApi.GitHubApi; | |
| 105 | const body = yield* api.graphql({ | |
| 106 | host: "github.com", | |
| 107 | operation: "viewer", | |
| 108 | query: "query { viewer { login } }", | |
| 109 | }); | |
| 110 | expect(body).toContain('"login":"julius"'); | |
| 111 | expect(requests[0]!.url).toBe("https://api.github.com/graphql"); | |
| 112 | expect(requests[0]!.headers.authorization).toBe("Bearer first"); | |
| 113 | // No `rateLimit` selection is added: the quota is read off the response headers. | |
| 114 | const sent = requests[0]!.body; | |
| 115 | expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( | |
| 116 | '"query":"query { viewer { login } }"', | |
| 117 | ); | |
| 118 | }).pipe(Effect.provide(layer)); | |
| 119 | }); | |
| 120 | ||
| 121 | it.effect( | |
| 122 | "traces the operation, path, query and quota, never the query string or variables", | |
| 123 | () => { | |
| 124 | const spans: Array<Tracer.NativeSpan> = []; | |
| 125 | const tracer = Tracer.make({ | |
| 126 | span: (options) => { | |
| 127 | const span = new Tracer.NativeSpan(options); | |
| 128 | spans.push(span); | |
| 129 | return span; | |
| 130 | }, | |
| 131 | }); | |
| 132 | const { layer } = harness((request) => | |
| 133 | request.url.endsWith("/graphql") | |
| 134 | ? json( | |
| 135 | { data: { viewer: { login: "julius" } } }, | |
| 136 | { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, | |
| 137 | ) | |
| 138 | : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), | |
| 139 | ); | |
| 140 | return Effect.gen(function* () { | |
| 141 | yield* TestClock.setTime(NOW); | |
| 142 | const api = yield* GitHubApi.GitHubApi; | |
| 143 | yield* api.graphql({ | |
| 144 | host: "github.com", | |
| 145 | operation: "getPullRequestDetail", | |
| 146 | query: "query($body: String!) { viewer { login } }", | |
| 147 | variables: { body: "secret user text" }, | |
| 148 | }); | |
| 149 | yield* api.rest({ | |
| 150 | host: "github.com", | |
| 151 | operation: "listWorkflowRuns", | |
| 152 | path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", | |
| 153 | }); | |
| 154 | const byName = (name: string) => spans.filter((span) => span.name === name); | |
| 155 | const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); | |
| 156 | expect(graphql).toMatchObject({ | |
| 157 | "github.operation": "getPullRequestDetail", | |
| 158 | "github.graphql.query": "query($body: String!) { viewer { login } }", | |
| 159 | }); | |
| 160 | expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ | |
| 161 | "github.ratelimit.remaining": 4990, | |
| 162 | "github.ratelimit.resource": "graphql", | |
| 163 | }); | |
| 164 | expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); | |
| 165 | const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); | |
| 166 | expect(rest).toMatchObject({ | |
| 167 | "github.operation": "listWorkflowRuns", | |
| 168 | "github.kind": "rest", | |
| 169 | "url.path": "/repos/acme/web/actions/runs", | |
| 170 | "http.response.status_code": 200, | |
| 171 | "github.ratelimit.remaining": 4800, | |
| 172 | }); | |
| 173 | // Nothing recorded carries the query string, the variables, or the token. | |
| 174 | const everything = spans | |
| 175 | .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) | |
| 176 | .join("\n"); | |
| 177 | expect(everything).not.toContain("head_sha"); | |
| 178 | expect(everything).not.toContain("secret user text"); | |
| 179 | expect(everything).not.toContain("first"); | |
| 180 | expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); | |
| 181 | }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); | |
| 182 | }, | |
| 183 | ); | |
| 184 | ||
| 185 | it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { | |
| 186 | const { layer } = harness(() => | |
| 187 | json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), | |
| 188 | ); | |
| 189 | return Effect.gen(function* () { | |
| 190 | const api = yield* GitHubApi.GitHubApi; | |
| 191 | const error = yield* Effect.flip( | |
| 192 | api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), | |
| 193 | ); | |
| 194 | expect(error._tag).toBe("GitHubApiResponseError"); | |
| 195 | expect(error.message).toContain("Resource not accessible"); | |
| 196 | }).pipe(Effect.provide(layer)); | |
| 197 | }); | |
| 198 | ||
| 199 | it.effect( | |
| 200 | "reads a NOT_FOUND answer with another error as a failure, not a missing resource", | |
| 201 | () => { | |
| 202 | const { layer } = harness(() => | |
| 203 | json({ | |
| 204 | data: null, | |
| 205 | errors: [ | |
| 206 | { type: "NOT_FOUND", message: "Could not resolve" }, | |
| 207 | { message: "Something went wrong" }, | |
| 208 | ], | |
| 209 | }), | |
| 210 | ); | |
| 211 | return Effect.gen(function* () { | |
| 212 | const api = yield* GitHubApi.GitHubApi; | |
| 213 | const error = yield* Effect.flip( | |
| 214 | api.graphql({ | |
| 215 | host: "github.com", | |
| 216 | operation: "detail", | |
| 217 | query: "query { viewer { id } }", | |
| 218 | }), | |
| 219 | ); | |
| 220 | expect(error._tag).toBe("GitHubApiResponseError"); | |
| 221 | }).pipe(Effect.provide(layer)); | |
| 222 | }, | |
| 223 | ); | |
| 224 | ||
| 225 | it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { | |
| 226 | const reset = Math.floor(NOW / 1000) + 600; | |
| 227 | const { layer, requests } = harness(() => | |
| 228 | json( | |
| 229 | { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, | |
| 230 | { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, | |
| 231 | ), | |
| 232 | ); | |
| 233 | return Effect.gen(function* () { | |
| 234 | yield* TestClock.setTime(NOW); | |
| 235 | const api = yield* GitHubApi.GitHubApi; | |
| 236 | const first = yield* Effect.flip( | |
| 237 | api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), | |
| 238 | ); | |
| 239 | expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); | |
| 240 | const second = yield* Effect.flip( | |
| 241 | api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), | |
| 242 | ); | |
| 243 | expect(second).toMatchObject({ | |
| 244 | _tag: "SourceControlRateLimitPausedError", | |
| 245 | retryAt: reset * 1000, | |
| 246 | }); | |
| 247 | expect(requests).toHaveLength(1); | |
| 248 | }).pipe(Effect.provide(layer)); | |
| 249 | }); | |
| 250 | ||
| 251 | it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { | |
| 252 | const reset = Math.floor(NOW / 1000) + 600; | |
| 253 | const quota = (remaining: number) => ({ | |
| 254 | "x-ratelimit-resource": "core", | |
| 255 | "x-ratelimit-limit": "5000", | |
| 256 | "x-ratelimit-remaining": String(remaining), | |
| 257 | "x-ratelimit-reset": String(reset), | |
| 258 | }); | |
| 259 | let remaining = 499; | |
| 260 | const { layer, requests } = harness((request) => | |
| 261 | request.url.endsWith("/graphql") | |
| 262 | ? json({ data: { viewer: { id: "1" } } }) | |
| 263 | : json({ ok: true }, { headers: quota(remaining--) }), | |
| 264 | ); | |
| 265 | return Effect.gen(function* () { | |
| 266 | yield* TestClock.setTime(NOW); | |
| 267 | const api = yield* GitHubApi.GitHubApi; | |
| 268 | const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; | |
| 269 | // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. | |
| 270 | yield* api.rest(read); | |
| 271 | const refused = yield* Effect.flip(api.rest(read)); | |
| 272 | expect(refused).toMatchObject({ | |
| 273 | _tag: "SourceControlRateLimitPausedError", | |
| 274 | retryAt: reset * 1000, | |
| 275 | }); | |
| 276 | // A user's own request may spend the reserve, and GraphQL has a quota of its own. | |
| 277 | yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 278 | yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); | |
| 279 | expect(requests).toHaveLength(3); | |
| 280 | // The reset gives the background its quota back. | |
| 281 | yield* TestClock.setTime(reset * 1000); | |
| 282 | yield* api.rest(read); | |
| 283 | expect(requests).toHaveLength(4); | |
| 284 | }).pipe(Effect.provide(layer)); | |
| 285 | }); | |
| 286 | ||
| 287 | it.effect("carries GitHub's own reason for a refused REST request", () => { | |
| 288 | const { layer } = harness(() => | |
| 289 | json( | |
| 290 | { | |
| 291 | message: "Validation Failed", | |
| 292 | errors: [ | |
| 293 | { | |
| 294 | resource: "PullRequest", | |
| 295 | code: "custom", | |
| 296 | message: "A pull request already exists for acme:feature.", | |
| 297 | }, | |
| 298 | ], | |
| 299 | }, | |
| 300 | { status: 422 }, | |
| 301 | ), | |
| 302 | ); | |
| 303 | return Effect.gen(function* () { | |
| 304 | const api = yield* GitHubApi.GitHubApi; | |
| 305 | const error = yield* Effect.flip( | |
| 306 | api.rest({ | |
| 307 | host: "github.com", | |
| 308 | operation: "createPullRequest", | |
| 309 | method: "POST", | |
| 310 | path: "repos/acme/web/pulls", | |
| 311 | }), | |
| 312 | ); | |
| 313 | expect(error.message).toBe( | |
| 314 | "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", | |
| 315 | ); | |
| 316 | }).pipe(Effect.provide(layer)); | |
| 317 | }); | |
| 318 | ||
| 319 | it.effect("lets an interactive request through a pause a background read recorded", () => { | |
| 320 | const reset = Math.floor(NOW / 1000) + 600; | |
| 321 | let call = 0; | |
| 322 | const { layer, requests } = harness(() => | |
| 323 | ++call === 1 | |
| 324 | ? json( | |
| 325 | { message: "API rate limit exceeded" }, | |
| 326 | { | |
| 327 | status: 403, | |
| 328 | headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, | |
| 329 | }, | |
| 330 | ) | |
| 331 | : json({ ok: true }), | |
| 332 | ); | |
| 333 | return Effect.gen(function* () { | |
| 334 | yield* TestClock.setTime(NOW); | |
| 335 | const api = yield* GitHubApi.GitHubApi; | |
| 336 | const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; | |
| 337 | yield* Effect.flip(api.rest(read)); | |
| 338 | expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); | |
| 339 | const merged = yield* api | |
| 340 | .rest({ | |
| 341 | host: "github.com", | |
| 342 | operation: "merge", | |
| 343 | method: "PUT", | |
| 344 | path: "repos/acme/web/pulls/7/merge", | |
| 345 | }) | |
| 346 | .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 347 | expect(merged.status).toBe(200); | |
| 348 | expect(requests).toHaveLength(2); | |
| 349 | }).pipe(Effect.provide(layer)); | |
| 350 | }); | |
| 351 | ||
| 352 | it.effect("reads an untyped GraphQL quota error as a rate limit", () => { | |
| 353 | const reset = Math.floor(NOW / 1000) + 900; | |
| 354 | const { layer } = harness(() => | |
| 355 | json( | |
| 356 | { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, | |
| 357 | { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, | |
| 358 | ), | |
| 359 | ); | |
| 360 | return Effect.gen(function* () { | |
| 361 | yield* TestClock.setTime(NOW); | |
| 362 | const api = yield* GitHubApi.GitHubApi; | |
| 363 | const error = yield* Effect.flip( | |
| 364 | api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), | |
| 365 | ); | |
| 366 | expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); | |
| 367 | }).pipe(Effect.provide(layer)); | |
| 368 | }); | |
| 369 | ||
| 370 | it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { | |
| 371 | let call = 0; | |
| 372 | const { layer } = harness(() => | |
| 373 | ++call === 1 | |
| 374 | ? new Response(null, { status: 304 }) | |
| 375 | : json( | |
| 376 | { message: "API rate limit exceeded" }, | |
| 377 | { | |
| 378 | status: 403, | |
| 379 | headers: { "retry-after": "60" }, | |
| 380 | }, | |
| 381 | ), | |
| 382 | ); | |
| 383 | return Effect.gen(function* () { | |
| 384 | yield* TestClock.setTime(NOW); | |
| 385 | const api = yield* GitHubApi.GitHubApi; | |
| 386 | const notModified = yield* api.rest({ | |
| 387 | host: "github.com", | |
| 388 | operation: "checks", | |
| 389 | path: "repos/acme/web/pulls/7", | |
| 390 | ifNoneMatch: '"abc"', | |
| 391 | }); | |
| 392 | expect(notModified.status).toBe(304); | |
| 393 | const error = yield* Effect.flip( | |
| 394 | api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), | |
| 395 | ); | |
| 396 | expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); | |
| 397 | }).pipe(Effect.provide(layer)); | |
| 398 | }); | |
| 399 | ||
| 400 | it.effect("drops a refused token so the next request asks the source again", () => { | |
| 401 | const { layer, requests, invalidations } = harness((request) => | |
| 402 | request.headers.authorization === "Bearer first" | |
| 403 | ? new Response(null, { status: 401 }) | |
| 404 | : json({ ok: true }), | |
| 405 | ); | |
| 406 | return Effect.gen(function* () { | |
| 407 | const api = yield* GitHubApi.GitHubApi; | |
| 408 | const refused = yield* Effect.flip( | |
| 409 | api.rest({ host: "github.com", operation: "user", path: "user" }), | |
| 410 | ); | |
| 411 | expect(refused._tag).toBe("GitHubApiAuthenticationError"); | |
| 412 | expect(invalidations()).toBe(1); | |
| 413 | const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); | |
| 414 | expect(answered.status).toBe(200); | |
| 415 | expect(requests.map((request) => request.headers.authorization)).toEqual([ | |
| 416 | "Bearer first", | |
| 417 | "Bearer second", | |
| 418 | ]); | |
| 419 | }).pipe(Effect.provide(layer)); | |
| 420 | }); | |
| 421 | ||
| 422 | it.effect("refuses to send a pinned credential to another host", () => { | |
| 423 | const { layer, requests } = harness(() => json({})); | |
| 424 | return Effect.gen(function* () { | |
| 425 | const api = yield* GitHubApi.GitHubApi; | |
| 426 | const error = yield* Effect.flip( | |
| 427 | api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), | |
| 428 | ).pipe( | |
| 429 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 430 | host: "github.com", | |
| 431 | token: Redacted.make("pinned"), | |
| 432 | credentialFingerprint: "github.com:pinned", | |
| 433 | }), | |
| 434 | ); | |
| 435 | expect(error._tag).toBe("GitHubApiAuthenticationError"); | |
| 436 | expect(requests).toHaveLength(0); | |
| 437 | }).pipe(Effect.provide(layer)); | |
| 438 | }); | |
| 439 | }); | |
| 440 | ||
| 441 | describe("GitHubCredentials", () => { | |
| 442 | const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) => | |
| 443 | GitHubCredentials.layer.pipe( | |
| 444 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), | |
| 445 | Layer.provide(NodeServices.layer), | |
| 446 | Layer.provide(ServerSettings.layerTest()), | |
| 447 | ); | |
| 448 | ||
| 449 | it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => | |
| 450 | Effect.gen(function* () { | |
| 451 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 452 | const error = yield* Effect.flip(credentials.get("git.acme.internal")); | |
| 453 | expect(error._tag).toBe("GitHubCliMissingError"); | |
| 454 | }).pipe( | |
| 455 | Effect.provide( | |
| 456 | credentialsWith(() => | |
| 457 | Effect.fail( | |
| 458 | new VcsProcessSpawnError({ | |
| 459 | operation: "GitHubCredentials.get", | |
| 460 | command: "gh", | |
| 461 | cwd: "/", | |
| 462 | cause: PlatformError.systemError({ | |
| 463 | _tag: "NotFound", | |
| 464 | module: "ChildProcess", | |
| 465 | method: "spawn", | |
| 466 | }), | |
| 467 | }), | |
| 468 | ), | |
| 469 | ), | |
| 470 | ), | |
| 471 | ), | |
| 472 | ); | |
| 473 | ||
| 474 | it.effect("does not cache a gh failure that is not about signing in", () => { | |
| 475 | let calls = 0; | |
| 476 | return Effect.gen(function* () { | |
| 477 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 478 | const first = yield* Effect.flip(credentials.get("github.com")); | |
| 479 | expect(first._tag).toBe("GitHubCliFailedError"); | |
| 480 | expect((yield* credentials.get("github.com")).source).toBe("gh"); | |
| 481 | expect(calls).toBe(2); | |
| 482 | }).pipe( | |
| 483 | Effect.provide( | |
| 484 | credentialsWith(() => | |
| 485 | ++calls === 1 | |
| 486 | ? Effect.fail( | |
| 487 | new VcsProcessTimeoutError({ | |
| 488 | operation: "GitHubCredentials.get", | |
| 489 | command: "gh", | |
| 490 | cwd: "/", | |
| 491 | timeoutMs: 10_000, | |
| 492 | }), | |
| 493 | ) | |
| 494 | : Effect.succeed({ | |
| 495 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 496 | stdout: "token\n", | |
| 497 | stderr: "", | |
| 498 | stdoutTruncated: false, | |
| 499 | stderrTruncated: false, | |
| 500 | }), | |
| 501 | ), | |
| 502 | ), | |
| 503 | ); | |
| 504 | }); | |
| 505 | ||
| 506 | it.effect("fails with GitHubNotSignedInError when gh prints no token", () => | |
| 507 | Effect.gen(function* () { | |
| 508 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 509 | const error = yield* Effect.flip(credentials.get("git.acme.internal")); | |
| 510 | expect(error._tag).toBe("GitHubNotSignedInError"); | |
| 511 | }).pipe( | |
| 512 | Effect.provide( | |
| 513 | credentialsWith(() => | |
| 514 | Effect.succeed({ | |
| 515 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 516 | stdout: "\n", | |
| 517 | stderr: "", | |
| 518 | stdoutTruncated: false, | |
| 519 | stderrTruncated: false, | |
| 520 | }), | |
| 521 | ), | |
| 522 | ), | |
| 523 | ), | |
| 524 | ); | |
| 525 | }); |
apps/server/src/sourceControl/GitHubApi.ts deleted-598| ... | ... | @@ -1,598 +0,0 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Semaphore from "effect/Semaphore"; | |
| 11 | import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; | |
| 12 | ||
| 13 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 15 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 16 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 17 | ||
| 18 | const DEFAULT_TIMEOUT = Duration.seconds(30); | |
| 19 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; | |
| 20 | /** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ | |
| 21 | const CONCURRENCY = 8; | |
| 22 | const API_VERSION = "2022-11-28"; | |
| 23 | ||
| 24 | /** | |
| 25 | * A credential already verified for one host. Every request made under it must target that | |
| 26 | * host, so a page cannot read one account's data with another's token mid-flight. Server-local: | |
| 27 | * never put its value in RPC payloads or cache keys. | |
| 28 | */ | |
| 29 | export const PinnedGitHubCredential = Context.Reference<{ | |
| 30 | readonly host: string; | |
| 31 | readonly token: Redacted.Redacted<string>; | |
| 32 | readonly credentialFingerprint: string; | |
| 33 | } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); | |
| 34 | ||
| 35 | /** | |
| 36 | * Set by interactive callers (a user's read or write, not a background sweep). Requests made | |
| 37 | * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a | |
| 38 | * pull request should not be refused because a background read exhausted the quota. | |
| 39 | */ | |
| 40 | export const AllowGitHubReserve = Context.Reference<boolean>( | |
| 41 | "t3/sourceControl/AllowGitHubReserve", | |
| 42 | { defaultValue: () => false }, | |
| 43 | ); | |
| 44 | ||
| 45 | export class GitHubApiRequestError extends Schema.TaggedError<GitHubApiRequestError>()( | |
| 46 | "GitHubApiRequestError", | |
| 47 | { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, | |
| 48 | ) { | |
| 49 | override get message(): string { | |
| 50 | return `Could not reach GitHub at ${this.host}.`; | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | export class GitHubApiAuthenticationError extends Schema.TaggedError<GitHubApiAuthenticationError>()( | |
| 55 | "GitHubApiAuthenticationError", | |
| 56 | { host: Schema.String, operation: Schema.String }, | |
| 57 | ) { | |
| 58 | override get message(): string { | |
| 59 | return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | export class GitHubApiRateLimitError extends Schema.TaggedError<GitHubApiRateLimitError>()( | |
| 64 | "GitHubApiRateLimitError", | |
| 65 | { | |
| 66 | host: Schema.String, | |
| 67 | operation: Schema.String, | |
| 68 | retryAt: Schema.optionalKey(Schema.Finite), | |
| 69 | }, | |
| 70 | ) { | |
| 71 | override get message(): string { | |
| 72 | return "GitHub API rate limit exceeded."; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | export class GitHubApiNotFoundError extends Schema.TaggedError<GitHubApiNotFoundError>()( | |
| 77 | "GitHubApiNotFoundError", | |
| 78 | { host: Schema.String, operation: Schema.String }, | |
| 79 | ) { | |
| 80 | override get message(): string { | |
| 81 | return "GitHub could not find the requested resource, or the credential cannot see it."; | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own | |
| 87 | * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the | |
| 88 | * field and the reason ("A pull request already exists for acme:feature"), never a token. | |
| 89 | */ | |
| 90 | export class GitHubApiResponseError extends Schema.TaggedError<GitHubApiResponseError>()( | |
| 91 | "GitHubApiResponseError", | |
| 92 | { | |
| 93 | host: Schema.String, | |
| 94 | operation: Schema.String, | |
| 95 | status: Schema.Int, | |
| 96 | githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 97 | }, | |
| 98 | ) { | |
| 99 | override get message(): string { | |
| 100 | return this.githubErrors !== undefined && this.githubErrors.length > 0 | |
| 101 | ? `GitHub returned an error: ${this.githubErrors.join("; ")}` | |
| 102 | : `GitHub returned HTTP ${this.status}.`; | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | export type GitHubApiError = | |
| 107 | | GitHubCredentials.GitHubCredentialUnavailableError | |
| 108 | | GitHubApiRequestError | |
| 109 | | GitHubApiAuthenticationError | |
| 110 | | GitHubApiRateLimitError | |
| 111 | | GitHubApiNotFoundError | |
| 112 | | GitHubApiResponseError | |
| 113 | | SourceControlRateLimit.SourceControlRateLimitPausedError; | |
| 114 | ||
| 115 | export interface GitHubRestResponse { | |
| 116 | readonly status: number; | |
| 117 | readonly headers: Readonly<Record<string, string | undefined>>; | |
| 118 | readonly body: string; | |
| 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; | |
| 122 | } | |
| 123 | ||
| 124 | export interface GitHubRestInput { | |
| 125 | readonly host: string; | |
| 126 | readonly operation: string; | |
| 127 | readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | |
| 128 | /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ | |
| 129 | readonly path: string; | |
| 130 | readonly body?: unknown; | |
| 131 | /** Defaults to `application/vnd.github+json`. */ | |
| 132 | readonly accept?: string; | |
| 133 | /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ | |
| 134 | readonly ifNoneMatch?: string; | |
| 135 | readonly maxResponseBytes?: number; | |
| 136 | /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ | |
| 137 | readonly timeout?: Duration.Input; | |
| 138 | /** Overrides `AllowGitHubReserve` for this one request. */ | |
| 139 | readonly allowReserve?: boolean; | |
| 140 | } | |
| 141 | ||
| 142 | export interface GitHubGraphQlInput { | |
| 143 | readonly host: string; | |
| 144 | readonly operation: string; | |
| 145 | readonly query: string; | |
| 146 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 147 | readonly allowReserve?: boolean; | |
| 148 | readonly maxResponseBytes?: number; | |
| 149 | } | |
| 150 | ||
| 151 | export class GitHubApi extends Context.Service< | |
| 152 | GitHubApi, | |
| 153 | { | |
| 154 | /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ | |
| 155 | readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect<string, GitHubApiError>; | |
| 156 | readonly rest: (input: GitHubRestInput) => Effect.Effect<GitHubRestResponse, GitHubApiError>; | |
| 157 | /** The credential a request to `host` would carry right now. */ | |
| 158 | readonly credential: ( | |
| 159 | host: string, | |
| 160 | ) => Effect.Effect< | |
| 161 | { readonly token: Redacted.Redacted<string>; readonly fingerprint: string }, | |
| 162 | GitHubApiError | |
| 163 | >; | |
| 164 | } | |
| 165 | >()("t3/sourceControl/GitHubApi") {} | |
| 166 | ||
| 167 | function normalizeHost(host: string): string { | |
| 168 | return host.trim().toLowerCase(); | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` | |
| 173 | * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. | |
| 174 | */ | |
| 175 | export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { | |
| 176 | const normalized = normalizeHost(host); | |
| 177 | if (normalized === "github.com") { | |
| 178 | return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; | |
| 179 | } | |
| 180 | if (normalized.endsWith(".ghe.com")) { | |
| 181 | return { | |
| 182 | rest: `https://api.${normalized}`, | |
| 183 | graphql: `https://api.${normalized}/graphql`, | |
| 184 | }; | |
| 185 | } | |
| 186 | return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; | |
| 187 | } | |
| 188 | ||
| 189 | /** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ | |
| 190 | const TRACED_QUERY_MAX_CHARS = 4_000; | |
| 191 | ||
| 192 | /** A stable short id for a GraphQL document, so traces group by query without its text. */ | |
| 193 | function queryHash(query: string): string { | |
| 194 | let hash = 0x811c9dc5; | |
| 195 | for (let index = 0; index < query.length; index++) { | |
| 196 | hash ^= query.charCodeAt(index); | |
| 197 | hash = Math.imul(hash, 0x01000193); | |
| 198 | } | |
| 199 | return (hash >>> 0).toString(16).padStart(8, "0"); | |
| 200 | } | |
| 201 | ||
| 202 | /** The numeric rate-limit headers GitHub sends, as span attributes. */ | |
| 203 | function rateLimitAttributes( | |
| 204 | headers: Readonly<Record<string, string | undefined>>, | |
| 205 | ): Record<string, number | string> { | |
| 206 | const attributes: Record<string, number | string> = {}; | |
| 207 | for (const [header, name] of [ | |
| 208 | ["x-ratelimit-limit", "github.ratelimit.limit"], | |
| 209 | ["x-ratelimit-remaining", "github.ratelimit.remaining"], | |
| 210 | ["x-ratelimit-used", "github.ratelimit.used"], | |
| 211 | ["x-ratelimit-reset", "github.ratelimit.reset"], | |
| 212 | ] as const) { | |
| 213 | const value = Number(headers[header]); | |
| 214 | if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; | |
| 215 | } | |
| 216 | const resource = headers["x-ratelimit-resource"]; | |
| 217 | if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; | |
| 218 | return attributes; | |
| 219 | } | |
| 220 | ||
| 221 | /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ | |
| 222 | function retryAtFrom( | |
| 223 | headers: Readonly<Record<string, string | undefined>>, | |
| 224 | now: number, | |
| 225 | ): number | undefined { | |
| 226 | const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); | |
| 227 | if (fromRetryAfter !== undefined) return fromRetryAfter; | |
| 228 | const reset = Number(headers["x-ratelimit-reset"]) * 1_000; | |
| 229 | return Number.isFinite(reset) && reset > now ? reset : undefined; | |
| 230 | } | |
| 231 | ||
| 232 | const decodeGraphQlErrors = Schema.decodeUnknownOption( | |
| 233 | Schema.fromJsonString( | |
| 234 | Schema.Struct({ | |
| 235 | errors: Schema.NonEmptyArray( | |
| 236 | Schema.Struct({ | |
| 237 | type: Schema.optional(Schema.String), | |
| 238 | message: Schema.optional(Schema.String), | |
| 239 | }), | |
| 240 | ), | |
| 241 | }), | |
| 242 | ), | |
| 243 | ); | |
| 244 | ||
| 245 | /** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ | |
| 246 | const decodeRestErrors = Schema.decodeUnknownOption( | |
| 247 | Schema.fromJsonString( | |
| 248 | Schema.Struct({ | |
| 249 | message: Schema.optional(Schema.String), | |
| 250 | errors: Schema.optional( | |
| 251 | Schema.Array( | |
| 252 | Schema.Union([ | |
| 253 | Schema.String, | |
| 254 | Schema.Struct({ | |
| 255 | message: Schema.optional(Schema.String), | |
| 256 | field: Schema.optional(Schema.String), | |
| 257 | code: Schema.optional(Schema.String), | |
| 258 | }), | |
| 259 | ]), | |
| 260 | ), | |
| 261 | ), | |
| 262 | }), | |
| 263 | ), | |
| 264 | ); | |
| 265 | ||
| 266 | function restErrorMessages(body: string): ReadonlyArray<string> | undefined { | |
| 267 | return Option.match(decodeRestErrors(body), { | |
| 268 | onNone: () => undefined, | |
| 269 | onSome: (decoded) => { | |
| 270 | const details = (decoded.errors ?? []).flatMap((error) => | |
| 271 | typeof error === "string" | |
| 272 | ? [error] | |
| 273 | : error.message !== undefined | |
| 274 | ? [error.message] | |
| 275 | : error.field !== undefined && error.code !== undefined | |
| 276 | ? [`${error.field} ${error.code}`] | |
| 277 | : [], | |
| 278 | ); | |
| 279 | const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; | |
| 280 | return messages.length > 0 ? messages : undefined; | |
| 281 | }, | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | /** What one GitHub answer means, decided once from its status, headers and body. */ | |
| 286 | type Answer = Data.TaggedEnum<{ | |
| 287 | Ok: {}; | |
| 288 | RateLimited: {}; | |
| 289 | Unauthorized: {}; | |
| 290 | NotFound: {}; | |
| 291 | Failed: { readonly messages: ReadonlyArray<string> | undefined }; | |
| 292 | }>; | |
| 293 | const Answer = Data.taggedEnum<Answer>(); | |
| 294 | ||
| 295 | /** | |
| 296 | * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body | |
| 297 | * is read for its error types as well as its status. `gh api graphql` failed those too, and | |
| 298 | * callers rely on that to fall back to narrower reads. | |
| 299 | */ | |
| 300 | function classify(input: { | |
| 301 | readonly status: number; | |
| 302 | readonly headers: Readonly<Record<string, string | undefined>>; | |
| 303 | readonly body: string; | |
| 304 | readonly graphql: boolean; | |
| 305 | readonly acceptNotModified: boolean; | |
| 306 | }): Answer { | |
| 307 | const { status, headers, body } = input; | |
| 308 | const errors = input.graphql | |
| 309 | ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors | |
| 310 | : undefined; | |
| 311 | const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; | |
| 312 | const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); | |
| 313 | if ( | |
| 314 | status === 429 || | |
| 315 | types.includes("RATE_LIMITED") || | |
| 316 | // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already | |
| 317 | // exceeded" error; the headers say the same thing. | |
| 318 | (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || | |
| 319 | (input.graphql && | |
| 320 | messages !== undefined && | |
| 321 | messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || | |
| 322 | (status === 403 && | |
| 323 | (headers["x-ratelimit-remaining"] === "0" || | |
| 324 | headers["retry-after"] !== undefined || | |
| 325 | /rate limit/i.test(body))) | |
| 326 | ) { | |
| 327 | return Answer.RateLimited(); | |
| 328 | } | |
| 329 | if (status === 401) return Answer.Unauthorized(); | |
| 330 | if ( | |
| 331 | status === 404 || | |
| 332 | (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) | |
| 333 | ) { | |
| 334 | return Answer.NotFound(); | |
| 335 | } | |
| 336 | if (errors !== undefined) return Answer.Failed({ messages }); | |
| 337 | if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { | |
| 338 | return Answer.Ok(); | |
| 339 | } | |
| 340 | return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); | |
| 341 | } | |
| 342 | ||
| 343 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 344 | export const make = Effect.gen(function* () { | |
| 345 | const httpClient = yield* HttpClient.HttpClient; | |
| 346 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 347 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 348 | const limits = yield* SourceControlRateLimit.SourceControlRateLimit; | |
| 349 | const gate = yield* Semaphore.make(CONCURRENCY); | |
| 350 | ||
| 351 | const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( | |
| 352 | function* (host) { | |
| 353 | const normalized = normalizeHost(host); | |
| 354 | const pinned = yield* PinnedGitHubCredential; | |
| 355 | if (pinned !== null) { | |
| 356 | // A pinned page only ever talks to the host it verified. | |
| 357 | if (pinned.host !== normalized) { | |
| 358 | return yield* new GitHubApiAuthenticationError({ | |
| 359 | host: normalized, | |
| 360 | operation: "credential", | |
| 361 | }); | |
| 362 | } | |
| 363 | return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; | |
| 364 | } | |
| 365 | const held = yield* credentials.get(normalized); | |
| 366 | return { token: held.token, fingerprint: held.fingerprint }; | |
| 367 | }, | |
| 368 | ); | |
| 369 | ||
| 370 | /** | |
| 371 | * Sends one request under the host's rate-limit pause and fails a refusal with the error that | |
| 372 | * says why. `onSuccess` sees the answer only once it is known to be one. | |
| 373 | */ | |
| 374 | const send = Effect.fn("GitHubApi.send")(function* (input: { | |
| 375 | readonly host: string; | |
| 376 | readonly operation: string; | |
| 377 | readonly request: HttpClientRequest.HttpClientRequest; | |
| 378 | readonly maxResponseBytes: number; | |
| 379 | readonly timeout?: Duration.Input | undefined; | |
| 380 | readonly allowReserve: boolean; | |
| 381 | readonly acceptNotModified: boolean; | |
| 382 | /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ | |
| 383 | readonly graphql?: boolean; | |
| 384 | }) { | |
| 385 | const host = normalizeHost(input.host); | |
| 386 | // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. | |
| 387 | yield* Effect.annotateCurrentSpan({ | |
| 388 | "github.host": host, | |
| 389 | "github.operation": input.operation, | |
| 390 | "github.kind": input.graphql === true ? "graphql" : "rest", | |
| 391 | "http.request.method": input.request.method, | |
| 392 | "url.path": new URL(input.request.url).pathname, | |
| 393 | }); | |
| 394 | const { token, fingerprint } = yield* credential(host); | |
| 395 | const scope = yield* SourceControlRateLimit.CredentialScope; | |
| 396 | // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path | |
| 397 | // this reads today is `core`; a `search/` read would need its own resource here. A refusal | |
| 398 | // still pauses the whole host, the key PullRequestService records its own backoff under. | |
| 399 | const resource = input.graphql === true ? "graphql" : "core"; | |
| 400 | const key = { provider: "github" as const, host }; | |
| 401 | const run = Effect.gen(function* () { | |
| 402 | const lease = yield* quota | |
| 403 | .admit(host, resource, { allowReserve: input.allowReserve }) | |
| 404 | .pipe( | |
| 405 | Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), | |
| 406 | ) | |
| 407 | .pipe( | |
| 408 | Effect.tapError((paused) => | |
| 409 | Effect.annotateCurrentSpan({ | |
| 410 | "github.paused": true, | |
| 411 | "github.retry_at": paused.retryAt, | |
| 412 | }), | |
| 413 | ), | |
| 414 | ); | |
| 415 | // One deadline covers the headers and the body: a host that answers headers and then stalls | |
| 416 | // must not hold a slot of the shared gate for undici's own five-minute body timeout. | |
| 417 | const { response, collected } = yield* Effect.gen(function* () { | |
| 418 | const response: HttpClientResponse.HttpClientResponse = yield* httpClient | |
| 419 | .execute( | |
| 420 | input.request.pipe( | |
| 421 | HttpClientRequest.bearerToken(Redacted.value(token)), | |
| 422 | HttpClientRequest.setHeaders({ | |
| 423 | "x-github-api-version": API_VERSION, | |
| 424 | "user-agent": "t3code", | |
| 425 | }), | |
| 426 | ), | |
| 427 | ) | |
| 428 | .pipe( | |
| 429 | // `GitHubApi.send` is the request's span. The client's own span would add `url.full` | |
| 430 | // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. | |
| 431 | Effect.provideService(HttpClient.TracerDisabledWhen, () => true), | |
| 432 | ); | |
| 433 | const collected = yield* collectUint8StreamText({ | |
| 434 | stream: response.stream, | |
| 435 | maxBytes: input.maxResponseBytes, | |
| 436 | }).pipe( | |
| 437 | // 204, 304 and many refusals carry no body at all, which is an empty answer. | |
| 438 | Effect.catchIf( | |
| 439 | (error) => error.reason._tag === "EmptyBodyError", | |
| 440 | () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), | |
| 441 | ), | |
| 442 | ); | |
| 443 | return { response, collected }; | |
| 444 | }).pipe( | |
| 445 | Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), | |
| 446 | Effect.mapError( | |
| 447 | (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), | |
| 448 | ), | |
| 449 | ); | |
| 450 | const headers = response.headers; | |
| 451 | const status = response.status; | |
| 452 | yield* quota.observe(host, headers); | |
| 453 | yield* Effect.annotateCurrentSpan({ | |
| 454 | "http.response.status_code": status, | |
| 455 | ...rateLimitAttributes(headers), | |
| 456 | }); | |
| 457 | const context = { host, operation: input.operation }; | |
| 458 | return yield* Answer.$match( | |
| 459 | classify({ | |
| 460 | status, | |
| 461 | headers, | |
| 462 | body: collected.text, | |
| 463 | graphql: input.graphql === true, | |
| 464 | acceptNotModified: input.acceptNotModified, | |
| 465 | }), | |
| 466 | { | |
| 467 | Ok: () => | |
| 468 | limits.recordSuccess({ ...key, lease }).pipe( | |
| 469 | Effect.as({ | |
| 470 | status, | |
| 471 | headers, | |
| 472 | body: collected.text, | |
| 473 | truncated: collected.truncated, | |
| 474 | invalidUtf8: collected.invalidUtf8, | |
| 475 | }), | |
| 476 | ), | |
| 477 | RateLimited: () => | |
| 478 | Effect.gen(function* () { | |
| 479 | const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); | |
| 480 | yield* limits.recordRateLimit({ ...key, lease, retryAt }); | |
| 481 | yield* Effect.annotateCurrentSpan({ | |
| 482 | "github.rate_limited": true, | |
| 483 | ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), | |
| 484 | }); | |
| 485 | return yield* new GitHubApiRateLimitError({ | |
| 486 | ...context, | |
| 487 | ...(retryAt === undefined ? {} : { retryAt }), | |
| 488 | }); | |
| 489 | }), | |
| 490 | // The source may hold a newer token than the one that was refused. | |
| 491 | Unauthorized: () => | |
| 492 | credentials | |
| 493 | .invalidate(host) | |
| 494 | .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), | |
| 495 | NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), | |
| 496 | Failed: ({ messages }) => | |
| 497 | Effect.fail( | |
| 498 | new GitHubApiResponseError({ | |
| 499 | ...context, | |
| 500 | status, | |
| 501 | ...(messages === undefined ? {} : { githubErrors: messages }), | |
| 502 | }), | |
| 503 | ), | |
| 504 | }, | |
| 505 | ); | |
| 506 | }); | |
| 507 | // The rate-limit scope follows the credential, so a pause on one account never blocks another. | |
| 508 | return yield* gate | |
| 509 | .withPermit(run) | |
| 510 | .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); | |
| 511 | }); | |
| 512 | ||
| 513 | const rest: GitHubApi["Service"]["rest"] = (input) => { | |
| 514 | const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; | |
| 515 | const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( | |
| 516 | HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), | |
| 517 | ); | |
| 518 | const withEtag = | |
| 519 | input.ifNoneMatch === undefined | |
| 520 | ? base | |
| 521 | : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); | |
| 522 | const request = | |
| 523 | input.body === undefined | |
| 524 | ? withEtag | |
| 525 | : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); | |
| 526 | return AllowGitHubReserve.pipe( | |
| 527 | Effect.flatMap((interactive) => | |
| 528 | send({ | |
| 529 | host: input.host, | |
| 530 | operation: input.operation, | |
| 531 | request, | |
| 532 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 533 | timeout: input.timeout, | |
| 534 | allowReserve: input.allowReserve ?? interactive, | |
| 535 | acceptNotModified: input.ifNoneMatch !== undefined, | |
| 536 | }), | |
| 537 | ), | |
| 538 | ); | |
| 539 | }; | |
| 540 | ||
| 541 | const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( | |
| 542 | function* (input) { | |
| 543 | const host = normalizeHost(input.host); | |
| 544 | const { fingerprint } = yield* credential(host); | |
| 545 | const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; | |
| 546 | const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); | |
| 547 | // The document, never its variables: user text (bodies, search terms) travels as variables. | |
| 548 | yield* Effect.annotateCurrentSpan({ | |
| 549 | "github.operation": input.operation, | |
| 550 | "github.graphql.query_hash": queryHash(input.query), | |
| 551 | "github.graphql.query": | |
| 552 | input.query.length > TRACED_QUERY_MAX_CHARS | |
| 553 | ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` | |
| 554 | : input.query, | |
| 555 | }); | |
| 556 | return yield* Effect.gen(function* () { | |
| 557 | const response = yield* send({ | |
| 558 | host, | |
| 559 | operation: input.operation, | |
| 560 | request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( | |
| 561 | HttpClientRequest.acceptJson, | |
| 562 | HttpClientRequest.bodyJsonUnsafe({ | |
| 563 | query: input.query, | |
| 564 | variables: input.variables ?? {}, | |
| 565 | }), | |
| 566 | ), | |
| 567 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 568 | allowReserve, | |
| 569 | acceptNotModified: false, | |
| 570 | graphql: true, | |
| 571 | }); | |
| 572 | // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. | |
| 573 | if (response.truncated) { | |
| 574 | return yield* new GitHubApiResponseError({ | |
| 575 | host, | |
| 576 | operation: input.operation, | |
| 577 | status: response.status, | |
| 578 | }); | |
| 579 | } | |
| 580 | return response.body; | |
| 581 | }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); | |
| 582 | }, | |
| 583 | ); | |
| 584 | ||
| 585 | return GitHubApi.of({ graphql, rest, credential }); | |
| 586 | }); | |
| 587 | ||
| 588 | export const layer = Layer.effect(GitHubApi, make); | |
| 589 | ||
| 590 | /** | |
| 591 | * The transport with the credential, quota reserve and rate-limit pause it reads through. An | |
| 592 | * application builds this once so every GitHub reader shares one view of each quota per host. | |
| 593 | */ | |
| 594 | export const layerWithDependencies = layer.pipe( | |
| 595 | Layer.provideMerge(GitHubCredentials.layer), | |
| 596 | Layer.provideMerge(GitHubQuota.layer), | |
| 597 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 598 | ); |
apps/server/src/sourceControl/GitHubCredentials.test.ts deleted-170| ... | ... | @@ -1,170 +0,0 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Redacted from "effect/Redacted"; | |
| 6 | import { ChildProcessSpawner } from "effect/process"; | |
| 7 | ||
| 8 | import * as ServerSettings from "../serverSettings.ts"; | |
| 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 10 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 11 | ||
| 12 | const TOKEN_VARIABLES = [ | |
| 13 | "GH_TOKEN", | |
| 14 | "GITHUB_TOKEN", | |
| 15 | "GH_ENTERPRISE_TOKEN", | |
| 16 | "GITHUB_ENTERPRISE_TOKEN", | |
| 17 | ] as const; | |
| 18 | ||
| 19 | function harness( | |
| 20 | hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }> = {}, | |
| 21 | signedOut: ReadonlyArray<string> = [], | |
| 22 | tokens: Record<string, string> = {}, | |
| 23 | ) { | |
| 24 | const calls: Array<ReadonlyArray<string>> = []; | |
| 25 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 26 | run: (input) => | |
| 27 | Effect.sync(() => { | |
| 28 | calls.push(input.args); | |
| 29 | const user = input.args[input.args.indexOf("--user") + 1]; | |
| 30 | if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { | |
| 31 | return { | |
| 32 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 33 | stdout: "", | |
| 34 | stderr: "", | |
| 35 | stdoutTruncated: false, | |
| 36 | stderrTruncated: false, | |
| 37 | }; | |
| 38 | } | |
| 39 | return { | |
| 40 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 41 | stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", | |
| 42 | stderr: "", | |
| 43 | stdoutTruncated: false, | |
| 44 | stderrTruncated: false, | |
| 45 | }; | |
| 46 | }), | |
| 47 | }); | |
| 48 | const layer = GitHubCredentials.layer.pipe( | |
| 49 | Layer.provideMerge( | |
| 50 | ServerSettings.ServerSettingsService.layerTest({ github: { hosts, tokens } }), | |
| 51 | ), | |
| 52 | Layer.provide(process), | |
| 53 | Layer.provide(NodeServices.layer), | |
| 54 | ); | |
| 55 | return { layer, calls }; | |
| 56 | } | |
| 57 | ||
| 58 | describe("GitHubCredentials", () => { | |
| 59 | beforeEach(() => { | |
| 60 | for (const name of TOKEN_VARIABLES) vi.stubEnv(name, ""); | |
| 61 | }); | |
| 62 | afterEach(() => { | |
| 63 | vi.unstubAllEnvs(); | |
| 64 | }); | |
| 65 | ||
| 66 | it.effect("asks gh for the active login when Settings pin nothing", () => { | |
| 67 | const { layer, calls } = harness(); | |
| 68 | return Effect.gen(function* () { | |
| 69 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 70 | const credential = yield* credentials.get("GitHub.com"); | |
| 71 | expect(Redacted.value(credential.token)).toBe("active-token"); | |
| 72 | expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]); | |
| 73 | }).pipe(Effect.provide(layer)); | |
| 74 | }); | |
| 75 | ||
| 76 | it.effect("passes --user for an account pinned in Settings", () => { | |
| 77 | const { layer, calls } = harness({ "github.com": { account: "work" } }); | |
| 78 | return Effect.gen(function* () { | |
| 79 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 80 | const credential = yield* credentials.get("github.com"); | |
| 81 | expect(Redacted.value(credential.token)).toBe("token-for-work"); | |
| 82 | expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); | |
| 83 | }).pipe(Effect.provide(layer)); | |
| 84 | }); | |
| 85 | ||
| 86 | it.effect("fails a host turned off in Settings without asking gh", () => { | |
| 87 | const { layer, calls } = harness({ "github.com": { enabled: false } }); | |
| 88 | return Effect.gen(function* () { | |
| 89 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 90 | const error = yield* Effect.flip(credentials.get("github.com")); | |
| 91 | expect(error._tag).toBe("GitHubHostDisabledError"); | |
| 92 | expect(error.message).toBe( | |
| 93 | "GitHub host github.com is turned off in Settings → Source Control.", | |
| 94 | ); | |
| 95 | expect(calls).toEqual([]); | |
| 96 | }).pipe(Effect.provide(layer)); | |
| 97 | }); | |
| 98 | ||
| 99 | it.effect("lets an environment token win over a pinned account, as gh does", () => { | |
| 100 | vi.stubEnv("GH_TOKEN", "from-env"); | |
| 101 | const { layer, calls } = harness({ "github.com": { account: "work" } }); | |
| 102 | return Effect.gen(function* () { | |
| 103 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 104 | const credential = yield* credentials.get("github.com"); | |
| 105 | expect(Redacted.value(credential.token)).toBe("from-env"); | |
| 106 | expect(credential.source).toBe("env"); | |
| 107 | expect(calls).toEqual([]); | |
| 108 | }).pipe(Effect.provide(layer)); | |
| 109 | }); | |
| 110 | ||
| 111 | it.effect("picks up a changed account on the next request without a restart", () => { | |
| 112 | const { layer, calls } = harness(); | |
| 113 | return Effect.gen(function* () { | |
| 114 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 115 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 116 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 117 | ||
| 118 | yield* settings.updateSettings({ | |
| 119 | github: { hosts: { "github.com": { account: "work", enabled: true } } }, | |
| 120 | }); | |
| 121 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); | |
| 122 | ||
| 123 | yield* settings.updateSettings({ github: { hosts: { "github.com": { enabled: false } } } }); | |
| 124 | expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( | |
| 125 | "GitHubHostDisabledError", | |
| 126 | ); | |
| 127 | ||
| 128 | yield* settings.updateSettings({ github: { hosts: {} } }); | |
| 129 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 130 | // The unpinned token stayed cached; only the newly pinned account cost a gh call. | |
| 131 | expect(calls).toHaveLength(2); | |
| 132 | }).pipe(Effect.provide(layer)); | |
| 133 | }); | |
| 134 | ||
| 135 | it.effect("falls back to the active login when the pinned one is no longer signed in", () => { | |
| 136 | const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]); | |
| 137 | return Effect.gen(function* () { | |
| 138 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 139 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 140 | expect(calls).toEqual([ | |
| 141 | ["auth", "token", "--hostname", "github.com", "--user", "gone"], | |
| 142 | ["auth", "token", "--hostname", "github.com"], | |
| 143 | ]); | |
| 144 | }).pipe(Effect.provide(layer)); | |
| 145 | }); | |
| 146 | ||
| 147 | it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => { | |
| 148 | vi.stubEnv("GH_TOKEN", "env-token"); | |
| 149 | const { layer, calls } = harness({}, [], { "github.com": "saved-token" }); | |
| 150 | return Effect.gen(function* () { | |
| 151 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 152 | const credential = yield* credentials.get("github.com"); | |
| 153 | expect(Redacted.value(credential.token)).toBe("saved-token"); | |
| 154 | expect(credential.source).toBe("settings"); | |
| 155 | expect(calls).toEqual([]); | |
| 156 | }).pipe(Effect.provide(layer)); | |
| 157 | }); | |
| 158 | ||
| 159 | it.effect("keeps a host turned off even with a token saved in Settings", () => { | |
| 160 | const { layer } = harness({ "github.com": { enabled: false } }, [], { | |
| 161 | "github.com": "saved-token", | |
| 162 | }); | |
| 163 | return Effect.gen(function* () { | |
| 164 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 165 | expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( | |
| 166 | "GitHubHostDisabledError", | |
| 167 | ); | |
| 168 | }).pipe(Effect.provide(layer)); | |
| 169 | }); | |
| 170 | }); |
apps/server/src/sourceControl/GitHubCredentials.ts deleted-262| ... | ... | @@ -1,262 +0,0 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Hex from "effect/encoding/Hex"; | |
| 7 | import * as Exit from "effect/Exit"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | ||
| 14 | import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; | |
| 15 | ||
| 16 | import * as ServerSettings from "../serverSettings.ts"; | |
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 18 | ||
| 19 | /** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ | |
| 20 | const TOKEN_TTL = Duration.minutes(5); | |
| 21 | /** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ | |
| 22 | const MISSING_TTL = Duration.seconds(10); | |
| 23 | ||
| 24 | export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); | |
| 25 | export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; | |
| 26 | ||
| 27 | export interface GitHubCredential { | |
| 28 | readonly host: string; | |
| 29 | readonly token: Redacted.Redacted<string>; | |
| 30 | readonly source: GitHubCredentialSource; | |
| 31 | /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ | |
| 32 | readonly fingerprint: string; | |
| 33 | } | |
| 34 | ||
| 35 | /** Nothing in the environment, and no `gh` on PATH to ask. */ | |
| 36 | export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingError>()( | |
| 37 | "GitHubCliMissingError", | |
| 38 | { host: Schema.String }, | |
| 39 | ) { | |
| 40 | override get message(): string { | |
| 41 | return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */ | |
| 46 | export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()( | |
| 47 | "GitHubNotSignedInError", | |
| 48 | { host: Schema.String, account: Schema.optional(Schema.String) }, | |
| 49 | ) { | |
| 50 | override get message(): string { | |
| 51 | return this.account === undefined | |
| 52 | ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` | |
| 53 | : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | /** The user turned the host off in Settings. */ | |
| 58 | export class GitHubHostDisabledError extends Schema.TaggedError<GitHubHostDisabledError>()( | |
| 59 | "GitHubHostDisabledError", | |
| 60 | { host: Schema.String }, | |
| 61 | ) { | |
| 62 | override get message(): string { | |
| 63 | return `GitHub host ${this.host} is turned off in Settings → Source Control.`; | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /** `gh auth token` timed out or failed for a reason other than having no login. */ | |
| 68 | export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedError>()( | |
| 69 | "GitHubCliFailedError", | |
| 70 | { host: Schema.String, cause: Schema.Defect() }, | |
| 71 | ) { | |
| 72 | override get message(): string { | |
| 73 | return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** There is no token for the host. */ | |
| 78 | export type GitHubCredentialUnavailableError = | |
| 79 | | GitHubCliMissingError | |
| 80 | | GitHubNotSignedInError | |
| 81 | | GitHubHostDisabledError | |
| 82 | | GitHubCliFailedError; | |
| 83 | ||
| 84 | /** | |
| 85 | * Where GitHub tokens come from. Callers ask per host and never see how the token was found, | |
| 86 | * so another source (an in-app OAuth login) slots in here without touching any of them. | |
| 87 | */ | |
| 88 | export class GitHubCredentials extends Context.Service< | |
| 89 | GitHubCredentials, | |
| 90 | { | |
| 91 | readonly get: ( | |
| 92 | host: string, | |
| 93 | ) => Effect.Effect<GitHubCredential, GitHubCredentialUnavailableError>; | |
| 94 | /** Drops the held token after GitHub refused it, so the next read asks its source again. */ | |
| 95 | readonly invalidate: (host: string) => Effect.Effect<void>; | |
| 96 | } | |
| 97 | >()("t3/sourceControl/GitHubCredentials") {} | |
| 98 | ||
| 99 | function normalizeHost(host: string): string { | |
| 100 | return host.trim().toLowerCase(); | |
| 101 | } | |
| 102 | ||
| 103 | /** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ | |
| 104 | function isGitHubDotCom(host: string): boolean { | |
| 105 | return host === "github.com" || host.endsWith(".ghe.com"); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to | |
| 110 | * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL | |
| 111 | * picks the host and a hostile one must not receive an enterprise token. | |
| 112 | */ | |
| 113 | export function environmentToken( | |
| 114 | host: string, | |
| 115 | env: Readonly<Record<string, string | undefined>>, | |
| 116 | ): string | null { | |
| 117 | const names = isGitHubDotCom(host) | |
| 118 | ? ["GH_TOKEN", "GITHUB_TOKEN"] | |
| 119 | : env.GH_HOST?.trim().toLowerCase() === host | |
| 120 | ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] | |
| 121 | : []; | |
| 122 | for (const name of names) { | |
| 123 | const value = env[name]?.trim(); | |
| 124 | if (value) return value; | |
| 125 | } | |
| 126 | return null; | |
| 127 | } | |
| 128 | ||
| 129 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 130 | export const make = Effect.gen(function* () { | |
| 131 | const process = yield* VcsProcess.VcsProcess; | |
| 132 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 133 | const crypto = yield* Crypto.Crypto; | |
| 134 | const environment = yield* HostProcessEnvironment; | |
| 135 | const workingDirectory = yield* HostProcessWorkingDirectory; | |
| 136 | ||
| 137 | /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ | |
| 138 | const fingerprintOf = (host: string, token: string) => | |
| 139 | crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( | |
| 140 | Effect.map((digest) => `${host}:${Hex.encode(digest)}`), | |
| 141 | // Hashing a string in memory has no platform failure worth a typed error. | |
| 142 | Effect.orDie, | |
| 143 | ); | |
| 144 | ||
| 145 | const fromGh = (host: string, account: string | undefined) => | |
| 146 | process | |
| 147 | .run({ | |
| 148 | operation: "GitHubCredentials.get", | |
| 149 | command: "gh", | |
| 150 | args: [ | |
| 151 | "auth", | |
| 152 | "token", | |
| 153 | "--hostname", | |
| 154 | host, | |
| 155 | ...(account === undefined ? [] : ["--user", account]), | |
| 156 | ], | |
| 157 | cwd: workingDirectory, | |
| 158 | // Never let gh print the token into a debug log. | |
| 159 | env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, | |
| 160 | timeoutMs: 10_000, | |
| 161 | }) | |
| 162 | .pipe( | |
| 163 | Effect.mapError((error) => | |
| 164 | error._tag === "VcsProcessSpawnError" && | |
| 165 | error.cause instanceof PlatformError.PlatformError && | |
| 166 | error.cause.reason._tag === "NotFound" | |
| 167 | ? new GitHubCliMissingError({ host }) | |
| 168 | : // gh exits non-zero with "no oauth token" when it has no login for the host. | |
| 169 | error._tag === "VcsProcessExitError" | |
| 170 | ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) | |
| 171 | : new GitHubCliFailedError({ host, cause: error }), | |
| 172 | ), | |
| 173 | Effect.map((output) => output.stdout.trim()), | |
| 174 | Effect.filterOrFail( | |
| 175 | (token) => token !== "", | |
| 176 | () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), | |
| 177 | ), | |
| 178 | ); | |
| 179 | ||
| 180 | /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ | |
| 181 | const hostChoice = (host: string) => | |
| 182 | serverSettings.getSettings.pipe( | |
| 183 | Effect.map((settings) => settings.github.hosts[host]), | |
| 184 | Effect.orElseSucceed(() => undefined), | |
| 185 | ); | |
| 186 | ||
| 187 | /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ | |
| 188 | const savedToken = (host: string) => | |
| 189 | serverSettings.getSettings.pipe( | |
| 190 | Effect.map((settings) => settings.github.tokens[host]?.trim() || null), | |
| 191 | Effect.orElseSucceed(() => null), | |
| 192 | ); | |
| 193 | ||
| 194 | /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ | |
| 195 | const cacheKey = (host: string, account: string | undefined) => | |
| 196 | account === undefined ? host : `${host}\u0000${account}`; | |
| 197 | ||
| 198 | const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { | |
| 199 | const [host = key, choice] = key.split("\u0000"); | |
| 200 | // An environment token wins over a pinned account, exactly as it does in gh. | |
| 201 | const fromEnv = environmentToken(host, environment); | |
| 202 | // A pinned login gh no longer holds (logged out, expired) falls back to the active one, | |
| 203 | // which is what discovery reports as the account in use. | |
| 204 | const token = | |
| 205 | fromEnv ?? | |
| 206 | (yield* fromGh(host, choice).pipe( | |
| 207 | Effect.catchTags({ | |
| 208 | GitHubNotSignedInError: (error) => | |
| 209 | choice === undefined ? Effect.fail(error) : fromGh(host, undefined), | |
| 210 | }), | |
| 211 | )); | |
| 212 | return { | |
| 213 | host, | |
| 214 | token: Redacted.make(token), | |
| 215 | source: fromEnv !== null ? "env" : "gh", | |
| 216 | fingerprint: yield* fingerprintOf(host, token), | |
| 217 | } satisfies GitHubCredential; | |
| 218 | }); | |
| 219 | ||
| 220 | const cache = yield* Cache.makeWith(lookup, { | |
| 221 | capacity: 32, | |
| 222 | // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. | |
| 223 | timeToLive: (exit) => | |
| 224 | Exit.isSuccess(exit) | |
| 225 | ? TOKEN_TTL | |
| 226 | : Exit.findErrorOption(exit).pipe( | |
| 227 | Option.exists((error) => error._tag === "GitHubCliFailedError"), | |
| 228 | ) | |
| 229 | ? Duration.zero | |
| 230 | : MISSING_TTL, | |
| 231 | }); | |
| 232 | ||
| 233 | return GitHubCredentials.of({ | |
| 234 | get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { | |
| 235 | const host = normalizeHost(rawHost); | |
| 236 | const choice = yield* hostChoice(host); | |
| 237 | if (choice?.enabled === false) { | |
| 238 | return yield* new GitHubHostDisabledError({ host }); | |
| 239 | } | |
| 240 | // A token saved in Settings is the most deliberate choice, so it comes before the | |
| 241 | // environment and gh. It is read from the secret store each time, so it needs no cache. | |
| 242 | const saved = yield* savedToken(host); | |
| 243 | if (saved !== null) { | |
| 244 | return { | |
| 245 | host, | |
| 246 | token: Redacted.make(saved), | |
| 247 | source: "settings", | |
| 248 | fingerprint: yield* fingerprintOf(host, saved), | |
| 249 | } satisfies GitHubCredential; | |
| 250 | } | |
| 251 | return yield* Cache.get(cache, cacheKey(host, choice?.account)); | |
| 252 | }), | |
| 253 | invalidate: (rawHost) => { | |
| 254 | const host = normalizeHost(rawHost); | |
| 255 | return hostChoice(host).pipe( | |
| 256 | Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), | |
| 257 | ); | |
| 258 | }, | |
| 259 | }); | |
| 260 | }); | |
| 261 | ||
| 262 | export const layer = Layer.effect(GitHubCredentials, make); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts deleted-1164| ... | ... | @@ -1,1164 +0,0 @@ |
| 1 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | import * as TestClock from "effect/testing/TestClock"; | |
| 12 | ||
| 13 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 15 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 16 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 17 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | |
| 18 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | |
| 19 | ||
| 20 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 21 | ||
| 22 | const processResult = ( | |
| 23 | stdout: string, | |
| 24 | options?: { | |
| 25 | readonly stderr?: string; | |
| 26 | readonly exitCode?: ChildProcessSpawner.ExitCode; | |
| 27 | }, | |
| 28 | ): VcsProcess.VcsProcessOutput => ({ | |
| 29 | exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), | |
| 30 | stdout, | |
| 31 | stderr: options?.stderr ?? "", | |
| 32 | stdoutTruncated: false, | |
| 33 | stderrTruncated: false, | |
| 34 | }); | |
| 35 | ||
| 36 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ | |
| 37 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 38 | stdout, | |
| 39 | stderr: "", | |
| 40 | stdoutTruncated: false, | |
| 41 | stderrTruncated: false, | |
| 42 | }); | |
| 43 | ||
| 44 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 45 | entries | |
| 46 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 47 | .join("\n"); | |
| 48 | ||
| 49 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 50 | status, | |
| 51 | headers: {}, | |
| 52 | body: body === undefined ? "" : encodeJson(body), | |
| 53 | truncated: false, | |
| 54 | invalidUtf8: false, | |
| 55 | }); | |
| 56 | ||
| 57 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 58 | number, | |
| 59 | title: `PR ${number}`, | |
| 60 | url: `https://github.com/acme/web/pull/${number}`, | |
| 61 | baseRefName: "main", | |
| 62 | headRefName, | |
| 63 | state: "OPEN", | |
| 64 | isCrossRepository: owner !== "acme", | |
| 65 | updatedAt: "2026-01-02T00:00:00Z", | |
| 66 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 67 | headRepositoryOwner: { login: owner }, | |
| 68 | }); | |
| 69 | ||
| 70 | /** | |
| 71 | * The provider over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 72 | * `git remote -v` prints; `git` records every driver call. | |
| 73 | */ | |
| 74 | function harness(input: { | |
| 75 | readonly remotes: string; | |
| 76 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 77 | readonly localBranches?: ReadonlyArray<string>; | |
| 78 | readonly resolved?: string; | |
| 79 | /** Fails the local branch listing, the first git read a checkout makes. */ | |
| 80 | readonly gitFailure?: unknown; | |
| 81 | }) { | |
| 82 | const git: Array<readonly [string, unknown]> = []; | |
| 83 | const record = | |
| 84 | <A>(name: string, value: A) => | |
| 85 | (args: unknown) => | |
| 86 | Effect.sync(() => { | |
| 87 | git.push([name, args]); | |
| 88 | return value; | |
| 89 | }); | |
| 90 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 91 | execute: (args) => | |
| 92 | Effect.sync(() => { | |
| 93 | git.push(["execute", args.args]); | |
| 94 | return processOutput(""); | |
| 95 | }), | |
| 96 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 97 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 98 | ensureRemote: (args) => | |
| 99 | Effect.sync(() => { | |
| 100 | git.push(["ensureRemote", args]); | |
| 101 | return args.preferredName; | |
| 102 | }), | |
| 103 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 104 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 105 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 106 | listLocalBranchNames: () => | |
| 107 | input.gitFailure === undefined | |
| 108 | ? Effect.succeed([...(input.localBranches ?? [])]) | |
| 109 | : Effect.fail(input.gitFailure as never), | |
| 110 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 111 | }); | |
| 112 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 113 | run: (args) => | |
| 114 | Effect.succeed( | |
| 115 | args.args[0] === "remote" | |
| 116 | ? processOutput(input.remotes) | |
| 117 | : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), | |
| 118 | ), | |
| 119 | }); | |
| 120 | const layer = Layer.mergeAll( | |
| 121 | driver, | |
| 122 | process, | |
| 123 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 124 | NodeServices.layer, | |
| 125 | ); | |
| 126 | return { layer, git }; | |
| 127 | } | |
| 128 | ||
| 129 | /** The provider context for a checkout whose remote is on `host`. */ | |
| 130 | /** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ | |
| 131 | const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) => | |
| 132 | GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); | |
| 133 | ||
| 134 | const githubContext = (host: string) => ({ | |
| 135 | provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, | |
| 136 | remoteName: "origin", | |
| 137 | remoteUrl: `git@${host}:acme/web.git`, | |
| 138 | }); | |
| 139 | ||
| 140 | describe("GitHubSourceControlProvider repository resolution", () => { | |
| 141 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 142 | const paths: string[] = []; | |
| 143 | const { layer } = harness({ | |
| 144 | remotes: remotesOutput( | |
| 145 | ["origin", "git@github.com:me/web.git"], | |
| 146 | ["upstream", "https://github.com/acme/web.git"], | |
| 147 | ), | |
| 148 | api: { | |
| 149 | rest: (input) => | |
| 150 | Effect.sync(() => { | |
| 151 | paths.push(`${input.host} ${input.path}`); | |
| 152 | return restResponse({ | |
| 153 | full_name: "acme/web", | |
| 154 | html_url: "https://github.com/acme/web", | |
| 155 | ssh_url: "git@github.com:acme/web.git", | |
| 156 | default_branch: "trunk", | |
| 157 | }); | |
| 158 | }), | |
| 159 | }, | |
| 160 | }); | |
| 161 | return Effect.gen(function* () { | |
| 162 | const gh = yield* GitHubSourceControlProvider.make; | |
| 163 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 164 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 165 | }).pipe(Effect.provide(layer)); | |
| 166 | }); | |
| 167 | ||
| 168 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 169 | const hosts: string[] = []; | |
| 170 | const { layer } = harness({ | |
| 171 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 172 | api: { | |
| 173 | rest: (input) => | |
| 174 | Effect.sync(() => { | |
| 175 | hosts.push(`${input.host} ${input.path}`); | |
| 176 | return restResponse({ | |
| 177 | full_name: "acme/web", | |
| 178 | html_url: "https://github.com/acme/web", | |
| 179 | ssh_url: "git@github.com:acme/web.git", | |
| 180 | default_branch: "main", | |
| 181 | }); | |
| 182 | }), | |
| 183 | }, | |
| 184 | }); | |
| 185 | return Effect.gen(function* () { | |
| 186 | const gh = yield* GitHubSourceControlProvider.make; | |
| 187 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 188 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 189 | yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); | |
| 190 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 191 | }).pipe(Effect.provide(layer)); | |
| 192 | }); | |
| 193 | ||
| 194 | it.effect("names a missing repository rather than a pull request", () => { | |
| 195 | const { layer } = harness({ | |
| 196 | remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), | |
| 197 | api: { | |
| 198 | rest: (input) => | |
| 199 | Effect.fail( | |
| 200 | new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), | |
| 201 | ), | |
| 202 | }, | |
| 203 | }); | |
| 204 | return Effect.gen(function* () { | |
| 205 | const gh = yield* GitHubSourceControlProvider.make; | |
| 206 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 207 | assert.include(error.detail, "Repository not found"); | |
| 208 | }).pipe(Effect.provide(layer)); | |
| 209 | }); | |
| 210 | ||
| 211 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 212 | const { layer } = harness({ | |
| 213 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 214 | api: {}, | |
| 215 | }); | |
| 216 | return Effect.gen(function* () { | |
| 217 | const gh = yield* GitHubSourceControlProvider.make; | |
| 218 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 219 | assert.strictEqual(error._tag, "SourceControlProviderError"); | |
| 220 | assert.include(error.detail, "No GitHub repository"); | |
| 221 | }).pipe(Effect.provide(layer)); | |
| 222 | }); | |
| 223 | }); | |
| 224 | ||
| 225 | describe("GitHubSourceControlProvider change request lookups by head", () => { | |
| 226 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 227 | ||
| 228 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 229 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 230 | const { layer } = harness({ | |
| 231 | remotes, | |
| 232 | api: { | |
| 233 | graphql: (input) => | |
| 234 | Effect.sync(() => { | |
| 235 | documents.push(input); | |
| 236 | return encodeJson({ | |
| 237 | data: { | |
| 238 | repository: { | |
| 239 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 240 | h1: { nodes: [] }, | |
| 241 | }, | |
| 242 | }, | |
| 243 | }); | |
| 244 | }), | |
| 245 | }, | |
| 246 | }); | |
| 247 | return Effect.gen(function* () { | |
| 248 | const gh = yield* GitHubSourceControlProvider.make; | |
| 249 | const lookup = (headSelector: string) => | |
| 250 | gh | |
| 251 | .listChangeRequests({ | |
| 252 | cwd: "/repo", | |
| 253 | headSelector, | |
| 254 | state: "all", | |
| 255 | limit: 100, | |
| 256 | context: githubContext("github.com"), | |
| 257 | }) | |
| 258 | .pipe(Effect.forkChild); | |
| 259 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 260 | const firstLookup = yield* lookup("feature/a"); | |
| 261 | yield* TestClock.adjust("200 millis"); | |
| 262 | const secondLookup = yield* lookup("feature/b"); | |
| 263 | yield* TestClock.adjust("300 millis"); | |
| 264 | const first = yield* Fiber.join(firstLookup); | |
| 265 | const second = yield* Fiber.join(secondLookup); | |
| 266 | assert.deepStrictEqual( | |
| 267 | first?.map((pr) => pr.number), | |
| 268 | [7], | |
| 269 | ); | |
| 270 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 271 | assert.deepStrictEqual(second, []); | |
| 272 | assert.strictEqual(documents.length, 1); | |
| 273 | assert.include(documents[0]!.query, "headRefOid"); | |
| 274 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 275 | owner: "acme", | |
| 276 | name: "web", | |
| 277 | h0: "feature/a", | |
| 278 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 279 | h1: "feature/b", | |
| 280 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 281 | }); | |
| 282 | }).pipe(Effect.provide(layer)); | |
| 283 | }); | |
| 284 | ||
| 285 | it.effect("caps a background document at twenty-five heads", () => { | |
| 286 | const headCounts: Array<number> = []; | |
| 287 | const { layer } = harness({ | |
| 288 | remotes, | |
| 289 | api: { | |
| 290 | graphql: (input) => | |
| 291 | Effect.sync(() => { | |
| 292 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 293 | headCounts.push(heads.length); | |
| 294 | return encodeJson({ | |
| 295 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 296 | }); | |
| 297 | }), | |
| 298 | }, | |
| 299 | }); | |
| 300 | return Effect.gen(function* () { | |
| 301 | const gh = yield* GitHubSourceControlProvider.make; | |
| 302 | const lookups = yield* Effect.all( | |
| 303 | Array.from({ length: 26 }, (_, index) => | |
| 304 | gh.listChangeRequests({ | |
| 305 | cwd: "/repo", | |
| 306 | headSelector: `feature/${index}`, | |
| 307 | state: "all", | |
| 308 | limit: 100, | |
| 309 | context: githubContext("github.com"), | |
| 310 | }), | |
| 311 | ), | |
| 312 | { concurrency: "unbounded" }, | |
| 313 | ).pipe(Effect.forkChild); | |
| 314 | yield* TestClock.adjust("500 millis"); | |
| 315 | yield* Fiber.join(lookups); | |
| 316 | assert.deepStrictEqual( | |
| 317 | headCounts.toSorted((a, b) => a - b), | |
| 318 | [1, 25], | |
| 319 | ); | |
| 320 | }).pipe(Effect.provide(layer)); | |
| 321 | }); | |
| 322 | ||
| 323 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 324 | const { layer } = harness({ | |
| 325 | remotes, | |
| 326 | api: { | |
| 327 | graphql: (input) => | |
| 328 | Effect.succeed( | |
| 329 | encodeJson({ | |
| 330 | data: { | |
| 331 | repository: { | |
| 332 | h0: { | |
| 333 | nodes: | |
| 334 | input.variables?.h0 === "main" | |
| 335 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 336 | : [], | |
| 337 | }, | |
| 338 | }, | |
| 339 | }, | |
| 340 | }), | |
| 341 | ), | |
| 342 | }, | |
| 343 | }); | |
| 344 | return Effect.gen(function* () { | |
| 345 | const gh = yield* GitHubSourceControlProvider.make; | |
| 346 | const open = yield* gh | |
| 347 | .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) | |
| 348 | .pipe(Effect.forkChild); | |
| 349 | yield* TestClock.adjust("50 millis"); | |
| 350 | assert.deepStrictEqual( | |
| 351 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 352 | [8], | |
| 353 | ); | |
| 354 | }).pipe(Effect.provide(layer)); | |
| 355 | }); | |
| 356 | ||
| 357 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 358 | const { layer } = harness({ | |
| 359 | remotes, | |
| 360 | api: { | |
| 361 | graphql: (input) => | |
| 362 | Effect.fail( | |
| 363 | input.variables?.h0 === "missing" | |
| 364 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 365 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 366 | host: "github.com", | |
| 367 | operation: "x", | |
| 368 | retryAt: 123, | |
| 369 | }), | |
| 370 | ), | |
| 371 | }, | |
| 372 | }); | |
| 373 | return Effect.gen(function* () { | |
| 374 | const gh = yield* GitHubSourceControlProvider.make; | |
| 375 | const read = (headSelector: string) => | |
| 376 | gh | |
| 377 | .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 378 | .pipe(Effect.flip, Effect.forkChild); | |
| 379 | const missing = yield* read("missing"); | |
| 380 | yield* TestClock.adjust("500 millis"); | |
| 381 | assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); | |
| 382 | const limited = yield* read("limited"); | |
| 383 | yield* TestClock.adjust("500 millis"); | |
| 384 | const error = yield* Fiber.join(limited); | |
| 385 | assert.include(error.detail, "rate limit exceeded"); | |
| 386 | assert.propertyVal(error.cause, "retryAt", 123); | |
| 387 | }).pipe(Effect.provide(layer)); | |
| 388 | }); | |
| 389 | }); | |
| 390 | ||
| 391 | describe("GitHubSourceControlProvider.getChangeRequest", () => { | |
| 392 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 393 | const variables: Array<unknown> = []; | |
| 394 | const { layer } = harness({ | |
| 395 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 396 | api: { | |
| 397 | graphql: (input) => | |
| 398 | Effect.sync(() => { | |
| 399 | variables.push(input.variables); | |
| 400 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 401 | }), | |
| 402 | }, | |
| 403 | }); | |
| 404 | return Effect.gen(function* () { | |
| 405 | const gh = yield* GitHubSourceControlProvider.make; | |
| 406 | assert.strictEqual( | |
| 407 | (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, | |
| 408 | 42, | |
| 409 | ); | |
| 410 | yield* gh.getChangeRequest({ | |
| 411 | cwd: "/repo", | |
| 412 | reference: "https://github.com/other/thing/pull/42", | |
| 413 | }); | |
| 414 | assert.deepStrictEqual(variables, [ | |
| 415 | { owner: "acme", name: "web", number: 42 }, | |
| 416 | { owner: "other", name: "thing", number: 42 }, | |
| 417 | ]); | |
| 418 | }).pipe(Effect.provide(layer)); | |
| 419 | }); | |
| 420 | ||
| 421 | it.effect("fails a missing pull request as not found", () => { | |
| 422 | const { layer } = harness({ | |
| 423 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 424 | api: { | |
| 425 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 426 | }, | |
| 427 | }); | |
| 428 | return Effect.gen(function* () { | |
| 429 | const gh = yield* GitHubSourceControlProvider.make; | |
| 430 | const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 431 | assert.include(error.detail, "Pull request not found"); | |
| 432 | }).pipe(Effect.provide(layer)); | |
| 433 | }); | |
| 434 | }); | |
| 435 | ||
| 436 | describe("GitHubSourceControlProvider writes", () => { | |
| 437 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 438 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 439 | const { layer } = harness({ | |
| 440 | remotes: remotesOutput( | |
| 441 | ["origin", "git@github.com:me/web.git"], | |
| 442 | ["upstream", "git@github.com:acme/web.git"], | |
| 443 | ), | |
| 444 | api: { | |
| 445 | rest: (input) => | |
| 446 | Effect.sync(() => { | |
| 447 | requests.push(input); | |
| 448 | return restResponse({ number: 1 }, 201); | |
| 449 | }), | |
| 450 | }, | |
| 451 | }); | |
| 452 | return Effect.gen(function* () { | |
| 453 | const fs = yield* FileSystem.FileSystem; | |
| 454 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 455 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 456 | const gh = yield* GitHubSourceControlProvider.make; | |
| 457 | yield* gh.createChangeRequest({ | |
| 458 | cwd: "/repo", | |
| 459 | baseRefName: "main", | |
| 460 | headSelector: "me:feature", | |
| 461 | title: "Title", | |
| 462 | bodyFile, | |
| 463 | }); | |
| 464 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 465 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 466 | // A user's own write, so the background's reserve is not held against it. | |
| 467 | assert.strictEqual(requests[0]!.allowReserve, true); | |
| 468 | assert.deepStrictEqual(requests[0]!.body, { | |
| 469 | base: "main", | |
| 470 | head: "me:feature", | |
| 471 | title: "Title", | |
| 472 | body: "Body", | |
| 473 | maintainer_can_modify: true, | |
| 474 | }); | |
| 475 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 476 | }); | |
| 477 | ||
| 478 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 479 | const requests: Array<string> = []; | |
| 480 | const { layer } = harness({ | |
| 481 | remotes: "", | |
| 482 | api: { | |
| 483 | rest: (input) => | |
| 484 | Effect.sync(() => { | |
| 485 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 486 | return input.path === "user" | |
| 487 | ? restResponse({ login: "me" }) | |
| 488 | : restResponse({ | |
| 489 | full_name: "acme/new", | |
| 490 | html_url: "https://github.com/acme/new", | |
| 491 | ssh_url: "git@github.com:acme/new.git", | |
| 492 | }); | |
| 493 | }), | |
| 494 | }, | |
| 495 | }); | |
| 496 | return Effect.gen(function* () { | |
| 497 | const gh = yield* GitHubSourceControlProvider.make; | |
| 498 | const urls = yield* gh.createRepository({ | |
| 499 | cwd: "/repo", | |
| 500 | repository: "acme/new", | |
| 501 | visibility: "private", | |
| 502 | }); | |
| 503 | assert.deepStrictEqual(urls, { | |
| 504 | nameWithOwner: "acme/new", | |
| 505 | url: "https://github.com/acme/new", | |
| 506 | sshUrl: "git@github.com:acme/new.git", | |
| 507 | }); | |
| 508 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 509 | }).pipe(Effect.provide(layer)); | |
| 510 | }); | |
| 511 | }); | |
| 512 | ||
| 513 | describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { | |
| 514 | const repository = (fullName: string, defaultBranch = "main") => | |
| 515 | restResponse({ | |
| 516 | full_name: fullName, | |
| 517 | html_url: `https://github.com/${fullName}`, | |
| 518 | ssh_url: `git@github.com:${fullName}.git`, | |
| 519 | default_branch: defaultBranch, | |
| 520 | }); | |
| 521 | ||
| 522 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 523 | const { layer, git } = harness({ | |
| 524 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 525 | api: { | |
| 526 | graphql: () => | |
| 527 | Effect.succeed( | |
| 528 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 529 | ), | |
| 530 | }, | |
| 531 | }); | |
| 532 | return Effect.gen(function* () { | |
| 533 | const gh = yield* GitHubSourceControlProvider.make; | |
| 534 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); | |
| 535 | assert.deepStrictEqual(git, [ | |
| 536 | [ | |
| 537 | "fetchRemoteTrackingBranch", | |
| 538 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 539 | ], | |
| 540 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 541 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 542 | [ | |
| 543 | "setBranchUpstream", | |
| 544 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 545 | ], | |
| 546 | ]); | |
| 547 | }).pipe(Effect.provide(layer)); | |
| 548 | }); | |
| 549 | ||
| 550 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 551 | const { layer, git } = harness({ | |
| 552 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 553 | localBranches: ["main"], | |
| 554 | api: { | |
| 555 | graphql: () => | |
| 556 | Effect.succeed( | |
| 557 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 558 | ), | |
| 559 | rest: () => | |
| 560 | Effect.fail( | |
| 561 | new GitHubApi.GitHubApiRequestError({ | |
| 562 | host: "github.com", | |
| 563 | operation: "x", | |
| 564 | cause: "offline", | |
| 565 | }), | |
| 566 | ), | |
| 567 | }, | |
| 568 | }); | |
| 569 | return Effect.gen(function* () { | |
| 570 | const gh = yield* GitHubSourceControlProvider.make; | |
| 571 | yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 572 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 573 | assert.deepStrictEqual(git, []); | |
| 574 | }).pipe(Effect.provide(layer)); | |
| 575 | }); | |
| 576 | ||
| 577 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 578 | const { layer, git } = harness({ | |
| 579 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 580 | localBranches: ["someone/main"], | |
| 581 | api: { | |
| 582 | graphql: () => | |
| 583 | Effect.succeed( | |
| 584 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 585 | ), | |
| 586 | rest: (input) => | |
| 587 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 588 | }, | |
| 589 | }); | |
| 590 | return Effect.gen(function* () { | |
| 591 | const gh = yield* GitHubSourceControlProvider.make; | |
| 592 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 593 | assert.deepStrictEqual(git, [ | |
| 594 | [ | |
| 595 | "ensureRemote", | |
| 596 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 597 | ], | |
| 598 | [ | |
| 599 | "fetchRemoteTrackingBranch", | |
| 600 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 601 | ], | |
| 602 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 603 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 604 | [ | |
| 605 | "setBranchUpstream", | |
| 606 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 607 | ], | |
| 608 | ]); | |
| 609 | }).pipe(Effect.provide(layer)); | |
| 610 | }); | |
| 611 | ||
| 612 | it.effect("keeps git's own output out of what a failed checkout reports", () => { | |
| 613 | const { layer } = harness({ | |
| 614 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 615 | gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), | |
| 616 | api: { | |
| 617 | graphql: () => | |
| 618 | Effect.succeed( | |
| 619 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 620 | ), | |
| 621 | }, | |
| 622 | }); | |
| 623 | return Effect.gen(function* () { | |
| 624 | const gh = yield* GitHubSourceControlProvider.make; | |
| 625 | const error = yield* gh | |
| 626 | .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) | |
| 627 | .pipe(Effect.flip); | |
| 628 | assert.strictEqual(error.detail, "The pull request could not be checked out with git."); | |
| 629 | assert.notInclude(error.message, "secret-path"); | |
| 630 | }).pipe(Effect.provide(layer)); | |
| 631 | }); | |
| 632 | ||
| 633 | it("names the local branch the way gh pr checkout does", () => { | |
| 634 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 635 | GitHubSourceControlProvider.pullRequestCheckoutBranchName({ | |
| 636 | headRefName, | |
| 637 | headOwner: "someone", | |
| 638 | isCrossRepository, | |
| 639 | defaultBranch: "main", | |
| 640 | }); | |
| 641 | assert.strictEqual(name("main", true), "someone/main"); | |
| 642 | assert.strictEqual(name("feature", true), "feature"); | |
| 643 | assert.strictEqual(name("main", false), "main"); | |
| 644 | }); | |
| 645 | }); | |
| 646 | ||
| 647 | it("accepts active authenticated GitHub accounts when another account fails", () => { | |
| 648 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 649 | processResult( | |
| 650 | JSON.stringify({ | |
| 651 | hosts: { | |
| 652 | "github.com": [ | |
| 653 | { | |
| 654 | state: "success", | |
| 655 | active: true, | |
| 656 | host: "github.com", | |
| 657 | login: "active-user", | |
| 658 | tokenSource: "keyring", | |
| 659 | gitProtocol: "ssh", | |
| 660 | }, | |
| 661 | { | |
| 662 | state: "error", | |
| 663 | active: false, | |
| 664 | host: "github.com", | |
| 665 | login: "stale-user", | |
| 666 | tokenSource: "keyring", | |
| 667 | gitProtocol: "ssh", | |
| 668 | error: "The token in keyring is invalid.", | |
| 669 | }, | |
| 670 | ], | |
| 671 | }, | |
| 672 | }), | |
| 673 | ), | |
| 674 | ); | |
| 675 | ||
| 676 | assert.deepStrictEqual( | |
| 677 | { | |
| 678 | status: auth.status, | |
| 679 | account: auth.account, | |
| 680 | host: auth.host, | |
| 681 | }, | |
| 682 | { | |
| 683 | status: "authenticated", | |
| 684 | account: Option.some("active-user"), | |
| 685 | host: Option.some("github.com"), | |
| 686 | }, | |
| 687 | ); | |
| 688 | }); | |
| 689 | ||
| 690 | it("parses GitHub auth JSON from stdout when stderr has warnings", () => { | |
| 691 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 692 | processResult( | |
| 693 | JSON.stringify({ | |
| 694 | hosts: { | |
| 695 | "github.com": [ | |
| 696 | { | |
| 697 | state: "success", | |
| 698 | active: true, | |
| 699 | host: "github.com", | |
| 700 | login: "active-user", | |
| 701 | tokenSource: "keyring", | |
| 702 | gitProtocol: "ssh", | |
| 703 | }, | |
| 704 | ], | |
| 705 | }, | |
| 706 | }), | |
| 707 | { stderr: "warning: ignored diagnostic from gh\n" }, | |
| 708 | ), | |
| 709 | ); | |
| 710 | ||
| 711 | assert.deepStrictEqual( | |
| 712 | { | |
| 713 | status: auth.status, | |
| 714 | account: auth.account, | |
| 715 | host: auth.host, | |
| 716 | }, | |
| 717 | { | |
| 718 | status: "authenticated", | |
| 719 | account: Option.some("active-user"), | |
| 720 | host: Option.some("github.com"), | |
| 721 | }, | |
| 722 | ); | |
| 723 | }); | |
| 724 | ||
| 725 | it("parses GitHub auth status accounts by host and active state", () => { | |
| 726 | assert.deepStrictEqual( | |
| 727 | parseGitHubAuthStatus( | |
| 728 | JSON.stringify({ | |
| 729 | hosts: { | |
| 730 | "github.com": [ | |
| 731 | { | |
| 732 | state: "success", | |
| 733 | active: true, | |
| 734 | host: "github.com", | |
| 735 | login: "active-user", | |
| 736 | tokenSource: "keyring", | |
| 737 | gitProtocol: "ssh", | |
| 738 | }, | |
| 739 | { | |
| 740 | state: "error", | |
| 741 | active: false, | |
| 742 | host: "github.com", | |
| 743 | login: "stale-user", | |
| 744 | tokenSource: "keyring", | |
| 745 | gitProtocol: "ssh", | |
| 746 | }, | |
| 747 | ], | |
| 748 | "github.example.test": [ | |
| 749 | { | |
| 750 | state: "success", | |
| 751 | active: false, | |
| 752 | host: "github.example.test", | |
| 753 | login: "enterprise-user", | |
| 754 | tokenSource: "keyring", | |
| 755 | gitProtocol: "ssh", | |
| 756 | }, | |
| 757 | ], | |
| 758 | }, | |
| 759 | }), | |
| 760 | ).accounts, | |
| 761 | [ | |
| 762 | { | |
| 763 | host: "github.com", | |
| 764 | account: "active-user", | |
| 765 | authenticated: true, | |
| 766 | active: true, | |
| 767 | error: null, | |
| 768 | environmentVariable: null, | |
| 769 | }, | |
| 770 | { | |
| 771 | host: "github.com", | |
| 772 | account: "stale-user", | |
| 773 | authenticated: false, | |
| 774 | active: false, | |
| 775 | error: null, | |
| 776 | environmentVariable: null, | |
| 777 | }, | |
| 778 | { | |
| 779 | host: "github.example.test", | |
| 780 | account: "enterprise-user", | |
| 781 | authenticated: true, | |
| 782 | active: false, | |
| 783 | error: null, | |
| 784 | environmentVariable: null, | |
| 785 | }, | |
| 786 | ], | |
| 787 | ); | |
| 788 | }); | |
| 789 | ||
| 790 | it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { | |
| 791 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 792 | processResult( | |
| 793 | JSON.stringify({ | |
| 794 | hosts: { | |
| 795 | "github.com": [ | |
| 796 | { | |
| 797 | state: "error", | |
| 798 | active: true, | |
| 799 | host: "github.com", | |
| 800 | login: "stale-user", | |
| 801 | tokenSource: "keyring", | |
| 802 | gitProtocol: "ssh", | |
| 803 | error: "The token in keyring is invalid.", | |
| 804 | }, | |
| 805 | ], | |
| 806 | }, | |
| 807 | }), | |
| 808 | ), | |
| 809 | ); | |
| 810 | ||
| 811 | assert.deepStrictEqual( | |
| 812 | { | |
| 813 | status: auth.status, | |
| 814 | host: auth.host, | |
| 815 | detail: auth.detail, | |
| 816 | }, | |
| 817 | { | |
| 818 | status: "unauthenticated", | |
| 819 | host: Option.some("github.com"), | |
| 820 | detail: Option.some("The token in keyring is invalid."), | |
| 821 | }, | |
| 822 | ); | |
| 823 | }); | |
| 824 | ||
| 825 | it("reports an update hint instead of unauthenticated when gh predates --json", () => { | |
| 826 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 827 | processResult("", { | |
| 828 | stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]\n", | |
| 829 | exitCode: ChildProcessSpawner.ExitCode(1), | |
| 830 | }), | |
| 831 | ); | |
| 832 | ||
| 833 | assert.strictEqual(auth.status, "unknown"); | |
| 834 | assert.match( | |
| 835 | Option.getOrElse(auth.detail, () => ""), | |
| 836 | /2\.81\.0/, | |
| 837 | ); | |
| 838 | }); | |
| 839 | ||
| 840 | it.effect.each(["pull", "issues"])( | |
| 841 | "resolves %s subjects on the linked host without using the checkout", | |
| 842 | (kind) => | |
| 843 | Effect.gen(function* () { | |
| 844 | const provider = yield* makeProvider({ | |
| 845 | rest: (input) => { | |
| 846 | assert.strictEqual(input.host, "github.com"); | |
| 847 | assert.strictEqual(input.path, "repos/owner/repo/issues/42"); | |
| 848 | return Effect.succeed( | |
| 849 | restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), | |
| 850 | ); | |
| 851 | }, | |
| 852 | }); | |
| 853 | const lookup = provider.resolveLink?.({ | |
| 854 | cwd: "/unrelated", | |
| 855 | url: new URL(`https://github.com/owner/repo/${kind}/42`), | |
| 856 | }); | |
| 857 | assert.ok(lookup); | |
| 858 | assert.deepStrictEqual(yield* lookup, { | |
| 859 | title: "Pairing expiry", | |
| 860 | body: "Preserve remote access", | |
| 861 | }); | |
| 862 | assert.strictEqual( | |
| 863 | provider.resolveLink?.({ | |
| 864 | cwd: "/unrelated", | |
| 865 | url: new URL("https://github.com/owner/repo"), | |
| 866 | }), | |
| 867 | undefined, | |
| 868 | ); | |
| 869 | }), | |
| 870 | ); | |
| 871 | ||
| 872 | it.effect.each(["read", "decode"] as const)( | |
| 873 | "retains the %s failure without exposing its raw contents", | |
| 874 | (stage) => | |
| 875 | Effect.gen(function* () { | |
| 876 | const cause = new GitHubApi.GitHubApiResponseError({ | |
| 877 | host: "github.com", | |
| 878 | operation: "resolveLink", | |
| 879 | status: 500, | |
| 880 | }); | |
| 881 | const provider = yield* makeProvider({ | |
| 882 | rest: () => | |
| 883 | stage === "read" | |
| 884 | ? Effect.fail(cause) | |
| 885 | : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), | |
| 886 | }); | |
| 887 | const lookup = provider.resolveLink?.({ | |
| 888 | cwd: "/repo", | |
| 889 | url: new URL("https://github.com/owner/repo/issues/42"), | |
| 890 | }); | |
| 891 | assert.ok(lookup); | |
| 892 | const error = yield* Effect.flip(lookup); | |
| 893 | assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); | |
| 894 | assert.strictEqual(error.detail, "The linked subject could not be read."); | |
| 895 | assert.notInclude(error.message, "private response text"); | |
| 896 | if (stage === "read") assert.strictEqual(error.cause, cause); | |
| 897 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); | |
| 898 | }), | |
| 899 | ); | |
| 900 | ||
| 901 | const multiAccountStatus = (extra: ReadonlyArray<Record<string, unknown>> = []) => | |
| 902 | processResult( | |
| 903 | JSON.stringify({ | |
| 904 | hosts: { | |
| 905 | "github.com": [ | |
| 906 | { state: "success", active: true, host: "github.com", login: "personal" }, | |
| 907 | { state: "success", active: false, host: "github.com", login: "work" }, | |
| 908 | ...extra, | |
| 909 | ], | |
| 910 | "ghe.acme.test": [ | |
| 911 | { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" }, | |
| 912 | ], | |
| 913 | }, | |
| 914 | }), | |
| 915 | ); | |
| 916 | ||
| 917 | it("reports every gh login and leads with the account Settings pin", () => { | |
| 918 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 919 | hosts: { "github.com": { account: "work", enabled: true } }, | |
| 920 | tokens: {}, | |
| 921 | }); | |
| 922 | assert.deepStrictEqual(auth.account, Option.some("work")); | |
| 923 | assert.deepStrictEqual(auth.accounts, [ | |
| 924 | { host: "github.com", account: "personal", active: true, authenticated: true }, | |
| 925 | { host: "github.com", account: "work", active: false, authenticated: true }, | |
| 926 | { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" }, | |
| 927 | ]); | |
| 928 | }); | |
| 929 | ||
| 930 | it("falls back to gh's active login when the pinned account is gone", () => { | |
| 931 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 932 | hosts: { "github.com": { account: "former-job", enabled: true } }, | |
| 933 | tokens: {}, | |
| 934 | }); | |
| 935 | assert.deepStrictEqual(auth.account, Option.some("personal")); | |
| 936 | }); | |
| 937 | ||
| 938 | it("reports unauthenticated when Settings turn off every signed-in host", () => { | |
| 939 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 940 | hosts: { "github.com": { enabled: false } }, | |
| 941 | tokens: {}, | |
| 942 | }); | |
| 943 | assert.strictEqual(auth.status, "unauthenticated"); | |
| 944 | assert.deepStrictEqual( | |
| 945 | auth.detail, | |
| 946 | Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."), | |
| 947 | ); | |
| 948 | }); | |
| 949 | ||
| 950 | it("names the environment token that overrides the Settings choice", () => { | |
| 951 | const auth = GitHubSourceControlProvider.parseGitHubAuth( | |
| 952 | multiAccountStatus([ | |
| 953 | { | |
| 954 | state: "success", | |
| 955 | active: false, | |
| 956 | host: "github.com", | |
| 957 | login: "bot", | |
| 958 | tokenSource: "GH_TOKEN", | |
| 959 | }, | |
| 960 | ]), | |
| 961 | { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, | |
| 962 | ); | |
| 963 | assert.deepStrictEqual(auth.account, Option.some("bot")); | |
| 964 | assert.deepStrictEqual( | |
| 965 | auth.detail, | |
| 966 | Option.some( | |
| 967 | "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.", | |
| 968 | ), | |
| 969 | ); | |
| 970 | assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); | |
| 971 | }); | |
| 972 | ||
| 973 | it.live.each([ | |
| 974 | "git@code.example.test:team/project.git", | |
| 975 | "ssh://git@code.example.test/team/project.git", | |
| 976 | "https://code.example.test/team/project.git", | |
| 977 | ])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => { | |
| 978 | const graphql: GitHubApi.GitHubGraphQlInput[] = []; | |
| 979 | const rest: GitHubApi.GitHubRestInput[] = []; | |
| 980 | const api = { | |
| 981 | graphql: (input: GitHubApi.GitHubGraphQlInput) => | |
| 982 | Effect.sync(() => { | |
| 983 | graphql.push(input); | |
| 984 | const pr = { | |
| 985 | ...node(42, "feature", "team"), | |
| 986 | url: "https://code.example.test/team/project/pull/42", | |
| 987 | isCrossRepository: false, | |
| 988 | headRepository: { name: "project", nameWithOwner: "team/project" }, | |
| 989 | }; | |
| 990 | return encodeJson({ | |
| 991 | data: { | |
| 992 | repository: | |
| 993 | input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } }, | |
| 994 | }, | |
| 995 | }); | |
| 996 | }), | |
| 997 | rest: (input: GitHubApi.GitHubRestInput) => | |
| 998 | Effect.sync(() => { | |
| 999 | rest.push(input); | |
| 1000 | return restResponse({ | |
| 1001 | full_name: "team/project", | |
| 1002 | html_url: `https://${input.host}/team/project`, | |
| 1003 | ssh_url: `git@${input.host}:team/project.git`, | |
| 1004 | default_branch: "main", | |
| 1005 | }); | |
| 1006 | }), | |
| 1007 | }; | |
| 1008 | const { layer, git } = harness({ | |
| 1009 | remotes: remotesOutput( | |
| 1010 | ["origin", "git@github.com:team/project.git"], | |
| 1011 | ["enterprise", remoteUrl], | |
| 1012 | ), | |
| 1013 | api, | |
| 1014 | }); | |
| 1015 | return Effect.gen(function* () { | |
| 1016 | const provider = yield* GitHubSourceControlProvider.make; | |
| 1017 | const fs = yield* FileSystem.FileSystem; | |
| 1018 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 1019 | yield* fs.writeFileString(bodyFile, "Enterprise PR"); | |
| 1020 | const input = { | |
| 1021 | cwd: "/repo", | |
| 1022 | context: { | |
| 1023 | provider: { | |
| 1024 | kind: "github" as const, | |
| 1025 | name: "GitHub Self-Hosted", | |
| 1026 | baseUrl: "https://code.example.test", | |
| 1027 | }, | |
| 1028 | remoteName: "enterprise", | |
| 1029 | remoteUrl, | |
| 1030 | }, | |
| 1031 | }; | |
| 1032 | for (const state of ["open", "closed"] as const) { | |
| 1033 | const prs = yield* provider.listChangeRequests({ | |
| 1034 | ...input, | |
| 1035 | state, | |
| 1036 | headSelector: "feature", | |
| 1037 | }); | |
| 1038 | assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42"); | |
| 1039 | } | |
| 1040 | yield* provider.getChangeRequest({ ...input, reference: "42" }); | |
| 1041 | yield* provider.getChangeRequest({ ...input, reference: "feature" }); | |
| 1042 | yield* provider.createChangeRequest({ | |
| 1043 | ...input, | |
| 1044 | baseRefName: "main", | |
| 1045 | headSelector: "feature", | |
| 1046 | title: "PR", | |
| 1047 | bodyFile, | |
| 1048 | }); | |
| 1049 | assert.strictEqual(yield* provider.getDefaultBranch(input), "main"); | |
| 1050 | yield* provider.checkoutChangeRequest({ ...input, reference: "42" }); | |
| 1051 | yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" }); | |
| 1052 | yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" }); | |
| 1053 | assert.strictEqual(graphql.length, 5); | |
| 1054 | for (const request of graphql) { | |
| 1055 | assert.strictEqual(request.host, "code.example.test"); | |
| 1056 | assert.strictEqual(request.variables?.owner, "team"); | |
| 1057 | assert.strictEqual(request.variables?.name, "project"); | |
| 1058 | } | |
| 1059 | assert.deepStrictEqual( | |
| 1060 | rest.map(({ host, path }) => [host, path]), | |
| 1061 | [ | |
| 1062 | ["code.example.test", "repos/team/project/pulls"], | |
| 1063 | ["code.example.test", "repos/team/project"], | |
| 1064 | ["code.example.test", "repos/team/other"], | |
| 1065 | ["github.com", "repos/team/explicit"], | |
| 1066 | ], | |
| 1067 | ); | |
| 1068 | assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], { | |
| 1069 | cwd: "/repo", | |
| 1070 | remoteName: "enterprise", | |
| 1071 | remoteBranch: "feature", | |
| 1072 | }); | |
| 1073 | }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped); | |
| 1074 | }); | |
| 1075 | ||
| 1076 | it.effect.each([ | |
| 1077 | { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" }, | |
| 1078 | { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" }, | |
| 1079 | { | |
| 1080 | host: "github.com", | |
| 1081 | resolved: "remote.origin.gh-resolved base", | |
| 1082 | envRepository: "", | |
| 1083 | expected: "me/web", | |
| 1084 | }, | |
| 1085 | { | |
| 1086 | host: "code.example.test", | |
| 1087 | resolved: "remote.origin.gh-resolved base", | |
| 1088 | envRepository: "", | |
| 1089 | expected: "me/web", | |
| 1090 | }, | |
| 1091 | { | |
| 1092 | host: "github.com", | |
| 1093 | resolved: "remote.upstream.gh-resolved acme/other", | |
| 1094 | envRepository: "", | |
| 1095 | expected: "acme/other", | |
| 1096 | }, | |
| 1097 | { | |
| 1098 | host: "code.example.test", | |
| 1099 | resolved: "remote.upstream.gh-resolved acme/other", | |
| 1100 | envRepository: "", | |
| 1101 | expected: "acme/other", | |
| 1102 | }, | |
| 1103 | { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" }, | |
| 1104 | { | |
| 1105 | host: "code.example.test", | |
| 1106 | resolved: "", | |
| 1107 | envRepository: "chosen/repo", | |
| 1108 | expected: "chosen/repo", | |
| 1109 | }, | |
| 1110 | ])( | |
| 1111 | "preserves repository precedence on $host: $resolved $envRepository", | |
| 1112 | ({ host, resolved, envRepository, expected }) => { | |
| 1113 | const targets: string[] = []; | |
| 1114 | const { layer } = harness({ | |
| 1115 | remotes: remotesOutput( | |
| 1116 | ["origin", `git@${host}:me/web.git`], | |
| 1117 | ["upstream", `git@${host}:acme/web.git`], | |
| 1118 | ), | |
| 1119 | resolved, | |
| 1120 | api: { | |
| 1121 | rest: (input) => | |
| 1122 | Effect.sync(() => { | |
| 1123 | targets.push(`${input.host}/${input.path}`); | |
| 1124 | return restResponse({ | |
| 1125 | full_name: expected, | |
| 1126 | html_url: `https://${host}/${expected}`, | |
| 1127 | ssh_url: `git@${host}:${expected}.git`, | |
| 1128 | default_branch: "main", | |
| 1129 | }); | |
| 1130 | }), | |
| 1131 | }, | |
| 1132 | }); | |
| 1133 | return Effect.gen(function* () { | |
| 1134 | const provider = yield* GitHubSourceControlProvider.make; | |
| 1135 | const fs = yield* FileSystem.FileSystem; | |
| 1136 | const bodyFile = yield* fs.makeTempFileScoped(); | |
| 1137 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 1138 | const input = { | |
| 1139 | cwd: "/repo", | |
| 1140 | context: { | |
| 1141 | provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, | |
| 1142 | remoteName: "origin", | |
| 1143 | remoteUrl: `git@${host}:me/web.git`, | |
| 1144 | }, | |
| 1145 | }; | |
| 1146 | yield* provider.getDefaultBranch(input); | |
| 1147 | yield* provider.createChangeRequest({ | |
| 1148 | ...input, | |
| 1149 | baseRefName: "main", | |
| 1150 | headSelector: "me:feature", | |
| 1151 | title: "PR", | |
| 1152 | bodyFile, | |
| 1153 | }); | |
| 1154 | assert.deepStrictEqual(targets, [ | |
| 1155 | `${host}/repos/${expected}`, | |
| 1156 | `${host}/repos/${expected}/pulls`, | |
| 1157 | ]); | |
| 1158 | }).pipe( | |
| 1159 | Effect.provide(layer), | |
| 1160 | Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }), | |
| 1161 | Effect.scoped, | |
| 1162 | ); | |
| 1163 | }, | |
| 1164 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts deleted-1076| ... | ... | @@ -1,1076 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Data from "effect/Data"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Request from "effect/Request"; | |
| 8 | import * as RequestResolver from "effect/RequestResolver"; | |
| 9 | import * as Result from "effect/Result"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { | |
| 12 | DEFAULT_SERVER_SETTINGS, | |
| 13 | SourceControlProviderError, | |
| 14 | TrimmedNonEmptyString, | |
| 15 | type ChangeRequest, | |
| 16 | type GitHubSettings, | |
| 17 | type SourceControlProviderDiscoveryItem, | |
| 18 | type SourceControlRepositoryCloneUrls, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 21 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 22 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 23 | import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; | |
| 24 | ||
| 25 | import * as ServerSettings from "../serverSettings.ts"; | |
| 26 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 27 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 28 | import { | |
| 29 | decodeGitHubPullRequestEntries, | |
| 30 | type NormalizedGitHubPullRequestRecord, | |
| 31 | } from "./gitHubPullRequests.ts"; | |
| 32 | import { | |
| 33 | gitHubApiHostForRemote, | |
| 34 | parseFetchRemotes, | |
| 35 | parseGitHubRepositorySelector, | |
| 36 | parsePullRequestReference, | |
| 37 | resolveGitHubRepository, | |
| 38 | type GitHubRepositoryLocator, | |
| 39 | } from "./gitHubRepositoryResolution.ts"; | |
| 40 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 41 | import { | |
| 42 | effectiveGitHubAccount, | |
| 43 | findAuthenticatedGitHubAccount, | |
| 44 | parseGitHubAuthStatus, | |
| 45 | type GitHubAuthStatusAccount, | |
| 46 | } from "./gitHubAuthStatus.ts"; | |
| 47 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 48 | import { | |
| 49 | combinedAuthOutput, | |
| 50 | firstSafeAuthLine, | |
| 51 | probeSourceControlProvider, | |
| 52 | providerAuth, | |
| 53 | type SourceControlAuthProbeInput, | |
| 54 | type SourceControlCliDiscoverySpec, | |
| 55 | type SourceControlManagedCliDiscoverySpec, | |
| 56 | } from "@t3tools/source-control-core/server/discovery"; | |
| 57 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 58 | ||
| 59 | const decodeLinkSubject = Schema.decodeUnknownEffect( | |
| 60 | Schema.fromJsonString( | |
| 61 | Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 62 | ), | |
| 63 | ); | |
| 64 | ||
| 65 | function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { | |
| 66 | return { provider: "github", ...record }; | |
| 67 | } | |
| 68 | ||
| 69 | function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) { | |
| 70 | return accounts.map((entry) => ({ | |
| 71 | host: entry.host, | |
| 72 | account: entry.account, | |
| 73 | active: entry.active, | |
| 74 | authenticated: entry.authenticated, | |
| 75 | ...(entry.error === null ? {} : { error: entry.error }), | |
| 76 | ...(entry.environmentVariable === null | |
| 77 | ? {} | |
| 78 | : { environmentVariable: entry.environmentVariable }), | |
| 79 | })); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will | |
| 84 | * use: Settings can pin a login per host or turn a host off, and an environment token beats both. | |
| 85 | */ | |
| 86 | export function parseGitHubAuth( | |
| 87 | input: SourceControlAuthProbeInput, | |
| 88 | settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, | |
| 89 | ) { | |
| 90 | const output = combinedAuthOutput(input); | |
| 91 | const authStatus = parseGitHubAuthStatus(input.stdout); | |
| 92 | const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; | |
| 93 | const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); | |
| 94 | // Lead with the host gh would pick, unless Settings turned it off. | |
| 95 | const orderedHosts = fallback | |
| 96 | ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] | |
| 97 | : hosts; | |
| 98 | const chosen = orderedHosts | |
| 99 | .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) | |
| 100 | .find((entry) => entry !== undefined); | |
| 101 | const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; | |
| 102 | ||
| 103 | if (chosen) { | |
| 104 | return { | |
| 105 | ...providerAuth({ | |
| 106 | status: "authenticated", | |
| 107 | account: chosen.account, | |
| 108 | host: chosen.host, | |
| 109 | detail: | |
| 110 | chosen.environmentVariable === null | |
| 111 | ? undefined | |
| 112 | : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, | |
| 113 | }), | |
| 114 | ...accounts, | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | if (fallback) { | |
| 119 | return { | |
| 120 | ...providerAuth({ | |
| 121 | status: "unauthenticated", | |
| 122 | host: fallback.host, | |
| 123 | detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", | |
| 124 | }), | |
| 125 | ...accounts, | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; | |
| 130 | if (authStatus.parsed) { | |
| 131 | return { | |
| 132 | ...providerAuth({ | |
| 133 | status: "unauthenticated", | |
| 134 | host: failedAccount?.host, | |
| 135 | detail: | |
| 136 | failedAccount?.error ?? | |
| 137 | "Run `gh auth login` to authenticate GitHub CLI with an active account.", | |
| 138 | }), | |
| 139 | ...accounts, | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit | |
| 144 | // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. | |
| 145 | if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { | |
| 146 | return providerAuth({ | |
| 147 | status: "unknown", | |
| 148 | detail: | |
| 149 | "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", | |
| 150 | }); | |
| 151 | } | |
| 152 | ||
| 153 | if (input.exitCode !== 0) { | |
| 154 | return providerAuth({ | |
| 155 | status: "unauthenticated", | |
| 156 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | return providerAuth({ | |
| 161 | status: "unknown", | |
| 162 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", | |
| 163 | }); | |
| 164 | } | |
| 165 | ||
| 166 | export const discovery = { | |
| 167 | type: "cli", | |
| 168 | kind: "github", | |
| 169 | label: "GitHub", | |
| 170 | executable: "gh", | |
| 171 | versionArgs: ["--version"], | |
| 172 | authArgs: ["auth", "status", "--json", "hosts"], | |
| 173 | parseAuth: parseGitHubAuth, | |
| 174 | installHint: | |
| 175 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", | |
| 176 | } satisfies SourceControlCliDiscoverySpec; | |
| 177 | ||
| 178 | const decodeViewer = Schema.decodeUnknownOption( | |
| 179 | Schema.fromJsonString(Schema.Struct({ login: Schema.String })), | |
| 180 | ); | |
| 181 | ||
| 182 | /** The environment variable gh would take a github.com token from, if one is set. */ | |
| 183 | function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { | |
| 184 | return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one | |
| 189 | * over. Reads the | |
| 190 | * GitHub settings on every probe, so a saved account choice shows on rescan. An environment | |
| 191 | * token is checked against the API, since `gh auth status` may not know it. | |
| 192 | */ | |
| 193 | export const makeDiscovery = Effect.gen(function* () { | |
| 194 | const api = yield* GitHubApi.GitHubApi; | |
| 195 | const process = yield* VcsProcess.VcsProcess; | |
| 196 | const environment = yield* HostProcessEnvironment; | |
| 197 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 198 | ||
| 199 | return { | |
| 200 | type: "managed-cli", | |
| 201 | kind: discovery.kind, | |
| 202 | label: discovery.label, | |
| 203 | installHint: discovery.installHint, | |
| 204 | probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { | |
| 205 | const settings = yield* serverSettings.getSettings.pipe( | |
| 206 | Effect.map((current) => current.github), | |
| 207 | Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), | |
| 208 | ); | |
| 209 | const cli = yield* probeSourceControlProvider({ | |
| 210 | cwd, | |
| 211 | process, | |
| 212 | spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, | |
| 213 | }); | |
| 214 | // A token saved in Settings wins over the environment, which wins over gh. | |
| 215 | const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; | |
| 216 | const variable = environmentTokenVariable(environment); | |
| 217 | const tokenSource = savedToken ? "the token saved in Settings" : variable; | |
| 218 | // A host turned off in Settings stays off even with a token. | |
| 219 | if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; | |
| 220 | const viewer = yield* api | |
| 221 | .rest({ host: "github.com", operation: "discovery", path: "user" }) | |
| 222 | .pipe(Effect.result); | |
| 223 | const login = Result.isSuccess(viewer) | |
| 224 | ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login | |
| 225 | : undefined; | |
| 226 | // The per-host logins stay, so the account picker still lists every host gh knows. | |
| 227 | const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; | |
| 228 | return { | |
| 229 | ...cli, | |
| 230 | status: "available" as const, | |
| 231 | auth: { | |
| 232 | ...(login !== undefined | |
| 233 | ? providerAuth({ | |
| 234 | status: "authenticated", | |
| 235 | account: login, | |
| 236 | host: "github.com", | |
| 237 | detail: savedToken | |
| 238 | ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." | |
| 239 | : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, | |
| 240 | }) | |
| 241 | : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" | |
| 242 | ? // Only a refusal says the token is bad; a network error or a pause says nothing. | |
| 243 | providerAuth({ | |
| 244 | status: "unknown", | |
| 245 | host: "github.com", | |
| 246 | detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, | |
| 247 | }) | |
| 248 | : providerAuth({ | |
| 249 | status: "unauthenticated", | |
| 250 | host: "github.com", | |
| 251 | detail: savedToken | |
| 252 | ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." | |
| 253 | : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, | |
| 254 | })), | |
| 255 | ...accounts, | |
| 256 | }, | |
| 257 | } satisfies SourceControlProviderDiscoveryItem; | |
| 258 | }), | |
| 259 | /** | |
| 260 | * Claims a custom host GitHub holds a credential for, from whichever source supplies it | |
| 261 | * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its | |
| 262 | * error says so instead of "unsupported host". | |
| 263 | */ | |
| 264 | refineUnknownRemote: ({ context }) => { | |
| 265 | // Identity resolution also probes providers before a web base URL is known. | |
| 266 | const url = URL.parse(context.provider.baseUrl); | |
| 267 | if (!url?.host) return Effect.succeed(null); | |
| 268 | return api.credential(url.host).pipe( | |
| 269 | Effect.as(true), | |
| 270 | Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), | |
| 271 | Effect.orElseSucceed(() => false), | |
| 272 | Effect.map((known) => | |
| 273 | known | |
| 274 | ? ({ | |
| 275 | kind: "github", | |
| 276 | name: "GitHub Self-Hosted", | |
| 277 | baseUrl: context.provider.baseUrl, | |
| 278 | } as const) | |
| 279 | : null, | |
| 280 | ), | |
| 281 | ); | |
| 282 | }, | |
| 283 | } satisfies SourceControlManagedCliDiscoverySpec; | |
| 284 | }); | |
| 285 | ||
| 286 | /** | |
| 287 | * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the | |
| 288 | * operation and request context; `cause` keeps the raw failure for logs, never for transport. | |
| 289 | */ | |
| 290 | class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ | |
| 291 | readonly detail: string; | |
| 292 | readonly cause: unknown; | |
| 293 | }> {} | |
| 294 | ||
| 295 | const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); | |
| 296 | ||
| 297 | const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; | |
| 298 | const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; | |
| 299 | ||
| 300 | /** `notFound` names what was missing, which only the operation knows. */ | |
| 301 | function fromGitHubApiError( | |
| 302 | error: GitHubApi.GitHubApiError, | |
| 303 | notFound = PULL_REQUEST_NOT_FOUND, | |
| 304 | ): GitHubFailure { | |
| 305 | switch (error._tag) { | |
| 306 | case "GitHubCliMissingError": | |
| 307 | return failure( | |
| 308 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 309 | error, | |
| 310 | ); | |
| 311 | case "GitHubNotSignedInError": | |
| 312 | case "GitHubHostDisabledError": | |
| 313 | // A missing or turned-off credential already says what to do about it. | |
| 314 | return failure(error.message, error); | |
| 315 | case "GitHubApiAuthenticationError": | |
| 316 | return failure( | |
| 317 | "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", | |
| 318 | error, | |
| 319 | ); | |
| 320 | case "GitHubApiRateLimitError": | |
| 321 | case "SourceControlRateLimitPausedError": | |
| 322 | return failure( | |
| 323 | "GitHub API rate limit exceeded. Requests resume when the limit resets.", | |
| 324 | error, | |
| 325 | ); | |
| 326 | case "GitHubApiNotFoundError": | |
| 327 | return failure(notFound, error); | |
| 328 | case "GitHubCliFailedError": | |
| 329 | case "GitHubApiResponseError": | |
| 330 | case "GitHubApiRequestError": | |
| 331 | // GitHub's own reason ("A pull request already exists…") when it gave one. | |
| 332 | return failure(error.message.trim() || "GitHub request failed.", error); | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); | |
| 337 | ||
| 338 | const RawRepositorySchema = Schema.Struct({ | |
| 339 | full_name: TrimmedNonEmptyString, | |
| 340 | html_url: TrimmedNonEmptyString, | |
| 341 | ssh_url: TrimmedNonEmptyString, | |
| 342 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 343 | }); | |
| 344 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 345 | ||
| 346 | function repositoryCloneUrls( | |
| 347 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 348 | ): SourceControlRepositoryCloneUrls { | |
| 349 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 350 | } | |
| 351 | ||
| 352 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 353 | ||
| 354 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 355 | ||
| 356 | /** The pull request fields every read selects, in GraphQL. */ | |
| 357 | const PULL_REQUEST_NODE_SELECTION = | |
| 358 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 359 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 360 | open: ["OPEN"], | |
| 361 | closed: ["CLOSED"], | |
| 362 | merged: ["MERGED"], | |
| 363 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 364 | }; | |
| 365 | /** | |
| 366 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 367 | * single lookup, but half that keeps each answer near half a second. | |
| 368 | */ | |
| 369 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 370 | /** | |
| 371 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 372 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 373 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 374 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 375 | */ | |
| 376 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 377 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 378 | /** | |
| 379 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 380 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 381 | * processing limit; twenty-five took ~7s. | |
| 382 | */ | |
| 383 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 384 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 385 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 386 | /** | |
| 387 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 388 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 389 | * top because the newest come first. | |
| 390 | */ | |
| 391 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 392 | ||
| 393 | class PullRequestsByHeadRead extends Request.Class< | |
| 394 | { | |
| 395 | readonly host: string; | |
| 396 | readonly owner: string; | |
| 397 | readonly name: string; | |
| 398 | readonly headRefName: string; | |
| 399 | readonly state: PullRequestListState; | |
| 400 | readonly limit: number; | |
| 401 | readonly allowReserve: boolean; | |
| 402 | }, | |
| 403 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 404 | GitHubFailure | |
| 405 | > {} | |
| 406 | ||
| 407 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 408 | function buildPullRequestsByHeadQuery( | |
| 409 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 410 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 411 | const variables: Record<string, unknown> = {}; | |
| 412 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 413 | const selections: string[] = []; | |
| 414 | for (const [index, lookup] of lookups.entries()) { | |
| 415 | variables[`h${index}`] = lookup.headRefName; | |
| 416 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 417 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 418 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 419 | // same rows. | |
| 420 | selections.push( | |
| 421 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 422 | ); | |
| 423 | } | |
| 424 | return { | |
| 425 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 426 | variables, | |
| 427 | }; | |
| 428 | } | |
| 429 | ||
| 430 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 431 | repository(owner: $owner, name: $name) { | |
| 432 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 433 | } | |
| 434 | }`; | |
| 435 | ||
| 436 | const decodePullRequestsByHead = decodeJsonResult( | |
| 437 | Schema.Struct({ | |
| 438 | data: Schema.Struct({ | |
| 439 | repository: Schema.NullOr( | |
| 440 | Schema.Record( | |
| 441 | Schema.String, | |
| 442 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 443 | ), | |
| 444 | ), | |
| 445 | }), | |
| 446 | }), | |
| 447 | ); | |
| 448 | ||
| 449 | const decodePullRequestByNumber = decodeJsonResult( | |
| 450 | Schema.Struct({ | |
| 451 | data: Schema.Struct({ | |
| 452 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 453 | }), | |
| 454 | }), | |
| 455 | ); | |
| 456 | ||
| 457 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 458 | export function pullRequestCheckoutBranchName(input: { | |
| 459 | readonly headRefName: string; | |
| 460 | readonly headOwner: string | null; | |
| 461 | readonly isCrossRepository: boolean; | |
| 462 | readonly defaultBranch: string | null; | |
| 463 | }): string { | |
| 464 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 465 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 466 | return input.isCrossRepository && | |
| 467 | input.headOwner !== null && | |
| 468 | input.defaultBranch !== null && | |
| 469 | input.headRefName === input.defaultBranch | |
| 470 | ? `${input.headOwner}/${input.headRefName}` | |
| 471 | : input.headRefName; | |
| 472 | } | |
| 473 | ||
| 474 | /** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ | |
| 475 | const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => | |
| 476 | context === undefined ? undefined : new URL(context.provider.baseUrl).host; | |
| 477 | ||
| 478 | export const make = Effect.gen(function* () { | |
| 479 | const api = yield* GitHubApi.GitHubApi; | |
| 480 | const process = yield* VcsProcess.VcsProcess; | |
| 481 | const environment = yield* HostProcessEnvironment; | |
| 482 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 483 | const fileSystem = yield* FileSystem.FileSystem; | |
| 484 | ||
| 485 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 486 | process.run({ | |
| 487 | operation: "GitHubSourceControlProvider.resolveRepository", | |
| 488 | command: "git", | |
| 489 | args, | |
| 490 | cwd, | |
| 491 | allowNonZeroExit: true, | |
| 492 | timeoutMs: 5_000, | |
| 493 | }); | |
| 494 | ||
| 495 | /** | |
| 496 | * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is | |
| 497 | * a fallback when the checkout's remotes cannot resolve that repository. | |
| 498 | */ | |
| 499 | const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( | |
| 500 | function* (input: { | |
| 501 | readonly cwd: string; | |
| 502 | readonly host?: string | undefined; | |
| 503 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 504 | }) { | |
| 505 | const envRepository = environment.GH_REPO?.trim(); | |
| 506 | const hostHint = input.host ?? contextHost(input.context); | |
| 507 | const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 508 | if (envRepository) { | |
| 509 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 510 | if (locator !== null) return locator; | |
| 511 | } | |
| 512 | const [remotes, resolved] = yield* Effect.all([ | |
| 513 | gitRead(input.cwd, ["remote", "-v"]), | |
| 514 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 515 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 516 | const { host, locator } = resolveGitHubRepository({ | |
| 517 | remotes: remotes?.exitCode === 0 ? remotes.stdout : "", | |
| 518 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 519 | hostHint, | |
| 520 | defaultHost, | |
| 521 | }); | |
| 522 | if (locator !== null) return locator; | |
| 523 | if (input.context) { | |
| 524 | const remote = normalizeGitRemoteUrl(input.context.remoteUrl); | |
| 525 | const fallback = parseGitHubRepositorySelector( | |
| 526 | `${host}/${remote.slice(remote.indexOf("/") + 1)}`, | |
| 527 | host, | |
| 528 | ); | |
| 529 | if (fallback !== null) return fallback; | |
| 530 | } | |
| 531 | return yield* failure( | |
| 532 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 533 | ); | |
| 534 | }, | |
| 535 | ); | |
| 536 | ||
| 537 | const graphqlJson = <A>( | |
| 538 | input: GitHubApi.GitHubGraphQlInput, | |
| 539 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 540 | decodeDetail: string, | |
| 541 | ) => | |
| 542 | api.graphql(input).pipe( | |
| 543 | Effect.mapError(fromGitHubApiError), | |
| 544 | Effect.flatMap((raw) => { | |
| 545 | const decoded = decode(raw); | |
| 546 | return Result.isSuccess(decoded) | |
| 547 | ? Effect.succeed(decoded.success) | |
| 548 | : Effect.fail(failure(decodeDetail, decoded.failure)); | |
| 549 | }), | |
| 550 | ); | |
| 551 | ||
| 552 | const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => | |
| 553 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); | |
| 554 | ||
| 555 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 556 | key: ({ request, context }) => | |
| 557 | [ | |
| 558 | request.host, | |
| 559 | request.owner, | |
| 560 | request.name, | |
| 561 | String(request.allowReserve), | |
| 562 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 563 | ?.credentialFingerprint ?? "", | |
| 564 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 565 | ].join("\0"), | |
| 566 | resolver: (entries) => { | |
| 567 | const [first] = entries; | |
| 568 | const { host, owner, name, allowReserve } = first.request; | |
| 569 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); | |
| 570 | return graphqlJson( | |
| 571 | { | |
| 572 | host, | |
| 573 | operation: "listPullRequestsByHead", | |
| 574 | query: query.document, | |
| 575 | variables: { owner, name, ...query.variables }, | |
| 576 | allowReserve, | |
| 577 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 578 | // fork pull requests, so the usual cap would cut the answer short. | |
| 579 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 580 | }, | |
| 581 | decodePullRequestsByHead, | |
| 582 | "GitHub returned an invalid change request list.", | |
| 583 | ).pipe( | |
| 584 | Effect.flatMap((decoded) => | |
| 585 | decoded.data.repository === null | |
| 586 | ? Effect.fail(notFound("The repository could not be read.")) | |
| 587 | : Effect.succeed(decoded.data.repository), | |
| 588 | ), | |
| 589 | Effect.map((aliases) => { | |
| 590 | for (const [index, entry] of entries.entries()) { | |
| 591 | const alias = aliases[`h${index}`]; | |
| 592 | entry.completeUnsafe( | |
| 593 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 594 | ); | |
| 595 | } | |
| 596 | }), | |
| 597 | Effect.catchCause((cause) => | |
| 598 | Effect.sync(() => { | |
| 599 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 600 | }), | |
| 601 | ), | |
| 602 | ); | |
| 603 | }, | |
| 604 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 605 | const interactiveHeadResolver = headResolver.pipe( | |
| 606 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 607 | ); | |
| 608 | const backgroundHeadResolver = headResolver.pipe( | |
| 609 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 610 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 611 | ); | |
| 612 | ||
| 613 | /** | |
| 614 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 615 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together | |
| 616 | * share one GraphQL document. | |
| 617 | */ | |
| 618 | const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { | |
| 619 | readonly cwd: string; | |
| 620 | readonly headSelector: string; | |
| 621 | readonly state: PullRequestListState; | |
| 622 | readonly limit: number; | |
| 623 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 624 | readonly host?: string | undefined; | |
| 625 | readonly allowReserve: boolean; | |
| 626 | }) { | |
| 627 | const locator = yield* resolveRepository(input); | |
| 628 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 629 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 630 | // owner is matched on the rows it returns. | |
| 631 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 632 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 633 | const rows = yield* Effect.request( | |
| 634 | new PullRequestsByHeadRead({ | |
| 635 | ...locator, | |
| 636 | headRefName, | |
| 637 | state: input.state, | |
| 638 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 639 | allowReserve: input.allowReserve, | |
| 640 | }), | |
| 641 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 642 | ); | |
| 643 | if (!ownerMatch) return rows; | |
| 644 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 645 | return rows | |
| 646 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 647 | .slice(0, limit); | |
| 648 | }); | |
| 649 | ||
| 650 | const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( | |
| 651 | function* (input: { | |
| 652 | readonly cwd: string; | |
| 653 | readonly reference: string; | |
| 654 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 655 | readonly host?: string | undefined; | |
| 656 | }) { | |
| 657 | const parsed = parsePullRequestReference(input.reference); | |
| 658 | if (parsed.kind === "branch") { | |
| 659 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 660 | const lookup = (state: PullRequestListState) => | |
| 661 | listByHead({ | |
| 662 | cwd: input.cwd, | |
| 663 | headSelector: parsed.headSelector, | |
| 664 | state, | |
| 665 | limit: 1, | |
| 666 | host: input.host, | |
| 667 | context: input.context, | |
| 668 | allowReserve: true, | |
| 669 | }); | |
| 670 | const [open] = yield* lookup("open"); | |
| 671 | const found = open ?? (yield* lookup("all"))[0]; | |
| 672 | if (found === undefined) return yield* notFound("No pull request has this head branch."); | |
| 673 | return found; | |
| 674 | } | |
| 675 | const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); | |
| 676 | const decodeDetail = "GitHub returned an invalid pull request."; | |
| 677 | const decoded = yield* graphqlJson( | |
| 678 | { | |
| 679 | host: locator.host, | |
| 680 | operation: "getPullRequest", | |
| 681 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 682 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 683 | allowReserve: true, | |
| 684 | }, | |
| 685 | decodePullRequestByNumber, | |
| 686 | decodeDetail, | |
| 687 | ); | |
| 688 | const node = decoded.data.repository?.pullRequest; | |
| 689 | if (node == null) return yield* notFound("The pull request does not exist."); | |
| 690 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 691 | if (record === undefined) | |
| 692 | return yield* failure(decodeDetail, new Error("Malformed pull request.")); | |
| 693 | return record; | |
| 694 | }, | |
| 695 | ); | |
| 696 | ||
| 697 | const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( | |
| 698 | locator: GitHubRepositoryLocator, | |
| 699 | ) { | |
| 700 | const response = yield* rest( | |
| 701 | { | |
| 702 | host: locator.host, | |
| 703 | operation: "getRepository", | |
| 704 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 705 | allowReserve: true, | |
| 706 | }, | |
| 707 | REPOSITORY_NOT_FOUND, | |
| 708 | ); | |
| 709 | const decoded = decodeRawRepository(response.body); | |
| 710 | if (Result.isFailure(decoded)) { | |
| 711 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 712 | } | |
| 713 | return decoded.success; | |
| 714 | }); | |
| 715 | ||
| 716 | const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( | |
| 717 | host: string, | |
| 718 | ) { | |
| 719 | const response = yield* rest({ | |
| 720 | host, | |
| 721 | operation: "getViewer", | |
| 722 | path: "user", | |
| 723 | allowReserve: true, | |
| 724 | }); | |
| 725 | const decoded = decodeViewerLogin(response.body); | |
| 726 | if (Result.isFailure(decoded)) { | |
| 727 | return yield* failure("GitHub request failed.", decoded.failure); | |
| 728 | } | |
| 729 | return decoded.success.login; | |
| 730 | }); | |
| 731 | ||
| 732 | // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client | |
| 733 | // sees is fixed and the raw error stays in `cause`. | |
| 734 | const gitFailure = (cause: unknown) => | |
| 735 | failure("The pull request could not be checked out with git.", cause); | |
| 736 | ||
| 737 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 738 | git.execute({ | |
| 739 | operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, | |
| 740 | cwd, | |
| 741 | args, | |
| 742 | }); | |
| 743 | ||
| 744 | /** | |
| 745 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 746 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 747 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 748 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 749 | */ | |
| 750 | const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( | |
| 751 | function* (input: { | |
| 752 | readonly cwd: string; | |
| 753 | readonly reference: string; | |
| 754 | readonly force?: boolean; | |
| 755 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 756 | }) { | |
| 757 | const reference = parsePullRequestReference(input.reference); | |
| 758 | const pullRequest = yield* readPullRequest(input); | |
| 759 | const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); | |
| 760 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 761 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 762 | const isCrossRepository = | |
| 763 | pullRequest.isCrossRepository ?? | |
| 764 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 765 | const headOwner = | |
| 766 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 767 | ||
| 768 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 769 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 770 | Effect.mapError(gitFailure), | |
| 771 | ); | |
| 772 | const remoteFor = (nameWithOwner: string) => | |
| 773 | parseFetchRemotes(remotes).find( | |
| 774 | (remote) => | |
| 775 | (gitHubApiHostForRemote(remote.url) ?? | |
| 776 | normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && | |
| 777 | normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === | |
| 778 | nameWithOwner.toLowerCase(), | |
| 779 | )?.name ?? null; | |
| 780 | const baseRemote = Effect.suspend(() => { | |
| 781 | const known = remoteFor(baseNameWithOwner); | |
| 782 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 783 | }); | |
| 784 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 785 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 786 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 787 | const defaultBranch = isCrossRepository | |
| 788 | ? yield* readRepository(base).pipe( | |
| 789 | Effect.map((repository) => repository.default_branch ?? null), | |
| 790 | ) | |
| 791 | : null; | |
| 792 | const localBranch = pullRequestCheckoutBranchName({ | |
| 793 | headRefName: pullRequest.headRefName, | |
| 794 | headOwner, | |
| 795 | isCrossRepository, | |
| 796 | defaultBranch, | |
| 797 | }); | |
| 798 | ||
| 799 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 800 | const headRemote = Effect.gen(function* () { | |
| 801 | if (!isCrossRepository) return yield* baseRemote; | |
| 802 | if (headNameWithOwner === null) return yield* failure("The fork is gone."); | |
| 803 | const known = remoteFor(headNameWithOwner); | |
| 804 | if (known !== null) return known; | |
| 805 | const [owner, name] = headNameWithOwner.split("/"); | |
| 806 | const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); | |
| 807 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 808 | return yield* git.ensureRemote({ | |
| 809 | cwd: input.cwd, | |
| 810 | preferredName: headOwner ?? "fork", | |
| 811 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 812 | }); | |
| 813 | }); | |
| 814 | ||
| 815 | const exists = (yield* git | |
| 816 | .listLocalBranchNames(input.cwd) | |
| 817 | .pipe(Effect.mapError(gitFailure))).includes(localBranch); | |
| 818 | ||
| 819 | // The commit to check out, fetched from the head branch where it still exists. | |
| 820 | const target = yield* Effect.gen(function* () { | |
| 821 | const remoteName = yield* headRemote; | |
| 822 | yield* git.fetchRemoteTrackingBranch({ | |
| 823 | cwd: input.cwd, | |
| 824 | remoteName, | |
| 825 | remoteBranch: pullRequest.headRefName, | |
| 826 | }); | |
| 827 | return { | |
| 828 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 829 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 830 | }; | |
| 831 | }).pipe( | |
| 832 | Effect.catch(() => | |
| 833 | Effect.gen(function* () { | |
| 834 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 835 | "fetch", | |
| 836 | "--quiet", | |
| 837 | "--no-tags", | |
| 838 | yield* baseRemote, | |
| 839 | `refs/pull/${pullRequest.number}/head`, | |
| 840 | ]); | |
| 841 | const { commitSha } = yield* git.resolveCommit({ | |
| 842 | cwd: input.cwd, | |
| 843 | revision: "FETCH_HEAD", | |
| 844 | }); | |
| 845 | return { ref: commitSha, upstream: null }; | |
| 846 | }), | |
| 847 | ), | |
| 848 | Effect.mapError(gitFailure), | |
| 849 | ); | |
| 850 | ||
| 851 | yield* Effect.gen(function* () { | |
| 852 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 853 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 854 | if (exists) { | |
| 855 | yield* runGit( | |
| 856 | input.cwd, | |
| 857 | "sync", | |
| 858 | input.force === true | |
| 859 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 860 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 861 | ); | |
| 862 | } | |
| 863 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 864 | // existing branch's upstream as it was. | |
| 865 | if (target.upstream !== null) { | |
| 866 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 867 | } | |
| 868 | }).pipe(Effect.mapError(gitFailure)); | |
| 869 | }, | |
| 870 | ); | |
| 871 | ||
| 872 | /** Reports a failure against the operation and request that made it, with transport-safe context. */ | |
| 873 | const providerError = | |
| 874 | ( | |
| 875 | operation: string, | |
| 876 | cwd: string, | |
| 877 | context?: { readonly reference?: string; readonly repository?: string }, | |
| 878 | ) => | |
| 879 | (error: GitHubFailure) => | |
| 880 | new SourceControlProviderError({ | |
| 881 | provider: "github", | |
| 882 | operation, | |
| 883 | cwd, | |
| 884 | ...(context?.reference === undefined | |
| 885 | ? {} | |
| 886 | : { | |
| 887 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 888 | context.reference, | |
| 889 | ), | |
| 890 | }), | |
| 891 | ...(context?.repository === undefined | |
| 892 | ? {} | |
| 893 | : { | |
| 894 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 895 | context.repository, | |
| 896 | ), | |
| 897 | }), | |
| 898 | detail: error.detail, | |
| 899 | cause: error.cause, | |
| 900 | }); | |
| 901 | ||
| 902 | const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( | |
| 903 | input: { readonly cwd: string; readonly url: URL }, | |
| 904 | endpoint: string, | |
| 905 | ) { | |
| 906 | const result = yield* api | |
| 907 | .rest({ | |
| 908 | host: input.url.host, | |
| 909 | operation: "resolveLink", | |
| 910 | path: endpoint, | |
| 911 | maxResponseBytes: 1_000_000, | |
| 912 | }) | |
| 913 | .pipe( | |
| 914 | Effect.timeout("3 seconds"), | |
| 915 | Effect.mapError( | |
| 916 | (cause) => | |
| 917 | new SourceControlProviderError({ | |
| 918 | provider: "github", | |
| 919 | operation: "resolveLink", | |
| 920 | cwd: input.cwd, | |
| 921 | detail: "The linked subject could not be read.", | |
| 922 | cause, | |
| 923 | }), | |
| 924 | ), | |
| 925 | ); | |
| 926 | const subject = yield* decodeLinkSubject(result.body).pipe( | |
| 927 | Effect.mapError( | |
| 928 | (cause) => | |
| 929 | new SourceControlProviderError({ | |
| 930 | provider: "github", | |
| 931 | operation: "resolveLink.decode", | |
| 932 | cwd: input.cwd, | |
| 933 | detail: "The linked subject could not be read.", | |
| 934 | cause, | |
| 935 | }), | |
| 936 | ), | |
| 937 | ); | |
| 938 | return { title: subject.title, body: subject.body ?? null }; | |
| 939 | }); | |
| 940 | ||
| 941 | return SourceControlProvider.SourceControlProvider.of({ | |
| 942 | kind: "github", | |
| 943 | resolveLink: (input) => { | |
| 944 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. | |
| 945 | if (input.url.host !== "github.com") return undefined; | |
| 946 | const match = /^\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( | |
| 947 | input.url.pathname, | |
| 948 | ); | |
| 949 | if (!match) return undefined; | |
| 950 | return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); | |
| 951 | }, | |
| 952 | listChangeRequests: (input) => | |
| 953 | // An open lookup is a user waiting on a status; the rest may be a background sweep. | |
| 954 | (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( | |
| 955 | Effect.flatMap((allowReserve) => | |
| 956 | listByHead({ | |
| 957 | cwd: input.cwd, | |
| 958 | headSelector: input.headSelector, | |
| 959 | state: input.state, | |
| 960 | limit: input.limit ?? (input.state === "open" ? 1 : 20), | |
| 961 | host: contextHost(input.context), | |
| 962 | context: input.context, | |
| 963 | allowReserve, | |
| 964 | }), | |
| 965 | ), | |
| 966 | Effect.map((records) => records.map(toChangeRequest)), | |
| 967 | Effect.mapError( | |
| 968 | providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), | |
| 969 | ), | |
| 970 | ), | |
| 971 | getChangeRequest: (input) => | |
| 972 | readPullRequest({ ...input, host: contextHost(input.context) }).pipe( | |
| 973 | Effect.map(toChangeRequest), | |
| 974 | Effect.mapError( | |
| 975 | providerError("getChangeRequest", input.cwd, { reference: input.reference }), | |
| 976 | ), | |
| 977 | ), | |
| 978 | createChangeRequest: (input) => | |
| 979 | Effect.gen(function* () { | |
| 980 | const locator = yield* resolveRepository(input); | |
| 981 | const body = yield* fileSystem | |
| 982 | .readFileString(input.bodyFile) | |
| 983 | .pipe( | |
| 984 | Effect.mapError((cause) => | |
| 985 | failure("The pull request description could not be read.", cause), | |
| 986 | ), | |
| 987 | ); | |
| 988 | yield* rest({ | |
| 989 | host: locator.host, | |
| 990 | operation: "createPullRequest", | |
| 991 | method: "POST", | |
| 992 | // A user's own write may spend the reserve the background leaves for it. | |
| 993 | allowReserve: true, | |
| 994 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 995 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 996 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 997 | body: { | |
| 998 | base: input.baseRefName, | |
| 999 | head: input.headSelector, | |
| 1000 | title: input.title, | |
| 1001 | body, | |
| 1002 | maintainer_can_modify: true, | |
| 1003 | }, | |
| 1004 | }); | |
| 1005 | }).pipe( | |
| 1006 | Effect.mapError( | |
| 1007 | providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), | |
| 1008 | ), | |
| 1009 | ), | |
| 1010 | getRepositoryCloneUrls: (input) => | |
| 1011 | Effect.gen(function* () { | |
| 1012 | const fallbackHost = (yield* resolveRepository(input).pipe( | |
| 1013 | Effect.map((locator) => locator.host), | |
| 1014 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 1015 | )).toLowerCase(); | |
| 1016 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 1017 | if (locator === null) return yield* failure("Repositories are named owner/name."); | |
| 1018 | return repositoryCloneUrls(yield* readRepository(locator)); | |
| 1019 | }).pipe( | |
| 1020 | Effect.mapError( | |
| 1021 | providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), | |
| 1022 | ), | |
| 1023 | ), | |
| 1024 | createRepository: (input) => | |
| 1025 | Effect.gen(function* () { | |
| 1026 | const locator = parseGitHubRepositorySelector( | |
| 1027 | input.repository, | |
| 1028 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 1029 | ); | |
| 1030 | const viewer = locator === null ? null : yield* readViewerLogin(locator.host); | |
| 1031 | const owner = locator?.owner ?? viewer; | |
| 1032 | const name = locator?.name ?? input.repository.trim(); | |
| 1033 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 1034 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 1035 | const response = yield* rest( | |
| 1036 | { | |
| 1037 | host, | |
| 1038 | operation: "createRepository", | |
| 1039 | method: "POST", | |
| 1040 | allowReserve: true, | |
| 1041 | path: | |
| 1042 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 1043 | body: { name, private: input.visibility === "private" }, | |
| 1044 | }, | |
| 1045 | // GitHub answers 404 for an organization the account cannot create repositories in. | |
| 1046 | `No organization named ${owner ?? "that"} that this account can create repositories in.`, | |
| 1047 | ); | |
| 1048 | const decoded = decodeRawRepository(response.body); | |
| 1049 | if (Result.isFailure(decoded)) { | |
| 1050 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 1051 | } | |
| 1052 | return repositoryCloneUrls(decoded.success); | |
| 1053 | }).pipe( | |
| 1054 | Effect.mapError( | |
| 1055 | providerError("createRepository", input.cwd, { repository: input.repository }), | |
| 1056 | ), | |
| 1057 | ), | |
| 1058 | getDefaultBranch: (input) => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | const locator = yield* resolveRepository({ | |
| 1061 | cwd: input.cwd, | |
| 1062 | host: contextHost(input.context), | |
| 1063 | context: input.context, | |
| 1064 | }); | |
| 1065 | const repository = yield* readRepository(locator); | |
| 1066 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1067 | return branch.length > 0 ? branch : null; | |
| 1068 | }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), | |
| 1069 | checkoutChangeRequest: (input) => | |
| 1070 | checkoutPullRequest(input).pipe( | |
| 1071 | Effect.mapError( | |
| 1072 | providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), | |
| 1073 | ), | |
| 1074 | ), | |
| 1075 | }); | |
| 1076 | }); |
apps/server/src/sourceControl/ServerSourceControlHost.ts+2| ... | ... | @@ -24,6 +24,8 @@ export const layer = Layer.effect( |
| 24 | 24 | settings: { get: serverSettings.getSettings }, |
| 25 | 25 | process: { run: process.run }, |
| 26 | 26 | git: { |
| 27 | execute: git.execute, | |
| 28 | resolveCommit: git.resolveCommit, | |
| 27 | 29 | remotes: (cwd) => |
| 28 | 30 | vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))), |
| 29 | 31 | readConfigValue: git.readConfigValue, |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+3-1| ... | ... | @@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu |
| 20 | 20 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 21 | 21 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 22 | 22 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; |
| 23 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 23 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 24 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; | |
| 24 | 25 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 25 | 26 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 26 | 27 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| ... | ... | @@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 46 | 47 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 47 | 48 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), |
| 48 | 49 | ServerSettings.ServerSettingsService.layerTest(), |
| 50 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), | |
| 49 | 51 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 50 | 52 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| 51 | 53 | Layer.mock(GitLabCli.GitLabCli)({}), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+4-2| ... | ... | @@ -18,8 +18,9 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops |
| 18 | 18 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 19 | 19 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; |
| 20 | 20 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 21 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 22 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 21 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 22 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; | |
| 23 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 23 | 24 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 24 | 25 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 25 | 26 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| ... | ... | @@ -115,6 +116,7 @@ function makeRegistry(input: { |
| 115 | 116 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 116 | 117 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), |
| 117 | 118 | ServerSettings.ServerSettingsService.layerTest(), |
| 119 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), | |
| 118 | 120 | Layer.mock(GitHubApi.GitHubApi)({ |
| 119 | 121 | // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. |
| 120 | 122 | credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1-16| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 12 | 12 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 13 | 13 | |
| 14 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | |
| 15 | 14 | import * as BuiltInDrivers from "./builtInDrivers.ts"; |
| 16 | 15 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 17 | 16 | import { |
| ... | ... | @@ -302,8 +301,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 302 | 301 | ); |
| 303 | 302 | |
| 304 | 303 | export const make = Effect.gen(function* () { |
| 305 | const github = yield* GitHubSourceControlProvider.make; | |
| 306 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; | |
| 307 | 304 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 308 | 305 | driver.make.pipe( |
| 309 | 306 | Effect.map((instance): SourceControlProviderRegistration => ({ |
| ... | ... | @@ -313,19 +310,7 @@ export const make = Effect.gen(function* () { |
| 313 | 310 | })), |
| 314 | 311 | ), |
| 315 | 312 | ); |
| 316 | // Discovery lists hosts in this order, so it is kept while hosts move into packages. | |
| 317 | const order = ["github", "gitlab", "azure-devops", "bitbucket", "forgejo"]; | |
| 318 | const registrations: ReadonlyArray<SourceControlProviderRegistration> = [ | |
| 319 | { | |
| 320 | kind: "github", | |
| 321 | provider: github, | |
| 322 | discovery: githubDiscovery, | |
| 323 | }, | |
| 324 | ...drivers, | |
| 325 | ]; | |
| 326 | return yield* makeWithProviders( | |
| 327 | registrations.toSorted((left, right) => order.indexOf(left.kind) - order.indexOf(right.kind)), | |
| 328 | ); | |
| 313 | return yield* makeWithProviders(drivers); | |
| 329 | 314 | }); |
| 330 | 315 | |
| 331 | 316 | export const layer = Layer.effect(SourceControlProviderRegistry, make); |
apps/server/src/sourceControl/builtInDrivers.ts+8| ... | ... | @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv |
| 15 | 15 | import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; |
| 16 | 16 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 17 | 17 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; |
| 18 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 19 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; | |
| 20 | import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; | |
| 18 | 21 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 19 | 22 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 20 | 23 | import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; |
| ... | ... | @@ -24,6 +27,7 @@ import * as Layer from "effect/Layer"; |
| 24 | 27 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 25 | 28 | |
| 26 | 29 | const drivers = [ |
| 30 | GitHubDriver.driver, | |
| 27 | 31 | GitLabDriver.driver, |
| 28 | 32 | AzureDevOpsDriver.driver, |
| 29 | 33 | BitbucketDriver.driver, |
| ... | ... | @@ -34,12 +38,16 @@ const drivers = [ |
| 34 | 38 | export type BuiltInSourceControlDriversEnv = |
| 35 | 39 | (typeof drivers)[number] extends SourceControlDriver<infer R> ? R : never; |
| 36 | 40 | |
| 41 | /** Ordered as the hosts appear in discovery. */ | |
| 37 | 42 | export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< |
| 38 | 43 | SourceControlDriver<BuiltInSourceControlDriversEnv> |
| 39 | 44 | > = drivers; |
| 40 | 45 | |
| 41 | 46 | /** The services the built-in drivers' packages own, plus the host port they all run against. */ |
| 42 | 47 | export const layer = Layer.mergeAll( |
| 48 | // `GitHubApi.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub | |
| 49 | // reader shares, so the server builds it once, here. | |
| 50 | GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubApi.layerWithDependencies)), | |
| 43 | 51 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), |
| 44 | 52 | BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), |
| 45 | 53 | ForgejoCli.layer, |
apps/server/src/sourceControl/gitHubAuthStatus.ts deleted-103| ... | ... | @@ -1,103 +0,0 @@ |
| 1 | import type { GitHubSettings } from "@t3tools/contracts"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | ||
| 5 | const GitHubAuthStatusAccountSchema = Schema.Struct({ | |
| 6 | state: Schema.String, | |
| 7 | error: Schema.optional(Schema.String), | |
| 8 | active: Schema.Boolean, | |
| 9 | host: Schema.String, | |
| 10 | login: Schema.String, | |
| 11 | tokenSource: Schema.optional(Schema.String), | |
| 12 | }); | |
| 13 | ||
| 14 | const GitHubAuthStatusSchema = Schema.Struct({ | |
| 15 | hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), | |
| 16 | }); | |
| 17 | ||
| 18 | const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( | |
| 19 | Schema.fromJsonString(GitHubAuthStatusSchema), | |
| 20 | ); | |
| 21 | ||
| 22 | export interface GitHubAuthStatusAccount { | |
| 23 | readonly host: string; | |
| 24 | readonly account: string; | |
| 25 | readonly authenticated: boolean; | |
| 26 | readonly active: boolean; | |
| 27 | readonly error: string | null; | |
| 28 | /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ | |
| 29 | readonly environmentVariable: string | null; | |
| 30 | } | |
| 31 | ||
| 32 | export interface GitHubAuthStatus { | |
| 33 | readonly parsed: boolean; | |
| 34 | readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>; | |
| 35 | } | |
| 36 | ||
| 37 | const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u; | |
| 38 | ||
| 39 | function nonEmptyString(value: string): string | null { | |
| 40 | const trimmed = value.trim(); | |
| 41 | return trimmed.length > 0 ? trimmed : null; | |
| 42 | } | |
| 43 | ||
| 44 | export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { | |
| 45 | return Option.match(decodeGitHubAuthStatusJson(text), { | |
| 46 | onNone: () => ({ parsed: false, accounts: [] }), | |
| 47 | onSome: (status) => | |
| 48 | ({ | |
| 49 | parsed: true, | |
| 50 | accounts: Object.values(status.hosts).flatMap((accounts) => | |
| 51 | accounts.flatMap((account) => { | |
| 52 | const host = nonEmptyString(account.host); | |
| 53 | const login = nonEmptyString(account.login); | |
| 54 | if (host === null || login === null) return []; | |
| 55 | ||
| 56 | return [ | |
| 57 | { | |
| 58 | host: host.toLowerCase(), | |
| 59 | account: login, | |
| 60 | authenticated: account.state === "success", | |
| 61 | active: account.active, | |
| 62 | error: account.error?.trim() || null, | |
| 63 | environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "") | |
| 64 | ? (account.tokenSource ?? null) | |
| 65 | : null, | |
| 66 | }, | |
| 67 | ]; | |
| 68 | }), | |
| 69 | ), | |
| 70 | }) satisfies GitHubAuthStatus, | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | export function findAuthenticatedGitHubAccount( | |
| 75 | accounts: ReadonlyArray<GitHubAuthStatusAccount>, | |
| 76 | ): GitHubAuthStatusAccount | undefined { | |
| 77 | return ( | |
| 78 | accounts.find((account) => account.authenticated && account.active) ?? | |
| 79 | accounts.find((account) => account.authenticated) | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * The login GitHub requests for a host will use, honoring Settings: an environment token | |
| 85 | * first (gh's own precedence), then the pinned account, then gh's active login. | |
| 86 | * Returns undefined when the host is turned off or has no usable login. | |
| 87 | */ | |
| 88 | export function effectiveGitHubAccount( | |
| 89 | host: string, | |
| 90 | accounts: ReadonlyArray<GitHubAuthStatusAccount>, | |
| 91 | settings: GitHubSettings, | |
| 92 | ): GitHubAuthStatusAccount | undefined { | |
| 93 | const choice = settings.hosts[host]; | |
| 94 | if (choice?.enabled === false) return undefined; | |
| 95 | const usable = accounts.filter((account) => account.host === host && account.authenticated); | |
| 96 | return ( | |
| 97 | usable.find((account) => account.environmentVariable !== null) ?? | |
| 98 | (choice?.account === undefined | |
| 99 | ? undefined | |
| 100 | : usable.find((account) => account.account === choice.account)) ?? | |
| 101 | findAuthenticatedGitHubAccount(usable) | |
| 102 | ); | |
| 103 | } |
apps/server/src/sourceControl/gitHubPullRequests.ts deleted-145| ... | ... | @@ -1,145 +0,0 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 9 | ||
| 10 | export interface NormalizedGitHubPullRequestRecord { | |
| 11 | readonly number: number; | |
| 12 | readonly title: string; | |
| 13 | readonly url: string; | |
| 14 | readonly baseRefName: string; | |
| 15 | readonly headRefName: string; | |
| 16 | /** The head commit, when the read asked for `headRefOid`. */ | |
| 17 | readonly headSha?: string; | |
| 18 | readonly state: "open" | "closed" | "merged"; | |
| 19 | readonly isDraft?: boolean; | |
| 20 | readonly closedAt?: string | null; | |
| 21 | readonly mergedAt?: string | null; | |
| 22 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 23 | readonly isCrossRepository?: boolean; | |
| 24 | readonly headRepositoryNameWithOwner?: string | null; | |
| 25 | readonly headRepositoryOwnerLogin?: string | null; | |
| 26 | } | |
| 27 | ||
| 28 | const GitHubPullRequestSchema = Schema.Struct({ | |
| 29 | number: PositiveInt, | |
| 30 | title: TrimmedNonEmptyString, | |
| 31 | url: TrimmedNonEmptyString, | |
| 32 | baseRefName: TrimmedNonEmptyString, | |
| 33 | headRefName: TrimmedNonEmptyString, | |
| 34 | headRefOid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 36 | isDraft: Schema.optional(Schema.Boolean), | |
| 37 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 38 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 39 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 40 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 41 | // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was | |
| 42 | // added later. Both fields stay optional so a version-drifted gh CLI can | |
| 43 | // never fail the decode and silently drop the PR from the list. | |
| 44 | headRepository: Schema.optional( | |
| 45 | Schema.NullOr( | |
| 46 | Schema.Struct({ | |
| 47 | nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), | |
| 48 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 49 | }), | |
| 50 | ), | |
| 51 | ), | |
| 52 | headRepositoryOwner: Schema.optional( | |
| 53 | Schema.NullOr( | |
| 54 | Schema.Struct({ | |
| 55 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 56 | }), | |
| 57 | ), | |
| 58 | ), | |
| 59 | }); | |
| 60 | ||
| 61 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 62 | const trimmed = value?.trim() ?? ""; | |
| 63 | return trimmed.length > 0 ? trimmed : null; | |
| 64 | } | |
| 65 | ||
| 66 | function normalizeGitHubPullRequestState(input: { | |
| 67 | state?: string | null | undefined; | |
| 68 | mergedAt?: string | null | undefined; | |
| 69 | }): "open" | "closed" | "merged" { | |
| 70 | const normalizedState = input.state?.trim().toUpperCase(); | |
| 71 | if ( | |
| 72 | (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || | |
| 73 | normalizedState === "MERGED" | |
| 74 | ) { | |
| 75 | return "merged"; | |
| 76 | } | |
| 77 | if (normalizedState === "CLOSED") { | |
| 78 | return "closed"; | |
| 79 | } | |
| 80 | return "open"; | |
| 81 | } | |
| 82 | ||
| 83 | function normalizeGitHubPullRequestRecord( | |
| 84 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | |
| 85 | ): NormalizedGitHubPullRequestRecord { | |
| 86 | const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | |
| 87 | const headRepositoryName = trimOptionalString(raw.headRepository?.name); | |
| 88 | const headRepositoryOwnerLogin = | |
| 89 | trimOptionalString(raw.headRepositoryOwner?.login) ?? | |
| 90 | (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); | |
| 91 | const headRepositoryNameWithOwner = | |
| 92 | explicitNameWithOwner ?? | |
| 93 | (headRepositoryOwnerLogin && headRepositoryName | |
| 94 | ? `${headRepositoryOwnerLogin}/${headRepositoryName}` | |
| 95 | : null); | |
| 96 | const headSha = trimOptionalString(raw.headRefOid); | |
| 97 | ||
| 98 | return { | |
| 99 | number: raw.number, | |
| 100 | title: raw.title, | |
| 101 | url: raw.url, | |
| 102 | baseRefName: raw.baseRefName, | |
| 103 | headRefName: raw.headRefName, | |
| 104 | ...(headSha ? { headSha } : {}), | |
| 105 | state: normalizeGitHubPullRequestState(raw), | |
| 106 | ...(raw.isDraft === true ? { isDraft: true } : {}), | |
| 107 | closedAt: raw.closedAt ?? null, | |
| 108 | mergedAt: raw.mergedAt ?? null, | |
| 109 | updatedAt: raw.updatedAt ?? Option.none(), | |
| 110 | ...(typeof raw.isCrossRepository === "boolean" | |
| 111 | ? { isCrossRepository: raw.isCrossRepository } | |
| 112 | : {}), | |
| 113 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 114 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 119 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 120 | ||
| 121 | /** | |
| 122 | * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is | |
| 123 | * skipped, so one malformed pull request cannot hide the rest. | |
| 124 | */ | |
| 125 | export function decodeGitHubPullRequestEntries( | |
| 126 | entries: ReadonlyArray<unknown>, | |
| 127 | ): ReadonlyArray<NormalizedGitHubPullRequestRecord> { | |
| 128 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | |
| 129 | for (const entry of entries) { | |
| 130 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | |
| 131 | if (Exit.isSuccess(decodedEntry)) { | |
| 132 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | |
| 133 | } | |
| 134 | } | |
| 135 | return pullRequests; | |
| 136 | } | |
| 137 | ||
| 138 | export function decodeGitHubPullRequestListJson( | |
| 139 | raw: string, | |
| 140 | ): Result.Result< | |
| 141 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 142 | Cause.Cause<Schema.SchemaError> | |
| 143 | > { | |
| 144 | return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); | |
| 145 | } |
apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts deleted-87| ... | ... | @@ -1,87 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitHubApiHostForRemote, | |
| 5 | selectGitHubBaseRepository, | |
| 6 | } from "./gitHubRepositoryResolution.ts"; | |
| 7 | ||
| 8 | describe("selectGitHubBaseRepository", () => { | |
| 9 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 10 | entries | |
| 11 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 12 | .join("\n"); | |
| 13 | const select = (input: { remotes: string; resolved?: string }) => | |
| 14 | selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 15 | ||
| 16 | it("picks the repository gh reads without a prompt", () => { | |
| 17 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 18 | owner: "me", | |
| 19 | name: "web", | |
| 20 | }); | |
| 21 | const fork = remotes( | |
| 22 | ["origin", "git@github.com:me/web.git"], | |
| 23 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 24 | ); | |
| 25 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 26 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 27 | owner: "me", | |
| 28 | name: "web", | |
| 29 | }); | |
| 30 | assert.deepStrictEqual( | |
| 31 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 32 | { owner: "acme", name: "other" }, | |
| 33 | ); | |
| 34 | // gh ranks remote names in any case. | |
| 35 | assert.deepStrictEqual( | |
| 36 | select({ | |
| 37 | remotes: remotes( | |
| 38 | ["origin", "git@github.com:me/web.git"], | |
| 39 | ["Upstream", "git@github.com:acme/web.git"], | |
| 40 | ), | |
| 41 | }), | |
| 42 | { owner: "acme", name: "web" }, | |
| 43 | ); | |
| 44 | }); | |
| 45 | ||
| 46 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 47 | for (const input of [ | |
| 48 | { remotes: "" }, | |
| 49 | { | |
| 50 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 51 | }, | |
| 52 | { | |
| 53 | remotes: remotes( | |
| 54 | ["origin", "git@github.com:me/web.git"], | |
| 55 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 56 | ), | |
| 57 | }, | |
| 58 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 59 | { | |
| 60 | remotes: remotes( | |
| 61 | ["origin", "git@github.com:me/web.git"], | |
| 62 | ["Origin", "git@github.com:acme/web.git"], | |
| 63 | ), | |
| 64 | }, | |
| 65 | { | |
| 66 | remotes: remotes( | |
| 67 | ["origin", "git@github.com:me/web.git"], | |
| 68 | ["upstream", "git@github.com:acme/web.git"], | |
| 69 | ), | |
| 70 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 71 | }, | |
| 72 | ]) { | |
| 73 | assert.strictEqual(select(input), null); | |
| 74 | } | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | describe("gitHubApiHostForRemote", () => { | |
| 79 | it("reads an SSH alias through github.com and a real host as itself", () => { | |
| 80 | assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); | |
| 81 | assert.strictEqual( | |
| 82 | gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 83 | "github.example.com", | |
| 84 | ); | |
| 85 | assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); | |
| 86 | }); | |
| 87 | }); |
apps/server/src/sourceControl/gitHubRepositoryResolution.ts deleted-192| ... | ... | @@ -1,192 +0,0 @@ |
| 1 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 2 | import { | |
| 3 | detectSourceControlProviderFromRemoteUrl, | |
| 4 | isSshRemoteUrl, | |
| 5 | } from "@t3tools/shared/sourceControl"; | |
| 6 | ||
| 7 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 8 | export interface GitHubRepositoryLocator { | |
| 9 | readonly host: string; | |
| 10 | readonly owner: string; | |
| 11 | readonly name: string; | |
| 12 | } | |
| 13 | ||
| 14 | /** The fetch remotes in `git remote -v` output, by name. */ | |
| 15 | export function parseFetchRemotes( | |
| 16 | remotes: string, | |
| 17 | ): ReadonlyArray<{ readonly name: string; readonly url: string }> { | |
| 18 | return remotes | |
| 19 | .split("\n") | |
| 20 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 21 | .filter((match): match is RegExpExecArray => match !== null) | |
| 22 | .map((match) => ({ name: match[1]!, url: match[2]! })); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 27 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 28 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 29 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 30 | * | |
| 31 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 32 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 33 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 34 | */ | |
| 35 | export function selectGitHubBaseRepository(input: { | |
| 36 | readonly remotes: string; | |
| 37 | readonly resolved: string; | |
| 38 | readonly host: string; | |
| 39 | }): { readonly owner: string; readonly name: string } | null { | |
| 40 | const host = input.host.toLowerCase(); | |
| 41 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 42 | for (const remote of parseFetchRemotes(input.remotes)) { | |
| 43 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); | |
| 44 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 45 | repositories.set(remote.name, { owner, name }); | |
| 46 | } | |
| 47 | const marks = input.resolved | |
| 48 | .split("\n") | |
| 49 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 50 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 51 | if (marks.length > 1) return null; | |
| 52 | const [mark] = marks; | |
| 53 | if (mark) { | |
| 54 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 55 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 56 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 57 | } | |
| 58 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 59 | // top place has no defined winner. | |
| 60 | const score = (remoteName: string) => | |
| 61 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 62 | const ranked = [...repositories.entries()].toSorted( | |
| 63 | ([left], [right]) => score(right) - score(left), | |
| 64 | ); | |
| 65 | const [top, next] = ranked; | |
| 66 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 67 | ? top[1] | |
| 68 | : null; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, | |
| 73 | * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller | |
| 74 | * names when no repository was found there. GH_REPO is the caller's to check first. | |
| 75 | */ | |
| 76 | export function resolveGitHubRepository(input: { | |
| 77 | readonly remotes: string; | |
| 78 | readonly resolved: string; | |
| 79 | /** The checkout's GitHub API host, when the caller already knows it. */ | |
| 80 | readonly hostHint: string | undefined; | |
| 81 | readonly defaultHost: string; | |
| 82 | }): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { | |
| 83 | const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ | |
| 84 | ...remote, | |
| 85 | host: gitHubApiHostForRemote(remote.url), | |
| 86 | })); | |
| 87 | const host = | |
| 88 | (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? | |
| 89 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 90 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 91 | input.defaultHost; | |
| 92 | const selected = selectGitHubBaseRepository({ | |
| 93 | remotes: input.remotes, | |
| 94 | resolved: input.resolved, | |
| 95 | host, | |
| 96 | }); | |
| 97 | if (selected !== null) return { host, locator: { host, ...selected } }; | |
| 98 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 99 | // the ones on this host (an SSH alias counts as its API host). | |
| 100 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 101 | const candidates = fetchRemotes | |
| 102 | .filter( | |
| 103 | (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host, | |
| 104 | ) | |
| 105 | .toSorted((left, right) => { | |
| 106 | const l = rank(left.name); | |
| 107 | const r = rank(right.name); | |
| 108 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 109 | }); | |
| 110 | for (const remote of candidates) { | |
| 111 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 112 | if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; | |
| 113 | } | |
| 114 | return { host, locator: null }; | |
| 115 | } | |
| 116 | ||
| 117 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 118 | export function parseGitHubRepositorySelector( | |
| 119 | selector: string, | |
| 120 | defaultHost: string, | |
| 121 | ): GitHubRepositoryLocator | null { | |
| 122 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 123 | if (/^https?:\/\//i.test(trimmed)) { | |
| 124 | try { | |
| 125 | const url = new URL(trimmed); | |
| 126 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 127 | return owner && name && rest.length === 0 | |
| 128 | ? { host: url.host.toLowerCase(), owner, name } | |
| 129 | : null; | |
| 130 | } catch { | |
| 131 | return null; | |
| 132 | } | |
| 133 | } | |
| 134 | const parts = trimmed.split("/").filter(Boolean); | |
| 135 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 136 | if (parts.length === 3) | |
| 137 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 138 | return null; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 143 | * URL, or a branch name. | |
| 144 | */ | |
| 145 | export function parsePullRequestReference( | |
| 146 | reference: string, | |
| 147 | ): | |
| 148 | | { readonly kind: "number"; readonly number: number } | |
| 149 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 150 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 151 | const trimmed = reference.trim(); | |
| 152 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 153 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 154 | if (/^https?:\/\//i.test(trimmed)) { | |
| 155 | try { | |
| 156 | const url = new URL(trimmed); | |
| 157 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 158 | if (match) { | |
| 159 | return { | |
| 160 | kind: "url", | |
| 161 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 162 | number: Number(match[3]), | |
| 163 | }; | |
| 164 | } | |
| 165 | } catch { | |
| 166 | // Not a URL after all; read it as a branch. | |
| 167 | } | |
| 168 | } | |
| 169 | return { kind: "branch", headSelector: trimmed }; | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 174 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 175 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 176 | */ | |
| 177 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 178 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 179 | if (provider === null) return null; | |
| 180 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 181 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 182 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 183 | return host.includes("github") ? "github.com" : null; | |
| 184 | } | |
| 185 | return provider.kind === "github" ? host : null; | |
| 186 | } | |
| 187 | ||
| 188 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 189 | function apiHostForHint(host: string): string { | |
| 190 | const normalized = host.toLowerCase(); | |
| 191 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 192 | } |
apps/server/src/sourceControl/githubGraphQl.test.ts deleted-105| ... | ... | @@ -1,105 +0,0 @@ |
| 1 | import { assert, describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; | |
| 5 | ||
| 6 | describe("aliasedGraphQlDocument", () => { | |
| 7 | it("keeps every value out of the document, each under its alias's own variable", () => { | |
| 8 | const document = aliasedGraphQlDocument({ | |
| 9 | operation: "query", | |
| 10 | name: "Lookups", | |
| 11 | alias: "s", | |
| 12 | items: ['acme") { x } #', "web"], | |
| 13 | shared: { owner: ["String!", "acme"] }, | |
| 14 | variables: (repo) => ({ name: ["String!", repo] }), | |
| 15 | field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, | |
| 16 | }); | |
| 17 | assert.isNotNull(document); | |
| 18 | expect(document!.query).toBe( | |
| 19 | "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + | |
| 20 | " s0: repository(owner: $owner, name: $s0_name) { id }\n" + | |
| 21 | " s1: repository(owner: $owner, name: $s1_name) { id }\n}", | |
| 22 | ); | |
| 23 | expect(document!.variables).toEqual({ | |
| 24 | owner: "acme", | |
| 25 | s0_name: 'acme") { x } #', | |
| 26 | s1_name: "web", | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | it("aliases by key and nests inside a shared parent when asked", () => { | |
| 31 | const document = aliasedGraphQlDocument({ | |
| 32 | operation: "query", | |
| 33 | alias: "pr", | |
| 34 | key: (number) => number, | |
| 35 | items: [2, 3], | |
| 36 | variables: (number) => ({ number: ["Int!", number] }), | |
| 37 | field: ({ number }) => `pullRequest(number: ${number}) { id }`, | |
| 38 | within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, | |
| 39 | }); | |
| 40 | expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); | |
| 41 | expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); | |
| 42 | expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); | |
| 43 | }); | |
| 44 | ||
| 45 | it("asks for nothing when there is nothing to ask", () => { | |
| 46 | expect( | |
| 47 | aliasedGraphQlDocument({ | |
| 48 | operation: "mutation", | |
| 49 | alias: "f", | |
| 50 | items: [], | |
| 51 | variables: () => ({}), | |
| 52 | field: () => "x", | |
| 53 | }), | |
| 54 | ).toBeNull(); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("readGraphQlPages", () => { | |
| 59 | const pages = (cursors: ReadonlyArray<string | null>) => { | |
| 60 | const asked: Array<string | null> = []; | |
| 61 | const read = (after: string | null) => | |
| 62 | Effect.sync(() => { | |
| 63 | asked.push(after); | |
| 64 | return { next: cursors[asked.length - 1] ?? null }; | |
| 65 | }); | |
| 66 | return { asked, read }; | |
| 67 | }; | |
| 68 | ||
| 69 | it.effect("follows the cursor until GitHub has no more", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const { asked, read } = pages(["a", "b", null]); | |
| 72 | const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); | |
| 73 | expect(asked).toEqual([null, "a", "b"]); | |
| 74 | expect(result.truncated).toBe(false); | |
| 75 | }), | |
| 76 | ); | |
| 77 | ||
| 78 | it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => | |
| 79 | Effect.gen(function* () { | |
| 80 | const limited = pages(["a", "b", "c"]); | |
| 81 | const atLimit = yield* readGraphQlPages(limited.read, { | |
| 82 | nextCursor: (page) => page.next, | |
| 83 | maxPages: 2, | |
| 84 | }); | |
| 85 | expect(limited.asked).toEqual([null, "a"]); | |
| 86 | expect(atLimit.truncated).toBe(true); | |
| 87 | ||
| 88 | const enough = pages(["a", "b", "c"]); | |
| 89 | const satisfied = yield* readGraphQlPages(enough.read, { | |
| 90 | nextCursor: (page) => page.next, | |
| 91 | until: (read) => read.length === 1, | |
| 92 | }); | |
| 93 | expect(enough.asked).toEqual([null]); | |
| 94 | expect(satisfied.truncated).toBe(true); | |
| 95 | ||
| 96 | const stuck = pages(["a", "a", "a"]); | |
| 97 | const repeated = yield* readGraphQlPages(stuck.read, { | |
| 98 | nextCursor: (page) => page.next, | |
| 99 | from: "start", | |
| 100 | }); | |
| 101 | expect(stuck.asked).toEqual(["start", "a"]); | |
| 102 | expect(repeated.truncated).toBe(true); | |
| 103 | }), | |
| 104 | ); | |
| 105 | }); |
apps/server/src/sourceControl/githubGraphQl.ts deleted-100| ... | ... | @@ -1,100 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | ||
| 3 | /** GraphQL variables by name, each with the type it is declared as and the value it carries. */ | |
| 4 | export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>; | |
| 5 | ||
| 6 | export interface GraphQlDocument { | |
| 7 | readonly query: string; | |
| 8 | readonly variables: Readonly<Record<string, unknown>>; | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * One document asking the same field for many items, each under its own alias: the prefix and | |
| 13 | * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub | |
| 14 | * has no bulk form of most lookups, so aliases are how a batch travels in one request. | |
| 15 | * | |
| 16 | * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those | |
| 17 | * placeholders rather than the values: nothing an item holds is written into the document. | |
| 18 | * `shared` variables are declared once and referenced by their own name; `within` nests the aliased | |
| 19 | * fields inside one parent selection, such as a single repository. | |
| 20 | * | |
| 21 | * Null for no items, since a document with no selection is not a document. | |
| 22 | */ | |
| 23 | export function aliasedGraphQlDocument<Item, Variables extends GraphQlVariables>(input: { | |
| 24 | readonly operation: "query" | "mutation"; | |
| 25 | /** The operation's name, which tells one batch from another in logs and recorded traffic. */ | |
| 26 | readonly name?: string; | |
| 27 | readonly alias: string; | |
| 28 | readonly key?: (item: Item, index: number) => number; | |
| 29 | readonly items: ReadonlyArray<Item>; | |
| 30 | readonly variables: (item: Item) => Variables; | |
| 31 | readonly field: ( | |
| 32 | placeholders: { readonly [Name in keyof Variables]: string }, | |
| 33 | item: Item, | |
| 34 | ) => string; | |
| 35 | readonly shared?: GraphQlVariables; | |
| 36 | readonly within?: (fields: string) => string; | |
| 37 | }): GraphQlDocument | null { | |
| 38 | if (input.items.length === 0) return null; | |
| 39 | const declarations: string[] = []; | |
| 40 | const variables: Record<string, unknown> = {}; | |
| 41 | for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { | |
| 42 | declarations.push(`$${name}: ${type}`); | |
| 43 | variables[name] = value; | |
| 44 | } | |
| 45 | const fields = input.items.map((item, index) => { | |
| 46 | const alias = `${input.alias}${input.key?.(item, index) ?? index}`; | |
| 47 | const placeholders: Record<string, string> = {}; | |
| 48 | for (const [name, [type, value]] of Object.entries(input.variables(item))) { | |
| 49 | const variable = `${alias}_${name}`; | |
| 50 | declarations.push(`$${variable}: ${type}`); | |
| 51 | variables[variable] = value; | |
| 52 | placeholders[name] = `$${variable}`; | |
| 53 | } | |
| 54 | return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; | |
| 55 | }); | |
| 56 | const selection = fields.join("\n"); | |
| 57 | const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; | |
| 58 | return { | |
| 59 | query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, | |
| 60 | variables, | |
| 61 | }; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * The pages of a GraphQL connection, read one after another from `from` (the first page by | |
| 66 | * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is | |
| 67 | * handed the pages so far, for reads that size or check the next page by what has already arrived. | |
| 68 | * | |
| 69 | * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time | |
| 70 | * would page forever, so it stops the read as truncated too. | |
| 71 | */ | |
| 72 | export const readGraphQlPages = <Page, E, R>( | |
| 73 | read: (after: string | null, pages: ReadonlyArray<Page>) => Effect.Effect<Page, E, R>, | |
| 74 | options: { | |
| 75 | readonly nextCursor: (page: NoInfer<Page>) => string | null; | |
| 76 | readonly from?: string | null; | |
| 77 | readonly maxPages?: number; | |
| 78 | readonly until?: (pages: ReadonlyArray<NoInfer<Page>>) => boolean; | |
| 79 | }, | |
| 80 | ): Effect.Effect<{ readonly pages: ReadonlyArray<Page>; readonly truncated: boolean }, E, R> => | |
| 81 | Effect.gen(function* () { | |
| 82 | const pages: Page[] = []; | |
| 83 | const seen = new Set<string>(); | |
| 84 | let after = options.from ?? null; | |
| 85 | while (true) { | |
| 86 | const page: Page = yield* read(after, pages); | |
| 87 | pages.push(page); | |
| 88 | const next = options.nextCursor(page); | |
| 89 | if (next === null) return { pages, truncated: false }; | |
| 90 | if ( | |
| 91 | seen.has(next) || | |
| 92 | pages.length >= (options.maxPages ?? Infinity) || | |
| 93 | options.until?.(pages) === true | |
| 94 | ) { | |
| 95 | return { pages, truncated: true }; | |
| 96 | } | |
| 97 | seen.add(next); | |
| 98 | after = next; | |
| 99 | } | |
| 100 | }); |
apps/server/src/sourceControl/githubQuota.test.ts deleted-86| ... | ... | @@ -1,86 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | ||
| 5 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 6 | import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 7 | ||
| 8 | const RESET = Date.parse("2099-08-13T14:00:00Z"); | |
| 9 | const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ | |
| 10 | "x-ratelimit-resource": resource, | |
| 11 | "x-ratelimit-limit": "5000", | |
| 12 | "x-ratelimit-remaining": String(remaining), | |
| 13 | "x-ratelimit-reset": String(reset / 1000), | |
| 14 | }); | |
| 15 | ||
| 16 | describe("GitHubQuota", () => { | |
| 17 | it.effect("refuses a background request below the reserve, per quota and account", () => | |
| 18 | Effect.gen(function* () { | |
| 19 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 20 | yield* quota.observe("github.com", headers(499)); | |
| 21 | const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 22 | assert.strictEqual(refused.retryAt, RESET); | |
| 23 | // Another quota, another host and another account each have their own balance. | |
| 24 | yield* quota.admit("github.com", "core"); | |
| 25 | yield* quota.admit("ghe.example", "graphql"); | |
| 26 | yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); | |
| 27 | yield* quota.admit("github.com", "graphql", { allowReserve: true }); | |
| 28 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 29 | ); | |
| 30 | ||
| 31 | it.effect("takes the latest balance in a window, and ignores an older window", () => | |
| 32 | Effect.gen(function* () { | |
| 33 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 34 | yield* quota.observe("github.com", headers(400)); | |
| 35 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 36 | // GitHub can report more left later in the same window, from another region. | |
| 37 | yield* quota.observe("github.com", headers(900)); | |
| 38 | yield* quota.admit("github.com", "graphql"); | |
| 39 | yield* quota.observe("github.com", headers(400)); | |
| 40 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 41 | // An answer from an older window says nothing about this one. | |
| 42 | yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); | |
| 43 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 44 | // A later window replaces it. | |
| 45 | yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); | |
| 46 | yield* quota.admit("github.com", "graphql"); | |
| 47 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 48 | ); | |
| 49 | ||
| 50 | it.effect("lets interactive requests spend the reserve but not an empty quota", () => | |
| 51 | Effect.gen(function* () { | |
| 52 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 53 | yield* quota.observe("github.com", headers(1)); | |
| 54 | yield* quota.admit("github.com", "graphql", { allowReserve: true }); | |
| 55 | yield* quota.observe("github.com", headers(0)); | |
| 56 | const refused = yield* Effect.flip( | |
| 57 | quota.admit("github.com", "graphql", { allowReserve: true }), | |
| 58 | ); | |
| 59 | assert.strictEqual(refused.retryAt, RESET); | |
| 60 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 61 | ); | |
| 62 | ||
| 63 | it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 66 | yield* quota.observe("github.com", headers(501, "core")); | |
| 67 | // Conditional reads answer 304 with the balance unchanged, however many are sent. | |
| 68 | for (let index = 0; index < 5; index++) { | |
| 69 | yield* quota.admit("github.com", "core"); | |
| 70 | yield* quota.observe("github.com", headers(501, "core")); | |
| 71 | } | |
| 72 | yield* quota.admit("github.com", "core"); | |
| 73 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 74 | ); | |
| 75 | ||
| 76 | it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => | |
| 77 | Effect.gen(function* () { | |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 79 | yield* quota.observe("github.com", headers(0)); | |
| 80 | yield* TestClock.setTime(RESET); | |
| 81 | yield* quota.admit("github.com", "graphql"); | |
| 82 | yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); | |
| 83 | yield* quota.admit("other.example", "graphql"); | |
| 84 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 85 | ); | |
| 86 | }); |
apps/server/src/sourceControl/githubQuota.ts deleted-122| ... | ... | @@ -1,122 +0,0 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | ||
| 7 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 8 | ||
| 9 | /** The share of each quota a background read leaves for a user's next click. */ | |
| 10 | const RESERVE_RATIO = 0.1; | |
| 11 | ||
| 12 | /** | |
| 13 | * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). | |
| 14 | * Each is spent and reset on its own, so each is tracked on its own. | |
| 15 | */ | |
| 16 | type GitHubQuotaResource = string; | |
| 17 | ||
| 18 | interface QuotaSnapshot { | |
| 19 | readonly limit: number; | |
| 20 | readonly remaining: number; | |
| 21 | readonly resetAtMs: number; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer | |
| 26 | * carries. Null when the answer named no quota, which an Enterprise host with limits off does. | |
| 27 | */ | |
| 28 | function quotaFromHeaders( | |
| 29 | headers: Readonly<Record<string, string | undefined>>, | |
| 30 | ): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { | |
| 31 | const resource = headers["x-ratelimit-resource"]?.trim(); | |
| 32 | const limit = Number(headers["x-ratelimit-limit"]); | |
| 33 | const remaining = Number(headers["x-ratelimit-remaining"]); | |
| 34 | const reset = Number(headers["x-ratelimit-reset"]); | |
| 35 | if ( | |
| 36 | !resource || | |
| 37 | !Number.isFinite(limit) || | |
| 38 | limit <= 0 || | |
| 39 | !Number.isFinite(remaining) || | |
| 40 | remaining < 0 || | |
| 41 | !Number.isFinite(reset) | |
| 42 | ) { | |
| 43 | return null; | |
| 44 | } | |
| 45 | return { resource, limit, remaining, resetAtMs: reset * 1_000 }; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that | |
| 50 | * would dip below it is refused until the quota resets, so a user's next click still has budget. | |
| 51 | * | |
| 52 | * The balance is GitHub's own count from the latest answer's headers, so it already includes | |
| 53 | * what anything else spent with the same token, such as an agent's `gh` commands. Requests in | |
| 54 | * flight together all see that one balance; the reserve absorbs what they spend before their | |
| 55 | * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub | |
| 56 | * spends nothing, and a local count would drift below GitHub's for the rest of the window. | |
| 57 | * | |
| 58 | * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. | |
| 59 | */ | |
| 60 | export class GitHubQuota extends Context.Service< | |
| 61 | GitHubQuota, | |
| 62 | { | |
| 63 | readonly admit: ( | |
| 64 | host: string, | |
| 65 | resource: GitHubQuotaResource, | |
| 66 | options?: { readonly allowReserve: boolean }, | |
| 67 | ) => Effect.Effect<void, SourceControlRateLimit.SourceControlRateLimitPausedError>; | |
| 68 | readonly observe: ( | |
| 69 | host: string, | |
| 70 | headers: Readonly<Record<string, string | undefined>>, | |
| 71 | ) => Effect.Effect<void>; | |
| 72 | } | |
| 73 | >()("t3/sourceControl/githubQuota") {} | |
| 74 | ||
| 75 | const make = Effect.gen(function* () { | |
| 76 | const snapshots = yield* Ref.make<ReadonlyMap<string, QuotaSnapshot>>(new Map()); | |
| 77 | const keyOf = (host: string, resource: string, scope: string) => | |
| 78 | `${host.trim().toLowerCase()}\0${resource}\0${scope}`; | |
| 79 | ||
| 80 | const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( | |
| 81 | function* (host, resource, options) { | |
| 82 | const now = yield* Clock.currentTimeMillis; | |
| 83 | const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); | |
| 84 | const snapshot = (yield* Ref.get(snapshots)).get(key); | |
| 85 | if (snapshot === undefined || snapshot.resetAtMs <= now) return; | |
| 86 | const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; | |
| 87 | if (snapshot.remaining >= floor) return; | |
| 88 | return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ | |
| 89 | provider: "github", | |
| 90 | host: host.trim().toLowerCase(), | |
| 91 | retryAt: snapshot.resetAtMs, | |
| 92 | }); | |
| 93 | }, | |
| 94 | ); | |
| 95 | ||
| 96 | const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( | |
| 97 | function* (host, headers) { | |
| 98 | const quota = quotaFromHeaders(headers); | |
| 99 | if (quota === null) return; | |
| 100 | const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); | |
| 101 | yield* Ref.update(snapshots, (current) => { | |
| 102 | const previous = current.get(key); | |
| 103 | // The latest answer wins. GitHub serves requests from several regions, so a later answer | |
| 104 | // can report more left in the same window, and keeping the lower one would hold | |
| 105 | // background work at the reserve until the reset. An answer from an older window says | |
| 106 | // nothing of this one. | |
| 107 | if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; | |
| 108 | const next = new Map(current); | |
| 109 | next.set(key, { | |
| 110 | limit: quota.limit, | |
| 111 | remaining: quota.remaining, | |
| 112 | resetAtMs: quota.resetAtMs, | |
| 113 | }); | |
| 114 | return next; | |
| 115 | }); | |
| 116 | }, | |
| 117 | ); | |
| 118 | ||
| 119 | return GitHubQuota.of({ admit, observe }); | |
| 120 | }); | |
| 121 | ||
| 122 | export const layer = Layer.effect(GitHubQuota, make); |
apps/server/src/sourceControl/githubStackRebase.test.ts created+173| ... | ... | @@ -0,0 +1,173 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | ||
| 8 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; | |
| 9 | import { cascadeRebaseStack } from "@t3tools/source-control-github/server/githubStackRebase"; | |
| 10 | ||
| 11 | import * as ServerSettings from "../serverSettings.ts"; | |
| 12 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 13 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 15 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | |
| 16 | ||
| 17 | const layer = Layer.mergeAll( | |
| 18 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 19 | credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), | |
| 20 | }), | |
| 21 | // The cascade runs real git through the server's host; the git driver itself is never asked. | |
| 22 | ServerSourceControlHost.layer.pipe( | |
| 23 | Layer.provide(ServerSettings.layerTest()), | |
| 24 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 25 | Layer.provide(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), | |
| 26 | Layer.provideMerge(VcsProcess.layer), | |
| 27 | ), | |
| 28 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 29 | ||
| 30 | /** | |
| 31 | * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a | |
| 32 | * second layer sits on the first. Returns each branch's head. | |
| 33 | */ | |
| 34 | const setup = Effect.gen(function* () { | |
| 35 | const fs = yield* FileSystem.FileSystem; | |
| 36 | const process = yield* VcsProcess.VcsProcess; | |
| 37 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); | |
| 38 | const remote = `${root}/remote.git`; | |
| 39 | const work = `${root}/work`; | |
| 40 | const git = (cwd: string, ...args: string[]) => | |
| 41 | process | |
| 42 | .run({ | |
| 43 | operation: "test", | |
| 44 | command: "git", | |
| 45 | args, | |
| 46 | cwd, | |
| 47 | env: { | |
| 48 | GIT_AUTHOR_NAME: "t", | |
| 49 | GIT_AUTHOR_EMAIL: "t@t", | |
| 50 | GIT_COMMITTER_NAME: "t", | |
| 51 | GIT_COMMITTER_EMAIL: "t@t", | |
| 52 | }, | |
| 53 | }) | |
| 54 | .pipe(Effect.map((out) => out.stdout.trim())); | |
| 55 | const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); | |
| 56 | yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); | |
| 57 | yield* git(root, "clone", "--quiet", remote, work); | |
| 58 | yield* write("index.ts", "export const answer = 41;\n"); | |
| 59 | yield* git(work, "add", "-A"); | |
| 60 | yield* git(work, "commit", "--quiet", "-m", "Initial"); | |
| 61 | yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); | |
| 62 | yield* write("index.ts", "export const answer = 42;\n"); | |
| 63 | yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); | |
| 64 | yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); | |
| 65 | yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); | |
| 66 | yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); | |
| 67 | yield* git(work, "switch", "--quiet", "main"); | |
| 68 | yield* write("README.md", "main moved ahead\n"); | |
| 69 | yield* git(work, "add", "-A"); | |
| 70 | yield* git(work, "commit", "--quiet", "-m", "Docs"); | |
| 71 | yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); | |
| 72 | const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); | |
| 73 | return { | |
| 74 | remote, | |
| 75 | head, | |
| 76 | git: (...args: string[]) => git(remote, ...args), | |
| 77 | layers: [ | |
| 78 | { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, | |
| 79 | { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, | |
| 80 | ], | |
| 81 | }; | |
| 82 | }); | |
| 83 | ||
| 84 | it.layer(layer)("cascadeRebaseStack", (it) => { | |
| 85 | it.effect("moves each layer's own commits onto the rebased layer below it", () => | |
| 86 | Effect.gen(function* () { | |
| 87 | const repo = yield* setup; | |
| 88 | const completed = yield* cascadeRebaseStack({ | |
| 89 | host: "github.com", | |
| 90 | repository: "acme/web", | |
| 91 | base: "main", | |
| 92 | layers: repo.layers, | |
| 93 | remote: repo.remote, | |
| 94 | }); | |
| 95 | assert.strictEqual(completed, 2); | |
| 96 | ||
| 97 | const main = yield* repo.head("main"); | |
| 98 | const bottom = yield* repo.head("feat/answer-42"); | |
| 99 | const top = yield* repo.head("feat/answer-doc"); | |
| 100 | // Bottom sits on the new main; top sits on the new bottom with only its own commit. | |
| 101 | expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); | |
| 102 | expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); | |
| 103 | expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( | |
| 104 | "B: document the answer", | |
| 105 | ); | |
| 106 | }).pipe(Effect.scoped), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect("refuses to overwrite a layer pushed after it was reviewed", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const repo = yield* setup; | |
| 112 | const stale = [ | |
| 113 | repo.layers[0]!, | |
| 114 | // Reviewed at the bottom layer's head, but the branch is really at its own commit. | |
| 115 | { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, | |
| 116 | ]; | |
| 117 | const error = yield* Effect.flip( | |
| 118 | cascadeRebaseStack({ | |
| 119 | host: "github.com", | |
| 120 | repository: "acme/web", | |
| 121 | base: "main", | |
| 122 | layers: stale, | |
| 123 | remote: repo.remote, | |
| 124 | }), | |
| 125 | ); | |
| 126 | expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); | |
| 127 | expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); | |
| 128 | }).pipe(Effect.scoped), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("stops at a conflicting layer and leaves it untouched", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | const repo = yield* setup; | |
| 134 | // Make main conflict with the bottom layer's change to the same line. | |
| 135 | const fs = yield* FileSystem.FileSystem; | |
| 136 | const process = yield* VcsProcess.VcsProcess; | |
| 137 | const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); | |
| 138 | const run = (...args: string[]) => | |
| 139 | process.run({ | |
| 140 | operation: "test", | |
| 141 | command: "git", | |
| 142 | args, | |
| 143 | cwd: scratch, | |
| 144 | env: { | |
| 145 | GIT_AUTHOR_NAME: "t", | |
| 146 | GIT_AUTHOR_EMAIL: "t@t", | |
| 147 | GIT_COMMITTER_NAME: "t", | |
| 148 | GIT_COMMITTER_EMAIL: "t@t", | |
| 149 | }, | |
| 150 | }); | |
| 151 | yield* run("clone", "--quiet", repo.remote, "."); | |
| 152 | yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); | |
| 153 | yield* run("commit", "--quiet", "-am", "Conflict"); | |
| 154 | yield* run("push", "--quiet", "origin", "main"); | |
| 155 | ||
| 156 | const error = yield* Effect.flip( | |
| 157 | cascadeRebaseStack({ | |
| 158 | host: "github.com", | |
| 159 | repository: "acme/web", | |
| 160 | base: "main", | |
| 161 | layers: repo.layers, | |
| 162 | remote: repo.remote, | |
| 163 | }), | |
| 164 | ); | |
| 165 | expect(error).toMatchObject({ | |
| 166 | _tag: "GitHubStackRebaseConflictError", | |
| 167 | number: 1, | |
| 168 | completed: 0, | |
| 169 | }); | |
| 170 | expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); | |
| 171 | }).pipe(Effect.scoped), | |
| 172 | ); | |
| 173 | }); |
apps/server/src/ws.ts+1-7| ... | ... | @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 229 | 229 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 230 | 230 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 231 | 231 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 232 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | |
| 233 | 232 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 234 | 233 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 235 | 234 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -3199,12 +3198,7 @@ export const layer = Layer.unwrap( |
| 3199 | 3198 | SourceControlDiscovery.layer.pipe( |
| 3200 | 3199 | Layer.provide( |
| 3201 | 3200 | SourceControlProviderRegistry.layer.pipe( |
| 3202 | Layer.provide( | |
| 3203 | Layer.mergeAll( | |
| 3204 | GitHubApi.layerWithDependencies, | |
| 3205 | SourceControlBuiltInDrivers.layer, | |
| 3206 | ), | |
| 3207 | ), | |
| 3201 | Layer.provide(SourceControlBuiltInDrivers.layer), | |
| 3208 | 3202 | Layer.provideMerge(GitVcsDriver.layer), |
| 3209 | 3203 | Layer.provide( |
| 3210 | 3204 | VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/source-control-core/src/server/SourceControlHost.ts+15| ... | ... | @@ -54,6 +54,11 @@ export interface SourceControlProcessOutput { |
| 54 | 54 | readonly stderrInvalidUtf8?: boolean; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | export type SourceControlGitOutput = Omit< | |
| 58 | SourceControlProcessOutput, | |
| 59 | "stdoutInvalidUtf8" | "stderrInvalidUtf8" | |
| 60 | >; | |
| 61 | ||
| 57 | 62 | export interface SourceControlGitRemoteBranch { |
| 58 | 63 | readonly cwd: string; |
| 59 | 64 | readonly remoteName: string; |
| ... | ... | @@ -73,6 +78,16 @@ export class SourceControlHost extends Context.Service< |
| 73 | 78 | ) => Effect.Effect<SourceControlProcessOutput, VcsError>; |
| 74 | 79 | }; |
| 75 | 80 | readonly git: { |
| 81 | /** Runs `git -C cwd …args` through the server's git driver, with its limits and metrics. */ | |
| 82 | readonly execute: (input: { | |
| 83 | readonly operation: string; | |
| 84 | readonly cwd: string; | |
| 85 | readonly args: ReadonlyArray<string>; | |
| 86 | }) => Effect.Effect<SourceControlGitOutput, GitCommandError>; | |
| 87 | readonly resolveCommit: (input: { | |
| 88 | readonly cwd: string; | |
| 89 | readonly revision: string; | |
| 90 | }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; | |
| 76 | 91 | /** |
| 77 | 92 | * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository |
| 78 | 93 | * and fails when the server cannot drive one there; the inner one lists its remotes. |
packages/source-control-github/package.json created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/source-control-github", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./server/driver": { | |
| 7 | "types": "./src/server/driver.ts", | |
| 8 | "import": "./src/server/driver.ts" | |
| 9 | }, | |
| 10 | "./server/GitHubApi": { | |
| 11 | "types": "./src/server/GitHubApi.ts", | |
| 12 | "import": "./src/server/GitHubApi.ts" | |
| 13 | }, | |
| 14 | "./server/gitHubAuthStatus": { | |
| 15 | "types": "./src/server/gitHubAuthStatus.ts", | |
| 16 | "import": "./src/server/gitHubAuthStatus.ts" | |
| 17 | }, | |
| 18 | "./server/gitHubConditionalChecks": { | |
| 19 | "types": "./src/server/gitHubConditionalChecks.ts", | |
| 20 | "import": "./src/server/gitHubConditionalChecks.ts" | |
| 21 | }, | |
| 22 | "./server/GitHubCredentials": { | |
| 23 | "types": "./src/server/GitHubCredentials.ts", | |
| 24 | "import": "./src/server/GitHubCredentials.ts" | |
| 25 | }, | |
| 26 | "./server/githubGraphQl": { | |
| 27 | "types": "./src/server/githubGraphQl.ts", | |
| 28 | "import": "./src/server/githubGraphQl.ts" | |
| 29 | }, | |
| 30 | "./server/GitHubPullRequestApi": { | |
| 31 | "types": "./src/server/GitHubPullRequestApi.ts", | |
| 32 | "import": "./src/server/GitHubPullRequestApi.ts" | |
| 33 | }, | |
| 34 | "./server/gitHubPullRequestJson": { | |
| 35 | "types": "./src/server/gitHubPullRequestJson.ts", | |
| 36 | "import": "./src/server/gitHubPullRequestJson.ts" | |
| 37 | }, | |
| 38 | "./server/GitHubPullRequestProvider": { | |
| 39 | "types": "./src/server/GitHubPullRequestProvider.ts", | |
| 40 | "import": "./src/server/GitHubPullRequestProvider.ts" | |
| 41 | }, | |
| 42 | "./server/gitHubPullRequests": { | |
| 43 | "types": "./src/server/gitHubPullRequests.ts", | |
| 44 | "import": "./src/server/gitHubPullRequests.ts" | |
| 45 | }, | |
| 46 | "./server/GitHubQuota": { | |
| 47 | "types": "./src/server/GitHubQuota.ts", | |
| 48 | "import": "./src/server/GitHubQuota.ts" | |
| 49 | }, | |
| 50 | "./server/gitHubRepositoryResolution": { | |
| 51 | "types": "./src/server/gitHubRepositoryResolution.ts", | |
| 52 | "import": "./src/server/gitHubRepositoryResolution.ts" | |
| 53 | }, | |
| 54 | "./server/GitHubSourceControlProvider": { | |
| 55 | "types": "./src/server/GitHubSourceControlProvider.ts", | |
| 56 | "import": "./src/server/GitHubSourceControlProvider.ts" | |
| 57 | }, | |
| 58 | "./server/githubStackActions": { | |
| 59 | "types": "./src/server/githubStackActions.ts", | |
| 60 | "import": "./src/server/githubStackActions.ts" | |
| 61 | }, | |
| 62 | "./server/githubStackRebase": { | |
| 63 | "types": "./src/server/githubStackRebase.ts", | |
| 64 | "import": "./src/server/githubStackRebase.ts" | |
| 65 | } | |
| 66 | }, | |
| 67 | "scripts": { | |
| 68 | "typecheck": "tsc --noEmit", | |
| 69 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 70 | }, | |
| 71 | "dependencies": { | |
| 72 | "@t3tools/contracts": "workspace:*", | |
| 73 | "@t3tools/provider-core": "workspace:*", | |
| 74 | "@t3tools/shared": "workspace:*", | |
| 75 | "@t3tools/source-control-core": "workspace:*", | |
| 76 | "effect": "catalog:" | |
| 77 | }, | |
| 78 | "devDependencies": { | |
| 79 | "@effect/platform-node": "catalog:", | |
| 80 | "@effect/vitest": "catalog:", | |
| 81 | "@t3tools/source-control-testing": "workspace:*", | |
| 82 | "@types/node": "catalog:", | |
| 83 | "vite-plus": "catalog:" | |
| 84 | } | |
| 85 | } |
packages/source-control-github/src/server/GitHubApi.test.ts created+525| ... | ... | @@ -0,0 +1,525 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | import * as PlatformError from "effect/PlatformError"; | |
| 8 | import * as TestClock from "effect/testing/TestClock"; | |
| 9 | import * as Tracer from "effect/Tracer"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; | |
| 12 | import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; | |
| 13 | ||
| 14 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 16 | import * as GitHubQuota from "./GitHubQuota.ts"; | |
| 17 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 18 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 19 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 20 | ||
| 21 | const NOW = Date.parse("2026-10-05T12:00:00.000Z"); | |
| 22 | ||
| 23 | function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { | |
| 24 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 25 | let tokens = ["first", "second"]; | |
| 26 | let invalidations = 0; | |
| 27 | const credentials = Layer.succeed( | |
| 28 | GitHubCredentials.GitHubCredentials, | |
| 29 | GitHubCredentials.GitHubCredentials.of({ | |
| 30 | get: (host) => | |
| 31 | Effect.sync(() => ({ | |
| 32 | host, | |
| 33 | token: Redacted.make(tokens[0]!), | |
| 34 | source: "gh" as const, | |
| 35 | fingerprint: `${host}:${tokens[0]}`, | |
| 36 | })), | |
| 37 | invalidate: () => | |
| 38 | Effect.sync(() => { | |
| 39 | invalidations++; | |
| 40 | tokens = tokens.slice(1); | |
| 41 | }), | |
| 42 | }), | |
| 43 | ); | |
| 44 | const http = Layer.succeed( | |
| 45 | HttpClient.HttpClient, | |
| 46 | HttpClient.make((request) => { | |
| 47 | requests.push(request); | |
| 48 | return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); | |
| 49 | }), | |
| 50 | ); | |
| 51 | const layer = GitHubApi.layer.pipe( | |
| 52 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 53 | Layer.provideMerge(GitHubQuota.layer), | |
| 54 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 55 | ); | |
| 56 | return { layer, requests, invalidations: () => invalidations }; | |
| 57 | } | |
| 58 | ||
| 59 | const json = (body: unknown, init?: ResponseInit) => | |
| 60 | new Response(JSON.stringify(body), { | |
| 61 | ...init, | |
| 62 | headers: { "content-type": "application/json", ...init?.headers }, | |
| 63 | }); | |
| 64 | ||
| 65 | describe("gitHubApiUrls", () => { | |
| 66 | it("maps github.com, GHE.com and GitHub Enterprise Server", () => { | |
| 67 | expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ | |
| 68 | rest: "https://api.github.com", | |
| 69 | graphql: "https://api.github.com/graphql", | |
| 70 | }); | |
| 71 | expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( | |
| 72 | "https://api.acme.ghe.com/graphql", | |
| 73 | ); | |
| 74 | expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ | |
| 75 | rest: "https://git.acme.internal/api/v3", | |
| 76 | graphql: "https://git.acme.internal/api/graphql", | |
| 77 | }); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("environmentToken", () => { | |
| 82 | it("follows gh's precedence per kind of host", () => { | |
| 83 | const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; | |
| 84 | expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); | |
| 85 | expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( | |
| 86 | "github", | |
| 87 | ); | |
| 88 | // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. | |
| 89 | expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); | |
| 90 | expect( | |
| 91 | GitHubCredentials.environmentToken("git.acme.internal", { | |
| 92 | ...env, | |
| 93 | GH_HOST: "git.acme.internal", | |
| 94 | }), | |
| 95 | ).toBe("ghe"); | |
| 96 | expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); | |
| 97 | }); | |
| 98 | }); | |
| 99 | ||
| 100 | describe("GitHubApi", () => { | |
| 101 | it.effect("sends GraphQL with the token, and the document as written", () => { | |
| 102 | const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); | |
| 103 | return Effect.gen(function* () { | |
| 104 | yield* TestClock.setTime(NOW); | |
| 105 | const api = yield* GitHubApi.GitHubApi; | |
| 106 | const body = yield* api.graphql({ | |
| 107 | host: "github.com", | |
| 108 | operation: "viewer", | |
| 109 | query: "query { viewer { login } }", | |
| 110 | }); | |
| 111 | expect(body).toContain('"login":"julius"'); | |
| 112 | expect(requests[0]!.url).toBe("https://api.github.com/graphql"); | |
| 113 | expect(requests[0]!.headers.authorization).toBe("Bearer first"); | |
| 114 | // No `rateLimit` selection is added: the quota is read off the response headers. | |
| 115 | const sent = requests[0]!.body; | |
| 116 | expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( | |
| 117 | '"query":"query { viewer { login } }"', | |
| 118 | ); | |
| 119 | }).pipe(Effect.provide(layer)); | |
| 120 | }); | |
| 121 | ||
| 122 | it.effect( | |
| 123 | "traces the operation, path, query and quota, never the query string or variables", | |
| 124 | () => { | |
| 125 | const spans: Array<Tracer.NativeSpan> = []; | |
| 126 | const tracer = Tracer.make({ | |
| 127 | span: (options) => { | |
| 128 | const span = new Tracer.NativeSpan(options); | |
| 129 | spans.push(span); | |
| 130 | return span; | |
| 131 | }, | |
| 132 | }); | |
| 133 | const { layer } = harness((request) => | |
| 134 | request.url.endsWith("/graphql") | |
| 135 | ? json( | |
| 136 | { data: { viewer: { login: "julius" } } }, | |
| 137 | { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, | |
| 138 | ) | |
| 139 | : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), | |
| 140 | ); | |
| 141 | return Effect.gen(function* () { | |
| 142 | yield* TestClock.setTime(NOW); | |
| 143 | const api = yield* GitHubApi.GitHubApi; | |
| 144 | yield* api.graphql({ | |
| 145 | host: "github.com", | |
| 146 | operation: "getPullRequestDetail", | |
| 147 | query: "query($body: String!) { viewer { login } }", | |
| 148 | variables: { body: "secret user text" }, | |
| 149 | }); | |
| 150 | yield* api.rest({ | |
| 151 | host: "github.com", | |
| 152 | operation: "listWorkflowRuns", | |
| 153 | path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", | |
| 154 | }); | |
| 155 | const byName = (name: string) => spans.filter((span) => span.name === name); | |
| 156 | const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); | |
| 157 | expect(graphql).toMatchObject({ | |
| 158 | "github.operation": "getPullRequestDetail", | |
| 159 | "github.graphql.query": "query($body: String!) { viewer { login } }", | |
| 160 | }); | |
| 161 | expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ | |
| 162 | "github.ratelimit.remaining": 4990, | |
| 163 | "github.ratelimit.resource": "graphql", | |
| 164 | }); | |
| 165 | expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); | |
| 166 | const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); | |
| 167 | expect(rest).toMatchObject({ | |
| 168 | "github.operation": "listWorkflowRuns", | |
| 169 | "github.kind": "rest", | |
| 170 | "url.path": "/repos/acme/web/actions/runs", | |
| 171 | "http.response.status_code": 200, | |
| 172 | "github.ratelimit.remaining": 4800, | |
| 173 | }); | |
| 174 | // Nothing recorded carries the query string, the variables, or the token. | |
| 175 | const everything = spans | |
| 176 | .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) | |
| 177 | .join("\n"); | |
| 178 | expect(everything).not.toContain("head_sha"); | |
| 179 | expect(everything).not.toContain("secret user text"); | |
| 180 | expect(everything).not.toContain("first"); | |
| 181 | expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); | |
| 182 | }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); | |
| 183 | }, | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { | |
| 187 | const { layer } = harness(() => | |
| 188 | json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), | |
| 189 | ); | |
| 190 | return Effect.gen(function* () { | |
| 191 | const api = yield* GitHubApi.GitHubApi; | |
| 192 | const error = yield* Effect.flip( | |
| 193 | api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), | |
| 194 | ); | |
| 195 | expect(error._tag).toBe("GitHubApiResponseError"); | |
| 196 | expect(error.message).toContain("Resource not accessible"); | |
| 197 | }).pipe(Effect.provide(layer)); | |
| 198 | }); | |
| 199 | ||
| 200 | it.effect( | |
| 201 | "reads a NOT_FOUND answer with another error as a failure, not a missing resource", | |
| 202 | () => { | |
| 203 | const { layer } = harness(() => | |
| 204 | json({ | |
| 205 | data: null, | |
| 206 | errors: [ | |
| 207 | { type: "NOT_FOUND", message: "Could not resolve" }, | |
| 208 | { message: "Something went wrong" }, | |
| 209 | ], | |
| 210 | }), | |
| 211 | ); | |
| 212 | return Effect.gen(function* () { | |
| 213 | const api = yield* GitHubApi.GitHubApi; | |
| 214 | const error = yield* Effect.flip( | |
| 215 | api.graphql({ | |
| 216 | host: "github.com", | |
| 217 | operation: "detail", | |
| 218 | query: "query { viewer { id } }", | |
| 219 | }), | |
| 220 | ); | |
| 221 | expect(error._tag).toBe("GitHubApiResponseError"); | |
| 222 | }).pipe(Effect.provide(layer)); | |
| 223 | }, | |
| 224 | ); | |
| 225 | ||
| 226 | it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { | |
| 227 | const reset = Math.floor(NOW / 1000) + 600; | |
| 228 | const { layer, requests } = harness(() => | |
| 229 | json( | |
| 230 | { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, | |
| 231 | { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, | |
| 232 | ), | |
| 233 | ); | |
| 234 | return Effect.gen(function* () { | |
| 235 | yield* TestClock.setTime(NOW); | |
| 236 | const api = yield* GitHubApi.GitHubApi; | |
| 237 | const first = yield* Effect.flip( | |
| 238 | api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), | |
| 239 | ); | |
| 240 | expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); | |
| 241 | const second = yield* Effect.flip( | |
| 242 | api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), | |
| 243 | ); | |
| 244 | expect(second).toMatchObject({ | |
| 245 | _tag: "SourceControlRateLimitPausedError", | |
| 246 | retryAt: reset * 1000, | |
| 247 | }); | |
| 248 | expect(requests).toHaveLength(1); | |
| 249 | }).pipe(Effect.provide(layer)); | |
| 250 | }); | |
| 251 | ||
| 252 | it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { | |
| 253 | const reset = Math.floor(NOW / 1000) + 600; | |
| 254 | const quota = (remaining: number) => ({ | |
| 255 | "x-ratelimit-resource": "core", | |
| 256 | "x-ratelimit-limit": "5000", | |
| 257 | "x-ratelimit-remaining": String(remaining), | |
| 258 | "x-ratelimit-reset": String(reset), | |
| 259 | }); | |
| 260 | let remaining = 499; | |
| 261 | const { layer, requests } = harness((request) => | |
| 262 | request.url.endsWith("/graphql") | |
| 263 | ? json({ data: { viewer: { id: "1" } } }) | |
| 264 | : json({ ok: true }, { headers: quota(remaining--) }), | |
| 265 | ); | |
| 266 | return Effect.gen(function* () { | |
| 267 | yield* TestClock.setTime(NOW); | |
| 268 | const api = yield* GitHubApi.GitHubApi; | |
| 269 | const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; | |
| 270 | // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. | |
| 271 | yield* api.rest(read); | |
| 272 | const refused = yield* Effect.flip(api.rest(read)); | |
| 273 | expect(refused).toMatchObject({ | |
| 274 | _tag: "SourceControlRateLimitPausedError", | |
| 275 | retryAt: reset * 1000, | |
| 276 | }); | |
| 277 | // A user's own request may spend the reserve, and GraphQL has a quota of its own. | |
| 278 | yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 279 | yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); | |
| 280 | expect(requests).toHaveLength(3); | |
| 281 | // The reset gives the background its quota back. | |
| 282 | yield* TestClock.setTime(reset * 1000); | |
| 283 | yield* api.rest(read); | |
| 284 | expect(requests).toHaveLength(4); | |
| 285 | }).pipe(Effect.provide(layer)); | |
| 286 | }); | |
| 287 | ||
| 288 | it.effect("carries GitHub's own reason for a refused REST request", () => { | |
| 289 | const { layer } = harness(() => | |
| 290 | json( | |
| 291 | { | |
| 292 | message: "Validation Failed", | |
| 293 | errors: [ | |
| 294 | { | |
| 295 | resource: "PullRequest", | |
| 296 | code: "custom", | |
| 297 | message: "A pull request already exists for acme:feature.", | |
| 298 | }, | |
| 299 | ], | |
| 300 | }, | |
| 301 | { status: 422 }, | |
| 302 | ), | |
| 303 | ); | |
| 304 | return Effect.gen(function* () { | |
| 305 | const api = yield* GitHubApi.GitHubApi; | |
| 306 | const error = yield* Effect.flip( | |
| 307 | api.rest({ | |
| 308 | host: "github.com", | |
| 309 | operation: "createPullRequest", | |
| 310 | method: "POST", | |
| 311 | path: "repos/acme/web/pulls", | |
| 312 | }), | |
| 313 | ); | |
| 314 | expect(error.message).toBe( | |
| 315 | "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", | |
| 316 | ); | |
| 317 | }).pipe(Effect.provide(layer)); | |
| 318 | }); | |
| 319 | ||
| 320 | it.effect("lets an interactive request through a pause a background read recorded", () => { | |
| 321 | const reset = Math.floor(NOW / 1000) + 600; | |
| 322 | let call = 0; | |
| 323 | const { layer, requests } = harness(() => | |
| 324 | ++call === 1 | |
| 325 | ? json( | |
| 326 | { message: "API rate limit exceeded" }, | |
| 327 | { | |
| 328 | status: 403, | |
| 329 | headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, | |
| 330 | }, | |
| 331 | ) | |
| 332 | : json({ ok: true }), | |
| 333 | ); | |
| 334 | return Effect.gen(function* () { | |
| 335 | yield* TestClock.setTime(NOW); | |
| 336 | const api = yield* GitHubApi.GitHubApi; | |
| 337 | const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; | |
| 338 | yield* Effect.flip(api.rest(read)); | |
| 339 | expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); | |
| 340 | const merged = yield* api | |
| 341 | .rest({ | |
| 342 | host: "github.com", | |
| 343 | operation: "merge", | |
| 344 | method: "PUT", | |
| 345 | path: "repos/acme/web/pulls/7/merge", | |
| 346 | }) | |
| 347 | .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 348 | expect(merged.status).toBe(200); | |
| 349 | expect(requests).toHaveLength(2); | |
| 350 | }).pipe(Effect.provide(layer)); | |
| 351 | }); | |
| 352 | ||
| 353 | it.effect("reads an untyped GraphQL quota error as a rate limit", () => { | |
| 354 | const reset = Math.floor(NOW / 1000) + 900; | |
| 355 | const { layer } = harness(() => | |
| 356 | json( | |
| 357 | { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, | |
| 358 | { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, | |
| 359 | ), | |
| 360 | ); | |
| 361 | return Effect.gen(function* () { | |
| 362 | yield* TestClock.setTime(NOW); | |
| 363 | const api = yield* GitHubApi.GitHubApi; | |
| 364 | const error = yield* Effect.flip( | |
| 365 | api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), | |
| 366 | ); | |
| 367 | expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); | |
| 368 | }).pipe(Effect.provide(layer)); | |
| 369 | }); | |
| 370 | ||
| 371 | it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { | |
| 372 | let call = 0; | |
| 373 | const { layer } = harness(() => | |
| 374 | ++call === 1 | |
| 375 | ? new Response(null, { status: 304 }) | |
| 376 | : json( | |
| 377 | { message: "API rate limit exceeded" }, | |
| 378 | { | |
| 379 | status: 403, | |
| 380 | headers: { "retry-after": "60" }, | |
| 381 | }, | |
| 382 | ), | |
| 383 | ); | |
| 384 | return Effect.gen(function* () { | |
| 385 | yield* TestClock.setTime(NOW); | |
| 386 | const api = yield* GitHubApi.GitHubApi; | |
| 387 | const notModified = yield* api.rest({ | |
| 388 | host: "github.com", | |
| 389 | operation: "checks", | |
| 390 | path: "repos/acme/web/pulls/7", | |
| 391 | ifNoneMatch: '"abc"', | |
| 392 | }); | |
| 393 | expect(notModified.status).toBe(304); | |
| 394 | const error = yield* Effect.flip( | |
| 395 | api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), | |
| 396 | ); | |
| 397 | expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); | |
| 398 | }).pipe(Effect.provide(layer)); | |
| 399 | }); | |
| 400 | ||
| 401 | it.effect("drops a refused token so the next request asks the source again", () => { | |
| 402 | const { layer, requests, invalidations } = harness((request) => | |
| 403 | request.headers.authorization === "Bearer first" | |
| 404 | ? new Response(null, { status: 401 }) | |
| 405 | : json({ ok: true }), | |
| 406 | ); | |
| 407 | return Effect.gen(function* () { | |
| 408 | const api = yield* GitHubApi.GitHubApi; | |
| 409 | const refused = yield* Effect.flip( | |
| 410 | api.rest({ host: "github.com", operation: "user", path: "user" }), | |
| 411 | ); | |
| 412 | expect(refused._tag).toBe("GitHubApiAuthenticationError"); | |
| 413 | expect(invalidations()).toBe(1); | |
| 414 | const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); | |
| 415 | expect(answered.status).toBe(200); | |
| 416 | expect(requests.map((request) => request.headers.authorization)).toEqual([ | |
| 417 | "Bearer first", | |
| 418 | "Bearer second", | |
| 419 | ]); | |
| 420 | }).pipe(Effect.provide(layer)); | |
| 421 | }); | |
| 422 | ||
| 423 | it.effect("refuses to send a pinned credential to another host", () => { | |
| 424 | const { layer, requests } = harness(() => json({})); | |
| 425 | return Effect.gen(function* () { | |
| 426 | const api = yield* GitHubApi.GitHubApi; | |
| 427 | const error = yield* Effect.flip( | |
| 428 | api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), | |
| 429 | ).pipe( | |
| 430 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 431 | host: "github.com", | |
| 432 | token: Redacted.make("pinned"), | |
| 433 | credentialFingerprint: "github.com:pinned", | |
| 434 | }), | |
| 435 | ); | |
| 436 | expect(error._tag).toBe("GitHubApiAuthenticationError"); | |
| 437 | expect(requests).toHaveLength(0); | |
| 438 | }).pipe(Effect.provide(layer)); | |
| 439 | }); | |
| 440 | }); | |
| 441 | ||
| 442 | describe("GitHubCredentials", () => { | |
| 443 | const credentialsWith = (run: SourceControlHost.SourceControlHost["Service"]["process"]["run"]) => | |
| 444 | GitHubCredentials.layer.pipe( | |
| 445 | Layer.provide(TestSourceControlHost.layer({ process: { run } })), | |
| 446 | Layer.provide(NodeCrypto.layer), | |
| 447 | ); | |
| 448 | ||
| 449 | it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => | |
| 450 | Effect.gen(function* () { | |
| 451 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 452 | const error = yield* Effect.flip(credentials.get("git.acme.internal")); | |
| 453 | expect(error._tag).toBe("GitHubCliMissingError"); | |
| 454 | }).pipe( | |
| 455 | Effect.provide( | |
| 456 | credentialsWith(() => | |
| 457 | Effect.fail( | |
| 458 | new VcsProcessSpawnError({ | |
| 459 | operation: "GitHubCredentials.get", | |
| 460 | command: "gh", | |
| 461 | cwd: "/", | |
| 462 | cause: PlatformError.systemError({ | |
| 463 | _tag: "NotFound", | |
| 464 | module: "ChildProcess", | |
| 465 | method: "spawn", | |
| 466 | }), | |
| 467 | }), | |
| 468 | ), | |
| 469 | ), | |
| 470 | ), | |
| 471 | ), | |
| 472 | ); | |
| 473 | ||
| 474 | it.effect("does not cache a gh failure that is not about signing in", () => { | |
| 475 | let calls = 0; | |
| 476 | return Effect.gen(function* () { | |
| 477 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 478 | const first = yield* Effect.flip(credentials.get("github.com")); | |
| 479 | expect(first._tag).toBe("GitHubCliFailedError"); | |
| 480 | expect((yield* credentials.get("github.com")).source).toBe("gh"); | |
| 481 | expect(calls).toBe(2); | |
| 482 | }).pipe( | |
| 483 | Effect.provide( | |
| 484 | credentialsWith(() => | |
| 485 | ++calls === 1 | |
| 486 | ? Effect.fail( | |
| 487 | new VcsProcessTimeoutError({ | |
| 488 | operation: "GitHubCredentials.get", | |
| 489 | command: "gh", | |
| 490 | cwd: "/", | |
| 491 | timeoutMs: 10_000, | |
| 492 | }), | |
| 493 | ) | |
| 494 | : Effect.succeed({ | |
| 495 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 496 | stdout: "token\n", | |
| 497 | stderr: "", | |
| 498 | stdoutTruncated: false, | |
| 499 | stderrTruncated: false, | |
| 500 | }), | |
| 501 | ), | |
| 502 | ), | |
| 503 | ); | |
| 504 | }); | |
| 505 | ||
| 506 | it.effect("fails with GitHubNotSignedInError when gh prints no token", () => | |
| 507 | Effect.gen(function* () { | |
| 508 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 509 | const error = yield* Effect.flip(credentials.get("git.acme.internal")); | |
| 510 | expect(error._tag).toBe("GitHubNotSignedInError"); | |
| 511 | }).pipe( | |
| 512 | Effect.provide( | |
| 513 | credentialsWith(() => | |
| 514 | Effect.succeed({ | |
| 515 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 516 | stdout: "\n", | |
| 517 | stderr: "", | |
| 518 | stdoutTruncated: false, | |
| 519 | stderrTruncated: false, | |
| 520 | }), | |
| 521 | ), | |
| 522 | ), | |
| 523 | ), | |
| 524 | ); | |
| 525 | }); |
packages/source-control-github/src/server/GitHubApi.ts created+600| ... | ... | @@ -0,0 +1,600 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Semaphore from "effect/Semaphore"; | |
| 11 | import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; | |
| 12 | ||
| 13 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 15 | import * as GitHubQuota from "./GitHubQuota.ts"; | |
| 16 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 17 | ||
| 18 | const DEFAULT_TIMEOUT = Duration.seconds(30); | |
| 19 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; | |
| 20 | /** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ | |
| 21 | const CONCURRENCY = 8; | |
| 22 | const API_VERSION = "2022-11-28"; | |
| 23 | ||
| 24 | /** | |
| 25 | * A credential already verified for one host. Every request made under it must target that | |
| 26 | * host, so a page cannot read one account's data with another's token mid-flight. Server-local: | |
| 27 | * never put its value in RPC payloads or cache keys. | |
| 28 | */ | |
| 29 | export const PinnedGitHubCredential = Context.Reference<{ | |
| 30 | readonly host: string; | |
| 31 | readonly token: Redacted.Redacted<string>; | |
| 32 | readonly credentialFingerprint: string; | |
| 33 | } | null>("@t3tools/source-control-github/server/GitHubApi/PinnedGitHubCredential", { | |
| 34 | defaultValue: () => null, | |
| 35 | }); | |
| 36 | ||
| 37 | /** | |
| 38 | * Set by interactive callers (a user's read or write, not a background sweep). Requests made | |
| 39 | * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a | |
| 40 | * pull request should not be refused because a background read exhausted the quota. | |
| 41 | */ | |
| 42 | export const AllowGitHubReserve = Context.Reference<boolean>( | |
| 43 | "@t3tools/source-control-github/server/GitHubApi/AllowGitHubReserve", | |
| 44 | { defaultValue: () => false }, | |
| 45 | ); | |
| 46 | ||
| 47 | export class GitHubApiRequestError extends Schema.TaggedError<GitHubApiRequestError>()( | |
| 48 | "GitHubApiRequestError", | |
| 49 | { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, | |
| 50 | ) { | |
| 51 | override get message(): string { | |
| 52 | return `Could not reach GitHub at ${this.host}.`; | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | export class GitHubApiAuthenticationError extends Schema.TaggedError<GitHubApiAuthenticationError>()( | |
| 57 | "GitHubApiAuthenticationError", | |
| 58 | { host: Schema.String, operation: Schema.String }, | |
| 59 | ) { | |
| 60 | override get message(): string { | |
| 61 | return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export class GitHubApiRateLimitError extends Schema.TaggedError<GitHubApiRateLimitError>()( | |
| 66 | "GitHubApiRateLimitError", | |
| 67 | { | |
| 68 | host: Schema.String, | |
| 69 | operation: Schema.String, | |
| 70 | retryAt: Schema.optionalKey(Schema.Finite), | |
| 71 | }, | |
| 72 | ) { | |
| 73 | override get message(): string { | |
| 74 | return "GitHub API rate limit exceeded."; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | export class GitHubApiNotFoundError extends Schema.TaggedError<GitHubApiNotFoundError>()( | |
| 79 | "GitHubApiNotFoundError", | |
| 80 | { host: Schema.String, operation: Schema.String }, | |
| 81 | ) { | |
| 82 | override get message(): string { | |
| 83 | return "GitHub could not find the requested resource, or the credential cannot see it."; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own | |
| 89 | * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the | |
| 90 | * field and the reason ("A pull request already exists for acme:feature"), never a token. | |
| 91 | */ | |
| 92 | export class GitHubApiResponseError extends Schema.TaggedError<GitHubApiResponseError>()( | |
| 93 | "GitHubApiResponseError", | |
| 94 | { | |
| 95 | host: Schema.String, | |
| 96 | operation: Schema.String, | |
| 97 | status: Schema.Int, | |
| 98 | githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 99 | }, | |
| 100 | ) { | |
| 101 | override get message(): string { | |
| 102 | return this.githubErrors !== undefined && this.githubErrors.length > 0 | |
| 103 | ? `GitHub returned an error: ${this.githubErrors.join("; ")}` | |
| 104 | : `GitHub returned HTTP ${this.status}.`; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export type GitHubApiError = | |
| 109 | | GitHubCredentials.GitHubCredentialUnavailableError | |
| 110 | | GitHubApiRequestError | |
| 111 | | GitHubApiAuthenticationError | |
| 112 | | GitHubApiRateLimitError | |
| 113 | | GitHubApiNotFoundError | |
| 114 | | GitHubApiResponseError | |
| 115 | | SourceControlRateLimit.SourceControlRateLimitPausedError; | |
| 116 | ||
| 117 | export interface GitHubRestResponse { | |
| 118 | readonly status: number; | |
| 119 | readonly headers: Readonly<Record<string, string | undefined>>; | |
| 120 | readonly body: string; | |
| 121 | readonly truncated: boolean; | |
| 122 | /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ | |
| 123 | readonly invalidUtf8: boolean; | |
| 124 | } | |
| 125 | ||
| 126 | export interface GitHubRestInput { | |
| 127 | readonly host: string; | |
| 128 | readonly operation: string; | |
| 129 | readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | |
| 130 | /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ | |
| 131 | readonly path: string; | |
| 132 | readonly body?: unknown; | |
| 133 | /** Defaults to `application/vnd.github+json`. */ | |
| 134 | readonly accept?: string; | |
| 135 | /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ | |
| 136 | readonly ifNoneMatch?: string; | |
| 137 | readonly maxResponseBytes?: number; | |
| 138 | /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ | |
| 139 | readonly timeout?: Duration.Input; | |
| 140 | /** Overrides `AllowGitHubReserve` for this one request. */ | |
| 141 | readonly allowReserve?: boolean; | |
| 142 | } | |
| 143 | ||
| 144 | export interface GitHubGraphQlInput { | |
| 145 | readonly host: string; | |
| 146 | readonly operation: string; | |
| 147 | readonly query: string; | |
| 148 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 149 | readonly allowReserve?: boolean; | |
| 150 | readonly maxResponseBytes?: number; | |
| 151 | } | |
| 152 | ||
| 153 | export class GitHubApi extends Context.Service< | |
| 154 | GitHubApi, | |
| 155 | { | |
| 156 | /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ | |
| 157 | readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect<string, GitHubApiError>; | |
| 158 | readonly rest: (input: GitHubRestInput) => Effect.Effect<GitHubRestResponse, GitHubApiError>; | |
| 159 | /** The credential a request to `host` would carry right now. */ | |
| 160 | readonly credential: ( | |
| 161 | host: string, | |
| 162 | ) => Effect.Effect< | |
| 163 | { readonly token: Redacted.Redacted<string>; readonly fingerprint: string }, | |
| 164 | GitHubApiError | |
| 165 | >; | |
| 166 | } | |
| 167 | >()("@t3tools/source-control-github/server/GitHubApi") {} | |
| 168 | ||
| 169 | function normalizeHost(host: string): string { | |
| 170 | return host.trim().toLowerCase(); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` | |
| 175 | * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. | |
| 176 | */ | |
| 177 | export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { | |
| 178 | const normalized = normalizeHost(host); | |
| 179 | if (normalized === "github.com") { | |
| 180 | return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; | |
| 181 | } | |
| 182 | if (normalized.endsWith(".ghe.com")) { | |
| 183 | return { | |
| 184 | rest: `https://api.${normalized}`, | |
| 185 | graphql: `https://api.${normalized}/graphql`, | |
| 186 | }; | |
| 187 | } | |
| 188 | return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; | |
| 189 | } | |
| 190 | ||
| 191 | /** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ | |
| 192 | const TRACED_QUERY_MAX_CHARS = 4_000; | |
| 193 | ||
| 194 | /** A stable short id for a GraphQL document, so traces group by query without its text. */ | |
| 195 | function queryHash(query: string): string { | |
| 196 | let hash = 0x811c9dc5; | |
| 197 | for (let index = 0; index < query.length; index++) { | |
| 198 | hash ^= query.charCodeAt(index); | |
| 199 | hash = Math.imul(hash, 0x01000193); | |
| 200 | } | |
| 201 | return (hash >>> 0).toString(16).padStart(8, "0"); | |
| 202 | } | |
| 203 | ||
| 204 | /** The numeric rate-limit headers GitHub sends, as span attributes. */ | |
| 205 | function rateLimitAttributes( | |
| 206 | headers: Readonly<Record<string, string | undefined>>, | |
| 207 | ): Record<string, number | string> { | |
| 208 | const attributes: Record<string, number | string> = {}; | |
| 209 | for (const [header, name] of [ | |
| 210 | ["x-ratelimit-limit", "github.ratelimit.limit"], | |
| 211 | ["x-ratelimit-remaining", "github.ratelimit.remaining"], | |
| 212 | ["x-ratelimit-used", "github.ratelimit.used"], | |
| 213 | ["x-ratelimit-reset", "github.ratelimit.reset"], | |
| 214 | ] as const) { | |
| 215 | const value = Number(headers[header]); | |
| 216 | if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; | |
| 217 | } | |
| 218 | const resource = headers["x-ratelimit-resource"]; | |
| 219 | if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; | |
| 220 | return attributes; | |
| 221 | } | |
| 222 | ||
| 223 | /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ | |
| 224 | function retryAtFrom( | |
| 225 | headers: Readonly<Record<string, string | undefined>>, | |
| 226 | now: number, | |
| 227 | ): number | undefined { | |
| 228 | const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); | |
| 229 | if (fromRetryAfter !== undefined) return fromRetryAfter; | |
| 230 | const reset = Number(headers["x-ratelimit-reset"]) * 1_000; | |
| 231 | return Number.isFinite(reset) && reset > now ? reset : undefined; | |
| 232 | } | |
| 233 | ||
| 234 | const decodeGraphQlErrors = Schema.decodeUnknownOption( | |
| 235 | Schema.fromJsonString( | |
| 236 | Schema.Struct({ | |
| 237 | errors: Schema.NonEmptyArray( | |
| 238 | Schema.Struct({ | |
| 239 | type: Schema.optional(Schema.String), | |
| 240 | message: Schema.optional(Schema.String), | |
| 241 | }), | |
| 242 | ), | |
| 243 | }), | |
| 244 | ), | |
| 245 | ); | |
| 246 | ||
| 247 | /** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ | |
| 248 | const decodeRestErrors = Schema.decodeUnknownOption( | |
| 249 | Schema.fromJsonString( | |
| 250 | Schema.Struct({ | |
| 251 | message: Schema.optional(Schema.String), | |
| 252 | errors: Schema.optional( | |
| 253 | Schema.Array( | |
| 254 | Schema.Union([ | |
| 255 | Schema.String, | |
| 256 | Schema.Struct({ | |
| 257 | message: Schema.optional(Schema.String), | |
| 258 | field: Schema.optional(Schema.String), | |
| 259 | code: Schema.optional(Schema.String), | |
| 260 | }), | |
| 261 | ]), | |
| 262 | ), | |
| 263 | ), | |
| 264 | }), | |
| 265 | ), | |
| 266 | ); | |
| 267 | ||
| 268 | function restErrorMessages(body: string): ReadonlyArray<string> | undefined { | |
| 269 | return Option.match(decodeRestErrors(body), { | |
| 270 | onNone: () => undefined, | |
| 271 | onSome: (decoded) => { | |
| 272 | const details = (decoded.errors ?? []).flatMap((error) => | |
| 273 | typeof error === "string" | |
| 274 | ? [error] | |
| 275 | : error.message !== undefined | |
| 276 | ? [error.message] | |
| 277 | : error.field !== undefined && error.code !== undefined | |
| 278 | ? [`${error.field} ${error.code}`] | |
| 279 | : [], | |
| 280 | ); | |
| 281 | const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; | |
| 282 | return messages.length > 0 ? messages : undefined; | |
| 283 | }, | |
| 284 | }); | |
| 285 | } | |
| 286 | ||
| 287 | /** What one GitHub answer means, decided once from its status, headers and body. */ | |
| 288 | type Answer = Data.TaggedEnum<{ | |
| 289 | Ok: {}; | |
| 290 | RateLimited: {}; | |
| 291 | Unauthorized: {}; | |
| 292 | NotFound: {}; | |
| 293 | Failed: { readonly messages: ReadonlyArray<string> | undefined }; | |
| 294 | }>; | |
| 295 | const Answer = Data.taggedEnum<Answer>(); | |
| 296 | ||
| 297 | /** | |
| 298 | * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body | |
| 299 | * is read for its error types as well as its status. `gh api graphql` failed those too, and | |
| 300 | * callers rely on that to fall back to narrower reads. | |
| 301 | */ | |
| 302 | function classify(input: { | |
| 303 | readonly status: number; | |
| 304 | readonly headers: Readonly<Record<string, string | undefined>>; | |
| 305 | readonly body: string; | |
| 306 | readonly graphql: boolean; | |
| 307 | readonly acceptNotModified: boolean; | |
| 308 | }): Answer { | |
| 309 | const { status, headers, body } = input; | |
| 310 | const errors = input.graphql | |
| 311 | ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors | |
| 312 | : undefined; | |
| 313 | const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; | |
| 314 | const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); | |
| 315 | if ( | |
| 316 | status === 429 || | |
| 317 | types.includes("RATE_LIMITED") || | |
| 318 | // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already | |
| 319 | // exceeded" error; the headers say the same thing. | |
| 320 | (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || | |
| 321 | (input.graphql && | |
| 322 | messages !== undefined && | |
| 323 | messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || | |
| 324 | (status === 403 && | |
| 325 | (headers["x-ratelimit-remaining"] === "0" || | |
| 326 | headers["retry-after"] !== undefined || | |
| 327 | /rate limit/i.test(body))) | |
| 328 | ) { | |
| 329 | return Answer.RateLimited(); | |
| 330 | } | |
| 331 | if (status === 401) return Answer.Unauthorized(); | |
| 332 | if ( | |
| 333 | status === 404 || | |
| 334 | (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) | |
| 335 | ) { | |
| 336 | return Answer.NotFound(); | |
| 337 | } | |
| 338 | if (errors !== undefined) return Answer.Failed({ messages }); | |
| 339 | if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { | |
| 340 | return Answer.Ok(); | |
| 341 | } | |
| 342 | return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); | |
| 343 | } | |
| 344 | ||
| 345 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 346 | export const make = Effect.gen(function* () { | |
| 347 | const httpClient = yield* HttpClient.HttpClient; | |
| 348 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 349 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 350 | const limits = yield* SourceControlRateLimit.SourceControlRateLimit; | |
| 351 | const gate = yield* Semaphore.make(CONCURRENCY); | |
| 352 | ||
| 353 | const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( | |
| 354 | function* (host) { | |
| 355 | const normalized = normalizeHost(host); | |
| 356 | const pinned = yield* PinnedGitHubCredential; | |
| 357 | if (pinned !== null) { | |
| 358 | // A pinned page only ever talks to the host it verified. | |
| 359 | if (pinned.host !== normalized) { | |
| 360 | return yield* new GitHubApiAuthenticationError({ | |
| 361 | host: normalized, | |
| 362 | operation: "credential", | |
| 363 | }); | |
| 364 | } | |
| 365 | return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; | |
| 366 | } | |
| 367 | const held = yield* credentials.get(normalized); | |
| 368 | return { token: held.token, fingerprint: held.fingerprint }; | |
| 369 | }, | |
| 370 | ); | |
| 371 | ||
| 372 | /** | |
| 373 | * Sends one request under the host's rate-limit pause and fails a refusal with the error that | |
| 374 | * says why. `onSuccess` sees the answer only once it is known to be one. | |
| 375 | */ | |
| 376 | const send = Effect.fn("GitHubApi.send")(function* (input: { | |
| 377 | readonly host: string; | |
| 378 | readonly operation: string; | |
| 379 | readonly request: HttpClientRequest.HttpClientRequest; | |
| 380 | readonly maxResponseBytes: number; | |
| 381 | readonly timeout?: Duration.Input | undefined; | |
| 382 | readonly allowReserve: boolean; | |
| 383 | readonly acceptNotModified: boolean; | |
| 384 | /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ | |
| 385 | readonly graphql?: boolean; | |
| 386 | }) { | |
| 387 | const host = normalizeHost(input.host); | |
| 388 | // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. | |
| 389 | yield* Effect.annotateCurrentSpan({ | |
| 390 | "github.host": host, | |
| 391 | "github.operation": input.operation, | |
| 392 | "github.kind": input.graphql === true ? "graphql" : "rest", | |
| 393 | "http.request.method": input.request.method, | |
| 394 | "url.path": new URL(input.request.url).pathname, | |
| 395 | }); | |
| 396 | const { token, fingerprint } = yield* credential(host); | |
| 397 | const scope = yield* SourceControlRateLimit.CredentialScope; | |
| 398 | // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path | |
| 399 | // this reads today is `core`; a `search/` read would need its own resource here. A refusal | |
| 400 | // still pauses the whole host, the key PullRequestService records its own backoff under. | |
| 401 | const resource = input.graphql === true ? "graphql" : "core"; | |
| 402 | const key = { provider: "github" as const, host }; | |
| 403 | const run = Effect.gen(function* () { | |
| 404 | const lease = yield* quota | |
| 405 | .admit(host, resource, { allowReserve: input.allowReserve }) | |
| 406 | .pipe( | |
| 407 | Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), | |
| 408 | ) | |
| 409 | .pipe( | |
| 410 | Effect.tapError((paused) => | |
| 411 | Effect.annotateCurrentSpan({ | |
| 412 | "github.paused": true, | |
| 413 | "github.retry_at": paused.retryAt, | |
| 414 | }), | |
| 415 | ), | |
| 416 | ); | |
| 417 | // One deadline covers the headers and the body: a host that answers headers and then stalls | |
| 418 | // must not hold a slot of the shared gate for undici's own five-minute body timeout. | |
| 419 | const { response, collected } = yield* Effect.gen(function* () { | |
| 420 | const response: HttpClientResponse.HttpClientResponse = yield* httpClient | |
| 421 | .execute( | |
| 422 | input.request.pipe( | |
| 423 | HttpClientRequest.bearerToken(Redacted.value(token)), | |
| 424 | HttpClientRequest.setHeaders({ | |
| 425 | "x-github-api-version": API_VERSION, | |
| 426 | "user-agent": "t3code", | |
| 427 | }), | |
| 428 | ), | |
| 429 | ) | |
| 430 | .pipe( | |
| 431 | // `GitHubApi.send` is the request's span. The client's own span would add `url.full` | |
| 432 | // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. | |
| 433 | Effect.provideService(HttpClient.TracerDisabledWhen, () => true), | |
| 434 | ); | |
| 435 | const collected = yield* collectUint8StreamText({ | |
| 436 | stream: response.stream, | |
| 437 | maxBytes: input.maxResponseBytes, | |
| 438 | }).pipe( | |
| 439 | // 204, 304 and many refusals carry no body at all, which is an empty answer. | |
| 440 | Effect.catchIf( | |
| 441 | (error) => error.reason._tag === "EmptyBodyError", | |
| 442 | () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), | |
| 443 | ), | |
| 444 | ); | |
| 445 | return { response, collected }; | |
| 446 | }).pipe( | |
| 447 | Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), | |
| 448 | Effect.mapError( | |
| 449 | (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), | |
| 450 | ), | |
| 451 | ); | |
| 452 | const headers = response.headers; | |
| 453 | const status = response.status; | |
| 454 | yield* quota.observe(host, headers); | |
| 455 | yield* Effect.annotateCurrentSpan({ | |
| 456 | "http.response.status_code": status, | |
| 457 | ...rateLimitAttributes(headers), | |
| 458 | }); | |
| 459 | const context = { host, operation: input.operation }; | |
| 460 | return yield* Answer.$match( | |
| 461 | classify({ | |
| 462 | status, | |
| 463 | headers, | |
| 464 | body: collected.text, | |
| 465 | graphql: input.graphql === true, | |
| 466 | acceptNotModified: input.acceptNotModified, | |
| 467 | }), | |
| 468 | { | |
| 469 | Ok: () => | |
| 470 | limits.recordSuccess({ ...key, lease }).pipe( | |
| 471 | Effect.as({ | |
| 472 | status, | |
| 473 | headers, | |
| 474 | body: collected.text, | |
| 475 | truncated: collected.truncated, | |
| 476 | invalidUtf8: collected.invalidUtf8, | |
| 477 | }), | |
| 478 | ), | |
| 479 | RateLimited: () => | |
| 480 | Effect.gen(function* () { | |
| 481 | const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); | |
| 482 | yield* limits.recordRateLimit({ ...key, lease, retryAt }); | |
| 483 | yield* Effect.annotateCurrentSpan({ | |
| 484 | "github.rate_limited": true, | |
| 485 | ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), | |
| 486 | }); | |
| 487 | return yield* new GitHubApiRateLimitError({ | |
| 488 | ...context, | |
| 489 | ...(retryAt === undefined ? {} : { retryAt }), | |
| 490 | }); | |
| 491 | }), | |
| 492 | // The source may hold a newer token than the one that was refused. | |
| 493 | Unauthorized: () => | |
| 494 | credentials | |
| 495 | .invalidate(host) | |
| 496 | .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), | |
| 497 | NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), | |
| 498 | Failed: ({ messages }) => | |
| 499 | Effect.fail( | |
| 500 | new GitHubApiResponseError({ | |
| 501 | ...context, | |
| 502 | status, | |
| 503 | ...(messages === undefined ? {} : { githubErrors: messages }), | |
| 504 | }), | |
| 505 | ), | |
| 506 | }, | |
| 507 | ); | |
| 508 | }); | |
| 509 | // The rate-limit scope follows the credential, so a pause on one account never blocks another. | |
| 510 | return yield* gate | |
| 511 | .withPermit(run) | |
| 512 | .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); | |
| 513 | }); | |
| 514 | ||
| 515 | const rest: GitHubApi["Service"]["rest"] = (input) => { | |
| 516 | const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; | |
| 517 | const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( | |
| 518 | HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), | |
| 519 | ); | |
| 520 | const withEtag = | |
| 521 | input.ifNoneMatch === undefined | |
| 522 | ? base | |
| 523 | : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); | |
| 524 | const request = | |
| 525 | input.body === undefined | |
| 526 | ? withEtag | |
| 527 | : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); | |
| 528 | return AllowGitHubReserve.pipe( | |
| 529 | Effect.flatMap((interactive) => | |
| 530 | send({ | |
| 531 | host: input.host, | |
| 532 | operation: input.operation, | |
| 533 | request, | |
| 534 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 535 | timeout: input.timeout, | |
| 536 | allowReserve: input.allowReserve ?? interactive, | |
| 537 | acceptNotModified: input.ifNoneMatch !== undefined, | |
| 538 | }), | |
| 539 | ), | |
| 540 | ); | |
| 541 | }; | |
| 542 | ||
| 543 | const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( | |
| 544 | function* (input) { | |
| 545 | const host = normalizeHost(input.host); | |
| 546 | const { fingerprint } = yield* credential(host); | |
| 547 | const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; | |
| 548 | const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); | |
| 549 | // The document, never its variables: user text (bodies, search terms) travels as variables. | |
| 550 | yield* Effect.annotateCurrentSpan({ | |
| 551 | "github.operation": input.operation, | |
| 552 | "github.graphql.query_hash": queryHash(input.query), | |
| 553 | "github.graphql.query": | |
| 554 | input.query.length > TRACED_QUERY_MAX_CHARS | |
| 555 | ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` | |
| 556 | : input.query, | |
| 557 | }); | |
| 558 | return yield* Effect.gen(function* () { | |
| 559 | const response = yield* send({ | |
| 560 | host, | |
| 561 | operation: input.operation, | |
| 562 | request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( | |
| 563 | HttpClientRequest.acceptJson, | |
| 564 | HttpClientRequest.bodyJsonUnsafe({ | |
| 565 | query: input.query, | |
| 566 | variables: input.variables ?? {}, | |
| 567 | }), | |
| 568 | ), | |
| 569 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 570 | allowReserve, | |
| 571 | acceptNotModified: false, | |
| 572 | graphql: true, | |
| 573 | }); | |
| 574 | // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. | |
| 575 | if (response.truncated) { | |
| 576 | return yield* new GitHubApiResponseError({ | |
| 577 | host, | |
| 578 | operation: input.operation, | |
| 579 | status: response.status, | |
| 580 | }); | |
| 581 | } | |
| 582 | return response.body; | |
| 583 | }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); | |
| 584 | }, | |
| 585 | ); | |
| 586 | ||
| 587 | return GitHubApi.of({ graphql, rest, credential }); | |
| 588 | }); | |
| 589 | ||
| 590 | export const layer = Layer.effect(GitHubApi, make); | |
| 591 | ||
| 592 | /** | |
| 593 | * The transport with the credential, quota reserve and rate-limit pause it reads through. An | |
| 594 | * application builds this once so every GitHub reader shares one view of each quota per host. | |
| 595 | */ | |
| 596 | export const layerWithDependencies = layer.pipe( | |
| 597 | Layer.provideMerge(GitHubCredentials.layer), | |
| 598 | Layer.provideMerge(GitHubQuota.layer), | |
| 599 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 600 | ); |
packages/source-control-github/src/server/GitHubCredentials.test.ts created+182| ... | ... | @@ -0,0 +1,182 @@ |
| 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Redacted from "effect/Redacted"; | |
| 6 | import { ChildProcessSpawner } from "effect/process"; | |
| 7 | ||
| 8 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 9 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 10 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 11 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 12 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 13 | ||
| 14 | const TOKEN_VARIABLES = [ | |
| 15 | "GH_TOKEN", | |
| 16 | "GITHUB_TOKEN", | |
| 17 | "GH_ENTERPRISE_TOKEN", | |
| 18 | "GITHUB_ENTERPRISE_TOKEN", | |
| 19 | ] as const; | |
| 20 | ||
| 21 | /** Settings keep `enabled` explicit; a host the test leaves it off is enabled. */ | |
| 22 | const hostSettings = ( | |
| 23 | hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }>, | |
| 24 | ) => | |
| 25 | Object.fromEntries( | |
| 26 | Object.entries(hosts).map(([host, choice]) => [host, { enabled: true, ...choice }]), | |
| 27 | ); | |
| 28 | ||
| 29 | function harness( | |
| 30 | hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }> = {}, | |
| 31 | signedOut: ReadonlyArray<string> = [], | |
| 32 | tokens: Record<string, string> = {}, | |
| 33 | ) { | |
| 34 | const calls: Array<ReadonlyArray<string>> = []; | |
| 35 | const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) => | |
| 36 | Effect.sync(() => { | |
| 37 | calls.push(input.args); | |
| 38 | const user = input.args[input.args.indexOf("--user") + 1]; | |
| 39 | if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { | |
| 40 | return { | |
| 41 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 42 | stdout: "", | |
| 43 | stderr: "", | |
| 44 | stdoutTruncated: false, | |
| 45 | stderrTruncated: false, | |
| 46 | }; | |
| 47 | } | |
| 48 | return { | |
| 49 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 50 | stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", | |
| 51 | stderr: "", | |
| 52 | stdoutTruncated: false, | |
| 53 | stderrTruncated: false, | |
| 54 | }; | |
| 55 | }); | |
| 56 | const layer = GitHubCredentials.layer.pipe( | |
| 57 | Layer.provideMerge( | |
| 58 | TestSourceControlHost.layer({ | |
| 59 | settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 60 | github: { hosts: hostSettings(hosts), tokens }, | |
| 61 | }), | |
| 62 | process: { run }, | |
| 63 | }), | |
| 64 | ), | |
| 65 | Layer.provide(NodeCrypto.layer), | |
| 66 | ); | |
| 67 | return { layer, calls }; | |
| 68 | } | |
| 69 | ||
| 70 | describe("GitHubCredentials", () => { | |
| 71 | beforeEach(() => { | |
| 72 | for (const name of TOKEN_VARIABLES) vi.stubEnv(name, ""); | |
| 73 | }); | |
| 74 | afterEach(() => { | |
| 75 | vi.unstubAllEnvs(); | |
| 76 | }); | |
| 77 | ||
| 78 | it.effect("asks gh for the active login when Settings pin nothing", () => { | |
| 79 | const { layer, calls } = harness(); | |
| 80 | return Effect.gen(function* () { | |
| 81 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 82 | const credential = yield* credentials.get("GitHub.com"); | |
| 83 | expect(Redacted.value(credential.token)).toBe("active-token"); | |
| 84 | expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]); | |
| 85 | }).pipe(Effect.provide(layer)); | |
| 86 | }); | |
| 87 | ||
| 88 | it.effect("passes --user for an account pinned in Settings", () => { | |
| 89 | const { layer, calls } = harness({ "github.com": { account: "work" } }); | |
| 90 | return Effect.gen(function* () { | |
| 91 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 92 | const credential = yield* credentials.get("github.com"); | |
| 93 | expect(Redacted.value(credential.token)).toBe("token-for-work"); | |
| 94 | expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); | |
| 95 | }).pipe(Effect.provide(layer)); | |
| 96 | }); | |
| 97 | ||
| 98 | it.effect("fails a host turned off in Settings without asking gh", () => { | |
| 99 | const { layer, calls } = harness({ "github.com": { enabled: false } }); | |
| 100 | return Effect.gen(function* () { | |
| 101 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 102 | const error = yield* Effect.flip(credentials.get("github.com")); | |
| 103 | expect(error._tag).toBe("GitHubHostDisabledError"); | |
| 104 | expect(error.message).toBe( | |
| 105 | "GitHub host github.com is turned off in Settings → Source Control.", | |
| 106 | ); | |
| 107 | expect(calls).toEqual([]); | |
| 108 | }).pipe(Effect.provide(layer)); | |
| 109 | }); | |
| 110 | ||
| 111 | it.effect("lets an environment token win over a pinned account, as gh does", () => { | |
| 112 | vi.stubEnv("GH_TOKEN", "from-env"); | |
| 113 | const { layer, calls } = harness({ "github.com": { account: "work" } }); | |
| 114 | return Effect.gen(function* () { | |
| 115 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 116 | const credential = yield* credentials.get("github.com"); | |
| 117 | expect(Redacted.value(credential.token)).toBe("from-env"); | |
| 118 | expect(credential.source).toBe("env"); | |
| 119 | expect(calls).toEqual([]); | |
| 120 | }).pipe(Effect.provide(layer)); | |
| 121 | }); | |
| 122 | ||
| 123 | it.effect("picks up a changed account on the next request without a restart", () => { | |
| 124 | const { layer, calls } = harness(); | |
| 125 | return Effect.gen(function* () { | |
| 126 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 127 | const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; | |
| 128 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 129 | ||
| 130 | yield* settings.update({ | |
| 131 | github: { hosts: { "github.com": { account: "work", enabled: true } } }, | |
| 132 | }); | |
| 133 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); | |
| 134 | ||
| 135 | yield* settings.update({ github: { hosts: { "github.com": { enabled: false } } } }); | |
| 136 | expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( | |
| 137 | "GitHubHostDisabledError", | |
| 138 | ); | |
| 139 | ||
| 140 | yield* settings.update({ github: { hosts: {} } }); | |
| 141 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 142 | // The unpinned token stayed cached; only the newly pinned account cost a gh call. | |
| 143 | expect(calls).toHaveLength(2); | |
| 144 | }).pipe(Effect.provide(layer)); | |
| 145 | }); | |
| 146 | ||
| 147 | it.effect("falls back to the active login when the pinned one is no longer signed in", () => { | |
| 148 | const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]); | |
| 149 | return Effect.gen(function* () { | |
| 150 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 151 | expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); | |
| 152 | expect(calls).toEqual([ | |
| 153 | ["auth", "token", "--hostname", "github.com", "--user", "gone"], | |
| 154 | ["auth", "token", "--hostname", "github.com"], | |
| 155 | ]); | |
| 156 | }).pipe(Effect.provide(layer)); | |
| 157 | }); | |
| 158 | ||
| 159 | it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => { | |
| 160 | vi.stubEnv("GH_TOKEN", "env-token"); | |
| 161 | const { layer, calls } = harness({}, [], { "github.com": "saved-token" }); | |
| 162 | return Effect.gen(function* () { | |
| 163 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 164 | const credential = yield* credentials.get("github.com"); | |
| 165 | expect(Redacted.value(credential.token)).toBe("saved-token"); | |
| 166 | expect(credential.source).toBe("settings"); | |
| 167 | expect(calls).toEqual([]); | |
| 168 | }).pipe(Effect.provide(layer)); | |
| 169 | }); | |
| 170 | ||
| 171 | it.effect("keeps a host turned off even with a token saved in Settings", () => { | |
| 172 | const { layer } = harness({ "github.com": { enabled: false } }, [], { | |
| 173 | "github.com": "saved-token", | |
| 174 | }); | |
| 175 | return Effect.gen(function* () { | |
| 176 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 177 | expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( | |
| 178 | "GitHubHostDisabledError", | |
| 179 | ); | |
| 180 | }).pipe(Effect.provide(layer)); | |
| 181 | }); | |
| 182 | }); |
packages/source-control-github/src/server/GitHubCredentials.ts created+261| ... | ... | @@ -0,0 +1,261 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Hex from "effect/encoding/Hex"; | |
| 7 | import * as Exit from "effect/Exit"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | ||
| 14 | import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; | |
| 15 | ||
| 16 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 17 | ||
| 18 | /** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ | |
| 19 | const TOKEN_TTL = Duration.minutes(5); | |
| 20 | /** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ | |
| 21 | const MISSING_TTL = Duration.seconds(10); | |
| 22 | ||
| 23 | export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); | |
| 24 | export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; | |
| 25 | ||
| 26 | export interface GitHubCredential { | |
| 27 | readonly host: string; | |
| 28 | readonly token: Redacted.Redacted<string>; | |
| 29 | readonly source: GitHubCredentialSource; | |
| 30 | /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ | |
| 31 | readonly fingerprint: string; | |
| 32 | } | |
| 33 | ||
| 34 | /** Nothing in the environment, and no `gh` on PATH to ask. */ | |
| 35 | export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingError>()( | |
| 36 | "GitHubCliMissingError", | |
| 37 | { host: Schema.String }, | |
| 38 | ) { | |
| 39 | override get message(): string { | |
| 40 | return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | /** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */ | |
| 45 | export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()( | |
| 46 | "GitHubNotSignedInError", | |
| 47 | { host: Schema.String, account: Schema.optional(Schema.String) }, | |
| 48 | ) { | |
| 49 | override get message(): string { | |
| 50 | return this.account === undefined | |
| 51 | ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` | |
| 52 | : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** The user turned the host off in Settings. */ | |
| 57 | export class GitHubHostDisabledError extends Schema.TaggedError<GitHubHostDisabledError>()( | |
| 58 | "GitHubHostDisabledError", | |
| 59 | { host: Schema.String }, | |
| 60 | ) { | |
| 61 | override get message(): string { | |
| 62 | return `GitHub host ${this.host} is turned off in Settings → Source Control.`; | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /** `gh auth token` timed out or failed for a reason other than having no login. */ | |
| 67 | export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedError>()( | |
| 68 | "GitHubCliFailedError", | |
| 69 | { host: Schema.String, cause: Schema.Defect() }, | |
| 70 | ) { | |
| 71 | override get message(): string { | |
| 72 | return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | /** There is no token for the host. */ | |
| 77 | export type GitHubCredentialUnavailableError = | |
| 78 | | GitHubCliMissingError | |
| 79 | | GitHubNotSignedInError | |
| 80 | | GitHubHostDisabledError | |
| 81 | | GitHubCliFailedError; | |
| 82 | ||
| 83 | /** | |
| 84 | * Where GitHub tokens come from. Callers ask per host and never see how the token was found, | |
| 85 | * so another source (an in-app OAuth login) slots in here without touching any of them. | |
| 86 | */ | |
| 87 | export class GitHubCredentials extends Context.Service< | |
| 88 | GitHubCredentials, | |
| 89 | { | |
| 90 | readonly get: ( | |
| 91 | host: string, | |
| 92 | ) => Effect.Effect<GitHubCredential, GitHubCredentialUnavailableError>; | |
| 93 | /** Drops the held token after GitHub refused it, so the next read asks its source again. */ | |
| 94 | readonly invalidate: (host: string) => Effect.Effect<void>; | |
| 95 | } | |
| 96 | >()("@t3tools/source-control-github/server/GitHubCredentials") {} | |
| 97 | ||
| 98 | function normalizeHost(host: string): string { | |
| 99 | return host.trim().toLowerCase(); | |
| 100 | } | |
| 101 | ||
| 102 | /** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ | |
| 103 | function isGitHubDotCom(host: string): boolean { | |
| 104 | return host === "github.com" || host.endsWith(".ghe.com"); | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to | |
| 109 | * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL | |
| 110 | * picks the host and a hostile one must not receive an enterprise token. | |
| 111 | */ | |
| 112 | export function environmentToken( | |
| 113 | host: string, | |
| 114 | env: Readonly<Record<string, string | undefined>>, | |
| 115 | ): string | null { | |
| 116 | const names = isGitHubDotCom(host) | |
| 117 | ? ["GH_TOKEN", "GITHUB_TOKEN"] | |
| 118 | : env.GH_HOST?.trim().toLowerCase() === host | |
| 119 | ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] | |
| 120 | : []; | |
| 121 | for (const name of names) { | |
| 122 | const value = env[name]?.trim(); | |
| 123 | if (value) return value; | |
| 124 | } | |
| 125 | return null; | |
| 126 | } | |
| 127 | ||
| 128 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 129 | export const make = Effect.gen(function* () { | |
| 130 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; | |
| 131 | const process = sourceControlHost.process; | |
| 132 | const crypto = yield* Crypto.Crypto; | |
| 133 | const environment = yield* HostProcessEnvironment; | |
| 134 | const workingDirectory = yield* HostProcessWorkingDirectory; | |
| 135 | ||
| 136 | /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ | |
| 137 | const fingerprintOf = (host: string, token: string) => | |
| 138 | crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( | |
| 139 | Effect.map((digest) => `${host}:${Hex.encode(digest)}`), | |
| 140 | // Hashing a string in memory has no platform failure worth a typed error. | |
| 141 | Effect.orDie, | |
| 142 | ); | |
| 143 | ||
| 144 | const fromGh = (host: string, account: string | undefined) => | |
| 145 | process | |
| 146 | .run({ | |
| 147 | operation: "GitHubCredentials.get", | |
| 148 | command: "gh", | |
| 149 | args: [ | |
| 150 | "auth", | |
| 151 | "token", | |
| 152 | "--hostname", | |
| 153 | host, | |
| 154 | ...(account === undefined ? [] : ["--user", account]), | |
| 155 | ], | |
| 156 | cwd: workingDirectory, | |
| 157 | // Never let gh print the token into a debug log. | |
| 158 | env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, | |
| 159 | timeoutMs: 10_000, | |
| 160 | }) | |
| 161 | .pipe( | |
| 162 | Effect.mapError((error) => | |
| 163 | error._tag === "VcsProcessSpawnError" && | |
| 164 | error.cause instanceof PlatformError.PlatformError && | |
| 165 | error.cause.reason._tag === "NotFound" | |
| 166 | ? new GitHubCliMissingError({ host }) | |
| 167 | : // gh exits non-zero with "no oauth token" when it has no login for the host. | |
| 168 | error._tag === "VcsProcessExitError" | |
| 169 | ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) | |
| 170 | : new GitHubCliFailedError({ host, cause: error }), | |
| 171 | ), | |
| 172 | Effect.map((output) => output.stdout.trim()), | |
| 173 | Effect.filterOrFail( | |
| 174 | (token) => token !== "", | |
| 175 | () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), | |
| 176 | ), | |
| 177 | ); | |
| 178 | ||
| 179 | /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ | |
| 180 | const hostChoice = (host: string) => | |
| 181 | sourceControlHost.settings.get.pipe( | |
| 182 | Effect.map((settings) => settings.github.hosts[host]), | |
| 183 | Effect.orElseSucceed(() => undefined), | |
| 184 | ); | |
| 185 | ||
| 186 | /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ | |
| 187 | const savedToken = (host: string) => | |
| 188 | sourceControlHost.settings.get.pipe( | |
| 189 | Effect.map((settings) => settings.github.tokens[host]?.trim() || null), | |
| 190 | Effect.orElseSucceed(() => null), | |
| 191 | ); | |
| 192 | ||
| 193 | /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ | |
| 194 | const cacheKey = (host: string, account: string | undefined) => | |
| 195 | account === undefined ? host : `${host}\u0000${account}`; | |
| 196 | ||
| 197 | const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { | |
| 198 | const [host = key, choice] = key.split("\u0000"); | |
| 199 | // An environment token wins over a pinned account, exactly as it does in gh. | |
| 200 | const fromEnv = environmentToken(host, environment); | |
| 201 | // A pinned login gh no longer holds (logged out, expired) falls back to the active one, | |
| 202 | // which is what discovery reports as the account in use. | |
| 203 | const token = | |
| 204 | fromEnv ?? | |
| 205 | (yield* fromGh(host, choice).pipe( | |
| 206 | Effect.catchTags({ | |
| 207 | GitHubNotSignedInError: (error) => | |
| 208 | choice === undefined ? Effect.fail(error) : fromGh(host, undefined), | |
| 209 | }), | |
| 210 | )); | |
| 211 | return { | |
| 212 | host, | |
| 213 | token: Redacted.make(token), | |
| 214 | source: fromEnv !== null ? "env" : "gh", | |
| 215 | fingerprint: yield* fingerprintOf(host, token), | |
| 216 | } satisfies GitHubCredential; | |
| 217 | }); | |
| 218 | ||
| 219 | const cache = yield* Cache.makeWith(lookup, { | |
| 220 | capacity: 32, | |
| 221 | // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. | |
| 222 | timeToLive: (exit) => | |
| 223 | Exit.isSuccess(exit) | |
| 224 | ? TOKEN_TTL | |
| 225 | : Exit.findErrorOption(exit).pipe( | |
| 226 | Option.exists((error) => error._tag === "GitHubCliFailedError"), | |
| 227 | ) | |
| 228 | ? Duration.zero | |
| 229 | : MISSING_TTL, | |
| 230 | }); | |
| 231 | ||
| 232 | return GitHubCredentials.of({ | |
| 233 | get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { | |
| 234 | const host = normalizeHost(rawHost); | |
| 235 | const choice = yield* hostChoice(host); | |
| 236 | if (choice?.enabled === false) { | |
| 237 | return yield* new GitHubHostDisabledError({ host }); | |
| 238 | } | |
| 239 | // A token saved in Settings is the most deliberate choice, so it comes before the | |
| 240 | // environment and gh. It is read from the secret store each time, so it needs no cache. | |
| 241 | const saved = yield* savedToken(host); | |
| 242 | if (saved !== null) { | |
| 243 | return { | |
| 244 | host, | |
| 245 | token: Redacted.make(saved), | |
| 246 | source: "settings", | |
| 247 | fingerprint: yield* fingerprintOf(host, saved), | |
| 248 | } satisfies GitHubCredential; | |
| 249 | } | |
| 250 | return yield* Cache.get(cache, cacheKey(host, choice?.account)); | |
| 251 | }), | |
| 252 | invalidate: (rawHost) => { | |
| 253 | const host = normalizeHost(rawHost); | |
| 254 | return hostChoice(host).pipe( | |
| 255 | Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), | |
| 256 | ); | |
| 257 | }, | |
| 258 | }); | |
| 259 | }); | |
| 260 | ||
| 261 | export const layer = Layer.effect(GitHubCredentials, make); |
packages/source-control-github/src/server/GitHubPullRequestApi.test.ts created+4589| ... | ... | @@ -0,0 +1,4589 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 14 | ||
| 15 | import { AllowGitHubReserve } from "./GitHubApi.ts"; | |
| 16 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 17 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 18 | import * as GitHubQuota from "./GitHubQuota.ts"; | |
| 19 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 20 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 21 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 22 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | |
| 23 | ||
| 24 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 25 | ||
| 26 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 27 | data: { | |
| 28 | repository: { | |
| 29 | mergeCommitAllowed: true, | |
| 30 | squashMergeAllowed: false, | |
| 31 | rebaseMergeAllowed: true, | |
| 32 | viewerPermission: "WRITE", | |
| 33 | pullRequest: { | |
| 34 | number: 7, | |
| 35 | title: "Pull request 7", | |
| 36 | url: "https://github.com/acme/web/pull/7", | |
| 37 | headRefName: "feature", | |
| 38 | baseRefName: "main", | |
| 39 | headRefOid: "abc123", | |
| 40 | state: "OPEN", | |
| 41 | createdAt: "2026-07-01T00:00:00Z", | |
| 42 | updatedAt: "2026-07-02T00:00:00Z", | |
| 43 | viewerCanUpdate: true, | |
| 44 | viewerDidAuthor: false, | |
| 45 | viewerCanUpdateBranch: true, | |
| 46 | baseRef: { compare: { behindBy: 2 } }, | |
| 47 | reviewRequests: { nodes: [] }, | |
| 48 | labels: { nodes: [] }, | |
| 49 | commits: { nodes: [] }, | |
| 50 | ...pullRequest, | |
| 51 | }, | |
| 52 | }, | |
| 53 | }, | |
| 54 | }); | |
| 55 | ||
| 56 | /** One request the CLI made, as the transport received it. */ | |
| 57 | type ApiCall = | |
| 58 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) | |
| 59 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); | |
| 60 | ||
| 61 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; | |
| 62 | ||
| 63 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); | |
| 64 | const defaultCredential = (host: string) => | |
| 65 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); | |
| 66 | const mockedCredential = vi.fn(defaultCredential); | |
| 67 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => | |
| 68 | Effect.succeed(output('{"data":{}}')), | |
| 69 | ); | |
| 70 | ||
| 71 | /** | |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL quota from each | |
| 73 | * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. | |
| 74 | */ | |
| 75 | const mockApi = Layer.effect( | |
| 76 | GitHubApi.GitHubApi, | |
| 77 | Effect.gen(function* () { | |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 79 | return GitHubApi.GitHubApi.of({ | |
| 80 | graphql: (input) => | |
| 81 | GitHubApi.AllowGitHubReserve.pipe( | |
| 82 | Effect.flatMap((interactive) => | |
| 83 | quota.admit(input.host, "graphql", { | |
| 84 | allowReserve: input.allowReserve ?? interactive, | |
| 85 | }), | |
| 86 | ), | |
| 87 | Effect.andThen(() => | |
| 88 | (input.query.includes("query PullRequestStackMemberships") | |
| 89 | ? mockedStackMemberships | |
| 90 | : mockedExecute)({ kind: "graphql", ...input }), | |
| 91 | ), | |
| 92 | Effect.tap((answer) => quota.observe(input.host, answer.headers)), | |
| 93 | Effect.map((answer) => answer.body), | |
| 94 | ), | |
| 95 | rest: (input) => mockedExecute({ kind: "rest", ...input }), | |
| 96 | credential: (host) => mockedCredential(host), | |
| 97 | }); | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | const layer = it.layer( | |
| 102 | GitHubPullRequestApi.layer.pipe( | |
| 103 | Layer.provideMerge(mockApi), | |
| 104 | Layer.provideMerge(GitHubQuota.layer), | |
| 105 | Layer.provide(NodeCrypto.layer), | |
| 106 | Layer.provide(TestSourceControlHost.layer()), | |
| 107 | Layer.provide(NodeServices.layer), | |
| 108 | ), | |
| 109 | ); | |
| 110 | ||
| 111 | /** A successful answer; GraphQL reads take its body. */ | |
| 112 | function output( | |
| 113 | body: string, | |
| 114 | truncated = false, | |
| 115 | invalidUtf8 = false, | |
| 116 | headers: Readonly<Record<string, string>> = {}, | |
| 117 | ): GitHubApi.GitHubRestResponse { | |
| 118 | return { status: 200, headers, body, truncated, invalidUtf8 }; | |
| 119 | } | |
| 120 | ||
| 121 | /** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ | |
| 122 | function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { | |
| 123 | return { | |
| 124 | "x-ratelimit-resource": "graphql", | |
| 125 | "x-ratelimit-limit": "5000", | |
| 126 | "x-ratelimit-remaining": String(remaining), | |
| 127 | "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), | |
| 128 | }; | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most | |
| 133 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. | |
| 134 | */ | |
| 135 | function rows( | |
| 136 | count: number, | |
| 137 | firstNumber: number, | |
| 138 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), | |
| 139 | ): ReadonlyArray<Record<string, unknown>> { | |
| 140 | return Array.from({ length: count }, (_, index) => { | |
| 141 | const number = firstNumber + index; | |
| 142 | const { reviewRequests, checks, ...rest } = overrides(number) as { | |
| 143 | reviewRequests?: ReadonlyArray<Record<string, string>>; | |
| 144 | checks?: string; | |
| 145 | } & Record<string, unknown>; | |
| 146 | return { | |
| 147 | number, | |
| 148 | title: `Pull request ${number}`, | |
| 149 | url: `https://github.com/acme/web/pull/${number}`, | |
| 150 | headRefName: "feat/page", | |
| 151 | baseRefName: "main", | |
| 152 | createdAt: "2026-07-01T00:00:00Z", | |
| 153 | updatedAt: "2026-07-02T00:00:00Z", | |
| 154 | repository: { nameWithOwner: "acme/web" }, | |
| 155 | ...(reviewRequests === undefined | |
| 156 | ? {} | |
| 157 | : { | |
| 158 | reviewRequests: { | |
| 159 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), | |
| 160 | }, | |
| 161 | }), | |
| 162 | ...(checks === undefined | |
| 163 | ? {} | |
| 164 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 165 | ...rest, | |
| 166 | }; | |
| 167 | }); | |
| 168 | } | |
| 169 | ||
| 170 | /** A page of the searched listing. */ | |
| 171 | function pullRequests( | |
| 172 | count: number, | |
| 173 | firstNumber: number, | |
| 174 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 175 | ): string { | |
| 176 | return encodeJson({ | |
| 177 | data: { | |
| 178 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, | |
| 179 | }, | |
| 180 | }); | |
| 181 | } | |
| 182 | ||
| 183 | /** A page of a repository's own list, which is what the search-free fallback reads. */ | |
| 184 | function listedPullRequests( | |
| 185 | count: number, | |
| 186 | firstNumber: number, | |
| 187 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 188 | ): string { | |
| 189 | return encodeJson({ | |
| 190 | data: { | |
| 191 | repository: { | |
| 192 | pullRequests: { | |
| 193 | pageInfo: { hasNextPage: false }, | |
| 194 | nodes: rows(count, firstNumber, overrides), | |
| 195 | }, | |
| 196 | }, | |
| 197 | }, | |
| 198 | }); | |
| 199 | } | |
| 200 | ||
| 201 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ | |
| 202 | const emptySearch = () => output(pullRequests(0, 1)); | |
| 203 | const emptyList = () => output(listedPullRequests(0, 1)); | |
| 204 | ||
| 205 | function pullRequestFiles(count: number, firstIndex: number): string { | |
| 206 | return encodeJson( | |
| 207 | Array.from({ length: count }, (_, index) => ({ | |
| 208 | filename: `src/file${firstIndex + index}.ts`, | |
| 209 | status: "modified", | |
| 210 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 211 | })), | |
| 212 | ); | |
| 213 | } | |
| 214 | ||
| 215 | /** One thread's comments as the GraphQL read returns them, cursor and all. */ | |
| 216 | function threadComments( | |
| 217 | ids: ReadonlyArray<string>, | |
| 218 | endCursor: string | null, | |
| 219 | totalCount = ids.length, | |
| 220 | ) { | |
| 221 | return { | |
| 222 | totalCount, | |
| 223 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 224 | nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), | |
| 225 | }; | |
| 226 | } | |
| 227 | ||
| 228 | function thread(id: string, ...commentIds: ReadonlyArray<string>) { | |
| 229 | return { | |
| 230 | id, | |
| 231 | path: "src/a.ts", | |
| 232 | line: 1, | |
| 233 | diffSide: "RIGHT", | |
| 234 | isResolved: false, | |
| 235 | isOutdated: false, | |
| 236 | comments: threadComments(commentIds, null), | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | function reviewThreadsPage( | |
| 241 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 242 | endCursor: string | null, | |
| 243 | ): string { | |
| 244 | return encodeJson({ | |
| 245 | data: { | |
| 246 | repository: { | |
| 247 | pullRequest: { | |
| 248 | reviewThreads: { | |
| 249 | totalCount: nodes.length, | |
| 250 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 251 | nodes, | |
| 252 | }, | |
| 253 | }, | |
| 254 | }, | |
| 255 | }, | |
| 256 | }); | |
| 257 | } | |
| 258 | ||
| 259 | function threadCommentsPage( | |
| 260 | ids: ReadonlyArray<string>, | |
| 261 | endCursor: string | null, | |
| 262 | totalCount: number, | |
| 263 | pullRequestId = "PR_7", | |
| 264 | ): string { | |
| 265 | return encodeJson({ | |
| 266 | data: { | |
| 267 | repository: { pullRequest: { id: "PR_7" } }, | |
| 268 | node: { | |
| 269 | pullRequest: { id: pullRequestId }, | |
| 270 | comments: threadComments(ids, endCursor, totalCount), | |
| 271 | }, | |
| 272 | }, | |
| 273 | }); | |
| 274 | } | |
| 275 | ||
| 276 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ | |
| 277 | const diffRefused = new GitHubApi.GitHubApiResponseError({ | |
| 278 | host: "github.com", | |
| 279 | operation: "getPullRequestDiff", | |
| 280 | status: 406, | |
| 281 | }); | |
| 282 | ||
| 283 | /** The nth request the CLI made. */ | |
| 284 | function callAt(index: number): ApiCall { | |
| 285 | const call = mockedExecute.mock.calls[index]; | |
| 286 | assert.isDefined(call); | |
| 287 | return call[0]; | |
| 288 | } | |
| 289 | ||
| 290 | /** The GraphQL variables of the nth request. */ | |
| 291 | function varsAt(index: number): Readonly<Record<string, unknown>> { | |
| 292 | const call = callAt(index); | |
| 293 | assert.strictEqual(call.kind, "graphql"); | |
| 294 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; | |
| 295 | } | |
| 296 | ||
| 297 | /** The GraphQL document of the nth request. */ | |
| 298 | function queryAt(index: number): string { | |
| 299 | const call = callAt(index); | |
| 300 | return call.kind === "graphql" ? call.query : ""; | |
| 301 | } | |
| 302 | ||
| 303 | /** The REST path of the nth request. */ | |
| 304 | function pathAt(index: number): string { | |
| 305 | const call = callAt(index); | |
| 306 | return call.kind === "rest" ? call.path : ""; | |
| 307 | } | |
| 308 | ||
| 309 | /** The search the nth request carried, or undefined for a read that did not search. */ | |
| 310 | function searchOfCall(index: number): string | undefined { | |
| 311 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; | |
| 312 | return typeof q === "string" ? q : undefined; | |
| 313 | } | |
| 314 | ||
| 315 | /** One row as a search answers it, which is the listing's row one connection deeper. */ | |
| 316 | function searchItem(number: number, repository: string, updatedAt: string) { | |
| 317 | return { | |
| 318 | number, | |
| 319 | title: `Pull request ${number}`, | |
| 320 | url: `https://github.com/${repository}/pull/${number}`, | |
| 321 | author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, | |
| 322 | headRefName: "feat/page", | |
| 323 | baseRefName: "main", | |
| 324 | state: "OPEN", | |
| 325 | isDraft: false, | |
| 326 | mergeable: "MERGEABLE", | |
| 327 | createdAt: "2026-07-01T00:00:00Z", | |
| 328 | updatedAt, | |
| 329 | repository: { nameWithOwner: repository }, | |
| 330 | reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, | |
| 331 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 332 | }; | |
| 333 | } | |
| 334 | ||
| 335 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { | |
| 336 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); | |
| 337 | } | |
| 338 | ||
| 339 | const searchQueryOfCall = searchOfCall; | |
| 340 | ||
| 341 | /** | |
| 342 | * Answers every request with the body of the first route whose needle its GraphQL document or | |
| 343 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. | |
| 344 | */ | |
| 345 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { | |
| 346 | mockedExecute.mockImplementation((call) => { | |
| 347 | const target = call.kind === "graphql" ? call.query : call.path; | |
| 348 | const match = routes.find(([needle]) => target.includes(needle)); | |
| 349 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); | |
| 350 | }); | |
| 351 | } | |
| 352 | ||
| 353 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ | |
| 354 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { | |
| 355 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 356 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], | |
| 357 | ); | |
| 358 | } | |
| 359 | ||
| 360 | /** Every REST request whose path contains `needle`, in order. */ | |
| 361 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { | |
| 362 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 363 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], | |
| 364 | ); | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * The check that a client-given node hangs off the pull request it names. Routed before the node | |
| 369 | * id lookup, whose document it contains. | |
| 370 | */ | |
| 371 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; | |
| 372 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ | |
| 373 | data: { | |
| 374 | repository: { pullRequest: { id: pullRequestId } }, | |
| 375 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, | |
| 376 | }, | |
| 377 | }); | |
| 378 | ||
| 379 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ | |
| 380 | const checkContextsPage = ( | |
| 381 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 382 | endCursor: string | null, | |
| 383 | headRefOid = "abc123", | |
| 384 | ) => | |
| 385 | encodeJson({ | |
| 386 | data: { | |
| 387 | repository: { | |
| 388 | pullRequest: { | |
| 389 | headRefOid, | |
| 390 | commits: { | |
| 391 | nodes: [ | |
| 392 | { | |
| 393 | commit: { | |
| 394 | statusCheckRollup: { | |
| 395 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, | |
| 396 | }, | |
| 397 | }, | |
| 398 | }, | |
| 399 | ], | |
| 400 | }, | |
| 401 | }, | |
| 402 | }, | |
| 403 | }, | |
| 404 | }); | |
| 405 | ||
| 406 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ | |
| 407 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); | |
| 408 | ||
| 409 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ | |
| 410 | const crossRepositoryDetail = (headRefOid = "abc123") => | |
| 411 | coreResponse({ | |
| 412 | headRefName: "feat/page", | |
| 413 | headRefOid, | |
| 414 | isCrossRepository: true, | |
| 415 | headRepositoryOwner: { login: "octocat" }, | |
| 416 | }); | |
| 417 | ||
| 418 | /** Open pull requests on one head branch, every one of them from the same fork head. */ | |
| 419 | const heads = (numbers: ReadonlyArray<number>) => ({ | |
| 420 | data: { | |
| 421 | repository: { | |
| 422 | pullRequests: { | |
| 423 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 424 | nodes: numbers.map((number) => ({ | |
| 425 | number, | |
| 426 | headRefOid: "abc123", | |
| 427 | isCrossRepository: true, | |
| 428 | headRepositoryOwner: { login: "octocat" }, | |
| 429 | })), | |
| 430 | }, | |
| 431 | }, | |
| 432 | }, | |
| 433 | }); | |
| 434 | ||
| 435 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ | |
| 436 | workflow_runs: ids.map((id) => ({ | |
| 437 | id, | |
| 438 | name: id === 10 ? "build" : `run ${id}`, | |
| 439 | html_url: `https://example.com/${id}`, | |
| 440 | })), | |
| 441 | }); | |
| 442 | ||
| 443 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ | |
| 444 | function workflowApprovalRoutes( | |
| 445 | detail: () => unknown, | |
| 446 | headsAnswer: unknown, | |
| 447 | runsAnswer: unknown, | |
| 448 | ): void { | |
| 449 | mockedExecute.mockImplementation((call) => | |
| 450 | Effect.sync(() => | |
| 451 | output( | |
| 452 | call.kind === "rest" | |
| 453 | ? call.path.endsWith("/approve") | |
| 454 | ? "" | |
| 455 | : encodeJson(runsAnswer) | |
| 456 | : call.query.includes("headRefName: $head") | |
| 457 | ? encodeJson(headsAnswer) | |
| 458 | : encodeJson(detail()), | |
| 459 | ), | |
| 460 | ), | |
| 461 | ); | |
| 462 | } | |
| 463 | ||
| 464 | /** The pull request node id lookup, which the layer caches for every test after the first. */ | |
| 465 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; | |
| 466 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); | |
| 467 | ||
| 468 | afterEach(() => { | |
| 469 | mockedExecute.mockReset(); | |
| 470 | mockedStackMemberships.mockReset(); | |
| 471 | mockedCredential.mockReset(); | |
| 472 | mockedCredential.mockImplementation(defaultCredential); | |
| 473 | }); | |
| 474 | ||
| 475 | it.effect( | |
| 476 | "keeps a verified credential through an auth switch and separates token fingerprints", | |
| 477 | () => | |
| 478 | Effect.gen(function* () { | |
| 479 | let activeToken = "broad-credential"; | |
| 480 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = | |
| 481 | []; | |
| 482 | const credentials = Layer.succeed( | |
| 483 | GitHubCredentials.GitHubCredentials, | |
| 484 | GitHubCredentials.GitHubCredentials.of({ | |
| 485 | get: (host) => | |
| 486 | Effect.sync(() => ({ | |
| 487 | host, | |
| 488 | token: Redacted.make(activeToken), | |
| 489 | source: "gh" as const, | |
| 490 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, | |
| 491 | })), | |
| 492 | invalidate: () => Effect.void, | |
| 493 | }), | |
| 494 | ); | |
| 495 | const http = Layer.succeed( | |
| 496 | HttpClient.HttpClient, | |
| 497 | HttpClient.make((request) => | |
| 498 | Effect.sync(() => { | |
| 499 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); | |
| 500 | const body = request.url.endsWith("/user") | |
| 501 | ? { id: 123, login: "same-account" } | |
| 502 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; | |
| 503 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); | |
| 504 | }), | |
| 505 | ), | |
| 506 | ); | |
| 507 | const cli = yield* GitHubPullRequestApi.make.pipe( | |
| 508 | Effect.provide( | |
| 509 | GitHubApi.layer.pipe( | |
| 510 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 511 | Layer.provide(GitHubQuota.layer), | |
| 512 | Layer.provide(SourceControlRateLimit.layer), | |
| 513 | Layer.merge(TestSourceControlHost.layer()), | |
| 514 | Layer.provideMerge(NodeServices.layer), | |
| 515 | ), | |
| 516 | ), | |
| 517 | ); | |
| 518 | const input = { cwd: "/repo", host: "github.com" }; | |
| 519 | const first = yield* cli.withVerifiedCredential(input, (identity) => | |
| 520 | Effect.gen(function* () { | |
| 521 | activeToken = "restricted-credential"; | |
| 522 | expect(yield* cli.getViewerLogin(input)).toBe("same-account"); | |
| 523 | yield* cli.commentOnPullRequest({ | |
| 524 | ...input, | |
| 525 | repository: "owner/repo", | |
| 526 | number: 1, | |
| 527 | body: "comment", | |
| 528 | }); | |
| 529 | return identity; | |
| 530 | }), | |
| 531 | ); | |
| 532 | const second = yield* cli.withVerifiedCredential(input, Effect.succeed); | |
| 533 | expect(first.accountId).toBe(second.accountId); | |
| 534 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); | |
| 535 | expect(encodeJson([first, second])).not.toContain("broad-credential"); | |
| 536 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); | |
| 537 | // The comment was written under the credential the page verified, not the switched one. | |
| 538 | expect( | |
| 539 | requests | |
| 540 | .filter((request) => request.url.endsWith("/graphql")) | |
| 541 | .map((request) => request.authorization), | |
| 542 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | |
| 543 | expect( | |
| 544 | requests | |
| 545 | .filter((request) => request.url.endsWith("/user")) | |
| 546 | .map((request) => request.authorization), | |
| 547 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); | |
| 548 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 549 | accountId: "123", | |
| 550 | viewer: "same-account", | |
| 551 | }); | |
| 552 | }), | |
| 553 | ); | |
| 554 | ||
| 555 | layer("GitHubPullRequestApi.layer", (it) => { | |
| 556 | it.effect("holds background previews once an answer reports the reserve, until it resets", () => | |
| 557 | Effect.gen(function* () { | |
| 558 | const now = yield* Clock.currentTimeMillis; | |
| 559 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 560 | const resetAt = "2099-08-13T14:00:00Z"; | |
| 561 | yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); | |
| 562 | // GitHub's count, not ours: the answer below reports the reserve has been reached. | |
| 563 | mockedExecute.mockReturnValue( | |
| 564 | Effect.succeed( | |
| 565 | output( | |
| 566 | encodeJson({ | |
| 567 | data: { | |
| 568 | repository: { | |
| 569 | pullRequest: { | |
| 570 | number: 7, | |
| 571 | title: "Fast previews", | |
| 572 | url: "https://preview-budget.example/acme/web/pull/7", | |
| 573 | state: "OPEN", | |
| 574 | isDraft: false, | |
| 575 | createdAt: "2026-07-01T00:00:00Z", | |
| 576 | author: null, | |
| 577 | }, | |
| 578 | }, | |
| 579 | }, | |
| 580 | }), | |
| 581 | false, | |
| 582 | false, | |
| 583 | graphqlQuota(499, resetAt), | |
| 584 | ), | |
| 585 | ), | |
| 586 | ); | |
| 587 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 588 | const input = { | |
| 589 | cwd: "/w", | |
| 590 | repository: "acme/web", | |
| 591 | host: "preview-budget.example", | |
| 592 | number: 7, | |
| 593 | }; | |
| 594 | yield* cli.getPullRequestPreview(input); | |
| 595 | const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); | |
| 596 | expect(held._tag).toBe("SourceControlRateLimitPausedError"); | |
| 597 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 598 | yield* TestClock.setTime(Date.parse(resetAt)); | |
| 599 | yield* cli.getPullRequestPreview(input); | |
| 600 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 601 | yield* TestClock.setTime(now); | |
| 602 | }), | |
| 603 | ); | |
| 604 | ||
| 605 | it.effect("loads the complete hover card with one GraphQL request", () => | |
| 606 | Effect.gen(function* () { | |
| 607 | mockedExecute.mockReturnValueOnce( | |
| 608 | Effect.succeed( | |
| 609 | output( | |
| 610 | encodeJson({ | |
| 611 | data: { | |
| 612 | repository: { | |
| 613 | pullRequest: { | |
| 614 | number: 7, | |
| 615 | title: "Fast previews", | |
| 616 | url: "https://github.example/acme/web/pull/7", | |
| 617 | state: "MERGED", | |
| 618 | isDraft: false, | |
| 619 | createdAt: "2026-07-01T00:00:00Z", | |
| 620 | author: { | |
| 621 | login: "octocat", | |
| 622 | name: "Octo Cat", | |
| 623 | avatarUrl: "https://github.example/avatar.png", | |
| 624 | }, | |
| 625 | }, | |
| 626 | }, | |
| 627 | }, | |
| 628 | }), | |
| 629 | ), | |
| 630 | ), | |
| 631 | ); | |
| 632 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 633 | const preview = yield* cli.getPullRequestPreview({ | |
| 634 | cwd: "/w", | |
| 635 | repository: "acme/web", | |
| 636 | host: "github.example", | |
| 637 | number: 7, | |
| 638 | }); | |
| 639 | expect(preview).toEqual({ | |
| 640 | number: 7, | |
| 641 | title: "Fast previews", | |
| 642 | url: "https://github.example/acme/web/pull/7", | |
| 643 | state: "merged", | |
| 644 | isDraft: false, | |
| 645 | createdAt: "2026-07-01T00:00:00Z", | |
| 646 | author: { | |
| 647 | login: "octocat", | |
| 648 | name: "Octo Cat", | |
| 649 | avatarUrl: "https://github.example/avatar.png", | |
| 650 | }, | |
| 651 | }); | |
| 652 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 653 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); | |
| 654 | }), | |
| 655 | ); | |
| 656 | ||
| 657 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | |
| 658 | Effect.gen(function* () { | |
| 659 | mockedExecute.mockImplementation(() => | |
| 660 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 661 | ); | |
| 662 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 663 | const results = yield* Effect.all( | |
| 664 | Array.from({ length: 4 }, () => | |
| 665 | cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), | |
| 666 | ), | |
| 667 | { concurrency: 4 }, | |
| 668 | ); | |
| 669 | expect(results).toEqual( | |
| 670 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), | |
| 671 | ); | |
| 672 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 673 | expect(pathAt(0)).toBe("user"); | |
| 674 | }), | |
| 675 | ); | |
| 676 | ||
| 677 | it.effect( | |
| 678 | "lets another identity reader continue when the first verification is interrupted", | |
| 679 | () => | |
| 680 | Effect.gen(function* () { | |
| 681 | const firstStarted = yield* Deferred.make<void>(); | |
| 682 | let verifications = 0; | |
| 683 | mockedExecute.mockImplementation(() => | |
| 684 | Effect.gen(function* () { | |
| 685 | if (++verifications === 1) { | |
| 686 | yield* Deferred.succeed(firstStarted, undefined); | |
| 687 | return yield* Effect.never; | |
| 688 | } | |
| 689 | return output('{"id":123,"login":"viewer"}'); | |
| 690 | }), | |
| 691 | ); | |
| 692 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 693 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; | |
| 694 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 695 | yield* Deferred.await(firstStarted); | |
| 696 | // The second reader is queued on the same credential's lock behind the first. | |
| 697 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 698 | yield* Effect.yieldNow; | |
| 699 | yield* Fiber.interrupt(first); | |
| 700 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); | |
| 701 | expect(verifications).toBe(2); | |
| 702 | }), | |
| 703 | ); | |
| 704 | ||
| 705 | it.effect("reads linked pull requests on one host together, filed back by position", () => | |
| 706 | Effect.gen(function* () { | |
| 707 | const node = (number: number) => ({ | |
| 708 | number, | |
| 709 | title: `Pull request ${number}`, | |
| 710 | url: `https://github.com/acme/web/pull/${number}`, | |
| 711 | author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 712 | baseRefName: "main", | |
| 713 | headRefName: `feat/${number}`, | |
| 714 | state: "OPEN", | |
| 715 | isDraft: false, | |
| 716 | mergeable: "MERGEABLE", | |
| 717 | reviewDecision: null, | |
| 718 | latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, | |
| 719 | additions: 12, | |
| 720 | deletions: 3, | |
| 721 | changedFiles: 2, | |
| 722 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 723 | mergedAt: null, | |
| 724 | closedAt: null, | |
| 725 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 726 | }); | |
| 727 | mockedExecute.mockReturnValueOnce( | |
| 728 | Effect.succeed( | |
| 729 | output( | |
| 730 | encodeJson({ | |
| 731 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, | |
| 732 | }), | |
| 733 | ), | |
| 734 | ), | |
| 735 | ); | |
| 736 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 737 | ||
| 738 | const reads = yield* Effect.forEach( | |
| 739 | [7, 8], | |
| 740 | (number) => | |
| 741 | cli.getPullRequestSummary({ | |
| 742 | cwd: "/w", | |
| 743 | repository: "acme/web", | |
| 744 | host: "github.com", | |
| 745 | number, | |
| 746 | }), | |
| 747 | { concurrency: "unbounded" }, | |
| 748 | ).pipe(Effect.forkChild); | |
| 749 | yield* TestClock.adjust("10 millis"); | |
| 750 | const [seven, eight] = yield* Fiber.join(reads); | |
| 751 | ||
| 752 | assert.deepStrictEqual( | |
| 753 | { | |
| 754 | number: seven?.number, | |
| 755 | state: seven?.state, | |
| 756 | headBranch: seven?.headBranch, | |
| 757 | author: seven?.author?.login, | |
| 758 | changedFiles: seven?.changedFiles, | |
| 759 | reviewDecision: seven?.reviewDecision, | |
| 760 | checksState: seven?.checksState, | |
| 761 | mergeability: seven?.mergeability, | |
| 762 | }, | |
| 763 | { | |
| 764 | number: 7, | |
| 765 | state: "open", | |
| 766 | headBranch: "feat/7", | |
| 767 | author: "octocat", | |
| 768 | changedFiles: 2, | |
| 769 | reviewDecision: "approved", | |
| 770 | checksState: "passing", | |
| 771 | mergeability: "mergeable", | |
| 772 | }, | |
| 773 | ); | |
| 774 | assert.strictEqual(eight?.headBranch, "feat/8"); | |
| 775 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 776 | expect(queryAt(0)).toContain( | |
| 777 | "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", | |
| 778 | ); | |
| 779 | expect(varsAt(0)).toMatchObject({ | |
| 780 | s0_owner: "acme", | |
| 781 | s0_name: "web", | |
| 782 | s0_number: 7, | |
| 783 | s1_number: 8, | |
| 784 | }); | |
| 785 | }), | |
| 786 | ); | |
| 787 | ||
| 788 | it.effect("fingerprints watched pull requests on one host in one read", () => | |
| 789 | Effect.gen(function* () { | |
| 790 | const node = (comments: number) => ({ | |
| 791 | state: "OPEN", | |
| 792 | mergeable: "MERGEABLE", | |
| 793 | headRefOid: "abc123", | |
| 794 | comments: { totalCount: comments, nodes: [] }, | |
| 795 | reviews: { totalCount: 0, nodes: [] }, | |
| 796 | reviewThreads: { totalCount: 0 }, | |
| 797 | commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, | |
| 798 | }); | |
| 799 | mockedExecute.mockReturnValueOnce( | |
| 800 | Effect.succeed( | |
| 801 | output( | |
| 802 | JSON.stringify({ | |
| 803 | data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, | |
| 804 | }), | |
| 805 | ), | |
| 806 | ), | |
| 807 | ); | |
| 808 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 809 | ||
| 810 | const reads = yield* Effect.forEach( | |
| 811 | [7, 8], | |
| 812 | (number) => | |
| 813 | cli.getPullRequestWatchFingerprint({ | |
| 814 | cwd: "/w", | |
| 815 | repository: "acme/web", | |
| 816 | host: "github.com", | |
| 817 | number, | |
| 818 | }), | |
| 819 | { concurrency: "unbounded" }, | |
| 820 | ).pipe(Effect.forkChild); | |
| 821 | yield* TestClock.adjust("10 millis"); | |
| 822 | const [seven, eight] = yield* Fiber.join(reads); | |
| 823 | ||
| 824 | expect(seven?.remarks.startsWith("1 ")).toBe(true); | |
| 825 | // GitHub had no answer for #8, so its watch reads it in full. | |
| 826 | expect(eight).toBeNull(); | |
| 827 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 828 | expect(queryAt(0)).toContain( | |
| 829 | "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", | |
| 830 | ); | |
| 831 | expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); | |
| 832 | }), | |
| 833 | ); | |
| 834 | ||
| 835 | it.effect("reads a pull request the batch said nothing about on its own", () => | |
| 836 | Effect.gen(function* () { | |
| 837 | mockedExecute | |
| 838 | .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) | |
| 839 | .mockReturnValueOnce( | |
| 840 | Effect.succeed( | |
| 841 | output( | |
| 842 | encodeJson({ | |
| 843 | data: { | |
| 844 | s0: { | |
| 845 | pullRequest: { | |
| 846 | number: 7, | |
| 847 | title: "Reuse the summary", | |
| 848 | url: "https://github.com/acme/web/pull/7", | |
| 849 | author: { login: "octocat", name: "Octo Cat" }, | |
| 850 | baseRefName: "main", | |
| 851 | headRefName: "feat/summary", | |
| 852 | state: "OPEN", | |
| 853 | isDraft: false, | |
| 854 | mergeable: "MERGEABLE", | |
| 855 | reviewDecision: "APPROVED", | |
| 856 | additions: 12, | |
| 857 | deletions: 3, | |
| 858 | changedFiles: 2, | |
| 859 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 860 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 861 | }, | |
| 862 | }, | |
| 863 | }, | |
| 864 | }), | |
| 865 | ), | |
| 866 | ), | |
| 867 | ); | |
| 868 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 869 | ||
| 870 | const read = yield* cli | |
| 871 | .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) | |
| 872 | .pipe(Effect.forkChild); | |
| 873 | yield* TestClock.adjust("10 millis"); | |
| 874 | const summary = yield* Fiber.join(read); | |
| 875 | ||
| 876 | assert.strictEqual(summary.headBranch, "feat/summary"); | |
| 877 | assert.strictEqual(summary.checksState, "passing"); | |
| 878 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 879 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); | |
| 880 | }), | |
| 881 | ); | |
| 882 | ||
| 883 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => | |
| 884 | Effect.gen(function* () { | |
| 885 | mockedExecute.mockReturnValueOnce( | |
| 886 | Effect.succeed( | |
| 887 | output( | |
| 888 | encodeJson([ | |
| 889 | { | |
| 890 | id: 42, | |
| 891 | number: 3, | |
| 892 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 893 | base: { ref: "main" }, | |
| 894 | pull_requests: [ | |
| 895 | { | |
| 896 | number: 6, | |
| 897 | head: { ref: "feat/one" }, | |
| 898 | state: "closed", | |
| 899 | merged_at: "2026-09-02", | |
| 900 | }, | |
| 901 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 902 | ], | |
| 903 | }, | |
| 904 | ]), | |
| 905 | ), | |
| 906 | ), | |
| 907 | ); | |
| 908 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 909 | ||
| 910 | const stack = yield* cli.getPullRequestStack({ | |
| 911 | cwd: "/w", | |
| 912 | repository: "acme/web", | |
| 913 | host: "ghe.example.com", | |
| 914 | number: 7, | |
| 915 | }); | |
| 916 | ||
| 917 | assert.deepStrictEqual(stack, { | |
| 918 | id: "42", | |
| 919 | number: 3, | |
| 920 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 921 | base: "main", | |
| 922 | layers: [ | |
| 923 | { number: 6, headBranch: "feat/one", state: "merged" }, | |
| 924 | { number: 7, headBranch: "feat/two", state: "open" }, | |
| 925 | ], | |
| 926 | }); | |
| 927 | expect(callAt(0)).toMatchObject({ | |
| 928 | kind: "rest", | |
| 929 | host: "ghe.example.com", | |
| 930 | path: "repos/acme/web/stacks?pull_request=7", | |
| 931 | }); | |
| 932 | }), | |
| 933 | ); | |
| 934 | ||
| 935 | it.effect("fetches layer titles only when the caller asks for stack details", () => | |
| 936 | Effect.gen(function* () { | |
| 937 | const minimal = { | |
| 938 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 939 | number: 3, | |
| 940 | base: { ref: "main" }, | |
| 941 | pull_requests: [ | |
| 942 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | |
| 943 | ], | |
| 944 | }; | |
| 945 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); | |
| 946 | mockedExecute.mockReturnValueOnce( | |
| 947 | Effect.succeed( | |
| 948 | output( | |
| 949 | encodeJson({ | |
| 950 | ...minimal, | |
| 951 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | |
| 952 | }), | |
| 953 | ), | |
| 954 | ), | |
| 955 | ); | |
| 956 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 957 | const stack = yield* cli.getPullRequestStack({ | |
| 958 | cwd: "/w", | |
| 959 | repository: "acme/web", | |
| 960 | host: "github.com", | |
| 961 | number: 7, | |
| 962 | includeDetails: true, | |
| 963 | }); | |
| 964 | expect(stack?.layers[0]).toMatchObject({ | |
| 965 | title: "Second layer", | |
| 966 | headSha: "abc123", | |
| 967 | isDraft: false, | |
| 968 | }); | |
| 969 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); | |
| 970 | }), | |
| 971 | ); | |
| 972 | ||
| 973 | it.effect("reads an empty stacks listing as not stacked", () => | |
| 974 | Effect.gen(function* () { | |
| 975 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 976 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 977 | ||
| 978 | const stack = yield* cli.getPullRequestStack({ | |
| 979 | cwd: "/w", | |
| 980 | repository: "acme/web", | |
| 981 | host: "github.com", | |
| 982 | number: 7, | |
| 983 | }); | |
| 984 | ||
| 985 | assert.isNull(stack); | |
| 986 | }), | |
| 987 | ); | |
| 988 | ||
| 989 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | |
| 990 | Effect.gen(function* () { | |
| 991 | mockedExecute.mockReturnValueOnce( | |
| 992 | Effect.fail( | |
| 993 | new GitHubApi.GitHubApiNotFoundError({ | |
| 994 | host: "github.com", | |
| 995 | operation: "getPullRequestStack", | |
| 996 | }), | |
| 997 | ), | |
| 998 | ); | |
| 999 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1000 | ||
| 1001 | const stack = yield* cli.getPullRequestStack({ | |
| 1002 | cwd: "/w", | |
| 1003 | repository: "acme/web", | |
| 1004 | host: "github.com", | |
| 1005 | number: 7, | |
| 1006 | }); | |
| 1007 | ||
| 1008 | assert.isNull(stack); | |
| 1009 | }), | |
| 1010 | ); | |
| 1011 | ||
| 1012 | it.effect("does not read a refused credential as an unstacked pull request", () => | |
| 1013 | Effect.gen(function* () { | |
| 1014 | mockedExecute.mockReturnValueOnce( | |
| 1015 | Effect.fail( | |
| 1016 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 1017 | host: "github.com", | |
| 1018 | operation: "getPullRequestStack", | |
| 1019 | }), | |
| 1020 | ), | |
| 1021 | ); | |
| 1022 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1023 | ||
| 1024 | const error = yield* Effect.flip( | |
| 1025 | cli.getPullRequestStack({ | |
| 1026 | cwd: "/w", | |
| 1027 | repository: "acme/web", | |
| 1028 | host: "github.com", | |
| 1029 | number: 7, | |
| 1030 | }), | |
| 1031 | ); | |
| 1032 | ||
| 1033 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); | |
| 1034 | }), | |
| 1035 | ); | |
| 1036 | ||
| 1037 | it.effect("preserves transient stack failures instead of reporting no stack", () => | |
| 1038 | Effect.gen(function* () { | |
| 1039 | const failure = new GitHubApi.GitHubApiResponseError({ | |
| 1040 | host: "github.com", | |
| 1041 | operation: "getPullRequestStack", | |
| 1042 | status: 503, | |
| 1043 | }); | |
| 1044 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | |
| 1045 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1046 | const error = yield* Effect.flip( | |
| 1047 | cli.getPullRequestStack({ | |
| 1048 | cwd: "/w", | |
| 1049 | repository: "acme/web", | |
| 1050 | host: "github.com", | |
| 1051 | number: 7, | |
| 1052 | }), | |
| 1053 | ); | |
| 1054 | assert.strictEqual(error, failure); | |
| 1055 | }), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | it.effect("reports a stacks answer it cannot read against the stack read", () => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); | |
| 1061 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1062 | ||
| 1063 | const error = yield* Effect.flip( | |
| 1064 | cli.getPullRequestStack({ | |
| 1065 | cwd: "/w", | |
| 1066 | repository: "acme/web", | |
| 1067 | host: "github.com", | |
| 1068 | number: 7, | |
| 1069 | }), | |
| 1070 | ); | |
| 1071 | ||
| 1072 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 1073 | if (error._tag !== "GitHubPullRequestReadError") return; | |
| 1074 | assert.strictEqual(error.operation, "getPullRequestStack"); | |
| 1075 | }), | |
| 1076 | ); | |
| 1077 | ||
| 1078 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 1079 | Effect.gen(function* () { | |
| 1080 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1081 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1082 | ||
| 1083 | const batch = yield* cli.listPullRequests({ | |
| 1084 | cwd: "/w", | |
| 1085 | repository: "acme/web", | |
| 1086 | host: "github.com", | |
| 1087 | state: "open", | |
| 1088 | involvement: "all", | |
| 1089 | viewer: "bilal", | |
| 1090 | limit: 10, | |
| 1091 | }); | |
| 1092 | ||
| 1093 | assert.strictEqual(batch.items.length, 3); | |
| 1094 | assert.isFalse(batch.truncated); | |
| 1095 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); | |
| 1096 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1097 | expect(queryAt(0)).toContain("first: 11"); | |
| 1098 | }), | |
| 1099 | ); | |
| 1100 | ||
| 1101 | it.effect("reports truncation from the extra row, counted before decoding", () => | |
| 1102 | Effect.gen(function* () { | |
| 1103 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 1104 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1105 | ||
| 1106 | const batch = yield* cli.listPullRequests({ | |
| 1107 | cwd: "/w", | |
| 1108 | repository: "acme/web", | |
| 1109 | host: "github.com", | |
| 1110 | state: "open", | |
| 1111 | involvement: "all", | |
| 1112 | viewer: "bilal", | |
| 1113 | limit: 10, | |
| 1114 | }); | |
| 1115 | ||
| 1116 | assert.strictEqual(batch.items.length, 10); | |
| 1117 | assert.isTrue(batch.truncated); | |
| 1118 | }), | |
| 1119 | ); | |
| 1120 | ||
| 1121 | it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => | |
| 1122 | Effect.gen(function* () { | |
| 1123 | // Two pages of 100 that hand back the same cursor, for a page of 250. | |
| 1124 | const repeating = output( | |
| 1125 | encodeJson({ | |
| 1126 | data: { | |
| 1127 | search: { | |
| 1128 | pageInfo: { hasNextPage: true, endCursor: "same" }, | |
| 1129 | nodes: rows(100, 1), | |
| 1130 | }, | |
| 1131 | }, | |
| 1132 | }), | |
| 1133 | ); | |
| 1134 | mockedExecute.mockReturnValue(Effect.succeed(repeating)); | |
| 1135 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1136 | ||
| 1137 | const batch = yield* cli.listPullRequests({ | |
| 1138 | cwd: "/w", | |
| 1139 | repository: "acme/web", | |
| 1140 | host: "github.com", | |
| 1141 | state: "open", | |
| 1142 | involvement: "all", | |
| 1143 | viewer: "bilal", | |
| 1144 | limit: 250, | |
| 1145 | }); | |
| 1146 | ||
| 1147 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1148 | assert.isTrue(batch.truncated); | |
| 1149 | }), | |
| 1150 | ); | |
| 1151 | ||
| 1152 | it.effect("excludes merged pull requests from the Closed tab", () => | |
| 1153 | Effect.gen(function* () { | |
| 1154 | mockedExecute.mockImplementation((call) => | |
| 1155 | Effect.succeed( | |
| 1156 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1157 | ? emptyList() | |
| 1158 | : emptySearch(), | |
| 1159 | ), | |
| 1160 | ); | |
| 1161 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1162 | ||
| 1163 | yield* cli.listPullRequests({ | |
| 1164 | cwd: "/w", | |
| 1165 | repository: "acme/web", | |
| 1166 | host: "github.com", | |
| 1167 | state: "closed", | |
| 1168 | involvement: "all", | |
| 1169 | viewer: "bilal", | |
| 1170 | limit: 10, | |
| 1171 | }); | |
| 1172 | ||
| 1173 | // A closed pull request may also be a merged one, so the tab narrows through search. | |
| 1174 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); | |
| 1175 | }), | |
| 1176 | ); | |
| 1177 | ||
| 1178 | it.effect("narrows to the author on the authored tab", () => | |
| 1179 | Effect.gen(function* () { | |
| 1180 | mockedExecute.mockImplementation((call) => | |
| 1181 | Effect.succeed( | |
| 1182 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1183 | ? emptyList() | |
| 1184 | : emptySearch(), | |
| 1185 | ), | |
| 1186 | ); | |
| 1187 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1188 | ||
| 1189 | yield* cli.listPullRequests({ | |
| 1190 | cwd: "/w", | |
| 1191 | repository: "acme/web", | |
| 1192 | host: "github.com", | |
| 1193 | state: "open", | |
| 1194 | involvement: "authored", | |
| 1195 | viewer: "bilal", | |
| 1196 | limit: 10, | |
| 1197 | }); | |
| 1198 | ||
| 1199 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); | |
| 1200 | }), | |
| 1201 | ); | |
| 1202 | ||
| 1203 | it.effect("narrows through search on the reviewing tab", () => | |
| 1204 | Effect.gen(function* () { | |
| 1205 | mockedExecute.mockImplementation((call) => | |
| 1206 | Effect.succeed( | |
| 1207 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1208 | ? emptyList() | |
| 1209 | : emptySearch(), | |
| 1210 | ), | |
| 1211 | ); | |
| 1212 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1213 | ||
| 1214 | yield* cli.listPullRequests({ | |
| 1215 | cwd: "/w", | |
| 1216 | repository: "acme/web", | |
| 1217 | host: "github.com", | |
| 1218 | state: "open", | |
| 1219 | involvement: "reviewing", | |
| 1220 | viewer: "bilal", | |
| 1221 | limit: 10, | |
| 1222 | }); | |
| 1223 | ||
| 1224 | expect(searchOfCall(0)).toBe( | |
| 1225 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", | |
| 1226 | ); | |
| 1227 | }), | |
| 1228 | ); | |
| 1229 | ||
| 1230 | it.effect("carries every repository and every qualifier into one search", () => | |
| 1231 | Effect.gen(function* () { | |
| 1232 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1233 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1234 | ||
| 1235 | yield* cli.searchPullRequests({ | |
| 1236 | cwd: "/w", | |
| 1237 | host: "github.com", | |
| 1238 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1239 | state: "closed", | |
| 1240 | involvement: "reviewing", | |
| 1241 | viewer: "bilal", | |
| 1242 | limit: 10, | |
| 1243 | query: "pull requests page", | |
| 1244 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1245 | }); | |
| 1246 | ||
| 1247 | // One request for both repositories, carrying everything the per-repository read expresses | |
| 1248 | // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the | |
| 1249 | // order the page reads in. | |
| 1250 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1251 | assert.strictEqual( | |
| 1252 | searchQueryOfCall(0), | |
| 1253 | 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + | |
| 1254 | "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", | |
| 1255 | ); | |
| 1256 | }), | |
| 1257 | ); | |
| 1258 | ||
| 1259 | it.effect("narrows a search to the author, and to merged on the merged tab", () => | |
| 1260 | Effect.gen(function* () { | |
| 1261 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1262 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1263 | ||
| 1264 | yield* cli.searchPullRequests({ | |
| 1265 | cwd: "/w", | |
| 1266 | host: "github.com", | |
| 1267 | repositories: ["acme/web"], | |
| 1268 | state: "merged", | |
| 1269 | involvement: "authored", | |
| 1270 | viewer: "bilal", | |
| 1271 | limit: 10, | |
| 1272 | }); | |
| 1273 | ||
| 1274 | assert.strictEqual( | |
| 1275 | searchQueryOfCall(0), | |
| 1276 | "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", | |
| 1277 | ); | |
| 1278 | }), | |
| 1279 | ); | |
| 1280 | ||
| 1281 | it.effect("keeps a searched-for qualifier inside the phrase", () => | |
| 1282 | Effect.gen(function* () { | |
| 1283 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1284 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1285 | ||
| 1286 | yield* cli.searchPullRequests({ | |
| 1287 | cwd: "/w", | |
| 1288 | host: "github.com", | |
| 1289 | repositories: ["acme/web"], | |
| 1290 | state: "open", | |
| 1291 | involvement: "all", | |
| 1292 | viewer: "bilal", | |
| 1293 | limit: 10, | |
| 1294 | query: 'x" is:merged repo:evil/repo', | |
| 1295 | }); | |
| 1296 | ||
| 1297 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. | |
| 1298 | assert.strictEqual( | |
| 1299 | searchQueryOfCall(0), | |
| 1300 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', | |
| 1301 | ); | |
| 1302 | }), | |
| 1303 | ); | |
| 1304 | ||
| 1305 | it.effect("refuses to search for a repository GitHub cannot address", () => | |
| 1306 | Effect.gen(function* () { | |
| 1307 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1308 | ||
| 1309 | const failure = yield* Effect.flip( | |
| 1310 | cli.searchPullRequests({ | |
| 1311 | cwd: "/w", | |
| 1312 | host: "github.com", | |
| 1313 | repositories: ["acme/web", "acme/web is:merged"], | |
| 1314 | state: "open", | |
| 1315 | involvement: "all", | |
| 1316 | viewer: "bilal", | |
| 1317 | limit: 10, | |
| 1318 | }), | |
| 1319 | ); | |
| 1320 | ||
| 1321 | // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. | |
| 1322 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1323 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1324 | }), | |
| 1325 | ); | |
| 1326 | ||
| 1327 | it.effect("files each searched row under the repository it came from", () => | |
| 1328 | Effect.gen(function* () { | |
| 1329 | mockedExecute.mockReturnValue( | |
| 1330 | Effect.succeed( | |
| 1331 | searchPage([ | |
| 1332 | searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1333 | searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), | |
| 1334 | // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. | |
| 1335 | {}, | |
| 1336 | ]), | |
| 1337 | ), | |
| 1338 | ); | |
| 1339 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1340 | ||
| 1341 | const batch = yield* cli.searchPullRequests({ | |
| 1342 | cwd: "/w", | |
| 1343 | host: "github.com", | |
| 1344 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1345 | state: "open", | |
| 1346 | involvement: "all", | |
| 1347 | viewer: "bilal", | |
| 1348 | limit: 10, | |
| 1349 | }); | |
| 1350 | ||
| 1351 | assert.deepStrictEqual( | |
| 1352 | batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), | |
| 1353 | [ | |
| 1354 | ["acme/web", 7, "https://avatars/octocat"], | |
| 1355 | ["pingdotgg/t3code", 9, "https://avatars/octocat"], | |
| 1356 | ], | |
| 1357 | ); | |
| 1358 | // The listing leaves the line counts to a read of their own. | |
| 1359 | assert.deepStrictEqual( | |
| 1360 | batch.items.map((item) => [item.additions, item.deletions]), | |
| 1361 | [ | |
| 1362 | [0, 0], | |
| 1363 | [0, 0], | |
| 1364 | ], | |
| 1365 | ); | |
| 1366 | assert.isFalse(batch.truncated); | |
| 1367 | }), | |
| 1368 | ); | |
| 1369 | ||
| 1370 | it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => | |
| 1371 | Effect.gen(function* () { | |
| 1372 | mockedExecute | |
| 1373 | .mockReturnValueOnce( | |
| 1374 | Effect.succeed( | |
| 1375 | searchPage([ | |
| 1376 | searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1377 | searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), | |
| 1378 | searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), | |
| 1379 | ]), | |
| 1380 | ), | |
| 1381 | ) | |
| 1382 | .mockReturnValueOnce( | |
| 1383 | Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), | |
| 1384 | ); | |
| 1385 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1386 | const read = () => | |
| 1387 | cli.searchPullRequests({ | |
| 1388 | cwd: "/w", | |
| 1389 | host: "github.com", | |
| 1390 | repositories: ["acme/web"], | |
| 1391 | state: "open", | |
| 1392 | involvement: "all", | |
| 1393 | viewer: "bilal", | |
| 1394 | limit: 2, | |
| 1395 | }); | |
| 1396 | ||
| 1397 | const overflowing = yield* read(); | |
| 1398 | const capped = yield* read(); | |
| 1399 | ||
| 1400 | // The extra row is the probe, and it is not handed on. | |
| 1401 | assert.strictEqual(overflowing.items.length, 2); | |
| 1402 | assert.isTrue(overflowing.truncated); | |
| 1403 | // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. | |
| 1404 | assert.isTrue(capped.truncated); | |
| 1405 | }), | |
| 1406 | ); | |
| 1407 | ||
| 1408 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | |
| 1409 | Effect.gen(function* () { | |
| 1410 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1411 | mockedExecute.mockReturnValueOnce( | |
| 1412 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1413 | ); | |
| 1414 | mockedExecute.mockReturnValueOnce( | |
| 1415 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | |
| 1416 | ); | |
| 1417 | mockedStackMemberships.mockReturnValueOnce( | |
| 1418 | Effect.succeed( | |
| 1419 | output( | |
| 1420 | encodeJson({ | |
| 1421 | data: { | |
| 1422 | s0: { | |
| 1423 | pullRequest: { | |
| 1424 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1425 | stackEntry: { position: 1 }, | |
| 1426 | }, | |
| 1427 | }, | |
| 1428 | s1: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1429 | }, | |
| 1430 | }), | |
| 1431 | ), | |
| 1432 | ), | |
| 1433 | ); | |
| 1434 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1435 | const batch = yield* cli.listPullRequests({ | |
| 1436 | cwd: "/w", | |
| 1437 | repository: "acme/web", | |
| 1438 | host: "github.com", | |
| 1439 | state: "open", | |
| 1440 | involvement: "all", | |
| 1441 | viewer: "bilal", | |
| 1442 | limit: 2, | |
| 1443 | filters: { draft: "hide" }, | |
| 1444 | }); | |
| 1445 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); | |
| 1446 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1447 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 1448 | expect(batch.truncated).toBe(true); | |
| 1449 | expect(batch.continues).toBe(false); | |
| 1450 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | |
| 1451 | const membership = mockedStackMemberships.mock.calls[0]?.[0]; | |
| 1452 | const numbers = Object.entries( | |
| 1453 | membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, | |
| 1454 | ) | |
| 1455 | .filter(([name]) => name.endsWith("_number")) | |
| 1456 | .map(([, number]) => number); | |
| 1457 | expect(numbers).toEqual([4, 5]); | |
| 1458 | }), | |
| 1459 | ); | |
| 1460 | ||
| 1461 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => | |
| 1462 | Effect.gen(function* () { | |
| 1463 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | |
| 1464 | mockedStackMemberships.mockImplementation((input) => | |
| 1465 | input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) | |
| 1466 | ? Effect.fail( | |
| 1467 | new GitHubApi.GitHubApiResponseError({ | |
| 1468 | host: "github.com", | |
| 1469 | operation: "listPullRequestStackMemberships", | |
| 1470 | status: 502, | |
| 1471 | }), | |
| 1472 | ) | |
| 1473 | : Effect.succeed( | |
| 1474 | output( | |
| 1475 | encodeJson({ | |
| 1476 | data: { | |
| 1477 | s0: { | |
| 1478 | pullRequest: { | |
| 1479 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1480 | stackEntry: { position: 1 }, | |
| 1481 | }, | |
| 1482 | }, | |
| 1483 | }, | |
| 1484 | }), | |
| 1485 | ), | |
| 1486 | ), | |
| 1487 | ); | |
| 1488 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1489 | const batch = yield* cli.listPullRequests({ | |
| 1490 | cwd: "/w", | |
| 1491 | repository: "acme/web", | |
| 1492 | host: "github.com", | |
| 1493 | state: "open", | |
| 1494 | involvement: "all", | |
| 1495 | viewer: "bilal", | |
| 1496 | limit: 26, | |
| 1497 | }); | |
| 1498 | expect(batch.items.map((item) => item.number)).toEqual( | |
| 1499 | Array.from({ length: 26 }, (_, i) => i + 1), | |
| 1500 | ); | |
| 1501 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1502 | expect(batch.items[25]?.stack).toBeUndefined(); | |
| 1503 | expect(batch.truncated).toBe(true); | |
| 1504 | expect(batch.continues).toBe(true); | |
| 1505 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); | |
| 1506 | }), | |
| 1507 | ); | |
| 1508 | ||
| 1509 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | |
| 1510 | Effect.gen(function* () { | |
| 1511 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1512 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); | |
| 1513 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | |
| 1514 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1515 | const input = { | |
| 1516 | cwd: "/w", | |
| 1517 | repository: "acme/web", | |
| 1518 | state: "open" as const, | |
| 1519 | involvement: "all" as const, | |
| 1520 | viewer: "bilal", | |
| 1521 | limit: 2, | |
| 1522 | }; | |
| 1523 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); | |
| 1524 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); | |
| 1525 | expect(empty.items).toEqual([]); | |
| 1526 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); | |
| 1527 | expect(mockedStackMemberships).not.toHaveBeenCalled(); | |
| 1528 | }), | |
| 1529 | ); | |
| 1530 | ||
| 1531 | it.effect("reads the line counts in chunks, and files them back by position", () => | |
| 1532 | Effect.gen(function* () { | |
| 1533 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ | |
| 1534 | repository: "acme/web", | |
| 1535 | number: index + 1, | |
| 1536 | })); | |
| 1537 | mockedExecute.mockImplementation(() => | |
| 1538 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is | |
| 1539 | // dropped rather than shown as a change of no size. | |
| 1540 | Effect.succeed( | |
| 1541 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), | |
| 1542 | ), | |
| 1543 | ); | |
| 1544 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1545 | ||
| 1546 | const stats = yield* cli.listPullRequestStats({ | |
| 1547 | cwd: "/w", | |
| 1548 | host: "github.com", | |
| 1549 | changeRequests, | |
| 1550 | }); | |
| 1551 | ||
| 1552 | // Twenty-five aliases a request, so twenty-six rows are two requests. | |
| 1553 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1554 | assert.deepStrictEqual(stats, [ | |
| 1555 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, | |
| 1556 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, | |
| 1557 | ]); | |
| 1558 | expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); | |
| 1559 | expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); | |
| 1560 | }), | |
| 1561 | ); | |
| 1562 | ||
| 1563 | it.effect("refuses to look up counts for a repository GitHub cannot address", () => | |
| 1564 | Effect.gen(function* () { | |
| 1565 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1566 | ||
| 1567 | const failure = yield* Effect.flip( | |
| 1568 | cli.listPullRequestStats({ | |
| 1569 | cwd: "/w", | |
| 1570 | host: "github.com", | |
| 1571 | changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], | |
| 1572 | }), | |
| 1573 | ); | |
| 1574 | ||
| 1575 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1576 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1577 | }), | |
| 1578 | ); | |
| 1579 | ||
| 1580 | it.effect("hands a search to GitHub rather than to the rows already read", () => | |
| 1581 | Effect.gen(function* () { | |
| 1582 | mockedExecute.mockImplementation((call) => | |
| 1583 | Effect.succeed( | |
| 1584 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1585 | ? emptyList() | |
| 1586 | : emptySearch(), | |
| 1587 | ), | |
| 1588 | ); | |
| 1589 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1590 | ||
| 1591 | yield* cli.listPullRequests({ | |
| 1592 | cwd: "/w", | |
| 1593 | repository: "acme/web", | |
| 1594 | host: "github.com", | |
| 1595 | state: "open", | |
| 1596 | involvement: "all", | |
| 1597 | viewer: "bilal", | |
| 1598 | limit: 10, | |
| 1599 | query: "pull requests page", | |
| 1600 | }); | |
| 1601 | ||
| 1602 | // The recency qualifier rides along, because free text would otherwise reorder the page | |
| 1603 | // by relevance and truncation would drop the newest matches. | |
| 1604 | expect(searchOfCall(0)).toBe( | |
| 1605 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', | |
| 1606 | ); | |
| 1607 | }), | |
| 1608 | ); | |
| 1609 | ||
| 1610 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => | |
| 1611 | Effect.gen(function* () { | |
| 1612 | mockedExecute.mockImplementation((call) => | |
| 1613 | Effect.succeed( | |
| 1614 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1615 | ? emptyList() | |
| 1616 | : emptySearch(), | |
| 1617 | ), | |
| 1618 | ); | |
| 1619 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1620 | ||
| 1621 | yield* cli.listPullRequests({ | |
| 1622 | cwd: "/w", | |
| 1623 | repository: "acme/web", | |
| 1624 | host: "github.com", | |
| 1625 | state: "closed", | |
| 1626 | involvement: "reviewing", | |
| 1627 | viewer: "bilal", | |
| 1628 | limit: 10, | |
| 1629 | query: "page", | |
| 1630 | }); | |
| 1631 | ||
| 1632 | expect(searchOfCall(0)).toBe( | |
| 1633 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', | |
| 1634 | ); | |
| 1635 | }), | |
| 1636 | ); | |
| 1637 | ||
| 1638 | it.effect("carries the further narrowings into the search as qualifiers", () => | |
| 1639 | Effect.gen(function* () { | |
| 1640 | mockedExecute.mockImplementation((call) => | |
| 1641 | Effect.succeed( | |
| 1642 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1643 | ? emptyList() | |
| 1644 | : emptySearch(), | |
| 1645 | ), | |
| 1646 | ); | |
| 1647 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1648 | ||
| 1649 | yield* cli.listPullRequests({ | |
| 1650 | cwd: "/w", | |
| 1651 | repository: "acme/web", | |
| 1652 | host: "github.com", | |
| 1653 | state: "open", | |
| 1654 | involvement: "all", | |
| 1655 | viewer: "bilal", | |
| 1656 | limit: 10, | |
| 1657 | filters: { | |
| 1658 | draft: "hide", | |
| 1659 | review: "changes-requested", | |
| 1660 | checks: "failing", | |
| 1661 | labels: [["needs design"], ['quo"te']], | |
| 1662 | excludedLabels: ["wip"], | |
| 1663 | author: "octocat", | |
| 1664 | }, | |
| 1665 | }); | |
| 1666 | ||
| 1667 | // Quotes around anything a reader typed, and the one character that could end a quoted | |
| 1668 | // value early dropped rather than escaped. | |
| 1669 | expect(searchOfCall(0)).toBe( | |
| 1670 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + | |
| 1671 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", | |
| 1672 | ); | |
| 1673 | }), | |
| 1674 | ); | |
| 1675 | ||
| 1676 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => | |
| 1677 | Effect.gen(function* () { | |
| 1678 | mockedExecute.mockImplementation((call) => | |
| 1679 | Effect.succeed( | |
| 1680 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1681 | ? emptyList() | |
| 1682 | : emptySearch(), | |
| 1683 | ), | |
| 1684 | ); | |
| 1685 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1686 | ||
| 1687 | yield* cli.listPullRequests({ | |
| 1688 | cwd: "/w", | |
| 1689 | repository: "acme/web", | |
| 1690 | host: "github.com", | |
| 1691 | state: "open", | |
| 1692 | involvement: "all", | |
| 1693 | viewer: "bilal", | |
| 1694 | limit: 10, | |
| 1695 | filters: { author: "me" }, | |
| 1696 | }); | |
| 1697 | ||
| 1698 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); | |
| 1699 | }), | |
| 1700 | ); | |
| 1701 | ||
| 1702 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => | |
| 1703 | Effect.gen(function* () { | |
| 1704 | mockedExecute.mockImplementation((call) => | |
| 1705 | Effect.succeed( | |
| 1706 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1707 | ? emptyList() | |
| 1708 | : emptySearch(), | |
| 1709 | ), | |
| 1710 | ); | |
| 1711 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1712 | ||
| 1713 | yield* cli.listPullRequests({ | |
| 1714 | cwd: "/w", | |
| 1715 | repository: "acme/web", | |
| 1716 | host: "github.com", | |
| 1717 | state: "open", | |
| 1718 | involvement: "all", | |
| 1719 | viewer: "bilal", | |
| 1720 | limit: 10, | |
| 1721 | filters: { labels: [["size:S", "size:XS"], ["bug"]] }, | |
| 1722 | }); | |
| 1723 | ||
| 1724 | // One qualifier satisfied by either size, and a second one that must hold as well. | |
| 1725 | expect(searchOfCall(0)).toBe( | |
| 1726 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', | |
| 1727 | ); | |
| 1728 | }), | |
| 1729 | ); | |
| 1730 | ||
| 1731 | it.effect( | |
| 1732 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", | |
| 1733 | () => | |
| 1734 | Effect.gen(function* () { | |
| 1735 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1736 | mockedExecute.mockReturnValueOnce( | |
| 1737 | Effect.succeed( | |
| 1738 | output( | |
| 1739 | listedPullRequests(2, 1, (number) => ({ | |
| 1740 | checks: number === 1 ? "SUCCESS" : "FAILURE", | |
| 1741 | })), | |
| 1742 | ), | |
| 1743 | ), | |
| 1744 | ); | |
| 1745 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1746 | ||
| 1747 | const batch = yield* cli.listPullRequests({ | |
| 1748 | cwd: "/w", | |
| 1749 | repository: "acme/web", | |
| 1750 | host: "github.com", | |
| 1751 | state: "open", | |
| 1752 | involvement: "all", | |
| 1753 | viewer: "bilal", | |
| 1754 | limit: 10, | |
| 1755 | filters: { checks: "passing" }, | |
| 1756 | }); | |
| 1757 | ||
| 1758 | // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is | |
| 1759 | // now a filter the fallback judges itself, the same as `draft`: an empty search answer | |
| 1760 | // under it is still ambiguous, and the row picked out afterwards is the one whose own | |
| 1761 | // `checksState` reads "passing". | |
| 1762 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1763 | assert.deepStrictEqual( | |
| 1764 | batch.items.map((item) => item.number), | |
| 1765 | [1], | |
| 1766 | ); | |
| 1767 | }), | |
| 1768 | ); | |
| 1769 | ||
| 1770 | it.effect("fails a checks filter for a row whose checks are still pending", () => | |
| 1771 | Effect.gen(function* () { | |
| 1772 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1773 | mockedExecute.mockReturnValueOnce( | |
| 1774 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), | |
| 1775 | ); | |
| 1776 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1777 | ||
| 1778 | const batch = yield* cli.listPullRequests({ | |
| 1779 | cwd: "/w", | |
| 1780 | repository: "acme/web", | |
| 1781 | host: "github.com", | |
| 1782 | state: "open", | |
| 1783 | involvement: "all", | |
| 1784 | viewer: "bilal", | |
| 1785 | limit: 10, | |
| 1786 | filters: { checks: "passing" }, | |
| 1787 | }); | |
| 1788 | ||
| 1789 | // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — | |
| 1790 | // the same row would also be dropped by `checks: "failing"`. | |
| 1791 | assert.deepStrictEqual(batch.items, []); | |
| 1792 | }), | |
| 1793 | ); | |
| 1794 | ||
| 1795 | it.effect( | |
| 1796 | "falls back for a repository the index does not cover even under a judgeable filter", | |
| 1797 | () => | |
| 1798 | Effect.gen(function* () { | |
| 1799 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1800 | mockedExecute.mockReturnValueOnce( | |
| 1801 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), | |
| 1802 | ); | |
| 1803 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1804 | ||
| 1805 | const batch = yield* cli.listPullRequests({ | |
| 1806 | cwd: "/w", | |
| 1807 | repository: "acme/web", | |
| 1808 | host: "github.com", | |
| 1809 | state: "open", | |
| 1810 | involvement: "all", | |
| 1811 | viewer: "bilal", | |
| 1812 | limit: 10, | |
| 1813 | filters: { draft: "hide" }, | |
| 1814 | }); | |
| 1815 | ||
| 1816 | // `draft` is a filter the fallback can judge over its own rows just as search judges it, | |
| 1817 | // so an empty search answer under it alone is still ambiguous between "nothing matches" | |
| 1818 | // and "this repository is not indexed" — and the fallback applies the filter itself, | |
| 1819 | // keeping only the non-draft row. | |
| 1820 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1821 | expect(batch.items.map((item) => item.number)).toEqual([2]); | |
| 1822 | }), | |
| 1823 | ); | |
| 1824 | ||
| 1825 | it.effect("carries the further narrowings into a batched search", () => | |
| 1826 | Effect.gen(function* () { | |
| 1827 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1828 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1829 | ||
| 1830 | yield* cli.searchPullRequests({ | |
| 1831 | cwd: "/w", | |
| 1832 | host: "github.com", | |
| 1833 | repositories: ["acme/web"], | |
| 1834 | state: "open", | |
| 1835 | involvement: "all", | |
| 1836 | viewer: "bilal", | |
| 1837 | limit: 10, | |
| 1838 | filters: { draft: "only", review: "none", labels: [["bug"]] }, | |
| 1839 | }); | |
| 1840 | ||
| 1841 | assert.strictEqual( | |
| 1842 | searchQueryOfCall(0), | |
| 1843 | 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', | |
| 1844 | ); | |
| 1845 | }), | |
| 1846 | ); | |
| 1847 | ||
| 1848 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => | |
| 1849 | Effect.gen(function* () { | |
| 1850 | mockedExecute.mockImplementation((call) => | |
| 1851 | Effect.succeed( | |
| 1852 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1853 | ? emptyList() | |
| 1854 | : emptySearch(), | |
| 1855 | ), | |
| 1856 | ); | |
| 1857 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1858 | ||
| 1859 | yield* cli.listPullRequests({ | |
| 1860 | cwd: "/w", | |
| 1861 | repository: "acme/web", | |
| 1862 | host: "github.com", | |
| 1863 | state: "open", | |
| 1864 | involvement: "all", | |
| 1865 | viewer: "bilal", | |
| 1866 | limit: 10, | |
| 1867 | query: '-- is:merged label:secret "widen me"', | |
| 1868 | }); | |
| 1869 | ||
| 1870 | // Every word stays inside one phrase: nothing before it, nothing after it, and the | |
| 1871 | // leading dashes are text rather than the start of another argument. | |
| 1872 | expect(searchOfCall(0)).toBe( | |
| 1873 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, | |
| 1874 | ); | |
| 1875 | }), | |
| 1876 | ); | |
| 1877 | ||
| 1878 | it.effect("escapes a backslash before the quote it would otherwise let out", () => | |
| 1879 | Effect.gen(function* () { | |
| 1880 | mockedExecute.mockImplementation((call) => | |
| 1881 | Effect.succeed( | |
| 1882 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1883 | ? emptyList() | |
| 1884 | : emptySearch(), | |
| 1885 | ), | |
| 1886 | ); | |
| 1887 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1888 | ||
| 1889 | yield* cli.listPullRequests({ | |
| 1890 | cwd: "/w", | |
| 1891 | repository: "acme/web", | |
| 1892 | host: "github.com", | |
| 1893 | state: "open", | |
| 1894 | involvement: "all", | |
| 1895 | viewer: "bilal", | |
| 1896 | limit: 10, | |
| 1897 | query: String.raw`a\" is:merged`, | |
| 1898 | }); | |
| 1899 | ||
| 1900 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where | |
| 1901 | // this says it does; escaping the quote alone would have closed it early. | |
| 1902 | expect(searchOfCall(0)).toBe( | |
| 1903 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, | |
| 1904 | ); | |
| 1905 | }), | |
| 1906 | ); | |
| 1907 | ||
| 1908 | it.effect("asks for nothing but the order when the reader typed only spaces", () => | |
| 1909 | Effect.gen(function* () { | |
| 1910 | mockedExecute.mockImplementation((call) => | |
| 1911 | Effect.succeed( | |
| 1912 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1913 | ? emptyList() | |
| 1914 | : emptySearch(), | |
| 1915 | ), | |
| 1916 | ); | |
| 1917 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1918 | ||
| 1919 | yield* cli.listPullRequests({ | |
| 1920 | cwd: "/w", | |
| 1921 | repository: "acme/web", | |
| 1922 | host: "github.com", | |
| 1923 | state: "open", | |
| 1924 | involvement: "all", | |
| 1925 | viewer: "bilal", | |
| 1926 | limit: 10, | |
| 1927 | query: " ", | |
| 1928 | }); | |
| 1929 | ||
| 1930 | // An empty phrase would match nothing rather than everything, so it is left out; the | |
| 1931 | // order the page reads rows in is asked for whether or not anything was typed. | |
| 1932 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1933 | }), | |
| 1934 | ); | |
| 1935 | ||
| 1936 | it.effect("carries on from the instant the last slice ended on", () => | |
| 1937 | Effect.gen(function* () { | |
| 1938 | mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1939 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1940 | ||
| 1941 | const batch = yield* cli.listPullRequests({ | |
| 1942 | cwd: "/w", | |
| 1943 | repository: "acme/web", | |
| 1944 | host: "github.com", | |
| 1945 | state: "open", | |
| 1946 | involvement: "all", | |
| 1947 | viewer: "bilal", | |
| 1948 | limit: 10, | |
| 1949 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1950 | }); | |
| 1951 | ||
| 1952 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — | |
| 1953 | // which is what keeps the ones beside them from being skipped. | |
| 1954 | expect(searchOfCall(0)).toBe( | |
| 1955 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", | |
| 1956 | ); | |
| 1957 | assert.isTrue(batch.continues); | |
| 1958 | }), | |
| 1959 | ); | |
| 1960 | ||
| 1961 | it.effect("answers a search that found nothing with nothing, not with the whole repository", () => | |
| 1962 | Effect.gen(function* () { | |
| 1963 | // The fallback is for a repository the index does not cover. Under a text search an empty | |
| 1964 | // answer means the text matched nothing, and listing everything instead would fill the | |
| 1965 | // page with rows the reader did not search for. | |
| 1966 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1967 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1968 | ||
| 1969 | const batch = yield* cli.listPullRequests({ | |
| 1970 | cwd: "/w", | |
| 1971 | repository: "acme/web", | |
| 1972 | host: "github.com", | |
| 1973 | state: "open", | |
| 1974 | involvement: "all", | |
| 1975 | viewer: "bilal", | |
| 1976 | limit: 10, | |
| 1977 | query: "fdsfklj", | |
| 1978 | }); | |
| 1979 | ||
| 1980 | assert.strictEqual(batch.items.length, 0); | |
| 1981 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1982 | }), | |
| 1983 | ); | |
| 1984 | ||
| 1985 | it.effect("reads a repository GitHub will not search from its own list", () => | |
| 1986 | Effect.gen(function* () { | |
| 1987 | // GitHub answers for a repository outside its search index with no rows and no error. | |
| 1988 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1989 | mockedExecute.mockReturnValueOnce( | |
| 1990 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1991 | ); | |
| 1992 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1993 | ||
| 1994 | const batch = yield* cli.listPullRequests({ | |
| 1995 | cwd: "/w", | |
| 1996 | repository: "acme/web", | |
| 1997 | host: "github.com", | |
| 1998 | state: "closed", | |
| 1999 | involvement: "all", | |
| 2000 | viewer: "bilal", | |
| 2001 | limit: 10, | |
| 2002 | }); | |
| 2003 | ||
| 2004 | assert.strictEqual(batch.items.length, 3); | |
| 2005 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive | |
| 2006 | // newest-created first, so nothing can carry on from them. | |
| 2007 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2008 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | |
| 2009 | assert.isFalse(batch.continues); | |
| 2010 | }), | |
| 2011 | ); | |
| 2012 | ||
| 2013 | it.effect("keeps state and involvement filters on the search-free fallback", () => | |
| 2014 | Effect.gen(function* () { | |
| 2015 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2016 | mockedExecute.mockReturnValueOnce( | |
| 2017 | Effect.succeed( | |
| 2018 | output( | |
| 2019 | listedPullRequests(4, 1, (number) => ({ | |
| 2020 | state: number === 4 ? "OPEN" : "CLOSED", | |
| 2021 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), | |
| 2022 | reviewRequests: | |
| 2023 | number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], | |
| 2024 | })), | |
| 2025 | ), | |
| 2026 | ), | |
| 2027 | ); | |
| 2028 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2029 | ||
| 2030 | const batch = yield* cli.listPullRequests({ | |
| 2031 | cwd: "/w", | |
| 2032 | repository: "acme/web", | |
| 2033 | host: "github.com", | |
| 2034 | state: "closed", | |
| 2035 | involvement: "reviewing", | |
| 2036 | viewer: "bilal", | |
| 2037 | limit: 10, | |
| 2038 | }); | |
| 2039 | ||
| 2040 | // Individual requests for this viewer and team requests survive. The fallback cannot | |
| 2041 | // resolve team membership, so dropping team-routed reviews would hide legitimate work. | |
| 2042 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 2043 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2044 | assert.isFalse(batch.continues); | |
| 2045 | }), | |
| 2046 | ); | |
| 2047 | ||
| 2048 | it.effect("grows the search-free fallback until it fills the filtered page", () => | |
| 2049 | Effect.gen(function* () { | |
| 2050 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); | |
| 2051 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2052 | mockedExecute.mockReturnValueOnce( | |
| 2053 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | |
| 2054 | ); | |
| 2055 | mockedExecute.mockReturnValueOnce( | |
| 2056 | Effect.succeed( | |
| 2057 | output( | |
| 2058 | listedPullRequests(4, 1, (number) => | |
| 2059 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), | |
| 2060 | ), | |
| 2061 | ), | |
| 2062 | ), | |
| 2063 | ); | |
| 2064 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2065 | ||
| 2066 | const batch = yield* cli.listPullRequests({ | |
| 2067 | cwd: "/w", | |
| 2068 | repository: "acme/web", | |
| 2069 | host: "github.com", | |
| 2070 | state: "open", | |
| 2071 | involvement: "reviewing", | |
| 2072 | viewer: "bilal", | |
| 2073 | limit: 2, | |
| 2074 | }); | |
| 2075 | ||
| 2076 | expect(batch.items.map((item) => item.number)).toEqual([4]); | |
| 2077 | expect(queryAt(1)).toContain("first: 3,"); | |
| 2078 | expect(queryAt(2)).toContain("first: 6,"); | |
| 2079 | assert.isFalse(batch.truncated); | |
| 2080 | }), | |
| 2081 | ); | |
| 2082 | ||
| 2083 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => | |
| 2084 | Effect.gen(function* () { | |
| 2085 | // A repository with far more pull requests than the bound, none of them for this reader. | |
| 2086 | let listed = 0; | |
| 2087 | mockedExecute.mockImplementation((call) => { | |
| 2088 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { | |
| 2089 | return Effect.succeed(emptySearch()); | |
| 2090 | } | |
| 2091 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); | |
| 2092 | const nodes = rows(first, listed + 1, () => ({ | |
| 2093 | reviewRequests: [{ login: "somebody-else" }], | |
| 2094 | })); | |
| 2095 | listed += first; | |
| 2096 | return Effect.succeed( | |
| 2097 | output( | |
| 2098 | encodeJson({ | |
| 2099 | data: { | |
| 2100 | repository: { | |
| 2101 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, | |
| 2102 | }, | |
| 2103 | }, | |
| 2104 | }), | |
| 2105 | ), | |
| 2106 | ); | |
| 2107 | }); | |
| 2108 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2109 | ||
| 2110 | const batch = yield* cli.listPullRequests({ | |
| 2111 | cwd: "/w", | |
| 2112 | repository: "acme/web", | |
| 2113 | host: "github.com", | |
| 2114 | state: "open", | |
| 2115 | involvement: "reviewing", | |
| 2116 | viewer: "bilal", | |
| 2117 | limit: 2, | |
| 2118 | }); | |
| 2119 | ||
| 2120 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. | |
| 2121 | const last = mockedExecute.mock.calls.length - 1; | |
| 2122 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); | |
| 2123 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); | |
| 2124 | assert.strictEqual(batch.items.length, 0); | |
| 2125 | assert.isTrue(batch.truncated); | |
| 2126 | }), | |
| 2127 | ); | |
| 2128 | ||
| 2129 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => | |
| 2130 | Effect.gen(function* () { | |
| 2131 | mockedExecute.mockImplementation((call) => | |
| 2132 | Effect.succeed( | |
| 2133 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2134 | ? emptyList() | |
| 2135 | : emptySearch(), | |
| 2136 | ), | |
| 2137 | ); | |
| 2138 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2139 | ||
| 2140 | yield* cli.listPullRequests({ | |
| 2141 | cwd: "/w", | |
| 2142 | repository: "acme/web", | |
| 2143 | host: "github.com", | |
| 2144 | state: "open", | |
| 2145 | involvement: "all", | |
| 2146 | viewer: "bilal", | |
| 2147 | limit: 10, | |
| 2148 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 2149 | }); | |
| 2150 | ||
| 2151 | // A repository that answered the search once answers it again, so an empty slice under a | |
| 2152 | // cursor is the end of it rather than a repository search cannot reach. | |
| 2153 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2154 | }), | |
| 2155 | ); | |
| 2156 | ||
| 2157 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ | |
| 2158 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 2159 | data: { | |
| 2160 | repository: { | |
| 2161 | pullRequest: { | |
| 2162 | id: "PR_7", | |
| 2163 | headRefOid: "abc123", | |
| 2164 | isMergeQueueEnabled: false, | |
| 2165 | mergeStateStatus: "BLOCKED", | |
| 2166 | baseRef: { compare: { behindBy: 2 } }, | |
| 2167 | ...pullRequest, | |
| 2168 | }, | |
| 2169 | }, | |
| 2170 | }, | |
| 2171 | }); | |
| 2172 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ | |
| 2173 | data: { | |
| 2174 | repository: { | |
| 2175 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, | |
| 2176 | }, | |
| 2177 | }, | |
| 2178 | }); | |
| 2179 | ||
| 2180 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => | |
| 2181 | Effect.gen(function* () { | |
| 2182 | route(["query PullRequestActionState", actionState()]); | |
| 2183 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2184 | ||
| 2185 | yield* cli.runPullRequestAction({ | |
| 2186 | cwd: "/w", | |
| 2187 | repository: "acme/web", | |
| 2188 | host: "github.com", | |
| 2189 | number: 7, | |
| 2190 | action: "update-branch", | |
| 2191 | }); | |
| 2192 | yield* cli.runPullRequestAction({ | |
| 2193 | cwd: "/w", | |
| 2194 | repository: "acme/web", | |
| 2195 | host: "github.com", | |
| 2196 | number: 7, | |
| 2197 | action: "update-branch", | |
| 2198 | updateMethod: "rebase", | |
| 2199 | }); | |
| 2200 | ||
| 2201 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. | |
| 2202 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ | |
| 2203 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, | |
| 2204 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, | |
| 2205 | ]); | |
| 2206 | }), | |
| 2207 | ); | |
| 2208 | ||
| 2209 | it.effect("merges with the strategy it was asked for", () => | |
| 2210 | Effect.gen(function* () { | |
| 2211 | route(["query PullRequestActionState", actionState()]); | |
| 2212 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2213 | ||
| 2214 | yield* cli.runPullRequestAction({ | |
| 2215 | cwd: "/w", | |
| 2216 | repository: "acme/web", | |
| 2217 | host: "github.com", | |
| 2218 | number: 7, | |
| 2219 | action: "merge", | |
| 2220 | mergeMethod: "squash", | |
| 2221 | }); | |
| 2222 | ||
| 2223 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2224 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2225 | ]); | |
| 2226 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2227 | }), | |
| 2228 | ); | |
| 2229 | ||
| 2230 | it.effect.each(["merge", "enable-auto-merge"] as const)( | |
| 2231 | "removes agent credits from the proposed message for %s", | |
| 2232 | (action) => | |
| 2233 | Effect.gen(function* () { | |
| 2234 | route( | |
| 2235 | [ | |
| 2236 | "query PullRequestMergeMessage", | |
| 2237 | mergeMessage( | |
| 2238 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | |
| 2239 | ), | |
| 2240 | ], | |
| 2241 | ["query PullRequestActionState", actionState()], | |
| 2242 | ); | |
| 2243 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2244 | yield* cli.runPullRequestAction({ | |
| 2245 | cwd: "/w", | |
| 2246 | repository: "acme/web", | |
| 2247 | host: "github.com", | |
| 2248 | number: 7, | |
| 2249 | action, | |
| 2250 | mergeMethod: "squash", | |
| 2251 | removeAgentCreditsOnMerge: true, | |
| 2252 | }); | |
| 2253 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); | |
| 2254 | expect( | |
| 2255 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), | |
| 2256 | ).toEqual([ | |
| 2257 | { | |
| 2258 | input: { | |
| 2259 | pullRequestId: "PR_7", | |
| 2260 | mergeMethod: "SQUASH", | |
| 2261 | // Pinned to the head the message was read from, so it cannot describe other commits. | |
| 2262 | expectedHeadOid: "abc123", | |
| 2263 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2264 | }, | |
| 2265 | }, | |
| 2266 | ]); | |
| 2267 | }), | |
| 2268 | ); | |
| 2269 | ||
| 2270 | it.effect.each([ | |
| 2271 | { | |
| 2272 | description: "an unchanged message", | |
| 2273 | body: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2274 | queued: false, | |
| 2275 | }, | |
| 2276 | { | |
| 2277 | description: "a merge queue", | |
| 2278 | body: "Co-authored-by: Claude <noreply@anthropic.com>", | |
| 2279 | queued: true, | |
| 2280 | }, | |
| 2281 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => | |
| 2282 | Effect.gen(function* () { | |
| 2283 | route( | |
| 2284 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], | |
| 2285 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], | |
| 2286 | ); | |
| 2287 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2288 | yield* cli.runPullRequestAction({ | |
| 2289 | cwd: "/w", | |
| 2290 | repository: "acme/web", | |
| 2291 | host: "github.com", | |
| 2292 | number: 7, | |
| 2293 | action: "merge", | |
| 2294 | removeAgentCreditsOnMerge: true, | |
| 2295 | }); | |
| 2296 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); | |
| 2297 | // A merge queue takes the pull request through auto-merge, with its own message. | |
| 2298 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ | |
| 2299 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2300 | ]); | |
| 2301 | }), | |
| 2302 | ); | |
| 2303 | ||
| 2304 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => | |
| 2305 | Effect.gen(function* () { | |
| 2306 | route( | |
| 2307 | [ | |
| 2308 | "query PullRequestMergeMessage", | |
| 2309 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), | |
| 2310 | ], | |
| 2311 | ["query PullRequestActionState", actionState()], | |
| 2312 | ); | |
| 2313 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2314 | yield* cli.runPullRequestAction({ | |
| 2315 | cwd: "/w", | |
| 2316 | repository: "acme/web", | |
| 2317 | host: "github.com", | |
| 2318 | number: 7, | |
| 2319 | action: "merge", | |
| 2320 | removeAgentCreditsOnMerge: true, | |
| 2321 | }); | |
| 2322 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2323 | { | |
| 2324 | input: { | |
| 2325 | pullRequestId: "PR_7", | |
| 2326 | mergeMethod: "MERGE", | |
| 2327 | expectedHeadOid: "abc123", | |
| 2328 | commitBody: "", | |
| 2329 | }, | |
| 2330 | }, | |
| 2331 | ]); | |
| 2332 | }), | |
| 2333 | ); | |
| 2334 | ||
| 2335 | it.effect("does not fetch a message for rebase merges", () => | |
| 2336 | Effect.gen(function* () { | |
| 2337 | route(["query PullRequestActionState", actionState()]); | |
| 2338 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2339 | yield* cli.runPullRequestAction({ | |
| 2340 | cwd: "/w", | |
| 2341 | repository: "acme/web", | |
| 2342 | host: "github.com", | |
| 2343 | number: 7, | |
| 2344 | action: "merge", | |
| 2345 | mergeMethod: "rebase", | |
| 2346 | removeAgentCreditsOnMerge: true, | |
| 2347 | }); | |
| 2348 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); | |
| 2349 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2350 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | |
| 2351 | ]); | |
| 2352 | }), | |
| 2353 | ); | |
| 2354 | ||
| 2355 | it.effect("refuses to merge when the proposed message cannot be read", () => | |
| 2356 | Effect.gen(function* () { | |
| 2357 | route( | |
| 2358 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], | |
| 2359 | ["query PullRequestActionState", actionState()], | |
| 2360 | ); | |
| 2361 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2362 | const result = yield* Effect.result( | |
| 2363 | cli.runPullRequestAction({ | |
| 2364 | cwd: "/w", | |
| 2365 | repository: "acme/web", | |
| 2366 | host: "github.com", | |
| 2367 | number: 7, | |
| 2368 | action: "merge", | |
| 2369 | removeAgentCreditsOnMerge: true, | |
| 2370 | }), | |
| 2371 | ); | |
| 2372 | expect(result._tag).toBe("Failure"); | |
| 2373 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2374 | }), | |
| 2375 | ); | |
| 2376 | ||
| 2377 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | |
| 2378 | Effect.gen(function* () { | |
| 2379 | route(["query PullRequestActionState", actionState()]); | |
| 2380 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2381 | ||
| 2382 | yield* cli.runPullRequestAction({ | |
| 2383 | cwd: "/w", | |
| 2384 | repository: "acme/web", | |
| 2385 | host: "github.com", | |
| 2386 | number: 7, | |
| 2387 | action: "enable-auto-merge", | |
| 2388 | mergeMethod: "squash", | |
| 2389 | }); | |
| 2390 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | |
| 2391 | yield* cli.runPullRequestAction({ | |
| 2392 | cwd: "/w", | |
| 2393 | repository: "acme/web", | |
| 2394 | host: "github.com", | |
| 2395 | number: 7, | |
| 2396 | action: "enable-auto-merge", | |
| 2397 | }); | |
| 2398 | ||
| 2399 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ | |
| 2400 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2401 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2402 | ]); | |
| 2403 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2404 | }), | |
| 2405 | ); | |
| 2406 | ||
| 2407 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => | |
| 2408 | Effect.gen(function* () { | |
| 2409 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); | |
| 2410 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2411 | ||
| 2412 | yield* cli.runPullRequestAction({ | |
| 2413 | cwd: "/w", | |
| 2414 | repository: "acme/web", | |
| 2415 | host: "github.com", | |
| 2416 | number: 7, | |
| 2417 | action: "enable-auto-merge", | |
| 2418 | mergeMethod: "squash", | |
| 2419 | }); | |
| 2420 | ||
| 2421 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2422 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2423 | ]); | |
| 2424 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2425 | }), | |
| 2426 | ); | |
| 2427 | ||
| 2428 | it.effect("takes auto-merge back off without naming a strategy", () => | |
| 2429 | Effect.gen(function* () { | |
| 2430 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2431 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2432 | ||
| 2433 | yield* cli.runPullRequestAction({ | |
| 2434 | cwd: "/w", | |
| 2435 | repository: "acme/web", | |
| 2436 | host: "github.com", | |
| 2437 | number: 7, | |
| 2438 | action: "disable-auto-merge", | |
| 2439 | mergeMethod: "squash", | |
| 2440 | }); | |
| 2441 | ||
| 2442 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2443 | }), | |
| 2444 | ); | |
| 2445 | ||
| 2446 | it.effect("opens a pull request that reverts a merged pull request", () => | |
| 2447 | Effect.gen(function* () { | |
| 2448 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2449 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2450 | ||
| 2451 | yield* cli.runPullRequestAction({ | |
| 2452 | cwd: "/w", | |
| 2453 | repository: "acme/web", | |
| 2454 | host: "github.com", | |
| 2455 | number: 7, | |
| 2456 | action: "revert", | |
| 2457 | }); | |
| 2458 | ||
| 2459 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2460 | }), | |
| 2461 | ); | |
| 2462 | ||
| 2463 | it.effect("does not approve action-required runs for a same-repository pull request", () => | |
| 2464 | Effect.gen(function* () { | |
| 2465 | mockedExecute.mockReturnValueOnce( | |
| 2466 | Effect.succeed( | |
| 2467 | output( | |
| 2468 | encodeJson( | |
| 2469 | coreResponse({ | |
| 2470 | number: 7, | |
| 2471 | title: "Pull request 7", | |
| 2472 | url: "https://github.com/acme/web/pull/7", | |
| 2473 | headRefName: "feat/page", | |
| 2474 | headRefOid: "abc123", | |
| 2475 | isCrossRepository: false, | |
| 2476 | headRepositoryOwner: { login: "acme" }, | |
| 2477 | baseRefName: "main", | |
| 2478 | createdAt: "2026-07-01T00:00:00Z", | |
| 2479 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2480 | }), | |
| 2481 | ), | |
| 2482 | ), | |
| 2483 | ), | |
| 2484 | ); | |
| 2485 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2486 | ||
| 2487 | yield* cli.runPullRequestAction({ | |
| 2488 | cwd: "/w", | |
| 2489 | repository: "acme/web", | |
| 2490 | host: "github.com", | |
| 2491 | number: 7, | |
| 2492 | action: "approve-workflows", | |
| 2493 | }); | |
| 2494 | ||
| 2495 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2496 | }), | |
| 2497 | ); | |
| 2498 | ||
| 2499 | it.effect("finds and approves every workflow waiting on a maintainer", () => | |
| 2500 | Effect.gen(function* () { | |
| 2501 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); | |
| 2502 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2503 | ||
| 2504 | yield* cli.runPullRequestAction({ | |
| 2505 | cwd: "/w", | |
| 2506 | repository: "acme/web", | |
| 2507 | host: "github.com", | |
| 2508 | number: 7, | |
| 2509 | action: "approve-workflows", | |
| 2510 | }); | |
| 2511 | ||
| 2512 | // Open pull requests on this head branch, and runs scoped to this exact head. | |
| 2513 | expect(variablesOf("headRefName: $head")[0]).toEqual({ | |
| 2514 | owner: "acme", | |
| 2515 | name: "web", | |
| 2516 | head: "feat/page", | |
| 2517 | after: null, | |
| 2518 | }); | |
| 2519 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( | |
| 2520 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", | |
| 2521 | ); | |
| 2522 | // Each run is approved only after the head is read again and still lists it. | |
| 2523 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ | |
| 2524 | ["POST", "repos/acme/web/actions/runs/10/approve"], | |
| 2525 | ["POST", "repos/acme/web/actions/runs/11/approve"], | |
| 2526 | ]); | |
| 2527 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); | |
| 2528 | }), | |
| 2529 | ); | |
| 2530 | ||
| 2531 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => | |
| 2532 | Effect.gen(function* () { | |
| 2533 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); | |
| 2534 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2535 | ||
| 2536 | const runs = yield* cli | |
| 2537 | .listWorkflowRunsRequiringApproval({ | |
| 2538 | cwd: "/w", | |
| 2539 | repository: "acme/web", | |
| 2540 | host: "github.com", | |
| 2541 | number: 7, | |
| 2542 | headSha: "abc123", | |
| 2543 | headBranch: "feat/page", | |
| 2544 | headRepositoryOwner: "octocat", | |
| 2545 | isCrossRepository: true, | |
| 2546 | }) | |
| 2547 | .pipe( | |
| 2548 | Effect.provideService(KnownWorkflowRuns, { | |
| 2549 | headSha: "abc123", | |
| 2550 | runs: [ | |
| 2551 | // Live shape: waiting on approval is completed + action_required. | |
| 2552 | { | |
| 2553 | id: 10, | |
| 2554 | status: "completed", | |
| 2555 | conclusion: "action_required", | |
| 2556 | head_branch: "feat/page", | |
| 2557 | }, | |
| 2558 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, | |
| 2559 | ], | |
| 2560 | }), | |
| 2561 | ); | |
| 2562 | ||
| 2563 | expect(runs.map((run) => run.id)).toEqual([10]); | |
| 2564 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); | |
| 2565 | }), | |
| 2566 | ); | |
| 2567 | ||
| 2568 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | |
| 2569 | Effect.gen(function* () { | |
| 2570 | let detailReads = 0; | |
| 2571 | workflowApprovalRoutes( | |
| 2572 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), | |
| 2573 | heads([7]), | |
| 2574 | workflowRuns([10]), | |
| 2575 | ); | |
| 2576 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2577 | ||
| 2578 | const error = yield* Effect.flip( | |
| 2579 | cli.runPullRequestAction({ | |
| 2580 | cwd: "/w", | |
| 2581 | repository: "acme/web", | |
| 2582 | host: "github.com", | |
| 2583 | number: 7, | |
| 2584 | action: "approve-workflows", | |
| 2585 | }), | |
| 2586 | ); | |
| 2587 | ||
| 2588 | expect(error).toMatchObject({ | |
| 2589 | _tag: "GitHubWorkflowApprovalHeadChangedError", | |
| 2590 | number: 7, | |
| 2591 | }); | |
| 2592 | assert.strictEqual(detailReads, 2); | |
| 2593 | expect(restCallsTo("/approve")).toEqual([]); | |
| 2594 | }), | |
| 2595 | ); | |
| 2596 | ||
| 2597 | it.effect("reads workflow runs and their pull request scope concurrently", () => | |
| 2598 | Effect.gen(function* () { | |
| 2599 | const headsStarted = yield* Deferred.make<void>(); | |
| 2600 | const runsStarted = yield* Deferred.make<void>(); | |
| 2601 | mockedExecute.mockImplementation((call) => | |
| 2602 | call.kind === "graphql" | |
| 2603 | ? Deferred.succeed(headsStarted, undefined).pipe( | |
| 2604 | Effect.andThen(Deferred.await(runsStarted)), | |
| 2605 | Effect.as(output(encodeJson(heads([7])))), | |
| 2606 | ) | |
| 2607 | : Deferred.succeed(runsStarted, undefined).pipe( | |
| 2608 | Effect.andThen(Deferred.await(headsStarted)), | |
| 2609 | Effect.as(output(encodeJson(workflowRuns([10])))), | |
| 2610 | ), | |
| 2611 | ); | |
| 2612 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2613 | ||
| 2614 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ | |
| 2615 | cwd: "/w", | |
| 2616 | repository: "acme/web", | |
| 2617 | host: "github.com", | |
| 2618 | number: 7, | |
| 2619 | headSha: "abc123", | |
| 2620 | headBranch: "feat/page", | |
| 2621 | headRepositoryOwner: "octocat", | |
| 2622 | isCrossRepository: true, | |
| 2623 | }); | |
| 2624 | ||
| 2625 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); | |
| 2626 | }), | |
| 2627 | ); | |
| 2628 | ||
| 2629 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | |
| 2630 | Effect.gen(function* () { | |
| 2631 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); | |
| 2632 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2633 | ||
| 2634 | const error = yield* Effect.flip( | |
| 2635 | cli.listWorkflowRunsRequiringApproval({ | |
| 2636 | cwd: "/w", | |
| 2637 | repository: "acme/web", | |
| 2638 | host: "github.com", | |
| 2639 | number: 7, | |
| 2640 | headSha: "abc123", | |
| 2641 | headBranch: "feat/page", | |
| 2642 | headRepositoryOwner: "octocat", | |
| 2643 | isCrossRepository: true, | |
| 2644 | }), | |
| 2645 | ); | |
| 2646 | ||
| 2647 | expect(error).toMatchObject({ | |
| 2648 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2649 | reason: "head-not-unique", | |
| 2650 | number: 7, | |
| 2651 | observedCount: 2, | |
| 2652 | limit: 1_000, | |
| 2653 | }); | |
| 2654 | expect(error.message).toContain("instead of uniquely matching #7"); | |
| 2655 | }), | |
| 2656 | ); | |
| 2657 | ||
| 2658 | it.effect("refuses workflow approval when the head list stops before its end", () => | |
| 2659 | Effect.gen(function* () { | |
| 2660 | // GitHub hands the same cursor back, so the second page is never read past. #7 looks | |
| 2661 | // unique on what was read, and an unread page could still hold another head like it. | |
| 2662 | const repeating = heads([7]); | |
| 2663 | repeating.data.repository.pullRequests.pageInfo = { | |
| 2664 | hasNextPage: true, | |
| 2665 | endCursor: "same", | |
| 2666 | } as never; | |
| 2667 | workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); | |
| 2668 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2669 | ||
| 2670 | const error = yield* Effect.flip( | |
| 2671 | cli.listWorkflowRunsRequiringApproval({ | |
| 2672 | cwd: "/w", | |
| 2673 | repository: "acme/web", | |
| 2674 | host: "github.com", | |
| 2675 | number: 7, | |
| 2676 | headSha: "abc123", | |
| 2677 | headBranch: "feat/page", | |
| 2678 | headRepositoryOwner: "octocat", | |
| 2679 | isCrossRepository: true, | |
| 2680 | }), | |
| 2681 | ); | |
| 2682 | ||
| 2683 | expect(error).toMatchObject({ | |
| 2684 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2685 | reason: "head-list-truncated", | |
| 2686 | }); | |
| 2687 | }), | |
| 2688 | ); | |
| 2689 | ||
| 2690 | it.effect("refuses workflow approval when GitHub omits the head repository", () => | |
| 2691 | Effect.gen(function* () { | |
| 2692 | mockedExecute.mockReturnValueOnce( | |
| 2693 | Effect.succeed( | |
| 2694 | output( | |
| 2695 | encodeJson( | |
| 2696 | coreResponse({ | |
| 2697 | number: 7, | |
| 2698 | title: "Pull request 7", | |
| 2699 | url: "https://github.com/acme/web/pull/7", | |
| 2700 | headRefName: "feat/page", | |
| 2701 | headRefOid: "abc123", | |
| 2702 | isCrossRepository: true, | |
| 2703 | headRepositoryOwner: null, | |
| 2704 | baseRefName: "main", | |
| 2705 | createdAt: "2026-07-01T00:00:00Z", | |
| 2706 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2707 | }), | |
| 2708 | ), | |
| 2709 | ), | |
| 2710 | ), | |
| 2711 | ); | |
| 2712 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2713 | ||
| 2714 | const error = yield* Effect.flip( | |
| 2715 | cli.runPullRequestAction({ | |
| 2716 | cwd: "/w", | |
| 2717 | repository: "acme/web", | |
| 2718 | host: "github.com", | |
| 2719 | number: 7, | |
| 2720 | action: "approve-workflows", | |
| 2721 | }), | |
| 2722 | ); | |
| 2723 | ||
| 2724 | expect(error).toMatchObject({ | |
| 2725 | _tag: "GitHubWorkflowApprovalHeadUnavailableError", | |
| 2726 | number: 7, | |
| 2727 | }); | |
| 2728 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2729 | }), | |
| 2730 | ); | |
| 2731 | ||
| 2732 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => | |
| 2733 | Effect.gen(function* () { | |
| 2734 | let runPages = 0; | |
| 2735 | mockedExecute.mockImplementation((call) => { | |
| 2736 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); | |
| 2737 | const page = runPages++; | |
| 2738 | return Effect.succeed( | |
| 2739 | output( | |
| 2740 | encodeJson( | |
| 2741 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), | |
| 2742 | ), | |
| 2743 | ), | |
| 2744 | ); | |
| 2745 | }); | |
| 2746 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2747 | ||
| 2748 | const error = yield* Effect.flip( | |
| 2749 | cli.listWorkflowRunsRequiringApproval({ | |
| 2750 | cwd: "/w", | |
| 2751 | repository: "acme/web", | |
| 2752 | host: "github.com", | |
| 2753 | number: 7, | |
| 2754 | headSha: "abc123", | |
| 2755 | headBranch: "feat/page", | |
| 2756 | headRepositoryOwner: "octocat", | |
| 2757 | isCrossRepository: true, | |
| 2758 | }), | |
| 2759 | ); | |
| 2760 | ||
| 2761 | // Paged a hundred at a time, it stops at the first page past the bound. | |
| 2762 | expect(error).toMatchObject({ | |
| 2763 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2764 | reason: "run-list-truncated", | |
| 2765 | number: 7, | |
| 2766 | observedCount: 1_100, | |
| 2767 | limit: 1_000, | |
| 2768 | }); | |
| 2769 | expect(error.message).toContain("more than 1000 workflow runs"); | |
| 2770 | assert.strictEqual(runPages, 11); | |
| 2771 | }), | |
| 2772 | ); | |
| 2773 | ||
| 2774 | it.effect("returns a pull request to draft by converting it", () => | |
| 2775 | Effect.gen(function* () { | |
| 2776 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2777 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2778 | ||
| 2779 | yield* cli.runPullRequestAction({ | |
| 2780 | cwd: "/w", | |
| 2781 | repository: "acme/web", | |
| 2782 | host: "github.com", | |
| 2783 | number: 7, | |
| 2784 | action: "draft", | |
| 2785 | }); | |
| 2786 | ||
| 2787 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2788 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); | |
| 2789 | }), | |
| 2790 | ); | |
| 2791 | ||
| 2792 | it.effect("sends a comment body as a variable, never inside the document", () => | |
| 2793 | Effect.gen(function* () { | |
| 2794 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2795 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2796 | ||
| 2797 | yield* cli.commentOnPullRequest({ | |
| 2798 | cwd: "/w", | |
| 2799 | repository: "acme/web", | |
| 2800 | host: "github.com", | |
| 2801 | number: 7, | |
| 2802 | body: "Looks good.", | |
| 2803 | }); | |
| 2804 | ||
| 2805 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); | |
| 2806 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); | |
| 2807 | }), | |
| 2808 | ); | |
| 2809 | ||
| 2810 | it.effect("names the host on every repository it addresses", () => | |
| 2811 | Effect.gen(function* () { | |
| 2812 | mockedExecute.mockImplementation((call) => | |
| 2813 | Effect.succeed( | |
| 2814 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2815 | ? emptyList() | |
| 2816 | : emptySearch(), | |
| 2817 | ), | |
| 2818 | ); | |
| 2819 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2820 | ||
| 2821 | yield* cli.listPullRequests({ | |
| 2822 | cwd: "/w", | |
| 2823 | repository: "acme/web", | |
| 2824 | host: "github.acme.dev", | |
| 2825 | state: "open", | |
| 2826 | involvement: "all", | |
| 2827 | viewer: "bilal", | |
| 2828 | limit: 10, | |
| 2829 | }); | |
| 2830 | ||
| 2831 | // A request sent to github.com would read a different repository of the same name. | |
| 2832 | assert.isAbove(mockedExecute.mock.calls.length, 0); | |
| 2833 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( | |
| 2834 | new Set(["github.acme.dev"]), | |
| 2835 | ); | |
| 2836 | expect(searchOfCall(0)).toContain("repo:acme/web"); | |
| 2837 | }), | |
| 2838 | ); | |
| 2839 | ||
| 2840 | it.effect("asks a GitHub Enterprise host for its own review threads", () => | |
| 2841 | Effect.gen(function* () { | |
| 2842 | mockedExecute.mockReturnValueOnce( | |
| 2843 | Effect.succeed( | |
| 2844 | output( | |
| 2845 | encodeJson({ | |
| 2846 | data: { | |
| 2847 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, | |
| 2848 | }, | |
| 2849 | }), | |
| 2850 | ), | |
| 2851 | ), | |
| 2852 | ); | |
| 2853 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2854 | ||
| 2855 | yield* cli.listReviewThreadComments({ | |
| 2856 | cwd: "/w", | |
| 2857 | repository: "acme/web", | |
| 2858 | host: "github.acme.dev", | |
| 2859 | number: 7, | |
| 2860 | }); | |
| 2861 | ||
| 2862 | expect(callAt(0).host).toBe("github.acme.dev"); | |
| 2863 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 2864 | }), | |
| 2865 | ); | |
| 2866 | ||
| 2867 | it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => | |
| 2868 | Effect.gen(function* () { | |
| 2869 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); | |
| 2870 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2871 | ||
| 2872 | const diff = yield* cli.getPullRequestDiff({ | |
| 2873 | cwd: "/w", | |
| 2874 | repository: "acme/web", | |
| 2875 | host: "github.com", | |
| 2876 | number: 7, | |
| 2877 | }); | |
| 2878 | ||
| 2879 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | |
| 2880 | assert.isNull(diff.nextCursor); | |
| 2881 | assert.isFalse(diff.truncated); | |
| 2882 | // The common case pays for one request and not the files API on top of it. | |
| 2883 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2884 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. | |
| 2885 | const call = callAt(0); | |
| 2886 | assert.strictEqual(call.kind, "rest"); | |
| 2887 | if (call.kind === "rest") { | |
| 2888 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); | |
| 2889 | assert.strictEqual(call.accept, "application/vnd.github.diff"); | |
| 2890 | } | |
| 2891 | }), | |
| 2892 | ); | |
| 2893 | ||
| 2894 | it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => | |
| 2895 | Effect.gen(function* () { | |
| 2896 | // GitHub answers 406 rather than a diff past 300 changed files. | |
| 2897 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2898 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); | |
| 2899 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2900 | ||
| 2901 | const diff = yield* cli.getPullRequestDiff({ | |
| 2902 | cwd: "/w", | |
| 2903 | repository: "acme/web", | |
| 2904 | host: "github.acme.dev", | |
| 2905 | number: 7, | |
| 2906 | }); | |
| 2907 | ||
| 2908 | assert.isFalse(diff.truncated); | |
| 2909 | // A short page is the end of the change set, so there is nothing to carry on from. | |
| 2910 | assert.isNull(diff.nextCursor); | |
| 2911 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2912 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); | |
| 2913 | assert.strictEqual(callAt(1).host, "github.acme.dev"); | |
| 2914 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 2915 | }), | |
| 2916 | ); | |
| 2917 | ||
| 2918 | it.effect("hands back a cursor for the next page rather than walking on by itself", () => | |
| 2919 | Effect.gen(function* () { | |
| 2920 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2921 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2922 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2923 | ||
| 2924 | const diff = yield* cli.getPullRequestDiff({ | |
| 2925 | cwd: "/w", | |
| 2926 | repository: "acme/web", | |
| 2927 | host: "github.com", | |
| 2928 | number: 7, | |
| 2929 | }); | |
| 2930 | ||
| 2931 | // A full page means more files, which the reader asks for; it is not a truncated slice. | |
| 2932 | assert.isFalse(diff.truncated); | |
| 2933 | assert.isNotNull(diff.nextCursor); | |
| 2934 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2935 | }), | |
| 2936 | ); | |
| 2937 | ||
| 2938 | it.effect("carries on from a cursor without asking for the whole diff again", () => | |
| 2939 | Effect.gen(function* () { | |
| 2940 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2941 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2942 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2943 | const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; | |
| 2944 | ||
| 2945 | const first = yield* cli.getPullRequestDiff(target); | |
| 2946 | assert.isNotNull(first.nextCursor); | |
| 2947 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); | |
| 2948 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2949 | ||
| 2950 | assert.isNull(second.nextCursor); | |
| 2951 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); | |
| 2952 | // The second slice is one request: the cursor already says where to read. | |
| 2953 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 2954 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); | |
| 2955 | }), | |
| 2956 | ); | |
| 2957 | ||
| 2958 | it.effect("refuses a cursor it never handed out rather than reading it into a request", () => | |
| 2959 | Effect.gen(function* () { | |
| 2960 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2961 | ||
| 2962 | const error = yield* Effect.flip( | |
| 2963 | cli.getPullRequestDiff({ | |
| 2964 | cwd: "/w", | |
| 2965 | repository: "acme/web", | |
| 2966 | host: "github.com", | |
| 2967 | number: 7, | |
| 2968 | cursor: "1&per_page=1", | |
| 2969 | }), | |
| 2970 | ); | |
| 2971 | ||
| 2972 | assert.strictEqual(error._tag, "GitHubDiffCursorError"); | |
| 2973 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2974 | }), | |
| 2975 | ); | |
| 2976 | ||
| 2977 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => | |
| 2978 | Effect.gen(function* () { | |
| 2979 | mockedExecute.mockReturnValueOnce( | |
| 2980 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), | |
| 2981 | ); | |
| 2982 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2983 | ||
| 2984 | const diff = yield* cli.getPullRequestDiff({ | |
| 2985 | cwd: "/w", | |
| 2986 | repository: "acme/web", | |
| 2987 | host: "github.com", | |
| 2988 | number: 7, | |
| 2989 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 2990 | }); | |
| 2991 | ||
| 2992 | // One request: the commit's own changes never take the whole-diff road. | |
| 2993 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2994 | assert.isNull(diff.nextCursor); | |
| 2995 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | |
| 2996 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2997 | assert.strictEqual( | |
| 2998 | pathAt(0), | |
| 2999 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", | |
| 3000 | ); | |
| 3001 | }), | |
| 3002 | ); | |
| 3003 | ||
| 3004 | it.effect("pages inside a commit the way it pages the pull request's own files", () => | |
| 3005 | Effect.gen(function* () { | |
| 3006 | mockedExecute.mockReturnValueOnce( | |
| 3007 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), | |
| 3008 | ); | |
| 3009 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3010 | const target = { | |
| 3011 | cwd: "/w", | |
| 3012 | repository: "acme/web", | |
| 3013 | host: "github.com", | |
| 3014 | number: 7, | |
| 3015 | commit: "a1b2c3d", | |
| 3016 | }; | |
| 3017 | ||
| 3018 | const first = yield* cli.getPullRequestDiff(target); | |
| 3019 | assert.isNotNull(first.nextCursor); | |
| 3020 | mockedExecute.mockReturnValueOnce( | |
| 3021 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), | |
| 3022 | ); | |
| 3023 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 3024 | ||
| 3025 | assert.isNull(second.nextCursor); | |
| 3026 | expect(second.patch).toContain("src/file100.ts"); | |
| 3027 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | |
| 3028 | }), | |
| 3029 | ); | |
| 3030 | ||
| 3031 | it.effect("refuses a commit that is not a sha rather than reading it into a request", () => | |
| 3032 | Effect.gen(function* () { | |
| 3033 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3034 | ||
| 3035 | const error = yield* Effect.flip( | |
| 3036 | cli.getPullRequestDiff({ | |
| 3037 | cwd: "/w", | |
| 3038 | repository: "acme/web", | |
| 3039 | host: "github.com", | |
| 3040 | number: 7, | |
| 3041 | commit: "../../pulls/8/files", | |
| 3042 | }), | |
| 3043 | ); | |
| 3044 | ||
| 3045 | assert.strictEqual(error._tag, "GitHubDiffCommitError"); | |
| 3046 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3047 | }), | |
| 3048 | ); | |
| 3049 | ||
| 3050 | it.effect("expands a new file from a root commit without requiring a parent", () => | |
| 3051 | Effect.gen(function* () { | |
| 3052 | mockedExecute.mockReturnValueOnce( | |
| 3053 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), | |
| 3054 | ); | |
| 3055 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); | |
| 3056 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3057 | ||
| 3058 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3059 | cwd: "/w", | |
| 3060 | repository: "acme/web", | |
| 3061 | host: "github.com", | |
| 3062 | number: 7, | |
| 3063 | commit: "a1b2c3d", | |
| 3064 | changeType: "new", | |
| 3065 | oldPath: "src/root.ts", | |
| 3066 | newPath: "src/root.ts", | |
| 3067 | }); | |
| 3068 | ||
| 3069 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); | |
| 3070 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3071 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); | |
| 3072 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); | |
| 3073 | }), | |
| 3074 | ); | |
| 3075 | ||
| 3076 | it.effect("reports unusable diff revisions as a structured error", () => | |
| 3077 | Effect.gen(function* () { | |
| 3078 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); | |
| 3079 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3080 | ||
| 3081 | const error = yield* Effect.flip( | |
| 3082 | cli.getPullRequestDiffFileContents({ | |
| 3083 | cwd: "/w", | |
| 3084 | repository: "acme/web", | |
| 3085 | host: "github.com", | |
| 3086 | number: 7, | |
| 3087 | commit: "a1b2c3d", | |
| 3088 | changeType: "change", | |
| 3089 | oldPath: "src/a.ts", | |
| 3090 | newPath: "src/a.ts", | |
| 3091 | }), | |
| 3092 | ); | |
| 3093 | ||
| 3094 | assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); | |
| 3095 | if (error._tag === "GitHubDiffRevisionsUnavailableError") { | |
| 3096 | assert.strictEqual(error.number, 7); | |
| 3097 | assert.strictEqual(error.commit, "a1b2c3d"); | |
| 3098 | } | |
| 3099 | }), | |
| 3100 | ); | |
| 3101 | ||
| 3102 | it.effect("reports an oversized diff file with its path and reason", () => | |
| 3103 | Effect.gen(function* () { | |
| 3104 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3105 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | |
| 3106 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3107 | ||
| 3108 | const error = yield* Effect.flip( | |
| 3109 | cli.getPullRequestDiffFileContents({ | |
| 3110 | cwd: "/w", | |
| 3111 | repository: "acme/web", | |
| 3112 | host: "github.com", | |
| 3113 | number: 7, | |
| 3114 | changeType: "deleted", | |
| 3115 | oldPath: "src/large.ts", | |
| 3116 | newPath: "src/large.ts", | |
| 3117 | }), | |
| 3118 | ); | |
| 3119 | ||
| 3120 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3121 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3122 | assert.strictEqual(error.path, "src/large.ts"); | |
| 3123 | assert.strictEqual(error.reason, "oversized"); | |
| 3124 | } | |
| 3125 | // A deleted file is read at the base revision only. | |
| 3126 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); | |
| 3127 | }), | |
| 3128 | ); | |
| 3129 | ||
| 3130 | it.effect("reports undecodable diff file contents as binary", () => | |
| 3131 | Effect.gen(function* () { | |
| 3132 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3133 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); | |
| 3134 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3135 | ||
| 3136 | const error = yield* Effect.flip( | |
| 3137 | cli.getPullRequestDiffFileContents({ | |
| 3138 | cwd: "/w", | |
| 3139 | repository: "acme/web", | |
| 3140 | host: "github.com", | |
| 3141 | number: 7, | |
| 3142 | changeType: "deleted", | |
| 3143 | oldPath: "assets/logo.png", | |
| 3144 | newPath: "assets/logo.png", | |
| 3145 | }), | |
| 3146 | ); | |
| 3147 | ||
| 3148 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3149 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3150 | assert.strictEqual(error.path, "assets/logo.png"); | |
| 3151 | assert.strictEqual(error.reason, "binary"); | |
| 3152 | } | |
| 3153 | }), | |
| 3154 | ); | |
| 3155 | ||
| 3156 | it.effect("returns valid text containing a literal replacement character", () => | |
| 3157 | Effect.gen(function* () { | |
| 3158 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3159 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); | |
| 3160 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3161 | ||
| 3162 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3163 | cwd: "/w", | |
| 3164 | repository: "acme/web", | |
| 3165 | host: "github.com", | |
| 3166 | number: 7, | |
| 3167 | changeType: "deleted", | |
| 3168 | oldPath: "docs/encoding.md", | |
| 3169 | newPath: "docs/encoding.md", | |
| 3170 | }); | |
| 3171 | ||
| 3172 | assert.strictEqual(contents.oldContents, "before�after"); | |
| 3173 | }), | |
| 3174 | ); | |
| 3175 | ||
| 3176 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | |
| 3177 | Effect.gen(function* () { | |
| 3178 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 3179 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3180 | ||
| 3181 | const diff = yield* cli.getPullRequestDiff({ | |
| 3182 | cwd: "/w", | |
| 3183 | repository: "acme/web", | |
| 3184 | host: "github.com", | |
| 3185 | number: 7, | |
| 3186 | cursor: "4", | |
| 3187 | }); | |
| 3188 | ||
| 3189 | assert.strictEqual(diff.patch, ""); | |
| 3190 | assert.isNull(diff.nextCursor); | |
| 3191 | }), | |
| 3192 | ); | |
| 3193 | ||
| 3194 | it.effect("reports the refused diff when the files API cannot answer either", () => | |
| 3195 | Effect.gen(function* () { | |
| 3196 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3197 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); | |
| 3198 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3199 | ||
| 3200 | const error = yield* Effect.flip( | |
| 3201 | cli.getPullRequestDiff({ | |
| 3202 | cwd: "/w", | |
| 3203 | repository: "acme/web", | |
| 3204 | host: "github.com", | |
| 3205 | number: 7, | |
| 3206 | }), | |
| 3207 | ); | |
| 3208 | ||
| 3209 | assert.strictEqual(error, diffRefused); | |
| 3210 | }), | |
| 3211 | ); | |
| 3212 | ||
| 3213 | it.effect("skips the avatar lookup when a listing named nobody", () => | |
| 3214 | Effect.gen(function* () { | |
| 3215 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3216 | ||
| 3217 | const avatars = yield* cli.listActorAvatars({ | |
| 3218 | cwd: "/w", | |
| 3219 | repository: "acme/web", | |
| 3220 | host: "github.com", | |
| 3221 | ids: [], | |
| 3222 | }); | |
| 3223 | ||
| 3224 | assert.strictEqual(avatars.size, 0); | |
| 3225 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3226 | }), | |
| 3227 | ); | |
| 3228 | ||
| 3229 | it.effect("looks up avatars by node id in one GraphQL read", () => | |
| 3230 | Effect.gen(function* () { | |
| 3231 | mockedExecute.mockReturnValueOnce( | |
| 3232 | Effect.succeed( | |
| 3233 | output( | |
| 3234 | encodeJson({ | |
| 3235 | data: { | |
| 3236 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], | |
| 3237 | }, | |
| 3238 | }), | |
| 3239 | ), | |
| 3240 | ), | |
| 3241 | ); | |
| 3242 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3243 | ||
| 3244 | const avatars = yield* cli.listActorAvatars({ | |
| 3245 | cwd: "/w", | |
| 3246 | repository: "acme/web", | |
| 3247 | host: "github.com", | |
| 3248 | ids: ["MDQ6VXNlcjE="], | |
| 3249 | }); | |
| 3250 | ||
| 3251 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); | |
| 3252 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); | |
| 3253 | }), | |
| 3254 | ); | |
| 3255 | ||
| 3256 | it.effect("fails when the authenticated account has no login", () => | |
| 3257 | Effect.gen(function* () { | |
| 3258 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); | |
| 3259 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3260 | ||
| 3261 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); | |
| 3262 | ||
| 3263 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); | |
| 3264 | }), | |
| 3265 | ); | |
| 3266 | ||
| 3267 | it.effect("looks up the authenticated account on the requested enterprise host", () => | |
| 3268 | Effect.gen(function* () { | |
| 3269 | mockedExecute.mockReturnValueOnce( | |
| 3270 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), | |
| 3271 | ); | |
| 3272 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3273 | ||
| 3274 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); | |
| 3275 | ||
| 3276 | expect(login).toBe("enterprise-user"); | |
| 3277 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); | |
| 3278 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); | |
| 3279 | }), | |
| 3280 | ); | |
| 3281 | ||
| 3282 | it.effect("reuses verified credentials offline and refuses an unverified replacement", () => | |
| 3283 | Effect.gen(function* () { | |
| 3284 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3285 | const input = { cwd: "/w", host: "github.identity-cache.test" }; | |
| 3286 | mockedCredential.mockImplementation((host) => | |
| 3287 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), | |
| 3288 | ); | |
| 3289 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3290 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3291 | accountId: "123", | |
| 3292 | viewer: "maria-rcks", | |
| 3293 | }); | |
| 3294 | ||
| 3295 | // The same credential again is answered from what was verified, without asking GitHub. | |
| 3296 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3297 | accountId: "123", | |
| 3298 | viewer: "maria-rcks", | |
| 3299 | }); | |
| 3300 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 3301 | ||
| 3302 | // A switched credential is not trusted on the strength of the old one's answer. | |
| 3303 | mockedCredential.mockImplementation((host) => | |
| 3304 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), | |
| 3305 | ); | |
| 3306 | mockedExecute.mockReturnValueOnce( | |
| 3307 | Effect.fail( | |
| 3308 | new GitHubApi.GitHubApiResponseError({ | |
| 3309 | host: "github.com", | |
| 3310 | operation: "getRoutingIdentity", | |
| 3311 | status: 502, | |
| 3312 | }), | |
| 3313 | ), | |
| 3314 | ); | |
| 3315 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | |
| 3316 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); | |
| 3317 | expect(failure._tag).toBe("GitHubApiResponseError"); | |
| 3318 | expect(String(failure)).not.toContain("test-credential-b"); | |
| 3319 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3320 | ||
| 3321 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3322 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3323 | accountId: "456", | |
| 3324 | viewer: "maria-rcks", | |
| 3325 | }); | |
| 3326 | }), | |
| 3327 | ); | |
| 3328 | ||
| 3329 | it.effect("sends a whole review as one request", () => | |
| 3330 | Effect.gen(function* () { | |
| 3331 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3332 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3333 | ||
| 3334 | yield* cli.submitReview({ | |
| 3335 | cwd: "/w", | |
| 3336 | repository: "acme/web", | |
| 3337 | host: "github.com", | |
| 3338 | number: 7, | |
| 3339 | verdict: "approve", | |
| 3340 | body: "Looks right.", | |
| 3341 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | |
| 3342 | }); | |
| 3343 | ||
| 3344 | // One request, so nothing is on the pull request until the verdict is. | |
| 3345 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3346 | expect(restCallsTo("/reviews")).toMatchObject([ | |
| 3347 | { | |
| 3348 | method: "POST", | |
| 3349 | path: "repos/acme/web/pulls/7/reviews", | |
| 3350 | body: { | |
| 3351 | event: "APPROVE", | |
| 3352 | body: "Looks right.", | |
| 3353 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | |
| 3354 | }, | |
| 3355 | }, | |
| 3356 | ]); | |
| 3357 | }), | |
| 3358 | ); | |
| 3359 | ||
| 3360 | it.effect("sends a reply body as a variable, never inside the document", () => | |
| 3361 | Effect.gen(function* () { | |
| 3362 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3363 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3364 | ||
| 3365 | yield* cli.replyToReviewThread({ | |
| 3366 | cwd: "/w", | |
| 3367 | repository: "acme/web", | |
| 3368 | host: "github.com", | |
| 3369 | threadId: "PRRT_1", | |
| 3370 | body: "Fixed in 42ff8ec.", | |
| 3371 | }); | |
| 3372 | ||
| 3373 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ | |
| 3374 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, | |
| 3375 | ]); | |
| 3376 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); | |
| 3377 | }), | |
| 3378 | ); | |
| 3379 | ||
| 3380 | it.effect("resolves and unresolves through the mutation each one needs", () => | |
| 3381 | Effect.gen(function* () { | |
| 3382 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3383 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3384 | ||
| 3385 | yield* cli.setReviewThreadResolution({ | |
| 3386 | cwd: "/w", | |
| 3387 | repository: "acme/web", | |
| 3388 | host: "github.acme.dev", | |
| 3389 | threadId: "PRRT_1", | |
| 3390 | resolved: true, | |
| 3391 | }); | |
| 3392 | yield* cli.setReviewThreadResolution({ | |
| 3393 | cwd: "/w", | |
| 3394 | repository: "acme/web", | |
| 3395 | host: "github.acme.dev", | |
| 3396 | threadId: "PRRT_1", | |
| 3397 | resolved: false, | |
| 3398 | }); | |
| 3399 | ||
| 3400 | expect(queryAt(0)).toContain("resolveReviewThread("); | |
| 3401 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); | |
| 3402 | expect(queryAt(1)).toContain("unresolveReviewThread("); | |
| 3403 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | |
| 3404 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. | |
| 3405 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); | |
| 3406 | }), | |
| 3407 | ); | |
| 3408 | ||
| 3409 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => | |
| 3410 | Effect.gen(function* () { | |
| 3411 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3412 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3413 | ||
| 3414 | yield* cli.setReaction({ | |
| 3415 | cwd: "/w", | |
| 3416 | repository: "acme/web", | |
| 3417 | host: "github.com", | |
| 3418 | number: 7, | |
| 3419 | subjectId: "IC_1", | |
| 3420 | content: "heart", | |
| 3421 | reacted: true, | |
| 3422 | }); | |
| 3423 | ||
| 3424 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3425 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ | |
| 3426 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, | |
| 3427 | ]); | |
| 3428 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3429 | }), | |
| 3430 | ); | |
| 3431 | ||
| 3432 | it.effect("refuses a given subject that belongs to a different pull request", () => | |
| 3433 | Effect.gen(function* () { | |
| 3434 | mockedExecute.mockReturnValueOnce( | |
| 3435 | Effect.succeed( | |
| 3436 | output( | |
| 3437 | encodeJson({ | |
| 3438 | data: { | |
| 3439 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3440 | // A comment on pull request #99 of a different repository, named as though it | |
| 3441 | // belonged to #7 here. | |
| 3442 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3443 | }, | |
| 3444 | }), | |
| 3445 | ), | |
| 3446 | ), | |
| 3447 | ); | |
| 3448 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3449 | ||
| 3450 | const error = yield* Effect.flip( | |
| 3451 | cli.setReaction({ | |
| 3452 | cwd: "/w", | |
| 3453 | repository: "acme/web", | |
| 3454 | host: "github.com", | |
| 3455 | number: 7, | |
| 3456 | subjectId: "IC_99", | |
| 3457 | content: "heart", | |
| 3458 | reacted: true, | |
| 3459 | }), | |
| 3460 | ); | |
| 3461 | ||
| 3462 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3463 | // Refused before any mutation was sent. | |
| 3464 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3465 | }), | |
| 3466 | ); | |
| 3467 | ||
| 3468 | it.effect("looks up the pull request's own node id when no subject was given", () => | |
| 3469 | Effect.gen(function* () { | |
| 3470 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3471 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3472 | ||
| 3473 | // Its own pull request: a node id looked up once is remembered for the life of the service. | |
| 3474 | yield* cli.setReaction({ | |
| 3475 | cwd: "/w", | |
| 3476 | repository: "acme/web", | |
| 3477 | host: "github.com", | |
| 3478 | number: 21, | |
| 3479 | content: "rocket", | |
| 3480 | reacted: true, | |
| 3481 | }); | |
| 3482 | ||
| 3483 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3484 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); | |
| 3485 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); | |
| 3486 | }), | |
| 3487 | ); | |
| 3488 | ||
| 3489 | it.effect("takes a reaction back through the remove mutation", () => | |
| 3490 | Effect.gen(function* () { | |
| 3491 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3492 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3493 | ||
| 3494 | yield* cli.setReaction({ | |
| 3495 | cwd: "/w", | |
| 3496 | repository: "acme/web", | |
| 3497 | host: "github.com", | |
| 3498 | number: 7, | |
| 3499 | subjectId: "IC_1", | |
| 3500 | content: "heart", | |
| 3501 | reacted: false, | |
| 3502 | }); | |
| 3503 | ||
| 3504 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3505 | expect(variablesOf("addReaction(")).toEqual([]); | |
| 3506 | }), | |
| 3507 | ); | |
| 3508 | ||
| 3509 | it.effect("rewrites only the words a request named", () => | |
| 3510 | Effect.gen(function* () { | |
| 3511 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3512 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3513 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => | |
| 3514 | cli.updatePullRequest({ | |
| 3515 | cwd: "/w", | |
| 3516 | repository: "acme/web", | |
| 3517 | host: "github.com", | |
| 3518 | number: 22, | |
| 3519 | ...fields, | |
| 3520 | }); | |
| 3521 | ||
| 3522 | yield* rewrite({ title: "A better title" }); | |
| 3523 | yield* rewrite({ body: "A better description." }); | |
| 3524 | yield* rewrite({ title: "Both", body: "at once." }); | |
| 3525 | ||
| 3526 | // One node id lookup for the pull request, then a mutation per rewrite. | |
| 3527 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); | |
| 3528 | expect(variablesOf("updatePullRequest(")).toEqual([ | |
| 3529 | { pullRequestId: "PR_kwDOA", title: "A better title" }, | |
| 3530 | { pullRequestId: "PR_kwDOA", body: "A better description." }, | |
| 3531 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, | |
| 3532 | ]); | |
| 3533 | }), | |
| 3534 | ); | |
| 3535 | ||
| 3536 | it.effect("rewrites a remark through the mutation its kind needs", () => | |
| 3537 | Effect.gen(function* () { | |
| 3538 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3539 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3540 | const rewrite = (kind: "issue-comment" | "review-comment") => | |
| 3541 | cli.updateComment({ | |
| 3542 | cwd: "/w", | |
| 3543 | repository: "acme/web", | |
| 3544 | host: "github.com", | |
| 3545 | number: 7, | |
| 3546 | commentId: "IC_1", | |
| 3547 | kind, | |
| 3548 | body: "Reworded.", | |
| 3549 | }); | |
| 3550 | ||
| 3551 | yield* rewrite("issue-comment"); | |
| 3552 | yield* rewrite("review-comment"); | |
| 3553 | ||
| 3554 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ | |
| 3555 | "IC_1", | |
| 3556 | "IC_1", | |
| 3557 | ]); | |
| 3558 | expect(variablesOf("updateIssueComment(")).toEqual([ | |
| 3559 | { commentId: "IC_1", body: "Reworded." }, | |
| 3560 | ]); | |
| 3561 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ | |
| 3562 | { commentId: "IC_1", body: "Reworded." }, | |
| 3563 | ]); | |
| 3564 | }), | |
| 3565 | ); | |
| 3566 | ||
| 3567 | it.effect("refuses a comment that belongs to a different pull request", () => | |
| 3568 | Effect.gen(function* () { | |
| 3569 | mockedExecute.mockReturnValueOnce( | |
| 3570 | Effect.succeed( | |
| 3571 | output( | |
| 3572 | encodeJson({ | |
| 3573 | data: { | |
| 3574 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3575 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3576 | }, | |
| 3577 | }), | |
| 3578 | ), | |
| 3579 | ), | |
| 3580 | ); | |
| 3581 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3582 | ||
| 3583 | const error = yield* Effect.flip( | |
| 3584 | cli.updateComment({ | |
| 3585 | cwd: "/w", | |
| 3586 | repository: "acme/web", | |
| 3587 | host: "github.com", | |
| 3588 | number: 7, | |
| 3589 | commentId: "IC_99", | |
| 3590 | kind: "issue-comment", | |
| 3591 | body: "Reworded.", | |
| 3592 | }), | |
| 3593 | ); | |
| 3594 | ||
| 3595 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3596 | if (error._tag === "GitHubSubjectScopeError") | |
| 3597 | assert.strictEqual(error.operation, "updateComment"); | |
| 3598 | // Refused before any mutation was sent. | |
| 3599 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3600 | }), | |
| 3601 | ); | |
| 3602 | ||
| 3603 | it.effect("fails the read when gh returns something unreadable", () => | |
| 3604 | Effect.gen(function* () { | |
| 3605 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 3606 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3607 | ||
| 3608 | const error = yield* Effect.flip( | |
| 3609 | cli.getPullRequestDetail({ | |
| 3610 | cwd: "/w", | |
| 3611 | repository: "acme/web", | |
| 3612 | host: "github.com", | |
| 3613 | number: 7, | |
| 3614 | }), | |
| 3615 | ); | |
| 3616 | ||
| 3617 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 3618 | }), | |
| 3619 | ); | |
| 3620 | ||
| 3621 | it.effect("keeps the core detail read separate from conversation activity", () => | |
| 3622 | Effect.gen(function* () { | |
| 3623 | mockedExecute.mockReturnValueOnce( | |
| 3624 | Effect.succeed( | |
| 3625 | output( | |
| 3626 | encodeJson( | |
| 3627 | coreResponse({ | |
| 3628 | title: "Progressive detail", | |
| 3629 | author: { login: "octocat" }, | |
| 3630 | body: "Core body", | |
| 3631 | changedFiles: 2, | |
| 3632 | }), | |
| 3633 | ), | |
| 3634 | ), | |
| 3635 | ), | |
| 3636 | ); | |
| 3637 | mockedExecute.mockReturnValueOnce( | |
| 3638 | Effect.succeed( | |
| 3639 | output( | |
| 3640 | encodeJson({ | |
| 3641 | data: { | |
| 3642 | repository: { | |
| 3643 | pullRequest: { | |
| 3644 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, | |
| 3645 | commits: { nodes: [] }, | |
| 3646 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3647 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3648 | }, | |
| 3649 | }, | |
| 3650 | }, | |
| 3651 | }), | |
| 3652 | ), | |
| 3653 | ), | |
| 3654 | ); | |
| 3655 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3656 | const input = { | |
| 3657 | cwd: "/w", | |
| 3658 | repository: "acme/web", | |
| 3659 | host: "github.com", | |
| 3660 | number: 7, | |
| 3661 | } as const; | |
| 3662 | ||
| 3663 | const detail = yield* cli.getPullRequestDetail(input); | |
| 3664 | const activity = yield* cli.getPullRequestActivity(input); | |
| 3665 | ||
| 3666 | expect(detail.body).toBe("Core body"); | |
| 3667 | expect(activity.author?.login).toBe("octocat"); | |
| 3668 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); | |
| 3669 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); | |
| 3670 | expect(queryAt(0)).not.toContain("reviews("); | |
| 3671 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ | |
| 3672 | merge: true, | |
| 3673 | squash: false, | |
| 3674 | rebase: true, | |
| 3675 | }); | |
| 3676 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | |
| 3677 | // Conversation activity is its own read, and asks for the head of the conversation once. | |
| 3678 | expect(queryAt(1)).toContain("reviews("); | |
| 3679 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); | |
| 3680 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3681 | }), | |
| 3682 | ); | |
| 3683 | ||
| 3684 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => | |
| 3685 | Effect.gen(function* () { | |
| 3686 | mockedExecute.mockReturnValueOnce( | |
| 3687 | Effect.succeed( | |
| 3688 | output( | |
| 3689 | encodeJson( | |
| 3690 | coreResponse({ | |
| 3691 | baseRef: null, | |
| 3692 | reviewRequests: { | |
| 3693 | nodes: [ | |
| 3694 | { requestedReviewer: { login: "reviewer" } }, | |
| 3695 | { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, | |
| 3696 | ], | |
| 3697 | }, | |
| 3698 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 3699 | commits: { | |
| 3700 | nodes: [ | |
| 3701 | { | |
| 3702 | commit: { | |
| 3703 | statusCheckRollup: { | |
| 3704 | contexts: { | |
| 3705 | nodes: [ | |
| 3706 | { | |
| 3707 | __typename: "CheckRun", | |
| 3708 | name: "build", | |
| 3709 | status: "COMPLETED", | |
| 3710 | conclusion: "SUCCESS", | |
| 3711 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, | |
| 3712 | }, | |
| 3713 | { | |
| 3714 | __typename: "CheckRun", | |
| 3715 | name: "build", | |
| 3716 | status: "COMPLETED", | |
| 3717 | conclusion: "FAILURE", | |
| 3718 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, | |
| 3719 | }, | |
| 3720 | ], | |
| 3721 | pageInfo: { hasNextPage: false }, | |
| 3722 | }, | |
| 3723 | }, | |
| 3724 | }, | |
| 3725 | }, | |
| 3726 | ], | |
| 3727 | }, | |
| 3728 | }), | |
| 3729 | ), | |
| 3730 | ), | |
| 3731 | ), | |
| 3732 | ); | |
| 3733 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3734 | const detail = yield* cli.getPullRequestDetail({ | |
| 3735 | cwd: "/w", | |
| 3736 | repository: "acme/web", | |
| 3737 | host: "github.com", | |
| 3738 | number: 7, | |
| 3739 | }); | |
| 3740 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3741 | expect(detail.comparison).toBeNull(); | |
| 3742 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); | |
| 3743 | expect(detail.hasTeamReviewRequest).toBe(true); | |
| 3744 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); | |
| 3745 | expect(detail.checks).toHaveLength(2); | |
| 3746 | expect(detail.checksState).toBe("failing"); | |
| 3747 | }), | |
| 3748 | ); | |
| 3749 | ||
| 3750 | it.effect("reads every check when the combined response has another page", () => | |
| 3751 | Effect.gen(function* () { | |
| 3752 | mockedExecute.mockReturnValueOnce( | |
| 3753 | Effect.succeed( | |
| 3754 | output( | |
| 3755 | encodeJson( | |
| 3756 | coreResponse({ | |
| 3757 | commits: { | |
| 3758 | nodes: [ | |
| 3759 | { | |
| 3760 | commit: { | |
| 3761 | statusCheckRollup: { | |
| 3762 | contexts: { | |
| 3763 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | |
| 3764 | pageInfo: { hasNextPage: true, endCursor: "c1" }, | |
| 3765 | }, | |
| 3766 | }, | |
| 3767 | }, | |
| 3768 | }, | |
| 3769 | ], | |
| 3770 | }, | |
| 3771 | }), | |
| 3772 | ), | |
| 3773 | ), | |
| 3774 | ), | |
| 3775 | ); | |
| 3776 | mockedExecute.mockReturnValueOnce( | |
| 3777 | Effect.succeed( | |
| 3778 | output( | |
| 3779 | checkContextsPage( | |
| 3780 | [ | |
| 3781 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 3782 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 3783 | ], | |
| 3784 | null, | |
| 3785 | ), | |
| 3786 | ), | |
| 3787 | ), | |
| 3788 | ); | |
| 3789 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3790 | const detail = yield* cli.getPullRequestDetail({ | |
| 3791 | cwd: "/w", | |
| 3792 | repository: "acme/web", | |
| 3793 | host: "github.com", | |
| 3794 | number: 7, | |
| 3795 | }); | |
| 3796 | expect(detail.checks).toHaveLength(2); | |
| 3797 | expect(detail.checksState).toBe("failing"); | |
| 3798 | expect(detail.checksTruncated).toBe(false); | |
| 3799 | // The whole rollup is walked from its start, so no check is counted twice or skipped. | |
| 3800 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); | |
| 3801 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3802 | }), | |
| 3803 | ); | |
| 3804 | ||
| 3805 | it.effect("refuses to combine checks from different head revisions", () => | |
| 3806 | Effect.gen(function* () { | |
| 3807 | const response = coreResponse({ | |
| 3808 | commits: { | |
| 3809 | nodes: [ | |
| 3810 | { | |
| 3811 | commit: { | |
| 3812 | statusCheckRollup: { | |
| 3813 | contexts: { | |
| 3814 | nodes: [], | |
| 3815 | pageInfo: { hasNextPage: true }, | |
| 3816 | }, | |
| 3817 | }, | |
| 3818 | }, | |
| 3819 | }, | |
| 3820 | ], | |
| 3821 | }, | |
| 3822 | }); | |
| 3823 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | |
| 3824 | mockedExecute.mockReturnValueOnce( | |
| 3825 | Effect.succeed( | |
| 3826 | output( | |
| 3827 | encodeJson({ | |
| 3828 | ...response.data.repository.pullRequest, | |
| 3829 | headRefOid: "new-head", | |
| 3830 | reviewRequests: [], | |
| 3831 | labels: [], | |
| 3832 | statusCheckRollup: [], | |
| 3833 | }), | |
| 3834 | ), | |
| 3835 | ), | |
| 3836 | ); | |
| 3837 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3838 | const error = yield* Effect.flip( | |
| 3839 | cli.getPullRequestDetail({ | |
| 3840 | cwd: "/w", | |
| 3841 | repository: "acme/web", | |
| 3842 | host: "github.com", | |
| 3843 | number: 7, | |
| 3844 | }), | |
| 3845 | ); | |
| 3846 | expect(error._tag).toBe("GitHubPullRequestReadError"); | |
| 3847 | }), | |
| 3848 | ); | |
| 3849 | ||
| 3850 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => | |
| 3851 | Effect.gen(function* () { | |
| 3852 | const response = coreResponse(); | |
| 3853 | mockedExecute.mockReturnValue( | |
| 3854 | Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), | |
| 3855 | ); | |
| 3856 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3857 | const input = { | |
| 3858 | cwd: "/w", | |
| 3859 | repository: "acme/web", | |
| 3860 | host: "github.core-reserve.test", | |
| 3861 | number: 7, | |
| 3862 | }; | |
| 3863 | yield* cli.getPullRequestDetail(input); | |
| 3864 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | |
| 3865 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | |
| 3866 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3867 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3868 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3869 | }), | |
| 3870 | ); | |
| 3871 | ||
| 3872 | it.effect("fails a files page too large to read rather than calling the diff whole", () => | |
| 3873 | Effect.gen(function* () { | |
| 3874 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3875 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); | |
| 3876 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3877 | ||
| 3878 | const error = yield* Effect.flip( | |
| 3879 | cli.getPullRequestDiff({ | |
| 3880 | cwd: "/w", | |
| 3881 | repository: "acme/web", | |
| 3882 | host: "github.com", | |
| 3883 | number: 7, | |
| 3884 | }), | |
| 3885 | ); | |
| 3886 | ||
| 3887 | // What matters is that it fails at all: an empty patch with no cursor would render as a | |
| 3888 | // change with no files and report the rest of it as already read. The refusal that sent | |
| 3889 | // the read down this road is the one reported, by design. | |
| 3890 | assert.strictEqual(error, diffRefused); | |
| 3891 | }), | |
| 3892 | ); | |
| 3893 | ||
| 3894 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => | |
| 3895 | Effect.gen(function* () { | |
| 3896 | // The whole diff came back, but cut at a byte, which lands mid-file. | |
| 3897 | mockedExecute.mockReturnValueOnce( | |
| 3898 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), | |
| 3899 | ); | |
| 3900 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); | |
| 3901 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3902 | ||
| 3903 | const slice = yield* cli.getPullRequestDiff({ | |
| 3904 | cwd: "/w", | |
| 3905 | repository: "acme/web", | |
| 3906 | host: "github.com", | |
| 3907 | number: 7, | |
| 3908 | }); | |
| 3909 | ||
| 3910 | // The severed patch is thrown away; what comes back is assembled from whole files. | |
| 3911 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 3912 | expect(slice.patch).toContain("src/file1.ts"); | |
| 3913 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | |
| 3914 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3915 | }), | |
| 3916 | ); | |
| 3917 | ||
| 3918 | it.effect("follows the cursor to the review threads the first page left behind", () => | |
| 3919 | Effect.gen(function* () { | |
| 3920 | mockedExecute.mockReturnValueOnce( | |
| 3921 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), | |
| 3922 | ); | |
| 3923 | mockedExecute.mockReturnValueOnce( | |
| 3924 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), | |
| 3925 | ); | |
| 3926 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3927 | ||
| 3928 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3929 | cwd: "/w", | |
| 3930 | repository: "acme/web", | |
| 3931 | host: "github.com", | |
| 3932 | number: 7, | |
| 3933 | }); | |
| 3934 | ||
| 3935 | // The first page asks from the beginning; the next carries on from where it stopped. | |
| 3936 | expect(varsAt(0)["cursor"]).toBeNull(); | |
| 3937 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); | |
| 3938 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); | |
| 3939 | assert.isFalse(conversation.truncated); | |
| 3940 | }), | |
| 3941 | ); | |
| 3942 | ||
| 3943 | it.effect("stops at the thread bound and says the conversation was cut short", () => | |
| 3944 | Effect.gen(function* () { | |
| 3945 | // A host that never runs out of pages: the walk has to end itself. | |
| 3946 | mockedExecute.mockReturnValue( | |
| 3947 | Effect.succeed( | |
| 3948 | output( | |
| 3949 | reviewThreadsPage( | |
| 3950 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3951 | "Y3Vyc29yOjE", | |
| 3952 | ), | |
| 3953 | ), | |
| 3954 | ), | |
| 3955 | ); | |
| 3956 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3957 | ||
| 3958 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3959 | cwd: "/w", | |
| 3960 | repository: "acme/web", | |
| 3961 | host: "github.com", | |
| 3962 | number: 7, | |
| 3963 | }); | |
| 3964 | ||
| 3965 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | |
| 3966 | assert.isTrue(conversation.truncated); | |
| 3967 | assert.isTrue(conversation.reviewThreadsTruncated); | |
| 3968 | }), | |
| 3969 | ); | |
| 3970 | ||
| 3971 | it.effect("leaves a long thread paged until the reader asks for more", () => | |
| 3972 | Effect.gen(function* () { | |
| 3973 | mockedExecute.mockReturnValueOnce( | |
| 3974 | Effect.succeed( | |
| 3975 | output( | |
| 3976 | reviewThreadsPage( | |
| 3977 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3978 | null, | |
| 3979 | ), | |
| 3980 | ), | |
| 3981 | ), | |
| 3982 | ); | |
| 3983 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3984 | ||
| 3985 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3986 | cwd: "/w", | |
| 3987 | repository: "acme/web", | |
| 3988 | host: "github.com", | |
| 3989 | number: 7, | |
| 3990 | }); | |
| 3991 | ||
| 3992 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3993 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 3994 | expect(conversation.reviewThreads[0]).toMatchObject({ | |
| 3995 | commentCount: 3, | |
| 3996 | nextCommentsCursor: "Y3Vyc29yOjI", | |
| 3997 | }); | |
| 3998 | assert.isTrue(conversation.truncated); | |
| 3999 | assert.isFalse(conversation.reviewThreadsTruncated); | |
| 4000 | }), | |
| 4001 | ); | |
| 4002 | ||
| 4003 | it.effect("reads one requested page from a review thread cursor", () => | |
| 4004 | Effect.gen(function* () { | |
| 4005 | mockedExecute.mockReturnValueOnce( | |
| 4006 | Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), | |
| 4007 | ); | |
| 4008 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4009 | ||
| 4010 | const page = yield* cli.getReviewThreadComments({ | |
| 4011 | cwd: "/w", | |
| 4012 | repository: "acme/web", | |
| 4013 | host: "github.com", | |
| 4014 | number: 7, | |
| 4015 | threadId: "PRRT_1", | |
| 4016 | cursor: "Y3Vyc29yOjI", | |
| 4017 | }); | |
| 4018 | ||
| 4019 | expect(varsAt(0)).toMatchObject({ | |
| 4020 | owner: "acme", | |
| 4021 | name: "web", | |
| 4022 | number: 7, | |
| 4023 | threadId: "PRRT_1", | |
| 4024 | cursor: "Y3Vyc29yOjI", | |
| 4025 | }); | |
| 4026 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4027 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); | |
| 4028 | expect(page.nextCursor).toBeNull(); | |
| 4029 | }), | |
| 4030 | ); | |
| 4031 | ||
| 4032 | it.effect("refuses a review thread from another pull request", () => | |
| 4033 | Effect.gen(function* () { | |
| 4034 | mockedExecute.mockReturnValueOnce( | |
| 4035 | Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), | |
| 4036 | ); | |
| 4037 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4038 | ||
| 4039 | const error = yield* Effect.flip( | |
| 4040 | cli.getReviewThreadComments({ | |
| 4041 | cwd: "/w", | |
| 4042 | repository: "acme/web", | |
| 4043 | host: "github.com", | |
| 4044 | number: 7, | |
| 4045 | threadId: "PRRT_FOREIGN", | |
| 4046 | cursor: "Y3Vyc29yOjI", | |
| 4047 | }), | |
| 4048 | ); | |
| 4049 | ||
| 4050 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 4051 | }), | |
| 4052 | ); | |
| 4053 | ||
| 4054 | it.effect( | |
| 4055 | "asks for the reader's standing on the repository and on the pull request at once", | |
| 4056 | () => | |
| 4057 | Effect.gen(function* () { | |
| 4058 | mockedExecute.mockReturnValue( | |
| 4059 | Effect.succeed( | |
| 4060 | output( | |
| 4061 | encodeJson({ | |
| 4062 | data: { | |
| 4063 | repository: { | |
| 4064 | mergeCommitAllowed: true, | |
| 4065 | squashMergeAllowed: false, | |
| 4066 | rebaseMergeAllowed: true, | |
| 4067 | viewerPermission: "READ", | |
| 4068 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4069 | }, | |
| 4070 | }, | |
| 4071 | }), | |
| 4072 | ), | |
| 4073 | ), | |
| 4074 | ); | |
| 4075 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4076 | ||
| 4077 | const access = yield* cli.getViewerAccess({ | |
| 4078 | cwd: "/w", | |
| 4079 | repository: "acme/web", | |
| 4080 | host: "github.com", | |
| 4081 | number: 7, | |
| 4082 | }); | |
| 4083 | ||
| 4084 | // One request, because both answers hang off the same repository object. | |
| 4085 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4086 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4087 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); | |
| 4088 | expect(access).toEqual({ | |
| 4089 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4090 | canWrite: false, | |
| 4091 | canTriage: false, | |
| 4092 | canUpdate: true, | |
| 4093 | didAuthor: true, | |
| 4094 | }); | |
| 4095 | }), | |
| 4096 | ); | |
| 4097 | ||
| 4098 | it.effect("stops GraphQL reads at the protected reserve until reset", () => | |
| 4099 | Effect.gen(function* () { | |
| 4100 | mockedExecute.mockReturnValue( | |
| 4101 | Effect.succeed( | |
| 4102 | output( | |
| 4103 | encodeJson({ | |
| 4104 | data: { | |
| 4105 | repository: { | |
| 4106 | mergeCommitAllowed: true, | |
| 4107 | squashMergeAllowed: false, | |
| 4108 | rebaseMergeAllowed: true, | |
| 4109 | viewerPermission: "READ", | |
| 4110 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4111 | }, | |
| 4112 | }, | |
| 4113 | }), | |
| 4114 | false, | |
| 4115 | false, | |
| 4116 | graphqlQuota(499), | |
| 4117 | ), | |
| 4118 | ), | |
| 4119 | ); | |
| 4120 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4121 | const input = { | |
| 4122 | cwd: "/w", | |
| 4123 | repository: "acme/web", | |
| 4124 | host: "github.com", | |
| 4125 | number: 7, | |
| 4126 | } as const; | |
| 4127 | ||
| 4128 | yield* cli.getViewerAccess(input); | |
| 4129 | ||
| 4130 | const error = yield* Effect.flip(cli.getViewerAccess(input)); | |
| 4131 | ||
| 4132 | assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); | |
| 4133 | if (error._tag !== "SourceControlRateLimitPausedError") return; | |
| 4134 | assert.strictEqual(error.host, "github.com"); | |
| 4135 | assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); | |
| 4136 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4137 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4138 | }), | |
| 4139 | ); | |
| 4140 | ||
| 4141 | it.effect("lets an interactive permission read use the protected reserve", () => | |
| 4142 | Effect.gen(function* () { | |
| 4143 | mockedExecute | |
| 4144 | .mockReturnValueOnce( | |
| 4145 | Effect.succeed( | |
| 4146 | output( | |
| 4147 | encodeJson({ | |
| 4148 | data: { | |
| 4149 | repository: { | |
| 4150 | mergeCommitAllowed: true, | |
| 4151 | squashMergeAllowed: false, | |
| 4152 | rebaseMergeAllowed: true, | |
| 4153 | viewerPermission: "READ", | |
| 4154 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4155 | }, | |
| 4156 | }, | |
| 4157 | }), | |
| 4158 | false, | |
| 4159 | false, | |
| 4160 | graphqlQuota(499), | |
| 4161 | ), | |
| 4162 | ), | |
| 4163 | ) | |
| 4164 | .mockReturnValueOnce( | |
| 4165 | Effect.succeed( | |
| 4166 | output( | |
| 4167 | encodeJson({ | |
| 4168 | data: { | |
| 4169 | repository: { | |
| 4170 | mergeCommitAllowed: true, | |
| 4171 | squashMergeAllowed: false, | |
| 4172 | rebaseMergeAllowed: true, | |
| 4173 | viewerPermission: "READ", | |
| 4174 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4175 | }, | |
| 4176 | }, | |
| 4177 | }), | |
| 4178 | ), | |
| 4179 | ), | |
| 4180 | ); | |
| 4181 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4182 | ||
| 4183 | yield* cli.getViewerAccess({ | |
| 4184 | cwd: "/w", | |
| 4185 | repository: "acme/web", | |
| 4186 | host: "github.com", | |
| 4187 | number: 7, | |
| 4188 | }); | |
| 4189 | const access = yield* cli.getViewerAccess({ | |
| 4190 | cwd: "/w", | |
| 4191 | repository: "acme/web", | |
| 4192 | host: "github.com", | |
| 4193 | number: 7, | |
| 4194 | allowReserve: true, | |
| 4195 | }); | |
| 4196 | ||
| 4197 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4198 | expect(access).toEqual({ | |
| 4199 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4200 | canWrite: false, | |
| 4201 | canTriage: false, | |
| 4202 | canUpdate: true, | |
| 4203 | didAuthor: true, | |
| 4204 | }); | |
| 4205 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4206 | }), | |
| 4207 | ); | |
| 4208 | ||
| 4209 | it.effect("asks GitHub to review, naming the collection a request is added to", () => | |
| 4210 | Effect.gen(function* () { | |
| 4211 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4212 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4213 | ||
| 4214 | yield* cli.setReviewerRequest({ | |
| 4215 | cwd: "/w", | |
| 4216 | repository: "acme/web", | |
| 4217 | host: "github.com", | |
| 4218 | number: 7, | |
| 4219 | reviewers: [ | |
| 4220 | { id: "octocat", kind: "user" }, | |
| 4221 | { id: "reviewers", kind: "team" }, | |
| 4222 | ], | |
| 4223 | requested: true, | |
| 4224 | }); | |
| 4225 | ||
| 4226 | expect(callAt(0)).toMatchObject({ | |
| 4227 | kind: "rest", | |
| 4228 | method: "POST", | |
| 4229 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4230 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, | |
| 4231 | }); | |
| 4232 | }), | |
| 4233 | ); | |
| 4234 | ||
| 4235 | it.effect("takes a request back by deleting from the same collection it was added to", () => | |
| 4236 | Effect.gen(function* () { | |
| 4237 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4238 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4239 | ||
| 4240 | yield* cli.setReviewerRequest({ | |
| 4241 | cwd: "/w", | |
| 4242 | repository: "acme/web", | |
| 4243 | host: "github.com", | |
| 4244 | number: 7, | |
| 4245 | reviewers: [{ id: "octocat", kind: "user" }], | |
| 4246 | requested: false, | |
| 4247 | }); | |
| 4248 | ||
| 4249 | expect(callAt(0)).toMatchObject({ | |
| 4250 | kind: "rest", | |
| 4251 | method: "DELETE", | |
| 4252 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4253 | body: { reviewers: ["octocat"], team_reviewers: [] }, | |
| 4254 | }); | |
| 4255 | }), | |
| 4256 | ); | |
| 4257 | ||
| 4258 | it.effect("reads who may review and who already has in one request", () => | |
| 4259 | Effect.gen(function* () { | |
| 4260 | mockedExecute.mockReturnValue( | |
| 4261 | Effect.succeed( | |
| 4262 | output( | |
| 4263 | encodeJson({ | |
| 4264 | data: { | |
| 4265 | repository: { | |
| 4266 | assignableUsers: { | |
| 4267 | pageInfo: { hasNextPage: false }, | |
| 4268 | nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], | |
| 4269 | }, | |
| 4270 | pullRequest: { | |
| 4271 | author: { login: "bilal" }, | |
| 4272 | reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, | |
| 4273 | }, | |
| 4274 | }, | |
| 4275 | }, | |
| 4276 | }), | |
| 4277 | ), | |
| 4278 | ), | |
| 4279 | ); | |
| 4280 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4281 | ||
| 4282 | const list = yield* cli.listReviewerCandidates({ | |
| 4283 | cwd: "/w", | |
| 4284 | repository: "acme/web", | |
| 4285 | host: "github.com", | |
| 4286 | number: 7, | |
| 4287 | }); | |
| 4288 | ||
| 4289 | // The people, who has been asked and who opened the pull request all hang off the same | |
| 4290 | // repository object, so the menu costs one request. | |
| 4291 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4292 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4293 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 4294 | ["octocat", true], | |
| 4295 | ["hubot", false], | |
| 4296 | ]); | |
| 4297 | }), | |
| 4298 | ); | |
| 4299 | ||
| 4300 | it.effect("puts labels on by posting to the issue's own collection, all at once", () => | |
| 4301 | Effect.gen(function* () { | |
| 4302 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4303 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4304 | ||
| 4305 | yield* cli.setLabels({ | |
| 4306 | cwd: "/w", | |
| 4307 | repository: "acme/web", | |
| 4308 | host: "github.com", | |
| 4309 | number: 7, | |
| 4310 | labels: ["bug", "size:XL"], | |
| 4311 | applied: true, | |
| 4312 | }); | |
| 4313 | ||
| 4314 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4315 | expect(callAt(0)).toMatchObject({ | |
| 4316 | kind: "rest", | |
| 4317 | method: "POST", | |
| 4318 | path: "repos/acme/web/issues/7/labels", | |
| 4319 | body: { labels: ["bug", "size:XL"] }, | |
| 4320 | }); | |
| 4321 | }), | |
| 4322 | ); | |
| 4323 | ||
| 4324 | it.effect("takes labels off one at a time, naming each in the path encoded", () => | |
| 4325 | Effect.gen(function* () { | |
| 4326 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4327 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4328 | ||
| 4329 | yield* cli.setLabels({ | |
| 4330 | cwd: "/w", | |
| 4331 | repository: "acme/web", | |
| 4332 | host: "github.com", | |
| 4333 | number: 7, | |
| 4334 | labels: ["good first issue", "area/web"], | |
| 4335 | applied: false, | |
| 4336 | }); | |
| 4337 | ||
| 4338 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4339 | expect(callAt(0)).toMatchObject({ | |
| 4340 | method: "DELETE", | |
| 4341 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", | |
| 4342 | }); | |
| 4343 | expect(callAt(1)).toMatchObject({ | |
| 4344 | method: "DELETE", | |
| 4345 | path: "repos/acme/web/issues/7/labels/area%2Fweb", | |
| 4346 | }); | |
| 4347 | }), | |
| 4348 | ); | |
| 4349 | ||
| 4350 | it.effect("reads every page of viewed files, and says so when there are too many", () => | |
| 4351 | Effect.gen(function* () { | |
| 4352 | const page = (index: number, hasNextPage: boolean) => | |
| 4353 | Effect.succeed( | |
| 4354 | output( | |
| 4355 | encodeJson({ | |
| 4356 | data: { | |
| 4357 | repository: { | |
| 4358 | pullRequest: { | |
| 4359 | files: { | |
| 4360 | pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, | |
| 4361 | nodes: [ | |
| 4362 | { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, | |
| 4363 | { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, | |
| 4364 | ], | |
| 4365 | }, | |
| 4366 | }, | |
| 4367 | }, | |
| 4368 | }, | |
| 4369 | }), | |
| 4370 | ), | |
| 4371 | ); | |
| 4372 | mockedExecute | |
| 4373 | .mockReturnValueOnce(page(0, true)) | |
| 4374 | .mockReturnValueOnce(page(1, true)) | |
| 4375 | .mockReturnValueOnce(page(2, false)); | |
| 4376 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4377 | ||
| 4378 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4379 | cwd: "/w", | |
| 4380 | repository: "acme/web", | |
| 4381 | host: "github.com", | |
| 4382 | number: 7, | |
| 4383 | }); | |
| 4384 | ||
| 4385 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4386 | // The first page asks from the start; each one after it carries the cursor before it. | |
| 4387 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ | |
| 4388 | null, | |
| 4389 | "cursor-0", | |
| 4390 | "cursor-1", | |
| 4391 | ]); | |
| 4392 | assert.isFalse(viewed.truncated); | |
| 4393 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ | |
| 4394 | ["src/file0.ts", "viewed"], | |
| 4395 | ["src/other0.ts", "unviewed"], | |
| 4396 | ["src/file1.ts", "viewed"], | |
| 4397 | ["src/other1.ts", "unviewed"], | |
| 4398 | ["src/file2.ts", "viewed"], | |
| 4399 | ["src/other2.ts", "unviewed"], | |
| 4400 | ]); | |
| 4401 | }), | |
| 4402 | ); | |
| 4403 | ||
| 4404 | it.effect("stops paging viewed files rather than following a change without end", () => | |
| 4405 | Effect.gen(function* () { | |
| 4406 | mockedExecute.mockReturnValue( | |
| 4407 | Effect.succeed( | |
| 4408 | output( | |
| 4409 | encodeJson({ | |
| 4410 | data: { | |
| 4411 | repository: { | |
| 4412 | pullRequest: { | |
| 4413 | files: { | |
| 4414 | pageInfo: { hasNextPage: true, endCursor: "cursor" }, | |
| 4415 | nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], | |
| 4416 | }, | |
| 4417 | }, | |
| 4418 | }, | |
| 4419 | }, | |
| 4420 | }), | |
| 4421 | ), | |
| 4422 | ), | |
| 4423 | ); | |
| 4424 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4425 | ||
| 4426 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4427 | cwd: "/w", | |
| 4428 | repository: "acme/web", | |
| 4429 | host: "github.com", | |
| 4430 | number: 7, | |
| 4431 | }); | |
| 4432 | ||
| 4433 | // The same cursor twice is a page GitHub already gave, so reading stops there. | |
| 4434 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4435 | assert.isTrue(viewed.truncated); | |
| 4436 | }), | |
| 4437 | ); | |
| 4438 | ||
| 4439 | it.effect("clears and restores a burst of files in one request", () => | |
| 4440 | Effect.gen(function* () { | |
| 4441 | mockedExecute | |
| 4442 | .mockReturnValueOnce( | |
| 4443 | Effect.succeed( | |
| 4444 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4445 | ), | |
| 4446 | ) | |
| 4447 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4448 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4449 | ||
| 4450 | yield* cli.setPullRequestFilesViewed({ | |
| 4451 | cwd: "/w", | |
| 4452 | repository: "acme/web", | |
| 4453 | host: "github.com", | |
| 4454 | number: 23, | |
| 4455 | files: [ | |
| 4456 | { path: "src/a.ts", viewed: true }, | |
| 4457 | { path: "src/b.ts", viewed: false }, | |
| 4458 | ], | |
| 4459 | }); | |
| 4460 | ||
| 4461 | // One request to learn the pull request's node id, one for every press together. | |
| 4462 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4463 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); | |
| 4464 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); | |
| 4465 | expect(varsAt(1)).toEqual({ | |
| 4466 | pullRequestId: "PR_1", | |
| 4467 | f0_path: "src/a.ts", | |
| 4468 | f1_path: "src/b.ts", | |
| 4469 | }); | |
| 4470 | }), | |
| 4471 | ); | |
| 4472 | ||
| 4473 | it.effect("asks the host nothing when nothing was pressed", () => | |
| 4474 | Effect.gen(function* () { | |
| 4475 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4476 | ||
| 4477 | yield* cli.setPullRequestFilesViewed({ | |
| 4478 | cwd: "/w", | |
| 4479 | repository: "acme/web", | |
| 4480 | host: "github.com", | |
| 4481 | number: 7, | |
| 4482 | files: [], | |
| 4483 | }); | |
| 4484 | ||
| 4485 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 4486 | }), | |
| 4487 | ); | |
| 4488 | ||
| 4489 | it.effect("looks a pull request's node id up once, however often it is written to", () => | |
| 4490 | Effect.gen(function* () { | |
| 4491 | mockedExecute | |
| 4492 | .mockReturnValueOnce( | |
| 4493 | Effect.succeed( | |
| 4494 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4495 | ), | |
| 4496 | ) | |
| 4497 | .mockReturnValue(Effect.succeed(output("{}"))); | |
| 4498 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4499 | const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; | |
| 4500 | ||
| 4501 | yield* cli.setPullRequestFilesViewed({ | |
| 4502 | ...pullRequest, | |
| 4503 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4504 | }); | |
| 4505 | yield* cli.setPullRequestFilesViewed({ | |
| 4506 | ...pullRequest, | |
| 4507 | files: [{ path: "src/b.ts", viewed: true }], | |
| 4508 | }); | |
| 4509 | yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); | |
| 4510 | ||
| 4511 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. | |
| 4512 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 4513 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); | |
| 4514 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ | |
| 4515 | "PR_24", | |
| 4516 | "PR_24", | |
| 4517 | "PR_24", | |
| 4518 | ]); | |
| 4519 | }), | |
| 4520 | ); | |
| 4521 | ||
| 4522 | it.effect("does not remember a node id lookup that failed", () => | |
| 4523 | Effect.gen(function* () { | |
| 4524 | mockedExecute | |
| 4525 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) | |
| 4526 | .mockReturnValueOnce( | |
| 4527 | Effect.succeed( | |
| 4528 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4529 | ), | |
| 4530 | ) | |
| 4531 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4532 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4533 | const write = () => | |
| 4534 | cli.setPullRequestFilesViewed({ | |
| 4535 | cwd: "/w", | |
| 4536 | repository: "acme/web", | |
| 4537 | host: "github.com", | |
| 4538 | number: 25, | |
| 4539 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4540 | }); | |
| 4541 | ||
| 4542 | const error = yield* Effect.flip(write()); | |
| 4543 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 4544 | ||
| 4545 | yield* write(); | |
| 4546 | ||
| 4547 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4548 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); | |
| 4549 | }), | |
| 4550 | ); | |
| 4551 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => | |
| 4552 | Effect.gen(function* () { | |
| 4553 | // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is | |
| 4554 | // working down is the first thing evicted once a listing has walked a cache's worth of cold | |
| 4555 | // pull requests, and every press after that pays a round trip again. | |
| 4556 | // This block shares one cache, so these numbers are its own and it runs last. | |
| 4557 | const HOT = 9_000; | |
| 4558 | const lookupsOf = new Map<number, number>(); | |
| 4559 | mockedExecute.mockImplementation((input) => { | |
| 4560 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { | |
| 4561 | return Effect.succeed(output("{}")); | |
| 4562 | } | |
| 4563 | const number = Number(input.variables?.["number"]); | |
| 4564 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); | |
| 4565 | return Effect.succeed( | |
| 4566 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), | |
| 4567 | ); | |
| 4568 | }); | |
| 4569 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4570 | const tick = (number: number) => | |
| 4571 | cli.setPullRequestFilesViewed({ | |
| 4572 | cwd: "/w", | |
| 4573 | repository: "acme/web", | |
| 4574 | host: "github.com", | |
| 4575 | number, | |
| 4576 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4577 | }); | |
| 4578 | ||
| 4579 | yield* tick(HOT); | |
| 4580 | // A cache's worth of cold pull requests, with the open one pressed in between each of them. | |
| 4581 | for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { | |
| 4582 | yield* tick(HOT + 1 + filled); | |
| 4583 | yield* tick(HOT); | |
| 4584 | } | |
| 4585 | ||
| 4586 | assert.strictEqual(lookupsOf.get(HOT), 1); | |
| 4587 | }), | |
| 4588 | ); | |
| 4589 | }); |
packages/source-control-github/src/server/GitHubPullRequestApi.ts created+2857| ... | ... | @@ -0,0 +1,2857 @@ |
| 1 | import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; | |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Clock from "effect/Clock"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Request from "effect/Request"; | |
| 14 | import * as RequestResolver from "effect/RequestResolver"; | |
| 15 | import * as Result from "effect/Result"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import { | |
| 19 | resolvePullRequestAuthorFilter, | |
| 20 | PositiveInt, | |
| 21 | TrimmedNonEmptyString, | |
| 22 | type PullRequestAction, | |
| 23 | type PullRequestStackHead, | |
| 24 | type PullRequestActor, | |
| 25 | type PullRequestComment, | |
| 26 | type PullRequestCommit, | |
| 27 | type PullRequestFileViewed, | |
| 28 | type PullRequestInvolvement, | |
| 29 | type PullRequestListFilters, | |
| 30 | type PullRequestListState, | |
| 31 | type PullRequestMergeMethod, | |
| 32 | type PullRequestOmittedFileStat, | |
| 33 | type PullRequestReaction, | |
| 34 | type PullRequestReactionContent, | |
| 35 | type PullRequestReviewCommentDraft, | |
| 36 | type PullRequestReviewVerdict, | |
| 37 | type PullRequestReviewerCandidateList, | |
| 38 | type PullRequestReviewerKind, | |
| 39 | type PullRequestLabelCandidateList, | |
| 40 | type PullRequestThreadCommentsResult, | |
| 41 | type PullRequestUpdateMethod, | |
| 42 | type PullRequestPreview, | |
| 43 | } from "@t3tools/contracts"; | |
| 44 | ||
| 45 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 46 | import { readGraphQlPages } from "./githubGraphQl.ts"; | |
| 47 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 48 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 49 | import { | |
| 50 | ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 51 | ADD_REACTION_GRAPHQL_MUTATION, | |
| 52 | buildReviewSubmission, | |
| 53 | buildReviewerRequest, | |
| 54 | buildSetFilesViewedGraphQlMutation, | |
| 55 | decodeActorAvatarsJson, | |
| 56 | decodePullRequestActivityJson, | |
| 57 | decodePullRequestCheckContextsJson, | |
| 58 | decodePullRequestCoreJson, | |
| 59 | decodeCommitFilesJson, | |
| 60 | pullRequestCheckContextsGraphQlQuery, | |
| 61 | pullRequestChecksFromContexts, | |
| 62 | pullRequestListGraphQlQuery, | |
| 63 | pullRequestSummaryGraphQlQuery, | |
| 64 | PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 65 | PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 66 | decodeWorkflowRunsJson, | |
| 67 | pullRequestCoreGraphQlQuery, | |
| 68 | type GitHubPullRequestCore, | |
| 69 | type GitHubPullRequestSummary, | |
| 70 | type GitHubPullRequestWatchFingerprint, | |
| 71 | decodePullRequestPreviewJson, | |
| 72 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 73 | decodePullRequestFilesJson, | |
| 74 | decodePullRequestFilesViewedJson, | |
| 75 | decodePullRequestHeadsJson, | |
| 76 | decodePullRequestListJson, | |
| 77 | decodePullRequestNodeIdJson, | |
| 78 | decodePullRequestSearchJson, | |
| 79 | decodePullRequestStacksJson, | |
| 80 | decodePullRequestStatsJson, | |
| 81 | decodePullRequestSummariesJson, | |
| 82 | decodePullRequestWatchFingerprintsJson, | |
| 83 | decodeReactionSubjectScopeJson, | |
| 84 | decodeReviewerCandidatesJson, | |
| 85 | decodeLabelCandidatesJson, | |
| 86 | buildLabelRequest, | |
| 87 | LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 88 | decodeReviewDismissalsJson, | |
| 89 | decodeReviewThreadCommentsJson, | |
| 90 | decodeReviewThreadsJson, | |
| 91 | buildPullRequestStatsGraphQlQuery, | |
| 92 | buildPullRequestSummariesGraphQlQuery, | |
| 93 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 94 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 95 | decodePullRequestStackMembershipsJson, | |
| 96 | pullRequestSearchGraphQlQuery, | |
| 97 | PULL_REQUEST_SEARCH_MAX_ROWS, | |
| 98 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 99 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 100 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 101 | REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 102 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 103 | gitHubReactionContent, | |
| 104 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 105 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 106 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 107 | REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 108 | REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 109 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 110 | reviewThreadConversation, | |
| 111 | UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 112 | UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, | |
| 113 | UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 114 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 115 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 116 | decodeViewerPermissionsJson, | |
| 117 | type GitHubPullRequestActivity, | |
| 118 | type GitHubPullRequestActivityPage, | |
| 119 | type GitHubWorkflowRunPage, | |
| 120 | type GitHubPullRequestHead, | |
| 121 | type GitHubPullRequestListItem, | |
| 122 | type GitHubPullRequestSearchItem, | |
| 123 | type GitHubPullRequestStack, | |
| 124 | type GitHubReviewThreadComments, | |
| 125 | type GitHubRepositoryAccess, | |
| 126 | type GitHubWorkflowRunApproval, | |
| 127 | type GitHubReviewThreadEntry, | |
| 128 | type GitHubReviewThreadPage, | |
| 129 | type GitHubViewerAccess, | |
| 130 | } from "./gitHubPullRequestJson.ts"; | |
| 131 | import type { | |
| 132 | ProviderChangeRequestSummary, | |
| 133 | ProviderListCursor, | |
| 134 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 135 | ||
| 136 | /** | |
| 137 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 138 | * rather than borrowing another operation's message. | |
| 139 | */ | |
| 140 | export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullRequestReadError>()( | |
| 141 | "GitHubPullRequestReadError", | |
| 142 | { | |
| 143 | cwd: Schema.String, | |
| 144 | operation: Schema.String, | |
| 145 | cause: Schema.Defect(), | |
| 146 | }, | |
| 147 | ) { | |
| 148 | override get message(): string { | |
| 149 | return `GitHub returned an unreadable ${this.operation} response.`; | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | /** Not a decode failure: GitHub answered, the account it answered for just has no login. */ | |
| 154 | export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHubViewerLoginUnavailableError>()( | |
| 155 | "GitHubViewerLoginUnavailableError", | |
| 156 | { | |
| 157 | cwd: Schema.String, | |
| 158 | }, | |
| 159 | ) { | |
| 160 | override get message(): string { | |
| 161 | return "GitHub returned no login for the authenticated account."; | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | /** Not a decode failure: GitHub answered, but the pull request carried no update time. */ | |
| 166 | export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError<GitHubPullRequestUpdatedAtUnavailableError>()( | |
| 167 | "GitHubPullRequestUpdatedAtUnavailableError", | |
| 168 | { | |
| 169 | cwd: Schema.String, | |
| 170 | repository: Schema.String, | |
| 171 | number: Schema.Int, | |
| 172 | }, | |
| 173 | ) { | |
| 174 | override get message(): string { | |
| 175 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | |
| 180 | export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorError>()( | |
| 181 | "GitHubDiffCursorError", | |
| 182 | { | |
| 183 | cwd: Schema.String, | |
| 184 | }, | |
| 185 | ) { | |
| 186 | override get message(): string { | |
| 187 | return "The diff cursor was not one this pull request handed out."; | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 192 | export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitError>()( | |
| 193 | "GitHubDiffCommitError", | |
| 194 | { | |
| 195 | cwd: Schema.String, | |
| 196 | }, | |
| 197 | ) { | |
| 198 | override get message(): string { | |
| 199 | return "The named commit was not a commit sha."; | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | /** The revisions read successfully, but cannot name both sides this file needs. */ | |
| 204 | export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitHubDiffRevisionsUnavailableError>()( | |
| 205 | "GitHubDiffRevisionsUnavailableError", | |
| 206 | { | |
| 207 | cwd: Schema.String, | |
| 208 | number: Schema.Int, | |
| 209 | commit: Schema.optional(Schema.String), | |
| 210 | }, | |
| 211 | ) { | |
| 212 | override get message(): string { | |
| 213 | return this.commit === undefined | |
| 214 | ? `Pull request #${this.number} reported no usable base and head revisions.` | |
| 215 | : `Commit ${this.commit} reported no usable revisions for this file.`; | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | |
| 220 | export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<GitHubDiffFileContentsUnavailableError>()( | |
| 221 | "GitHubDiffFileContentsUnavailableError", | |
| 222 | { | |
| 223 | cwd: Schema.String, | |
| 224 | path: Schema.String, | |
| 225 | reason: Schema.Literals(["oversized", "binary"]), | |
| 226 | }, | |
| 227 | ) { | |
| 228 | override get message(): string { | |
| 229 | return this.reason === "oversized" | |
| 230 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | |
| 231 | : `The diff file '${this.path}' is binary.`; | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL | |
| 237 | * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a | |
| 238 | * name that is not one is refused here rather than escaped into something GitHub might read as a | |
| 239 | * qualifier of its own. | |
| 240 | */ | |
| 241 | export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepositorySelectorError>()( | |
| 242 | "GitHubRepositorySelectorError", | |
| 243 | { | |
| 244 | cwd: Schema.String, | |
| 245 | operation: Schema.String, | |
| 246 | }, | |
| 247 | ) { | |
| 248 | override get message(): string { | |
| 249 | return "A repository was named that GitHub cannot address."; | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /** Not a decode failure: the reader named a subject this pull request never handed out. */ | |
| 254 | export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectScopeError>()( | |
| 255 | "GitHubSubjectScopeError", | |
| 256 | { | |
| 257 | cwd: Schema.String, | |
| 258 | operation: Schema.String, | |
| 259 | }, | |
| 260 | ) { | |
| 261 | override get message(): string { | |
| 262 | return "The named subject did not belong to the named pull request."; | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | /** GitHub answered successfully, but approving every returned workflow would be unsafe. */ | |
| 267 | export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHubWorkflowApprovalRefusedError>()( | |
| 268 | "GitHubWorkflowApprovalRefusedError", | |
| 269 | { | |
| 270 | cwd: Schema.String, | |
| 271 | number: Schema.Int, | |
| 272 | reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), | |
| 273 | observedCount: Schema.Int, | |
| 274 | limit: Schema.Int, | |
| 275 | }, | |
| 276 | ) { | |
| 277 | override get message(): string { | |
| 278 | if (this.reason === "head-list-truncated") { | |
| 279 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | |
| 280 | } | |
| 281 | if (this.reason === "head-not-unique") { | |
| 282 | return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; | |
| 283 | } | |
| 284 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ | |
| 289 | export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError<GitHubWorkflowApprovalHeadUnavailableError>()( | |
| 290 | "GitHubWorkflowApprovalHeadUnavailableError", | |
| 291 | { | |
| 292 | cwd: Schema.String, | |
| 293 | number: Schema.Int, | |
| 294 | }, | |
| 295 | ) { | |
| 296 | override get message(): string { | |
| 297 | return `GitHub did not report a complete head revision for #${this.number}.`; | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** The pull request moved after its approval candidates were read. */ | |
| 302 | export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<GitHubWorkflowApprovalHeadChangedError>()( | |
| 303 | "GitHubWorkflowApprovalHeadChangedError", | |
| 304 | { | |
| 305 | cwd: Schema.String, | |
| 306 | number: Schema.Int, | |
| 307 | }, | |
| 308 | ) { | |
| 309 | override get message(): string { | |
| 310 | return `The head revision of #${this.number} changed before its workflows could be approved.`; | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | export type GitHubPullRequestApiError = | |
| 315 | | GitHubStackActionError | |
| 316 | | GitHubApi.GitHubApiError | |
| 317 | | GitHubPullRequestReadError | |
| 318 | | GitHubDiffCursorError | |
| 319 | | GitHubDiffCommitError | |
| 320 | | GitHubDiffRevisionsUnavailableError | |
| 321 | | GitHubDiffFileContentsUnavailableError | |
| 322 | | GitHubRepositorySelectorError | |
| 323 | | GitHubSubjectScopeError | |
| 324 | | GitHubWorkflowApprovalRefusedError | |
| 325 | | GitHubWorkflowApprovalHeadUnavailableError | |
| 326 | | GitHubWorkflowApprovalHeadChangedError | |
| 327 | | SourceControlRateLimit.SourceControlRateLimitPausedError | |
| 328 | | GitHubViewerLoginUnavailableError | |
| 329 | | GitHubPullRequestUpdatedAtUnavailableError; | |
| 330 | ||
| 331 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ | |
| 332 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 333 | const DIFF_TIMEOUT = Duration.seconds(60); | |
| 334 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ | |
| 335 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | |
| 336 | ||
| 337 | /** What `gh pr list --state` asked a repository's own list for, without search. */ | |
| 338 | const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = { | |
| 339 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 340 | open: ["OPEN"], | |
| 341 | // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out | |
| 342 | // locally by `matchesUnsortedListing`. | |
| 343 | closed: ["CLOSED", "MERGED"], | |
| 344 | merged: ["MERGED"], | |
| 345 | }; | |
| 346 | ||
| 347 | interface GitHubPullRequestListPage { | |
| 348 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 349 | readonly rawCount: number; | |
| 350 | readonly endCursor: string | null; | |
| 351 | } | |
| 352 | ||
| 353 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ | |
| 354 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; | |
| 355 | ||
| 356 | /** What the files API serves at most in one response, which is what one slice is made of. */ | |
| 357 | const DIFF_FILES_PAGE_SIZE = 100; | |
| 358 | /** | |
| 359 | * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL | |
| 360 | * budget per page, against a change request nobody reviews in one sitting past the first few | |
| 361 | * hundred files: beyond this the read stops and says it was cut short. | |
| 362 | */ | |
| 363 | const FILES_VIEWED_MAX_PAGES = 5; | |
| 364 | ||
| 365 | /** | |
| 366 | * How many pull requests' node ids are remembered at once. A long-lived server sees far more of | |
| 367 | * them than a reader ever has open, and least recently used rather than first in: a listing | |
| 368 | * walking cold pull requests must not evict the review being ticked through. | |
| 369 | */ | |
| 370 | export const NODE_ID_CACHE_CAPACITY = 128; | |
| 371 | ||
| 372 | /** | |
| 373 | * Pages of review threads to follow before the conversation is reported as truncated. GitHub | |
| 374 | * serves a hundred threads a page, so this is a thousand threads — past anything a pull request | |
| 375 | * a person is reading has, and short of walking a repository-sized conversation forever. | |
| 376 | */ | |
| 377 | const REVIEW_THREAD_PAGES = 10; | |
| 378 | ||
| 379 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ | |
| 380 | const CHECK_CONTEXT_PAGES = 10; | |
| 381 | ||
| 382 | export interface GitHubPullRequestListBatch { | |
| 383 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 384 | readonly truncated: boolean; | |
| 385 | /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ | |
| 386 | readonly continues: boolean; | |
| 387 | } | |
| 388 | ||
| 389 | export interface GitHubPullRequestStat { | |
| 390 | readonly repository: string; | |
| 391 | readonly number: number; | |
| 392 | readonly additions: number; | |
| 393 | readonly deletions: number; | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request | |
| 398 | * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. | |
| 399 | */ | |
| 400 | const STAT_ALIASES_PER_REQUEST = 25; | |
| 401 | const STAT_REQUEST_CONCURRENCY = 4; | |
| 402 | /** | |
| 403 | * How long a summary read waits for company. The background sync asks for every linked pull | |
| 404 | * request at once, and each read reaches the resolver after its own cache check, so a batch | |
| 405 | * needs a moment longer than one scheduler tick to gather them. | |
| 406 | */ | |
| 407 | const SUMMARY_BATCH_WINDOW = "10 millis"; | |
| 408 | ||
| 409 | class PullRequestSummaryRead extends Request.Class< | |
| 410 | { | |
| 411 | readonly cwd: string; | |
| 412 | readonly repository: string; | |
| 413 | readonly host: string; | |
| 414 | readonly number: number; | |
| 415 | }, | |
| 416 | ProviderChangeRequestSummary, | |
| 417 | GitHubPullRequestApiError | |
| 418 | > {} | |
| 419 | ||
| 420 | class PullRequestWatchFingerprintRead extends Request.Class< | |
| 421 | { | |
| 422 | readonly cwd: string; | |
| 423 | readonly repository: string; | |
| 424 | readonly host: string; | |
| 425 | readonly number: number; | |
| 426 | }, | |
| 427 | GitHubPullRequestWatchFingerprint | null, | |
| 428 | GitHubPullRequestApiError | |
| 429 | > {} | |
| 430 | ||
| 431 | export interface GitHubPullRequestSearchBatch { | |
| 432 | /** Rows across every repository asked for, newest update first, each naming its own. */ | |
| 433 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 434 | readonly truncated: boolean; | |
| 435 | } | |
| 436 | ||
| 437 | export interface GitHubPullRequestDiffSlice { | |
| 438 | readonly patch: string; | |
| 439 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | |
| 440 | readonly truncated: boolean; | |
| 441 | /** Where the next slice starts, or null once the patch is whole. */ | |
| 442 | readonly nextCursor: string | null; | |
| 443 | /** GitHub's own counts for the files whose hunks it withheld from this slice. */ | |
| 444 | readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 445 | } | |
| 446 | ||
| 447 | export interface GitHubPullRequestFilesViewed { | |
| 448 | readonly files: ReadonlyArray<PullRequestFileViewed>; | |
| 449 | /** GitHub had more files than the page budget below would read. */ | |
| 450 | readonly truncated: boolean; | |
| 451 | } | |
| 452 | ||
| 453 | export class GitHubPullRequestApi extends Context.Service< | |
| 454 | GitHubPullRequestApi, | |
| 455 | { | |
| 456 | readonly withVerifiedCredential: <A, E, R>( | |
| 457 | input: { readonly cwd: string; readonly host: string }, | |
| 458 | use: (identity: { | |
| 459 | readonly accountId: string; | |
| 460 | readonly viewer: string; | |
| 461 | readonly credentialFingerprint: string; | |
| 462 | }) => Effect.Effect<A, E, R>, | |
| 463 | ) => Effect.Effect<A, E | GitHubPullRequestApiError, R>; | |
| 464 | readonly getRoutingIdentity: (input: { | |
| 465 | readonly cwd: string; | |
| 466 | readonly host: string; | |
| 467 | }) => Effect.Effect< | |
| 468 | { readonly accountId: string; readonly viewer: string }, | |
| 469 | GitHubPullRequestApiError | |
| 470 | >; | |
| 471 | readonly getViewerLogin: (input: { | |
| 472 | readonly cwd: string; | |
| 473 | readonly host: string; | |
| 474 | }) => Effect.Effect<string, GitHubPullRequestApiError>; | |
| 475 | ||
| 476 | readonly listPullRequests: (input: { | |
| 477 | readonly cwd: string; | |
| 478 | readonly repository: string; | |
| 479 | readonly host: string; | |
| 480 | readonly state: PullRequestListState; | |
| 481 | readonly involvement: PullRequestInvolvement; | |
| 482 | readonly viewer: string; | |
| 483 | readonly limit: number; | |
| 484 | /** Free text for `--search`, matched as one literal phrase. */ | |
| 485 | readonly query?: string | undefined; | |
| 486 | /** Where to carry on from, as a `updated:` qualifier on the same search. */ | |
| 487 | readonly cursor?: ProviderListCursor | undefined; | |
| 488 | /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ | |
| 489 | readonly filters?: PullRequestListFilters | undefined; | |
| 490 | }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError>; | |
| 491 | ||
| 492 | /** | |
| 493 | * The same listing for a whole host in one search. `limit` is the size of the slice across | |
| 494 | * all of the repositories rather than per repository, because that is what a search answers: | |
| 495 | * the newest rows of the lot, which is exactly the page. | |
| 496 | */ | |
| 497 | readonly searchPullRequests: (input: { | |
| 498 | /** Any checkout on the host; the search names its repositories itself. */ | |
| 499 | readonly cwd: string; | |
| 500 | readonly host: string; | |
| 501 | readonly repositories: ReadonlyArray<string>; | |
| 502 | readonly state: PullRequestListState; | |
| 503 | readonly involvement: PullRequestInvolvement; | |
| 504 | readonly viewer: string; | |
| 505 | readonly limit: number; | |
| 506 | readonly query?: string | undefined; | |
| 507 | readonly cursor?: ProviderListCursor | undefined; | |
| 508 | readonly filters?: PullRequestListFilters | undefined; | |
| 509 | }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestApiError>; | |
| 510 | ||
| 511 | /** The line counts the search leaves out, for rows already on the page. */ | |
| 512 | readonly listPullRequestStats: (input: { | |
| 513 | readonly cwd: string; | |
| 514 | readonly host: string; | |
| 515 | readonly changeRequests: ReadonlyArray<{ | |
| 516 | readonly repository: string; | |
| 517 | readonly number: number; | |
| 518 | }>; | |
| 519 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestApiError>; | |
| 520 | ||
| 521 | readonly getPullRequestSummary: (input: { | |
| 522 | readonly cwd: string; | |
| 523 | readonly repository: string; | |
| 524 | readonly host: string; | |
| 525 | readonly number: number; | |
| 526 | }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestApiError>; | |
| 527 | /** | |
| 528 | * What a watch compares between passes, batched like summaries. Null when GitHub gave no | |
| 529 | * answer for this pull request, so the watch reads it in full instead. | |
| 530 | */ | |
| 531 | readonly getPullRequestWatchFingerprint: (input: { | |
| 532 | readonly cwd: string; | |
| 533 | readonly repository: string; | |
| 534 | readonly host: string; | |
| 535 | readonly number: number; | |
| 536 | }) => Effect.Effect<GitHubPullRequestWatchFingerprint | null, GitHubPullRequestApiError>; | |
| 537 | ||
| 538 | readonly revalidateChecks: Effect.Success<typeof makeChecksRevalidator>; | |
| 539 | ||
| 540 | readonly getPullRequestDetail: (input: { | |
| 541 | readonly cwd: string; | |
| 542 | readonly repository: string; | |
| 543 | readonly host: string; | |
| 544 | readonly number: number; | |
| 545 | }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApiError>; | |
| 546 | ||
| 547 | readonly getPullRequestPreview: (input: { | |
| 548 | readonly cwd: string; | |
| 549 | readonly repository: string; | |
| 550 | readonly host: string; | |
| 551 | readonly number: number; | |
| 552 | }) => Effect.Effect< | |
| 553 | Omit<PullRequestPreview, "projectId" | "repository">, | |
| 554 | GitHubPullRequestApiError | |
| 555 | >; | |
| 556 | ||
| 557 | readonly listWorkflowRunsRequiringApproval: (input: { | |
| 558 | readonly cwd: string; | |
| 559 | readonly repository: string; | |
| 560 | readonly host: string; | |
| 561 | readonly number: number; | |
| 562 | readonly headSha: string; | |
| 563 | readonly headBranch: string; | |
| 564 | readonly headRepositoryOwner: string; | |
| 565 | readonly isCrossRepository: true; | |
| 566 | }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestApiError>; | |
| 567 | ||
| 568 | /** | |
| 569 | * The host-native stack this pull request is in, or null when it is in none — which is also | |
| 570 | * the answer for a host that refuses the stacks preview altogether. | |
| 571 | */ | |
| 572 | readonly getPullRequestStack: (input: { | |
| 573 | readonly cwd: string; | |
| 574 | readonly includeDetails?: boolean; | |
| 575 | readonly repository: string; | |
| 576 | readonly host: string; | |
| 577 | readonly number: number; | |
| 578 | }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestApiError>; | |
| 579 | ||
| 580 | readonly getPullRequestActivity: (input: { | |
| 581 | readonly cwd: string; | |
| 582 | readonly repository: string; | |
| 583 | readonly host: string; | |
| 584 | readonly number: number; | |
| 585 | }) => Effect.Effect<GitHubPullRequestActivity, GitHubPullRequestApiError>; | |
| 586 | ||
| 587 | readonly getPullRequestDiff: (input: { | |
| 588 | readonly cwd: string; | |
| 589 | readonly repository: string; | |
| 590 | readonly host: string; | |
| 591 | readonly number: number; | |
| 592 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | |
| 593 | readonly cursor?: string | undefined; | |
| 594 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 595 | readonly commit?: string | undefined; | |
| 596 | }) => Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError>; | |
| 597 | ||
| 598 | readonly getPullRequestDiffFileContents: (input: { | |
| 599 | readonly cwd: string; | |
| 600 | readonly repository: string; | |
| 601 | readonly host: string; | |
| 602 | readonly number: number; | |
| 603 | readonly commit?: string | undefined; | |
| 604 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | |
| 605 | readonly oldPath: string; | |
| 606 | readonly newPath: string; | |
| 607 | }) => Effect.Effect< | |
| 608 | { readonly oldContents: string; readonly newContents: string }, | |
| 609 | GitHubPullRequestApiError | |
| 610 | >; | |
| 611 | ||
| 612 | /** | |
| 613 | * Which files of the pull request the signed-in account has cleared, and which of those have | |
| 614 | * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. | |
| 615 | */ | |
| 616 | readonly getPullRequestFilesViewed: (input: { | |
| 617 | readonly cwd: string; | |
| 618 | readonly repository: string; | |
| 619 | readonly host: string; | |
| 620 | readonly number: number; | |
| 621 | }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestApiError>; | |
| 622 | ||
| 623 | /** | |
| 624 | * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, | |
| 625 | * so a burst is batched with aliases into one document rather than one subprocess per press. | |
| 626 | */ | |
| 627 | readonly setPullRequestFilesViewed: (input: { | |
| 628 | readonly cwd: string; | |
| 629 | readonly repository: string; | |
| 630 | readonly host: string; | |
| 631 | readonly number: number; | |
| 632 | readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; | |
| 633 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 634 | ||
| 635 | readonly listReviewThreadComments: (input: { | |
| 636 | readonly cwd: string; | |
| 637 | readonly repository: string; | |
| 638 | readonly host: string; | |
| 639 | readonly number: number; | |
| 640 | }) => Effect.Effect<GitHubReviewThreadComments, GitHubPullRequestApiError>; | |
| 641 | ||
| 642 | /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ | |
| 643 | readonly listActorAvatars: (input: { | |
| 644 | readonly cwd: string; | |
| 645 | readonly repository: string; | |
| 646 | readonly host: string; | |
| 647 | readonly ids: ReadonlyArray<string>; | |
| 648 | }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestApiError>; | |
| 649 | ||
| 650 | readonly getReviewThreadComments: (input: { | |
| 651 | readonly cwd: string; | |
| 652 | readonly repository: string; | |
| 653 | readonly host: string; | |
| 654 | readonly number: number; | |
| 655 | readonly threadId: string; | |
| 656 | readonly cursor: string; | |
| 657 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestApiError>; | |
| 658 | ||
| 659 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ | |
| 660 | readonly getViewerAccess: (input: { | |
| 661 | readonly cwd: string; | |
| 662 | readonly repository: string; | |
| 663 | readonly host: string; | |
| 664 | readonly number: number; | |
| 665 | /** Manual action checks may use the quota held back from automatic reads. */ | |
| 666 | readonly allowReserve?: boolean | undefined; | |
| 667 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestApiError>; | |
| 668 | ||
| 669 | /** Who this pull request may be sent to, and who it has already been sent to. */ | |
| 670 | readonly listReviewerCandidates: (input: { | |
| 671 | readonly cwd: string; | |
| 672 | readonly repository: string; | |
| 673 | readonly host: string; | |
| 674 | readonly number: number; | |
| 675 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitHubPullRequestApiError>; | |
| 676 | ||
| 677 | readonly setReviewerRequest: (input: { | |
| 678 | readonly cwd: string; | |
| 679 | readonly repository: string; | |
| 680 | readonly host: string; | |
| 681 | readonly number: number; | |
| 682 | readonly reviewers: ReadonlyArray<{ | |
| 683 | readonly id: string; | |
| 684 | readonly kind: PullRequestReviewerKind; | |
| 685 | }>; | |
| 686 | /** False deletes the same collection a request posts to, which takes the request back. */ | |
| 687 | readonly requested: boolean; | |
| 688 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 689 | ||
| 690 | /** The repository's labels, and which of them this pull request already wears. */ | |
| 691 | readonly listLabelCandidates: (input: { | |
| 692 | readonly cwd: string; | |
| 693 | readonly repository: string; | |
| 694 | readonly host: string; | |
| 695 | readonly number: number; | |
| 696 | }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestApiError>; | |
| 697 | ||
| 698 | readonly setLabels: (input: { | |
| 699 | readonly cwd: string; | |
| 700 | readonly repository: string; | |
| 701 | readonly host: string; | |
| 702 | readonly number: number; | |
| 703 | readonly labels: ReadonlyArray<string>; | |
| 704 | /** False takes each label off; true adds each to whatever is already there. */ | |
| 705 | readonly applied: boolean; | |
| 706 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 707 | ||
| 708 | readonly runPullRequestAction: (input: { | |
| 709 | readonly cwd: string; | |
| 710 | readonly repository: string; | |
| 711 | readonly host: string; | |
| 712 | readonly number: number; | |
| 713 | readonly action: PullRequestAction; | |
| 714 | readonly stackNumber?: number; | |
| 715 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 716 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 717 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 718 | readonly updateMethod?: PullRequestUpdateMethod; | |
| 719 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 720 | ||
| 721 | readonly commentOnPullRequest: (input: { | |
| 722 | readonly cwd: string; | |
| 723 | readonly repository: string; | |
| 724 | readonly host: string; | |
| 725 | readonly number: number; | |
| 726 | readonly body: string; | |
| 727 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 728 | ||
| 729 | readonly submitReview: (input: { | |
| 730 | readonly cwd: string; | |
| 731 | readonly repository: string; | |
| 732 | readonly host: string; | |
| 733 | readonly number: number; | |
| 734 | readonly verdict: PullRequestReviewVerdict; | |
| 735 | readonly body: string; | |
| 736 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 737 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 738 | ||
| 739 | readonly replyToReviewThread: (input: { | |
| 740 | readonly cwd: string; | |
| 741 | readonly repository: string; | |
| 742 | readonly host: string; | |
| 743 | readonly threadId: string; | |
| 744 | readonly body: string; | |
| 745 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 746 | ||
| 747 | readonly setReviewThreadResolution: (input: { | |
| 748 | readonly cwd: string; | |
| 749 | readonly repository: string; | |
| 750 | readonly host: string; | |
| 751 | readonly threadId: string; | |
| 752 | readonly resolved: boolean; | |
| 753 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 754 | ||
| 755 | /** | |
| 756 | * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls | |
| 757 | * reactable — a comment, a review, or the pull request itself, which is looked up here | |
| 758 | * because nothing in the conversation names it. A given `subjectId` is confirmed to belong | |
| 759 | * to this pull request before the mutation runs, since nothing else ties the two together. | |
| 760 | */ | |
| 761 | readonly setReaction: (input: { | |
| 762 | readonly cwd: string; | |
| 763 | readonly repository: string; | |
| 764 | readonly host: string; | |
| 765 | readonly number: number; | |
| 766 | readonly subjectId?: string | undefined; | |
| 767 | readonly content: PullRequestReactionContent; | |
| 768 | readonly reacted: boolean; | |
| 769 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 770 | ||
| 771 | /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ | |
| 772 | readonly updatePullRequest: (input: { | |
| 773 | readonly cwd: string; | |
| 774 | readonly repository: string; | |
| 775 | readonly host: string; | |
| 776 | readonly number: number; | |
| 777 | readonly title?: string | undefined; | |
| 778 | readonly body?: string | undefined; | |
| 779 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 780 | ||
| 781 | /** | |
| 782 | * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed | |
| 783 | * to belong to this pull request before the mutation runs, the way a reaction subject is. | |
| 784 | * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. | |
| 785 | */ | |
| 786 | readonly updateComment: (input: { | |
| 787 | readonly cwd: string; | |
| 788 | readonly repository: string; | |
| 789 | readonly host: string; | |
| 790 | readonly number: number; | |
| 791 | readonly commentId: string; | |
| 792 | readonly kind: "issue-comment" | "review-comment"; | |
| 793 | readonly body: string; | |
| 794 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 795 | } | |
| 796 | >()("@t3tools/source-control-github/server/GitHubPullRequestApi") {} | |
| 797 | ||
| 798 | /** | |
| 799 | * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. | |
| 800 | * The host is not read off the identity: it travels alongside it, because the identity a | |
| 801 | * project records is the path below its host and never names the host itself. | |
| 802 | */ | |
| 803 | function parseRepositorySelector(value: string): { | |
| 804 | readonly owner: string; | |
| 805 | readonly name: string; | |
| 806 | } { | |
| 807 | const parts = value.trim().split("/").filter(Boolean); | |
| 808 | return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; | |
| 809 | } | |
| 810 | ||
| 811 | /** | |
| 812 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | |
| 813 | * arrives from the reader as a string and goes straight into a request path, so it is parsed | |
| 814 | * rather than trusted; the length bound keeps a page number out of exponential notation. | |
| 815 | */ | |
| 816 | function diffCursorPage(cursor: string): number | null { | |
| 817 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | |
| 818 | } | |
| 819 | ||
| 820 | /** | |
| 821 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 822 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 823 | * whole sha. | |
| 824 | */ | |
| 825 | function isCommitSha(value: string): boolean { | |
| 826 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 827 | } | |
| 828 | ||
| 829 | /** | |
| 830 | * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole | |
| 831 | * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so | |
| 832 | * text typed into a search box could widen the very listing it is meant to narrow — inside them | |
| 833 | * it is only text. The two characters that could end the phrase early are therefore escaped | |
| 834 | * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which | |
| 835 | * would let everything after it out of the phrase. | |
| 836 | * | |
| 837 | * The phrase is one argv element, so nothing in it can become a flag of its own either. | |
| 838 | */ | |
| 839 | function searchPhrase(query: string): string { | |
| 840 | return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; | |
| 841 | } | |
| 842 | ||
| 843 | /** GitHub's own spelling of a review state, which is not the contract's. */ | |
| 844 | const REVIEW_QUALIFIERS = { | |
| 845 | approved: "approved", | |
| 846 | "changes-requested": "changes_requested", | |
| 847 | "review-required": "required", | |
| 848 | none: "none", | |
| 849 | } as const; | |
| 850 | ||
| 851 | /** | |
| 852 | * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the | |
| 853 | * one character that could end the quoted value early is dropped rather than escaped: no GitHub | |
| 854 | * label or login holds a double quote, so there is nothing to preserve and everything to lose. | |
| 855 | */ | |
| 856 | function qualifierValue(value: string): string { | |
| 857 | return `"${value.replaceAll('"', "").trim()}"`; | |
| 858 | } | |
| 859 | ||
| 860 | function filterQualifiers( | |
| 861 | filters: PullRequestListFilters | undefined, | |
| 862 | viewer: string, | |
| 863 | ): ReadonlyArray<string> { | |
| 864 | if (filters === undefined) return []; | |
| 865 | return [ | |
| 866 | // One qualifier per group, its names joined by commas — GitHub's own OR. | |
| 867 | ...(filters.labels ?? []).flatMap((group) => | |
| 868 | group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], | |
| 869 | ), | |
| 870 | ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), | |
| 871 | ...(filters.author === undefined | |
| 872 | ? [] | |
| 873 | : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), | |
| 874 | ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), | |
| 875 | ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), | |
| 876 | ...(filters.checks === undefined | |
| 877 | ? [] | |
| 878 | : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), | |
| 879 | ]; | |
| 880 | } | |
| 881 | ||
| 882 | /** | |
| 883 | * The same narrowings over a row that has already arrived, for the search-free fallback. Every | |
| 884 | * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by | |
| 885 | * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an | |
| 886 | * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, | |
| 887 | * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would | |
| 888 | * not have surfaced for `status:success` or `status:failure`. | |
| 889 | */ | |
| 890 | function matchesFilters( | |
| 891 | item: GitHubPullRequestListItem, | |
| 892 | filters: PullRequestListFilters | undefined, | |
| 893 | viewer: string, | |
| 894 | ): boolean { | |
| 895 | if (filters === undefined) return true; | |
| 896 | const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 897 | const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 898 | return ( | |
| 899 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && | |
| 900 | (filters.review === undefined || | |
| 901 | (filters.review === "none" | |
| 902 | ? item.reviewDecision === null | |
| 903 | : item.reviewDecision === filters.review)) && | |
| 904 | (filters.checks === undefined || item.checksState === filters.checks) && | |
| 905 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && | |
| 906 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && | |
| 907 | (filters.author === undefined || | |
| 908 | item.author?.login.toLowerCase() === | |
| 909 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) | |
| 910 | ); | |
| 911 | } | |
| 912 | ||
| 913 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ | |
| 914 | function matchesUnsortedListing( | |
| 915 | item: GitHubPullRequestListItem, | |
| 916 | input: { | |
| 917 | readonly state: PullRequestListState; | |
| 918 | readonly involvement: PullRequestInvolvement; | |
| 919 | readonly viewer: string; | |
| 920 | readonly filters?: PullRequestListFilters | undefined; | |
| 921 | }, | |
| 922 | ): boolean { | |
| 923 | const matchesState = input.state === "all" || item.state === input.state; | |
| 924 | const viewer = input.viewer.toLowerCase(); | |
| 925 | const matchesInvolvement = | |
| 926 | input.involvement === "all" || | |
| 927 | (input.involvement === "authored" | |
| 928 | ? item.author?.login.toLowerCase() === viewer | |
| 929 | : item.hasTeamReviewRequest || | |
| 930 | item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); | |
| 931 | return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); | |
| 932 | } | |
| 933 | ||
| 934 | /** What a repository selector may hold before it goes into a search as itself. */ | |
| 935 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | |
| 936 | ||
| 937 | /** | |
| 938 | * A listing as one GitHub search, across one repository or several: the only way to read a | |
| 939 | * whole host in one request, and the only order (`updated`) a continuation can carry on from. | |
| 940 | * | |
| 941 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is | |
| 942 | * `is:closed is:unmerged` here. | |
| 943 | * | |
| 944 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a | |
| 945 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its | |
| 946 | * own — so an unaddressable one refuses the whole read rather than being escaped into something | |
| 947 | * GitHub might still read. | |
| 948 | */ | |
| 949 | function searchQuery(input: { | |
| 950 | readonly repositories: ReadonlyArray<string>; | |
| 951 | readonly state: PullRequestListState; | |
| 952 | readonly involvement: PullRequestInvolvement; | |
| 953 | readonly viewer: string; | |
| 954 | readonly query?: string | undefined; | |
| 955 | readonly cursor?: ProviderListCursor | undefined; | |
| 956 | readonly filters?: PullRequestListFilters | undefined; | |
| 957 | }): string | null { | |
| 958 | if (input.repositories.length === 0) return null; | |
| 959 | const repositories = input.repositories.map((repository) => repository.trim()); | |
| 960 | if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; | |
| 961 | const query = input.query?.trim() ?? ""; | |
| 962 | return [ | |
| 963 | "is:pr", | |
| 964 | // "all" is every state, which `is:pr` already is. | |
| 965 | ...(input.state === "open" ? ["is:open"] : []), | |
| 966 | ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), | |
| 967 | ...(input.state === "merged" ? ["is:merged"] : []), | |
| 968 | ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), | |
| 969 | ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), | |
| 970 | ...(query.length === 0 ? [] : [searchPhrase(query)]), | |
| 971 | // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. | |
| 972 | ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), | |
| 973 | ...filterQualifiers(input.filters, input.viewer), | |
| 974 | // The order the page reads its rows in, and the only order a continuation can carry on from. | |
| 975 | "sort:updated-desc", | |
| 976 | ...repositories.map((repository) => `repo:${repository}`), | |
| 977 | ].join(" "); | |
| 978 | } | |
| 979 | ||
| 980 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | |
| 981 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { | |
| 982 | repository(owner: $owner, name: $name) { | |
| 983 | pullRequest(number: $number) { | |
| 984 | isMergeQueueEnabled | |
| 985 | headRefOid | |
| 986 | viewerMergeBodyText(mergeType: $method) | |
| 987 | } | |
| 988 | } | |
| 989 | }`; | |
| 990 | ||
| 991 | /** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ | |
| 992 | const decodeRevisionRefs = Schema.decodeUnknownOption( | |
| 993 | Schema.fromJsonString( | |
| 994 | Schema.Struct({ | |
| 995 | sha: Schema.optional(Schema.String), | |
| 996 | parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), | |
| 997 | base: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 998 | head: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 999 | }), | |
| 1000 | ), | |
| 1001 | ); | |
| 1002 | ||
| 1003 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( | |
| 1004 | Schema.fromJsonString( | |
| 1005 | Schema.Struct({ | |
| 1006 | data: Schema.Struct({ | |
| 1007 | repository: Schema.Struct({ | |
| 1008 | pullRequest: Schema.Struct({ | |
| 1009 | isMergeQueueEnabled: Schema.Boolean, | |
| 1010 | headRefOid: Schema.String, | |
| 1011 | viewerMergeBodyText: Schema.String, | |
| 1012 | }), | |
| 1013 | }), | |
| 1014 | }), | |
| 1015 | }), | |
| 1016 | ), | |
| 1017 | ); | |
| 1018 | const decodeMergeMessage = (raw: string) => | |
| 1019 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1020 | ||
| 1021 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ | |
| 1022 | const ACTION_STATE_GRAPHQL_QUERY = ` | |
| 1023 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 1024 | repository(owner: $owner, name: $name) { | |
| 1025 | pullRequest(number: $number) { | |
| 1026 | id | |
| 1027 | headRefOid | |
| 1028 | isMergeQueueEnabled | |
| 1029 | mergeStateStatus | |
| 1030 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 1031 | } | |
| 1032 | } | |
| 1033 | }`; | |
| 1034 | ||
| 1035 | const decodeActionStateResponse = Schema.decodeUnknownResult( | |
| 1036 | Schema.fromJsonString( | |
| 1037 | Schema.Struct({ | |
| 1038 | data: Schema.Struct({ | |
| 1039 | repository: Schema.Struct({ | |
| 1040 | pullRequest: Schema.Struct({ | |
| 1041 | id: Schema.String, | |
| 1042 | headRefOid: Schema.String, | |
| 1043 | isMergeQueueEnabled: Schema.optional(Schema.Boolean), | |
| 1044 | mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 1045 | baseRef: Schema.optional( | |
| 1046 | Schema.NullOr( | |
| 1047 | Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), | |
| 1048 | ), | |
| 1049 | ), | |
| 1050 | }), | |
| 1051 | }), | |
| 1052 | }), | |
| 1053 | }), | |
| 1054 | ), | |
| 1055 | ); | |
| 1056 | const decodeActionState = (raw: string) => | |
| 1057 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1058 | ||
| 1059 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { | |
| 1060 | mergePullRequest(input: $input) { clientMutationId } | |
| 1061 | }`; | |
| 1062 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { | |
| 1063 | enablePullRequestAutoMerge(input: $input) { clientMutationId } | |
| 1064 | }`; | |
| 1065 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1066 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1067 | }`; | |
| 1068 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { | |
| 1069 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } | |
| 1070 | }`; | |
| 1071 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1072 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1073 | }`; | |
| 1074 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1075 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1076 | }`; | |
| 1077 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1078 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1079 | }`; | |
| 1080 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1081 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1082 | }`; | |
| 1083 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { | |
| 1084 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } | |
| 1085 | }`; | |
| 1086 | ||
| 1087 | const GRAPHQL_MERGE_METHODS = { | |
| 1088 | merge: "MERGE", | |
| 1089 | squash: "SQUASH", | |
| 1090 | rebase: "REBASE", | |
| 1091 | } as const satisfies Record<PullRequestMergeMethod, string>; | |
| 1092 | ||
| 1093 | /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ | |
| 1094 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); | |
| 1095 | ||
| 1096 | /** The mutations that only need the pull request's node id. */ | |
| 1097 | const SIMPLE_ACTION_MUTATIONS = { | |
| 1098 | "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, | |
| 1099 | ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, | |
| 1100 | draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, | |
| 1101 | close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1102 | reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1103 | } as const satisfies Partial<Record<PullRequestAction, string>>; | |
| 1104 | ||
| 1105 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 1106 | export const make = Effect.gen(function* () { | |
| 1107 | const api = yield* GitHubApi.GitHubApi; | |
| 1108 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; | |
| 1109 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1110 | const revalidateChecks = yield* makeChecksRevalidator; | |
| 1111 | const routingIdentities = new Map< | |
| 1112 | string, | |
| 1113 | { | |
| 1114 | at: number; | |
| 1115 | value: { accountId: string; viewer: string }; | |
| 1116 | } | |
| 1117 | >(); | |
| 1118 | const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>(); | |
| 1119 | const decodeRoutingIdentity = Schema.decodeUnknownEffect( | |
| 1120 | Schema.fromJsonString( | |
| 1121 | Schema.Struct({ | |
| 1122 | id: PositiveInt, | |
| 1123 | login: TrimmedNonEmptyString, | |
| 1124 | }), | |
| 1125 | ), | |
| 1126 | ); | |
| 1127 | const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( | |
| 1128 | function* (input: { readonly cwd: string; readonly host: string }) { | |
| 1129 | const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); | |
| 1130 | const host = input.host.toLowerCase(); | |
| 1131 | // A missing or signed-out credential keeps its own error, so the page can say which. | |
| 1132 | const { token, fingerprint: key } = yield* api.credential(host); | |
| 1133 | const credential = { host, token, credentialFingerprint: key }; | |
| 1134 | // A cold page may ask several times. Wait per credential and check again after the | |
| 1135 | // first verification; cancellation releases the next waiter without losing its request. | |
| 1136 | return yield* Effect.acquireUseRelease( | |
| 1137 | Effect.sync(() => { | |
| 1138 | const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; | |
| 1139 | lock.users++; | |
| 1140 | identityLocks.set(key, lock); | |
| 1141 | return lock; | |
| 1142 | }), | |
| 1143 | (lock) => | |
| 1144 | lock.gate.withPermit( | |
| 1145 | Effect.gen(function* () { | |
| 1146 | const now = yield* Clock.currentTimeMillis; | |
| 1147 | const cached = routingIdentities.get(key); | |
| 1148 | if (cached !== undefined && now - cached.at < 10 * 60_000) | |
| 1149 | return { ...credential, ...cached.value }; | |
| 1150 | // Pin this read so an auth switch cannot poison its cache entry. | |
| 1151 | const response = yield* api | |
| 1152 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) | |
| 1153 | .pipe( | |
| 1154 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 1155 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), | |
| 1156 | ); | |
| 1157 | const identity = yield* decodeRoutingIdentity(response.body).pipe( | |
| 1158 | Effect.mapError(unavailable), | |
| 1159 | ); | |
| 1160 | const value = { accountId: String(identity.id), viewer: identity.login }; | |
| 1161 | if (routingIdentities.size >= 128) | |
| 1162 | routingIdentities.delete(routingIdentities.keys().next().value!); | |
| 1163 | routingIdentities.set(key, { at: now, value }); | |
| 1164 | return { ...credential, ...value }; | |
| 1165 | }), | |
| 1166 | ), | |
| 1167 | (lock) => | |
| 1168 | Effect.sync(() => { | |
| 1169 | lock.users--; | |
| 1170 | if (lock.users === 0) identityLocks.delete(key); | |
| 1171 | }), | |
| 1172 | ); | |
| 1173 | }, | |
| 1174 | ); | |
| 1175 | const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( | |
| 1176 | input, | |
| 1177 | use, | |
| 1178 | ) => | |
| 1179 | captureVerifiedCredential(input).pipe( | |
| 1180 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => | |
| 1181 | use({ accountId, viewer, credentialFingerprint }).pipe( | |
| 1182 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), | |
| 1183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 1184 | host, | |
| 1185 | token, | |
| 1186 | credentialFingerprint, | |
| 1187 | }), | |
| 1188 | ), | |
| 1189 | ), | |
| 1190 | ); | |
| 1191 | const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => | |
| 1192 | captureVerifiedCredential(input).pipe( | |
| 1193 | Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), | |
| 1194 | ); | |
| 1195 | ||
| 1196 | /** | |
| 1197 | * The pull request's own node id, which is what a mutation against the pull request itself is | |
| 1198 | * addressed by: a reaction on its description, or a rewrite of its words. | |
| 1199 | * | |
| 1200 | * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader | |
| 1201 | * ticking files viewed would otherwise pay a GraphQL round trip per press. | |
| 1202 | */ | |
| 1203 | const nodeIds = new Map<string, string>(); | |
| 1204 | ||
| 1205 | const pullRequestNodeId = (input: { | |
| 1206 | readonly cwd: string; | |
| 1207 | readonly repository: string; | |
| 1208 | readonly host: string; | |
| 1209 | readonly number: number; | |
| 1210 | readonly operation: string; | |
| 1211 | }): Effect.Effect<string, GitHubPullRequestApiError> => { | |
| 1212 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1213 | const key = `${input.host} ${owner}/${name} ${input.number}`; | |
| 1214 | const held = nodeIds.get(key); | |
| 1215 | if (held !== undefined) { | |
| 1216 | // Put back at the end on every hit, so what falls out is the pull request nobody has looked | |
| 1217 | // at rather than the one being ticked through: a run of cold reads would otherwise evict the | |
| 1218 | // open review and make it pay a round trip per press. | |
| 1219 | nodeIds.delete(key); | |
| 1220 | nodeIds.set(key, held); | |
| 1221 | return Effect.succeed(held); | |
| 1222 | } | |
| 1223 | return graphqlRead({ | |
| 1224 | cwd: input.cwd, | |
| 1225 | host: input.host, | |
| 1226 | operation: input.operation, | |
| 1227 | allowReserve: true, | |
| 1228 | variables: { owner, name, number: input.number }, | |
| 1229 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 1230 | decode: decodePullRequestNodeIdJson, | |
| 1231 | }).pipe( | |
| 1232 | Effect.tap((nodeId) => | |
| 1233 | Effect.sync(() => { | |
| 1234 | if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { | |
| 1235 | const oldest = nodeIds.keys().next().value; | |
| 1236 | if (oldest !== undefined) nodeIds.delete(oldest); | |
| 1237 | } | |
| 1238 | nodeIds.set(key, nodeId); | |
| 1239 | }), | |
| 1240 | ), | |
| 1241 | ); | |
| 1242 | }; | |
| 1243 | ||
| 1244 | /** | |
| 1245 | * Whether a client-given subject actually belongs to the pull request the request names. A | |
| 1246 | * subject id is trusted to be whatever node it names, and that node can hang off any pull | |
| 1247 | * request on the host — so the mutation itself would write wherever the id actually belongs, | |
| 1248 | * not wherever the request says it does, unless this confirms the two agree first. | |
| 1249 | */ | |
| 1250 | const subjectBelongsToPullRequest = (input: { | |
| 1251 | readonly cwd: string; | |
| 1252 | readonly repository: string; | |
| 1253 | readonly host: string; | |
| 1254 | readonly number: number; | |
| 1255 | readonly subjectId: string; | |
| 1256 | readonly operation: string; | |
| 1257 | }) => { | |
| 1258 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1259 | return graphqlRead({ | |
| 1260 | cwd: input.cwd, | |
| 1261 | host: input.host, | |
| 1262 | operation: input.operation, | |
| 1263 | allowReserve: true, | |
| 1264 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, | |
| 1265 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 1266 | decode: decodeReactionSubjectScopeJson, | |
| 1267 | }); | |
| 1268 | }; | |
| 1269 | ||
| 1270 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ | |
| 1271 | const graphql = (input: { | |
| 1272 | readonly host: string; | |
| 1273 | readonly operation: string; | |
| 1274 | readonly query: string; | |
| 1275 | readonly variables: Readonly<Record<string, unknown>>; | |
| 1276 | }) => api.graphql(input).pipe(Effect.asVoid); | |
| 1277 | ||
| 1278 | const readError = (cwd: string, operation: string, cause: unknown) => | |
| 1279 | new GitHubPullRequestReadError({ cwd, operation, cause }); | |
| 1280 | ||
| 1281 | /** Decodes an answer, reporting a failure against the read that made it. */ | |
| 1282 | const decodeWith = <A>( | |
| 1283 | cwd: string, | |
| 1284 | operation: string, | |
| 1285 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 1286 | raw: string, | |
| 1287 | ): Effect.Effect<A, GitHubPullRequestReadError> => { | |
| 1288 | const decoded = decode(raw.trim()); | |
| 1289 | return Result.isSuccess(decoded) | |
| 1290 | ? Effect.succeed(decoded.success) | |
| 1291 | : Effect.fail(readError(cwd, operation, decoded.failure)); | |
| 1292 | }; | |
| 1293 | ||
| 1294 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ | |
| 1295 | const graphqlRead = <A>(input: { | |
| 1296 | readonly cwd: string; | |
| 1297 | readonly host: string; | |
| 1298 | readonly operation: string; | |
| 1299 | readonly allowReserve?: boolean | undefined; | |
| 1300 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 1301 | readonly query: string; | |
| 1302 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1303 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1304 | api | |
| 1305 | .graphql({ | |
| 1306 | host: input.host, | |
| 1307 | operation: input.operation, | |
| 1308 | query: input.query, | |
| 1309 | ...(input.variables === undefined ? {} : { variables: input.variables }), | |
| 1310 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 1311 | }) | |
| 1312 | .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); | |
| 1313 | ||
| 1314 | /** A REST read whose JSON answer is decoded the same way. */ | |
| 1315 | const restRead = <A>(input: { | |
| 1316 | readonly cwd: string; | |
| 1317 | readonly host: string; | |
| 1318 | readonly operation: string; | |
| 1319 | readonly path: string; | |
| 1320 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1321 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1322 | api | |
| 1323 | .rest({ host: input.host, operation: input.operation, path: input.path }) | |
| 1324 | .pipe( | |
| 1325 | Effect.flatMap((response) => | |
| 1326 | decodeWith(input.cwd, input.operation, input.decode, response.body), | |
| 1327 | ), | |
| 1328 | ); | |
| 1329 | ||
| 1330 | /** | |
| 1331 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed | |
| 1332 | * files, and still serves those files' hunks here. | |
| 1333 | * | |
| 1334 | * A page is a whole number of files, so each one parses on its own; the caller carries on from | |
| 1335 | * `nextCursor` for as long as GitHub keeps handing pages back. | |
| 1336 | * | |
| 1337 | * A named commit is read from the commit endpoint, which lists the same file entries and pages | |
| 1338 | * them the same way, only wrapped in an object. | |
| 1339 | */ | |
| 1340 | const diffFilesPage = (input: { | |
| 1341 | readonly cwd: string; | |
| 1342 | readonly repository: string; | |
| 1343 | readonly host: string; | |
| 1344 | readonly number: number; | |
| 1345 | readonly page: number; | |
| 1346 | readonly commit?: string | undefined; | |
| 1347 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError> => { | |
| 1348 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1349 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; | |
| 1350 | return api | |
| 1351 | .rest({ | |
| 1352 | host: input.host, | |
| 1353 | operation: "getPullRequestDiff", | |
| 1354 | path: | |
| 1355 | input.commit === undefined | |
| 1356 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` | |
| 1357 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, | |
| 1358 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1359 | timeout: DIFF_TIMEOUT, | |
| 1360 | }) | |
| 1361 | .pipe( | |
| 1362 | Effect.flatMap((response) => { | |
| 1363 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would | |
| 1364 | // fail to parse. Nothing of this page can be shown, and an empty patch would render | |
| 1365 | // as a change with no files rather than as the failure it is; slices already handed | |
| 1366 | // over stay with the reader either way. | |
| 1367 | if (response.truncated) { | |
| 1368 | return Effect.fail( | |
| 1369 | readError( | |
| 1370 | input.cwd, | |
| 1371 | "getPullRequestDiff", | |
| 1372 | new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 1373 | ), | |
| 1374 | ); | |
| 1375 | } | |
| 1376 | return decodeWith( | |
| 1377 | input.cwd, | |
| 1378 | "getPullRequestDiff", | |
| 1379 | input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, | |
| 1380 | response.body, | |
| 1381 | ); | |
| 1382 | }), | |
| 1383 | Effect.map((files) => { | |
| 1384 | // Counted before decoding, so a page whose files all failed to decode still moves on | |
| 1385 | // rather than pointing the reader back at the page it just read. | |
| 1386 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1387 | return { | |
| 1388 | patch: files.patch, | |
| 1389 | truncated: files.truncated, | |
| 1390 | nextCursor: morePages ? String(input.page + 1) : null, | |
| 1391 | ...(files.omittedFileStats.length === 0 | |
| 1392 | ? {} | |
| 1393 | : { omittedFileStats: files.omittedFileStats }), | |
| 1394 | }; | |
| 1395 | }), | |
| 1396 | ); | |
| 1397 | }; | |
| 1398 | ||
| 1399 | const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = | |
| 1400 | (input) => | |
| 1401 | Effect.gen(function* () { | |
| 1402 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 1403 | return yield* new GitHubDiffCommitError({ cwd: input.cwd }); | |
| 1404 | } | |
| 1405 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1406 | const refsResponse = yield* api.rest({ | |
| 1407 | host: input.host, | |
| 1408 | operation: "getPullRequestDiffFileContents", | |
| 1409 | path: | |
| 1410 | input.commit === undefined | |
| 1411 | ? `repos/${owner}/${name}/pulls/${input.number}` | |
| 1412 | : `repos/${owner}/${name}/commits/${input.commit}`, | |
| 1413 | timeout: DIFF_TIMEOUT, | |
| 1414 | }); | |
| 1415 | const refs = decodeRevisionRefs(refsResponse.body); | |
| 1416 | // A root commit has no parent, which is an absent old revision. Every file in it is new, | |
| 1417 | // so that is a usable answer whenever the caller does not need the old side. | |
| 1418 | const baseRef = Option.isSome(refs) | |
| 1419 | ? input.commit === undefined | |
| 1420 | ? refs.value.base?.sha | |
| 1421 | : (refs.value.parents?.[0]?.sha ?? "") | |
| 1422 | : undefined; | |
| 1423 | const headRef = Option.isSome(refs) | |
| 1424 | ? input.commit === undefined | |
| 1425 | ? refs.value.head?.sha | |
| 1426 | : refs.value.sha | |
| 1427 | : undefined; | |
| 1428 | const rootCommitNewFile = | |
| 1429 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; | |
| 1430 | if ( | |
| 1431 | headRef === undefined || | |
| 1432 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || | |
| 1433 | !isCommitSha(headRef) | |
| 1434 | ) { | |
| 1435 | return yield* new GitHubDiffRevisionsUnavailableError({ | |
| 1436 | cwd: input.cwd, | |
| 1437 | number: input.number, | |
| 1438 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 1439 | }); | |
| 1440 | } | |
| 1441 | ||
| 1442 | const readFile = (revision: string, filePath: string) => | |
| 1443 | api | |
| 1444 | .rest({ | |
| 1445 | host: input.host, | |
| 1446 | operation: "getPullRequestDiffFileContents", | |
| 1447 | accept: "application/vnd.github.raw+json", | |
| 1448 | path: `repos/${owner}/${name}/contents/${filePath | |
| 1449 | .split("/") | |
| 1450 | .map(encodeURIComponent) | |
| 1451 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1452 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1453 | timeout: DIFF_TIMEOUT, | |
| 1454 | }) | |
| 1455 | .pipe( | |
| 1456 | Effect.flatMap((response) => | |
| 1457 | response.truncated || response.body.includes("\0") || response.invalidUtf8 | |
| 1458 | ? Effect.fail( | |
| 1459 | new GitHubDiffFileContentsUnavailableError({ | |
| 1460 | cwd: input.cwd, | |
| 1461 | path: filePath, | |
| 1462 | reason: response.truncated ? "oversized" : "binary", | |
| 1463 | }), | |
| 1464 | ) | |
| 1465 | : Effect.succeed(response.body), | |
| 1466 | ), | |
| 1467 | ); | |
| 1468 | ||
| 1469 | const [oldContents, newContents] = yield* Effect.all( | |
| 1470 | [ | |
| 1471 | input.changeType === "new" | |
| 1472 | ? Effect.succeed("") | |
| 1473 | : readFile(baseRef ?? "", input.oldPath), | |
| 1474 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), | |
| 1475 | ], | |
| 1476 | { concurrency: 2 }, | |
| 1477 | ); | |
| 1478 | return { oldContents, newContents }; | |
| 1479 | }); | |
| 1480 | ||
| 1481 | /** | |
| 1482 | * Every check context of the head commit, a page at a time, for a rollup the detail read cut at | |
| 1483 | * its first hundred. Each page names the head it read, so a push mid-walk fails rather than | |
| 1484 | * mixing two revisions' checks. | |
| 1485 | */ | |
| 1486 | type CheckContextsPage = Result.Result.Success< | |
| 1487 | ReturnType<typeof decodePullRequestCheckContextsJson> | |
| 1488 | >; | |
| 1489 | const readAllCheckContexts = ( | |
| 1490 | input: Parameters<GitHubPullRequestApi["Service"]["getPullRequestDetail"]>[0], | |
| 1491 | allowReserve: boolean, | |
| 1492 | ) => | |
| 1493 | Effect.gen(function* () { | |
| 1494 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1495 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1496 | (after, previous: ReadonlyArray<CheckContextsPage>) => | |
| 1497 | graphqlRead({ | |
| 1498 | cwd: input.cwd, | |
| 1499 | host: input.host, | |
| 1500 | operation: "getPullRequestDetail", | |
| 1501 | allowReserve, | |
| 1502 | variables: { owner, name, number: input.number, after }, | |
| 1503 | query: pullRequestCheckContextsGraphQlQuery(input.host), | |
| 1504 | decode: decodePullRequestCheckContextsJson, | |
| 1505 | }).pipe( | |
| 1506 | // Each page names the head it read, so a push mid-walk fails rather than mixing | |
| 1507 | // two revisions' checks. | |
| 1508 | Effect.filterOrFail( | |
| 1509 | (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, | |
| 1510 | () => | |
| 1511 | readError( | |
| 1512 | input.cwd, | |
| 1513 | "getPullRequestDetail", | |
| 1514 | new Error("Pull request head changed while reading checks."), | |
| 1515 | ), | |
| 1516 | ), | |
| 1517 | ), | |
| 1518 | { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, | |
| 1519 | ); | |
| 1520 | return { | |
| 1521 | headSha: pages[0]?.headSha ?? null, | |
| 1522 | contexts: pages.flatMap((page) => page.contexts), | |
| 1523 | truncated, | |
| 1524 | }; | |
| 1525 | }); | |
| 1526 | ||
| 1527 | const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { | |
| 1528 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1529 | return GitHubApi.AllowGitHubReserve.pipe( | |
| 1530 | Effect.flatMap((allowReserve) => | |
| 1531 | graphqlRead({ | |
| 1532 | allowReserve, | |
| 1533 | cwd: input.cwd, | |
| 1534 | host: input.host, | |
| 1535 | operation: "getPullRequestDetail", | |
| 1536 | variables: { | |
| 1537 | owner, | |
| 1538 | name, | |
| 1539 | number: input.number, | |
| 1540 | headRef: `refs/pull/${input.number}/head`, | |
| 1541 | }, | |
| 1542 | query: pullRequestCoreGraphQlQuery(input.host), | |
| 1543 | decode: decodePullRequestCoreJson, | |
| 1544 | }).pipe( | |
| 1545 | // Past a hundred checks the first page would let the rest imply success, so the whole | |
| 1546 | // rollup is walked instead. | |
| 1547 | Effect.filterOrElse( | |
| 1548 | (core) => !core.checksTruncated, | |
| 1549 | (core) => | |
| 1550 | readAllCheckContexts(input, allowReserve).pipe( | |
| 1551 | Effect.filterOrFail( | |
| 1552 | (all) => all.headSha === core.headSha, | |
| 1553 | () => | |
| 1554 | readError( | |
| 1555 | input.cwd, | |
| 1556 | "getPullRequestDetail", | |
| 1557 | new Error("Pull request head changed while reading checks."), | |
| 1558 | ), | |
| 1559 | ), | |
| 1560 | Effect.map((all) => ({ | |
| 1561 | ...core, | |
| 1562 | ...pullRequestChecksFromContexts(all.contexts), | |
| 1563 | checksTruncated: all.truncated, | |
| 1564 | })), | |
| 1565 | ), | |
| 1566 | ), | |
| 1567 | ), | |
| 1568 | ), | |
| 1569 | ); | |
| 1570 | }; | |
| 1571 | ||
| 1572 | const workflowApprovalLimit = 1_000; | |
| 1573 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | |
| 1574 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); | |
| 1575 | ||
| 1576 | /** | |
| 1577 | * Every open pull request whose head branch carries this name, up to one past the limit. | |
| 1578 | * `truncated` says some were never read, which is never enough to call a head unique. | |
| 1579 | */ | |
| 1580 | const listHeadsByBranch = (input: { | |
| 1581 | readonly cwd: string; | |
| 1582 | readonly repository: string; | |
| 1583 | readonly host: string; | |
| 1584 | readonly headBranch: string; | |
| 1585 | }) => | |
| 1586 | Effect.gen(function* () { | |
| 1587 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1588 | const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray<unknown> }>) => | |
| 1589 | pages.reduce((total, page) => total + page.heads.length, 0); | |
| 1590 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1591 | (after) => | |
| 1592 | graphqlRead({ | |
| 1593 | cwd: input.cwd, | |
| 1594 | host: input.host, | |
| 1595 | operation: "listWorkflowRunsRequiringApproval", | |
| 1596 | allowReserve: true, | |
| 1597 | variables: { owner, name, head: input.headBranch, after }, | |
| 1598 | query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 1599 | decode: decodePullRequestHeadsJson, | |
| 1600 | }), | |
| 1601 | { | |
| 1602 | nextCursor: (page) => page.nextCursor, | |
| 1603 | until: (pages) => countHeads(pages) > workflowApprovalLimit, | |
| 1604 | }, | |
| 1605 | ); | |
| 1606 | return { heads: pages.flatMap((page) => page.heads), truncated }; | |
| 1607 | }); | |
| 1608 | ||
| 1609 | /** | |
| 1610 | * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is | |
| 1611 | * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. | |
| 1612 | */ | |
| 1613 | const listActionRequiredRuns = (input: { | |
| 1614 | readonly cwd: string; | |
| 1615 | readonly repository: string; | |
| 1616 | readonly host: string; | |
| 1617 | readonly headSha: string; | |
| 1618 | readonly headBranch: string; | |
| 1619 | }) => | |
| 1620 | Effect.gen(function* () { | |
| 1621 | // The checks revalidator may have just confirmed every run of this head as current. | |
| 1622 | const known = yield* KnownWorkflowRuns; | |
| 1623 | if (known !== null && known.headSha === input.headSha) { | |
| 1624 | return known.runs.flatMap((run) => | |
| 1625 | // A run waiting on a maintainer reports `completed` with an `action_required` | |
| 1626 | // conclusion; the `status=action_required` query matches either. | |
| 1627 | (run.conclusion === "action_required" || run.status === "action_required") && | |
| 1628 | run.head_branch === input.headBranch | |
| 1629 | ? [ | |
| 1630 | { | |
| 1631 | id: run.id, | |
| 1632 | name: run.name?.trim() || `Workflow run ${run.id}`, | |
| 1633 | url: run.html_url?.trim() || null, | |
| 1634 | }, | |
| 1635 | ] | |
| 1636 | : [], | |
| 1637 | ); | |
| 1638 | } | |
| 1639 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1640 | const runs: GitHubWorkflowRunApproval[] = []; | |
| 1641 | for (let page = 1; runs.length <= workflowApprovalLimit; page++) { | |
| 1642 | const read: GitHubWorkflowRunPage = yield* restRead({ | |
| 1643 | cwd: input.cwd, | |
| 1644 | host: input.host, | |
| 1645 | operation: "listWorkflowRunsRequiringApproval", | |
| 1646 | 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}`, | |
| 1647 | decode: decodeWorkflowRunsJson, | |
| 1648 | }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); | |
| 1649 | runs.push(...read.runs); | |
| 1650 | if (read.rawCount < 100) break; | |
| 1651 | } | |
| 1652 | return runs; | |
| 1653 | }); | |
| 1654 | ||
| 1655 | const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = | |
| 1656 | (input) => | |
| 1657 | Effect.all( | |
| 1658 | [ | |
| 1659 | listHeadsByBranch(input).pipe( | |
| 1660 | Effect.flatMap( | |
| 1661 | ({ | |
| 1662 | heads, | |
| 1663 | truncated, | |
| 1664 | }): Effect.Effect< | |
| 1665 | GitHubPullRequestHead, | |
| 1666 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1667 | > => { | |
| 1668 | const exactHeads = heads.filter( | |
| 1669 | (pullRequest) => | |
| 1670 | pullRequest.headSha === input.headSha && | |
| 1671 | pullRequest.isCrossRepository === true && | |
| 1672 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1673 | input.headRepositoryOwner.toLowerCase(), | |
| 1674 | ); | |
| 1675 | if (truncated || heads.length > workflowApprovalLimit) { | |
| 1676 | return Effect.fail( | |
| 1677 | new GitHubWorkflowApprovalRefusedError({ | |
| 1678 | cwd: input.cwd, | |
| 1679 | number: input.number, | |
| 1680 | reason: "head-list-truncated", | |
| 1681 | observedCount: heads.length, | |
| 1682 | limit: workflowApprovalLimit, | |
| 1683 | }), | |
| 1684 | ); | |
| 1685 | } | |
| 1686 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1687 | return Effect.fail( | |
| 1688 | new GitHubWorkflowApprovalRefusedError({ | |
| 1689 | cwd: input.cwd, | |
| 1690 | number: input.number, | |
| 1691 | reason: "head-not-unique", | |
| 1692 | observedCount: exactHeads.length, | |
| 1693 | limit: workflowApprovalLimit, | |
| 1694 | }), | |
| 1695 | ); | |
| 1696 | } | |
| 1697 | return Effect.succeed(exactHeads[0]); | |
| 1698 | }, | |
| 1699 | ), | |
| 1700 | ), | |
| 1701 | listActionRequiredRuns(input).pipe( | |
| 1702 | Effect.flatMap((runs) => | |
| 1703 | runs.length > workflowApprovalLimit | |
| 1704 | ? Effect.fail( | |
| 1705 | new GitHubWorkflowApprovalRefusedError({ | |
| 1706 | cwd: input.cwd, | |
| 1707 | number: input.number, | |
| 1708 | reason: "run-list-truncated", | |
| 1709 | observedCount: runs.length, | |
| 1710 | limit: workflowApprovalLimit, | |
| 1711 | }), | |
| 1712 | ) | |
| 1713 | : Effect.succeed(runs), | |
| 1714 | ), | |
| 1715 | ), | |
| 1716 | ], | |
| 1717 | { concurrency: 2 }, | |
| 1718 | ).pipe(Effect.map(([, runs]) => runs)); | |
| 1719 | ||
| 1720 | /** One pull request's summary, through the same aliased query the batch uses. */ | |
| 1721 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { | |
| 1722 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1723 | return graphqlRead({ | |
| 1724 | cwd: input.cwd, | |
| 1725 | host: input.host, | |
| 1726 | operation: "getPullRequestSummary", | |
| 1727 | variables: { owner, name, number: input.number }, | |
| 1728 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), | |
| 1729 | decode: decodePullRequestSummariesJson, | |
| 1730 | }).pipe( | |
| 1731 | Effect.flatMap((summaries) => { | |
| 1732 | const summary = summaries.get(0); | |
| 1733 | return summary === undefined | |
| 1734 | ? Effect.fail( | |
| 1735 | readError( | |
| 1736 | input.cwd, | |
| 1737 | "getPullRequestSummary", | |
| 1738 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), | |
| 1739 | ), | |
| 1740 | ) | |
| 1741 | : Effect.succeed(summary); | |
| 1742 | }), | |
| 1743 | ); | |
| 1744 | }; | |
| 1745 | ||
| 1746 | /** | |
| 1747 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads | |
| 1748 | * of twenty-five: the background sync reads every linked pull request each minute, and one | |
| 1749 | * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector | |
| 1750 | * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. | |
| 1751 | */ | |
| 1752 | const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({ | |
| 1753 | key: ({ request, context }) => | |
| 1754 | JSON.stringify([ | |
| 1755 | request.host.toLowerCase(), | |
| 1756 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1757 | ?.credentialFingerprint ?? null, | |
| 1758 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1759 | ]), | |
| 1760 | resolver: (entries) => { | |
| 1761 | const [first] = entries; | |
| 1762 | const batchable = entries.filter( | |
| 1763 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, | |
| 1764 | ); | |
| 1765 | // Stack membership rides along where GitHub serves stacks, so the background sync can skip | |
| 1766 | // the REST stack read for pull requests that are in none. | |
| 1767 | const document = buildPullRequestSummariesGraphQlQuery( | |
| 1768 | batchable.map((entry) => entry.request), | |
| 1769 | first.request.host === "github.com", | |
| 1770 | ); | |
| 1771 | const batched = | |
| 1772 | document === null | |
| 1773 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) | |
| 1774 | : graphqlRead({ | |
| 1775 | cwd: first.request.cwd, | |
| 1776 | host: first.request.host, | |
| 1777 | operation: "getPullRequestSummary", | |
| 1778 | ...document, | |
| 1779 | decode: decodePullRequestSummariesJson, | |
| 1780 | }); | |
| 1781 | return batched.pipe( | |
| 1782 | // A GraphQL error anywhere fails the whole document — one repository gone or out of | |
| 1783 | // reach — so a batch that could not be read leaves every entry to its own read. A paused | |
| 1784 | // budget is the exception: reading one at a time would only spend what is being saved. | |
| 1785 | Effect.catchCauseIf( | |
| 1786 | (cause) => | |
| 1787 | !Cause.hasInterruptsOnly(cause) && | |
| 1788 | !Cause.findErrorOption(cause).pipe( | |
| 1789 | Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), | |
| 1790 | ), | |
| 1791 | (cause) => | |
| 1792 | Effect.logDebug("batched pull request summary read failed", { cause }).pipe( | |
| 1793 | Effect.as(new Map<number, GitHubPullRequestSummary>()), | |
| 1794 | ), | |
| 1795 | ), | |
| 1796 | Effect.flatMap((summaries) => { | |
| 1797 | const unanswered = entries.filter((entry) => { | |
| 1798 | const summary = summaries.get(batchable.indexOf(entry)); | |
| 1799 | if (summary === undefined) return true; | |
| 1800 | entry.completeUnsafe(Exit.succeed(summary)); | |
| 1801 | return false; | |
| 1802 | }); | |
| 1803 | return Effect.forEach( | |
| 1804 | unanswered, | |
| 1805 | (entry) => | |
| 1806 | viewPullRequestSummary(entry.request).pipe( | |
| 1807 | Effect.exit, | |
| 1808 | Effect.map((exit) => entry.completeUnsafe(exit)), | |
| 1809 | ), | |
| 1810 | { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, | |
| 1811 | ); | |
| 1812 | }), | |
| 1813 | Effect.catchCause((cause) => | |
| 1814 | Effect.sync(() => { | |
| 1815 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1816 | }), | |
| 1817 | ), | |
| 1818 | ); | |
| 1819 | }, | |
| 1820 | }).pipe( | |
| 1821 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1822 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1823 | ); | |
| 1824 | const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => | |
| 1825 | Effect.request(new PullRequestSummaryRead(input), summaryResolver); | |
| 1826 | ||
| 1827 | // Every watched pull request on a host in one read per pass. A pull request the batch has no | |
| 1828 | // answer for gets null, and its watch reads it in full; a failed batch fails every entry. | |
| 1829 | const watchFingerprintResolver = RequestResolver.makeGrouped< | |
| 1830 | PullRequestWatchFingerprintRead, | |
| 1831 | string | |
| 1832 | >({ | |
| 1833 | key: ({ request, context }) => | |
| 1834 | JSON.stringify([ | |
| 1835 | request.host.toLowerCase(), | |
| 1836 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1837 | ?.credentialFingerprint ?? null, | |
| 1838 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1839 | ]), | |
| 1840 | resolver: (entries) => { | |
| 1841 | const [first] = entries; | |
| 1842 | const batchable = entries.filter( | |
| 1843 | (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, | |
| 1844 | ); | |
| 1845 | const document = buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 1846 | batchable.map((entry) => entry.request), | |
| 1847 | ); | |
| 1848 | const read = | |
| 1849 | document === null | |
| 1850 | ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>()) | |
| 1851 | : graphqlRead({ | |
| 1852 | cwd: first.request.cwd, | |
| 1853 | host: first.request.host, | |
| 1854 | operation: "getPullRequestWatchFingerprint", | |
| 1855 | ...document, | |
| 1856 | decode: decodePullRequestWatchFingerprintsJson, | |
| 1857 | }); | |
| 1858 | return read.pipe( | |
| 1859 | Effect.map((fingerprints) => { | |
| 1860 | for (const entry of entries) { | |
| 1861 | const index = batchable.indexOf(entry); | |
| 1862 | entry.completeUnsafe( | |
| 1863 | Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), | |
| 1864 | ); | |
| 1865 | } | |
| 1866 | }), | |
| 1867 | Effect.catchCause((cause) => | |
| 1868 | Effect.sync(() => { | |
| 1869 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1870 | }), | |
| 1871 | ), | |
| 1872 | ); | |
| 1873 | }, | |
| 1874 | }).pipe( | |
| 1875 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1876 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1877 | ); | |
| 1878 | const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = | |
| 1879 | (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); | |
| 1880 | ||
| 1881 | return GitHubPullRequestApi.of({ | |
| 1882 | withVerifiedCredential, | |
| 1883 | revalidateChecks, | |
| 1884 | getRoutingIdentity, | |
| 1885 | getViewerLogin: (input) => | |
| 1886 | getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), | |
| 1887 | ||
| 1888 | listPullRequests: (input) => { | |
| 1889 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); | |
| 1890 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1891 | const query = searchQuery({ ...input, repositories: [input.repository] }); | |
| 1892 | if (query === null) { | |
| 1893 | return Effect.fail( | |
| 1894 | new GitHubRepositorySelectorError({ | |
| 1895 | cwd: input.cwd, | |
| 1896 | operation: "listPullRequests", | |
| 1897 | }), | |
| 1898 | ); | |
| 1899 | } | |
| 1900 | /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ | |
| 1901 | const collect = ( | |
| 1902 | rows: number, | |
| 1903 | page: ( | |
| 1904 | after: string | null, | |
| 1905 | rows: number, | |
| 1906 | ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestApiError>, | |
| 1907 | ) => | |
| 1908 | Effect.gen(function* () { | |
| 1909 | const countRows = (pages: ReadonlyArray<GitHubPullRequestListPage>) => | |
| 1910 | pages.reduce((total, read) => total + read.rawCount, 0); | |
| 1911 | const { pages, truncated } = yield* readGraphQlPages( | |
| 1912 | (after, pages: ReadonlyArray<GitHubPullRequestListPage>) => | |
| 1913 | page(after, rows - countRows(pages)), | |
| 1914 | { | |
| 1915 | nextCursor: (read) => read.endCursor, | |
| 1916 | until: (pages) => countRows(pages) >= rows, | |
| 1917 | }, | |
| 1918 | ); | |
| 1919 | // `truncated` also covers a cursor GitHub repeated before `rows` arrived. | |
| 1920 | return { | |
| 1921 | items: pages.flatMap((read) => read.items), | |
| 1922 | rawCount: countRows(pages), | |
| 1923 | truncated, | |
| 1924 | }; | |
| 1925 | }); | |
| 1926 | const read = ( | |
| 1927 | continues: boolean, | |
| 1928 | requestedRows = input.limit + 1, | |
| 1929 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError> => | |
| 1930 | collect(requestedRows, (after, rows) => | |
| 1931 | continues | |
| 1932 | ? graphqlRead({ | |
| 1933 | cwd: input.cwd, | |
| 1934 | host: input.host, | |
| 1935 | operation: "listPullRequests", | |
| 1936 | // The reader's own words are in the query. | |
| 1937 | variables: { q: query, after }, | |
| 1938 | query: pullRequestSearchGraphQlQuery(rows, false, true), | |
| 1939 | decode: decodePullRequestSearchJson, | |
| 1940 | }) | |
| 1941 | : graphqlRead({ | |
| 1942 | cwd: input.cwd, | |
| 1943 | host: input.host, | |
| 1944 | operation: "listPullRequests", | |
| 1945 | variables: { owner, name, states: LIST_STATES[input.state], after }, | |
| 1946 | query: pullRequestListGraphQlQuery(rows), | |
| 1947 | decode: decodePullRequestListJson, | |
| 1948 | }), | |
| 1949 | ).pipe( | |
| 1950 | Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { | |
| 1951 | const items = continues | |
| 1952 | ? rawItems | |
| 1953 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); | |
| 1954 | if ( | |
| 1955 | !continues && | |
| 1956 | items.length < input.limit && | |
| 1957 | rawCount >= requestedRows && | |
| 1958 | requestedRows < fallbackMaxRows | |
| 1959 | ) { | |
| 1960 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 1961 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 1962 | } | |
| 1963 | return Effect.succeed({ | |
| 1964 | items: items.slice(0, input.limit), | |
| 1965 | // One row over the page size is the probe for a next page, and it is | |
| 1966 | // counted before decoding: a skipped malformed row must not end paging. | |
| 1967 | truncated: | |
| 1968 | cutShort || | |
| 1969 | (continues | |
| 1970 | ? rawCount > input.limit | |
| 1971 | : items.length > input.limit || rawCount >= requestedRows), | |
| 1972 | continues, | |
| 1973 | }); | |
| 1974 | }), | |
| 1975 | ); | |
| 1976 | // GitHub does not index every repository for search, and one it will not search answers | |
| 1977 | // with no rows rather than with an error — so an empty listing is read again from the | |
| 1978 | // repository's own list. Those rows come back newest-created first, an order no `updated:` | |
| 1979 | // qualifier can carry on from, so that page says it cannot be continued and the reader | |
| 1980 | // reaches the rest of it by asking for a larger page, as every listing used to. | |
| 1981 | // | |
| 1982 | // Only ever the first slice: a repository that answered the search once will answer it | |
| 1983 | // again, so an empty slice under a cursor is a repository that has run out. | |
| 1984 | // A text search that finds nothing has found nothing: falling back would answer it with the | |
| 1985 | // repository's whole list, which is every row the reader did not search for. The fallback | |
| 1986 | // is for a repository the index does not cover, and a listing with no text to match is the | |
| 1987 | // only place an empty answer can mean that. | |
| 1988 | // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just | |
| 1989 | // as well as search judges them over its own, so carrying them into the fallback answers | |
| 1990 | // the same read rather than a wider one. Free text is the one thing the fallback cannot | |
| 1991 | // judge locally — it lists rows, it does not search their text — so a query still rules | |
| 1992 | // the fallback out: an empty answer under one is already the answer. | |
| 1993 | const hasQuery = (input.query?.trim().length ?? 0) > 0; | |
| 1994 | return read(true).pipe( | |
| 1995 | Effect.filterOrElse( | |
| 1996 | (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, | |
| 1997 | () => read(false), | |
| 1998 | ), | |
| 1999 | Effect.flatMap((batch) => { | |
| 2000 | // Match the search query's host support, and enrich only rows that survived paging. | |
| 2001 | if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); | |
| 2002 | const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = []; | |
| 2003 | for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2004 | chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2005 | } | |
| 2006 | return Effect.forEach( | |
| 2007 | chunks, | |
| 2008 | (chunk) => { | |
| 2009 | const document = buildPullRequestStackMembershipsGraphQlQuery( | |
| 2010 | input.repository, | |
| 2011 | chunk.map((item) => item.number), | |
| 2012 | ); | |
| 2013 | if (document === null) return Effect.succeed(chunk); | |
| 2014 | return graphqlRead({ | |
| 2015 | cwd: input.cwd, | |
| 2016 | host: input.host, | |
| 2017 | operation: "listPullRequestStackMemberships", | |
| 2018 | ...document, | |
| 2019 | decode: decodePullRequestStackMembershipsJson, | |
| 2020 | }).pipe( | |
| 2021 | Effect.map((memberships) => | |
| 2022 | chunk.map((item, index) => { | |
| 2023 | const stack = memberships.get(index); | |
| 2024 | return stack === undefined ? item : { ...item, stack }; | |
| 2025 | }), | |
| 2026 | ), | |
| 2027 | // Optional badges must not take down a listing that already read successfully. | |
| 2028 | Effect.catch(() => | |
| 2029 | Effect.logWarning("Pull request stack membership enrichment failed", { | |
| 2030 | operation: "listPullRequestStackMemberships", | |
| 2031 | host: input.host, | |
| 2032 | rows: chunk.length, | |
| 2033 | }).pipe(Effect.as(chunk)), | |
| 2034 | ), | |
| 2035 | ); | |
| 2036 | }, | |
| 2037 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2038 | ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); | |
| 2039 | }), | |
| 2040 | ); | |
| 2041 | }, | |
| 2042 | ||
| 2043 | searchPullRequests: (input) => { | |
| 2044 | const query = searchQuery(input); | |
| 2045 | if (query === null) { | |
| 2046 | return Effect.fail( | |
| 2047 | new GitHubRepositorySelectorError({ | |
| 2048 | cwd: input.cwd, | |
| 2049 | operation: "searchPullRequests", | |
| 2050 | }), | |
| 2051 | ); | |
| 2052 | } | |
| 2053 | // One extra row reveals that the host has more than the slice shows, the way the | |
| 2054 | // per-repository read does — up to GitHub's own ceiling on a search page, past which | |
| 2055 | // `hasNextPage` is what says there is more. | |
| 2056 | const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 2057 | return graphqlRead({ | |
| 2058 | cwd: input.cwd, | |
| 2059 | host: input.host, | |
| 2060 | operation: "searchPullRequests", | |
| 2061 | variables: { q: query, after: null }, | |
| 2062 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | |
| 2063 | decode: decodePullRequestSearchJson, | |
| 2064 | }).pipe( | |
| 2065 | Effect.map((batch) => ({ | |
| 2066 | items: batch.items.slice(0, input.limit), | |
| 2067 | truncated: batch.rawCount > input.limit || batch.hasNextPage, | |
| 2068 | })), | |
| 2069 | ); | |
| 2070 | }, | |
| 2071 | ||
| 2072 | listPullRequestStats: (input) => { | |
| 2073 | const chunks: Array<ReadonlyArray<{ readonly repository: string; readonly number: number }>> = | |
| 2074 | []; | |
| 2075 | for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2076 | chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2077 | } | |
| 2078 | return Effect.forEach( | |
| 2079 | chunks, | |
| 2080 | (chunk) => { | |
| 2081 | const document = buildPullRequestStatsGraphQlQuery(chunk); | |
| 2082 | if (document === null) { | |
| 2083 | return Effect.fail( | |
| 2084 | new GitHubRepositorySelectorError({ | |
| 2085 | cwd: input.cwd, | |
| 2086 | operation: "listPullRequestStats", | |
| 2087 | }), | |
| 2088 | ); | |
| 2089 | } | |
| 2090 | return graphqlRead({ | |
| 2091 | cwd: input.cwd, | |
| 2092 | host: input.host, | |
| 2093 | operation: "listPullRequestStats", | |
| 2094 | ...document, | |
| 2095 | decode: decodePullRequestStatsJson, | |
| 2096 | }).pipe( | |
| 2097 | Effect.map((stats) => | |
| 2098 | chunk.flatMap((changeRequest, index) => { | |
| 2099 | const stat = stats.get(index); | |
| 2100 | return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; | |
| 2101 | }), | |
| 2102 | ), | |
| 2103 | ); | |
| 2104 | }, | |
| 2105 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2106 | ).pipe(Effect.map((results) => results.flat())); | |
| 2107 | }, | |
| 2108 | ||
| 2109 | getPullRequestSummary, | |
| 2110 | getPullRequestWatchFingerprint, | |
| 2111 | ||
| 2112 | getPullRequestDetail, | |
| 2113 | getPullRequestPreview: (input) => { | |
| 2114 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2115 | return graphqlRead({ | |
| 2116 | cwd: input.cwd, | |
| 2117 | host: input.host, | |
| 2118 | operation: "getPullRequestPreview", | |
| 2119 | variables: { owner, name, number: input.number }, | |
| 2120 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 2121 | decode: decodePullRequestPreviewJson, | |
| 2122 | }); | |
| 2123 | }, | |
| 2124 | listWorkflowRunsRequiringApproval, | |
| 2125 | ||
| 2126 | getPullRequestStack: (input) => { | |
| 2127 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2128 | return restRead({ | |
| 2129 | cwd: input.cwd, | |
| 2130 | host: input.host, | |
| 2131 | operation: "getPullRequestStack", | |
| 2132 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2133 | decode: decodePullRequestStacksJson, | |
| 2134 | }).pipe( | |
| 2135 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | |
| 2136 | Effect.flatMap((stack) => { | |
| 2137 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | |
| 2138 | return restRead({ | |
| 2139 | cwd: input.cwd, | |
| 2140 | host: input.host, | |
| 2141 | operation: "getPullRequestStack", | |
| 2142 | path: `repos/${owner}/${name}/stacks/${stack.number}`, | |
| 2143 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), | |
| 2144 | }); | |
| 2145 | }), | |
| 2146 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 2147 | // previously synced stack and let the caller retry. | |
| 2148 | Effect.catchTags({ | |
| 2149 | GitHubApiNotFoundError: () => Effect.succeed(null), | |
| 2150 | }), | |
| 2151 | ); | |
| 2152 | }, | |
| 2153 | ||
| 2154 | getPullRequestActivity: (input) => | |
| 2155 | Effect.gen(function* () { | |
| 2156 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2157 | let author: PullRequestActor | null = null; | |
| 2158 | let commits: ReadonlyArray<PullRequestCommit> = []; | |
| 2159 | const remarks: PullRequestComment[] = []; | |
| 2160 | let commentsAfter: string | null = null; | |
| 2161 | let reviewsAfter: string | null = null; | |
| 2162 | let withComments = true; | |
| 2163 | let withReviews = true; | |
| 2164 | // Both remark lists page on their own; a page asks only for the ones with more to give. | |
| 2165 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { | |
| 2166 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ | |
| 2167 | cwd: input.cwd, | |
| 2168 | host: input.host, | |
| 2169 | operation: "getPullRequestActivity", | |
| 2170 | variables: { | |
| 2171 | owner, | |
| 2172 | name, | |
| 2173 | number: input.number, | |
| 2174 | head: page === 0, | |
| 2175 | withComments, | |
| 2176 | commentsAfter, | |
| 2177 | withReviews, | |
| 2178 | reviewsAfter, | |
| 2179 | }, | |
| 2180 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 2181 | decode: decodePullRequestActivityJson, | |
| 2182 | }); | |
| 2183 | if (page === 0) { | |
| 2184 | author = read.author ?? null; | |
| 2185 | commits = read.commits ?? []; | |
| 2186 | } | |
| 2187 | remarks.push(...read.remarks); | |
| 2188 | commentsAfter = read.nextCommentsCursor; | |
| 2189 | reviewsAfter = read.nextReviewsCursor; | |
| 2190 | withComments = withComments && commentsAfter !== null; | |
| 2191 | withReviews = withReviews && reviewsAfter !== null; | |
| 2192 | } | |
| 2193 | return { | |
| 2194 | author, | |
| 2195 | comments: remarks.toSorted((left, right) => | |
| 2196 | left.createdAt.localeCompare(right.createdAt), | |
| 2197 | ), | |
| 2198 | commits, | |
| 2199 | } satisfies GitHubPullRequestActivity; | |
| 2200 | }), | |
| 2201 | ||
| 2202 | getPullRequestDiff: (input) => { | |
| 2203 | const filesPage = (page: number) => | |
| 2204 | diffFilesPage({ | |
| 2205 | cwd: input.cwd, | |
| 2206 | repository: input.repository, | |
| 2207 | host: input.host, | |
| 2208 | number: input.number, | |
| 2209 | page, | |
| 2210 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 2211 | }); | |
| 2212 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 2213 | return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); | |
| 2214 | } | |
| 2215 | // A cursor only ever comes from the files walk, so a reader carrying one is already past | |
| 2216 | // the point where `gh pr diff` had anything to say. | |
| 2217 | if (input.cursor !== undefined) { | |
| 2218 | const page = diffCursorPage(input.cursor); | |
| 2219 | return page === null | |
| 2220 | ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) | |
| 2221 | : filesPage(page); | |
| 2222 | } | |
| 2223 | // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. | |
| 2224 | if (input.commit !== undefined) { | |
| 2225 | return filesPage(1); | |
| 2226 | } | |
| 2227 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2228 | return api | |
| 2229 | .rest({ | |
| 2230 | host: input.host, | |
| 2231 | operation: "getPullRequestDiff", | |
| 2232 | path: `repos/${owner}/${name}/pulls/${input.number}`, | |
| 2233 | accept: "application/vnd.github.diff", | |
| 2234 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 2235 | timeout: DIFF_TIMEOUT, | |
| 2236 | }) | |
| 2237 | .pipe( | |
| 2238 | Effect.flatMap((response) => | |
| 2239 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor | |
| 2240 | // something the reader can carry on from. The files API can serve the same change a | |
| 2241 | // whole number of files at a time, so an oversized patch takes that road as well. | |
| 2242 | response.truncated | |
| 2243 | ? filesPage(1) | |
| 2244 | : // One read served the whole patch, so there is no next slice to ask for. | |
| 2245 | Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), | |
| 2246 | ), | |
| 2247 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read | |
| 2248 | // from the files API instead, a page per call. Only once the direct read has failed: a | |
| 2249 | // pull request GitHub will serve a diff for must not pay for a second request. A | |
| 2250 | // fallback that fails too reports the original refusal, which is the one that explains | |
| 2251 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or | |
| 2252 | // a rate limit fails the same way for every request. | |
| 2253 | Effect.catchTags({ | |
| 2254 | GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), | |
| 2255 | }), | |
| 2256 | ); | |
| 2257 | }, | |
| 2258 | ||
| 2259 | getPullRequestDiffFileContents, | |
| 2260 | ||
| 2261 | getReviewThreadComments: (input) => { | |
| 2262 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2263 | return graphqlRead({ | |
| 2264 | cwd: input.cwd, | |
| 2265 | host: input.host, | |
| 2266 | operation: "getReviewThreadComments", | |
| 2267 | variables: { | |
| 2268 | owner, | |
| 2269 | name, | |
| 2270 | number: input.number, | |
| 2271 | threadId: input.threadId, | |
| 2272 | cursor: input.cursor, | |
| 2273 | }, | |
| 2274 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 2275 | decode: decodeReviewThreadCommentsJson, | |
| 2276 | }).pipe( | |
| 2277 | Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => | |
| 2278 | belongsToPullRequest | |
| 2279 | ? Effect.succeed({ comments, nextCursor }) | |
| 2280 | : Effect.fail( | |
| 2281 | new GitHubSubjectScopeError({ | |
| 2282 | cwd: input.cwd, | |
| 2283 | operation: "getReviewThreadComments", | |
| 2284 | }), | |
| 2285 | ), | |
| 2286 | ), | |
| 2287 | ); | |
| 2288 | }, | |
| 2289 | ||
| 2290 | listReviewThreadComments: (input) => | |
| 2291 | Effect.gen(function* () { | |
| 2292 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2293 | const threadPage = ( | |
| 2294 | cursor: string | null, | |
| 2295 | ): Effect.Effect<GitHubReviewThreadPage, GitHubPullRequestApiError> => | |
| 2296 | graphqlRead({ | |
| 2297 | cwd: input.cwd, | |
| 2298 | host: input.host, | |
| 2299 | operation: "listReviewThreadComments", | |
| 2300 | variables: { owner, name, number: input.number, cursor }, | |
| 2301 | query: REVIEW_THREADS_GRAPHQL_QUERY, | |
| 2302 | decode: decodeReviewThreadsJson, | |
| 2303 | }); | |
| 2304 | const entries: GitHubReviewThreadEntry[] = []; | |
| 2305 | const avatarsByLogin = new Map<string, string>(); | |
| 2306 | const botLogins = new Set<string>(); | |
| 2307 | const commitStats = new Map< | |
| 2308 | string, | |
| 2309 | { readonly additions: number; readonly deletions: number } | |
| 2310 | >(); | |
| 2311 | let reviewers: ReadonlyArray<PullRequestActor> = []; | |
| 2312 | let reactions: GitHubReviewThreadPage["reactions"] = []; | |
| 2313 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2314 | const editedAtById = new Map<string, string>(); | |
| 2315 | let commits: GitHubReviewThreadPage["commits"] = []; | |
| 2316 | let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; | |
| 2317 | const dismissalsByReviewId = new Map<string, string>(); | |
| 2318 | let dismissalCursor: string | null = null; | |
| 2319 | let cursor: string | null = null; | |
| 2320 | let page = 0; | |
| 2321 | do { | |
| 2322 | const read: GitHubReviewThreadPage = yield* threadPage(cursor); | |
| 2323 | entries.push(...read.threads); | |
| 2324 | for (const login of read.botLogins) botLogins.add(login); | |
| 2325 | for (const [login, avatarUrl] of read.avatarsByLogin) | |
| 2326 | avatarsByLogin.set(login, avatarUrl); | |
| 2327 | // The roster, the commits and the viewer's standing travel with every page, and the | |
| 2328 | // first one already carries all of them. | |
| 2329 | if (page === 0) { | |
| 2330 | reviewers = read.reviewers; | |
| 2331 | reactions = read.reactions; | |
| 2332 | for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); | |
| 2333 | for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); | |
| 2334 | commits = read.commits; | |
| 2335 | viewer = read.viewer; | |
| 2336 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2337 | dismissalsByReviewId.set(id, message); | |
| 2338 | dismissalCursor = read.nextDismissalCursor; | |
| 2339 | for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); | |
| 2340 | } | |
| 2341 | cursor = read.nextCursor; | |
| 2342 | page += 1; | |
| 2343 | } while (cursor !== null && page < REVIEW_THREAD_PAGES); | |
| 2344 | ||
| 2345 | // Almost never entered: the embedded page already holds every dismissal a pull request | |
| 2346 | // ordinarily accrues. Followed so a review whose event fell past that page still finds | |
| 2347 | // its reason. | |
| 2348 | if (dismissalCursor !== null) { | |
| 2349 | const { pages } = yield* readGraphQlPages( | |
| 2350 | (cursor) => | |
| 2351 | graphqlRead({ | |
| 2352 | cwd: input.cwd, | |
| 2353 | host: input.host, | |
| 2354 | operation: "listReviewThreadComments", | |
| 2355 | variables: { owner, name, number: input.number, cursor }, | |
| 2356 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 2357 | decode: decodeReviewDismissalsJson, | |
| 2358 | }), | |
| 2359 | { | |
| 2360 | from: dismissalCursor, | |
| 2361 | maxPages: REVIEW_THREAD_PAGES, | |
| 2362 | nextCursor: (read) => read.nextCursor, | |
| 2363 | }, | |
| 2364 | ); | |
| 2365 | for (const read of pages) | |
| 2366 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2367 | dismissalsByReviewId.set(id, message); | |
| 2368 | } | |
| 2369 | ||
| 2370 | const reviewThreads = entries.map((entry) => ({ | |
| 2371 | ...entry.thread, | |
| 2372 | commentCount: entry.commentCount, | |
| 2373 | ...(entry.nextCommentCursor === null | |
| 2374 | ? {} | |
| 2375 | : { nextCommentsCursor: entry.nextCommentCursor }), | |
| 2376 | })); | |
| 2377 | return { | |
| 2378 | comments: reviewThreadConversation(reviewThreads), | |
| 2379 | dismissalsByReviewId, | |
| 2380 | reviewThreads, | |
| 2381 | // GitHub's own count of each thread, so the number the page shows is the host's even | |
| 2382 | // where a bound kept some of the words on GitHub. | |
| 2383 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), | |
| 2384 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), | |
| 2385 | reviewThreadsTruncated: cursor !== null, | |
| 2386 | reactions, | |
| 2387 | reactionsById, | |
| 2388 | editedAtById, | |
| 2389 | reviewers, | |
| 2390 | avatarsByLogin, | |
| 2391 | botLogins, | |
| 2392 | commitStats, | |
| 2393 | commits, | |
| 2394 | viewer, | |
| 2395 | }; | |
| 2396 | }), | |
| 2397 | ||
| 2398 | listActorAvatars: (input) => { | |
| 2399 | if (input.ids.length === 0) { | |
| 2400 | return Effect.succeed(new Map<string, string>()); | |
| 2401 | } | |
| 2402 | return graphqlRead({ | |
| 2403 | cwd: input.cwd, | |
| 2404 | host: input.host, | |
| 2405 | operation: "listActorAvatars", | |
| 2406 | variables: { ids: input.ids }, | |
| 2407 | query: ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 2408 | decode: decodeActorAvatarsJson, | |
| 2409 | }); | |
| 2410 | }, | |
| 2411 | ||
| 2412 | getViewerAccess: (input) => { | |
| 2413 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2414 | return graphqlRead({ | |
| 2415 | cwd: input.cwd, | |
| 2416 | host: input.host, | |
| 2417 | operation: "getViewerAccess", | |
| 2418 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 2419 | variables: { owner, name, number: input.number }, | |
| 2420 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 2421 | decode: decodeViewerPermissionsJson, | |
| 2422 | }); | |
| 2423 | }, | |
| 2424 | ||
| 2425 | listReviewerCandidates: (input) => { | |
| 2426 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2427 | return graphqlRead({ | |
| 2428 | cwd: input.cwd, | |
| 2429 | host: input.host, | |
| 2430 | operation: "listReviewerCandidates", | |
| 2431 | allowReserve: true, | |
| 2432 | variables: { owner, name, number: input.number }, | |
| 2433 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 2434 | decode: decodeReviewerCandidatesJson, | |
| 2435 | }); | |
| 2436 | }, | |
| 2437 | ||
| 2438 | setReviewerRequest: (input) => { | |
| 2439 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2440 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2441 | // there is nothing to say here about somebody who has reviewed once already. | |
| 2442 | return api | |
| 2443 | .rest({ | |
| 2444 | host: input.host, | |
| 2445 | operation: "setReviewerRequest", | |
| 2446 | method: input.requested ? "POST" : "DELETE", | |
| 2447 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | |
| 2448 | body: buildReviewerRequest(input.reviewers), | |
| 2449 | }) | |
| 2450 | .pipe(Effect.asVoid); | |
| 2451 | }, | |
| 2452 | ||
| 2453 | listLabelCandidates: (input) => { | |
| 2454 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2455 | return graphqlRead({ | |
| 2456 | cwd: input.cwd, | |
| 2457 | host: input.host, | |
| 2458 | operation: "listLabelCandidates", | |
| 2459 | allowReserve: true, | |
| 2460 | variables: { owner, name, number: input.number }, | |
| 2461 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 2462 | decode: decodeLabelCandidatesJson, | |
| 2463 | }); | |
| 2464 | }, | |
| 2465 | ||
| 2466 | setLabels: (input) => { | |
| 2467 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2468 | // A pull request is an issue to the labels API. Adding posts a list and leaves what was | |
| 2469 | // already there; taking off is one delete per label, since the endpoint names one in its | |
| 2470 | // path. The name goes into the path encoded, because a label may carry a space or a slash. | |
| 2471 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; | |
| 2472 | if (input.applied) { | |
| 2473 | return api | |
| 2474 | .rest({ | |
| 2475 | host: input.host, | |
| 2476 | operation: "setLabels", | |
| 2477 | method: "POST", | |
| 2478 | path: issue, | |
| 2479 | body: buildLabelRequest(input.labels), | |
| 2480 | }) | |
| 2481 | .pipe(Effect.asVoid); | |
| 2482 | } | |
| 2483 | return Effect.forEach( | |
| 2484 | input.labels, | |
| 2485 | (label) => | |
| 2486 | api.rest({ | |
| 2487 | host: input.host, | |
| 2488 | operation: "setLabels", | |
| 2489 | method: "DELETE", | |
| 2490 | path: `${issue}/${encodeURIComponent(label)}`, | |
| 2491 | }), | |
| 2492 | { concurrency: 1, discard: true }, | |
| 2493 | ); | |
| 2494 | }, | |
| 2495 | ||
| 2496 | runPullRequestAction: (input) => { | |
| 2497 | if (input.stackNumber !== undefined) | |
| 2498 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | |
| 2499 | Effect.provideService(GitHubApi.GitHubApi, api), | |
| 2500 | Effect.provideService(SourceControlHost.SourceControlHost, sourceControlHost), | |
| 2501 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2502 | ); | |
| 2503 | if (input.action === "revert") { | |
| 2504 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | |
| 2505 | Effect.flatMap((pullRequestId) => | |
| 2506 | graphql({ | |
| 2507 | host: input.host, | |
| 2508 | operation: "revertPullRequest", | |
| 2509 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2510 | variables: { pullRequestId }, | |
| 2511 | }), | |
| 2512 | ), | |
| 2513 | ); | |
| 2514 | } | |
| 2515 | if (input.action === "approve-workflows") { | |
| 2516 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2517 | return getPullRequestDetail(input).pipe( | |
| 2518 | Effect.flatMap((detail) => { | |
| 2519 | if (detail.isCrossRepository !== true) return Effect.void; | |
| 2520 | if (detail.headSha == null || detail.headRepositoryOwner == null) { | |
| 2521 | return Effect.fail( | |
| 2522 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2523 | cwd: input.cwd, | |
| 2524 | number: input.number, | |
| 2525 | }), | |
| 2526 | ); | |
| 2527 | } | |
| 2528 | const expectedHeadSha = detail.headSha; | |
| 2529 | const expectedHeadBranch = detail.headBranch; | |
| 2530 | const expectedHeadRepositoryOwner = detail.headRepositoryOwner; | |
| 2531 | return listWorkflowRunsRequiringApproval({ | |
| 2532 | ...input, | |
| 2533 | headSha: expectedHeadSha, | |
| 2534 | headBranch: expectedHeadBranch, | |
| 2535 | headRepositoryOwner: expectedHeadRepositoryOwner, | |
| 2536 | isCrossRepository: true, | |
| 2537 | }).pipe( | |
| 2538 | Effect.flatMap((runs) => | |
| 2539 | Effect.forEach( | |
| 2540 | runs, | |
| 2541 | (run) => | |
| 2542 | getPullRequestDetail(input).pipe( | |
| 2543 | Effect.flatMap((current) => { | |
| 2544 | if (current.headSha == null || current.headRepositoryOwner == null) { | |
| 2545 | return Effect.fail( | |
| 2546 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2547 | cwd: input.cwd, | |
| 2548 | number: input.number, | |
| 2549 | }), | |
| 2550 | ); | |
| 2551 | } | |
| 2552 | if ( | |
| 2553 | current.isCrossRepository !== true || | |
| 2554 | current.headSha !== expectedHeadSha || | |
| 2555 | current.headBranch !== expectedHeadBranch || | |
| 2556 | current.headRepositoryOwner.toLowerCase() !== | |
| 2557 | expectedHeadRepositoryOwner.toLowerCase() | |
| 2558 | ) { | |
| 2559 | return Effect.fail( | |
| 2560 | new GitHubWorkflowApprovalHeadChangedError({ | |
| 2561 | cwd: input.cwd, | |
| 2562 | number: input.number, | |
| 2563 | }), | |
| 2564 | ); | |
| 2565 | } | |
| 2566 | return listWorkflowRunsRequiringApproval({ | |
| 2567 | ...input, | |
| 2568 | headSha: current.headSha, | |
| 2569 | headBranch: current.headBranch, | |
| 2570 | headRepositoryOwner: current.headRepositoryOwner, | |
| 2571 | isCrossRepository: true, | |
| 2572 | }); | |
| 2573 | }), | |
| 2574 | Effect.flatMap((currentRuns) => | |
| 2575 | currentRuns.some((current) => current.id === run.id) | |
| 2576 | ? api | |
| 2577 | .rest({ | |
| 2578 | host: input.host, | |
| 2579 | operation: "approveWorkflowRun", | |
| 2580 | method: "POST", | |
| 2581 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2582 | }) | |
| 2583 | .pipe(Effect.asVoid) | |
| 2584 | : Effect.void, | |
| 2585 | ), | |
| 2586 | ), | |
| 2587 | { concurrency: 1, discard: true }, | |
| 2588 | ), | |
| 2589 | ), | |
| 2590 | ); | |
| 2591 | }), | |
| 2592 | ); | |
| 2593 | } | |
| 2594 | const action = input.action; | |
| 2595 | if (action in SIMPLE_ACTION_MUTATIONS) { | |
| 2596 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( | |
| 2597 | Effect.flatMap((pullRequestId) => | |
| 2598 | graphql({ | |
| 2599 | host: input.host, | |
| 2600 | operation: "runPullRequestAction", | |
| 2601 | query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], | |
| 2602 | variables: { pullRequestId }, | |
| 2603 | }), | |
| 2604 | ), | |
| 2605 | ); | |
| 2606 | } | |
| 2607 | return Effect.gen(function* () { | |
| 2608 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2609 | // Read fresh rather than from the node id cache: merging and updating act on the head | |
| 2610 | // as it stands now, and the merge queue decides which mutation a merge is. | |
| 2611 | const state = yield* graphqlRead({ | |
| 2612 | cwd: input.cwd, | |
| 2613 | host: input.host, | |
| 2614 | operation: "runPullRequestAction", | |
| 2615 | allowReserve: true, | |
| 2616 | query: ACTION_STATE_GRAPHQL_QUERY, | |
| 2617 | variables: { | |
| 2618 | owner, | |
| 2619 | name, | |
| 2620 | number: input.number, | |
| 2621 | headRef: `refs/pull/${input.number}/head`, | |
| 2622 | }, | |
| 2623 | decode: decodeActionState, | |
| 2624 | }); | |
| 2625 | if (action === "update-branch") { | |
| 2626 | // Already current is done, the way `gh pr update-branch` reports it. | |
| 2627 | if (state.baseRef?.compare?.behindBy === 0) return; | |
| 2628 | // GitHub updates with a merge commit unless asked to rebase, which is its own default. | |
| 2629 | return yield* graphql({ | |
| 2630 | host: input.host, | |
| 2631 | operation: "runPullRequestAction", | |
| 2632 | query: UPDATE_BRANCH_GRAPHQL_MUTATION, | |
| 2633 | variables: { | |
| 2634 | pullRequestId: state.id, | |
| 2635 | expectedHeadOid: state.headRefOid, | |
| 2636 | updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", | |
| 2637 | }, | |
| 2638 | }); | |
| 2639 | } | |
| 2640 | let body: string | undefined; | |
| 2641 | let expectedHead: string | undefined; | |
| 2642 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { | |
| 2643 | const message = yield* graphqlRead({ | |
| 2644 | cwd: input.cwd, | |
| 2645 | host: input.host, | |
| 2646 | operation: "runPullRequestAction", | |
| 2647 | allowReserve: true, | |
| 2648 | query: MERGE_MESSAGE_GRAPHQL_QUERY, | |
| 2649 | variables: { | |
| 2650 | owner, | |
| 2651 | name, | |
| 2652 | number: input.number, | |
| 2653 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", | |
| 2654 | }, | |
| 2655 | decode: decodeMergeMessage, | |
| 2656 | }); | |
| 2657 | // GitHub's merge queue chooses its own message and ignores custom text. | |
| 2658 | if (!message.isMergeQueueEnabled) { | |
| 2659 | const cleaned = removeAgentCredits(message.viewerMergeBodyText); | |
| 2660 | if (cleaned !== message.viewerMergeBodyText) { | |
| 2661 | body = cleaned; | |
| 2662 | expectedHead = message.headRefOid; | |
| 2663 | } | |
| 2664 | } | |
| 2665 | } | |
| 2666 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and | |
| 2667 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. | |
| 2668 | // GitHub stores the strategy with a standing instruction rather than choosing one at | |
| 2669 | // merge time, so arming still names it. | |
| 2670 | const auto = | |
| 2671 | state.isMergeQueueEnabled === true || | |
| 2672 | (action === "enable-auto-merge" && | |
| 2673 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); | |
| 2674 | yield* graphql({ | |
| 2675 | host: input.host, | |
| 2676 | operation: "runPullRequestAction", | |
| 2677 | query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2678 | variables: { | |
| 2679 | input: { | |
| 2680 | pullRequestId: state.id, | |
| 2681 | mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], | |
| 2682 | ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), | |
| 2683 | ...(body === undefined ? {} : { commitBody: body }), | |
| 2684 | }, | |
| 2685 | }, | |
| 2686 | }); | |
| 2687 | }); | |
| 2688 | }, | |
| 2689 | ||
| 2690 | commentOnPullRequest: (input) => | |
| 2691 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( | |
| 2692 | Effect.flatMap((subjectId) => | |
| 2693 | graphql({ | |
| 2694 | host: input.host, | |
| 2695 | operation: "commentOnPullRequest", | |
| 2696 | query: ADD_COMMENT_GRAPHQL_MUTATION, | |
| 2697 | variables: { subjectId, body: input.body }, | |
| 2698 | }), | |
| 2699 | ), | |
| 2700 | ), | |
| 2701 | ||
| 2702 | submitReview: (input) => { | |
| 2703 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2704 | // The whole review is one request, so nothing is visible to anyone else until the verdict | |
| 2705 | // is sent. | |
| 2706 | return api | |
| 2707 | .rest({ | |
| 2708 | host: input.host, | |
| 2709 | operation: "submitReview", | |
| 2710 | method: "POST", | |
| 2711 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2712 | body: buildReviewSubmission({ | |
| 2713 | verdict: input.verdict, | |
| 2714 | body: input.body, | |
| 2715 | comments: input.comments, | |
| 2716 | }), | |
| 2717 | }) | |
| 2718 | .pipe(Effect.asVoid); | |
| 2719 | }, | |
| 2720 | ||
| 2721 | replyToReviewThread: (input) => | |
| 2722 | graphql({ | |
| 2723 | host: input.host, | |
| 2724 | operation: "replyToReviewThread", | |
| 2725 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 2726 | variables: { threadId: input.threadId, body: input.body }, | |
| 2727 | }), | |
| 2728 | ||
| 2729 | getPullRequestFilesViewed: (input) => { | |
| 2730 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2731 | return readGraphQlPages( | |
| 2732 | (after) => | |
| 2733 | graphqlRead({ | |
| 2734 | cwd: input.cwd, | |
| 2735 | host: input.host, | |
| 2736 | operation: "getPullRequestFilesViewed", | |
| 2737 | variables: { owner, name, number: input.number, after }, | |
| 2738 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 2739 | decode: decodePullRequestFilesViewedJson, | |
| 2740 | }), | |
| 2741 | { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, | |
| 2742 | ).pipe( | |
| 2743 | Effect.map(({ pages, truncated }) => ({ | |
| 2744 | files: pages.flatMap((page) => page.files), | |
| 2745 | truncated, | |
| 2746 | })), | |
| 2747 | ); | |
| 2748 | }, | |
| 2749 | ||
| 2750 | setPullRequestFilesViewed: (input) => { | |
| 2751 | if (input.files.length === 0) return Effect.void; | |
| 2752 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( | |
| 2753 | Effect.flatMap((pullRequestId) => { | |
| 2754 | const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); | |
| 2755 | return mutation === null | |
| 2756 | ? Effect.void | |
| 2757 | : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); | |
| 2758 | }), | |
| 2759 | ); | |
| 2760 | }, | |
| 2761 | ||
| 2762 | setReviewThreadResolution: (input) => | |
| 2763 | graphql({ | |
| 2764 | host: input.host, | |
| 2765 | operation: "setReviewThreadResolution", | |
| 2766 | query: input.resolved | |
| 2767 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION | |
| 2768 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 2769 | variables: { threadId: input.threadId }, | |
| 2770 | }), | |
| 2771 | ||
| 2772 | setReaction: (input) => { | |
| 2773 | const givenSubjectId = input.subjectId; | |
| 2774 | const subjectId = | |
| 2775 | givenSubjectId === undefined | |
| 2776 | ? pullRequestNodeId({ ...input, operation: "setReaction" }) | |
| 2777 | : subjectBelongsToPullRequest({ | |
| 2778 | ...input, | |
| 2779 | subjectId: givenSubjectId, | |
| 2780 | operation: "setReaction", | |
| 2781 | }).pipe( | |
| 2782 | Effect.flatMap((belongs) => | |
| 2783 | belongs | |
| 2784 | ? Effect.succeed(givenSubjectId) | |
| 2785 | : Effect.fail( | |
| 2786 | new GitHubSubjectScopeError({ | |
| 2787 | cwd: input.cwd, | |
| 2788 | operation: "setReaction", | |
| 2789 | }), | |
| 2790 | ), | |
| 2791 | ), | |
| 2792 | ); | |
| 2793 | return subjectId.pipe( | |
| 2794 | Effect.flatMap((subjectId) => | |
| 2795 | graphql({ | |
| 2796 | host: input.host, | |
| 2797 | operation: "setReaction", | |
| 2798 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 2799 | variables: { subjectId, content: gitHubReactionContent(input.content) }, | |
| 2800 | }), | |
| 2801 | ), | |
| 2802 | ); | |
| 2803 | }, | |
| 2804 | ||
| 2805 | updatePullRequest: (input) => | |
| 2806 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( | |
| 2807 | Effect.flatMap((pullRequestId) => | |
| 2808 | graphql({ | |
| 2809 | host: input.host, | |
| 2810 | operation: "updatePullRequest", | |
| 2811 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2812 | // A field the caller did not name is left out of the request entirely, so GitHub | |
| 2813 | // keeps the words that are there rather than being asked for an empty one. | |
| 2814 | variables: { | |
| 2815 | pullRequestId, | |
| 2816 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 2817 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 2818 | }, | |
| 2819 | }), | |
| 2820 | ), | |
| 2821 | ), | |
| 2822 | ||
| 2823 | updateComment: (input) => | |
| 2824 | subjectBelongsToPullRequest({ | |
| 2825 | cwd: input.cwd, | |
| 2826 | repository: input.repository, | |
| 2827 | host: input.host, | |
| 2828 | number: input.number, | |
| 2829 | subjectId: input.commentId, | |
| 2830 | operation: "updateComment", | |
| 2831 | }).pipe( | |
| 2832 | Effect.flatMap((belongs) => | |
| 2833 | belongs | |
| 2834 | ? Effect.succeed(input.commentId) | |
| 2835 | : Effect.fail( | |
| 2836 | new GitHubSubjectScopeError({ | |
| 2837 | cwd: input.cwd, | |
| 2838 | operation: "updateComment", | |
| 2839 | }), | |
| 2840 | ), | |
| 2841 | ), | |
| 2842 | Effect.flatMap((commentId) => | |
| 2843 | graphql({ | |
| 2844 | host: input.host, | |
| 2845 | operation: "updateComment", | |
| 2846 | query: | |
| 2847 | input.kind === "issue-comment" | |
| 2848 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION | |
| 2849 | : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 2850 | variables: { commentId, body: input.body }, | |
| 2851 | }), | |
| 2852 | ), | |
| 2853 | ), | |
| 2854 | }); | |
| 2855 | }); | |
| 2856 | ||
| 2857 | export const layer = Layer.effect(GitHubPullRequestApi, make); |
packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts created+1067| ... | ... | @@ -0,0 +1,1067 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Redacted from "effect/Redacted"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import type { PullRequestReaction } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; | |
| 9 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 10 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 11 | import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; | |
| 12 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | |
| 13 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; | |
| 14 | ||
| 15 | const coreFields = { | |
| 16 | checksTruncated: false, | |
| 17 | comparison: { behindBy: 0, viewerCanUpdate: true }, | |
| 18 | viewerAccess: { | |
| 19 | canWrite: true, | |
| 20 | canTriage: true, | |
| 21 | canUpdate: true, | |
| 22 | didAuthor: false, | |
| 23 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 24 | }, | |
| 25 | }; | |
| 26 | ||
| 27 | it.effect("maps credential verification failures without relabeling operation failures", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | let verificationFails = true; | |
| 30 | let operations = 0; | |
| 31 | const provider = yield* make.pipe( | |
| 32 | Effect.provide( | |
| 33 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 34 | revalidateChecks: (_input, read) => read, | |
| 35 | withVerifiedCredential: (_input, use) => | |
| 36 | verificationFails | |
| 37 | ? Effect.fail( | |
| 38 | new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ | |
| 39 | cwd: "/w", | |
| 40 | }), | |
| 41 | ) | |
| 42 | : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }), | |
| 43 | }), | |
| 44 | ), | |
| 45 | ); | |
| 46 | const verify = provider.withVerifiedCredential; | |
| 47 | if (verify === undefined) | |
| 48 | return yield* Effect.die("credential verification was not implemented"); | |
| 49 | const input = { cwd: "/w", host: "github.com" }; | |
| 50 | const operation = () => | |
| 51 | Effect.sync(() => { | |
| 52 | operations++; | |
| 53 | }).pipe(Effect.andThen(Effect.fail("operation-failed"))); | |
| 54 | expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({ | |
| 55 | _tag: "PullRequestProviderError", | |
| 56 | operation: "routeIdentity", | |
| 57 | }); | |
| 58 | expect(operations).toBe(0); | |
| 59 | verificationFails = false; | |
| 60 | expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed"); | |
| 61 | expect(operations).toBe(1); | |
| 62 | }), | |
| 63 | ); | |
| 64 | ||
| 65 | it.effect("refreshes checks without permissions or comparison reads", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | let reads = 0; | |
| 68 | const snapshot = Result.getOrThrow( | |
| 69 | decodePullRequestDetailJson(`{ | |
| 70 | "number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7", | |
| 71 | "headRefName": "feature", "baseRefName": "main", "state": "OPEN", | |
| 72 | "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z" | |
| 73 | }`), | |
| 74 | ); | |
| 75 | const provider = yield* make.pipe( | |
| 76 | Effect.provide( | |
| 77 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 78 | revalidateChecks: (_input, read) => read, | |
| 79 | getPullRequestDetail: () => | |
| 80 | Effect.sync(() => { | |
| 81 | reads++; | |
| 82 | return { | |
| 83 | ...snapshot, | |
| 84 | ...coreFields, | |
| 85 | state: reads === 3 ? ("merged" as const) : ("open" as const), | |
| 86 | checks: [ | |
| 87 | { | |
| 88 | name: "build", | |
| 89 | status: reads === 1 ? ("pending" as const) : ("success" as const), | |
| 90 | description: null, | |
| 91 | url: null, | |
| 92 | }, | |
| 93 | ], | |
| 94 | }; | |
| 95 | }), | |
| 96 | }), | |
| 97 | ), | |
| 98 | ); | |
| 99 | const read = provider.getChangeRequestChecks; | |
| 100 | if (read === undefined) return yield* Effect.die("checks read missing"); | |
| 101 | for (let tick = 1; tick <= 3; tick++) { | |
| 102 | const result = yield* read({ | |
| 103 | cwd: "/w", | |
| 104 | repository: "acme/web", | |
| 105 | host: "github.com", | |
| 106 | number: 7, | |
| 107 | }); | |
| 108 | expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success"); | |
| 109 | expect(result.state).toBe(tick === 3 ? "merged" : "open"); | |
| 110 | } | |
| 111 | expect(reads).toBe(3); | |
| 112 | }), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("uses one narrow read for a linked pull request summary", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | let summaryReads = 0; | |
| 118 | const provider = yield* make.pipe( | |
| 119 | Effect.provide( | |
| 120 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 121 | revalidateChecks: (_input, read) => read, | |
| 122 | getPullRequestSummary: () => | |
| 123 | Effect.sync(() => { | |
| 124 | summaryReads += 1; | |
| 125 | return { | |
| 126 | number: 7, | |
| 127 | title: "Summary", | |
| 128 | url: "https://github.com/acme/web/pull/7", | |
| 129 | headBranch: "feat/summary", | |
| 130 | baseBranch: "main", | |
| 131 | state: "open" as const, | |
| 132 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 133 | author: { login: "octocat", name: null, avatarUrl: null }, | |
| 134 | }; | |
| 135 | }), | |
| 136 | }), | |
| 137 | ), | |
| 138 | ); | |
| 139 | ||
| 140 | const readSummary = provider.getChangeRequestSummary; | |
| 141 | if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); | |
| 142 | const summary = yield* readSummary({ | |
| 143 | cwd: "/w", | |
| 144 | repository: "acme/web", | |
| 145 | host: "github.com", | |
| 146 | number: 7, | |
| 147 | }); | |
| 148 | ||
| 149 | expect(summary.state).toBe("open"); | |
| 150 | expect(summaryReads).toBe(1); | |
| 151 | // The author's avatar comes from the login-shaped URL, not a second request. | |
| 152 | expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); | |
| 153 | }), | |
| 154 | ); | |
| 155 | ||
| 156 | it.effect("declares host-native stacks and passes the one the CLI reads through", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const stack = { | |
| 159 | id: "42", | |
| 160 | number: 3, | |
| 161 | url: "https://github.com/acme/web/stacks/3", | |
| 162 | base: "main", | |
| 163 | layers: [ | |
| 164 | { number: 6, headBranch: "feat/one", state: "merged" as const }, | |
| 165 | { number: 7, headBranch: "feat/two", state: "open" as const }, | |
| 166 | ], | |
| 167 | }; | |
| 168 | const provider = yield* make.pipe( | |
| 169 | Effect.provide( | |
| 170 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 171 | revalidateChecks: (_input, read) => read, | |
| 172 | getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), | |
| 173 | }), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | expect(provider.capabilities.stacks).toBe(true); | |
| 178 | const readStack = provider.getChangeRequestStack; | |
| 179 | if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); | |
| 180 | const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; | |
| 181 | expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); | |
| 182 | expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); | |
| 183 | }), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("reports a failed stack read against its own operation", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const provider = yield* make.pipe( | |
| 189 | Effect.provide( | |
| 190 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 191 | revalidateChecks: (_input, read) => read, | |
| 192 | getPullRequestStack: () => | |
| 193 | Effect.fail( | |
| 194 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 195 | cwd: "/w", | |
| 196 | operation: "getPullRequestStack", | |
| 197 | cause: new Error("unreadable"), | |
| 198 | }), | |
| 199 | ), | |
| 200 | }), | |
| 201 | ), | |
| 202 | ); | |
| 203 | ||
| 204 | const readStack = provider.getChangeRequestStack; | |
| 205 | if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); | |
| 206 | const error = yield* Effect.flip( | |
| 207 | readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), | |
| 208 | ); | |
| 209 | ||
| 210 | expect(error.operation).toBe("getChangeRequestStack"); | |
| 211 | expect(error.reason).toBe("failed"); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | describe("gitHubViewerPermissions", () => { | |
| 216 | it("offers everything to a viewer who can write to the repository", () => { | |
| 217 | expect( | |
| 218 | gitHubViewerPermissions({ | |
| 219 | canWrite: true, | |
| 220 | canTriage: true, | |
| 221 | canUpdate: true, | |
| 222 | didAuthor: false, | |
| 223 | }), | |
| 224 | ).toEqual({ | |
| 225 | // Arming a merge for later is the merge, so it travels with it. | |
| 226 | actions: [ | |
| 227 | "merge", | |
| 228 | "enable-auto-merge", | |
| 229 | "disable-auto-merge", | |
| 230 | "revert", | |
| 231 | "approve-workflows", | |
| 232 | "ready", | |
| 233 | "draft", | |
| 234 | "close", | |
| 235 | "reopen", | |
| 236 | ], | |
| 237 | comment: true, | |
| 238 | resolve: true, | |
| 239 | stackRebase: true, | |
| 240 | verdicts: ["comment", "approve", "request-changes"], | |
| 241 | requestReviewers: true, | |
| 242 | labels: true, | |
| 243 | }); | |
| 244 | }); | |
| 245 | ||
| 246 | it("leaves a passer-by on a repository they can only read nothing but the review", () => { | |
| 247 | // Every open-source pull request somebody else opened: GitHub says no to all five actions | |
| 248 | // and to resolving, and yes to commenting and to every verdict. | |
| 249 | expect( | |
| 250 | gitHubViewerPermissions({ | |
| 251 | canWrite: false, | |
| 252 | canTriage: false, | |
| 253 | canUpdate: false, | |
| 254 | didAuthor: false, | |
| 255 | }), | |
| 256 | ).toEqual({ | |
| 257 | actions: [], | |
| 258 | comment: true, | |
| 259 | resolve: false, | |
| 260 | verdicts: ["comment", "approve", "request-changes"], | |
| 261 | // Asking somebody else to review is the one thing read access never stretches to. | |
| 262 | requestReviewers: false, | |
| 263 | labels: false, | |
| 264 | }); | |
| 265 | }); | |
| 266 | ||
| 267 | it("lets a triager label without letting them merge or ask for a review", () => { | |
| 268 | const permissions = gitHubViewerPermissions({ | |
| 269 | canWrite: false, | |
| 270 | canTriage: true, | |
| 271 | canUpdate: false, | |
| 272 | didAuthor: false, | |
| 273 | }); | |
| 274 | expect(permissions.labels).toBe(true); | |
| 275 | expect(permissions.requestReviewers).toBe(false); | |
| 276 | expect(permissions.actions).toEqual([]); | |
| 277 | }); | |
| 278 | ||
| 279 | it("keeps an author's own pull request theirs to close, with read access and no more", () => { | |
| 280 | expect( | |
| 281 | gitHubViewerPermissions({ | |
| 282 | canWrite: false, | |
| 283 | canTriage: false, | |
| 284 | canUpdate: true, | |
| 285 | didAuthor: true, | |
| 286 | }), | |
| 287 | ).toEqual({ | |
| 288 | // Merging is the one thing writing is needed for, now or later; the rest an author may do. | |
| 289 | actions: ["ready", "draft", "close", "reopen"], | |
| 290 | comment: true, | |
| 291 | resolve: true, | |
| 292 | // GitHub refuses an author's approval of their own change, so the page does not offer one. | |
| 293 | verdicts: ["comment"], | |
| 294 | requestReviewers: false, | |
| 295 | labels: false, | |
| 296 | }); | |
| 297 | }); | |
| 298 | ||
| 299 | it.effect("uses the permissions carried by the core read without a second request", () => | |
| 300 | Effect.gen(function* () { | |
| 301 | const provider = yield* make; | |
| 302 | const detail = yield* provider.getChangeRequest({ | |
| 303 | cwd: "/w", | |
| 304 | repository: "acme/web", | |
| 305 | host: "github.com", | |
| 306 | number: 7, | |
| 307 | }); | |
| 308 | ||
| 309 | expect(detail.viewerPermissions).toEqual({ | |
| 310 | actions: ["ready", "draft", "close", "reopen"], | |
| 311 | comment: true, | |
| 312 | resolve: false, | |
| 313 | verdicts: ["comment", "approve", "request-changes"], | |
| 314 | requestReviewers: false, | |
| 315 | labels: false, | |
| 316 | }); | |
| 317 | expect(detail.workflowApprovalsRequired).toBeUndefined(); | |
| 318 | expect(detail.checks).toContainEqual({ | |
| 319 | name: "Workflow approval status", | |
| 320 | status: "action-required", | |
| 321 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 322 | url: null, | |
| 323 | }); | |
| 324 | for (const fingerprint of ["broad", "restricted", "broad"]) { | |
| 325 | const scoped = yield* provider | |
| 326 | .getChangeRequest({ | |
| 327 | cwd: "/w", | |
| 328 | repository: "acme/web", | |
| 329 | host: "github.com", | |
| 330 | number: 7, | |
| 331 | }) | |
| 332 | .pipe( | |
| 333 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 334 | host: "github.com", | |
| 335 | token: Redacted.make("credential"), | |
| 336 | credentialFingerprint: fingerprint, | |
| 337 | }), | |
| 338 | ); | |
| 339 | expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted"); | |
| 340 | } | |
| 341 | }).pipe( | |
| 342 | Effect.provide( | |
| 343 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 344 | revalidateChecks: (_input, read) => read, | |
| 345 | getPullRequestDetail: () => | |
| 346 | Effect.succeed({ | |
| 347 | ...coreFields, | |
| 348 | comparison: null, | |
| 349 | viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, | |
| 350 | authorId: null, | |
| 351 | number: 7, | |
| 352 | title: "Pull request 7", | |
| 353 | url: "https://github.com/acme/web/pull/7", | |
| 354 | author: null, | |
| 355 | isCrossRepository: true, | |
| 356 | headRepositoryOwner: null, | |
| 357 | headBranch: "feat/page", | |
| 358 | baseBranch: "main", | |
| 359 | state: "open" as const, | |
| 360 | isDraft: false, | |
| 361 | mergeability: "mergeable" as const, | |
| 362 | reviewDecision: null, | |
| 363 | additions: 1, | |
| 364 | deletions: 1, | |
| 365 | createdAt: "2026-07-01T00:00:00Z", | |
| 366 | updatedAt: "2026-07-02T00:00:00Z", | |
| 367 | reviewRequestLogins: [], | |
| 368 | hasTeamReviewRequest: false, | |
| 369 | checksState: null, | |
| 370 | labels: [], | |
| 371 | body: "", | |
| 372 | changedFiles: 1, | |
| 373 | mergedAt: null, | |
| 374 | closedAt: null, | |
| 375 | checks: [], | |
| 376 | comments: [], | |
| 377 | commits: [], | |
| 378 | }).pipe( | |
| 379 | Effect.flatMap((detail) => | |
| 380 | GitHubApi.PinnedGitHubCredential.pipe( | |
| 381 | Effect.map((credential) => ({ | |
| 382 | ...detail, | |
| 383 | viewerAccess: { | |
| 384 | ...detail.viewerAccess, | |
| 385 | mergeCapabilities: { | |
| 386 | ...detail.viewerAccess.mergeCapabilities, | |
| 387 | squash: credential?.credentialFingerprint !== "restricted", | |
| 388 | }, | |
| 389 | }, | |
| 390 | })), | |
| 391 | ), | |
| 392 | ), | |
| 393 | ), | |
| 394 | }), | |
| 395 | ), | |
| 396 | ), | |
| 397 | ); | |
| 398 | ||
| 399 | it.effect("keeps fork workflows awaiting approval out of the passing state", () => | |
| 400 | Effect.gen(function* () { | |
| 401 | const provider = yield* make; | |
| 402 | const detail = yield* provider.getChangeRequest({ | |
| 403 | cwd: "/w", | |
| 404 | repository: "acme/web", | |
| 405 | host: "github.com", | |
| 406 | number: 7, | |
| 407 | }); | |
| 408 | ||
| 409 | const readChecks = provider.getChangeRequestChecks; | |
| 410 | if (readChecks === undefined) return yield* Effect.die("checks read missing"); | |
| 411 | expect( | |
| 412 | yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), | |
| 413 | ).toEqual({ state: detail.state, checks: detail.checks }); | |
| 414 | expect(detail.workflowApprovalsRequired).toBe(1); | |
| 415 | expect(detail.checks).toEqual([ | |
| 416 | { | |
| 417 | name: "manual gate", | |
| 418 | status: "action-required", | |
| 419 | description: null, | |
| 420 | url: "https://example.com/manual-gate", | |
| 421 | }, | |
| 422 | { | |
| 423 | name: "build", | |
| 424 | status: "success", | |
| 425 | description: null, | |
| 426 | url: null, | |
| 427 | }, | |
| 428 | { | |
| 429 | name: "contributor tests", | |
| 430 | status: "action-required", | |
| 431 | description: "A maintainer must approve this workflow before it can run.", | |
| 432 | url: "https://github.com/acme/web/actions/runs/123", | |
| 433 | }, | |
| 434 | ]); | |
| 435 | }).pipe( | |
| 436 | Effect.provide( | |
| 437 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 438 | revalidateChecks: (_input, read) => read, | |
| 439 | getPullRequestDetail: () => | |
| 440 | Effect.succeed({ | |
| 441 | ...coreFields, | |
| 442 | authorId: null, | |
| 443 | number: 7, | |
| 444 | title: "Pull request 7", | |
| 445 | url: "https://github.com/acme/web/pull/7", | |
| 446 | author: null, | |
| 447 | isCrossRepository: true, | |
| 448 | headRepositoryOwner: "octocat", | |
| 449 | headSha: "abc123", | |
| 450 | headBranch: "feat/page", | |
| 451 | baseBranch: "main", | |
| 452 | state: "open", | |
| 453 | isDraft: false, | |
| 454 | mergeability: "mergeable", | |
| 455 | reviewDecision: null, | |
| 456 | additions: 1, | |
| 457 | deletions: 1, | |
| 458 | createdAt: "2026-07-01T00:00:00Z", | |
| 459 | updatedAt: "2026-07-02T00:00:00Z", | |
| 460 | reviewRequestLogins: [], | |
| 461 | hasTeamReviewRequest: false, | |
| 462 | checksState: "passing", | |
| 463 | labels: [], | |
| 464 | body: "", | |
| 465 | changedFiles: 1, | |
| 466 | mergedAt: null, | |
| 467 | closedAt: null, | |
| 468 | checks: [ | |
| 469 | { | |
| 470 | name: "manual gate", | |
| 471 | status: "action-required", | |
| 472 | description: null, | |
| 473 | url: "https://example.com/manual-gate", | |
| 474 | }, | |
| 475 | { name: "build", status: "success", description: null, url: null }, | |
| 476 | ], | |
| 477 | comments: [], | |
| 478 | commits: [], | |
| 479 | }), | |
| 480 | listWorkflowRunsRequiringApproval: () => | |
| 481 | Effect.succeed([ | |
| 482 | { | |
| 483 | id: 123, | |
| 484 | name: "contributor tests", | |
| 485 | url: "https://github.com/acme/web/actions/runs/123", | |
| 486 | }, | |
| 487 | ]), | |
| 488 | getViewerAccess: () => | |
| 489 | Effect.succeed({ | |
| 490 | canWrite: true, | |
| 491 | canTriage: true, | |
| 492 | canUpdate: true, | |
| 493 | didAuthor: false, | |
| 494 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 495 | }), | |
| 496 | }), | |
| 497 | ), | |
| 498 | ), | |
| 499 | ); | |
| 500 | }); | |
| 501 | ||
| 502 | const openDetail = { | |
| 503 | ...coreFields, | |
| 504 | authorId: null, | |
| 505 | number: 7, | |
| 506 | title: "Pull request 7", | |
| 507 | url: "https://github.com/acme/web/pull/7", | |
| 508 | author: null, | |
| 509 | isCrossRepository: true, | |
| 510 | headRepositoryOwner: "acme", | |
| 511 | headSha: "abc123", | |
| 512 | headBranch: "feat/page", | |
| 513 | baseBranch: "main", | |
| 514 | state: "open" as const, | |
| 515 | isDraft: false, | |
| 516 | mergeability: "mergeable" as const, | |
| 517 | reviewDecision: null, | |
| 518 | additions: 1, | |
| 519 | deletions: 1, | |
| 520 | createdAt: "2026-07-01T00:00:00Z", | |
| 521 | updatedAt: "2026-07-02T00:00:00Z", | |
| 522 | reviewRequestLogins: [], | |
| 523 | hasTeamReviewRequest: false, | |
| 524 | checksState: null, | |
| 525 | labels: [], | |
| 526 | body: "", | |
| 527 | changedFiles: 1, | |
| 528 | mergedAt: null, | |
| 529 | closedAt: null, | |
| 530 | checks: [], | |
| 531 | comments: [], | |
| 532 | commits: [], | |
| 533 | }; | |
| 534 | ||
| 535 | it.effect( | |
| 536 | "uses the core comparison and permissions while preserving workflow approval checks", | |
| 537 | () => | |
| 538 | Effect.gen(function* () { | |
| 539 | const provider = yield* make.pipe( | |
| 540 | Effect.provide( | |
| 541 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 542 | getPullRequestDetail: () => | |
| 543 | Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), | |
| 544 | listWorkflowRunsRequiringApproval: () => | |
| 545 | Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), | |
| 546 | }), | |
| 547 | ), | |
| 548 | ); | |
| 549 | ||
| 550 | const detail = yield* provider.getChangeRequest({ | |
| 551 | cwd: "/w", | |
| 552 | repository: "acme/web", | |
| 553 | host: "github.com", | |
| 554 | number: 7, | |
| 555 | }); | |
| 556 | ||
| 557 | expect(detail.behindBy).toBe(2); | |
| 558 | expect(detail.workflowApprovalsRequired).toBe(1); | |
| 559 | expect(detail.checks).toContainEqual({ | |
| 560 | name: "tests", | |
| 561 | status: "action-required", | |
| 562 | description: "A maintainer must approve this workflow before it can run.", | |
| 563 | url: "https://example.com/runs/123", | |
| 564 | }); | |
| 565 | }), | |
| 566 | ); | |
| 567 | ||
| 568 | it.effect("does not classify same-repository gates as fork workflow approvals", () => | |
| 569 | Effect.gen(function* () { | |
| 570 | const provider = yield* make; | |
| 571 | const detail = yield* provider.getChangeRequest({ | |
| 572 | cwd: "/w", | |
| 573 | repository: "acme/web", | |
| 574 | host: "github.com", | |
| 575 | number: 7, | |
| 576 | }); | |
| 577 | ||
| 578 | expect(detail.workflowApprovalsRequired).toBe(0); | |
| 579 | expect(detail.checks).toEqual([]); | |
| 580 | }).pipe( | |
| 581 | Effect.provide( | |
| 582 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 583 | revalidateChecks: (_input, read) => read, | |
| 584 | getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), | |
| 585 | listWorkflowRunsRequiringApproval: () => | |
| 586 | Effect.die("same-repository pull requests must not probe fork workflow approvals"), | |
| 587 | getViewerAccess: () => | |
| 588 | Effect.succeed({ | |
| 589 | canWrite: true, | |
| 590 | canTriage: true, | |
| 591 | canUpdate: true, | |
| 592 | didAuthor: false, | |
| 593 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 594 | }), | |
| 595 | }), | |
| 596 | ), | |
| 597 | ), | |
| 598 | ); | |
| 599 | ||
| 600 | it.effect("keeps an unsafe workflow approval scope visible as unknown", () => | |
| 601 | Effect.gen(function* () { | |
| 602 | const provider = yield* make; | |
| 603 | const detail = yield* provider.getChangeRequest({ | |
| 604 | cwd: "/w", | |
| 605 | repository: "acme/web", | |
| 606 | host: "github.com", | |
| 607 | number: 7, | |
| 608 | }); | |
| 609 | ||
| 610 | expect(detail.workflowApprovalsRequired).toBeUndefined(); | |
| 611 | expect(detail.checks).toEqual([ | |
| 612 | { | |
| 613 | name: "Workflow approval status", | |
| 614 | status: "action-required", | |
| 615 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 616 | url: null, | |
| 617 | }, | |
| 618 | ]); | |
| 619 | }).pipe( | |
| 620 | Effect.provide( | |
| 621 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 622 | revalidateChecks: (_input, read) => read, | |
| 623 | getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 624 | listWorkflowRunsRequiringApproval: () => | |
| 625 | Effect.fail( | |
| 626 | new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ | |
| 627 | cwd: "/w", | |
| 628 | number: 7, | |
| 629 | reason: "head-not-unique", | |
| 630 | observedCount: 2, | |
| 631 | limit: 1_000, | |
| 632 | }), | |
| 633 | ), | |
| 634 | getViewerAccess: () => | |
| 635 | Effect.succeed({ | |
| 636 | canWrite: true, | |
| 637 | canTriage: true, | |
| 638 | canUpdate: true, | |
| 639 | didAuthor: false, | |
| 640 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 641 | }), | |
| 642 | }), | |
| 643 | ), | |
| 644 | ), | |
| 645 | ); | |
| 646 | ||
| 647 | it.effect("propagates workflow discovery rate limits", () => | |
| 648 | Effect.gen(function* () { | |
| 649 | const provider = yield* make; | |
| 650 | const error = yield* provider | |
| 651 | .getChangeRequest({ | |
| 652 | cwd: "/w", | |
| 653 | repository: "acme/web", | |
| 654 | host: "github.com", | |
| 655 | number: 7, | |
| 656 | }) | |
| 657 | .pipe(Effect.flip); | |
| 658 | ||
| 659 | expect(error.operation).toBe("getChangeRequest"); | |
| 660 | expect(error.reason).toBe("rate-limited"); | |
| 661 | }).pipe( | |
| 662 | Effect.provide( | |
| 663 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 664 | revalidateChecks: (_input, read) => read, | |
| 665 | getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 666 | listWorkflowRunsRequiringApproval: () => | |
| 667 | Effect.fail( | |
| 668 | new GitHubApi.GitHubApiRateLimitError({ | |
| 669 | host: "github.com", | |
| 670 | operation: "listWorkflowRunsRequiringApproval", | |
| 671 | }), | |
| 672 | ), | |
| 673 | getViewerAccess: () => | |
| 674 | Effect.succeed({ | |
| 675 | canWrite: true, | |
| 676 | canTriage: true, | |
| 677 | canUpdate: true, | |
| 678 | didAuthor: false, | |
| 679 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 680 | }), | |
| 681 | }), | |
| 682 | ), | |
| 683 | ), | |
| 684 | ); | |
| 685 | ||
| 686 | describe("getViewerPermissions", () => { | |
| 687 | const access = { | |
| 688 | canWrite: true, | |
| 689 | canTriage: true, | |
| 690 | canUpdate: true, | |
| 691 | didAuthor: false, | |
| 692 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 693 | }; | |
| 694 | ||
| 695 | it.effect("checks fresh access without reading branch details for unrelated operations", () => { | |
| 696 | let accessReads = 0; | |
| 697 | return Effect.gen(function* () { | |
| 698 | const provider = yield* make; | |
| 699 | const permissions = yield* provider.getViewerPermissions({ | |
| 700 | cwd: "/w", | |
| 701 | repository: "acme/web", | |
| 702 | host: "github.com", | |
| 703 | number: 7, | |
| 704 | includeUpdateBranch: false, | |
| 705 | }); | |
| 706 | ||
| 707 | expect(accessReads).toBe(1); | |
| 708 | expect(permissions.actions).toContain("merge"); | |
| 709 | expect(permissions.actions).not.toContain("update-branch"); | |
| 710 | }).pipe( | |
| 711 | Effect.provide( | |
| 712 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 713 | revalidateChecks: (_input, read) => read, | |
| 714 | getPullRequestDetail: () => Effect.die("Unexpected detail read"), | |
| 715 | getViewerAccess: () => Effect.sync(() => (accessReads++, access)), | |
| 716 | }), | |
| 717 | ), | |
| 718 | ); | |
| 719 | }); | |
| 720 | ||
| 721 | const layerWithDetail = ( | |
| 722 | detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApi.GitHubPullRequestApiError>, | |
| 723 | onDetail: (allowReserve: boolean) => void = () => {}, | |
| 724 | ) => | |
| 725 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 726 | revalidateChecks: (_input, read) => read, | |
| 727 | getPullRequestDetail: () => | |
| 728 | GitHubApi.AllowGitHubReserve.pipe( | |
| 729 | Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), | |
| 730 | Effect.flatMap(() => detail), | |
| 731 | ), | |
| 732 | getViewerAccess: () => Effect.die("Unexpected access read"), | |
| 733 | }); | |
| 734 | ||
| 735 | it.effect("answers access and update-branch from the one detail read", () => | |
| 736 | Effect.gen(function* () { | |
| 737 | const provider = yield* make; | |
| 738 | const permissions = yield* provider.getViewerPermissions({ | |
| 739 | cwd: "/w", | |
| 740 | repository: "acme/web", | |
| 741 | host: "github.com", | |
| 742 | number: 7, | |
| 743 | }); | |
| 744 | ||
| 745 | expect(permissions.actions).toContain("merge"); | |
| 746 | expect(permissions.actions).toContain("update-branch"); | |
| 747 | expect(permissions.updateMethods).toEqual(["merge", "rebase"]); | |
| 748 | }).pipe( | |
| 749 | Effect.provide( | |
| 750 | layerWithDetail( | |
| 751 | Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }), | |
| 752 | ), | |
| 753 | ), | |
| 754 | ), | |
| 755 | ); | |
| 756 | ||
| 757 | it.effect("uses the GraphQL reserve for manual permission checks", () => { | |
| 758 | let allowed: boolean | undefined; | |
| 759 | return Effect.gen(function* () { | |
| 760 | const provider = yield* make; | |
| 761 | yield* provider.getViewerPermissions({ | |
| 762 | cwd: "/w", | |
| 763 | repository: "acme/web", | |
| 764 | host: "github.com", | |
| 765 | number: 7, | |
| 766 | }); | |
| 767 | expect(allowed).toBe(true); | |
| 768 | }).pipe( | |
| 769 | Effect.provide( | |
| 770 | layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)), | |
| 771 | ), | |
| 772 | ); | |
| 773 | }); | |
| 774 | ||
| 775 | it.effect("still answers from the light access read when the detail read fails", () => | |
| 776 | Effect.gen(function* () { | |
| 777 | const provider = yield* make; | |
| 778 | const permissions = yield* provider.getViewerPermissions({ | |
| 779 | cwd: "/w", | |
| 780 | repository: "acme/web", | |
| 781 | host: "github.com", | |
| 782 | number: 7, | |
| 783 | }); | |
| 784 | expect(permissions.actions).toContain("merge"); | |
| 785 | expect(permissions.actions).not.toContain("update-branch"); | |
| 786 | }).pipe( | |
| 787 | Effect.provide( | |
| 788 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 789 | revalidateChecks: (_input, read) => read, | |
| 790 | getPullRequestDetail: () => | |
| 791 | Effect.fail( | |
| 792 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 793 | cwd: "/w", | |
| 794 | operation: "getPullRequestDetail", | |
| 795 | cause: new Error("head changed"), | |
| 796 | }), | |
| 797 | ), | |
| 798 | getViewerAccess: () => Effect.succeed(access), | |
| 799 | }), | |
| 800 | ), | |
| 801 | ), | |
| 802 | ); | |
| 803 | ||
| 804 | it.effect("withholds update-branch where GitHub could not compare the branch", () => | |
| 805 | Effect.gen(function* () { | |
| 806 | const provider = yield* make; | |
| 807 | const permissions = yield* provider.getViewerPermissions({ | |
| 808 | cwd: "/w", | |
| 809 | repository: "acme/web", | |
| 810 | host: "github.com", | |
| 811 | number: 7, | |
| 812 | }); | |
| 813 | ||
| 814 | expect(permissions.actions).not.toContain("update-branch"); | |
| 815 | expect(permissions.updateMethods).toBeUndefined(); | |
| 816 | expect(permissions.actions).toContain("merge"); | |
| 817 | }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))), | |
| 818 | ); | |
| 819 | }); | |
| 820 | ||
| 821 | describe("getChangeRequest commits", () => { | |
| 822 | const baseDetail = { | |
| 823 | authorId: null, | |
| 824 | number: 7, | |
| 825 | title: "Pull request 7", | |
| 826 | url: "https://github.com/acme/web/pull/7", | |
| 827 | author: null, | |
| 828 | headBranch: "feat/page", | |
| 829 | baseBranch: "main", | |
| 830 | state: "open" as const, | |
| 831 | isDraft: false, | |
| 832 | mergeability: "mergeable" as const, | |
| 833 | additions: 1, | |
| 834 | deletions: 1, | |
| 835 | createdAt: "2026-07-01T00:00:00Z", | |
| 836 | updatedAt: "2026-07-02T00:00:00Z", | |
| 837 | reviewRequestLogins: [], | |
| 838 | hasTeamReviewRequest: false, | |
| 839 | checksState: null, | |
| 840 | labels: [], | |
| 841 | body: "", | |
| 842 | changedFiles: 1, | |
| 843 | mergedAt: null, | |
| 844 | closedAt: null, | |
| 845 | checks: [], | |
| 846 | comments: [], | |
| 847 | }; | |
| 848 | ||
| 849 | const baseThreadComments = { | |
| 850 | comments: [], | |
| 851 | dismissalsByReviewId: new Map<string, string>(), | |
| 852 | reviewThreads: [], | |
| 853 | commentCount: 0, | |
| 854 | truncated: false, | |
| 855 | reviewThreadsTruncated: false, | |
| 856 | reactions: [], | |
| 857 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), | |
| 858 | editedAtById: new Map<string, string>(), | |
| 859 | reviewers: [], | |
| 860 | avatarsByLogin: new Map<string, string>(), | |
| 861 | botLogins: new Set<string>(), | |
| 862 | commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(), | |
| 863 | viewer: { canUpdate: true, didAuthor: false }, | |
| 864 | }; | |
| 865 | ||
| 866 | const layerWith = (commits: GitHubReviewThreadComments["commits"]) => | |
| 867 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 868 | revalidateChecks: (_input, read) => read, | |
| 869 | getPullRequestActivity: () => | |
| 870 | Effect.succeed({ | |
| 871 | author: baseDetail.author, | |
| 872 | comments: baseDetail.comments, | |
| 873 | commits: [ | |
| 874 | { | |
| 875 | oid: "view-oldest", | |
| 876 | messageHeadline: "gh pr view's oldest commit", | |
| 877 | committedDate: "2026-01-01T00:00:00Z", | |
| 878 | authors: [], | |
| 879 | }, | |
| 880 | ], | |
| 881 | }), | |
| 882 | listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }), | |
| 883 | }); | |
| 884 | ||
| 885 | it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () => | |
| 886 | Effect.gen(function* () { | |
| 887 | const provider = yield* make; | |
| 888 | const detail = yield* provider.getChangeRequestActivity({ | |
| 889 | cwd: "/w", | |
| 890 | repository: "acme/web", | |
| 891 | host: "github.com", | |
| 892 | number: 7, | |
| 893 | }); | |
| 894 | ||
| 895 | expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]); | |
| 896 | }).pipe( | |
| 897 | Effect.provide( | |
| 898 | layerWith([ | |
| 899 | { | |
| 900 | oid: "graphql-newest", | |
| 901 | messageHeadline: "the newest commit gh pr view drops", | |
| 902 | committedDate: "2026-07-06T00:00:00Z", | |
| 903 | authors: [], | |
| 904 | }, | |
| 905 | ]), | |
| 906 | ), | |
| 907 | ), | |
| 908 | ); | |
| 909 | ||
| 910 | it.effect("falls back to the gh view list when the GraphQL read has no commits", () => | |
| 911 | Effect.gen(function* () { | |
| 912 | const provider = yield* make; | |
| 913 | const detail = yield* provider.getChangeRequestActivity({ | |
| 914 | cwd: "/w", | |
| 915 | repository: "acme/web", | |
| 916 | host: "github.com", | |
| 917 | number: 7, | |
| 918 | }); | |
| 919 | ||
| 920 | expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]); | |
| 921 | }).pipe(Effect.provide(layerWith([]))), | |
| 922 | ); | |
| 923 | }); | |
| 924 | ||
| 925 | describe("getChangeRequestActivity dismissed reviews", () => { | |
| 926 | const dismissedReview = (body: string) => ({ | |
| 927 | id: "PRR_1", | |
| 928 | kind: "review" as const, | |
| 929 | author: { login: "macroscopeapp", name: null, avatarUrl: null }, | |
| 930 | body, | |
| 931 | createdAt: "2026-07-03T00:00:00Z", | |
| 932 | url: null, | |
| 933 | path: null, | |
| 934 | reviewState: "DISMISSED", | |
| 935 | }); | |
| 936 | const threadComments: GitHubReviewThreadComments = { | |
| 937 | comments: [], | |
| 938 | dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), | |
| 939 | reviewThreads: [], | |
| 940 | commentCount: 0, | |
| 941 | truncated: false, | |
| 942 | reviewThreadsTruncated: false, | |
| 943 | reactions: [], | |
| 944 | reactionsById: new Map(), | |
| 945 | editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]), | |
| 946 | reviewers: [], | |
| 947 | avatarsByLogin: new Map(), | |
| 948 | botLogins: new Set(["macroscopeapp"]), | |
| 949 | commitStats: new Map(), | |
| 950 | commits: [], | |
| 951 | viewer: { canUpdate: true, didAuthor: false }, | |
| 952 | }; | |
| 953 | const layerFor = (body: string) => | |
| 954 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 955 | revalidateChecks: (_input, read) => read, | |
| 956 | getPullRequestActivity: () => | |
| 957 | Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), | |
| 958 | listReviewThreadComments: () => Effect.succeed(threadComments), | |
| 959 | }); | |
| 960 | const readActivity = Effect.gen(function* () { | |
| 961 | const provider = yield* make; | |
| 962 | return yield* provider.getChangeRequestActivity({ | |
| 963 | cwd: "/w", | |
| 964 | repository: "acme/web", | |
| 965 | host: "github.com", | |
| 966 | number: 7, | |
| 967 | }); | |
| 968 | }); | |
| 969 | ||
| 970 | it.effect("fills a marker-only dismissed review with the timeline's reason", () => | |
| 971 | // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — | |
| 972 | // an empty-string check misses them and the card opens onto nothing. | |
| 973 | readActivity.pipe( | |
| 974 | Effect.map((activity) => { | |
| 975 | expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); | |
| 976 | expect(activity.comments[0]?.author?.isBot).toBe(true); | |
| 977 | expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z"); | |
| 978 | }), | |
| 979 | Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")), | |
| 980 | ), | |
| 981 | ); | |
| 982 | ||
| 983 | it.effect("keeps the words of a dismissed review that has its own", () => | |
| 984 | readActivity.pipe( | |
| 985 | Effect.map((activity) => { | |
| 986 | expect(activity.comments[0]?.body).toBe("These findings still stand."); | |
| 987 | }), | |
| 988 | Effect.provide(layerFor("These findings still stand.")), | |
| 989 | ), | |
| 990 | ); | |
| 991 | }); | |
| 992 | ||
| 993 | describe("editing", () => { | |
| 994 | const rewrites: Array<unknown> = []; | |
| 995 | ||
| 996 | it.effect("hands a rewrite to the CLI as the request named it", () => | |
| 997 | Effect.gen(function* () { | |
| 998 | const provider = yield* make; | |
| 999 | ||
| 1000 | expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); | |
| 1001 | yield* provider.updateChangeRequest!({ | |
| 1002 | cwd: "/w", | |
| 1003 | repository: "acme/web", | |
| 1004 | host: "github.com", | |
| 1005 | number: 7, | |
| 1006 | title: "A better title", | |
| 1007 | }); | |
| 1008 | yield* provider.updateComment!({ | |
| 1009 | cwd: "/w", | |
| 1010 | repository: "acme/web", | |
| 1011 | host: "github.com", | |
| 1012 | number: 7, | |
| 1013 | commentId: "IC_1", | |
| 1014 | kind: "review-comment", | |
| 1015 | body: "Reworded.", | |
| 1016 | }); | |
| 1017 | ||
| 1018 | expect(rewrites).toEqual([ | |
| 1019 | { | |
| 1020 | cwd: "/w", | |
| 1021 | repository: "acme/web", | |
| 1022 | host: "github.com", | |
| 1023 | number: 7, | |
| 1024 | title: "A better title", | |
| 1025 | }, | |
| 1026 | { | |
| 1027 | cwd: "/w", | |
| 1028 | repository: "acme/web", | |
| 1029 | host: "github.com", | |
| 1030 | number: 7, | |
| 1031 | commentId: "IC_1", | |
| 1032 | kind: "review-comment", | |
| 1033 | body: "Reworded.", | |
| 1034 | }, | |
| 1035 | ]); | |
| 1036 | }).pipe( | |
| 1037 | Effect.provide( | |
| 1038 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 1039 | revalidateChecks: (_input, read) => read, | |
| 1040 | updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), | |
| 1041 | updateComment: (input) => Effect.sync(() => void rewrites.push(input)), | |
| 1042 | }), | |
| 1043 | ), | |
| 1044 | ), | |
| 1045 | ); | |
| 1046 | }); | |
| 1047 | ||
| 1048 | describe("loginAvatarUrl", () => { | |
| 1049 | it("serves a user's picture from the host they belong to", () => { | |
| 1050 | expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); | |
| 1051 | expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( | |
| 1052 | "https://ghe.example.com/octocat.png?size=80", | |
| 1053 | ); | |
| 1054 | }); | |
| 1055 | ||
| 1056 | it("has nothing for an app, which names no page", () => { | |
| 1057 | // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is | |
| 1058 | // worse than the initials it would replace. | |
| 1059 | expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); | |
| 1060 | }); | |
| 1061 | ||
| 1062 | it("refuses anything that is not a login, rather than building a URL out of it", () => { | |
| 1063 | for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { | |
| 1064 | expect(loginAvatarUrl(login, "github.com")).toBeNull(); | |
| 1065 | } | |
| 1066 | }); | |
| 1067 | }); |
packages/source-control-github/src/server/GitHubPullRequestProvider.ts created+672| ... | ... | @@ -0,0 +1,672 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type { | |
| 3 | PullRequestActor, | |
| 4 | PullRequestCapabilities, | |
| 5 | PullRequestCheck, | |
| 6 | PullRequestReaction, | |
| 7 | PullRequestViewerPermissions, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 12 | import { | |
| 13 | PullRequestProviderError, | |
| 14 | type PullRequestProviderFailure, | |
| 15 | type ProviderChangeRequestActivity, | |
| 16 | type ProviderChangeRequestDetail, | |
| 17 | type PullRequestProviderApi, | |
| 18 | type ProviderRepositoryRef, | |
| 19 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 20 | import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; | |
| 21 | ||
| 22 | const CAPABILITIES: PullRequestCapabilities = { | |
| 23 | diff: true, | |
| 24 | comment: true, | |
| 25 | actions: [ | |
| 26 | "merge", | |
| 27 | "ready", | |
| 28 | "draft", | |
| 29 | "close", | |
| 30 | "reopen", | |
| 31 | "update-branch", | |
| 32 | "enable-auto-merge", | |
| 33 | "disable-auto-merge", | |
| 34 | "revert", | |
| 35 | "approve-workflows", | |
| 36 | ], | |
| 37 | mergeMethods: ["merge", "squash", "rebase"], | |
| 38 | updateMethods: ["merge", "rebase"], | |
| 39 | search: true, | |
| 40 | reactions: true, | |
| 41 | viewedFiles: "host", | |
| 42 | review: { | |
| 43 | inlineComment: true, | |
| 44 | reply: true, | |
| 45 | resolve: true, | |
| 46 | verdicts: ["comment", "approve", "request-changes"], | |
| 47 | }, | |
| 48 | reviewers: { request: true, listCandidates: true }, | |
| 49 | edit: { changeRequest: true, comment: true }, | |
| 50 | stacks: true, | |
| 51 | stackActions: true, | |
| 52 | labels: true, | |
| 53 | }; | |
| 54 | ||
| 55 | /** | |
| 56 | * What the signed-in account may do here, from the three things GitHub says about it. | |
| 57 | * | |
| 58 | * Merging needs a role that can push, which is the one thing a stranger on an open-source | |
| 59 | * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a | |
| 60 | * pull request may close it, reopen it and move it in and out of draft with no more than read | |
| 61 | * access on the repository it was opened against. | |
| 62 | * | |
| 63 | * Commenting and reviewing are not gated at all: read access is enough to say something and | |
| 64 | * enough to approve or ask for changes, which is what open-source review consists of. Resolving a | |
| 65 | * conversation is the exception — GitHub allows it to whoever can write, and to the author of the | |
| 66 | * pull request the conversation is on. | |
| 67 | * | |
| 68 | * Asking somebody else for a review needs write access, which is the one thing here an author | |
| 69 | * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control | |
| 70 | * and refuses the request behind it. | |
| 71 | */ | |
| 72 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { | |
| 73 | return { | |
| 74 | ...(access.canWrite ? { stackRebase: true } : {}), | |
| 75 | actions: [ | |
| 76 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not | |
| 77 | // merge here may not leave an instruction to merge later either. | |
| 78 | ...(access.canWrite | |
| 79 | ? ([ | |
| 80 | "merge", | |
| 81 | "enable-auto-merge", | |
| 82 | "disable-auto-merge", | |
| 83 | "revert", | |
| 84 | "approve-workflows", | |
| 85 | ] as const) | |
| 86 | : []), | |
| 87 | ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), | |
| 88 | // Whether this viewer may update the branch is GitHub's own answer, read with the | |
| 89 | // comparison; without it the action is offered to nobody rather than to everybody. | |
| 90 | ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), | |
| 91 | ], | |
| 92 | comment: true, | |
| 93 | resolve: access.canWrite || access.didAuthor, | |
| 94 | // Anyone may review a pull request they can see, except their own: GitHub refuses an author's | |
| 95 | // approval and their request for changes ("Can not approve your own pull request"), and | |
| 96 | // leaves them commenting, which is what an author has to say about their own change anyway. | |
| 97 | verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, | |
| 98 | requestReviewers: access.canWrite, | |
| 99 | ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), | |
| 100 | // Triage is the one role that labels without writing, which is what triage is for. | |
| 101 | labels: access.canTriage, | |
| 102 | }; | |
| 103 | } | |
| 104 | ||
| 105 | /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ | |
| 106 | export function gitHubProviderFailure( | |
| 107 | error: GitHubPullRequestApi.GitHubPullRequestApiError, | |
| 108 | ): PullRequestProviderFailure { | |
| 109 | switch (error._tag) { | |
| 110 | case "GitHubCliMissingError": | |
| 111 | return { reason: "missing-tool" }; | |
| 112 | case "GitHubNotSignedInError": | |
| 113 | case "GitHubHostDisabledError": | |
| 114 | case "GitHubApiAuthenticationError": | |
| 115 | return { reason: "unauthenticated" }; | |
| 116 | case "GitHubApiRateLimitError": | |
| 117 | return { | |
| 118 | reason: "rate-limited", | |
| 119 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 120 | }; | |
| 121 | case "SourceControlRateLimitPausedError": | |
| 122 | return { reason: "rate-limited", retryAt: error.retryAt }; | |
| 123 | case "GitHubApiNotFoundError": | |
| 124 | return { reason: "not-found" }; | |
| 125 | default: | |
| 126 | return { reason: "failed" }; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are | |
| 132 | * applied here by login. An actor already carrying one keeps it. | |
| 133 | * | |
| 134 | * A login GitHub did not answer for falls back to the picture every GitHub install serves at | |
| 135 | * `/<login>.png`. The lookup is one more request per repository and can be refused — a rate | |
| 136 | * limit, a slow host — and a face that comes and goes between two loads of the same page reads | |
| 137 | * as a bug in the page rather than as a request that failed quietly. | |
| 138 | */ | |
| 139 | function withAvatar( | |
| 140 | actor: PullRequestActor | null, | |
| 141 | avatarsByLogin: ReadonlyMap<string, string>, | |
| 142 | host: string, | |
| 143 | botLogins?: ReadonlySet<string>, | |
| 144 | ): PullRequestActor | null { | |
| 145 | if (actor === null) return actor; | |
| 146 | if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; | |
| 147 | if (actor.avatarUrl !== null) return actor; | |
| 148 | const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); | |
| 149 | return avatarUrl === null ? actor : { ...actor, avatarUrl }; | |
| 150 | } | |
| 151 | ||
| 152 | function withWorkflowApprovals( | |
| 153 | checks: ReadonlyArray<PullRequestCheck>, | |
| 154 | runs: ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 155 | unavailable: boolean, | |
| 156 | ): ReadonlyArray<PullRequestCheck> { | |
| 157 | const representedRunIds = new Set<number>(); | |
| 158 | for (const check of checks) { | |
| 159 | if (check.status !== "action-required" || check.url === null) continue; | |
| 160 | const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; | |
| 161 | if (id !== undefined) representedRunIds.add(Number(id)); | |
| 162 | } | |
| 163 | const approvalChecks = runs | |
| 164 | .filter((run) => !representedRunIds.has(run.id)) | |
| 165 | .map((run): PullRequestCheck => ({ | |
| 166 | name: run.name, | |
| 167 | status: "action-required", | |
| 168 | description: "A maintainer must approve this workflow before it can run.", | |
| 169 | url: run.url, | |
| 170 | })); | |
| 171 | return [ | |
| 172 | ...checks, | |
| 173 | ...approvalChecks, | |
| 174 | ...(unavailable | |
| 175 | ? [ | |
| 176 | { | |
| 177 | name: "Workflow approval status", | |
| 178 | status: "action-required" as const, | |
| 179 | description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 180 | url: null, | |
| 181 | }, | |
| 182 | ] | |
| 183 | : []), | |
| 184 | ]; | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which | |
| 189 | * names no page, and a guessed URL that 404s is worse than the initials it would replace. | |
| 190 | */ | |
| 191 | export function loginAvatarUrl(login: string, host: string): string | null { | |
| 192 | return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; | |
| 193 | } | |
| 194 | ||
| 195 | /** True where markdown would render nothing: whitespace, or only HTML comments. */ | |
| 196 | const rendersEmpty = (body: string): boolean => | |
| 197 | body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0; | |
| 198 | ||
| 199 | export const make = Effect.gen(function* () { | |
| 200 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 201 | ||
| 202 | const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => | |
| 203 | new PullRequestProviderError({ | |
| 204 | provider: "github", | |
| 205 | operation, | |
| 206 | ...gitHubProviderFailure(error), | |
| 207 | detail: error.message, | |
| 208 | cause: error, | |
| 209 | }); | |
| 210 | ||
| 211 | const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) => | |
| 212 | cli.getPullRequestDetail(input).pipe( | |
| 213 | Effect.flatMap((pullRequest) => | |
| 214 | (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true | |
| 215 | ? Effect.succeed({ | |
| 216 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 217 | unavailable: false, | |
| 218 | }) | |
| 219 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null | |
| 220 | ? Effect.succeed({ | |
| 221 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 222 | unavailable: true, | |
| 223 | }) | |
| 224 | : cli | |
| 225 | .listWorkflowRunsRequiringApproval({ | |
| 226 | ...input, | |
| 227 | headSha: pullRequest.headSha, | |
| 228 | headBranch: pullRequest.headBranch, | |
| 229 | headRepositoryOwner: pullRequest.headRepositoryOwner, | |
| 230 | isCrossRepository: true, | |
| 231 | }) | |
| 232 | .pipe( | |
| 233 | Effect.matchEffect({ | |
| 234 | onFailure: (error) => | |
| 235 | error._tag === "GitHubApiRateLimitError" || | |
| 236 | error._tag === "SourceControlRateLimitPausedError" | |
| 237 | ? Effect.fail(error) | |
| 238 | : Effect.succeed({ | |
| 239 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 240 | unavailable: true, | |
| 241 | }), | |
| 242 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), | |
| 243 | }), | |
| 244 | ) | |
| 245 | ).pipe( | |
| 246 | Effect.map((workflowApprovals) => ({ | |
| 247 | ...pullRequest, | |
| 248 | author: withAvatar(pullRequest.author, new Map(), input.host), | |
| 249 | checks: withWorkflowApprovals( | |
| 250 | pullRequest.checks, | |
| 251 | workflowApprovals.runs, | |
| 252 | workflowApprovals.unavailable, | |
| 253 | ), | |
| 254 | ...(workflowApprovals.unavailable | |
| 255 | ? {} | |
| 256 | : { workflowApprovalsRequired: workflowApprovals.runs.length }), | |
| 257 | })), | |
| 258 | ), | |
| 259 | ), | |
| 260 | ); | |
| 261 | ||
| 262 | const provider: PullRequestProviderApi = { | |
| 263 | kind: "github", | |
| 264 | capabilities: CAPABILITIES, | |
| 265 | getRoutingIdentity: (input) => | |
| 266 | cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), | |
| 267 | withVerifiedCredential: (input, use) => | |
| 268 | cli | |
| 269 | .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) | |
| 270 | .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), | |
| 271 | ||
| 272 | getViewer: (input) => | |
| 273 | cli | |
| 274 | .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) | |
| 275 | .pipe(Effect.mapError(fail("getViewer"))), | |
| 276 | ||
| 277 | listChangeRequests: (input) => | |
| 278 | cli | |
| 279 | .listPullRequests({ | |
| 280 | cwd: input.cwd, | |
| 281 | repository: input.repository, | |
| 282 | host: input.host, | |
| 283 | state: input.state, | |
| 284 | involvement: input.involvement, | |
| 285 | viewer: input.viewer, | |
| 286 | limit: input.limit, | |
| 287 | query: input.query, | |
| 288 | cursor: input.cursor, | |
| 289 | filters: input.filters, | |
| 290 | }) | |
| 291 | .pipe( | |
| 292 | Effect.mapError(fail("listChangeRequests")), | |
| 293 | Effect.flatMap((page) => | |
| 294 | cli | |
| 295 | .listActorAvatars({ | |
| 296 | cwd: input.cwd, | |
| 297 | repository: input.repository, | |
| 298 | host: input.host, | |
| 299 | ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], | |
| 300 | }) | |
| 301 | // A listing without faces is still a listing, so a failed lookup falls back to | |
| 302 | // the initials rather than taking the rows down with it. | |
| 303 | .pipe( | |
| 304 | Effect.orElseSucceed(() => new Map<string, string>()), | |
| 305 | Effect.map((avatarsByLogin) => ({ | |
| 306 | ...page, | |
| 307 | items: page.items.map((item) => ({ | |
| 308 | ...item, | |
| 309 | author: withAvatar(item.author, avatarsByLogin, input.host), | |
| 310 | })), | |
| 311 | })), | |
| 312 | ), | |
| 313 | ), | |
| 314 | ), | |
| 315 | ||
| 316 | /** | |
| 317 | * The same listing for a whole host in one search. The avatar lookup the per-repository read | |
| 318 | * needs is not here: a search reports an author's picture itself, so a face costs no request | |
| 319 | * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. | |
| 320 | */ | |
| 321 | listChangeRequestsAcross: (input) => | |
| 322 | cli | |
| 323 | .searchPullRequests({ | |
| 324 | cwd: input.cwd, | |
| 325 | host: input.host, | |
| 326 | repositories: input.repositories, | |
| 327 | state: input.state, | |
| 328 | involvement: input.involvement, | |
| 329 | viewer: input.viewer, | |
| 330 | limit: input.limit, | |
| 331 | query: input.query, | |
| 332 | cursor: input.cursor, | |
| 333 | filters: input.filters, | |
| 334 | }) | |
| 335 | .pipe( | |
| 336 | Effect.mapError(fail("listChangeRequestsAcross")), | |
| 337 | Effect.map((batch) => ({ | |
| 338 | truncated: batch.truncated, | |
| 339 | items: batch.items.map((item) => ({ | |
| 340 | ...item, | |
| 341 | author: withAvatar(item.author, new Map<string, string>(), input.host), | |
| 342 | })), | |
| 343 | })), | |
| 344 | ), | |
| 345 | ||
| 346 | listChangeRequestStats: (input) => | |
| 347 | cli | |
| 348 | .listPullRequestStats({ | |
| 349 | cwd: input.cwd, | |
| 350 | host: input.host, | |
| 351 | changeRequests: input.changeRequests, | |
| 352 | }) | |
| 353 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), | |
| 354 | ||
| 355 | getChangeRequestSummary: (input) => | |
| 356 | cli.getPullRequestSummary(input).pipe( | |
| 357 | // `gh pr view` names the author without an avatar; the login-shaped URL every user | |
| 358 | // has stands in, without the second request the listing spends on it. | |
| 359 | Effect.map((summary) => ({ | |
| 360 | ...summary, | |
| 361 | ...(summary.author === undefined | |
| 362 | ? {} | |
| 363 | : { author: withAvatar(summary.author, new Map(), input.host) }), | |
| 364 | })), | |
| 365 | Effect.mapError(fail("getChangeRequestSummary")), | |
| 366 | ), | |
| 367 | ||
| 368 | getChangeRequestStack: (input) => | |
| 369 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), | |
| 370 | ||
| 371 | getChangeRequestWatchFingerprint: (input) => | |
| 372 | cli | |
| 373 | .getPullRequestWatchFingerprint(input) | |
| 374 | .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), | |
| 375 | ||
| 376 | getChangeRequestPreview: (input) => | |
| 377 | cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), | |
| 378 | ||
| 379 | getChangeRequestChecks: (input) => | |
| 380 | cli.revalidateChecks(input, readChecks(input)).pipe( | |
| 381 | Effect.map(({ state, checks }) => ({ state, checks })), | |
| 382 | Effect.mapError(fail("getChangeRequestChecks")), | |
| 383 | ), | |
| 384 | ||
| 385 | getChangeRequest: (input) => | |
| 386 | readChecks(input).pipe( | |
| 387 | Effect.map((pullRequest): ProviderChangeRequestDetail => ({ | |
| 388 | ...pullRequest, | |
| 389 | author: withAvatar(pullRequest.author, new Map<string, string>(), input.host), | |
| 390 | reviewers: pullRequest.reviewRequestLogins.map((login) => ({ | |
| 391 | login, | |
| 392 | name: null, | |
| 393 | avatarUrl: null, | |
| 394 | })), | |
| 395 | mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, | |
| 396 | viewerPermissions: gitHubViewerPermissions({ | |
| 397 | ...pullRequest.viewerAccess, | |
| 398 | canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, | |
| 399 | }), | |
| 400 | baseComparison: | |
| 401 | pullRequest.comparison === null || pullRequest.comparison.behindBy === null | |
| 402 | ? "unknown" | |
| 403 | : pullRequest.comparison.behindBy > 0 | |
| 404 | ? "behind" | |
| 405 | : "up-to-date", | |
| 406 | ...(pullRequest.comparison?.behindBy == null | |
| 407 | ? {} | |
| 408 | : { behindBy: pullRequest.comparison.behindBy }), | |
| 409 | })), | |
| 410 | Effect.mapError(fail("getChangeRequest")), | |
| 411 | ), | |
| 412 | ||
| 413 | getChangeRequestActivity: (input) => | |
| 414 | Effect.all( | |
| 415 | [ | |
| 416 | cli.getPullRequestActivity(input), | |
| 417 | // Line comments live on review threads, which `gh pr view --json` cannot reach. A | |
| 418 | // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. | |
| 419 | cli.listReviewThreadComments(input).pipe( | |
| 420 | Effect.orElseSucceed(() => ({ | |
| 421 | comments: [], | |
| 422 | dismissalsByReviewId: new Map<string, string>(), | |
| 423 | reactions: [], | |
| 424 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), | |
| 425 | editedAtById: new Map<string, string>(), | |
| 426 | reviewThreads: [], | |
| 427 | commentCount: 0, | |
| 428 | truncated: true, | |
| 429 | reviewThreadsTruncated: true, | |
| 430 | reviewers: [], | |
| 431 | avatarsByLogin: new Map<string, string>(), | |
| 432 | botLogins: new Set<string>(), | |
| 433 | commitStats: new Map< | |
| 434 | string, | |
| 435 | { readonly additions: number; readonly deletions: number } | |
| 436 | >(), | |
| 437 | commits: [], | |
| 438 | viewer: { canUpdate: true, didAuthor: false }, | |
| 439 | })), | |
| 440 | ), | |
| 441 | ], | |
| 442 | { concurrency: 2 }, | |
| 443 | ).pipe( | |
| 444 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 445 | Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ | |
| 446 | author: withAvatar( | |
| 447 | pullRequest.author, | |
| 448 | reviewThreads.avatarsByLogin, | |
| 449 | input.host, | |
| 450 | reviewThreads.botLogins, | |
| 451 | ), | |
| 452 | reviewers: reviewThreads.reviewers, | |
| 453 | reactions: reviewThreads.reactions, | |
| 454 | commits: (reviewThreads.commits.length > 0 | |
| 455 | ? reviewThreads.commits | |
| 456 | : pullRequest.commits | |
| 457 | ).map((commit) => ({ | |
| 458 | ...commit, | |
| 459 | ...reviewThreads.commitStats.get(commit.oid), | |
| 460 | authors: commit.authors?.map( | |
| 461 | (author) => | |
| 462 | withAvatar( | |
| 463 | author, | |
| 464 | reviewThreads.avatarsByLogin, | |
| 465 | input.host, | |
| 466 | reviewThreads.botLogins, | |
| 467 | ) ?? author, | |
| 468 | ), | |
| 469 | })), | |
| 470 | comments: [...pullRequest.comments, ...reviewThreads.comments] | |
| 471 | .map((comment) => ({ | |
| 472 | ...comment, | |
| 473 | // GitHub keeps the dismissal reason on the timeline event, not on the review, | |
| 474 | // so a dismissed review with nothing visible of its own reads its words from | |
| 475 | // there. "Visible" and not "empty": bot reviews often carry only an HTML | |
| 476 | // marker comment, which markdown renders as nothing. | |
| 477 | body: | |
| 478 | comment.kind === "review" && | |
| 479 | comment.reviewState?.toUpperCase() === "DISMISSED" && | |
| 480 | rendersEmpty(comment.body) | |
| 481 | ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) | |
| 482 | : comment.body, | |
| 483 | author: withAvatar( | |
| 484 | comment.author, | |
| 485 | reviewThreads.avatarsByLogin, | |
| 486 | input.host, | |
| 487 | reviewThreads.botLogins, | |
| 488 | ), | |
| 489 | // A comment out of `gh pr view --json` carries none of its own: that read | |
| 490 | // reports no reaction at all, so they arrive from the GraphQL page by node id. | |
| 491 | reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], | |
| 492 | editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, | |
| 493 | })) | |
| 494 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), | |
| 495 | // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two | |
| 496 | // are always whole and only the thread walk can stop short of the host. | |
| 497 | commentCount: pullRequest.comments.length + reviewThreads.commentCount, | |
| 498 | commentsTruncated: reviewThreads.truncated, | |
| 499 | reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, | |
| 500 | reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ | |
| 501 | ...thread, | |
| 502 | comments: thread.comments.map((comment) => ({ | |
| 503 | ...comment, | |
| 504 | author: withAvatar( | |
| 505 | comment.author, | |
| 506 | reviewThreads.avatarsByLogin, | |
| 507 | input.host, | |
| 508 | reviewThreads.botLogins, | |
| 509 | ), | |
| 510 | })), | |
| 511 | })), | |
| 512 | })), | |
| 513 | ), | |
| 514 | ||
| 515 | getReviewThreadComments: (input) => | |
| 516 | cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), | |
| 517 | ||
| 518 | getViewerPermissions: (input) => { | |
| 519 | const lightAccess = cli | |
| 520 | .getViewerAccess({ ...input, allowReserve: true }) | |
| 521 | .pipe(Effect.map((access) => gitHubViewerPermissions(access))); | |
| 522 | if (input.includeUpdateBranch === false) { | |
| 523 | return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); | |
| 524 | } | |
| 525 | // The core detail already carries the viewer's access, the merge settings, and the base | |
| 526 | // comparison, so one read usually answers what used to take three. When that heavier read | |
| 527 | // fails, the light access read still answers, withholding only update-branch. | |
| 528 | return cli.getPullRequestDetail(input).pipe( | |
| 529 | Effect.provideService(GitHubApi.AllowGitHubReserve, true), | |
| 530 | Effect.map((pullRequest) => | |
| 531 | gitHubViewerPermissions({ | |
| 532 | ...pullRequest.viewerAccess, | |
| 533 | canUpdateBranch: | |
| 534 | pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, | |
| 535 | }), | |
| 536 | ), | |
| 537 | Effect.catchIf( | |
| 538 | (error) => | |
| 539 | error._tag !== "GitHubApiRateLimitError" && | |
| 540 | error._tag !== "SourceControlRateLimitPausedError", | |
| 541 | () => lightAccess, | |
| 542 | ), | |
| 543 | Effect.mapError(fail("getViewerPermissions")), | |
| 544 | ); | |
| 545 | }, | |
| 546 | ||
| 547 | getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), | |
| 548 | ||
| 549 | getDiffFileContents: (input) => | |
| 550 | cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), | |
| 551 | ||
| 552 | getFilesViewed: (input) => | |
| 553 | cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), | |
| 554 | ||
| 555 | setFilesViewed: (input) => | |
| 556 | cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), | |
| 557 | ||
| 558 | listReviewerCandidates: (input) => | |
| 559 | cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), | |
| 560 | ||
| 561 | setReviewerRequest: (input) => | |
| 562 | cli | |
| 563 | .setReviewerRequest({ | |
| 564 | cwd: input.cwd, | |
| 565 | repository: input.repository, | |
| 566 | host: input.host, | |
| 567 | number: input.number, | |
| 568 | reviewers: input.reviewers, | |
| 569 | requested: input.requested, | |
| 570 | }) | |
| 571 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 572 | ||
| 573 | listLabelCandidates: (input) => | |
| 574 | cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), | |
| 575 | ||
| 576 | setLabels: (input) => | |
| 577 | cli | |
| 578 | .setLabels({ | |
| 579 | cwd: input.cwd, | |
| 580 | repository: input.repository, | |
| 581 | host: input.host, | |
| 582 | number: input.number, | |
| 583 | labels: input.labels, | |
| 584 | applied: input.applied, | |
| 585 | }) | |
| 586 | .pipe(Effect.mapError(fail("setLabels"))), | |
| 587 | ||
| 588 | runAction: (input) => | |
| 589 | cli | |
| 590 | .runPullRequestAction({ | |
| 591 | cwd: input.cwd, | |
| 592 | repository: input.repository, | |
| 593 | host: input.host, | |
| 594 | number: input.number, | |
| 595 | action: input.action, | |
| 596 | ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), | |
| 597 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | |
| 598 | ...(input.expectedStackHeads === undefined | |
| 599 | ? {} | |
| 600 | : { expectedStackHeads: input.expectedStackHeads }), | |
| 601 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 602 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), | |
| 603 | }) | |
| 604 | .pipe(Effect.mapError(fail("runAction"))), | |
| 605 | ||
| 606 | updateChangeRequest: (input) => | |
| 607 | cli | |
| 608 | .updatePullRequest({ | |
| 609 | cwd: input.cwd, | |
| 610 | repository: input.repository, | |
| 611 | host: input.host, | |
| 612 | number: input.number, | |
| 613 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 614 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 615 | }) | |
| 616 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 617 | ||
| 618 | comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), | |
| 619 | ||
| 620 | updateComment: (input) => | |
| 621 | cli | |
| 622 | .updateComment({ | |
| 623 | cwd: input.cwd, | |
| 624 | repository: input.repository, | |
| 625 | host: input.host, | |
| 626 | number: input.number, | |
| 627 | commentId: input.commentId, | |
| 628 | kind: input.kind, | |
| 629 | body: input.body, | |
| 630 | }) | |
| 631 | .pipe(Effect.mapError(fail("updateComment"))), | |
| 632 | ||
| 633 | submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), | |
| 634 | ||
| 635 | replyToThread: (input) => | |
| 636 | cli | |
| 637 | .replyToReviewThread({ | |
| 638 | cwd: input.cwd, | |
| 639 | repository: input.repository, | |
| 640 | host: input.host, | |
| 641 | threadId: input.threadId, | |
| 642 | body: input.body, | |
| 643 | }) | |
| 644 | .pipe(Effect.mapError(fail("replyToThread"))), | |
| 645 | ||
| 646 | setReaction: (input) => | |
| 647 | cli | |
| 648 | .setReaction({ | |
| 649 | cwd: input.cwd, | |
| 650 | repository: input.repository, | |
| 651 | host: input.host, | |
| 652 | number: input.number, | |
| 653 | ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), | |
| 654 | content: input.content, | |
| 655 | reacted: input.reacted, | |
| 656 | }) | |
| 657 | .pipe(Effect.mapError(fail("setReaction"))), | |
| 658 | ||
| 659 | setThreadResolution: (input) => | |
| 660 | cli | |
| 661 | .setReviewThreadResolution({ | |
| 662 | cwd: input.cwd, | |
| 663 | repository: input.repository, | |
| 664 | host: input.host, | |
| 665 | threadId: input.threadId, | |
| 666 | resolved: input.resolved, | |
| 667 | }) | |
| 668 | .pipe(Effect.mapError(fail("setThreadResolution"))), | |
| 669 | }; | |
| 670 | ||
| 671 | return provider; | |
| 672 | }); |
packages/source-control-github/src/server/GitHubQuota.test.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | ||
| 5 | import * as GitHubQuota from "./GitHubQuota.ts"; | |
| 6 | import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 7 | ||
| 8 | const RESET = Date.parse("2099-08-13T14:00:00Z"); | |
| 9 | const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ | |
| 10 | "x-ratelimit-resource": resource, | |
| 11 | "x-ratelimit-limit": "5000", | |
| 12 | "x-ratelimit-remaining": String(remaining), | |
| 13 | "x-ratelimit-reset": String(reset / 1000), | |
| 14 | }); | |
| 15 | ||
| 16 | describe("GitHubQuota", () => { | |
| 17 | it.effect("refuses a background request below the reserve, per quota and account", () => | |
| 18 | Effect.gen(function* () { | |
| 19 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 20 | yield* quota.observe("github.com", headers(499)); | |
| 21 | const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 22 | assert.strictEqual(refused.retryAt, RESET); | |
| 23 | // Another quota, another host and another account each have their own balance. | |
| 24 | yield* quota.admit("github.com", "core"); | |
| 25 | yield* quota.admit("ghe.example", "graphql"); | |
| 26 | yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); | |
| 27 | yield* quota.admit("github.com", "graphql", { allowReserve: true }); | |
| 28 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 29 | ); | |
| 30 | ||
| 31 | it.effect("takes the latest balance in a window, and ignores an older window", () => | |
| 32 | Effect.gen(function* () { | |
| 33 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 34 | yield* quota.observe("github.com", headers(400)); | |
| 35 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 36 | // GitHub can report more left later in the same window, from another region. | |
| 37 | yield* quota.observe("github.com", headers(900)); | |
| 38 | yield* quota.admit("github.com", "graphql"); | |
| 39 | yield* quota.observe("github.com", headers(400)); | |
| 40 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 41 | // An answer from an older window says nothing about this one. | |
| 42 | yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); | |
| 43 | yield* Effect.flip(quota.admit("github.com", "graphql")); | |
| 44 | // A later window replaces it. | |
| 45 | yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); | |
| 46 | yield* quota.admit("github.com", "graphql"); | |
| 47 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 48 | ); | |
| 49 | ||
| 50 | it.effect("lets interactive requests spend the reserve but not an empty quota", () => | |
| 51 | Effect.gen(function* () { | |
| 52 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 53 | yield* quota.observe("github.com", headers(1)); | |
| 54 | yield* quota.admit("github.com", "graphql", { allowReserve: true }); | |
| 55 | yield* quota.observe("github.com", headers(0)); | |
| 56 | const refused = yield* Effect.flip( | |
| 57 | quota.admit("github.com", "graphql", { allowReserve: true }), | |
| 58 | ); | |
| 59 | assert.strictEqual(refused.retryAt, RESET); | |
| 60 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 61 | ); | |
| 62 | ||
| 63 | it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 66 | yield* quota.observe("github.com", headers(501, "core")); | |
| 67 | // Conditional reads answer 304 with the balance unchanged, however many are sent. | |
| 68 | for (let index = 0; index < 5; index++) { | |
| 69 | yield* quota.admit("github.com", "core"); | |
| 70 | yield* quota.observe("github.com", headers(501, "core")); | |
| 71 | } | |
| 72 | yield* quota.admit("github.com", "core"); | |
| 73 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 74 | ); | |
| 75 | ||
| 76 | it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => | |
| 77 | Effect.gen(function* () { | |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 79 | yield* quota.observe("github.com", headers(0)); | |
| 80 | yield* TestClock.setTime(RESET); | |
| 81 | yield* quota.admit("github.com", "graphql"); | |
| 82 | yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); | |
| 83 | yield* quota.admit("other.example", "graphql"); | |
| 84 | }).pipe(Effect.provide(GitHubQuota.layer)), | |
| 85 | ); | |
| 86 | }); |
packages/source-control-github/src/server/GitHubQuota.ts created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | ||
| 7 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 8 | ||
| 9 | /** The share of each quota a background read leaves for a user's next click. */ | |
| 10 | const RESERVE_RATIO = 0.1; | |
| 11 | ||
| 12 | /** | |
| 13 | * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). | |
| 14 | * Each is spent and reset on its own, so each is tracked on its own. | |
| 15 | */ | |
| 16 | type GitHubQuotaResource = string; | |
| 17 | ||
| 18 | interface QuotaSnapshot { | |
| 19 | readonly limit: number; | |
| 20 | readonly remaining: number; | |
| 21 | readonly resetAtMs: number; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer | |
| 26 | * carries. Null when the answer named no quota, which an Enterprise host with limits off does. | |
| 27 | */ | |
| 28 | function quotaFromHeaders( | |
| 29 | headers: Readonly<Record<string, string | undefined>>, | |
| 30 | ): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { | |
| 31 | const resource = headers["x-ratelimit-resource"]?.trim(); | |
| 32 | const limit = Number(headers["x-ratelimit-limit"]); | |
| 33 | const remaining = Number(headers["x-ratelimit-remaining"]); | |
| 34 | const reset = Number(headers["x-ratelimit-reset"]); | |
| 35 | if ( | |
| 36 | !resource || | |
| 37 | !Number.isFinite(limit) || | |
| 38 | limit <= 0 || | |
| 39 | !Number.isFinite(remaining) || | |
| 40 | remaining < 0 || | |
| 41 | !Number.isFinite(reset) | |
| 42 | ) { | |
| 43 | return null; | |
| 44 | } | |
| 45 | return { resource, limit, remaining, resetAtMs: reset * 1_000 }; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that | |
| 50 | * would dip below it is refused until the quota resets, so a user's next click still has budget. | |
| 51 | * | |
| 52 | * The balance is GitHub's own count from the latest answer's headers, so it already includes | |
| 53 | * what anything else spent with the same token, such as an agent's `gh` commands. Requests in | |
| 54 | * flight together all see that one balance; the reserve absorbs what they spend before their | |
| 55 | * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub | |
| 56 | * spends nothing, and a local count would drift below GitHub's for the rest of the window. | |
| 57 | * | |
| 58 | * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. | |
| 59 | */ | |
| 60 | export class GitHubQuota extends Context.Service< | |
| 61 | GitHubQuota, | |
| 62 | { | |
| 63 | readonly admit: ( | |
| 64 | host: string, | |
| 65 | resource: GitHubQuotaResource, | |
| 66 | options?: { readonly allowReserve: boolean }, | |
| 67 | ) => Effect.Effect<void, SourceControlRateLimit.SourceControlRateLimitPausedError>; | |
| 68 | readonly observe: ( | |
| 69 | host: string, | |
| 70 | headers: Readonly<Record<string, string | undefined>>, | |
| 71 | ) => Effect.Effect<void>; | |
| 72 | } | |
| 73 | >()("@t3tools/source-control-github/server/GitHubQuota") {} | |
| 74 | ||
| 75 | const make = Effect.gen(function* () { | |
| 76 | const snapshots = yield* Ref.make<ReadonlyMap<string, QuotaSnapshot>>(new Map()); | |
| 77 | const keyOf = (host: string, resource: string, scope: string) => | |
| 78 | `${host.trim().toLowerCase()}\0${resource}\0${scope}`; | |
| 79 | ||
| 80 | const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( | |
| 81 | function* (host, resource, options) { | |
| 82 | const now = yield* Clock.currentTimeMillis; | |
| 83 | const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); | |
| 84 | const snapshot = (yield* Ref.get(snapshots)).get(key); | |
| 85 | if (snapshot === undefined || snapshot.resetAtMs <= now) return; | |
| 86 | const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; | |
| 87 | if (snapshot.remaining >= floor) return; | |
| 88 | return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ | |
| 89 | provider: "github", | |
| 90 | host: host.trim().toLowerCase(), | |
| 91 | retryAt: snapshot.resetAtMs, | |
| 92 | }); | |
| 93 | }, | |
| 94 | ); | |
| 95 | ||
| 96 | const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( | |
| 97 | function* (host, headers) { | |
| 98 | const quota = quotaFromHeaders(headers); | |
| 99 | if (quota === null) return; | |
| 100 | const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); | |
| 101 | yield* Ref.update(snapshots, (current) => { | |
| 102 | const previous = current.get(key); | |
| 103 | // The latest answer wins. GitHub serves requests from several regions, so a later answer | |
| 104 | // can report more left in the same window, and keeping the lower one would hold | |
| 105 | // background work at the reserve until the reset. An answer from an older window says | |
| 106 | // nothing of this one. | |
| 107 | if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; | |
| 108 | const next = new Map(current); | |
| 109 | next.set(key, { | |
| 110 | limit: quota.limit, | |
| 111 | remaining: quota.remaining, | |
| 112 | resetAtMs: quota.resetAtMs, | |
| 113 | }); | |
| 114 | return next; | |
| 115 | }); | |
| 116 | }, | |
| 117 | ); | |
| 118 | ||
| 119 | return GitHubQuota.of({ admit, observe }); | |
| 120 | }); | |
| 121 | ||
| 122 | export const layer = Layer.effect(GitHubQuota, make); |
packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts created+1169| ... | ... | @@ -0,0 +1,1169 @@ |
| 1 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | import * as TestClock from "effect/testing/TestClock"; | |
| 12 | ||
| 13 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 14 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 15 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 16 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 17 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | |
| 18 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | |
| 19 | ||
| 20 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 21 | ||
| 22 | const processResult = ( | |
| 23 | stdout: string, | |
| 24 | options?: { | |
| 25 | readonly stderr?: string; | |
| 26 | readonly exitCode?: ChildProcessSpawner.ExitCode; | |
| 27 | }, | |
| 28 | ): SourceControlHost.SourceControlProcessOutput => ({ | |
| 29 | exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), | |
| 30 | stdout, | |
| 31 | stderr: options?.stderr ?? "", | |
| 32 | stdoutTruncated: false, | |
| 33 | stderrTruncated: false, | |
| 34 | }); | |
| 35 | ||
| 36 | const processOutput = ( | |
| 37 | stdout: string, | |
| 38 | exitCode = 0, | |
| 39 | ): SourceControlHost.SourceControlProcessOutput => ({ | |
| 40 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 41 | stdout, | |
| 42 | stderr: "", | |
| 43 | stdoutTruncated: false, | |
| 44 | stderrTruncated: false, | |
| 45 | }); | |
| 46 | ||
| 47 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 48 | entries | |
| 49 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 50 | .join("\n"); | |
| 51 | ||
| 52 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 53 | status, | |
| 54 | headers: {}, | |
| 55 | body: body === undefined ? "" : encodeJson(body), | |
| 56 | truncated: false, | |
| 57 | invalidUtf8: false, | |
| 58 | }); | |
| 59 | ||
| 60 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 61 | number, | |
| 62 | title: `PR ${number}`, | |
| 63 | url: `https://github.com/acme/web/pull/${number}`, | |
| 64 | baseRefName: "main", | |
| 65 | headRefName, | |
| 66 | state: "OPEN", | |
| 67 | isCrossRepository: owner !== "acme", | |
| 68 | updatedAt: "2026-01-02T00:00:00Z", | |
| 69 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 70 | headRepositoryOwner: { login: owner }, | |
| 71 | }); | |
| 72 | ||
| 73 | /** | |
| 74 | * The provider over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 75 | * `git remote -v` prints; `git` records every driver call. | |
| 76 | */ | |
| 77 | function harness(input: { | |
| 78 | readonly remotes: string; | |
| 79 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 80 | readonly localBranches?: ReadonlyArray<string>; | |
| 81 | readonly resolved?: string; | |
| 82 | /** Fails the local branch listing, the first git read a checkout makes. */ | |
| 83 | readonly gitFailure?: unknown; | |
| 84 | }) { | |
| 85 | const git: Array<readonly [string, unknown]> = []; | |
| 86 | const record = | |
| 87 | <A>(name: string, value: A) => | |
| 88 | (args: unknown) => | |
| 89 | Effect.sync(() => { | |
| 90 | git.push([name, args]); | |
| 91 | return value; | |
| 92 | }); | |
| 93 | const hostGit: Partial<SourceControlHost.SourceControlHost["Service"]["git"]> = { | |
| 94 | execute: (args) => | |
| 95 | Effect.sync(() => { | |
| 96 | git.push(["execute", args.args]); | |
| 97 | return processOutput(""); | |
| 98 | }), | |
| 99 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 100 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 101 | ensureRemote: (args) => | |
| 102 | Effect.sync(() => { | |
| 103 | git.push(["ensureRemote", args]); | |
| 104 | return args.preferredName; | |
| 105 | }), | |
| 106 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 107 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 108 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 109 | listLocalBranchNames: () => | |
| 110 | input.gitFailure === undefined | |
| 111 | ? Effect.succeed([...(input.localBranches ?? [])]) | |
| 112 | : Effect.fail(input.gitFailure as never), | |
| 113 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 114 | }; | |
| 115 | const host = TestSourceControlHost.layer({ | |
| 116 | git: hostGit, | |
| 117 | process: { | |
| 118 | run: (args) => | |
| 119 | Effect.succeed( | |
| 120 | args.args[0] === "remote" | |
| 121 | ? processOutput(input.remotes) | |
| 122 | : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), | |
| 123 | ), | |
| 124 | }, | |
| 125 | }); | |
| 126 | const layer = Layer.mergeAll( | |
| 127 | host, | |
| 128 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 129 | NodeServices.layer, | |
| 130 | ); | |
| 131 | return { layer, git }; | |
| 132 | } | |
| 133 | ||
| 134 | /** The provider context for a checkout whose remote is on `host`. */ | |
| 135 | /** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ | |
| 136 | const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) => | |
| 137 | GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); | |
| 138 | ||
| 139 | const githubContext = (host: string) => ({ | |
| 140 | provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, | |
| 141 | remoteName: "origin", | |
| 142 | remoteUrl: `git@${host}:acme/web.git`, | |
| 143 | }); | |
| 144 | ||
| 145 | describe("GitHubSourceControlProvider repository resolution", () => { | |
| 146 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 147 | const paths: string[] = []; | |
| 148 | const { layer } = harness({ | |
| 149 | remotes: remotesOutput( | |
| 150 | ["origin", "git@github.com:me/web.git"], | |
| 151 | ["upstream", "https://github.com/acme/web.git"], | |
| 152 | ), | |
| 153 | api: { | |
| 154 | rest: (input) => | |
| 155 | Effect.sync(() => { | |
| 156 | paths.push(`${input.host} ${input.path}`); | |
| 157 | return restResponse({ | |
| 158 | full_name: "acme/web", | |
| 159 | html_url: "https://github.com/acme/web", | |
| 160 | ssh_url: "git@github.com:acme/web.git", | |
| 161 | default_branch: "trunk", | |
| 162 | }); | |
| 163 | }), | |
| 164 | }, | |
| 165 | }); | |
| 166 | return Effect.gen(function* () { | |
| 167 | const gh = yield* GitHubSourceControlProvider.make; | |
| 168 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 169 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 170 | }).pipe(Effect.provide(layer)); | |
| 171 | }); | |
| 172 | ||
| 173 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 174 | const hosts: string[] = []; | |
| 175 | const { layer } = harness({ | |
| 176 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 177 | api: { | |
| 178 | rest: (input) => | |
| 179 | Effect.sync(() => { | |
| 180 | hosts.push(`${input.host} ${input.path}`); | |
| 181 | return restResponse({ | |
| 182 | full_name: "acme/web", | |
| 183 | html_url: "https://github.com/acme/web", | |
| 184 | ssh_url: "git@github.com:acme/web.git", | |
| 185 | default_branch: "main", | |
| 186 | }); | |
| 187 | }), | |
| 188 | }, | |
| 189 | }); | |
| 190 | return Effect.gen(function* () { | |
| 191 | const gh = yield* GitHubSourceControlProvider.make; | |
| 192 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 193 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 194 | yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); | |
| 195 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 196 | }).pipe(Effect.provide(layer)); | |
| 197 | }); | |
| 198 | ||
| 199 | it.effect("names a missing repository rather than a pull request", () => { | |
| 200 | const { layer } = harness({ | |
| 201 | remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), | |
| 202 | api: { | |
| 203 | rest: (input) => | |
| 204 | Effect.fail( | |
| 205 | new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), | |
| 206 | ), | |
| 207 | }, | |
| 208 | }); | |
| 209 | return Effect.gen(function* () { | |
| 210 | const gh = yield* GitHubSourceControlProvider.make; | |
| 211 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 212 | assert.include(error.detail, "Repository not found"); | |
| 213 | }).pipe(Effect.provide(layer)); | |
| 214 | }); | |
| 215 | ||
| 216 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 217 | const { layer } = harness({ | |
| 218 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 219 | api: {}, | |
| 220 | }); | |
| 221 | return Effect.gen(function* () { | |
| 222 | const gh = yield* GitHubSourceControlProvider.make; | |
| 223 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 224 | assert.strictEqual(error._tag, "SourceControlProviderError"); | |
| 225 | assert.include(error.detail, "No GitHub repository"); | |
| 226 | }).pipe(Effect.provide(layer)); | |
| 227 | }); | |
| 228 | }); | |
| 229 | ||
| 230 | describe("GitHubSourceControlProvider change request lookups by head", () => { | |
| 231 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 232 | ||
| 233 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 234 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 235 | const { layer } = harness({ | |
| 236 | remotes, | |
| 237 | api: { | |
| 238 | graphql: (input) => | |
| 239 | Effect.sync(() => { | |
| 240 | documents.push(input); | |
| 241 | return encodeJson({ | |
| 242 | data: { | |
| 243 | repository: { | |
| 244 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 245 | h1: { nodes: [] }, | |
| 246 | }, | |
| 247 | }, | |
| 248 | }); | |
| 249 | }), | |
| 250 | }, | |
| 251 | }); | |
| 252 | return Effect.gen(function* () { | |
| 253 | const gh = yield* GitHubSourceControlProvider.make; | |
| 254 | const lookup = (headSelector: string) => | |
| 255 | gh | |
| 256 | .listChangeRequests({ | |
| 257 | cwd: "/repo", | |
| 258 | headSelector, | |
| 259 | state: "all", | |
| 260 | limit: 100, | |
| 261 | context: githubContext("github.com"), | |
| 262 | }) | |
| 263 | .pipe(Effect.forkChild); | |
| 264 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 265 | const firstLookup = yield* lookup("feature/a"); | |
| 266 | yield* TestClock.adjust("200 millis"); | |
| 267 | const secondLookup = yield* lookup("feature/b"); | |
| 268 | yield* TestClock.adjust("300 millis"); | |
| 269 | const first = yield* Fiber.join(firstLookup); | |
| 270 | const second = yield* Fiber.join(secondLookup); | |
| 271 | assert.deepStrictEqual( | |
| 272 | first?.map((pr) => pr.number), | |
| 273 | [7], | |
| 274 | ); | |
| 275 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 276 | assert.deepStrictEqual(second, []); | |
| 277 | assert.strictEqual(documents.length, 1); | |
| 278 | assert.include(documents[0]!.query, "headRefOid"); | |
| 279 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 280 | owner: "acme", | |
| 281 | name: "web", | |
| 282 | h0: "feature/a", | |
| 283 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 284 | h1: "feature/b", | |
| 285 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 286 | }); | |
| 287 | }).pipe(Effect.provide(layer)); | |
| 288 | }); | |
| 289 | ||
| 290 | it.effect("caps a background document at twenty-five heads", () => { | |
| 291 | const headCounts: Array<number> = []; | |
| 292 | const { layer } = harness({ | |
| 293 | remotes, | |
| 294 | api: { | |
| 295 | graphql: (input) => | |
| 296 | Effect.sync(() => { | |
| 297 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 298 | headCounts.push(heads.length); | |
| 299 | return encodeJson({ | |
| 300 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 301 | }); | |
| 302 | }), | |
| 303 | }, | |
| 304 | }); | |
| 305 | return Effect.gen(function* () { | |
| 306 | const gh = yield* GitHubSourceControlProvider.make; | |
| 307 | const lookups = yield* Effect.all( | |
| 308 | Array.from({ length: 26 }, (_, index) => | |
| 309 | gh.listChangeRequests({ | |
| 310 | cwd: "/repo", | |
| 311 | headSelector: `feature/${index}`, | |
| 312 | state: "all", | |
| 313 | limit: 100, | |
| 314 | context: githubContext("github.com"), | |
| 315 | }), | |
| 316 | ), | |
| 317 | { concurrency: "unbounded" }, | |
| 318 | ).pipe(Effect.forkChild); | |
| 319 | yield* TestClock.adjust("500 millis"); | |
| 320 | yield* Fiber.join(lookups); | |
| 321 | assert.deepStrictEqual( | |
| 322 | headCounts.toSorted((a, b) => a - b), | |
| 323 | [1, 25], | |
| 324 | ); | |
| 325 | }).pipe(Effect.provide(layer)); | |
| 326 | }); | |
| 327 | ||
| 328 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 329 | const { layer } = harness({ | |
| 330 | remotes, | |
| 331 | api: { | |
| 332 | graphql: (input) => | |
| 333 | Effect.succeed( | |
| 334 | encodeJson({ | |
| 335 | data: { | |
| 336 | repository: { | |
| 337 | h0: { | |
| 338 | nodes: | |
| 339 | input.variables?.h0 === "main" | |
| 340 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 341 | : [], | |
| 342 | }, | |
| 343 | }, | |
| 344 | }, | |
| 345 | }), | |
| 346 | ), | |
| 347 | }, | |
| 348 | }); | |
| 349 | return Effect.gen(function* () { | |
| 350 | const gh = yield* GitHubSourceControlProvider.make; | |
| 351 | const open = yield* gh | |
| 352 | .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) | |
| 353 | .pipe(Effect.forkChild); | |
| 354 | yield* TestClock.adjust("50 millis"); | |
| 355 | assert.deepStrictEqual( | |
| 356 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 357 | [8], | |
| 358 | ); | |
| 359 | }).pipe(Effect.provide(layer)); | |
| 360 | }); | |
| 361 | ||
| 362 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 363 | const { layer } = harness({ | |
| 364 | remotes, | |
| 365 | api: { | |
| 366 | graphql: (input) => | |
| 367 | Effect.fail( | |
| 368 | input.variables?.h0 === "missing" | |
| 369 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 370 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 371 | host: "github.com", | |
| 372 | operation: "x", | |
| 373 | retryAt: 123, | |
| 374 | }), | |
| 375 | ), | |
| 376 | }, | |
| 377 | }); | |
| 378 | return Effect.gen(function* () { | |
| 379 | const gh = yield* GitHubSourceControlProvider.make; | |
| 380 | const read = (headSelector: string) => | |
| 381 | gh | |
| 382 | .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 383 | .pipe(Effect.flip, Effect.forkChild); | |
| 384 | const missing = yield* read("missing"); | |
| 385 | yield* TestClock.adjust("500 millis"); | |
| 386 | assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); | |
| 387 | const limited = yield* read("limited"); | |
| 388 | yield* TestClock.adjust("500 millis"); | |
| 389 | const error = yield* Fiber.join(limited); | |
| 390 | assert.include(error.detail, "rate limit exceeded"); | |
| 391 | assert.propertyVal(error.cause, "retryAt", 123); | |
| 392 | }).pipe(Effect.provide(layer)); | |
| 393 | }); | |
| 394 | }); | |
| 395 | ||
| 396 | describe("GitHubSourceControlProvider.getChangeRequest", () => { | |
| 397 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 398 | const variables: Array<unknown> = []; | |
| 399 | const { layer } = harness({ | |
| 400 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 401 | api: { | |
| 402 | graphql: (input) => | |
| 403 | Effect.sync(() => { | |
| 404 | variables.push(input.variables); | |
| 405 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 406 | }), | |
| 407 | }, | |
| 408 | }); | |
| 409 | return Effect.gen(function* () { | |
| 410 | const gh = yield* GitHubSourceControlProvider.make; | |
| 411 | assert.strictEqual( | |
| 412 | (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, | |
| 413 | 42, | |
| 414 | ); | |
| 415 | yield* gh.getChangeRequest({ | |
| 416 | cwd: "/repo", | |
| 417 | reference: "https://github.com/other/thing/pull/42", | |
| 418 | }); | |
| 419 | assert.deepStrictEqual(variables, [ | |
| 420 | { owner: "acme", name: "web", number: 42 }, | |
| 421 | { owner: "other", name: "thing", number: 42 }, | |
| 422 | ]); | |
| 423 | }).pipe(Effect.provide(layer)); | |
| 424 | }); | |
| 425 | ||
| 426 | it.effect("fails a missing pull request as not found", () => { | |
| 427 | const { layer } = harness({ | |
| 428 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 429 | api: { | |
| 430 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 431 | }, | |
| 432 | }); | |
| 433 | return Effect.gen(function* () { | |
| 434 | const gh = yield* GitHubSourceControlProvider.make; | |
| 435 | const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 436 | assert.include(error.detail, "Pull request not found"); | |
| 437 | }).pipe(Effect.provide(layer)); | |
| 438 | }); | |
| 439 | }); | |
| 440 | ||
| 441 | describe("GitHubSourceControlProvider writes", () => { | |
| 442 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 443 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 444 | const { layer } = harness({ | |
| 445 | remotes: remotesOutput( | |
| 446 | ["origin", "git@github.com:me/web.git"], | |
| 447 | ["upstream", "git@github.com:acme/web.git"], | |
| 448 | ), | |
| 449 | api: { | |
| 450 | rest: (input) => | |
| 451 | Effect.sync(() => { | |
| 452 | requests.push(input); | |
| 453 | return restResponse({ number: 1 }, 201); | |
| 454 | }), | |
| 455 | }, | |
| 456 | }); | |
| 457 | return Effect.gen(function* () { | |
| 458 | const fs = yield* FileSystem.FileSystem; | |
| 459 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 460 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 461 | const gh = yield* GitHubSourceControlProvider.make; | |
| 462 | yield* gh.createChangeRequest({ | |
| 463 | cwd: "/repo", | |
| 464 | baseRefName: "main", | |
| 465 | headSelector: "me:feature", | |
| 466 | title: "Title", | |
| 467 | bodyFile, | |
| 468 | }); | |
| 469 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 470 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 471 | // A user's own write, so the background's reserve is not held against it. | |
| 472 | assert.strictEqual(requests[0]!.allowReserve, true); | |
| 473 | assert.deepStrictEqual(requests[0]!.body, { | |
| 474 | base: "main", | |
| 475 | head: "me:feature", | |
| 476 | title: "Title", | |
| 477 | body: "Body", | |
| 478 | maintainer_can_modify: true, | |
| 479 | }); | |
| 480 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 481 | }); | |
| 482 | ||
| 483 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 484 | const requests: Array<string> = []; | |
| 485 | const { layer } = harness({ | |
| 486 | remotes: "", | |
| 487 | api: { | |
| 488 | rest: (input) => | |
| 489 | Effect.sync(() => { | |
| 490 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 491 | return input.path === "user" | |
| 492 | ? restResponse({ login: "me" }) | |
| 493 | : restResponse({ | |
| 494 | full_name: "acme/new", | |
| 495 | html_url: "https://github.com/acme/new", | |
| 496 | ssh_url: "git@github.com:acme/new.git", | |
| 497 | }); | |
| 498 | }), | |
| 499 | }, | |
| 500 | }); | |
| 501 | return Effect.gen(function* () { | |
| 502 | const gh = yield* GitHubSourceControlProvider.make; | |
| 503 | const urls = yield* gh.createRepository({ | |
| 504 | cwd: "/repo", | |
| 505 | repository: "acme/new", | |
| 506 | visibility: "private", | |
| 507 | }); | |
| 508 | assert.deepStrictEqual(urls, { | |
| 509 | nameWithOwner: "acme/new", | |
| 510 | url: "https://github.com/acme/new", | |
| 511 | sshUrl: "git@github.com:acme/new.git", | |
| 512 | }); | |
| 513 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 514 | }).pipe(Effect.provide(layer)); | |
| 515 | }); | |
| 516 | }); | |
| 517 | ||
| 518 | describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { | |
| 519 | const repository = (fullName: string, defaultBranch = "main") => | |
| 520 | restResponse({ | |
| 521 | full_name: fullName, | |
| 522 | html_url: `https://github.com/${fullName}`, | |
| 523 | ssh_url: `git@github.com:${fullName}.git`, | |
| 524 | default_branch: defaultBranch, | |
| 525 | }); | |
| 526 | ||
| 527 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 528 | const { layer, git } = harness({ | |
| 529 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 530 | api: { | |
| 531 | graphql: () => | |
| 532 | Effect.succeed( | |
| 533 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 534 | ), | |
| 535 | }, | |
| 536 | }); | |
| 537 | return Effect.gen(function* () { | |
| 538 | const gh = yield* GitHubSourceControlProvider.make; | |
| 539 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); | |
| 540 | assert.deepStrictEqual(git, [ | |
| 541 | [ | |
| 542 | "fetchRemoteTrackingBranch", | |
| 543 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 544 | ], | |
| 545 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 546 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 547 | [ | |
| 548 | "setBranchUpstream", | |
| 549 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 550 | ], | |
| 551 | ]); | |
| 552 | }).pipe(Effect.provide(layer)); | |
| 553 | }); | |
| 554 | ||
| 555 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 556 | const { layer, git } = harness({ | |
| 557 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 558 | localBranches: ["main"], | |
| 559 | api: { | |
| 560 | graphql: () => | |
| 561 | Effect.succeed( | |
| 562 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 563 | ), | |
| 564 | rest: () => | |
| 565 | Effect.fail( | |
| 566 | new GitHubApi.GitHubApiRequestError({ | |
| 567 | host: "github.com", | |
| 568 | operation: "x", | |
| 569 | cause: "offline", | |
| 570 | }), | |
| 571 | ), | |
| 572 | }, | |
| 573 | }); | |
| 574 | return Effect.gen(function* () { | |
| 575 | const gh = yield* GitHubSourceControlProvider.make; | |
| 576 | yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 577 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 578 | assert.deepStrictEqual(git, []); | |
| 579 | }).pipe(Effect.provide(layer)); | |
| 580 | }); | |
| 581 | ||
| 582 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 583 | const { layer, git } = harness({ | |
| 584 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 585 | localBranches: ["someone/main"], | |
| 586 | api: { | |
| 587 | graphql: () => | |
| 588 | Effect.succeed( | |
| 589 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 590 | ), | |
| 591 | rest: (input) => | |
| 592 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 593 | }, | |
| 594 | }); | |
| 595 | return Effect.gen(function* () { | |
| 596 | const gh = yield* GitHubSourceControlProvider.make; | |
| 597 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 598 | assert.deepStrictEqual(git, [ | |
| 599 | [ | |
| 600 | "ensureRemote", | |
| 601 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 602 | ], | |
| 603 | [ | |
| 604 | "fetchRemoteTrackingBranch", | |
| 605 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 606 | ], | |
| 607 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 608 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 609 | [ | |
| 610 | "setBranchUpstream", | |
| 611 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 612 | ], | |
| 613 | ]); | |
| 614 | }).pipe(Effect.provide(layer)); | |
| 615 | }); | |
| 616 | ||
| 617 | it.effect("keeps git's own output out of what a failed checkout reports", () => { | |
| 618 | const { layer } = harness({ | |
| 619 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 620 | gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), | |
| 621 | api: { | |
| 622 | graphql: () => | |
| 623 | Effect.succeed( | |
| 624 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 625 | ), | |
| 626 | }, | |
| 627 | }); | |
| 628 | return Effect.gen(function* () { | |
| 629 | const gh = yield* GitHubSourceControlProvider.make; | |
| 630 | const error = yield* gh | |
| 631 | .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) | |
| 632 | .pipe(Effect.flip); | |
| 633 | assert.strictEqual(error.detail, "The pull request could not be checked out with git."); | |
| 634 | assert.notInclude(error.message, "secret-path"); | |
| 635 | }).pipe(Effect.provide(layer)); | |
| 636 | }); | |
| 637 | ||
| 638 | it("names the local branch the way gh pr checkout does", () => { | |
| 639 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 640 | GitHubSourceControlProvider.pullRequestCheckoutBranchName({ | |
| 641 | headRefName, | |
| 642 | headOwner: "someone", | |
| 643 | isCrossRepository, | |
| 644 | defaultBranch: "main", | |
| 645 | }); | |
| 646 | assert.strictEqual(name("main", true), "someone/main"); | |
| 647 | assert.strictEqual(name("feature", true), "feature"); | |
| 648 | assert.strictEqual(name("main", false), "main"); | |
| 649 | }); | |
| 650 | }); | |
| 651 | ||
| 652 | it("accepts active authenticated GitHub accounts when another account fails", () => { | |
| 653 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 654 | processResult( | |
| 655 | JSON.stringify({ | |
| 656 | hosts: { | |
| 657 | "github.com": [ | |
| 658 | { | |
| 659 | state: "success", | |
| 660 | active: true, | |
| 661 | host: "github.com", | |
| 662 | login: "active-user", | |
| 663 | tokenSource: "keyring", | |
| 664 | gitProtocol: "ssh", | |
| 665 | }, | |
| 666 | { | |
| 667 | state: "error", | |
| 668 | active: false, | |
| 669 | host: "github.com", | |
| 670 | login: "stale-user", | |
| 671 | tokenSource: "keyring", | |
| 672 | gitProtocol: "ssh", | |
| 673 | error: "The token in keyring is invalid.", | |
| 674 | }, | |
| 675 | ], | |
| 676 | }, | |
| 677 | }), | |
| 678 | ), | |
| 679 | ); | |
| 680 | ||
| 681 | assert.deepStrictEqual( | |
| 682 | { | |
| 683 | status: auth.status, | |
| 684 | account: auth.account, | |
| 685 | host: auth.host, | |
| 686 | }, | |
| 687 | { | |
| 688 | status: "authenticated", | |
| 689 | account: Option.some("active-user"), | |
| 690 | host: Option.some("github.com"), | |
| 691 | }, | |
| 692 | ); | |
| 693 | }); | |
| 694 | ||
| 695 | it("parses GitHub auth JSON from stdout when stderr has warnings", () => { | |
| 696 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 697 | processResult( | |
| 698 | JSON.stringify({ | |
| 699 | hosts: { | |
| 700 | "github.com": [ | |
| 701 | { | |
| 702 | state: "success", | |
| 703 | active: true, | |
| 704 | host: "github.com", | |
| 705 | login: "active-user", | |
| 706 | tokenSource: "keyring", | |
| 707 | gitProtocol: "ssh", | |
| 708 | }, | |
| 709 | ], | |
| 710 | }, | |
| 711 | }), | |
| 712 | { stderr: "warning: ignored diagnostic from gh\n" }, | |
| 713 | ), | |
| 714 | ); | |
| 715 | ||
| 716 | assert.deepStrictEqual( | |
| 717 | { | |
| 718 | status: auth.status, | |
| 719 | account: auth.account, | |
| 720 | host: auth.host, | |
| 721 | }, | |
| 722 | { | |
| 723 | status: "authenticated", | |
| 724 | account: Option.some("active-user"), | |
| 725 | host: Option.some("github.com"), | |
| 726 | }, | |
| 727 | ); | |
| 728 | }); | |
| 729 | ||
| 730 | it("parses GitHub auth status accounts by host and active state", () => { | |
| 731 | assert.deepStrictEqual( | |
| 732 | parseGitHubAuthStatus( | |
| 733 | JSON.stringify({ | |
| 734 | hosts: { | |
| 735 | "github.com": [ | |
| 736 | { | |
| 737 | state: "success", | |
| 738 | active: true, | |
| 739 | host: "github.com", | |
| 740 | login: "active-user", | |
| 741 | tokenSource: "keyring", | |
| 742 | gitProtocol: "ssh", | |
| 743 | }, | |
| 744 | { | |
| 745 | state: "error", | |
| 746 | active: false, | |
| 747 | host: "github.com", | |
| 748 | login: "stale-user", | |
| 749 | tokenSource: "keyring", | |
| 750 | gitProtocol: "ssh", | |
| 751 | }, | |
| 752 | ], | |
| 753 | "github.example.test": [ | |
| 754 | { | |
| 755 | state: "success", | |
| 756 | active: false, | |
| 757 | host: "github.example.test", | |
| 758 | login: "enterprise-user", | |
| 759 | tokenSource: "keyring", | |
| 760 | gitProtocol: "ssh", | |
| 761 | }, | |
| 762 | ], | |
| 763 | }, | |
| 764 | }), | |
| 765 | ).accounts, | |
| 766 | [ | |
| 767 | { | |
| 768 | host: "github.com", | |
| 769 | account: "active-user", | |
| 770 | authenticated: true, | |
| 771 | active: true, | |
| 772 | error: null, | |
| 773 | environmentVariable: null, | |
| 774 | }, | |
| 775 | { | |
| 776 | host: "github.com", | |
| 777 | account: "stale-user", | |
| 778 | authenticated: false, | |
| 779 | active: false, | |
| 780 | error: null, | |
| 781 | environmentVariable: null, | |
| 782 | }, | |
| 783 | { | |
| 784 | host: "github.example.test", | |
| 785 | account: "enterprise-user", | |
| 786 | authenticated: true, | |
| 787 | active: false, | |
| 788 | error: null, | |
| 789 | environmentVariable: null, | |
| 790 | }, | |
| 791 | ], | |
| 792 | ); | |
| 793 | }); | |
| 794 | ||
| 795 | it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { | |
| 796 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 797 | processResult( | |
| 798 | JSON.stringify({ | |
| 799 | hosts: { | |
| 800 | "github.com": [ | |
| 801 | { | |
| 802 | state: "error", | |
| 803 | active: true, | |
| 804 | host: "github.com", | |
| 805 | login: "stale-user", | |
| 806 | tokenSource: "keyring", | |
| 807 | gitProtocol: "ssh", | |
| 808 | error: "The token in keyring is invalid.", | |
| 809 | }, | |
| 810 | ], | |
| 811 | }, | |
| 812 | }), | |
| 813 | ), | |
| 814 | ); | |
| 815 | ||
| 816 | assert.deepStrictEqual( | |
| 817 | { | |
| 818 | status: auth.status, | |
| 819 | host: auth.host, | |
| 820 | detail: auth.detail, | |
| 821 | }, | |
| 822 | { | |
| 823 | status: "unauthenticated", | |
| 824 | host: Option.some("github.com"), | |
| 825 | detail: Option.some("The token in keyring is invalid."), | |
| 826 | }, | |
| 827 | ); | |
| 828 | }); | |
| 829 | ||
| 830 | it("reports an update hint instead of unauthenticated when gh predates --json", () => { | |
| 831 | const auth = GitHubSourceControlProvider.discovery.parseAuth( | |
| 832 | processResult("", { | |
| 833 | stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]\n", | |
| 834 | exitCode: ChildProcessSpawner.ExitCode(1), | |
| 835 | }), | |
| 836 | ); | |
| 837 | ||
| 838 | assert.strictEqual(auth.status, "unknown"); | |
| 839 | assert.match( | |
| 840 | Option.getOrElse(auth.detail, () => ""), | |
| 841 | /2\.81\.0/, | |
| 842 | ); | |
| 843 | }); | |
| 844 | ||
| 845 | it.effect.each(["pull", "issues"])( | |
| 846 | "resolves %s subjects on the linked host without using the checkout", | |
| 847 | (kind) => | |
| 848 | Effect.gen(function* () { | |
| 849 | const provider = yield* makeProvider({ | |
| 850 | rest: (input) => { | |
| 851 | assert.strictEqual(input.host, "github.com"); | |
| 852 | assert.strictEqual(input.path, "repos/owner/repo/issues/42"); | |
| 853 | return Effect.succeed( | |
| 854 | restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), | |
| 855 | ); | |
| 856 | }, | |
| 857 | }); | |
| 858 | const lookup = provider.resolveLink?.({ | |
| 859 | cwd: "/unrelated", | |
| 860 | url: new URL(`https://github.com/owner/repo/${kind}/42`), | |
| 861 | }); | |
| 862 | assert.ok(lookup); | |
| 863 | assert.deepStrictEqual(yield* lookup, { | |
| 864 | title: "Pairing expiry", | |
| 865 | body: "Preserve remote access", | |
| 866 | }); | |
| 867 | assert.strictEqual( | |
| 868 | provider.resolveLink?.({ | |
| 869 | cwd: "/unrelated", | |
| 870 | url: new URL("https://github.com/owner/repo"), | |
| 871 | }), | |
| 872 | undefined, | |
| 873 | ); | |
| 874 | }), | |
| 875 | ); | |
| 876 | ||
| 877 | it.effect.each(["read", "decode"] as const)( | |
| 878 | "retains the %s failure without exposing its raw contents", | |
| 879 | (stage) => | |
| 880 | Effect.gen(function* () { | |
| 881 | const cause = new GitHubApi.GitHubApiResponseError({ | |
| 882 | host: "github.com", | |
| 883 | operation: "resolveLink", | |
| 884 | status: 500, | |
| 885 | }); | |
| 886 | const provider = yield* makeProvider({ | |
| 887 | rest: () => | |
| 888 | stage === "read" | |
| 889 | ? Effect.fail(cause) | |
| 890 | : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), | |
| 891 | }); | |
| 892 | const lookup = provider.resolveLink?.({ | |
| 893 | cwd: "/repo", | |
| 894 | url: new URL("https://github.com/owner/repo/issues/42"), | |
| 895 | }); | |
| 896 | assert.ok(lookup); | |
| 897 | const error = yield* Effect.flip(lookup); | |
| 898 | assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); | |
| 899 | assert.strictEqual(error.detail, "The linked subject could not be read."); | |
| 900 | assert.notInclude(error.message, "private response text"); | |
| 901 | if (stage === "read") assert.strictEqual(error.cause, cause); | |
| 902 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); | |
| 903 | }), | |
| 904 | ); | |
| 905 | ||
| 906 | const multiAccountStatus = (extra: ReadonlyArray<Record<string, unknown>> = []) => | |
| 907 | processResult( | |
| 908 | JSON.stringify({ | |
| 909 | hosts: { | |
| 910 | "github.com": [ | |
| 911 | { state: "success", active: true, host: "github.com", login: "personal" }, | |
| 912 | { state: "success", active: false, host: "github.com", login: "work" }, | |
| 913 | ...extra, | |
| 914 | ], | |
| 915 | "ghe.acme.test": [ | |
| 916 | { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" }, | |
| 917 | ], | |
| 918 | }, | |
| 919 | }), | |
| 920 | ); | |
| 921 | ||
| 922 | it("reports every gh login and leads with the account Settings pin", () => { | |
| 923 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 924 | hosts: { "github.com": { account: "work", enabled: true } }, | |
| 925 | tokens: {}, | |
| 926 | }); | |
| 927 | assert.deepStrictEqual(auth.account, Option.some("work")); | |
| 928 | assert.deepStrictEqual(auth.accounts, [ | |
| 929 | { host: "github.com", account: "personal", active: true, authenticated: true }, | |
| 930 | { host: "github.com", account: "work", active: false, authenticated: true }, | |
| 931 | { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" }, | |
| 932 | ]); | |
| 933 | }); | |
| 934 | ||
| 935 | it("falls back to gh's active login when the pinned account is gone", () => { | |
| 936 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 937 | hosts: { "github.com": { account: "former-job", enabled: true } }, | |
| 938 | tokens: {}, | |
| 939 | }); | |
| 940 | assert.deepStrictEqual(auth.account, Option.some("personal")); | |
| 941 | }); | |
| 942 | ||
| 943 | it("reports unauthenticated when Settings turn off every signed-in host", () => { | |
| 944 | const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { | |
| 945 | hosts: { "github.com": { enabled: false } }, | |
| 946 | tokens: {}, | |
| 947 | }); | |
| 948 | assert.strictEqual(auth.status, "unauthenticated"); | |
| 949 | assert.deepStrictEqual( | |
| 950 | auth.detail, | |
| 951 | Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."), | |
| 952 | ); | |
| 953 | }); | |
| 954 | ||
| 955 | it("names the environment token that overrides the Settings choice", () => { | |
| 956 | const auth = GitHubSourceControlProvider.parseGitHubAuth( | |
| 957 | multiAccountStatus([ | |
| 958 | { | |
| 959 | state: "success", | |
| 960 | active: false, | |
| 961 | host: "github.com", | |
| 962 | login: "bot", | |
| 963 | tokenSource: "GH_TOKEN", | |
| 964 | }, | |
| 965 | ]), | |
| 966 | { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, | |
| 967 | ); | |
| 968 | assert.deepStrictEqual(auth.account, Option.some("bot")); | |
| 969 | assert.deepStrictEqual( | |
| 970 | auth.detail, | |
| 971 | Option.some( | |
| 972 | "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.", | |
| 973 | ), | |
| 974 | ); | |
| 975 | assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); | |
| 976 | }); | |
| 977 | ||
| 978 | it.live.each([ | |
| 979 | "git@code.example.test:team/project.git", | |
| 980 | "ssh://git@code.example.test/team/project.git", | |
| 981 | "https://code.example.test/team/project.git", | |
| 982 | ])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => { | |
| 983 | const graphql: GitHubApi.GitHubGraphQlInput[] = []; | |
| 984 | const rest: GitHubApi.GitHubRestInput[] = []; | |
| 985 | const api = { | |
| 986 | graphql: (input: GitHubApi.GitHubGraphQlInput) => | |
| 987 | Effect.sync(() => { | |
| 988 | graphql.push(input); | |
| 989 | const pr = { | |
| 990 | ...node(42, "feature", "team"), | |
| 991 | url: "https://code.example.test/team/project/pull/42", | |
| 992 | isCrossRepository: false, | |
| 993 | headRepository: { name: "project", nameWithOwner: "team/project" }, | |
| 994 | }; | |
| 995 | return encodeJson({ | |
| 996 | data: { | |
| 997 | repository: | |
| 998 | input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } }, | |
| 999 | }, | |
| 1000 | }); | |
| 1001 | }), | |
| 1002 | rest: (input: GitHubApi.GitHubRestInput) => | |
| 1003 | Effect.sync(() => { | |
| 1004 | rest.push(input); | |
| 1005 | return restResponse({ | |
| 1006 | full_name: "team/project", | |
| 1007 | html_url: `https://${input.host}/team/project`, | |
| 1008 | ssh_url: `git@${input.host}:team/project.git`, | |
| 1009 | default_branch: "main", | |
| 1010 | }); | |
| 1011 | }), | |
| 1012 | }; | |
| 1013 | const { layer, git } = harness({ | |
| 1014 | remotes: remotesOutput( | |
| 1015 | ["origin", "git@github.com:team/project.git"], | |
| 1016 | ["enterprise", remoteUrl], | |
| 1017 | ), | |
| 1018 | api, | |
| 1019 | }); | |
| 1020 | return Effect.gen(function* () { | |
| 1021 | const provider = yield* GitHubSourceControlProvider.make; | |
| 1022 | const fs = yield* FileSystem.FileSystem; | |
| 1023 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 1024 | yield* fs.writeFileString(bodyFile, "Enterprise PR"); | |
| 1025 | const input = { | |
| 1026 | cwd: "/repo", | |
| 1027 | context: { | |
| 1028 | provider: { | |
| 1029 | kind: "github" as const, | |
| 1030 | name: "GitHub Self-Hosted", | |
| 1031 | baseUrl: "https://code.example.test", | |
| 1032 | }, | |
| 1033 | remoteName: "enterprise", | |
| 1034 | remoteUrl, | |
| 1035 | }, | |
| 1036 | }; | |
| 1037 | for (const state of ["open", "closed"] as const) { | |
| 1038 | const prs = yield* provider.listChangeRequests({ | |
| 1039 | ...input, | |
| 1040 | state, | |
| 1041 | headSelector: "feature", | |
| 1042 | }); | |
| 1043 | assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42"); | |
| 1044 | } | |
| 1045 | yield* provider.getChangeRequest({ ...input, reference: "42" }); | |
| 1046 | yield* provider.getChangeRequest({ ...input, reference: "feature" }); | |
| 1047 | yield* provider.createChangeRequest({ | |
| 1048 | ...input, | |
| 1049 | baseRefName: "main", | |
| 1050 | headSelector: "feature", | |
| 1051 | title: "PR", | |
| 1052 | bodyFile, | |
| 1053 | }); | |
| 1054 | assert.strictEqual(yield* provider.getDefaultBranch(input), "main"); | |
| 1055 | yield* provider.checkoutChangeRequest({ ...input, reference: "42" }); | |
| 1056 | yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" }); | |
| 1057 | yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" }); | |
| 1058 | assert.strictEqual(graphql.length, 5); | |
| 1059 | for (const request of graphql) { | |
| 1060 | assert.strictEqual(request.host, "code.example.test"); | |
| 1061 | assert.strictEqual(request.variables?.owner, "team"); | |
| 1062 | assert.strictEqual(request.variables?.name, "project"); | |
| 1063 | } | |
| 1064 | assert.deepStrictEqual( | |
| 1065 | rest.map(({ host, path }) => [host, path]), | |
| 1066 | [ | |
| 1067 | ["code.example.test", "repos/team/project/pulls"], | |
| 1068 | ["code.example.test", "repos/team/project"], | |
| 1069 | ["code.example.test", "repos/team/other"], | |
| 1070 | ["github.com", "repos/team/explicit"], | |
| 1071 | ], | |
| 1072 | ); | |
| 1073 | assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], { | |
| 1074 | cwd: "/repo", | |
| 1075 | remoteName: "enterprise", | |
| 1076 | remoteBranch: "feature", | |
| 1077 | }); | |
| 1078 | }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped); | |
| 1079 | }); | |
| 1080 | ||
| 1081 | it.effect.each([ | |
| 1082 | { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" }, | |
| 1083 | { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" }, | |
| 1084 | { | |
| 1085 | host: "github.com", | |
| 1086 | resolved: "remote.origin.gh-resolved base", | |
| 1087 | envRepository: "", | |
| 1088 | expected: "me/web", | |
| 1089 | }, | |
| 1090 | { | |
| 1091 | host: "code.example.test", | |
| 1092 | resolved: "remote.origin.gh-resolved base", | |
| 1093 | envRepository: "", | |
| 1094 | expected: "me/web", | |
| 1095 | }, | |
| 1096 | { | |
| 1097 | host: "github.com", | |
| 1098 | resolved: "remote.upstream.gh-resolved acme/other", | |
| 1099 | envRepository: "", | |
| 1100 | expected: "acme/other", | |
| 1101 | }, | |
| 1102 | { | |
| 1103 | host: "code.example.test", | |
| 1104 | resolved: "remote.upstream.gh-resolved acme/other", | |
| 1105 | envRepository: "", | |
| 1106 | expected: "acme/other", | |
| 1107 | }, | |
| 1108 | { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" }, | |
| 1109 | { | |
| 1110 | host: "code.example.test", | |
| 1111 | resolved: "", | |
| 1112 | envRepository: "chosen/repo", | |
| 1113 | expected: "chosen/repo", | |
| 1114 | }, | |
| 1115 | ])( | |
| 1116 | "preserves repository precedence on $host: $resolved $envRepository", | |
| 1117 | ({ host, resolved, envRepository, expected }) => { | |
| 1118 | const targets: string[] = []; | |
| 1119 | const { layer } = harness({ | |
| 1120 | remotes: remotesOutput( | |
| 1121 | ["origin", `git@${host}:me/web.git`], | |
| 1122 | ["upstream", `git@${host}:acme/web.git`], | |
| 1123 | ), | |
| 1124 | resolved, | |
| 1125 | api: { | |
| 1126 | rest: (input) => | |
| 1127 | Effect.sync(() => { | |
| 1128 | targets.push(`${input.host}/${input.path}`); | |
| 1129 | return restResponse({ | |
| 1130 | full_name: expected, | |
| 1131 | html_url: `https://${host}/${expected}`, | |
| 1132 | ssh_url: `git@${host}:${expected}.git`, | |
| 1133 | default_branch: "main", | |
| 1134 | }); | |
| 1135 | }), | |
| 1136 | }, | |
| 1137 | }); | |
| 1138 | return Effect.gen(function* () { | |
| 1139 | const provider = yield* GitHubSourceControlProvider.make; | |
| 1140 | const fs = yield* FileSystem.FileSystem; | |
| 1141 | const bodyFile = yield* fs.makeTempFileScoped(); | |
| 1142 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 1143 | const input = { | |
| 1144 | cwd: "/repo", | |
| 1145 | context: { | |
| 1146 | provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, | |
| 1147 | remoteName: "origin", | |
| 1148 | remoteUrl: `git@${host}:me/web.git`, | |
| 1149 | }, | |
| 1150 | }; | |
| 1151 | yield* provider.getDefaultBranch(input); | |
| 1152 | yield* provider.createChangeRequest({ | |
| 1153 | ...input, | |
| 1154 | baseRefName: "main", | |
| 1155 | headSelector: "me:feature", | |
| 1156 | title: "PR", | |
| 1157 | bodyFile, | |
| 1158 | }); | |
| 1159 | assert.deepStrictEqual(targets, [ | |
| 1160 | `${host}/repos/${expected}`, | |
| 1161 | `${host}/repos/${expected}/pulls`, | |
| 1162 | ]); | |
| 1163 | }).pipe( | |
| 1164 | Effect.provide(layer), | |
| 1165 | Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }), | |
| 1166 | Effect.scoped, | |
| 1167 | ); | |
| 1168 | }, | |
| 1169 | ); |
packages/source-control-github/src/server/GitHubSourceControlProvider.ts created+1075| ... | ... | @@ -0,0 +1,1075 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Data from "effect/Data"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Request from "effect/Request"; | |
| 8 | import * as RequestResolver from "effect/RequestResolver"; | |
| 9 | import * as Result from "effect/Result"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { | |
| 12 | DEFAULT_SERVER_SETTINGS, | |
| 13 | SourceControlProviderError, | |
| 14 | TrimmedNonEmptyString, | |
| 15 | type ChangeRequest, | |
| 16 | type GitHubSettings, | |
| 17 | type SourceControlProviderDiscoveryItem, | |
| 18 | type SourceControlRepositoryCloneUrls, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 21 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 22 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 23 | import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; | |
| 24 | ||
| 25 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 26 | import { | |
| 27 | decodeGitHubPullRequestEntries, | |
| 28 | type NormalizedGitHubPullRequestRecord, | |
| 29 | } from "./gitHubPullRequests.ts"; | |
| 30 | import { | |
| 31 | gitHubApiHostForRemote, | |
| 32 | parseFetchRemotes, | |
| 33 | parseGitHubRepositorySelector, | |
| 34 | parsePullRequestReference, | |
| 35 | resolveGitHubRepository, | |
| 36 | type GitHubRepositoryLocator, | |
| 37 | } from "./gitHubRepositoryResolution.ts"; | |
| 38 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 39 | import { | |
| 40 | effectiveGitHubAccount, | |
| 41 | findAuthenticatedGitHubAccount, | |
| 42 | parseGitHubAuthStatus, | |
| 43 | type GitHubAuthStatusAccount, | |
| 44 | } from "./gitHubAuthStatus.ts"; | |
| 45 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 46 | import { | |
| 47 | combinedAuthOutput, | |
| 48 | firstSafeAuthLine, | |
| 49 | probeSourceControlProvider, | |
| 50 | providerAuth, | |
| 51 | type SourceControlAuthProbeInput, | |
| 52 | type SourceControlCliDiscoverySpec, | |
| 53 | type SourceControlManagedCliDiscoverySpec, | |
| 54 | } from "@t3tools/source-control-core/server/discovery"; | |
| 55 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 56 | ||
| 57 | const decodeLinkSubject = Schema.decodeUnknownEffect( | |
| 58 | Schema.fromJsonString( | |
| 59 | Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 60 | ), | |
| 61 | ); | |
| 62 | ||
| 63 | function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { | |
| 64 | return { provider: "github", ...record }; | |
| 65 | } | |
| 66 | ||
| 67 | function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) { | |
| 68 | return accounts.map((entry) => ({ | |
| 69 | host: entry.host, | |
| 70 | account: entry.account, | |
| 71 | active: entry.active, | |
| 72 | authenticated: entry.authenticated, | |
| 73 | ...(entry.error === null ? {} : { error: entry.error }), | |
| 74 | ...(entry.environmentVariable === null | |
| 75 | ? {} | |
| 76 | : { environmentVariable: entry.environmentVariable }), | |
| 77 | })); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will | |
| 82 | * use: Settings can pin a login per host or turn a host off, and an environment token beats both. | |
| 83 | */ | |
| 84 | export function parseGitHubAuth( | |
| 85 | input: SourceControlAuthProbeInput, | |
| 86 | settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, | |
| 87 | ) { | |
| 88 | const output = combinedAuthOutput(input); | |
| 89 | const authStatus = parseGitHubAuthStatus(input.stdout); | |
| 90 | const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; | |
| 91 | const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); | |
| 92 | // Lead with the host gh would pick, unless Settings turned it off. | |
| 93 | const orderedHosts = fallback | |
| 94 | ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] | |
| 95 | : hosts; | |
| 96 | const chosen = orderedHosts | |
| 97 | .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) | |
| 98 | .find((entry) => entry !== undefined); | |
| 99 | const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; | |
| 100 | ||
| 101 | if (chosen) { | |
| 102 | return { | |
| 103 | ...providerAuth({ | |
| 104 | status: "authenticated", | |
| 105 | account: chosen.account, | |
| 106 | host: chosen.host, | |
| 107 | detail: | |
| 108 | chosen.environmentVariable === null | |
| 109 | ? undefined | |
| 110 | : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, | |
| 111 | }), | |
| 112 | ...accounts, | |
| 113 | }; | |
| 114 | } | |
| 115 | ||
| 116 | if (fallback) { | |
| 117 | return { | |
| 118 | ...providerAuth({ | |
| 119 | status: "unauthenticated", | |
| 120 | host: fallback.host, | |
| 121 | detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", | |
| 122 | }), | |
| 123 | ...accounts, | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; | |
| 128 | if (authStatus.parsed) { | |
| 129 | return { | |
| 130 | ...providerAuth({ | |
| 131 | status: "unauthenticated", | |
| 132 | host: failedAccount?.host, | |
| 133 | detail: | |
| 134 | failedAccount?.error ?? | |
| 135 | "Run `gh auth login` to authenticate GitHub CLI with an active account.", | |
| 136 | }), | |
| 137 | ...accounts, | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit | |
| 142 | // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. | |
| 143 | if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { | |
| 144 | return providerAuth({ | |
| 145 | status: "unknown", | |
| 146 | detail: | |
| 147 | "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", | |
| 148 | }); | |
| 149 | } | |
| 150 | ||
| 151 | if (input.exitCode !== 0) { | |
| 152 | return providerAuth({ | |
| 153 | status: "unauthenticated", | |
| 154 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", | |
| 155 | }); | |
| 156 | } | |
| 157 | ||
| 158 | return providerAuth({ | |
| 159 | status: "unknown", | |
| 160 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | export const discovery = { | |
| 165 | type: "cli", | |
| 166 | kind: "github", | |
| 167 | label: "GitHub", | |
| 168 | executable: "gh", | |
| 169 | versionArgs: ["--version"], | |
| 170 | authArgs: ["auth", "status", "--json", "hosts"], | |
| 171 | parseAuth: parseGitHubAuth, | |
| 172 | installHint: | |
| 173 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", | |
| 174 | } satisfies SourceControlCliDiscoverySpec; | |
| 175 | ||
| 176 | const decodeViewer = Schema.decodeUnknownOption( | |
| 177 | Schema.fromJsonString(Schema.Struct({ login: Schema.String })), | |
| 178 | ); | |
| 179 | ||
| 180 | /** The environment variable gh would take a github.com token from, if one is set. */ | |
| 181 | function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { | |
| 182 | return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one | |
| 187 | * over. Reads the | |
| 188 | * GitHub settings on every probe, so a saved account choice shows on rescan. An environment | |
| 189 | * token is checked against the API, since `gh auth status` may not know it. | |
| 190 | */ | |
| 191 | export const makeDiscovery = Effect.gen(function* () { | |
| 192 | const api = yield* GitHubApi.GitHubApi; | |
| 193 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; | |
| 194 | const process = sourceControlHost.process; | |
| 195 | const environment = yield* HostProcessEnvironment; | |
| 196 | ||
| 197 | return { | |
| 198 | type: "managed-cli", | |
| 199 | kind: discovery.kind, | |
| 200 | label: discovery.label, | |
| 201 | installHint: discovery.installHint, | |
| 202 | probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { | |
| 203 | const settings = yield* sourceControlHost.settings.get.pipe( | |
| 204 | Effect.map((current) => current.github), | |
| 205 | Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), | |
| 206 | ); | |
| 207 | const cli = yield* probeSourceControlProvider({ | |
| 208 | cwd, | |
| 209 | process, | |
| 210 | spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, | |
| 211 | }); | |
| 212 | // A token saved in Settings wins over the environment, which wins over gh. | |
| 213 | const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; | |
| 214 | const variable = environmentTokenVariable(environment); | |
| 215 | const tokenSource = savedToken ? "the token saved in Settings" : variable; | |
| 216 | // A host turned off in Settings stays off even with a token. | |
| 217 | if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; | |
| 218 | const viewer = yield* api | |
| 219 | .rest({ host: "github.com", operation: "discovery", path: "user" }) | |
| 220 | .pipe(Effect.result); | |
| 221 | const login = Result.isSuccess(viewer) | |
| 222 | ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login | |
| 223 | : undefined; | |
| 224 | // The per-host logins stay, so the account picker still lists every host gh knows. | |
| 225 | const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; | |
| 226 | return { | |
| 227 | ...cli, | |
| 228 | status: "available" as const, | |
| 229 | auth: { | |
| 230 | ...(login !== undefined | |
| 231 | ? providerAuth({ | |
| 232 | status: "authenticated", | |
| 233 | account: login, | |
| 234 | host: "github.com", | |
| 235 | detail: savedToken | |
| 236 | ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." | |
| 237 | : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, | |
| 238 | }) | |
| 239 | : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" | |
| 240 | ? // Only a refusal says the token is bad; a network error or a pause says nothing. | |
| 241 | providerAuth({ | |
| 242 | status: "unknown", | |
| 243 | host: "github.com", | |
| 244 | detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, | |
| 245 | }) | |
| 246 | : providerAuth({ | |
| 247 | status: "unauthenticated", | |
| 248 | host: "github.com", | |
| 249 | detail: savedToken | |
| 250 | ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." | |
| 251 | : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, | |
| 252 | })), | |
| 253 | ...accounts, | |
| 254 | }, | |
| 255 | } satisfies SourceControlProviderDiscoveryItem; | |
| 256 | }), | |
| 257 | /** | |
| 258 | * Claims a custom host GitHub holds a credential for, from whichever source supplies it | |
| 259 | * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its | |
| 260 | * error says so instead of "unsupported host". | |
| 261 | */ | |
| 262 | refineUnknownRemote: ({ context }) => { | |
| 263 | // Identity resolution also probes providers before a web base URL is known. | |
| 264 | const url = URL.parse(context.provider.baseUrl); | |
| 265 | if (!url?.host) return Effect.succeed(null); | |
| 266 | return api.credential(url.host).pipe( | |
| 267 | Effect.as(true), | |
| 268 | Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), | |
| 269 | Effect.orElseSucceed(() => false), | |
| 270 | Effect.map((known) => | |
| 271 | known | |
| 272 | ? ({ | |
| 273 | kind: "github", | |
| 274 | name: "GitHub Self-Hosted", | |
| 275 | baseUrl: context.provider.baseUrl, | |
| 276 | } as const) | |
| 277 | : null, | |
| 278 | ), | |
| 279 | ); | |
| 280 | }, | |
| 281 | } satisfies SourceControlManagedCliDiscoverySpec; | |
| 282 | }); | |
| 283 | ||
| 284 | /** | |
| 285 | * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the | |
| 286 | * operation and request context; `cause` keeps the raw failure for logs, never for transport. | |
| 287 | */ | |
| 288 | class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ | |
| 289 | readonly detail: string; | |
| 290 | readonly cause: unknown; | |
| 291 | }> {} | |
| 292 | ||
| 293 | const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); | |
| 294 | ||
| 295 | const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; | |
| 296 | const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; | |
| 297 | ||
| 298 | /** `notFound` names what was missing, which only the operation knows. */ | |
| 299 | function fromGitHubApiError( | |
| 300 | error: GitHubApi.GitHubApiError, | |
| 301 | notFound = PULL_REQUEST_NOT_FOUND, | |
| 302 | ): GitHubFailure { | |
| 303 | switch (error._tag) { | |
| 304 | case "GitHubCliMissingError": | |
| 305 | return failure( | |
| 306 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 307 | error, | |
| 308 | ); | |
| 309 | case "GitHubNotSignedInError": | |
| 310 | case "GitHubHostDisabledError": | |
| 311 | // A missing or turned-off credential already says what to do about it. | |
| 312 | return failure(error.message, error); | |
| 313 | case "GitHubApiAuthenticationError": | |
| 314 | return failure( | |
| 315 | "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", | |
| 316 | error, | |
| 317 | ); | |
| 318 | case "GitHubApiRateLimitError": | |
| 319 | case "SourceControlRateLimitPausedError": | |
| 320 | return failure( | |
| 321 | "GitHub API rate limit exceeded. Requests resume when the limit resets.", | |
| 322 | error, | |
| 323 | ); | |
| 324 | case "GitHubApiNotFoundError": | |
| 325 | return failure(notFound, error); | |
| 326 | case "GitHubCliFailedError": | |
| 327 | case "GitHubApiResponseError": | |
| 328 | case "GitHubApiRequestError": | |
| 329 | // GitHub's own reason ("A pull request already exists…") when it gave one. | |
| 330 | return failure(error.message.trim() || "GitHub request failed.", error); | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); | |
| 335 | ||
| 336 | const RawRepositorySchema = Schema.Struct({ | |
| 337 | full_name: TrimmedNonEmptyString, | |
| 338 | html_url: TrimmedNonEmptyString, | |
| 339 | ssh_url: TrimmedNonEmptyString, | |
| 340 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 341 | }); | |
| 342 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 343 | ||
| 344 | function repositoryCloneUrls( | |
| 345 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 346 | ): SourceControlRepositoryCloneUrls { | |
| 347 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 348 | } | |
| 349 | ||
| 350 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 351 | ||
| 352 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 353 | ||
| 354 | /** The pull request fields every read selects, in GraphQL. */ | |
| 355 | const PULL_REQUEST_NODE_SELECTION = | |
| 356 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 357 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 358 | open: ["OPEN"], | |
| 359 | closed: ["CLOSED"], | |
| 360 | merged: ["MERGED"], | |
| 361 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 362 | }; | |
| 363 | /** | |
| 364 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 365 | * single lookup, but half that keeps each answer near half a second. | |
| 366 | */ | |
| 367 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 368 | /** | |
| 369 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 370 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 371 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 372 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 373 | */ | |
| 374 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 375 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 376 | /** | |
| 377 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 378 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 379 | * processing limit; twenty-five took ~7s. | |
| 380 | */ | |
| 381 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 382 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 383 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 384 | /** | |
| 385 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 386 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 387 | * top because the newest come first. | |
| 388 | */ | |
| 389 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 390 | ||
| 391 | class PullRequestsByHeadRead extends Request.Class< | |
| 392 | { | |
| 393 | readonly host: string; | |
| 394 | readonly owner: string; | |
| 395 | readonly name: string; | |
| 396 | readonly headRefName: string; | |
| 397 | readonly state: PullRequestListState; | |
| 398 | readonly limit: number; | |
| 399 | readonly allowReserve: boolean; | |
| 400 | }, | |
| 401 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 402 | GitHubFailure | |
| 403 | > {} | |
| 404 | ||
| 405 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 406 | function buildPullRequestsByHeadQuery( | |
| 407 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 408 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 409 | const variables: Record<string, unknown> = {}; | |
| 410 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 411 | const selections: string[] = []; | |
| 412 | for (const [index, lookup] of lookups.entries()) { | |
| 413 | variables[`h${index}`] = lookup.headRefName; | |
| 414 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 415 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 416 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 417 | // same rows. | |
| 418 | selections.push( | |
| 419 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 420 | ); | |
| 421 | } | |
| 422 | return { | |
| 423 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 424 | variables, | |
| 425 | }; | |
| 426 | } | |
| 427 | ||
| 428 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 429 | repository(owner: $owner, name: $name) { | |
| 430 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 431 | } | |
| 432 | }`; | |
| 433 | ||
| 434 | const decodePullRequestsByHead = decodeJsonResult( | |
| 435 | Schema.Struct({ | |
| 436 | data: Schema.Struct({ | |
| 437 | repository: Schema.NullOr( | |
| 438 | Schema.Record( | |
| 439 | Schema.String, | |
| 440 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 441 | ), | |
| 442 | ), | |
| 443 | }), | |
| 444 | }), | |
| 445 | ); | |
| 446 | ||
| 447 | const decodePullRequestByNumber = decodeJsonResult( | |
| 448 | Schema.Struct({ | |
| 449 | data: Schema.Struct({ | |
| 450 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 451 | }), | |
| 452 | }), | |
| 453 | ); | |
| 454 | ||
| 455 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 456 | export function pullRequestCheckoutBranchName(input: { | |
| 457 | readonly headRefName: string; | |
| 458 | readonly headOwner: string | null; | |
| 459 | readonly isCrossRepository: boolean; | |
| 460 | readonly defaultBranch: string | null; | |
| 461 | }): string { | |
| 462 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 463 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 464 | return input.isCrossRepository && | |
| 465 | input.headOwner !== null && | |
| 466 | input.defaultBranch !== null && | |
| 467 | input.headRefName === input.defaultBranch | |
| 468 | ? `${input.headOwner}/${input.headRefName}` | |
| 469 | : input.headRefName; | |
| 470 | } | |
| 471 | ||
| 472 | /** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ | |
| 473 | const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => | |
| 474 | context === undefined ? undefined : new URL(context.provider.baseUrl).host; | |
| 475 | ||
| 476 | export const make = Effect.gen(function* () { | |
| 477 | const api = yield* GitHubApi.GitHubApi; | |
| 478 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; | |
| 479 | const process = sourceControlHost.process; | |
| 480 | const environment = yield* HostProcessEnvironment; | |
| 481 | const git = sourceControlHost.git; | |
| 482 | const fileSystem = yield* FileSystem.FileSystem; | |
| 483 | ||
| 484 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 485 | process.run({ | |
| 486 | operation: "GitHubSourceControlProvider.resolveRepository", | |
| 487 | command: "git", | |
| 488 | args, | |
| 489 | cwd, | |
| 490 | allowNonZeroExit: true, | |
| 491 | timeoutMs: 5_000, | |
| 492 | }); | |
| 493 | ||
| 494 | /** | |
| 495 | * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is | |
| 496 | * a fallback when the checkout's remotes cannot resolve that repository. | |
| 497 | */ | |
| 498 | const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( | |
| 499 | function* (input: { | |
| 500 | readonly cwd: string; | |
| 501 | readonly host?: string | undefined; | |
| 502 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 503 | }) { | |
| 504 | const envRepository = environment.GH_REPO?.trim(); | |
| 505 | const hostHint = input.host ?? contextHost(input.context); | |
| 506 | const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 507 | if (envRepository) { | |
| 508 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 509 | if (locator !== null) return locator; | |
| 510 | } | |
| 511 | const [remotes, resolved] = yield* Effect.all([ | |
| 512 | gitRead(input.cwd, ["remote", "-v"]), | |
| 513 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 514 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 515 | const { host, locator } = resolveGitHubRepository({ | |
| 516 | remotes: remotes?.exitCode === 0 ? remotes.stdout : "", | |
| 517 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 518 | hostHint, | |
| 519 | defaultHost, | |
| 520 | }); | |
| 521 | if (locator !== null) return locator; | |
| 522 | if (input.context) { | |
| 523 | const remote = normalizeGitRemoteUrl(input.context.remoteUrl); | |
| 524 | const fallback = parseGitHubRepositorySelector( | |
| 525 | `${host}/${remote.slice(remote.indexOf("/") + 1)}`, | |
| 526 | host, | |
| 527 | ); | |
| 528 | if (fallback !== null) return fallback; | |
| 529 | } | |
| 530 | return yield* failure( | |
| 531 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 532 | ); | |
| 533 | }, | |
| 534 | ); | |
| 535 | ||
| 536 | const graphqlJson = <A>( | |
| 537 | input: GitHubApi.GitHubGraphQlInput, | |
| 538 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 539 | decodeDetail: string, | |
| 540 | ) => | |
| 541 | api.graphql(input).pipe( | |
| 542 | Effect.mapError(fromGitHubApiError), | |
| 543 | Effect.flatMap((raw) => { | |
| 544 | const decoded = decode(raw); | |
| 545 | return Result.isSuccess(decoded) | |
| 546 | ? Effect.succeed(decoded.success) | |
| 547 | : Effect.fail(failure(decodeDetail, decoded.failure)); | |
| 548 | }), | |
| 549 | ); | |
| 550 | ||
| 551 | const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => | |
| 552 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); | |
| 553 | ||
| 554 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 555 | key: ({ request, context }) => | |
| 556 | [ | |
| 557 | request.host, | |
| 558 | request.owner, | |
| 559 | request.name, | |
| 560 | String(request.allowReserve), | |
| 561 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 562 | ?.credentialFingerprint ?? "", | |
| 563 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 564 | ].join("\0"), | |
| 565 | resolver: (entries) => { | |
| 566 | const [first] = entries; | |
| 567 | const { host, owner, name, allowReserve } = first.request; | |
| 568 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); | |
| 569 | return graphqlJson( | |
| 570 | { | |
| 571 | host, | |
| 572 | operation: "listPullRequestsByHead", | |
| 573 | query: query.document, | |
| 574 | variables: { owner, name, ...query.variables }, | |
| 575 | allowReserve, | |
| 576 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 577 | // fork pull requests, so the usual cap would cut the answer short. | |
| 578 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 579 | }, | |
| 580 | decodePullRequestsByHead, | |
| 581 | "GitHub returned an invalid change request list.", | |
| 582 | ).pipe( | |
| 583 | Effect.flatMap((decoded) => | |
| 584 | decoded.data.repository === null | |
| 585 | ? Effect.fail(notFound("The repository could not be read.")) | |
| 586 | : Effect.succeed(decoded.data.repository), | |
| 587 | ), | |
| 588 | Effect.map((aliases) => { | |
| 589 | for (const [index, entry] of entries.entries()) { | |
| 590 | const alias = aliases[`h${index}`]; | |
| 591 | entry.completeUnsafe( | |
| 592 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 593 | ); | |
| 594 | } | |
| 595 | }), | |
| 596 | Effect.catchCause((cause) => | |
| 597 | Effect.sync(() => { | |
| 598 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 599 | }), | |
| 600 | ), | |
| 601 | ); | |
| 602 | }, | |
| 603 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 604 | const interactiveHeadResolver = headResolver.pipe( | |
| 605 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 606 | ); | |
| 607 | const backgroundHeadResolver = headResolver.pipe( | |
| 608 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 609 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 610 | ); | |
| 611 | ||
| 612 | /** | |
| 613 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 614 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together | |
| 615 | * share one GraphQL document. | |
| 616 | */ | |
| 617 | const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { | |
| 618 | readonly cwd: string; | |
| 619 | readonly headSelector: string; | |
| 620 | readonly state: PullRequestListState; | |
| 621 | readonly limit: number; | |
| 622 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 623 | readonly host?: string | undefined; | |
| 624 | readonly allowReserve: boolean; | |
| 625 | }) { | |
| 626 | const locator = yield* resolveRepository(input); | |
| 627 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 628 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 629 | // owner is matched on the rows it returns. | |
| 630 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 631 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 632 | const rows = yield* Effect.request( | |
| 633 | new PullRequestsByHeadRead({ | |
| 634 | ...locator, | |
| 635 | headRefName, | |
| 636 | state: input.state, | |
| 637 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 638 | allowReserve: input.allowReserve, | |
| 639 | }), | |
| 640 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 641 | ); | |
| 642 | if (!ownerMatch) return rows; | |
| 643 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 644 | return rows | |
| 645 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 646 | .slice(0, limit); | |
| 647 | }); | |
| 648 | ||
| 649 | const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( | |
| 650 | function* (input: { | |
| 651 | readonly cwd: string; | |
| 652 | readonly reference: string; | |
| 653 | readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; | |
| 654 | readonly host?: string | undefined; | |
| 655 | }) { | |
| 656 | const parsed = parsePullRequestReference(input.reference); | |
| 657 | if (parsed.kind === "branch") { | |
| 658 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 659 | const lookup = (state: PullRequestListState) => | |
| 660 | listByHead({ | |
| 661 | cwd: input.cwd, | |
| 662 | headSelector: parsed.headSelector, | |
| 663 | state, | |
| 664 | limit: 1, | |
| 665 | host: input.host, | |
| 666 | context: input.context, | |
| 667 | allowReserve: true, | |
| 668 | }); | |
| 669 | const [open] = yield* lookup("open"); | |
| 670 | const found = open ?? (yield* lookup("all"))[0]; | |
| 671 | if (found === undefined) return yield* notFound("No pull request has this head branch."); | |
| 672 | return found; | |
| 673 | } | |
| 674 | const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); | |
| 675 | const decodeDetail = "GitHub returned an invalid pull request."; | |
| 676 | const decoded = yield* graphqlJson( | |
| 677 | { | |
| 678 | host: locator.host, | |
| 679 | operation: "getPullRequest", | |
| 680 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 681 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 682 | allowReserve: true, | |
| 683 | }, | |
| 684 | decodePullRequestByNumber, | |
| 685 | decodeDetail, | |
| 686 | ); | |
| 687 | const node = decoded.data.repository?.pullRequest; | |
| 688 | if (node == null) return yield* notFound("The pull request does not exist."); | |
| 689 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 690 | if (record === undefined) | |
| 691 | return yield* failure(decodeDetail, new Error("Malformed pull request.")); | |
| 692 | return record; | |
| 693 | }, | |
| 694 | ); | |
| 695 | ||
| 696 | const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( | |
| 697 | locator: GitHubRepositoryLocator, | |
| 698 | ) { | |
| 699 | const response = yield* rest( | |
| 700 | { | |
| 701 | host: locator.host, | |
| 702 | operation: "getRepository", | |
| 703 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 704 | allowReserve: true, | |
| 705 | }, | |
| 706 | REPOSITORY_NOT_FOUND, | |
| 707 | ); | |
| 708 | const decoded = decodeRawRepository(response.body); | |
| 709 | if (Result.isFailure(decoded)) { | |
| 710 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 711 | } | |
| 712 | return decoded.success; | |
| 713 | }); | |
| 714 | ||
| 715 | const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( | |
| 716 | host: string, | |
| 717 | ) { | |
| 718 | const response = yield* rest({ | |
| 719 | host, | |
| 720 | operation: "getViewer", | |
| 721 | path: "user", | |
| 722 | allowReserve: true, | |
| 723 | }); | |
| 724 | const decoded = decodeViewerLogin(response.body); | |
| 725 | if (Result.isFailure(decoded)) { | |
| 726 | return yield* failure("GitHub request failed.", decoded.failure); | |
| 727 | } | |
| 728 | return decoded.success.login; | |
| 729 | }); | |
| 730 | ||
| 731 | // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client | |
| 732 | // sees is fixed and the raw error stays in `cause`. | |
| 733 | const gitFailure = (cause: unknown) => | |
| 734 | failure("The pull request could not be checked out with git.", cause); | |
| 735 | ||
| 736 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 737 | git.execute({ | |
| 738 | operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, | |
| 739 | cwd, | |
| 740 | args, | |
| 741 | }); | |
| 742 | ||
| 743 | /** | |
| 744 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 745 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 746 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 747 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 748 | */ | |
| 749 | const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( | |
| 750 | function* (input: { | |
| 751 | readonly cwd: string; | |
| 752 | readonly reference: string; | |
| 753 | readonly force?: boolean; | |
| 754 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 755 | }) { | |
| 756 | const reference = parsePullRequestReference(input.reference); | |
| 757 | const pullRequest = yield* readPullRequest(input); | |
| 758 | const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); | |
| 759 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 760 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 761 | const isCrossRepository = | |
| 762 | pullRequest.isCrossRepository ?? | |
| 763 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 764 | const headOwner = | |
| 765 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 766 | ||
| 767 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 768 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 769 | Effect.mapError(gitFailure), | |
| 770 | ); | |
| 771 | const remoteFor = (nameWithOwner: string) => | |
| 772 | parseFetchRemotes(remotes).find( | |
| 773 | (remote) => | |
| 774 | (gitHubApiHostForRemote(remote.url) ?? | |
| 775 | normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && | |
| 776 | normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === | |
| 777 | nameWithOwner.toLowerCase(), | |
| 778 | )?.name ?? null; | |
| 779 | const baseRemote = Effect.suspend(() => { | |
| 780 | const known = remoteFor(baseNameWithOwner); | |
| 781 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 782 | }); | |
| 783 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 784 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 785 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 786 | const defaultBranch = isCrossRepository | |
| 787 | ? yield* readRepository(base).pipe( | |
| 788 | Effect.map((repository) => repository.default_branch ?? null), | |
| 789 | ) | |
| 790 | : null; | |
| 791 | const localBranch = pullRequestCheckoutBranchName({ | |
| 792 | headRefName: pullRequest.headRefName, | |
| 793 | headOwner, | |
| 794 | isCrossRepository, | |
| 795 | defaultBranch, | |
| 796 | }); | |
| 797 | ||
| 798 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 799 | const headRemote = Effect.gen(function* () { | |
| 800 | if (!isCrossRepository) return yield* baseRemote; | |
| 801 | if (headNameWithOwner === null) return yield* failure("The fork is gone."); | |
| 802 | const known = remoteFor(headNameWithOwner); | |
| 803 | if (known !== null) return known; | |
| 804 | const [owner, name] = headNameWithOwner.split("/"); | |
| 805 | const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); | |
| 806 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 807 | return yield* git.ensureRemote({ | |
| 808 | cwd: input.cwd, | |
| 809 | preferredName: headOwner ?? "fork", | |
| 810 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 811 | }); | |
| 812 | }); | |
| 813 | ||
| 814 | const exists = (yield* git | |
| 815 | .listLocalBranchNames(input.cwd) | |
| 816 | .pipe(Effect.mapError(gitFailure))).includes(localBranch); | |
| 817 | ||
| 818 | // The commit to check out, fetched from the head branch where it still exists. | |
| 819 | const target = yield* Effect.gen(function* () { | |
| 820 | const remoteName = yield* headRemote; | |
| 821 | yield* git.fetchRemoteTrackingBranch({ | |
| 822 | cwd: input.cwd, | |
| 823 | remoteName, | |
| 824 | remoteBranch: pullRequest.headRefName, | |
| 825 | }); | |
| 826 | return { | |
| 827 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 828 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 829 | }; | |
| 830 | }).pipe( | |
| 831 | Effect.catch(() => | |
| 832 | Effect.gen(function* () { | |
| 833 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 834 | "fetch", | |
| 835 | "--quiet", | |
| 836 | "--no-tags", | |
| 837 | yield* baseRemote, | |
| 838 | `refs/pull/${pullRequest.number}/head`, | |
| 839 | ]); | |
| 840 | const { commitSha } = yield* git.resolveCommit({ | |
| 841 | cwd: input.cwd, | |
| 842 | revision: "FETCH_HEAD", | |
| 843 | }); | |
| 844 | return { ref: commitSha, upstream: null }; | |
| 845 | }), | |
| 846 | ), | |
| 847 | Effect.mapError(gitFailure), | |
| 848 | ); | |
| 849 | ||
| 850 | yield* Effect.gen(function* () { | |
| 851 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 852 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 853 | if (exists) { | |
| 854 | yield* runGit( | |
| 855 | input.cwd, | |
| 856 | "sync", | |
| 857 | input.force === true | |
| 858 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 859 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 860 | ); | |
| 861 | } | |
| 862 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 863 | // existing branch's upstream as it was. | |
| 864 | if (target.upstream !== null) { | |
| 865 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 866 | } | |
| 867 | }).pipe(Effect.mapError(gitFailure)); | |
| 868 | }, | |
| 869 | ); | |
| 870 | ||
| 871 | /** Reports a failure against the operation and request that made it, with transport-safe context. */ | |
| 872 | const providerError = | |
| 873 | ( | |
| 874 | operation: string, | |
| 875 | cwd: string, | |
| 876 | context?: { readonly reference?: string; readonly repository?: string }, | |
| 877 | ) => | |
| 878 | (error: GitHubFailure) => | |
| 879 | new SourceControlProviderError({ | |
| 880 | provider: "github", | |
| 881 | operation, | |
| 882 | cwd, | |
| 883 | ...(context?.reference === undefined | |
| 884 | ? {} | |
| 885 | : { | |
| 886 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 887 | context.reference, | |
| 888 | ), | |
| 889 | }), | |
| 890 | ...(context?.repository === undefined | |
| 891 | ? {} | |
| 892 | : { | |
| 893 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 894 | context.repository, | |
| 895 | ), | |
| 896 | }), | |
| 897 | detail: error.detail, | |
| 898 | cause: error.cause, | |
| 899 | }); | |
| 900 | ||
| 901 | const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( | |
| 902 | input: { readonly cwd: string; readonly url: URL }, | |
| 903 | endpoint: string, | |
| 904 | ) { | |
| 905 | const result = yield* api | |
| 906 | .rest({ | |
| 907 | host: input.url.host, | |
| 908 | operation: "resolveLink", | |
| 909 | path: endpoint, | |
| 910 | maxResponseBytes: 1_000_000, | |
| 911 | }) | |
| 912 | .pipe( | |
| 913 | Effect.timeout("3 seconds"), | |
| 914 | Effect.mapError( | |
| 915 | (cause) => | |
| 916 | new SourceControlProviderError({ | |
| 917 | provider: "github", | |
| 918 | operation: "resolveLink", | |
| 919 | cwd: input.cwd, | |
| 920 | detail: "The linked subject could not be read.", | |
| 921 | cause, | |
| 922 | }), | |
| 923 | ), | |
| 924 | ); | |
| 925 | const subject = yield* decodeLinkSubject(result.body).pipe( | |
| 926 | Effect.mapError( | |
| 927 | (cause) => | |
| 928 | new SourceControlProviderError({ | |
| 929 | provider: "github", | |
| 930 | operation: "resolveLink.decode", | |
| 931 | cwd: input.cwd, | |
| 932 | detail: "The linked subject could not be read.", | |
| 933 | cause, | |
| 934 | }), | |
| 935 | ), | |
| 936 | ); | |
| 937 | return { title: subject.title, body: subject.body ?? null }; | |
| 938 | }); | |
| 939 | ||
| 940 | return SourceControlProvider.SourceControlProvider.of({ | |
| 941 | kind: "github", | |
| 942 | resolveLink: (input) => { | |
| 943 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. | |
| 944 | if (input.url.host !== "github.com") return undefined; | |
| 945 | const match = /^\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( | |
| 946 | input.url.pathname, | |
| 947 | ); | |
| 948 | if (!match) return undefined; | |
| 949 | return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); | |
| 950 | }, | |
| 951 | listChangeRequests: (input) => | |
| 952 | // An open lookup is a user waiting on a status; the rest may be a background sweep. | |
| 953 | (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( | |
| 954 | Effect.flatMap((allowReserve) => | |
| 955 | listByHead({ | |
| 956 | cwd: input.cwd, | |
| 957 | headSelector: input.headSelector, | |
| 958 | state: input.state, | |
| 959 | limit: input.limit ?? (input.state === "open" ? 1 : 20), | |
| 960 | host: contextHost(input.context), | |
| 961 | context: input.context, | |
| 962 | allowReserve, | |
| 963 | }), | |
| 964 | ), | |
| 965 | Effect.map((records) => records.map(toChangeRequest)), | |
| 966 | Effect.mapError( | |
| 967 | providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), | |
| 968 | ), | |
| 969 | ), | |
| 970 | getChangeRequest: (input) => | |
| 971 | readPullRequest({ ...input, host: contextHost(input.context) }).pipe( | |
| 972 | Effect.map(toChangeRequest), | |
| 973 | Effect.mapError( | |
| 974 | providerError("getChangeRequest", input.cwd, { reference: input.reference }), | |
| 975 | ), | |
| 976 | ), | |
| 977 | createChangeRequest: (input) => | |
| 978 | Effect.gen(function* () { | |
| 979 | const locator = yield* resolveRepository(input); | |
| 980 | const body = yield* fileSystem | |
| 981 | .readFileString(input.bodyFile) | |
| 982 | .pipe( | |
| 983 | Effect.mapError((cause) => | |
| 984 | failure("The pull request description could not be read.", cause), | |
| 985 | ), | |
| 986 | ); | |
| 987 | yield* rest({ | |
| 988 | host: locator.host, | |
| 989 | operation: "createPullRequest", | |
| 990 | method: "POST", | |
| 991 | // A user's own write may spend the reserve the background leaves for it. | |
| 992 | allowReserve: true, | |
| 993 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 994 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 995 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 996 | body: { | |
| 997 | base: input.baseRefName, | |
| 998 | head: input.headSelector, | |
| 999 | title: input.title, | |
| 1000 | body, | |
| 1001 | maintainer_can_modify: true, | |
| 1002 | }, | |
| 1003 | }); | |
| 1004 | }).pipe( | |
| 1005 | Effect.mapError( | |
| 1006 | providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), | |
| 1007 | ), | |
| 1008 | ), | |
| 1009 | getRepositoryCloneUrls: (input) => | |
| 1010 | Effect.gen(function* () { | |
| 1011 | const fallbackHost = (yield* resolveRepository(input).pipe( | |
| 1012 | Effect.map((locator) => locator.host), | |
| 1013 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 1014 | )).toLowerCase(); | |
| 1015 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 1016 | if (locator === null) return yield* failure("Repositories are named owner/name."); | |
| 1017 | return repositoryCloneUrls(yield* readRepository(locator)); | |
| 1018 | }).pipe( | |
| 1019 | Effect.mapError( | |
| 1020 | providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), | |
| 1021 | ), | |
| 1022 | ), | |
| 1023 | createRepository: (input) => | |
| 1024 | Effect.gen(function* () { | |
| 1025 | const locator = parseGitHubRepositorySelector( | |
| 1026 | input.repository, | |
| 1027 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 1028 | ); | |
| 1029 | const viewer = locator === null ? null : yield* readViewerLogin(locator.host); | |
| 1030 | const owner = locator?.owner ?? viewer; | |
| 1031 | const name = locator?.name ?? input.repository.trim(); | |
| 1032 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 1033 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 1034 | const response = yield* rest( | |
| 1035 | { | |
| 1036 | host, | |
| 1037 | operation: "createRepository", | |
| 1038 | method: "POST", | |
| 1039 | allowReserve: true, | |
| 1040 | path: | |
| 1041 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 1042 | body: { name, private: input.visibility === "private" }, | |
| 1043 | }, | |
| 1044 | // GitHub answers 404 for an organization the account cannot create repositories in. | |
| 1045 | `No organization named ${owner ?? "that"} that this account can create repositories in.`, | |
| 1046 | ); | |
| 1047 | const decoded = decodeRawRepository(response.body); | |
| 1048 | if (Result.isFailure(decoded)) { | |
| 1049 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 1050 | } | |
| 1051 | return repositoryCloneUrls(decoded.success); | |
| 1052 | }).pipe( | |
| 1053 | Effect.mapError( | |
| 1054 | providerError("createRepository", input.cwd, { repository: input.repository }), | |
| 1055 | ), | |
| 1056 | ), | |
| 1057 | getDefaultBranch: (input) => | |
| 1058 | Effect.gen(function* () { | |
| 1059 | const locator = yield* resolveRepository({ | |
| 1060 | cwd: input.cwd, | |
| 1061 | host: contextHost(input.context), | |
| 1062 | context: input.context, | |
| 1063 | }); | |
| 1064 | const repository = yield* readRepository(locator); | |
| 1065 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1066 | return branch.length > 0 ? branch : null; | |
| 1067 | }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), | |
| 1068 | checkoutChangeRequest: (input) => | |
| 1069 | checkoutPullRequest(input).pipe( | |
| 1070 | Effect.mapError( | |
| 1071 | providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), | |
| 1072 | ), | |
| 1073 | ), | |
| 1074 | }); | |
| 1075 | }); |
packages/source-control-github/src/server/driver.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | /** | |
| 2 | * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests | |
| 3 | * through the GitHub REST and GraphQL APIs. | |
| 4 | * | |
| 5 | * @module source-control-github/server/driver | |
| 6 | */ | |
| 7 | import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; | |
| 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | |
| 12 | ||
| 13 | export const driver = defineSourceControlDriver({ | |
| 14 | kind: "github", | |
| 15 | make: Effect.all({ | |
| 16 | sourceControl: GitHubSourceControlProvider.make, | |
| 17 | discovery: GitHubSourceControlProvider.makeDiscovery, | |
| 18 | pullRequests: GitHubPullRequestProvider.make, | |
| 19 | }), | |
| 20 | }); |
packages/source-control-github/src/server/gitHubAuthStatus.ts created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import type { GitHubSettings } from "@t3tools/contracts"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | ||
| 5 | const GitHubAuthStatusAccountSchema = Schema.Struct({ | |
| 6 | state: Schema.String, | |
| 7 | error: Schema.optional(Schema.String), | |
| 8 | active: Schema.Boolean, | |
| 9 | host: Schema.String, | |
| 10 | login: Schema.String, | |
| 11 | tokenSource: Schema.optional(Schema.String), | |
| 12 | }); | |
| 13 | ||
| 14 | const GitHubAuthStatusSchema = Schema.Struct({ | |
| 15 | hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), | |
| 16 | }); | |
| 17 | ||
| 18 | const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( | |
| 19 | Schema.fromJsonString(GitHubAuthStatusSchema), | |
| 20 | ); | |
| 21 | ||
| 22 | export interface GitHubAuthStatusAccount { | |
| 23 | readonly host: string; | |
| 24 | readonly account: string; | |
| 25 | readonly authenticated: boolean; | |
| 26 | readonly active: boolean; | |
| 27 | readonly error: string | null; | |
| 28 | /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ | |
| 29 | readonly environmentVariable: string | null; | |
| 30 | } | |
| 31 | ||
| 32 | export interface GitHubAuthStatus { | |
| 33 | readonly parsed: boolean; | |
| 34 | readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>; | |
| 35 | } | |
| 36 | ||
| 37 | const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u; | |
| 38 | ||
| 39 | function nonEmptyString(value: string): string | null { | |
| 40 | const trimmed = value.trim(); | |
| 41 | return trimmed.length > 0 ? trimmed : null; | |
| 42 | } | |
| 43 | ||
| 44 | export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { | |
| 45 | return Option.match(decodeGitHubAuthStatusJson(text), { | |
| 46 | onNone: () => ({ parsed: false, accounts: [] }), | |
| 47 | onSome: (status) => | |
| 48 | ({ | |
| 49 | parsed: true, | |
| 50 | accounts: Object.values(status.hosts).flatMap((accounts) => | |
| 51 | accounts.flatMap((account) => { | |
| 52 | const host = nonEmptyString(account.host); | |
| 53 | const login = nonEmptyString(account.login); | |
| 54 | if (host === null || login === null) return []; | |
| 55 | ||
| 56 | return [ | |
| 57 | { | |
| 58 | host: host.toLowerCase(), | |
| 59 | account: login, | |
| 60 | authenticated: account.state === "success", | |
| 61 | active: account.active, | |
| 62 | error: account.error?.trim() || null, | |
| 63 | environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "") | |
| 64 | ? (account.tokenSource ?? null) | |
| 65 | : null, | |
| 66 | }, | |
| 67 | ]; | |
| 68 | }), | |
| 69 | ), | |
| 70 | }) satisfies GitHubAuthStatus, | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | export function findAuthenticatedGitHubAccount( | |
| 75 | accounts: ReadonlyArray<GitHubAuthStatusAccount>, | |
| 76 | ): GitHubAuthStatusAccount | undefined { | |
| 77 | return ( | |
| 78 | accounts.find((account) => account.authenticated && account.active) ?? | |
| 79 | accounts.find((account) => account.authenticated) | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * The login GitHub requests for a host will use, honoring Settings: an environment token | |
| 85 | * first (gh's own precedence), then the pinned account, then gh's active login. | |
| 86 | * Returns undefined when the host is turned off or has no usable login. | |
| 87 | */ | |
| 88 | export function effectiveGitHubAccount( | |
| 89 | host: string, | |
| 90 | accounts: ReadonlyArray<GitHubAuthStatusAccount>, | |
| 91 | settings: GitHubSettings, | |
| 92 | ): GitHubAuthStatusAccount | undefined { | |
| 93 | const choice = settings.hosts[host]; | |
| 94 | if (choice?.enabled === false) return undefined; | |
| 95 | const usable = accounts.filter((account) => account.host === host && account.authenticated); | |
| 96 | return ( | |
| 97 | usable.find((account) => account.environmentVariable !== null) ?? | |
| 98 | (choice?.account === undefined | |
| 99 | ? undefined | |
| 100 | : usable.find((account) => account.account === choice.account)) ?? | |
| 101 | findAuthenticatedGitHubAccount(usable) | |
| 102 | ); | |
| 103 | } |
packages/source-control-github/src/server/gitHubConditionalChecks.test.ts created+245| ... | ... | @@ -0,0 +1,245 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as TestClock from "effect/testing/TestClock"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | import type { PullRequestCheck } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 10 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 11 | ||
| 12 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 13 | ||
| 14 | const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 }; | |
| 15 | const credential = { | |
| 16 | host: "github.com", | |
| 17 | token: Redacted.make("token"), | |
| 18 | credentialFingerprint: "one", | |
| 19 | }; | |
| 20 | ||
| 21 | it.effect( | |
| 22 | "reuses unchanged checks and catches reruns, later pages, fork approvals, pushes and account changes", | |
| 23 | () => | |
| 24 | Effect.gen(function* () { | |
| 25 | let sha = "a".repeat(40); | |
| 26 | let changed = ""; | |
| 27 | let reads = 0; | |
| 28 | const requests: string[] = []; | |
| 29 | const known: Array<unknown> = []; | |
| 30 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 31 | Effect.provide( | |
| 32 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 33 | rest: ({ path, ifNoneMatch }) => | |
| 34 | Effect.sync(() => { | |
| 35 | requests.push(path); | |
| 36 | const head = path.endsWith("/pulls/1"); | |
| 37 | const modified = | |
| 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 | return { | |
| 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, | |
| 62 | }; | |
| 63 | }), | |
| 64 | }), | |
| 65 | ), | |
| 66 | ); | |
| 67 | const read = Effect.gen(function* () { | |
| 68 | known.push(yield* KnownWorkflowRuns); | |
| 69 | reads++; | |
| 70 | return { | |
| 71 | state: "open" as const, | |
| 72 | checks: [ | |
| 73 | { name: "build", status: "success", description: null, url: null }, | |
| 74 | ] satisfies PullRequestCheck[], | |
| 75 | headSha: sha, | |
| 76 | workflowApprovalsRequired: 0, | |
| 77 | }; | |
| 78 | }); | |
| 79 | const poll = (identity = credential) => | |
| 80 | revalidate(reference, read).pipe( | |
| 81 | Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), | |
| 82 | ); | |
| 83 | yield* poll(); | |
| 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 | }); | |
| 90 | requests.length = 0; | |
| 91 | yield* poll(); | |
| 92 | expect(reads).toBe(1); | |
| 93 | expect(requests).toHaveLength(5); | |
| 94 | for (const endpoint of [ | |
| 95 | "check-runs?filter=all&per_page=100&page=2", | |
| 96 | "/status", | |
| 97 | "/actions/runs", | |
| 98 | "/pulls/1", | |
| 99 | ]) { | |
| 100 | changed = endpoint; | |
| 101 | yield* poll(); | |
| 102 | changed = ""; | |
| 103 | yield* poll(); | |
| 104 | } | |
| 105 | expect(reads).toBe(5); | |
| 106 | sha = "b".repeat(40); | |
| 107 | changed = "/pulls/1"; | |
| 108 | requests.length = 0; | |
| 109 | yield* poll(); | |
| 110 | expect(reads).toBe(6); | |
| 111 | expect( | |
| 112 | requests | |
| 113 | .filter((endpoint) => endpoint.includes("/commits/")) | |
| 114 | .every((endpoint) => endpoint.includes(sha)), | |
| 115 | ).toBe(true); | |
| 116 | changed = ""; | |
| 117 | yield* poll({ ...credential, credentialFingerprint: "two" }); | |
| 118 | expect(reads).toBe(7); | |
| 119 | yield* TestClock.adjust("5 minutes"); | |
| 120 | yield* poll(); | |
| 121 | expect(reads).toBe(8); | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect("does not retain failed or incomplete reads, and supports hosts without ETags", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const sha = "a".repeat(40); | |
| 128 | let etags = true; | |
| 129 | let unavailable = false; | |
| 130 | let fail = true; | |
| 131 | let complete = true; | |
| 132 | let reads = 0; | |
| 133 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 134 | Effect.provide( | |
| 135 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 136 | rest: ({ path, ifNoneMatch }) => | |
| 137 | unavailable | |
| 138 | ? Effect.fail( | |
| 139 | new GitHubApi.GitHubApiResponseError({ | |
| 140 | host: "github.com", | |
| 141 | operation: "revalidateChecks", | |
| 142 | status: 502, | |
| 143 | }), | |
| 144 | ) | |
| 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 | ), | |
| 161 | }), | |
| 162 | ), | |
| 163 | ); | |
| 164 | const read = Effect.suspend(() => { | |
| 165 | reads++; | |
| 166 | return fail | |
| 167 | ? Effect.fail( | |
| 168 | new GitHubApi.GitHubApiResponseError({ | |
| 169 | host: "github.com", | |
| 170 | operation: "read", | |
| 171 | status: 500, | |
| 172 | }), | |
| 173 | ) | |
| 174 | : Effect.succeed({ | |
| 175 | state: "open" as const, | |
| 176 | checks: [], | |
| 177 | headSha: sha, | |
| 178 | ...(complete ? { workflowApprovalsRequired: 0 } : {}), | |
| 179 | }); | |
| 180 | }); | |
| 181 | const poll = () => | |
| 182 | revalidate(reference, read).pipe( | |
| 183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 184 | ); | |
| 185 | yield* poll().pipe(Effect.flip); | |
| 186 | fail = false; | |
| 187 | complete = false; | |
| 188 | yield* poll(); | |
| 189 | yield* poll(); | |
| 190 | expect(reads).toBe(3); | |
| 191 | complete = true; | |
| 192 | yield* poll(); | |
| 193 | yield* poll(); | |
| 194 | expect(reads).toBe(4); | |
| 195 | yield* TestClock.adjust("5 minutes"); | |
| 196 | complete = false; | |
| 197 | yield* poll(); | |
| 198 | yield* poll(); | |
| 199 | expect(reads).toBe(6); | |
| 200 | complete = true; | |
| 201 | unavailable = true; | |
| 202 | yield* poll(); | |
| 203 | expect(reads).toBe(7); | |
| 204 | unavailable = false; | |
| 205 | yield* poll(); | |
| 206 | yield* poll(); | |
| 207 | expect(reads).toBe(8); | |
| 208 | etags = false; | |
| 209 | yield* poll(); | |
| 210 | yield* poll(); | |
| 211 | expect(reads).toBe(10); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | it.effect("falls back when REST checks are unavailable without retrying unsupported probes", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | let probes = 0; | |
| 218 | let reads = 0; | |
| 219 | const revalidate = yield* makeChecksRevalidator.pipe( | |
| 220 | Effect.provide( | |
| 221 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 222 | rest: () => { | |
| 223 | probes++; | |
| 224 | return Effect.fail( | |
| 225 | new GitHubApi.GitHubApiNotFoundError({ | |
| 226 | host: "github.com", | |
| 227 | operation: "revalidateChecks", | |
| 228 | }), | |
| 229 | ); | |
| 230 | }, | |
| 231 | }), | |
| 232 | ), | |
| 233 | ); | |
| 234 | const read = Effect.sync(() => { | |
| 235 | reads++; | |
| 236 | return { state: "open" as const, checks: [] }; | |
| 237 | }); | |
| 238 | for (let tick = 0; tick < 2; tick++) | |
| 239 | yield* revalidate(reference, read).pipe( | |
| 240 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 241 | ); | |
| 242 | expect(probes).toBe(1); | |
| 243 | expect(reads).toBe(2); | |
| 244 | }), | |
| 245 | ); |
packages/source-control-github/src/server/gitHubConditionalChecks.ts created+195| ... | ... | @@ -0,0 +1,195 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Cache from "effect/Cache"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 11 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; | |
| 12 | import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; | |
| 13 | import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 14 | ||
| 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 | }), | |
| 21 | base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), | |
| 22 | }); | |
| 23 | const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); | |
| 24 | ||
| 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>("@t3tools/source-control-github/server/gitHubConditionalChecks/KnownWorkflowRuns", { | |
| 47 | defaultValue: () => null, | |
| 48 | }); | |
| 49 | ||
| 50 | type Validator = { etag: string | undefined; next: boolean; body: string }; | |
| 51 | ||
| 52 | /** A host that answers these with one of them has no conditional REST for it at all. */ | |
| 53 | const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); | |
| 54 | ||
| 55 | export const makeChecksRevalidator = Effect.gen(function* () { | |
| 56 | const api = yield* GitHubApi.GitHubApi; | |
| 57 | const entries = yield* Cache.makeWith( | |
| 58 | (_key: string) => | |
| 59 | Effect.sync(() => ({ | |
| 60 | gate: Semaphore.makeUnsafe(1), | |
| 61 | validators: new Map<string, Validator>(), | |
| 62 | head: null as typeof HeadSchema.Type | null, | |
| 63 | value: null as PullRequestChecks | null, | |
| 64 | readAt: 0, | |
| 65 | supported: true, | |
| 66 | })), | |
| 67 | { capacity: 128, timeToLive: () => "30 minutes" }, | |
| 68 | ); | |
| 69 | return ( | |
| 70 | input: ProviderRepositoryRef & { readonly number: number }, | |
| 71 | read: Effect.Effect< | |
| 72 | Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & { | |
| 73 | workflowApprovalsRequired?: number; | |
| 74 | }, | |
| 75 | GitHubPullRequestApiError | |
| 76 | >, | |
| 77 | ) => | |
| 78 | Effect.gen(function* () { | |
| 79 | const credential = yield* GitHubApi.PinnedGitHubCredential; | |
| 80 | if (credential === null) return yield* read; | |
| 81 | const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; | |
| 82 | const entry = yield* Cache.get(entries, key); | |
| 83 | return yield* entry.gate.withPermit( | |
| 84 | Effect.gen(function* () { | |
| 85 | if (!entry.supported) return yield* read; | |
| 86 | const fail = (status: number) => | |
| 87 | new GitHubApi.GitHubApiResponseError({ | |
| 88 | host: input.host, | |
| 89 | operation: "revalidateChecks", | |
| 90 | status, | |
| 91 | }); | |
| 92 | /** Whether the endpoint has a next page, or null where the host cannot say. */ | |
| 93 | const get = (endpoint: string, head = false) => | |
| 94 | Effect.gen(function* () { | |
| 95 | const previous = entry.validators.get(endpoint); | |
| 96 | const response = yield* api | |
| 97 | .rest({ | |
| 98 | host: input.host, | |
| 99 | operation: "revalidateChecks", | |
| 100 | path: endpoint, | |
| 101 | ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), | |
| 102 | }) | |
| 103 | .pipe( | |
| 104 | Effect.catchTags({ | |
| 105 | GitHubApiNotFoundError: () => | |
| 106 | Effect.sync(() => { | |
| 107 | entry.supported = false; | |
| 108 | return null; | |
| 109 | }), | |
| 110 | GitHubApiResponseError: (error) => | |
| 111 | Effect.sync(() => { | |
| 112 | entry.supported = !UNSUPPORTED_STATUSES.has(error.status); | |
| 113 | return null; | |
| 114 | }), | |
| 115 | }), | |
| 116 | ); | |
| 117 | if (response === null) { | |
| 118 | entry.value = null; | |
| 119 | return null; | |
| 120 | } | |
| 121 | if (response.status === 304 && previous) return previous.next; | |
| 122 | if (response.status !== 200 || response.truncated) { | |
| 123 | return yield* fail(response.status); | |
| 124 | } | |
| 125 | entry.value = null; | |
| 126 | if (head) { | |
| 127 | const decoded = yield* decodeHead(response.body).pipe( | |
| 128 | Effect.mapError(() => fail(response.status)), | |
| 129 | ); | |
| 130 | if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); | |
| 131 | entry.head = decoded; | |
| 132 | } | |
| 133 | const validator = { | |
| 134 | etag: response.headers["etag"]?.trim(), | |
| 135 | next: /rel="next"/.test(response.headers["link"] ?? ""), | |
| 136 | body: response.body, | |
| 137 | }; | |
| 138 | entry.validators.set(endpoint, validator); | |
| 139 | return validator.next; | |
| 140 | }); | |
| 141 | const root = `repos/${input.repository}`; | |
| 142 | if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; | |
| 143 | const head = entry.head; | |
| 144 | if (head === null) return yield* fail(200); | |
| 145 | const runsEndpoint = | |
| 146 | head.head.repo?.id !== head.base.repo.id | |
| 147 | ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request` | |
| 148 | : null; | |
| 149 | const endpoints = [ | |
| 150 | `${root}/commits/${head.head.sha}/check-runs?filter=all`, | |
| 151 | `${root}/commits/${head.head.sha}/status`, | |
| 152 | ...(runsEndpoint === null ? [] : [runsEndpoint]), | |
| 153 | ]; | |
| 154 | const runPages: Array<string> = []; | |
| 155 | for (const endpoint of endpoints) { | |
| 156 | for (let page = 1; ; page++) { | |
| 157 | if (page > 100) return yield* fail(200); | |
| 158 | const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; | |
| 159 | const next = yield* get(paged); | |
| 160 | if (next === null) return yield* read; | |
| 161 | if (endpoint === runsEndpoint) runPages.push(paged); | |
| 162 | if (!next) break; | |
| 163 | } | |
| 164 | } | |
| 165 | const now = yield* Clock.currentTimeMillis; | |
| 166 | if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; | |
| 167 | // Every page of the head's runs was just confirmed current, so the read filters them | |
| 168 | // rather than listing the runs a second time. | |
| 169 | const pages = runPages.map((endpoint) => | |
| 170 | decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), | |
| 171 | ); | |
| 172 | const known = | |
| 173 | runsEndpoint !== null && pages.every(Option.isSome) | |
| 174 | ? { | |
| 175 | headSha: head.head.sha, | |
| 176 | runs: pages.flatMap((page) => | |
| 177 | Option.isSome(page) ? page.value.workflow_runs : [], | |
| 178 | ), | |
| 179 | } | |
| 180 | : null; | |
| 181 | const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); | |
| 182 | const value = { | |
| 183 | state: fresh.state, | |
| 184 | checks: fresh.checks, | |
| 185 | }; | |
| 186 | entry.value = | |
| 187 | fresh.workflowApprovalsRequired !== undefined && fresh.headSha === head.head.sha | |
| 188 | ? value | |
| 189 | : null; | |
| 190 | entry.readAt = now; | |
| 191 | return value; | |
| 192 | }), | |
| 193 | ); | |
| 194 | }); | |
| 195 | }); |
packages/source-control-github/src/server/gitHubPullRequestJson.test.ts created+2111| ... | ... | @@ -0,0 +1,2111 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | buildPullRequestSummariesGraphQlQuery, | |
| 6 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 7 | decodePullRequestSummariesJson, | |
| 8 | decodePullRequestWatchFingerprintsJson, | |
| 9 | buildReviewSubmission, | |
| 10 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 11 | decodePullRequestStackMembershipsJson, | |
| 12 | buildReviewerRequest, | |
| 13 | buildSetFilesViewedGraphQlMutation, | |
| 14 | decodePullRequestActivityJson, | |
| 15 | decodePullRequestDetailJson, | |
| 16 | decodePullRequestFilesJson, | |
| 17 | decodePullRequestFilesViewedJson, | |
| 18 | decodePullRequestListJson, | |
| 19 | decodePullRequestNodeIdJson, | |
| 20 | decodePullRequestSearchJson, | |
| 21 | decodePullRequestStacksJson, | |
| 22 | decodeLabelCandidatesJson, | |
| 23 | decodeReviewerCandidatesJson, | |
| 24 | decodeReviewThreadCommentsJson, | |
| 25 | decodeReviewThreadsJson, | |
| 26 | decodeViewerPermissionsJson, | |
| 27 | decodeWorkflowRunsJson, | |
| 28 | reviewThreadConversation, | |
| 29 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 30 | pullRequestCoreGraphQlQuery, | |
| 31 | pullRequestSearchGraphQlQuery, | |
| 32 | } from "./gitHubPullRequestJson.ts"; | |
| 33 | ||
| 34 | /** | |
| 35 | * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: | |
| 36 | * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. | |
| 37 | */ | |
| 38 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | |
| 39 | return JSON.stringify({ | |
| 40 | data: { | |
| 41 | repository: { | |
| 42 | pullRequests: { | |
| 43 | pageInfo: { hasNextPage: false }, | |
| 44 | nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ | |
| 45 | number: 1, | |
| 46 | title: "Add the pull requests page", | |
| 47 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 48 | headRefName: "feat/page", | |
| 49 | baseRefName: "main", | |
| 50 | createdAt: "2026-07-01T00:00:00Z", | |
| 51 | updatedAt: "2026-07-02T00:00:00Z", | |
| 52 | ...(reviewRequests === undefined | |
| 53 | ? {} | |
| 54 | : { | |
| 55 | reviewRequests: { | |
| 56 | nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({ | |
| 57 | requestedReviewer, | |
| 58 | })), | |
| 59 | }, | |
| 60 | }), | |
| 61 | ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), | |
| 62 | ...(checks === undefined | |
| 63 | ? {} | |
| 64 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 65 | ...entry, | |
| 66 | })), | |
| 67 | }, | |
| 68 | }, | |
| 69 | }, | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 73 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 74 | expect(Result.isSuccess(result)).toBe(true); | |
| 75 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 76 | return result.success; | |
| 77 | } | |
| 78 | ||
| 79 | describe("pull request list decoding", () => { | |
| 80 | it("treats a merge timestamp as merged even when the state still says closed", () => { | |
| 81 | const [entry] = expectSuccess( | |
| 82 | decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), | |
| 83 | ).items; | |
| 84 | expect(entry?.state).toBe("merged"); | |
| 85 | }); | |
| 86 | ||
| 87 | it("normalizes mergeability and defaults unknown values", () => { | |
| 88 | const batch = expectSuccess( | |
| 89 | decodePullRequestListJson( | |
| 90 | listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), | |
| 91 | ), | |
| 92 | ); | |
| 93 | expect(batch.items.map((entry) => entry.mergeability)).toEqual([ | |
| 94 | "conflicting", | |
| 95 | "unknown", | |
| 96 | "unknown", | |
| 97 | ]); | |
| 98 | }); | |
| 99 | ||
| 100 | it("keeps user review requests and drops team ones, which are not logins", () => { | |
| 101 | const [entry] = expectSuccess( | |
| 102 | decodePullRequestListJson( | |
| 103 | listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), | |
| 104 | ), | |
| 105 | ).items; | |
| 106 | expect(entry?.reviewRequestLogins).toEqual(["octocat"]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { | |
| 110 | const batch = expectSuccess( | |
| 111 | decodePullRequestListJson( | |
| 112 | listJson([ | |
| 113 | { reviewDecision: "APPROVED" }, | |
| 114 | { reviewDecision: "CHANGES_REQUESTED" }, | |
| 115 | { reviewDecision: "REVIEW_REQUIRED" }, | |
| 116 | { reviewDecision: null }, | |
| 117 | ]), | |
| 118 | ), | |
| 119 | ); | |
| 120 | expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ | |
| 121 | "approved", | |
| 122 | "changes-requested", | |
| 123 | "review-required", | |
| 124 | null, | |
| 125 | ]); | |
| 126 | }); | |
| 127 | ||
| 128 | it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { | |
| 129 | const batch = expectSuccess( | |
| 130 | decodePullRequestListJson( | |
| 131 | listJson([ | |
| 132 | { | |
| 133 | reviewDecision: null, | |
| 134 | latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], | |
| 135 | }, | |
| 136 | { | |
| 137 | reviewDecision: "REVIEW_REQUIRED", | |
| 138 | latestReviews: [ | |
| 139 | { author: { login: "octocat" }, state: "APPROVED" }, | |
| 140 | { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, | |
| 141 | ], | |
| 142 | }, | |
| 143 | { | |
| 144 | reviewDecision: "APPROVED", | |
| 145 | latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], | |
| 146 | }, | |
| 147 | { | |
| 148 | reviewDecision: null, | |
| 149 | latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], | |
| 150 | }, | |
| 151 | ]), | |
| 152 | ), | |
| 153 | ); | |
| 154 | expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ | |
| 155 | "approved", | |
| 156 | "changes-requested", | |
| 157 | "approved", | |
| 158 | null, | |
| 159 | ]); | |
| 160 | }); | |
| 161 | ||
| 162 | it("rolls the head commit's rollup up to the one word a row has space for", () => { | |
| 163 | const batch = expectSuccess( | |
| 164 | decodePullRequestListJson( | |
| 165 | listJson([ | |
| 166 | { checks: "FAILURE" }, | |
| 167 | { checks: "PENDING" }, | |
| 168 | { checks: "SUCCESS" }, | |
| 169 | { checks: "ERROR" }, | |
| 170 | { checks: "EXPECTED" }, | |
| 171 | {}, | |
| 172 | ]), | |
| 173 | ), | |
| 174 | ); | |
| 175 | expect(batch.items.map((entry) => entry.checksState)).toEqual([ | |
| 176 | "failing", | |
| 177 | "pending", | |
| 178 | "passing", | |
| 179 | "failing", | |
| 180 | "pending", | |
| 181 | null, | |
| 182 | ]); | |
| 183 | }); | |
| 184 | ||
| 185 | it("skips malformed entries but still counts them, so paging does not stop early", () => { | |
| 186 | const batch = expectSuccess( | |
| 187 | decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), | |
| 188 | ); | |
| 189 | expect(batch.items).toHaveLength(1); | |
| 190 | expect(batch.rawCount).toBe(2); | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | describe("pull request search decoding", () => { | |
| 195 | function searchJson(rollupStates: ReadonlyArray<string | null>): string { | |
| 196 | return JSON.stringify({ | |
| 197 | data: { | |
| 198 | search: { | |
| 199 | pageInfo: { hasNextPage: false }, | |
| 200 | nodes: rollupStates.map((state, index) => ({ | |
| 201 | number: index + 1, | |
| 202 | title: "Add the pull requests page", | |
| 203 | url: "https://github.com/pingdotgg/t3code/pull/1", | |
| 204 | headRefName: "feat/page", | |
| 205 | baseRefName: "main", | |
| 206 | createdAt: "2026-07-01T00:00:00Z", | |
| 207 | updatedAt: "2026-07-02T00:00:00Z", | |
| 208 | repository: { nameWithOwner: "pingdotgg/t3code" }, | |
| 209 | commits: { | |
| 210 | nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], | |
| 211 | }, | |
| 212 | })), | |
| 213 | }, | |
| 214 | }, | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | it("keeps stack membership beside search results without extra per-PR reads", () => { | |
| 219 | const raw = JSON.parse(searchJson(["SUCCESS", null])); | |
| 220 | raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; | |
| 221 | raw.data.search.nodes[0].stackEntry = { position: 1 }; | |
| 222 | const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); | |
| 223 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); | |
| 224 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 225 | expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); | |
| 226 | expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); | |
| 227 | }); | |
| 228 | ||
| 229 | it("maps the rollup enum the search answers with onto the same three words", () => { | |
| 230 | // The search asks GitHub for the verdict rather than the checks behind it, so this path sees | |
| 231 | // one enum where the listing sees an array. | |
| 232 | const batch = expectSuccess( | |
| 233 | decodePullRequestSearchJson( | |
| 234 | searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), | |
| 235 | ), | |
| 236 | ); | |
| 237 | expect(batch.items.map((entry) => entry.checksState)).toEqual([ | |
| 238 | "passing", | |
| 239 | "failing", | |
| 240 | "failing", | |
| 241 | "pending", | |
| 242 | "pending", | |
| 243 | null, | |
| 244 | ]); | |
| 245 | }); | |
| 246 | }); | |
| 247 | ||
| 248 | describe("pull request detail decoding", () => { | |
| 249 | const detailJson = JSON.stringify({ | |
| 250 | number: 7, | |
| 251 | title: "Detail", | |
| 252 | url: "https://github.com/pingdotgg/t3code/pull/7", | |
| 253 | headRefName: "feat/detail", | |
| 254 | baseRefName: "main", | |
| 255 | createdAt: "2026-07-01T00:00:00Z", | |
| 256 | updatedAt: "2026-07-05T00:00:00Z", | |
| 257 | body: "Body", | |
| 258 | statusCheckRollup: [ | |
| 259 | { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, | |
| 260 | { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 261 | { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, | |
| 262 | ], | |
| 263 | comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], | |
| 264 | reviews: [ | |
| 265 | { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, | |
| 266 | { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, | |
| 267 | ], | |
| 268 | commits: [ | |
| 269 | { | |
| 270 | oid: "abc1234", | |
| 271 | messageHeadline: "Ship the timeline", | |
| 272 | committedDate: "2026-07-05T00:00:00Z", | |
| 273 | authors: [ | |
| 274 | { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, | |
| 275 | { name: "Pair Author", email: "pair@example.com" }, | |
| 276 | ], | |
| 277 | }, | |
| 278 | ], | |
| 279 | }); | |
| 280 | ||
| 281 | /** The same conversation as the GraphQL activity query answers it. */ | |
| 282 | const activityJson = (raw: Record<string, unknown>) => | |
| 283 | JSON.stringify({ | |
| 284 | data: { | |
| 285 | repository: { | |
| 286 | pullRequest: { | |
| 287 | comments: { nodes: raw["comments"] ?? [] }, | |
| 288 | reviews: { nodes: raw["reviews"] ?? [] }, | |
| 289 | commits: { | |
| 290 | nodes: ((raw["commits"] ?? []) as ReadonlyArray<Record<string, unknown>>).map( | |
| 291 | ({ authors, ...commit }) => ({ | |
| 292 | commit: { | |
| 293 | ...commit, | |
| 294 | authors: { | |
| 295 | nodes: ((authors ?? []) as ReadonlyArray<Record<string, unknown>>).map( | |
| 296 | ({ login, ...author }) => ({ | |
| 297 | ...author, | |
| 298 | ...(login === undefined ? {} : { user: { login } }), | |
| 299 | }), | |
| 300 | ), | |
| 301 | }, | |
| 302 | }, | |
| 303 | }), | |
| 304 | ), | |
| 305 | }, | |
| 306 | }, | |
| 307 | }, | |
| 308 | }, | |
| 309 | }); | |
| 310 | const activity = (raw: Record<string, unknown>) => | |
| 311 | Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ | |
| 312 | comments: page.remarks.toSorted((left, right) => | |
| 313 | left.createdAt.localeCompare(right.createdAt), | |
| 314 | ), | |
| 315 | commits: page.commits ?? [], | |
| 316 | })); | |
| 317 | ||
| 318 | it("maps check-run status and commit-status state onto one vocabulary", () => { | |
| 319 | const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); | |
| 320 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ | |
| 321 | ["build", "pending"], | |
| 322 | ["test", "failure"], | |
| 323 | ["ci/legacy", "success"], | |
| 324 | ]); | |
| 325 | }); | |
| 326 | ||
| 327 | it("keeps what branch protection requires, and asks for it on github.com only", () => { | |
| 328 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 329 | const detail = expectSuccess( | |
| 330 | decodePullRequestDetailJson( | |
| 331 | JSON.stringify({ | |
| 332 | ...raw, | |
| 333 | statusCheckRollup: [ | |
| 334 | { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, | |
| 335 | { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, | |
| 336 | { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, | |
| 337 | ], | |
| 338 | }), | |
| 339 | ), | |
| 340 | ); | |
| 341 | expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); | |
| 342 | expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); | |
| 343 | expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); | |
| 344 | }); | |
| 345 | ||
| 346 | it("keeps a workflow waiting for approval out of the passing state", () => { | |
| 347 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 348 | const detail = expectSuccess( | |
| 349 | decodePullRequestDetailJson( | |
| 350 | JSON.stringify({ | |
| 351 | ...raw, | |
| 352 | statusCheckRollup: [ | |
| 353 | { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 354 | { | |
| 355 | __typename: "CheckRun", | |
| 356 | name: "contributor tests", | |
| 357 | status: "COMPLETED", | |
| 358 | conclusion: "ACTION_REQUIRED", | |
| 359 | }, | |
| 360 | ], | |
| 361 | }), | |
| 362 | ), | |
| 363 | ); | |
| 364 | ||
| 365 | expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]); | |
| 366 | expect(detail.checksState).toBe("pending"); | |
| 367 | }); | |
| 368 | ||
| 369 | it("decodes workflow runs that can be approved", () => { | |
| 370 | expect( | |
| 371 | expectSuccess( | |
| 372 | decodeWorkflowRunsJson( | |
| 373 | JSON.stringify({ | |
| 374 | workflow_runs: [ | |
| 375 | { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, | |
| 376 | { id: 11, name: null, html_url: null }, | |
| 377 | ], | |
| 378 | }), | |
| 379 | ), | |
| 380 | ).runs, | |
| 381 | ).toEqual([ | |
| 382 | { id: 10, name: "contributor tests", url: "https://example.com/10" }, | |
| 383 | { id: 11, name: "Workflow run 11", url: null }, | |
| 384 | ]); | |
| 385 | }); | |
| 386 | ||
| 387 | it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => { | |
| 388 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 389 | const armed = (entry: Record<string, unknown>) => | |
| 390 | expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); | |
| 391 | ||
| 392 | expect( | |
| 393 | armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), | |
| 394 | ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | |
| 395 | expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false); | |
| 396 | // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. | |
| 397 | expect(armed({}).autoMergeEnabled).toBeUndefined(); | |
| 398 | }); | |
| 399 | ||
| 400 | it("shows a re-running check once, as the run that is happening now", () => { | |
| 401 | // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, | |
| 402 | // the finished run and the one that replaced it, with no id to tell them apart. | |
| 403 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 404 | const detail = expectSuccess( | |
| 405 | decodePullRequestDetailJson( | |
| 406 | JSON.stringify({ | |
| 407 | ...raw, | |
| 408 | statusCheckRollup: [ | |
| 409 | { | |
| 410 | __typename: "CheckRun", | |
| 411 | name: "Prepare PR size config", | |
| 412 | workflowName: "PR Size", | |
| 413 | status: "COMPLETED", | |
| 414 | conclusion: "SUCCESS", | |
| 415 | startedAt: "2026-08-11T16:06:20Z", | |
| 416 | completedAt: "2026-08-11T16:06:25Z", | |
| 417 | }, | |
| 418 | { | |
| 419 | __typename: "CheckRun", | |
| 420 | name: "Prepare PR size config", | |
| 421 | workflowName: "PR Size", | |
| 422 | status: "IN_PROGRESS", | |
| 423 | conclusion: "", | |
| 424 | startedAt: "2026-08-11T17:01:04Z", | |
| 425 | completedAt: "0001-01-01T00:00:00Z", | |
| 426 | }, | |
| 427 | ], | |
| 428 | }), | |
| 429 | ), | |
| 430 | ); | |
| 431 | ||
| 432 | expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ | |
| 433 | ["Prepare PR size config", "pending"], | |
| 434 | ]); | |
| 435 | expect(detail.checksState).toBe("pending"); | |
| 436 | }); | |
| 437 | ||
| 438 | it("merges reviews with comments in time order and keeps a bodyless approval", () => { | |
| 439 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); | |
| 440 | // r2 approved without writing anything, which is still the event worth seeing. | |
| 441 | expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); | |
| 442 | expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); | |
| 443 | }); | |
| 444 | ||
| 445 | it("keeps every attributed commit author, including an unlinked signature", () => { | |
| 446 | const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>)); | |
| 447 | expect(detail.commits[0]?.authors).toEqual([ | |
| 448 | { login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 449 | { login: "Pair Author", name: "Pair Author", avatarUrl: null }, | |
| 450 | ]); | |
| 451 | }); | |
| 452 | ||
| 453 | it("drops the bodyless review GitHub opens to hold line comments", () => { | |
| 454 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 455 | const detail = expectSuccess( | |
| 456 | activity({ | |
| 457 | ...raw, | |
| 458 | reviews: [ | |
| 459 | // What a reviewer leaving inline comments produces: a container with a state but | |
| 460 | // nothing to read. Its comments come from the review threads instead. | |
| 461 | { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, | |
| 462 | { | |
| 463 | id: "r5", | |
| 464 | body: "Looks good.", | |
| 465 | state: "COMMENTED", | |
| 466 | submittedAt: "2026-07-08T00:00:00Z", | |
| 467 | }, | |
| 468 | ], | |
| 469 | }), | |
| 470 | ); | |
| 471 | ||
| 472 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); | |
| 473 | }); | |
| 474 | ||
| 475 | it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( | |
| 476 | "keeps a bodyless %s review, which is the event itself", | |
| 477 | (state) => { | |
| 478 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 479 | const detail = expectSuccess( | |
| 480 | activity({ | |
| 481 | ...raw, | |
| 482 | reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], | |
| 483 | }), | |
| 484 | ); | |
| 485 | ||
| 486 | expect(detail.comments.map((comment) => comment.id)).toContain("r6"); | |
| 487 | }, | |
| 488 | ); | |
| 489 | ||
| 490 | it("drops a review that carries neither a body nor a state", () => { | |
| 491 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | |
| 492 | const detail = expectSuccess( | |
| 493 | activity({ | |
| 494 | ...raw, | |
| 495 | reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], | |
| 496 | }), | |
| 497 | ); | |
| 498 | expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 499 | }); | |
| 500 | }); | |
| 501 | ||
| 502 | describe("review thread decoding", () => { | |
| 503 | const threadsJson = ( | |
| 504 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 505 | totalCount = nodes.length, | |
| 506 | pageInfo: Record<string, unknown> = { hasNextPage: false, endCursor: null }, | |
| 507 | ): string => | |
| 508 | JSON.stringify({ | |
| 509 | data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, | |
| 510 | }); | |
| 511 | ||
| 512 | /** The same query carries the review roster, so it is built alongside the threads. */ | |
| 513 | const reviewJson = (input: { | |
| 514 | readonly requested?: ReadonlyArray<unknown>; | |
| 515 | readonly reviewed?: ReadonlyArray<unknown>; | |
| 516 | }): string => | |
| 517 | JSON.stringify({ | |
| 518 | data: { | |
| 519 | repository: { | |
| 520 | pullRequest: { | |
| 521 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 522 | reviewRequests: { | |
| 523 | nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), | |
| 524 | }, | |
| 525 | latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, | |
| 526 | }, | |
| 527 | }, | |
| 528 | }, | |
| 529 | }); | |
| 530 | ||
| 531 | it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { | |
| 532 | const result = expectSuccess( | |
| 533 | decodeReviewThreadsJson( | |
| 534 | reviewJson({ | |
| 535 | requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], | |
| 536 | // An app that has reviewed is no longer an outstanding request, which is why asking | |
| 537 | // only for requests reported nobody on a pull request a bot had reviewed. | |
| 538 | reviewed: [ | |
| 539 | { | |
| 540 | __typename: "Bot", | |
| 541 | login: "macroscopeapp", | |
| 542 | avatarUrl: "https://avatars/in/900172.png", | |
| 543 | }, | |
| 544 | ], | |
| 545 | }), | |
| 546 | ), | |
| 547 | ); | |
| 548 | ||
| 549 | expect(result.botLogins).toEqual(new Set(["macroscopeapp"])); | |
| 550 | expect(result.reviewers).toEqual([ | |
| 551 | { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, | |
| 552 | { | |
| 553 | login: "macroscopeapp", | |
| 554 | name: null, | |
| 555 | avatarUrl: "https://avatars/in/900172.png", | |
| 556 | isBot: true, | |
| 557 | }, | |
| 558 | ]); | |
| 559 | }); | |
| 560 | ||
| 561 | it("carries per-commit line counts from the pull-request connection", () => { | |
| 562 | const result = expectSuccess( | |
| 563 | decodeReviewThreadsJson( | |
| 564 | JSON.stringify({ | |
| 565 | data: { | |
| 566 | repository: { | |
| 567 | pullRequest: { | |
| 568 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 569 | commits: { | |
| 570 | nodes: [ | |
| 571 | { commit: { oid: "abc123", additions: 18, deletions: 7 } }, | |
| 572 | { commit: { oid: "def456", additions: 3, deletions: 0 } }, | |
| 573 | ], | |
| 574 | }, | |
| 575 | }, | |
| 576 | }, | |
| 577 | }, | |
| 578 | }), | |
| 579 | ), | |
| 580 | ); | |
| 581 | ||
| 582 | expect([...result.commitStats]).toEqual([ | |
| 583 | ["abc123", { additions: 18, deletions: 7 }], | |
| 584 | ["def456", { additions: 3, deletions: 0 }], | |
| 585 | ]); | |
| 586 | }); | |
| 587 | ||
| 588 | it("omits misleading line counts from merge commits", () => { | |
| 589 | const result = expectSuccess( | |
| 590 | decodeReviewThreadsJson( | |
| 591 | JSON.stringify({ | |
| 592 | data: { | |
| 593 | repository: { | |
| 594 | pullRequest: { | |
| 595 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 596 | commits: { | |
| 597 | nodes: [ | |
| 598 | { | |
| 599 | commit: { | |
| 600 | oid: "merge123", | |
| 601 | additions: 36_858, | |
| 602 | deletions: 12_928, | |
| 603 | parents: { totalCount: 2 }, | |
| 604 | }, | |
| 605 | }, | |
| 606 | ], | |
| 607 | }, | |
| 608 | }, | |
| 609 | }, | |
| 610 | }, | |
| 611 | }), | |
| 612 | ), | |
| 613 | ); | |
| 614 | ||
| 615 | expect([...result.commitStats]).toEqual([]); | |
| 616 | }); | |
| 617 | ||
| 618 | it("decodes the newest commits off the same connection, oldest to newest", () => { | |
| 619 | const result = expectSuccess( | |
| 620 | decodeReviewThreadsJson( | |
| 621 | JSON.stringify({ | |
| 622 | data: { | |
| 623 | repository: { | |
| 624 | pullRequest: { | |
| 625 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 626 | commits: { | |
| 627 | nodes: [ | |
| 628 | { | |
| 629 | commit: { | |
| 630 | oid: "abc123", | |
| 631 | messageHeadline: "Ship the timeline", | |
| 632 | committedDate: "2026-07-05T00:00:00Z", | |
| 633 | additions: 18, | |
| 634 | deletions: 7, | |
| 635 | authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] }, | |
| 636 | }, | |
| 637 | }, | |
| 638 | { | |
| 639 | commit: { | |
| 640 | oid: "def456", | |
| 641 | messageHeadline: "Fix the flaky test", | |
| 642 | committedDate: "2026-07-06T00:00:00Z", | |
| 643 | }, | |
| 644 | }, | |
| 645 | ], | |
| 646 | }, | |
| 647 | }, | |
| 648 | }, | |
| 649 | }, | |
| 650 | }), | |
| 651 | ), | |
| 652 | ); | |
| 653 | ||
| 654 | expect(result.commits).toEqual([ | |
| 655 | { | |
| 656 | oid: "abc123", | |
| 657 | messageHeadline: "Ship the timeline", | |
| 658 | committedDate: "2026-07-05T00:00:00Z", | |
| 659 | authors: [{ login: "julius", name: "Julius", avatarUrl: null }], | |
| 660 | }, | |
| 661 | { | |
| 662 | oid: "def456", | |
| 663 | messageHeadline: "Fix the flaky test", | |
| 664 | committedDate: "2026-07-06T00:00:00Z", | |
| 665 | authors: [], | |
| 666 | }, | |
| 667 | ]); | |
| 668 | }); | |
| 669 | ||
| 670 | it("lists someone who was asked and then answered only once", () => { | |
| 671 | const result = expectSuccess( | |
| 672 | decodeReviewThreadsJson( | |
| 673 | reviewJson({ | |
| 674 | requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], | |
| 675 | reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], | |
| 676 | }), | |
| 677 | ), | |
| 678 | ); | |
| 679 | ||
| 680 | expect(result.reviewers).toHaveLength(1); | |
| 681 | }); | |
| 682 | ||
| 683 | it("skips a team request, which names nobody to show", () => { | |
| 684 | const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); | |
| 685 | ||
| 686 | expect(result.reviewers).toEqual([]); | |
| 687 | }); | |
| 688 | ||
| 689 | it("keeps the conversation when a request is from a team, which has no login", () => { | |
| 690 | // GraphQL answers with an empty object for a union member the query has no fragment for. | |
| 691 | // Failing on it would take the whole response down, comments included. | |
| 692 | const result = expectSuccess( | |
| 693 | decodeReviewThreadsJson( | |
| 694 | reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), | |
| 695 | ), | |
| 696 | ); | |
| 697 | ||
| 698 | expect(result.reviewers).toEqual([ | |
| 699 | { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, | |
| 700 | ]); | |
| 701 | }); | |
| 702 | ||
| 703 | it("carries a resolved thread into the conversation, which was still said", () => { | |
| 704 | const result = expectSuccess( | |
| 705 | decodeReviewThreadsJson( | |
| 706 | threadsJson([ | |
| 707 | { | |
| 708 | id: "PRRT_a", | |
| 709 | isResolved: false, | |
| 710 | path: "apps/server/src/ws.ts", | |
| 711 | comments: { | |
| 712 | nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], | |
| 713 | }, | |
| 714 | }, | |
| 715 | { | |
| 716 | id: "PRRT_b", | |
| 717 | isResolved: true, | |
| 718 | path: "apps/web/src/main.tsx", | |
| 719 | comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, | |
| 720 | }, | |
| 721 | ]), | |
| 722 | ), | |
| 723 | ); | |
| 724 | const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); | |
| 725 | expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); | |
| 726 | expect(comments[0]).toMatchObject({ | |
| 727 | id: "t1", | |
| 728 | kind: "review-comment", | |
| 729 | path: "apps/server/src/ws.ts", | |
| 730 | }); | |
| 731 | }); | |
| 732 | ||
| 733 | it("carries every reply, not only the remark each thread opened with", () => { | |
| 734 | const result = expectSuccess( | |
| 735 | decodeReviewThreadsJson( | |
| 736 | threadsJson([ | |
| 737 | { | |
| 738 | id: "PRRT_c", | |
| 739 | isResolved: false, | |
| 740 | path: "apps/server/src/ws.ts", | |
| 741 | comments: { | |
| 742 | nodes: [ | |
| 743 | { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, | |
| 744 | { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, | |
| 745 | ], | |
| 746 | }, | |
| 747 | }, | |
| 748 | ]), | |
| 749 | ), | |
| 750 | ); | |
| 751 | const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); | |
| 752 | expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); | |
| 753 | }); | |
| 754 | ||
| 755 | it("hands back the cursor the next page of threads carries on from", () => { | |
| 756 | const result = expectSuccess( | |
| 757 | decodeReviewThreadsJson( | |
| 758 | threadsJson( | |
| 759 | [ | |
| 760 | { | |
| 761 | id: "PRRT_d", | |
| 762 | path: "apps/server/src/ws.ts", | |
| 763 | isResolved: false, | |
| 764 | comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, | |
| 765 | }, | |
| 766 | ], | |
| 767 | 80, | |
| 768 | { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, | |
| 769 | ), | |
| 770 | ), | |
| 771 | ); | |
| 772 | expect(result.nextCursor).toBe("Y3Vyc29yOjE"); | |
| 773 | }); | |
| 774 | ||
| 775 | it("keeps GitHub's own count of a thread whose comments were not all read", () => { | |
| 776 | const result = expectSuccess( | |
| 777 | decodeReviewThreadsJson( | |
| 778 | threadsJson([ | |
| 779 | { | |
| 780 | id: "PRRT_e", | |
| 781 | path: "apps/server/src/ws.ts", | |
| 782 | isResolved: false, | |
| 783 | comments: { | |
| 784 | totalCount: 140, | |
| 785 | pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, | |
| 786 | nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], | |
| 787 | }, | |
| 788 | }, | |
| 789 | ]), | |
| 790 | ), | |
| 791 | ); | |
| 792 | expect(result.threads[0]).toMatchObject({ | |
| 793 | commentCount: 140, | |
| 794 | nextCommentCursor: "Y3Vyc29yOjI", | |
| 795 | }); | |
| 796 | }); | |
| 797 | ||
| 798 | it("ends a thread's walk on the last page, which still names a cursor", () => { | |
| 799 | const decoded = expectSuccess( | |
| 800 | decodeReviewThreadCommentsJson( | |
| 801 | JSON.stringify({ | |
| 802 | data: { | |
| 803 | repository: { pullRequest: { id: "PR_1" } }, | |
| 804 | node: { | |
| 805 | pullRequest: { id: "PR_1" }, | |
| 806 | comments: { | |
| 807 | pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, | |
| 808 | nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], | |
| 809 | }, | |
| 810 | }, | |
| 811 | }, | |
| 812 | }), | |
| 813 | ), | |
| 814 | ); | |
| 815 | expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); | |
| 816 | expect(decoded.nextCursor).toBeNull(); | |
| 817 | }); | |
| 818 | }); | |
| 819 | ||
| 820 | describe("reaction decoding", () => { | |
| 821 | const commentWithGroups = (reactionGroups: ReadonlyArray<Record<string, unknown>>) => | |
| 822 | JSON.stringify({ | |
| 823 | data: { | |
| 824 | repository: { pullRequest: { id: "PR_1" } }, | |
| 825 | node: { | |
| 826 | pullRequest: { id: "PR_1" }, | |
| 827 | comments: { | |
| 828 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 829 | nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], | |
| 830 | }, | |
| 831 | }, | |
| 832 | }, | |
| 833 | }); | |
| 834 | ||
| 835 | it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { | |
| 836 | const decoded = expectSuccess( | |
| 837 | decodeReviewThreadCommentsJson( | |
| 838 | commentWithGroups([ | |
| 839 | { | |
| 840 | content: "THUMBS_UP", | |
| 841 | viewerHasReacted: true, | |
| 842 | reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, | |
| 843 | }, | |
| 844 | // Not one of the eight the contract carries. | |
| 845 | { | |
| 846 | content: "PARTY_PARROT", | |
| 847 | reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, | |
| 848 | }, | |
| 849 | // Nobody behind it, which GitHub still answers a group for. | |
| 850 | { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, | |
| 851 | // More reactors than the bounded read named, and no `viewerHasReacted` at all. | |
| 852 | { | |
| 853 | content: "ROCKET", | |
| 854 | reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, | |
| 855 | }, | |
| 856 | ]), | |
| 857 | ), | |
| 858 | ); | |
| 859 | ||
| 860 | expect(decoded.comments[0]?.reactions).toEqual([ | |
| 861 | { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, | |
| 862 | { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, | |
| 863 | ]); | |
| 864 | }); | |
| 865 | ||
| 866 | it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { | |
| 867 | const decoded = expectSuccess( | |
| 868 | decodeReviewThreadCommentsJson( | |
| 869 | JSON.stringify({ | |
| 870 | data: { | |
| 871 | viewer: { login: "Bilal" }, | |
| 872 | repository: { pullRequest: { id: "PR_1" } }, | |
| 873 | node: { | |
| 874 | pullRequest: { id: "PR_1" }, | |
| 875 | comments: { | |
| 876 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 877 | nodes: [ | |
| 878 | { | |
| 879 | id: "t1", | |
| 880 | body: "nice", | |
| 881 | createdAt: "2026-07-01T00:00:00Z", | |
| 882 | reactionGroups: [ | |
| 883 | { | |
| 884 | content: "HEART", | |
| 885 | viewerHasReacted: true, | |
| 886 | reactors: { | |
| 887 | totalCount: 2, | |
| 888 | nodes: [{ login: "bilal" }, { login: "julius" }], | |
| 889 | }, | |
| 890 | }, | |
| 891 | ], | |
| 892 | }, | |
| 893 | ], | |
| 894 | }, | |
| 895 | }, | |
| 896 | }, | |
| 897 | }), | |
| 898 | ), | |
| 899 | ); | |
| 900 | ||
| 901 | expect(decoded.comments[0]?.reactions).toEqual([ | |
| 902 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | |
| 903 | ]); | |
| 904 | }); | |
| 905 | }); | |
| 906 | ||
| 907 | describe("repository access decoding", () => { | |
| 908 | const repositoryJson = (viewerPermission?: string | null) => | |
| 909 | JSON.stringify({ | |
| 910 | data: { | |
| 911 | repository: { | |
| 912 | pullRequest: null, | |
| 913 | mergeCommitAllowed: true, | |
| 914 | squashMergeAllowed: false, | |
| 915 | rebaseMergeAllowed: true, | |
| 916 | ...(viewerPermission === undefined ? {} : { viewerPermission }), | |
| 917 | }, | |
| 918 | }, | |
| 919 | }); | |
| 920 | ||
| 921 | it("reads merge settings with viewer permissions", () => { | |
| 922 | expect( | |
| 923 | expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, | |
| 924 | ).toEqual({ merge: true, squash: false, rebase: true }); | |
| 925 | }); | |
| 926 | ||
| 927 | it("fails rather than defaulting open when a setting is missing", () => { | |
| 928 | const decoded = decodeViewerPermissionsJson( | |
| 929 | JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), | |
| 930 | ); | |
| 931 | expect(Result.isSuccess(decoded)).toBe(false); | |
| 932 | }); | |
| 933 | ||
| 934 | it("counts the roles that can push as write, and the ones that cannot as read", () => { | |
| 935 | for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { | |
| 936 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( | |
| 937 | true, | |
| 938 | ); | |
| 939 | } | |
| 940 | for (const permission of ["TRIAGE", "READ", "NONE"]) { | |
| 941 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( | |
| 942 | false, | |
| 943 | ); | |
| 944 | } | |
| 945 | }); | |
| 946 | ||
| 947 | it("withholds write where gh names no permission, which is not a standing it gave", () => { | |
| 948 | // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes | |
| 949 | // the press, where a missing one still leaves the pull request open on its host. | |
| 950 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); | |
| 951 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); | |
| 952 | }); | |
| 953 | }); | |
| 954 | ||
| 955 | describe("viewer permission decoding", () => { | |
| 956 | const viewerJson = (repository: Record<string, unknown>) => | |
| 957 | JSON.stringify({ | |
| 958 | data: { | |
| 959 | repository: { | |
| 960 | mergeCommitAllowed: true, | |
| 961 | squashMergeAllowed: false, | |
| 962 | rebaseMergeAllowed: true, | |
| 963 | ...repository, | |
| 964 | }, | |
| 965 | }, | |
| 966 | }); | |
| 967 | ||
| 968 | it("reads the repository's role and the pull request's own viewer fields together", () => { | |
| 969 | expect( | |
| 970 | expectSuccess( | |
| 971 | decodeViewerPermissionsJson( | |
| 972 | viewerJson({ | |
| 973 | viewerPermission: "READ", | |
| 974 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 975 | }), | |
| 976 | ), | |
| 977 | ), | |
| 978 | ).toEqual({ | |
| 979 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 980 | canWrite: false, | |
| 981 | canTriage: false, | |
| 982 | canUpdate: true, | |
| 983 | didAuthor: true, | |
| 984 | }); | |
| 985 | }); | |
| 986 | ||
| 987 | it("says no to a passer-by on a repository they can only read", () => { | |
| 988 | expect( | |
| 989 | expectSuccess( | |
| 990 | decodeViewerPermissionsJson( | |
| 991 | viewerJson({ | |
| 992 | viewerPermission: "READ", | |
| 993 | pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, | |
| 994 | }), | |
| 995 | ), | |
| 996 | ), | |
| 997 | ).toEqual({ | |
| 998 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 999 | canWrite: false, | |
| 1000 | canTriage: false, | |
| 1001 | canUpdate: false, | |
| 1002 | didAuthor: false, | |
| 1003 | }); | |
| 1004 | }); | |
| 1005 | ||
| 1006 | it("reads silence as permission, but not as authorship", () => { | |
| 1007 | // A node the viewer cannot see comes back null. Updating is a permission, so an unknown | |
| 1008 | // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, | |
| 1009 | // and claiming it for someone who did not is how an author's own rules get handed out. | |
| 1010 | expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ | |
| 1011 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 1012 | canWrite: false, | |
| 1013 | canTriage: false, | |
| 1014 | canUpdate: true, | |
| 1015 | didAuthor: false, | |
| 1016 | }); | |
| 1017 | }); | |
| 1018 | ||
| 1019 | it("reads triage as enough to label, and not enough to write", () => { | |
| 1020 | const access = expectSuccess( | |
| 1021 | decodeViewerPermissionsJson( | |
| 1022 | viewerJson({ | |
| 1023 | viewerPermission: "TRIAGE", | |
| 1024 | pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, | |
| 1025 | }), | |
| 1026 | ), | |
| 1027 | ); | |
| 1028 | expect(access.canTriage).toBe(true); | |
| 1029 | expect(access.canWrite).toBe(false); | |
| 1030 | }); | |
| 1031 | }); | |
| 1032 | ||
| 1033 | describe("label candidate decoding", () => { | |
| 1034 | const labelsJson = (input: { | |
| 1035 | readonly defined: ReadonlyArray<Record<string, unknown>>; | |
| 1036 | readonly applied?: ReadonlyArray<string>; | |
| 1037 | readonly hasNextPage?: boolean; | |
| 1038 | }) => | |
| 1039 | JSON.stringify({ | |
| 1040 | data: { | |
| 1041 | repository: { | |
| 1042 | labels: { | |
| 1043 | pageInfo: { hasNextPage: input.hasNextPage ?? false }, | |
| 1044 | nodes: input.defined, | |
| 1045 | }, | |
| 1046 | pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, | |
| 1047 | }, | |
| 1048 | }, | |
| 1049 | }); | |
| 1050 | ||
| 1051 | it("marks the labels the pull request already wears", () => { | |
| 1052 | const list = expectSuccess( | |
| 1053 | decodeLabelCandidatesJson( | |
| 1054 | labelsJson({ | |
| 1055 | defined: [ | |
| 1056 | { name: "bug", color: "d73a4a", description: "Something is broken" }, | |
| 1057 | { name: "size:XL", color: "e4572e", description: null }, | |
| 1058 | ], | |
| 1059 | applied: ["size:XL"], | |
| 1060 | }), | |
| 1061 | ), | |
| 1062 | ); | |
| 1063 | expect(list.candidates).toEqual([ | |
| 1064 | { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, | |
| 1065 | { name: "size:XL", color: "e4572e", description: null, isApplied: true }, | |
| 1066 | ]); | |
| 1067 | expect(list.truncated).toBe(false); | |
| 1068 | }); | |
| 1069 | ||
| 1070 | it("keeps a worn label the repository no longer defines, so it can be taken off", () => { | |
| 1071 | const list = expectSuccess( | |
| 1072 | decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), | |
| 1073 | ); | |
| 1074 | expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ | |
| 1075 | ["legacy", true], | |
| 1076 | ["bug", false], | |
| 1077 | ]); | |
| 1078 | }); | |
| 1079 | ||
| 1080 | it("says so when the repository defines more labels than the read asked for", () => { | |
| 1081 | expect( | |
| 1082 | expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) | |
| 1083 | .truncated, | |
| 1084 | ).toBe(true); | |
| 1085 | }); | |
| 1086 | }); | |
| 1087 | ||
| 1088 | describe("review thread decoding", () => { | |
| 1089 | const threadsJson = ( | |
| 1090 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 1091 | pullRequest: Record<string, unknown> = {}, | |
| 1092 | ) => | |
| 1093 | JSON.stringify({ | |
| 1094 | data: { | |
| 1095 | repository: { | |
| 1096 | pullRequest: { | |
| 1097 | reviewThreads: { totalCount: nodes.length, nodes }, | |
| 1098 | author: null, | |
| 1099 | comments: { nodes: [] }, | |
| 1100 | reviewRequests: { nodes: [] }, | |
| 1101 | latestReviews: { nodes: [] }, | |
| 1102 | ...pullRequest, | |
| 1103 | }, | |
| 1104 | }, | |
| 1105 | }, | |
| 1106 | }); | |
| 1107 | ||
| 1108 | it("carries what the reader may do with the pull request, off the conversation read", () => { | |
| 1109 | // The same response the threads arrive in, so knowing this costs no request of its own. | |
| 1110 | expect( | |
| 1111 | expectSuccess( | |
| 1112 | decodeReviewThreadsJson( | |
| 1113 | threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), | |
| 1114 | ), | |
| 1115 | ).viewer, | |
| 1116 | ).toEqual({ canUpdate: false, didAuthor: false }); | |
| 1117 | expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ | |
| 1118 | canUpdate: true, | |
| 1119 | didAuthor: false, | |
| 1120 | }); | |
| 1121 | }); | |
| 1122 | ||
| 1123 | const comment = (id: string, body: string) => ({ | |
| 1124 | id, | |
| 1125 | author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, | |
| 1126 | body, | |
| 1127 | createdAt: "2026-07-01T00:00:00Z", | |
| 1128 | url: `https://github.com/acme/web/pull/1#discussion_r${id}`, | |
| 1129 | }); | |
| 1130 | ||
| 1131 | it("anchors a thread to its line and side, keeping the whole conversation", () => { | |
| 1132 | const reviewThreads = expectSuccess( | |
| 1133 | decodeReviewThreadsJson( | |
| 1134 | threadsJson([ | |
| 1135 | { | |
| 1136 | id: "PRRT_1", | |
| 1137 | isResolved: false, | |
| 1138 | isOutdated: false, | |
| 1139 | path: "src/a.ts", | |
| 1140 | line: 42, | |
| 1141 | diffSide: "LEFT", | |
| 1142 | comments: { | |
| 1143 | totalCount: 2, | |
| 1144 | nodes: [ | |
| 1145 | { ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" }, | |
| 1146 | comment("c2", "second"), | |
| 1147 | ], | |
| 1148 | }, | |
| 1149 | }, | |
| 1150 | ]), | |
| 1151 | ), | |
| 1152 | ); | |
| 1153 | expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ | |
| 1154 | { | |
| 1155 | id: "PRRT_1", | |
| 1156 | path: "src/a.ts", | |
| 1157 | line: 42, | |
| 1158 | side: "left", | |
| 1159 | isResolved: false, | |
| 1160 | isOutdated: false, | |
| 1161 | comments: [ | |
| 1162 | { | |
| 1163 | id: "c1", | |
| 1164 | author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, | |
| 1165 | body: "first", | |
| 1166 | createdAt: "2026-07-01T00:00:00Z", | |
| 1167 | editedAt: "2026-07-02T00:00:00Z", | |
| 1168 | url: "https://github.com/acme/web/pull/1#discussion_rc1", | |
| 1169 | reactions: [], | |
| 1170 | }, | |
| 1171 | { | |
| 1172 | id: "c2", | |
| 1173 | author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, | |
| 1174 | body: "second", | |
| 1175 | createdAt: "2026-07-01T00:00:00Z", | |
| 1176 | editedAt: null, | |
| 1177 | url: "https://github.com/acme/web/pull/1#discussion_rc2", | |
| 1178 | reactions: [], | |
| 1179 | }, | |
| 1180 | ], | |
| 1181 | }, | |
| 1182 | ]); | |
| 1183 | expect( | |
| 1184 | reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt, | |
| 1185 | ).toBe("2026-07-02T00:00:00Z"); | |
| 1186 | }); | |
| 1187 | ||
| 1188 | it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { | |
| 1189 | const reviewThreads = expectSuccess( | |
| 1190 | decodeReviewThreadsJson( | |
| 1191 | threadsJson([ | |
| 1192 | { | |
| 1193 | id: "PRRT_2", | |
| 1194 | isResolved: true, | |
| 1195 | isOutdated: true, | |
| 1196 | path: "src/a.ts", | |
| 1197 | // GitHub reports no current line once the thread has fallen off the diff. | |
| 1198 | line: null, | |
| 1199 | diffSide: "RIGHT", | |
| 1200 | comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, | |
| 1201 | }, | |
| 1202 | ]), | |
| 1203 | ), | |
| 1204 | ); | |
| 1205 | expect(reviewThreads.threads[0]?.thread).toMatchObject({ | |
| 1206 | line: null, | |
| 1207 | isOutdated: true, | |
| 1208 | isResolved: true, | |
| 1209 | }); | |
| 1210 | }); | |
| 1211 | ||
| 1212 | it("keeps a resolved thread in the conversation as well as against its line", () => { | |
| 1213 | const decoded = expectSuccess( | |
| 1214 | decodeReviewThreadsJson( | |
| 1215 | threadsJson([ | |
| 1216 | { | |
| 1217 | id: "PRRT_3", | |
| 1218 | isResolved: true, | |
| 1219 | path: "src/a.ts", | |
| 1220 | line: 7, | |
| 1221 | diffSide: "RIGHT", | |
| 1222 | comments: { totalCount: 1, nodes: [comment("c4", "done")] }, | |
| 1223 | }, | |
| 1224 | ]), | |
| 1225 | ), | |
| 1226 | ); | |
| 1227 | // A resolved conversation is finished work, not unsaid work: the timeline reads it and the | |
| 1228 | // diff pins it to its line, the same as any other. | |
| 1229 | const threads = decoded.threads.map((entry) => entry.thread); | |
| 1230 | expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); | |
| 1231 | expect(threads).toHaveLength(1); | |
| 1232 | }); | |
| 1233 | ||
| 1234 | it("reads edit times for issue comments and reviews without using reaction updates", () => { | |
| 1235 | const editedAt = "2026-10-02T12:06:00Z"; | |
| 1236 | const result = expectSuccess( | |
| 1237 | decodeReviewThreadsJson( | |
| 1238 | threadsJson([], { | |
| 1239 | comments: { | |
| 1240 | nodes: [ | |
| 1241 | { id: "edited", lastEditedAt: editedAt }, | |
| 1242 | { id: "reaction-only", lastEditedAt: null, updatedAt: editedAt }, | |
| 1243 | ], | |
| 1244 | }, | |
| 1245 | reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] }, | |
| 1246 | }), | |
| 1247 | ), | |
| 1248 | ); | |
| 1249 | expect([...result.editedAtById]).toEqual([ | |
| 1250 | ["edited", editedAt], | |
| 1251 | ["review", editedAt], | |
| 1252 | ]); | |
| 1253 | }); | |
| 1254 | ||
| 1255 | it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { | |
| 1256 | const result = expectSuccess( | |
| 1257 | decodeReviewThreadsJson( | |
| 1258 | threadsJson([], { | |
| 1259 | reactionGroups: [ | |
| 1260 | { | |
| 1261 | content: "HEART", | |
| 1262 | viewerHasReacted: true, | |
| 1263 | reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, | |
| 1264 | }, | |
| 1265 | ], | |
| 1266 | comments: { | |
| 1267 | nodes: [ | |
| 1268 | { | |
| 1269 | id: "c1", | |
| 1270 | reactionGroups: [ | |
| 1271 | { | |
| 1272 | content: "THUMBS_UP", | |
| 1273 | reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, | |
| 1274 | }, | |
| 1275 | ], | |
| 1276 | }, | |
| 1277 | ], | |
| 1278 | }, | |
| 1279 | reviews: { | |
| 1280 | nodes: [ | |
| 1281 | { | |
| 1282 | id: "r1", | |
| 1283 | reactionGroups: [ | |
| 1284 | { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, | |
| 1285 | ], | |
| 1286 | }, | |
| 1287 | ], | |
| 1288 | }, | |
| 1289 | }), | |
| 1290 | ), | |
| 1291 | ); | |
| 1292 | ||
| 1293 | expect(result.reactions).toEqual([ | |
| 1294 | { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, | |
| 1295 | ]); | |
| 1296 | expect([...result.reactionsById]).toEqual([ | |
| 1297 | ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], | |
| 1298 | ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], | |
| 1299 | ]); | |
| 1300 | }); | |
| 1301 | ||
| 1302 | it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { | |
| 1303 | const result = expectSuccess( | |
| 1304 | decodeReviewThreadsJson( | |
| 1305 | JSON.stringify({ | |
| 1306 | data: { | |
| 1307 | viewer: { login: "Bilal" }, | |
| 1308 | repository: { | |
| 1309 | pullRequest: { | |
| 1310 | reviewThreads: { totalCount: 0, nodes: [] }, | |
| 1311 | reactionGroups: [ | |
| 1312 | { | |
| 1313 | content: "HEART", | |
| 1314 | viewerHasReacted: true, | |
| 1315 | reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, | |
| 1316 | }, | |
| 1317 | ], | |
| 1318 | }, | |
| 1319 | }, | |
| 1320 | }, | |
| 1321 | }), | |
| 1322 | ), | |
| 1323 | ); | |
| 1324 | ||
| 1325 | expect(result.reactions).toEqual([ | |
| 1326 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | |
| 1327 | ]); | |
| 1328 | }); | |
| 1329 | }); | |
| 1330 | ||
| 1331 | describe("decodePullRequestNodeIdJson", () => { | |
| 1332 | it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { | |
| 1333 | expect( | |
| 1334 | expectSuccess( | |
| 1335 | decodePullRequestNodeIdJson( | |
| 1336 | JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), | |
| 1337 | ), | |
| 1338 | ), | |
| 1339 | ).toBe("PR_kwDOA"); | |
| 1340 | }); | |
| 1341 | }); | |
| 1342 | ||
| 1343 | describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { | |
| 1344 | it("caps the initial query after the 104-point rate-limit regression", () => { | |
| 1345 | const match = REVIEW_THREADS_GRAPHQL_QUERY.match( | |
| 1346 | /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u, | |
| 1347 | ); | |
| 1348 | ||
| 1349 | expect(match).not.toBeNull(); | |
| 1350 | if (match === null) throw new Error("expected review-thread connections"); | |
| 1351 | expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000); | |
| 1352 | }); | |
| 1353 | ||
| 1354 | it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { | |
| 1355 | expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); | |
| 1356 | // The reviews connection is new: only reactions were ever wanted off it. | |
| 1357 | expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); | |
| 1358 | }); | |
| 1359 | }); | |
| 1360 | ||
| 1361 | describe("reviewer candidate decoding", () => { | |
| 1362 | const candidatesJson = (input: { | |
| 1363 | readonly assignable: ReadonlyArray<Record<string, unknown> | null>; | |
| 1364 | readonly requested?: ReadonlyArray<Record<string, unknown> | null>; | |
| 1365 | readonly author?: string; | |
| 1366 | readonly hasNextPage?: boolean; | |
| 1367 | }) => | |
| 1368 | JSON.stringify({ | |
| 1369 | data: { | |
| 1370 | repository: { | |
| 1371 | assignableUsers: { | |
| 1372 | pageInfo: { hasNextPage: input.hasNextPage ?? false }, | |
| 1373 | nodes: input.assignable, | |
| 1374 | }, | |
| 1375 | pullRequest: { | |
| 1376 | author: input.author === undefined ? null : { login: input.author }, | |
| 1377 | reviewRequests: { | |
| 1378 | nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), | |
| 1379 | }, | |
| 1380 | }, | |
| 1381 | }, | |
| 1382 | }, | |
| 1383 | }); | |
| 1384 | ||
| 1385 | it("leaves the author out of the people their own pull request can be sent to", () => { | |
| 1386 | const list = expectSuccess( | |
| 1387 | decodeReviewerCandidatesJson( | |
| 1388 | candidatesJson({ | |
| 1389 | assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], | |
| 1390 | author: "bilal", | |
| 1391 | }), | |
| 1392 | ), | |
| 1393 | ); | |
| 1394 | expect(list.candidates).toEqual([ | |
| 1395 | { | |
| 1396 | id: "octocat", | |
| 1397 | kind: "user", | |
| 1398 | login: "octocat", | |
| 1399 | name: "The Octocat", | |
| 1400 | avatarUrl: null, | |
| 1401 | isRequested: false, | |
| 1402 | }, | |
| 1403 | ]); | |
| 1404 | expect(list.truncated).toBe(false); | |
| 1405 | }); | |
| 1406 | ||
| 1407 | it("marks whoever has already been asked, and leaves the rest to be asked", () => { | |
| 1408 | const list = expectSuccess( | |
| 1409 | decodeReviewerCandidatesJson( | |
| 1410 | candidatesJson({ | |
| 1411 | assignable: [{ login: "octocat" }, { login: "hubot" }], | |
| 1412 | requested: [{ login: "octocat" }], | |
| 1413 | }), | |
| 1414 | ), | |
| 1415 | ); | |
| 1416 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 1417 | ["octocat", true], | |
| 1418 | ["hubot", false], | |
| 1419 | ]); | |
| 1420 | }); | |
| 1421 | ||
| 1422 | it("keeps a requested team apart from the people, so the request can be taken back", () => { | |
| 1423 | // A team is never among the assignable users, and a request that cannot be seen cannot be | |
| 1424 | // undone — so the ones GitHub reports are carried, marked as the teams they are. | |
| 1425 | const list = expectSuccess( | |
| 1426 | decodeReviewerCandidatesJson( | |
| 1427 | candidatesJson({ | |
| 1428 | assignable: [{ login: "octocat" }], | |
| 1429 | requested: [{ slug: "reviewers", name: "Reviewers" }], | |
| 1430 | }), | |
| 1431 | ), | |
| 1432 | ); | |
| 1433 | expect(list.candidates).toEqual([ | |
| 1434 | { | |
| 1435 | id: "reviewers", | |
| 1436 | kind: "team", | |
| 1437 | login: "reviewers", | |
| 1438 | name: "Reviewers", | |
| 1439 | avatarUrl: null, | |
| 1440 | isRequested: true, | |
| 1441 | }, | |
| 1442 | { | |
| 1443 | id: "octocat", | |
| 1444 | kind: "user", | |
| 1445 | login: "octocat", | |
| 1446 | name: null, | |
| 1447 | avatarUrl: null, | |
| 1448 | isRequested: false, | |
| 1449 | }, | |
| 1450 | ]); | |
| 1451 | }); | |
| 1452 | ||
| 1453 | it("says so when the repository has more people than the read asked for", () => { | |
| 1454 | expect( | |
| 1455 | expectSuccess( | |
| 1456 | decodeReviewerCandidatesJson( | |
| 1457 | candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), | |
| 1458 | ), | |
| 1459 | ).truncated, | |
| 1460 | ).toBe(true); | |
| 1461 | }); | |
| 1462 | }); | |
| 1463 | ||
| 1464 | describe("reviewer request payload", () => { | |
| 1465 | it("sends people and teams in the two lists GitHub keeps them in", () => { | |
| 1466 | expect( | |
| 1467 | buildReviewerRequest([ | |
| 1468 | { id: "octocat", kind: "user" }, | |
| 1469 | { id: "reviewers", kind: "team" }, | |
| 1470 | { id: "hubot", kind: "user" }, | |
| 1471 | ]), | |
| 1472 | ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); | |
| 1473 | }); | |
| 1474 | ||
| 1475 | it("sends both lists even where one of them is empty, which is what GitHub reads", () => { | |
| 1476 | expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ | |
| 1477 | reviewers: ["octocat"], | |
| 1478 | team_reviewers: [], | |
| 1479 | }); | |
| 1480 | }); | |
| 1481 | }); | |
| 1482 | ||
| 1483 | describe("review submission payload", () => { | |
| 1484 | it("sends the verdict, the summary and every line comment in one body", () => { | |
| 1485 | const payload = buildReviewSubmission({ | |
| 1486 | verdict: "request-changes", | |
| 1487 | body: "Two things.", | |
| 1488 | comments: [ | |
| 1489 | { | |
| 1490 | path: "src/a.ts", | |
| 1491 | position: { kind: "added", newLine: 12 }, | |
| 1492 | body: "rename this", | |
| 1493 | }, | |
| 1494 | { | |
| 1495 | path: "src/b.ts", | |
| 1496 | position: { kind: "deleted", oldLine: 3 }, | |
| 1497 | body: "why remove?", | |
| 1498 | }, | |
| 1499 | ], | |
| 1500 | }); | |
| 1501 | expect(payload).toEqual({ | |
| 1502 | event: "REQUEST_CHANGES", | |
| 1503 | body: "Two things.", | |
| 1504 | comments: [ | |
| 1505 | { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, | |
| 1506 | { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, | |
| 1507 | ], | |
| 1508 | }); | |
| 1509 | }); | |
| 1510 | ||
| 1511 | it("sends an approval with no words and no comments", () => { | |
| 1512 | expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ | |
| 1513 | event: "APPROVE", | |
| 1514 | body: "", | |
| 1515 | comments: [], | |
| 1516 | }); | |
| 1517 | }); | |
| 1518 | }); | |
| 1519 | ||
| 1520 | describe("decodePullRequestFilesJson", () => { | |
| 1521 | it("quotes literal backslashes without interpreting them as escapes", () => { | |
| 1522 | const result = expectSuccess( | |
| 1523 | decodePullRequestFilesJson( | |
| 1524 | JSON.stringify([ | |
| 1525 | { | |
| 1526 | filename: String.raw`src\notes.ts`, | |
| 1527 | status: "modified", | |
| 1528 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1529 | }, | |
| 1530 | ]), | |
| 1531 | ), | |
| 1532 | ); | |
| 1533 | ||
| 1534 | expect(result.patch).toBe( | |
| 1535 | [ | |
| 1536 | String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, | |
| 1537 | String.raw`--- "a/src\\notes.ts"`, | |
| 1538 | String.raw`+++ "b/src\\notes.ts"`, | |
| 1539 | "@@ -1 +1 @@", | |
| 1540 | "-old", | |
| 1541 | "+new", | |
| 1542 | "", | |
| 1543 | ].join("\n"), | |
| 1544 | ); | |
| 1545 | }); | |
| 1546 | ||
| 1547 | it("preserves spaces and literal backslashes in both rename paths", () => { | |
| 1548 | const result = expectSuccess( | |
| 1549 | decodePullRequestFilesJson( | |
| 1550 | JSON.stringify([ | |
| 1551 | { | |
| 1552 | previous_filename: String.raw` old\name.ts `, | |
| 1553 | filename: String.raw` new\name.ts `, | |
| 1554 | status: "renamed", | |
| 1555 | }, | |
| 1556 | ]), | |
| 1557 | ), | |
| 1558 | ); | |
| 1559 | ||
| 1560 | expect(result.patch).toBe( | |
| 1561 | [ | |
| 1562 | String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, | |
| 1563 | String.raw`rename from " old\\name.ts "`, | |
| 1564 | String.raw`rename to " new\\name.ts "`, | |
| 1565 | String.raw`--- "a/ old\\name.ts "`, | |
| 1566 | String.raw`+++ "b/ new\\name.ts "`, | |
| 1567 | "", | |
| 1568 | ].join("\n"), | |
| 1569 | ); | |
| 1570 | }); | |
| 1571 | ||
| 1572 | it("assembles a unified patch the files API does not return", () => { | |
| 1573 | const result = expectSuccess( | |
| 1574 | decodePullRequestFilesJson( | |
| 1575 | JSON.stringify([ | |
| 1576 | { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, | |
| 1577 | ]), | |
| 1578 | ), | |
| 1579 | ); | |
| 1580 | ||
| 1581 | expect(result.patch).toBe( | |
| 1582 | [ | |
| 1583 | "diff --git a/src/app.ts b/src/app.ts", | |
| 1584 | "--- a/src/app.ts", | |
| 1585 | "+++ b/src/app.ts", | |
| 1586 | "@@ -1 +1 @@", | |
| 1587 | "-old", | |
| 1588 | "+new", | |
| 1589 | "", | |
| 1590 | ].join("\n"), | |
| 1591 | ); | |
| 1592 | expect(result.truncated).toBe(false); | |
| 1593 | expect(result.rawCount).toBe(1); | |
| 1594 | }); | |
| 1595 | ||
| 1596 | it("points an added file at /dev/null on the left and a removed one on the right", () => { | |
| 1597 | const result = expectSuccess( | |
| 1598 | decodePullRequestFilesJson( | |
| 1599 | JSON.stringify([ | |
| 1600 | { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, | |
| 1601 | { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, | |
| 1602 | ]), | |
| 1603 | ), | |
| 1604 | ); | |
| 1605 | ||
| 1606 | expect(result.patch).toBe( | |
| 1607 | [ | |
| 1608 | "diff --git a/src/new.ts b/src/new.ts", | |
| 1609 | "new file mode 100644", | |
| 1610 | "--- /dev/null", | |
| 1611 | "+++ b/src/new.ts", | |
| 1612 | "@@ -0,0 +1 @@", | |
| 1613 | "+hello", | |
| 1614 | "diff --git a/src/gone.ts b/src/gone.ts", | |
| 1615 | "deleted file mode 100644", | |
| 1616 | "--- a/src/gone.ts", | |
| 1617 | "+++ /dev/null", | |
| 1618 | "@@ -1 +0,0 @@", | |
| 1619 | "-bye", | |
| 1620 | "", | |
| 1621 | ].join("\n"), | |
| 1622 | ); | |
| 1623 | }); | |
| 1624 | ||
| 1625 | it("names both paths of a rename, counting its hunks against the old one", () => { | |
| 1626 | const result = expectSuccess( | |
| 1627 | decodePullRequestFilesJson( | |
| 1628 | JSON.stringify([ | |
| 1629 | { | |
| 1630 | filename: "src/new.ts", | |
| 1631 | status: "renamed", | |
| 1632 | previous_filename: "src/old.ts", | |
| 1633 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1634 | }, | |
| 1635 | ]), | |
| 1636 | ), | |
| 1637 | ); | |
| 1638 | ||
| 1639 | expect(result.patch).toBe( | |
| 1640 | [ | |
| 1641 | "diff --git a/src/old.ts b/src/new.ts", | |
| 1642 | "rename from src/old.ts", | |
| 1643 | "rename to src/new.ts", | |
| 1644 | "--- a/src/old.ts", | |
| 1645 | "+++ b/src/new.ts", | |
| 1646 | "@@ -1 +1 @@", | |
| 1647 | "-old", | |
| 1648 | "+new", | |
| 1649 | "", | |
| 1650 | ].join("\n"), | |
| 1651 | ); | |
| 1652 | }); | |
| 1653 | ||
| 1654 | it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { | |
| 1655 | const result = expectSuccess( | |
| 1656 | decodePullRequestFilesJson( | |
| 1657 | JSON.stringify([ | |
| 1658 | // Binary: it changed, and none of it can be shown. | |
| 1659 | { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, | |
| 1660 | { | |
| 1661 | filename: "src/app.ts", | |
| 1662 | status: "modified", | |
| 1663 | additions: 1, | |
| 1664 | deletions: 1, | |
| 1665 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 1666 | }, | |
| 1667 | ]), | |
| 1668 | ), | |
| 1669 | ); | |
| 1670 | ||
| 1671 | // Dropping it would take the file out of the change altogether, not just its contents. | |
| 1672 | expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); | |
| 1673 | expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); | |
| 1674 | expect(result.truncated).toBe(true); | |
| 1675 | expect(result.rawCount).toBe(2); | |
| 1676 | }); | |
| 1677 | ||
| 1678 | it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { | |
| 1679 | const result = expectSuccess( | |
| 1680 | decodePullRequestFilesJson( | |
| 1681 | JSON.stringify([ | |
| 1682 | { | |
| 1683 | filename: "src/new.ts", | |
| 1684 | previous_filename: "src/old.ts", | |
| 1685 | status: "renamed", | |
| 1686 | additions: 0, | |
| 1687 | deletions: 0, | |
| 1688 | }, | |
| 1689 | ]), | |
| 1690 | ), | |
| 1691 | ); | |
| 1692 | ||
| 1693 | expect(result.patch).toContain("rename from src/old.ts"); | |
| 1694 | expect(result.truncated).toBe(false); | |
| 1695 | }); | |
| 1696 | }); | |
| 1697 | ||
| 1698 | describe("decodePullRequestFilesViewedJson", () => { | |
| 1699 | const page = ( | |
| 1700 | nodes: ReadonlyArray<unknown>, | |
| 1701 | pageInfo: { hasNextPage: boolean; endCursor: string | null }, | |
| 1702 | ) => | |
| 1703 | JSON.stringify({ | |
| 1704 | data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, | |
| 1705 | }); | |
| 1706 | ||
| 1707 | it("reads each file's state and where the next page carries on", () => { | |
| 1708 | const decoded = decodePullRequestFilesViewedJson( | |
| 1709 | page( | |
| 1710 | [ | |
| 1711 | { path: "src/a.ts", viewerViewedState: "VIEWED" }, | |
| 1712 | { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, | |
| 1713 | { path: "src/c.ts", viewerViewedState: "DISMISSED" }, | |
| 1714 | ], | |
| 1715 | { hasNextPage: true, endCursor: "cursor-2" }, | |
| 1716 | ), | |
| 1717 | ); | |
| 1718 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1719 | if (!Result.isSuccess(decoded)) return; | |
| 1720 | expect(decoded.success).toEqual({ | |
| 1721 | files: [ | |
| 1722 | { path: "src/a.ts", state: "viewed" }, | |
| 1723 | { path: "src/b.ts", state: "unviewed" }, | |
| 1724 | { path: "src/c.ts", state: "dismissed" }, | |
| 1725 | ], | |
| 1726 | nextCursor: "cursor-2", | |
| 1727 | }); | |
| 1728 | }); | |
| 1729 | ||
| 1730 | it("treats a state it has never heard of as unread rather than failing the page", () => { | |
| 1731 | const decoded = decodePullRequestFilesViewedJson( | |
| 1732 | page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { | |
| 1733 | hasNextPage: false, | |
| 1734 | endCursor: null, | |
| 1735 | }), | |
| 1736 | ); | |
| 1737 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1738 | if (!Result.isSuccess(decoded)) return; | |
| 1739 | expect(decoded.success).toEqual({ | |
| 1740 | files: [{ path: "src/a.ts", state: "unviewed" }], | |
| 1741 | nextCursor: null, | |
| 1742 | }); | |
| 1743 | }); | |
| 1744 | ||
| 1745 | it("answers empty for a pull request the host has nothing to say about", () => { | |
| 1746 | const decoded = decodePullRequestFilesViewedJson( | |
| 1747 | JSON.stringify({ data: { repository: { pullRequest: null } } }), | |
| 1748 | ); | |
| 1749 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1750 | if (!Result.isSuccess(decoded)) return; | |
| 1751 | expect(decoded.success).toEqual({ files: [], nextCursor: null }); | |
| 1752 | }); | |
| 1753 | }); | |
| 1754 | ||
| 1755 | describe("buildSetFilesViewedGraphQlMutation", () => { | |
| 1756 | it("asks for nothing when nothing was pressed", () => { | |
| 1757 | expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); | |
| 1758 | }); | |
| 1759 | ||
| 1760 | it("clears and restores in one document, each file under its own alias", () => { | |
| 1761 | const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ | |
| 1762 | { path: "src/a.ts", viewed: true }, | |
| 1763 | { path: "src/b.ts", viewed: false }, | |
| 1764 | ]); | |
| 1765 | expect(mutation).not.toBeNull(); | |
| 1766 | if (mutation === null) return; | |
| 1767 | expect(mutation.query).toContain( | |
| 1768 | "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", | |
| 1769 | ); | |
| 1770 | expect(mutation.query).toContain( | |
| 1771 | "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", | |
| 1772 | ); | |
| 1773 | expect(mutation.query).toContain( | |
| 1774 | "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", | |
| 1775 | ); | |
| 1776 | expect(mutation.variables).toEqual({ | |
| 1777 | pullRequestId: "PR_1", | |
| 1778 | f0_path: "src/a.ts", | |
| 1779 | f1_path: "src/b.ts", | |
| 1780 | }); | |
| 1781 | }); | |
| 1782 | ||
| 1783 | it("keeps a path out of the document, so one cannot be read as part of it", () => { | |
| 1784 | const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ | |
| 1785 | { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, | |
| 1786 | ]); | |
| 1787 | expect(mutation).not.toBeNull(); | |
| 1788 | if (mutation === null) return; | |
| 1789 | expect(mutation.query).not.toContain("evil"); | |
| 1790 | expect(mutation.variables.f0_path).toBe( | |
| 1791 | '") { __typename } evil: markFileAsViewed(input: { path: "x', | |
| 1792 | ); | |
| 1793 | }); | |
| 1794 | }); | |
| 1795 | ||
| 1796 | describe("host-native stack decoding", () => { | |
| 1797 | /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ | |
| 1798 | function stack(overrides: Record<string, unknown> = {}) { | |
| 1799 | return { | |
| 1800 | id: 42, | |
| 1801 | number: 3, | |
| 1802 | node_id: "STK_kwDO", | |
| 1803 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1804 | base: { ref: "main", sha: "abc" }, | |
| 1805 | open: true, | |
| 1806 | created_at: "2026-09-01T00:00:00Z", | |
| 1807 | pull_requests: [ | |
| 1808 | { | |
| 1809 | number: 10, | |
| 1810 | head: { ref: "feat/one" }, | |
| 1811 | state: "closed", | |
| 1812 | merged_at: "2026-09-02T00:00:00Z", | |
| 1813 | }, | |
| 1814 | { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 1815 | { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, | |
| 1816 | ], | |
| 1817 | ...overrides, | |
| 1818 | }; | |
| 1819 | } | |
| 1820 | ||
| 1821 | /** The one stack a listing answered with, which these reads all expect to find. */ | |
| 1822 | function expectStack(overrides: Record<string, unknown> = {}) { | |
| 1823 | const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); | |
| 1824 | if (decoded === null) throw new Error("expected a stack"); | |
| 1825 | return decoded; | |
| 1826 | } | |
| 1827 | ||
| 1828 | it("reads the first stack, bottom to top, with merged_at outranking state", () => { | |
| 1829 | expect(expectStack()).toEqual({ | |
| 1830 | id: "42", | |
| 1831 | number: 3, | |
| 1832 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1833 | base: "main", | |
| 1834 | layers: [ | |
| 1835 | { number: 10, headBranch: "feat/one", state: "merged" }, | |
| 1836 | { number: 11, headBranch: "feat/two", state: "open" }, | |
| 1837 | { number: 12, headBranch: "feat/three", state: "closed" }, | |
| 1838 | ], | |
| 1839 | }); | |
| 1840 | }); | |
| 1841 | ||
| 1842 | it("retains the detailed layer titles, draft state and expected revision", () => { | |
| 1843 | expect( | |
| 1844 | expectStack({ | |
| 1845 | pull_requests: [ | |
| 1846 | { | |
| 1847 | number: 11, | |
| 1848 | title: "Second layer", | |
| 1849 | draft: true, | |
| 1850 | head: { ref: "feat/two", sha: "abc123" }, | |
| 1851 | state: "open", | |
| 1852 | merged_at: null, | |
| 1853 | }, | |
| 1854 | ], | |
| 1855 | }).layers, | |
| 1856 | ).toEqual([ | |
| 1857 | { | |
| 1858 | number: 11, | |
| 1859 | title: "Second layer", | |
| 1860 | isDraft: true, | |
| 1861 | headSha: "abc123", | |
| 1862 | headBranch: "feat/two", | |
| 1863 | state: "open", | |
| 1864 | }, | |
| 1865 | ]); | |
| 1866 | }); | |
| 1867 | ||
| 1868 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { | |
| 1869 | expect(expectStack({ base: "develop" }).base).toBe("develop"); | |
| 1870 | }); | |
| 1871 | ||
| 1872 | it("prefers the page a person opens over the API URL, where the host reports one", () => { | |
| 1873 | expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( | |
| 1874 | "https://github.com/acme/web/stacks/3", | |
| 1875 | ); | |
| 1876 | }); | |
| 1877 | ||
| 1878 | it("falls back to the node id, then the number, for a stack without an id", () => { | |
| 1879 | expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); | |
| 1880 | expect(expectStack({ id: null, node_id: null }).id).toBe("3"); | |
| 1881 | }); | |
| 1882 | ||
| 1883 | it("reads an empty listing as not stacked", () => { | |
| 1884 | expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); | |
| 1885 | }); | |
| 1886 | ||
| 1887 | it("refuses a stack without a number or without its pull requests", () => { | |
| 1888 | expect( | |
| 1889 | Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), | |
| 1890 | ).toBe(false); | |
| 1891 | expect( | |
| 1892 | Result.isSuccess( | |
| 1893 | decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), | |
| 1894 | ), | |
| 1895 | ).toBe(false); | |
| 1896 | expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); | |
| 1897 | }); | |
| 1898 | }); | |
| 1899 | ||
| 1900 | describe("pull request stack membership batches", () => { | |
| 1901 | it("maps aliases while skipping missing pull requests and incomplete memberships", () => { | |
| 1902 | const memberships = expectSuccess( | |
| 1903 | decodePullRequestStackMembershipsJson( | |
| 1904 | JSON.stringify({ | |
| 1905 | data: { | |
| 1906 | s0: { | |
| 1907 | pullRequest: { | |
| 1908 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1909 | stackEntry: { position: 1 }, | |
| 1910 | }, | |
| 1911 | }, | |
| 1912 | s1: null, | |
| 1913 | s2: { pullRequest: null }, | |
| 1914 | s3: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1915 | s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, | |
| 1916 | }, | |
| 1917 | }), | |
| 1918 | ), | |
| 1919 | ); | |
| 1920 | expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); | |
| 1921 | }); | |
| 1922 | ||
| 1923 | it("refuses malformed responses and unsafe query selectors", () => { | |
| 1924 | expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); | |
| 1925 | expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); | |
| 1926 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); | |
| 1927 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); | |
| 1928 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); | |
| 1929 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ | |
| 1930 | s0_owner: "acme", | |
| 1931 | s0_name: "web", | |
| 1932 | s0_number: 7, | |
| 1933 | s1_owner: "acme", | |
| 1934 | s1_name: "web", | |
| 1935 | s1_number: 8, | |
| 1936 | }); | |
| 1937 | }); | |
| 1938 | }); | |
| 1939 | ||
| 1940 | describe("batched pull request summaries", () => { | |
| 1941 | it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { | |
| 1942 | expect( | |
| 1943 | buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), | |
| 1944 | ).toBeNull(); | |
| 1945 | expect( | |
| 1946 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), | |
| 1947 | ).toBeNull(); | |
| 1948 | expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); | |
| 1949 | }); | |
| 1950 | ||
| 1951 | it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { | |
| 1952 | const decoded = decodePullRequestSummariesJson( | |
| 1953 | JSON.stringify({ | |
| 1954 | data: { | |
| 1955 | s0: { | |
| 1956 | pullRequest: { | |
| 1957 | number: 7, | |
| 1958 | title: "Merged", | |
| 1959 | url: "https://github.com/acme/web/pull/7", | |
| 1960 | author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, | |
| 1961 | headRefName: "feat/seven", | |
| 1962 | baseRefName: "main", | |
| 1963 | state: "MERGED", | |
| 1964 | mergedAt: "2026-08-24T00:00:00Z", | |
| 1965 | closedAt: "2026-08-24T00:00:00Z", | |
| 1966 | updatedAt: "2026-08-24T00:00:00Z", | |
| 1967 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, | |
| 1968 | }, | |
| 1969 | }, | |
| 1970 | s1: { pullRequest: null }, | |
| 1971 | s2: { pullRequest: { number: 9 } }, | |
| 1972 | rateLimit: { cost: 1 }, | |
| 1973 | }, | |
| 1974 | }), | |
| 1975 | ); | |
| 1976 | expect(Result.isSuccess(decoded)).toBe(true); | |
| 1977 | if (!Result.isSuccess(decoded)) return; | |
| 1978 | expect([...decoded.success.keys()]).toEqual([0]); | |
| 1979 | expect(decoded.success.get(0)).toMatchObject({ | |
| 1980 | number: 7, | |
| 1981 | state: "merged", | |
| 1982 | mergedAt: "2026-08-24T00:00:00Z", | |
| 1983 | author: { login: "renovate", isBot: true }, | |
| 1984 | checksState: "failing", | |
| 1985 | mergeability: "unknown", | |
| 1986 | additions: 0, | |
| 1987 | }); | |
| 1988 | // The document did not ask about stacks, so the summary does not claim an answer. | |
| 1989 | expect(decoded.success.get(0)).not.toHaveProperty("stack"); | |
| 1990 | }); | |
| 1991 | ||
| 1992 | it("reads stack membership only where the document asked for it", () => { | |
| 1993 | expect( | |
| 1994 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, | |
| 1995 | ).toContain("stack { number size baseRefName } stackEntry { position }"); | |
| 1996 | expect( | |
| 1997 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, | |
| 1998 | ).not.toContain("stack {"); | |
| 1999 | const pullRequest = (number: number, stack: Record<string, unknown>) => ({ | |
| 2000 | pullRequest: { | |
| 2001 | number, | |
| 2002 | title: "Stacked", | |
| 2003 | url: `https://github.com/acme/web/pull/${number}`, | |
| 2004 | headRefName: `feat/${number}`, | |
| 2005 | baseRefName: "main", | |
| 2006 | state: "OPEN", | |
| 2007 | updatedAt: "2026-08-24T00:00:00Z", | |
| 2008 | ...stack, | |
| 2009 | }, | |
| 2010 | }); | |
| 2011 | const decoded = expectSuccess( | |
| 2012 | decodePullRequestSummariesJson( | |
| 2013 | JSON.stringify({ | |
| 2014 | data: { | |
| 2015 | s0: pullRequest(7, { stack: null, stackEntry: null }), | |
| 2016 | s1: pullRequest(8, { | |
| 2017 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 2018 | stackEntry: { position: 2 }, | |
| 2019 | }), | |
| 2020 | }, | |
| 2021 | }), | |
| 2022 | ), | |
| 2023 | ); | |
| 2024 | expect(decoded.get(0)?.stack).toBeNull(); | |
| 2025 | expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 }); | |
| 2026 | }); | |
| 2027 | }); | |
| 2028 | ||
| 2029 | describe("pull request watch fingerprints", () => { | |
| 2030 | it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { | |
| 2031 | const document = buildPullRequestWatchFingerprintsGraphQlQuery([ | |
| 2032 | { repository: "pingdotgg/t3code", number: 7 }, | |
| 2033 | { repository: "pingdotgg/lakebed", number: 8 }, | |
| 2034 | ]); | |
| 2035 | expect(document?.query).toContain( | |
| 2036 | "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", | |
| 2037 | ); | |
| 2038 | expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); | |
| 2039 | expect(document?.variables).toEqual({ | |
| 2040 | w0_owner: "pingdotgg", | |
| 2041 | w0_name: "t3code", | |
| 2042 | w0_number: 7, | |
| 2043 | w1_owner: "pingdotgg", | |
| 2044 | w1_name: "lakebed", | |
| 2045 | w1_number: 8, | |
| 2046 | }); | |
| 2047 | expect( | |
| 2048 | buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), | |
| 2049 | ).toBeNull(); | |
| 2050 | }); | |
| 2051 | ||
| 2052 | it("moves status with checks and remarks with comments, each without the other", () => { | |
| 2053 | const pullRequest = (input: { | |
| 2054 | readonly running: number; | |
| 2055 | readonly failed: number; | |
| 2056 | readonly commentEditedAt: string | null; | |
| 2057 | readonly reviews: number; | |
| 2058 | }) => ({ | |
| 2059 | state: "OPEN", | |
| 2060 | mergeable: "MERGEABLE", | |
| 2061 | headRefOid: "abc123", | |
| 2062 | comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] }, | |
| 2063 | reviews: { totalCount: input.reviews, nodes: [] }, | |
| 2064 | reviewThreads: { totalCount: 0 }, | |
| 2065 | commits: { | |
| 2066 | nodes: [ | |
| 2067 | { | |
| 2068 | commit: { | |
| 2069 | statusCheckRollup: { | |
| 2070 | contexts: { | |
| 2071 | checkRunCountsByState: [ | |
| 2072 | { state: "IN_PROGRESS", count: input.running }, | |
| 2073 | { state: "FAILURE", count: input.failed }, | |
| 2074 | { state: "SUCCESS", count: 0 }, | |
| 2075 | ], | |
| 2076 | statusContextCountsByState: null, | |
| 2077 | }, | |
| 2078 | }, | |
| 2079 | }, | |
| 2080 | }, | |
| 2081 | ], | |
| 2082 | }, | |
| 2083 | }); | |
| 2084 | const decode = (...pullRequests: ReadonlyArray<object | null>) => { | |
| 2085 | const decoded = decodePullRequestWatchFingerprintsJson( | |
| 2086 | JSON.stringify({ | |
| 2087 | data: Object.fromEntries( | |
| 2088 | pullRequests.map((value, index) => [`w${index}`, { pullRequest: value }]), | |
| 2089 | ), | |
| 2090 | }), | |
| 2091 | ); | |
| 2092 | if (!Result.isSuccess(decoded)) throw new Error("fingerprints did not decode"); | |
| 2093 | return decoded.success; | |
| 2094 | }; | |
| 2095 | const base = { running: 2, failed: 0, commentEditedAt: null, reviews: 1 }; | |
| 2096 | const fingerprint = (input: Parameters<typeof pullRequest>[0]) => | |
| 2097 | decode(pullRequest(input)).get(0)!; | |
| 2098 | const before = fingerprint(base); | |
| 2099 | const checkFinished = fingerprint({ ...base, running: 1, failed: 1 }); | |
| 2100 | const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" }); | |
| 2101 | const replied = fingerprint({ ...base, reviews: 2 }); | |
| 2102 | ||
| 2103 | expect(checkFinished.status).not.toBe(before.status); | |
| 2104 | expect(checkFinished.remarks).toBe(before.remarks); | |
| 2105 | expect(summaryEdited.remarks).not.toBe(before.remarks); | |
| 2106 | expect(summaryEdited.status).toBe(before.status); | |
| 2107 | expect(replied.remarks).not.toBe(before.remarks); | |
| 2108 | // A pull request GitHub had no answer for is left for a full read. | |
| 2109 | expect(decode(null, pullRequest(base)).has(0)).toBe(false); | |
| 2110 | }); | |
| 2111 | }); |
packages/source-control-github/src/server/gitHubPullRequestJson.ts created+3462| ... | ... | @@ -0,0 +1,3462 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Result from "effect/Result"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { | |
| 6 | PullRequestStackMembership, | |
| 7 | PullRequestActor, | |
| 8 | PullRequestPreview, | |
| 9 | PullRequestCheck, | |
| 10 | PullRequestCheckStatus, | |
| 11 | PullRequestChecksState, | |
| 12 | PullRequestComment, | |
| 13 | PullRequestCommit, | |
| 14 | PullRequestFileViewedState, | |
| 15 | PullRequestLabel, | |
| 16 | PullRequestMergeCapabilities, | |
| 17 | PullRequestMergeMethod, | |
| 18 | PullRequestOmittedFileStat, | |
| 19 | PullRequestMergeability, | |
| 20 | PullRequestReaction, | |
| 21 | PullRequestReactionContent, | |
| 22 | PullRequestReviewCommentDraft, | |
| 23 | PullRequestReviewDecision, | |
| 24 | PullRequestReviewPosition, | |
| 25 | PullRequestReviewThread, | |
| 26 | PullRequestReviewVerdict, | |
| 27 | PullRequestReviewerCandidate, | |
| 28 | PullRequestReviewerCandidateList, | |
| 29 | PullRequestReviewerKind, | |
| 30 | PullRequestLabelCandidate, | |
| 31 | PullRequestLabelCandidateList, | |
| 32 | PullRequestState, | |
| 33 | PullRequestThreadComment, | |
| 34 | } from "@t3tools/contracts"; | |
| 35 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 36 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 37 | ||
| 38 | import { aliasedGraphQlDocument, type GraphQlDocument } from "./githubGraphQl.ts"; | |
| 39 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | |
| 40 | ||
| 41 | /** | |
| 42 | * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` | |
| 43 | * release that adds a conclusion or a review state must not fail the whole payload. | |
| 44 | */ | |
| 45 | const RawActorSchema = Schema.Struct({ | |
| 46 | __typename: Schema.optional(Schema.String), | |
| 47 | is_bot: Schema.optional(Schema.Boolean), | |
| 48 | /** | |
| 49 | * Optional because a review can be requested from a team or a mannequin, which the query has | |
| 50 | * no fragment for and GraphQL answers with an empty object. A reviewer with no login names | |
| 51 | * nobody to show, and must not fail the response the conversation travels in. | |
| 52 | */ | |
| 53 | login: Schema.optional(Schema.String), | |
| 54 | /** The node id, which is how a listing's authors are resolved to avatars in one request. */ | |
| 55 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 56 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 57 | /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ | |
| 58 | avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 59 | }); | |
| 60 | ||
| 61 | const RawLabelSchema = Schema.Struct({ | |
| 62 | name: Schema.String, | |
| 63 | color: Schema.optional(Schema.NullOr(Schema.String)), | |
| 64 | }); | |
| 65 | ||
| 66 | const RawReviewRequestSchema = Schema.Struct({ | |
| 67 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 68 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 69 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 70 | }); | |
| 71 | ||
| 72 | /** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ | |
| 73 | const RawLatestReviewSchema = Schema.Struct({ | |
| 74 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 75 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 76 | }); | |
| 77 | ||
| 78 | const RawCheckSchema = Schema.Struct({ | |
| 79 | __typename: Schema.optional(Schema.String), | |
| 80 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 81 | context: Schema.optional(Schema.NullOr(Schema.String)), | |
| 82 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 83 | conclusion: Schema.optional(Schema.NullOr(Schema.String)), | |
| 84 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 85 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 86 | detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 87 | targetUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 88 | /** | |
| 89 | * What tells two same-named checks apart, and which run of one is the newest. All three ride | |
| 90 | * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them | |
| 91 | * costs no request. Empty for an app-provided check run, which belongs to no workflow, and | |
| 92 | * absent entirely on a commit status, which is not a run at all. | |
| 93 | */ | |
| 94 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 95 | startedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 96 | completedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 97 | /** Branch protection requires this check; read by the detail query on github.com only. */ | |
| 98 | isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 99 | }); | |
| 100 | ||
| 101 | const RawListItemSchema = Schema.Struct({ | |
| 102 | number: Schema.Int, | |
| 103 | title: Schema.String, | |
| 104 | url: Schema.String, | |
| 105 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 106 | headRefName: Schema.String, | |
| 107 | baseRefName: Schema.String, | |
| 108 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 109 | isDraft: Schema.optional(Schema.Boolean), | |
| 110 | mergeable: Schema.optional(Schema.NullOr(Schema.String)), | |
| 111 | reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), | |
| 112 | additions: Schema.optional(Schema.Int), | |
| 113 | deletions: Schema.optional(Schema.Int), | |
| 114 | createdAt: Schema.String, | |
| 115 | updatedAt: Schema.String, | |
| 116 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 117 | reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), | |
| 118 | latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), | |
| 119 | labels: Schema.optional(Schema.Array(RawLabelSchema)), | |
| 120 | /** | |
| 121 | * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there | |
| 122 | * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs | |
| 123 | * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing | |
| 124 | * reduces to one word. The listing pays it because the alternative is a request per row; the | |
| 125 | * cross-repository search below asks GitHub for the verdict itself instead. | |
| 126 | */ | |
| 127 | statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), | |
| 128 | }); | |
| 129 | ||
| 130 | const RawStackMembershipSchema = Schema.Struct({ | |
| 131 | stack: Schema.optional( | |
| 132 | Schema.NullOr( | |
| 133 | Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), | |
| 134 | ), | |
| 135 | ), | |
| 136 | stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), | |
| 137 | }); | |
| 138 | ||
| 139 | /** | |
| 140 | * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` | |
| 141 | * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional | |
| 142 | * because a node that is not a pull request decodes as an empty object, which is skipped. | |
| 143 | */ | |
| 144 | const RawSearchItemSchema = Schema.Struct({ | |
| 145 | ...RawStackMembershipSchema.fields, | |
| 146 | number: Schema.Int, | |
| 147 | title: Schema.String, | |
| 148 | url: Schema.String, | |
| 149 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 150 | headRefName: Schema.String, | |
| 151 | baseRefName: Schema.String, | |
| 152 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 153 | isDraft: Schema.optional(Schema.Boolean), | |
| 154 | mergeable: Schema.optional(Schema.NullOr(Schema.String)), | |
| 155 | reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), | |
| 156 | latestReviews: Schema.optional( | |
| 157 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), | |
| 158 | ), | |
| 159 | /** Asked for by the per-repository listing, and left out of the cross-repository search. */ | |
| 160 | additions: Schema.optional(Schema.Int), | |
| 161 | deletions: Schema.optional(Schema.Int), | |
| 162 | createdAt: Schema.String, | |
| 163 | updatedAt: Schema.String, | |
| 164 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 165 | repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), | |
| 166 | reviewRequests: Schema.optional( | |
| 167 | Schema.NullOr( | |
| 168 | Schema.Struct({ | |
| 169 | nodes: Schema.optional( | |
| 170 | Schema.NullOr( | |
| 171 | Schema.Array( | |
| 172 | Schema.NullOr( | |
| 173 | Schema.Struct({ | |
| 174 | requestedReviewer: Schema.optional( | |
| 175 | Schema.NullOr( | |
| 176 | Schema.Struct({ | |
| 177 | ...RawActorSchema.fields, | |
| 178 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 179 | }), | |
| 180 | ), | |
| 181 | ), | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | ), | |
| 186 | ), | |
| 187 | }), | |
| 188 | ), | |
| 189 | ), | |
| 190 | labels: Schema.optional( | |
| 191 | Schema.NullOr( | |
| 192 | Schema.Struct({ | |
| 193 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), | |
| 194 | }), | |
| 195 | ), | |
| 196 | ), | |
| 197 | /** | |
| 198 | * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather | |
| 199 | * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across | |
| 200 | * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. | |
| 201 | */ | |
| 202 | commits: Schema.optional( | |
| 203 | Schema.NullOr( | |
| 204 | Schema.Struct({ | |
| 205 | nodes: Schema.optional( | |
| 206 | Schema.NullOr( | |
| 207 | Schema.Array( | |
| 208 | Schema.NullOr( | |
| 209 | Schema.Struct({ | |
| 210 | commit: Schema.optional( | |
| 211 | Schema.NullOr( | |
| 212 | Schema.Struct({ | |
| 213 | statusCheckRollup: Schema.optional( | |
| 214 | Schema.NullOr(Schema.Struct({ state: Schema.String })), | |
| 215 | ), | |
| 216 | }), | |
| 217 | ), | |
| 218 | ), | |
| 219 | }), | |
| 220 | ), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ), | |
| 224 | }), | |
| 225 | ), | |
| 226 | ), | |
| 227 | }); | |
| 228 | ||
| 229 | const RawRowConnectionSchema = Schema.Struct({ | |
| 230 | pageInfo: Schema.optional( | |
| 231 | Schema.NullOr( | |
| 232 | Schema.Struct({ | |
| 233 | hasNextPage: Schema.Boolean, | |
| 234 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 235 | }), | |
| 236 | ), | |
| 237 | ), | |
| 238 | // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped | |
| 239 | // rather than blanking every repository at once. | |
| 240 | nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), | |
| 241 | }); | |
| 242 | ||
| 243 | const RawSearchSchema = Schema.Struct({ | |
| 244 | data: Schema.Struct({ search: RawRowConnectionSchema }), | |
| 245 | }); | |
| 246 | ||
| 247 | const RawRepositoryPullRequestsSchema = Schema.Struct({ | |
| 248 | data: Schema.Struct({ | |
| 249 | repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), | |
| 250 | }), | |
| 251 | }); | |
| 252 | ||
| 253 | /** One aliased lookup per row, so the response is keyed by the position it was asked in. */ | |
| 254 | const RawStatsSchema = Schema.Struct({ | |
| 255 | data: Schema.optional( | |
| 256 | Schema.NullOr( | |
| 257 | Schema.Record( | |
| 258 | Schema.String, | |
| 259 | Schema.NullOr( | |
| 260 | Schema.Struct({ | |
| 261 | pullRequest: Schema.optional( | |
| 262 | Schema.NullOr( | |
| 263 | Schema.Struct({ | |
| 264 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 265 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 266 | }), | |
| 267 | ), | |
| 268 | ), | |
| 269 | }), | |
| 270 | ), | |
| 271 | ), | |
| 272 | ), | |
| 273 | ), | |
| 274 | }); | |
| 275 | ||
| 276 | const RawStackMembershipsSchema = Schema.Struct({ | |
| 277 | data: Schema.Record( | |
| 278 | Schema.String, | |
| 279 | Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), | |
| 280 | ), | |
| 281 | }); | |
| 282 | ||
| 283 | /** How many of a reaction's people the hover names before it counts the rest. */ | |
| 284 | const REACTORS_PER_GROUP = 10; | |
| 285 | ||
| 286 | /** | |
| 287 | * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: | |
| 288 | * a hover says who reacted, and a hundred and forty names is a count, not a sentence. | |
| 289 | */ | |
| 290 | const REACTION_GROUPS_FIELDS = `reactionGroups { | |
| 291 | content | |
| 292 | viewerHasReacted | |
| 293 | reactors(first: ${REACTORS_PER_GROUP}) { | |
| 294 | totalCount | |
| 295 | nodes { | |
| 296 | ... on User { login } | |
| 297 | ... on Bot { login } | |
| 298 | ... on Organization { login } | |
| 299 | ... on Mannequin { login } | |
| 300 | } | |
| 301 | } | |
| 302 | }`; | |
| 303 | ||
| 304 | /** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ | |
| 305 | const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = { | |
| 306 | THUMBS_UP: "thumbs-up", | |
| 307 | THUMBS_DOWN: "thumbs-down", | |
| 308 | LAUGH: "laugh", | |
| 309 | HOORAY: "hooray", | |
| 310 | CONFUSED: "confused", | |
| 311 | HEART: "heart", | |
| 312 | ROCKET: "rocket", | |
| 313 | EYES: "eyes", | |
| 314 | }; | |
| 315 | ||
| 316 | const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { | |
| 317 | "thumbs-up": "THUMBS_UP", | |
| 318 | "thumbs-down": "THUMBS_DOWN", | |
| 319 | laugh: "LAUGH", | |
| 320 | hooray: "HOORAY", | |
| 321 | confused: "CONFUSED", | |
| 322 | heart: "HEART", | |
| 323 | rocket: "ROCKET", | |
| 324 | eyes: "EYES", | |
| 325 | }; | |
| 326 | ||
| 327 | export function gitHubReactionContent(content: PullRequestReactionContent): string { | |
| 328 | return GITHUB_REACTION_BY_CONTENT[content]; | |
| 329 | } | |
| 330 | ||
| 331 | const RawReactionGroupsSchema = Schema.optional( | |
| 332 | Schema.NullOr( | |
| 333 | Schema.Array( | |
| 334 | Schema.Struct({ | |
| 335 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 336 | viewerHasReacted: Schema.optional(Schema.Boolean), | |
| 337 | reactors: Schema.optional( | |
| 338 | Schema.NullOr( | |
| 339 | Schema.Struct({ | |
| 340 | totalCount: Schema.optional(Schema.Int), | |
| 341 | nodes: Schema.optional( | |
| 342 | Schema.NullOr( | |
| 343 | Schema.Array( | |
| 344 | Schema.NullOr( | |
| 345 | Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 346 | ), | |
| 347 | ), | |
| 348 | ), | |
| 349 | ), | |
| 350 | }), | |
| 351 | ), | |
| 352 | ), | |
| 353 | }), | |
| 354 | ), | |
| 355 | ), | |
| 356 | ); | |
| 357 | ||
| 358 | type RawReactionGroups = typeof RawReactionGroupsSchema.Type; | |
| 359 | ||
| 360 | /** | |
| 361 | * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is | |
| 362 | * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The | |
| 363 | * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it | |
| 364 | * in would name them twice — but `count` still counts them along with everyone else. | |
| 365 | */ | |
| 366 | function toReactions( | |
| 367 | groups: RawReactionGroups, | |
| 368 | viewer: string | null, | |
| 369 | ): ReadonlyArray<PullRequestReaction> { | |
| 370 | const normalizedViewer = viewer?.toLowerCase() ?? null; | |
| 371 | const reactions: PullRequestReaction[] = []; | |
| 372 | for (const group of groups ?? []) { | |
| 373 | const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; | |
| 374 | if (content === undefined) continue; | |
| 375 | const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); | |
| 376 | const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); | |
| 377 | if (count <= 0) continue; | |
| 378 | const actors = | |
| 379 | normalizedViewer === null | |
| 380 | ? logins | |
| 381 | : logins.filter((login) => login.toLowerCase() !== normalizedViewer); | |
| 382 | reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); | |
| 383 | } | |
| 384 | return reactions; | |
| 385 | } | |
| 386 | ||
| 387 | const RawCommentSchema = Schema.Struct({ | |
| 388 | id: Schema.String, | |
| 389 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 390 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 391 | body: Schema.optional(Schema.String), | |
| 392 | createdAt: Schema.String, | |
| 393 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 394 | /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ | |
| 395 | reactionGroups: RawReactionGroupsSchema, | |
| 396 | }); | |
| 397 | ||
| 398 | const RawReviewSchema = Schema.Struct({ | |
| 399 | id: Schema.String, | |
| 400 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 401 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 402 | body: Schema.optional(Schema.String), | |
| 403 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 404 | submittedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 405 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 406 | }); | |
| 407 | ||
| 408 | const RawCommitSchema = Schema.Struct({ | |
| 409 | oid: Schema.String, | |
| 410 | messageHeadline: Schema.optional(Schema.String), | |
| 411 | committedDate: Schema.String, | |
| 412 | authors: Schema.optional( | |
| 413 | Schema.Array( | |
| 414 | Schema.Struct({ | |
| 415 | email: Schema.optional(Schema.NullOr(Schema.String)), | |
| 416 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 417 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 418 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 419 | }), | |
| 420 | ), | |
| 421 | ), | |
| 422 | }); | |
| 423 | ||
| 424 | const RawDetailSchema = Schema.Struct({ | |
| 425 | ...RawListItemSchema.fields, | |
| 426 | /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ | |
| 427 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 428 | /** Names the fork a pull request came from, which is what qualifies its head ref. */ | |
| 429 | headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), | |
| 430 | /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ | |
| 431 | headRefOid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 432 | body: Schema.optional(Schema.String), | |
| 433 | changedFiles: Schema.optional(Schema.Int), | |
| 434 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 435 | /** The standing instruction and strategy GitHub will use once its requirements are met. */ | |
| 436 | autoMergeRequest: Schema.optional( | |
| 437 | Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 438 | ), | |
| 439 | }); | |
| 440 | ||
| 441 | /** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ | |
| 442 | const RawWorkflowRunsSchema = Schema.Struct({ | |
| 443 | workflow_runs: Schema.Array( | |
| 444 | Schema.Struct({ | |
| 445 | id: Schema.Int, | |
| 446 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 447 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 448 | }), | |
| 449 | ), | |
| 450 | }); | |
| 451 | ||
| 452 | const RawPullRequestHeadsSchema = Schema.Struct({ | |
| 453 | data: Schema.Struct({ | |
| 454 | repository: Schema.NullOr( | |
| 455 | Schema.Struct({ | |
| 456 | pullRequests: Schema.Struct({ | |
| 457 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | |
| 458 | nodes: Schema.Array( | |
| 459 | Schema.Struct({ | |
| 460 | number: Schema.Int, | |
| 461 | headRefOid: Schema.String, | |
| 462 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 463 | headRepositoryOwner: Schema.optional( | |
| 464 | Schema.NullOr(Schema.Struct({ login: Schema.String })), | |
| 465 | ), | |
| 466 | }), | |
| 467 | ), | |
| 468 | }), | |
| 469 | }), | |
| 470 | ), | |
| 471 | }), | |
| 472 | }); | |
| 473 | ||
| 474 | const RawActivityConnection = <S extends Schema.Top>(node: S) => | |
| 475 | Schema.optional( | |
| 476 | Schema.NullOr( | |
| 477 | Schema.Struct({ | |
| 478 | pageInfo: Schema.optional(RawPageInfoSchemaFields()), | |
| 479 | nodes: Schema.Array(node), | |
| 480 | }), | |
| 481 | ), | |
| 482 | ); | |
| 483 | ||
| 484 | const RawActivitySchema = Schema.Struct({ | |
| 485 | data: Schema.Struct({ | |
| 486 | repository: Schema.Struct({ | |
| 487 | pullRequest: Schema.Struct({ | |
| 488 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 489 | comments: RawActivityConnection(RawCommentSchema), | |
| 490 | reviews: RawActivityConnection(RawReviewSchema), | |
| 491 | commits: Schema.optional( | |
| 492 | Schema.NullOr( | |
| 493 | Schema.Struct({ | |
| 494 | nodes: Schema.Array( | |
| 495 | Schema.Struct({ | |
| 496 | commit: Schema.Struct({ | |
| 497 | oid: Schema.String, | |
| 498 | messageHeadline: Schema.optional(Schema.String), | |
| 499 | committedDate: Schema.String, | |
| 500 | authors: Schema.optional( | |
| 501 | Schema.NullOr( | |
| 502 | Schema.Struct({ | |
| 503 | nodes: Schema.Array( | |
| 504 | Schema.Struct({ | |
| 505 | email: Schema.optional(Schema.NullOr(Schema.String)), | |
| 506 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 507 | user: Schema.optional( | |
| 508 | Schema.NullOr( | |
| 509 | Schema.Struct({ | |
| 510 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 511 | }), | |
| 512 | ), | |
| 513 | ), | |
| 514 | }), | |
| 515 | ), | |
| 516 | }), | |
| 517 | ), | |
| 518 | ), | |
| 519 | }), | |
| 520 | }), | |
| 521 | ), | |
| 522 | }), | |
| 523 | ), | |
| 524 | ), | |
| 525 | }), | |
| 526 | }), | |
| 527 | }), | |
| 528 | }); | |
| 529 | ||
| 530 | /** Where a connection carries on from, which is what every paged read below follows. */ | |
| 531 | function RawPageInfoSchemaFields() { | |
| 532 | return Schema.Struct({ | |
| 533 | hasNextPage: Schema.optional(Schema.Boolean), | |
| 534 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 535 | }); | |
| 536 | } | |
| 537 | const RawPageInfoSchema = RawPageInfoSchemaFields(); | |
| 538 | ||
| 539 | /** | |
| 540 | * What GitHub says the viewer may do with a pull request. Both are optional so that an install | |
| 541 | * that answers without them still delivers the conversation they travel with; an absent field | |
| 542 | * reads as granted, which is what an unknown permission is. | |
| 543 | */ | |
| 544 | const RawViewerFieldsSchema = Schema.Struct({ | |
| 545 | viewerCanUpdate: Schema.optional(Schema.Boolean), | |
| 546 | viewerDidAuthor: Schema.optional(Schema.Boolean), | |
| 547 | }); | |
| 548 | ||
| 549 | const RawThreadCommentsSchema = Schema.Struct({ | |
| 550 | totalCount: Schema.optional(Schema.Int), | |
| 551 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 552 | nodes: Schema.Array(RawCommentSchema), | |
| 553 | }); | |
| 554 | ||
| 555 | /** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ | |
| 556 | const RawReviewThreadsSchema = Schema.Struct({ | |
| 557 | data: Schema.Struct({ | |
| 558 | // Rides along in the same request: GitHub names who reacted but never says whether that is | |
| 559 | // the reader, so the comparison is made here rather than paid for with a request of its own. | |
| 560 | viewer: Schema.optional( | |
| 561 | Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 562 | ), | |
| 563 | repository: Schema.Struct({ | |
| 564 | pullRequest: Schema.Struct({ | |
| 565 | reviewThreads: Schema.Struct({ | |
| 566 | totalCount: Schema.optional(Schema.Int), | |
| 567 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 568 | nodes: Schema.Array( | |
| 569 | Schema.Struct({ | |
| 570 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 571 | isResolved: Schema.optional(Schema.Boolean), | |
| 572 | isOutdated: Schema.optional(Schema.Boolean), | |
| 573 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 574 | /** Null once the thread's line has left the diff, which `isOutdated` reports. */ | |
| 575 | line: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 576 | diffSide: Schema.optional(Schema.NullOr(Schema.String)), | |
| 577 | comments: RawThreadCommentsSchema, | |
| 578 | }), | |
| 579 | ), | |
| 580 | }), | |
| 581 | ...RawViewerFieldsSchema.fields, | |
| 582 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 583 | reactionGroups: RawReactionGroupsSchema, | |
| 584 | comments: Schema.optional( | |
| 585 | Schema.NullOr( | |
| 586 | Schema.Struct({ | |
| 587 | nodes: Schema.Array( | |
| 588 | Schema.Struct({ | |
| 589 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 590 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 591 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 592 | reactionGroups: RawReactionGroupsSchema, | |
| 593 | }), | |
| 594 | ), | |
| 595 | }), | |
| 596 | ), | |
| 597 | ), | |
| 598 | /** | |
| 599 | * Reviews for their reactions and actor identity: the words and the verdict arrive with | |
| 600 | * `gh pr view --json reviews`, which reports no reaction of any kind. | |
| 601 | */ | |
| 602 | reviews: Schema.optional( | |
| 603 | Schema.NullOr( | |
| 604 | Schema.Struct({ | |
| 605 | nodes: Schema.Array( | |
| 606 | Schema.Struct({ | |
| 607 | id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 608 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 609 | lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 610 | reactionGroups: RawReactionGroupsSchema, | |
| 611 | }), | |
| 612 | ), | |
| 613 | }), | |
| 614 | ), | |
| 615 | ), | |
| 616 | reviewRequests: Schema.optional( | |
| 617 | Schema.NullOr( | |
| 618 | Schema.Struct({ | |
| 619 | nodes: Schema.Array( | |
| 620 | Schema.Struct({ | |
| 621 | // Null for a team, which is a request nobody in particular owns. | |
| 622 | requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 623 | }), | |
| 624 | ), | |
| 625 | }), | |
| 626 | ), | |
| 627 | ), | |
| 628 | latestReviews: Schema.optional( | |
| 629 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), | |
| 630 | ), | |
| 631 | reviewDismissals: Schema.optional( | |
| 632 | Schema.NullOr( | |
| 633 | Schema.Struct({ | |
| 634 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 635 | nodes: Schema.Array( | |
| 636 | Schema.Struct({ | |
| 637 | dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), | |
| 638 | review: Schema.optional( | |
| 639 | Schema.NullOr( | |
| 640 | Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), | |
| 641 | ), | |
| 642 | ), | |
| 643 | }), | |
| 644 | ), | |
| 645 | }), | |
| 646 | ), | |
| 647 | ), | |
| 648 | commits: Schema.optional( | |
| 649 | Schema.NullOr( | |
| 650 | Schema.Struct({ | |
| 651 | nodes: Schema.Array( | |
| 652 | Schema.Struct({ | |
| 653 | commit: Schema.Struct({ | |
| 654 | oid: Schema.String, | |
| 655 | messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), | |
| 656 | committedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 657 | additions: Schema.optional(Schema.Int), | |
| 658 | deletions: Schema.optional(Schema.Int), | |
| 659 | parents: Schema.optional( | |
| 660 | Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), | |
| 661 | ), | |
| 662 | authors: Schema.optional( | |
| 663 | Schema.NullOr( | |
| 664 | Schema.Struct({ | |
| 665 | nodes: Schema.Array( | |
| 666 | Schema.Struct({ | |
| 667 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 668 | avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 669 | user: Schema.optional( | |
| 670 | Schema.NullOr( | |
| 671 | Schema.Struct({ | |
| 672 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 673 | }), | |
| 674 | ), | |
| 675 | ), | |
| 676 | }), | |
| 677 | ), | |
| 678 | }), | |
| 679 | ), | |
| 680 | ), | |
| 681 | }), | |
| 682 | }), | |
| 683 | ), | |
| 684 | }), | |
| 685 | ), | |
| 686 | ), | |
| 687 | }), | |
| 688 | }), | |
| 689 | }), | |
| 690 | }); | |
| 691 | ||
| 692 | /** Requested together, so a response missing any of them fails rather than defaulting open: | |
| 693 | * guessing `true` would offer a merge method the repository forbids. */ | |
| 694 | const RawRepositoryAccessSchema = Schema.Struct({ | |
| 695 | mergeCommitAllowed: Schema.Boolean, | |
| 696 | squashMergeAllowed: Schema.Boolean, | |
| 697 | rebaseMergeAllowed: Schema.Boolean, | |
| 698 | /** | |
| 699 | * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the | |
| 700 | * three above: an install that does not report it leaves the viewer's standing unknown, which | |
| 701 | * is answered by granting rather than by failing the whole detail read. | |
| 702 | */ | |
| 703 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), | |
| 704 | }); | |
| 705 | ||
| 706 | const RawCoreSchema = Schema.Struct({ | |
| 707 | data: Schema.Struct({ | |
| 708 | repository: Schema.Struct({ | |
| 709 | ...RawRepositoryAccessSchema.fields, | |
| 710 | pullRequest: Schema.Struct({ | |
| 711 | ...RawDetailSchema.fields, | |
| 712 | ...RawViewerFieldsSchema.fields, | |
| 713 | viewerCanUpdateBranch: Schema.Boolean, | |
| 714 | baseRef: Schema.NullOr( | |
| 715 | Schema.Struct({ | |
| 716 | compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), | |
| 717 | }), | |
| 718 | ), | |
| 719 | reviewRequests: Schema.Struct({ | |
| 720 | nodes: Schema.Array( | |
| 721 | Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), | |
| 722 | ), | |
| 723 | }), | |
| 724 | labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), | |
| 725 | commits: Schema.Struct({ | |
| 726 | nodes: Schema.Array( | |
| 727 | Schema.Struct({ | |
| 728 | commit: Schema.Struct({ | |
| 729 | statusCheckRollup: Schema.NullOr( | |
| 730 | Schema.Struct({ | |
| 731 | contexts: Schema.Struct({ | |
| 732 | nodes: Schema.Array( | |
| 733 | Schema.Struct({ | |
| 734 | ...RawCheckSchema.fields, | |
| 735 | checkSuite: Schema.optional( | |
| 736 | Schema.NullOr( | |
| 737 | Schema.Struct({ | |
| 738 | workflowRun: Schema.NullOr( | |
| 739 | Schema.Struct({ | |
| 740 | workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), | |
| 741 | }), | |
| 742 | ), | |
| 743 | }), | |
| 744 | ), | |
| 745 | ), | |
| 746 | }), | |
| 747 | ), | |
| 748 | pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), | |
| 749 | }), | |
| 750 | }), | |
| 751 | ), | |
| 752 | }), | |
| 753 | }), | |
| 754 | ), | |
| 755 | }), | |
| 756 | }), | |
| 757 | }), | |
| 758 | }), | |
| 759 | }); | |
| 760 | const decodeCore = decodeJsonResult(RawCoreSchema); | |
| 761 | ||
| 762 | const RawPullRequestFileSchema = Schema.Struct({ | |
| 763 | filename: Schema.String, | |
| 764 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 765 | /** Only on a rename, where it names the file the hunks are counted against. */ | |
| 766 | previous_filename: Schema.optional(Schema.NullOr(Schema.String)), | |
| 767 | /** Absent for a binary file, and for one whose diff GitHub considers too large. */ | |
| 768 | patch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 769 | /** Whether anything was withheld is the difference between a binary file and a pure rename. */ | |
| 770 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 771 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 772 | }); | |
| 773 | ||
| 774 | /** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ | |
| 775 | export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { | |
| 776 | nodes(ids: $ids) { | |
| 777 | ... on User { login avatarUrl } | |
| 778 | ... on Bot { login avatarUrl } | |
| 779 | } | |
| 780 | }`; | |
| 781 | ||
| 782 | const RawActorAvatarsSchema = Schema.Struct({ | |
| 783 | data: Schema.Struct({ | |
| 784 | nodes: Schema.Array(Schema.NullOr(RawActorSchema)), | |
| 785 | }), | |
| 786 | }); | |
| 787 | ||
| 788 | const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); | |
| 789 | ||
| 790 | export function decodeActorAvatarsJson( | |
| 791 | raw: string, | |
| 792 | ): Result.Result<ReadonlyMap<string, string>, DecodeFailure> { | |
| 793 | const decoded = decodeActorAvatars(raw); | |
| 794 | if (!Result.isSuccess(decoded)) { | |
| 795 | return Result.fail(decoded.failure); | |
| 796 | } | |
| 797 | const avatarsByLogin = new Map<string, string>(); | |
| 798 | for (const node of decoded.success.data.nodes) { | |
| 799 | const login = trimmed(node?.login); | |
| 800 | const avatarUrl = trimmed(node?.avatarUrl); | |
| 801 | if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); | |
| 802 | } | |
| 803 | return Result.succeed(avatarsByLogin); | |
| 804 | } | |
| 805 | ||
| 806 | /** | |
| 807 | * Pull refs let the comparison share the detail read without first resolving a fork branch. | |
| 808 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and | |
| 809 | * an unknown field fails the whole read. | |
| 810 | */ | |
| 811 | function checkContextNodesSelection(host: string): string { | |
| 812 | const required = | |
| 813 | host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; | |
| 814 | return `nodes { | |
| 815 | __typename | |
| 816 | ... on StatusContext { context state targetUrl createdAt description${required} } | |
| 817 | ... on CheckRun { | |
| 818 | name status conclusion startedAt completedAt detailsUrl${required} | |
| 819 | checkSuite { workflowRun { workflow { name } } } | |
| 820 | } | |
| 821 | }`; | |
| 822 | } | |
| 823 | ||
| 824 | export const pullRequestCoreGraphQlQuery = (host: string) => { | |
| 825 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 826 | repository(owner: $owner, name: $name) { | |
| 827 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | |
| 828 | pullRequest(number: $number) { | |
| 829 | number title url body state isDraft mergeable reviewDecision | |
| 830 | additions deletions changedFiles createdAt updatedAt mergedAt closedAt | |
| 831 | headRefName baseRefName headRefOid isCrossRepository | |
| 832 | headRepositoryOwner { login } | |
| 833 | author { login avatarUrl ... on User { id name } } | |
| 834 | autoMergeRequest { mergeMethod } | |
| 835 | viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch | |
| 836 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 837 | reviewRequests(first: 100) { | |
| 838 | nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } | |
| 839 | } | |
| 840 | labels(first: 100) { nodes { name color } } | |
| 841 | commits(last: 1) { | |
| 842 | nodes { commit { statusCheckRollup { contexts(first: 100) { | |
| 843 | ${checkContextNodesSelection(host)} | |
| 844 | pageInfo { hasNextPage } | |
| 845 | } } } } | |
| 846 | } | |
| 847 | } | |
| 848 | } | |
| 849 | }`; | |
| 850 | }; | |
| 851 | ||
| 852 | export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 853 | repository(owner: $owner, name: $name) { | |
| 854 | pullRequest(number: $number) { | |
| 855 | number title url state isDraft createdAt | |
| 856 | author { login avatarUrl ... on User { name } } | |
| 857 | } | |
| 858 | } | |
| 859 | }`; | |
| 860 | ||
| 861 | const decodePullRequestPreview = decodeJsonResult( | |
| 862 | Schema.Struct({ | |
| 863 | data: Schema.Struct({ | |
| 864 | repository: Schema.Struct({ | |
| 865 | pullRequest: Schema.Struct({ | |
| 866 | number: Schema.Int, | |
| 867 | title: Schema.String, | |
| 868 | url: Schema.String, | |
| 869 | state: Schema.String, | |
| 870 | isDraft: Schema.Boolean, | |
| 871 | createdAt: Schema.String, | |
| 872 | author: Schema.NullOr(RawActorSchema), | |
| 873 | }), | |
| 874 | }), | |
| 875 | }), | |
| 876 | }), | |
| 877 | ); | |
| 878 | ||
| 879 | export function decodePullRequestPreviewJson( | |
| 880 | raw: string, | |
| 881 | ): Result.Result<Omit<PullRequestPreview, "projectId" | "repository">, DecodeFailure> { | |
| 882 | return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ | |
| 883 | ...data.repository.pullRequest, | |
| 884 | author: toActor(data.repository.pullRequest.author), | |
| 885 | state: toState(data.repository.pullRequest), | |
| 886 | })); | |
| 887 | } | |
| 888 | ||
| 889 | /** | |
| 890 | * The conversation a pull request carries outside its review threads: who opened it, its issue | |
| 891 | * comments, its reviews and its newest commits. The two remark connections page independently, so | |
| 892 | * each is switched on only while it has more to give; the first read asks for everything. | |
| 893 | */ | |
| 894 | export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { | |
| 895 | repository(owner: $owner, name: $name) { | |
| 896 | pullRequest(number: $number) { | |
| 897 | author @include(if: $head) { __typename login avatarUrl ... on User { name } } | |
| 898 | commits(last: 100) @include(if: $head) { | |
| 899 | nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } | |
| 900 | } | |
| 901 | comments(first: 100, after: $commentsAfter) @include(if: $withComments) { | |
| 902 | pageInfo { hasNextPage endCursor } | |
| 903 | nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | |
| 904 | } | |
| 905 | reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { | |
| 906 | pageInfo { hasNextPage endCursor } | |
| 907 | nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } | |
| 908 | } | |
| 909 | } | |
| 910 | } | |
| 911 | }`; | |
| 912 | ||
| 913 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ | |
| 914 | const GRAPHQL_PAGE_SIZE = 100; | |
| 915 | ||
| 916 | /** | |
| 917 | * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: | |
| 918 | * `first: 101` is an error, `first: 100` is not). | |
| 919 | */ | |
| 920 | export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; | |
| 921 | ||
| 922 | /** | |
| 923 | * Every repository of a host in one read, which is what makes a listing one request rather than | |
| 924 | * one process per repository. | |
| 925 | * | |
| 926 | * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a | |
| 927 | * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two | |
| 928 | * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. | |
| 929 | * | |
| 930 | * The row count is written into the document rather than sent as a variable because every | |
| 931 | * variable here travels as a string — and it is this module's own number, clamped by the caller, | |
| 932 | * never a reader's. | |
| 933 | * | |
| 934 | * `first` on the two inner connections is a bound rather than a page: a pull request with more | |
| 935 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review | |
| 936 | * is already past what a row can say. | |
| 937 | */ | |
| 938 | export function pullRequestSearchGraphQlQuery( | |
| 939 | rows: number, | |
| 940 | includeStacks = false, | |
| 941 | includeStats = false, | |
| 942 | ): string { | |
| 943 | return `query($q: String!, $after: String) { | |
| 944 | search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { | |
| 945 | pageInfo { hasNextPage endCursor } | |
| 946 | nodes { | |
| 947 | ... on PullRequest { | |
| 948 | ${pullRequestRowSelection(includeStacks, includeStats)} | |
| 949 | } | |
| 950 | } | |
| 951 | } | |
| 952 | }`; | |
| 953 | } | |
| 954 | ||
| 955 | /** | |
| 956 | * A repository's pull requests without search, newest created first: the order `gh pr list` lists | |
| 957 | * in when it does not search, for a repository GitHub's search index does not cover. | |
| 958 | */ | |
| 959 | export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { | |
| 960 | return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { | |
| 961 | repository(owner: $owner, name: $name) { | |
| 962 | pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { | |
| 963 | pageInfo { hasNextPage endCursor } | |
| 964 | nodes { ${pullRequestRowSelection(includeStacks, true)} } | |
| 965 | } | |
| 966 | } | |
| 967 | }`; | |
| 968 | } | |
| 969 | ||
| 970 | function pageRows(rows: number): number { | |
| 971 | return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 972 | } | |
| 973 | ||
| 974 | /** One listing row, the same whether it came from a search or from a repository's own list. */ | |
| 975 | function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { | |
| 976 | return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | |
| 977 | number | |
| 978 | title | |
| 979 | url | |
| 980 | author { __typename login avatarUrl ... on User { name } } | |
| 981 | headRefName | |
| 982 | baseRefName | |
| 983 | state | |
| 984 | isDraft | |
| 985 | mergeable | |
| 986 | reviewDecision | |
| 987 | latestReviews(first: 20) { nodes { state author { login } } } | |
| 988 | ${includeStats ? "additions deletions" : ""} | |
| 989 | createdAt | |
| 990 | updatedAt | |
| 991 | mergedAt | |
| 992 | repository { nameWithOwner } | |
| 993 | reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } | |
| 994 | labels(first: 20) { nodes { name color } } | |
| 995 | commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; | |
| 996 | } | |
| 997 | ||
| 998 | /** | |
| 999 | * One page of review threads with their comments, and the people on the review. `$cursor` is | |
| 1000 | * null for the first page and the last page's `endCursor` after that, so a pull request with | |
| 1001 | * more threads than one page holds is walked rather than cut off at the first fifty. | |
| 1002 | * | |
| 1003 | * Only ten comments ride with each thread. A hundred threads times a hundred comments made | |
| 1004 | * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from | |
| 1005 | * their own cursor below. | |
| 1006 | * | |
| 1007 | * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: | |
| 1008 | * that field holds only requests still outstanding, so anyone who has already reviewed drops off | |
| 1009 | * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person | |
| 1010 | * or an app, and both are asked for by name because they are different GraphQL types. | |
| 1011 | * | |
| 1012 | * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the | |
| 1013 | * pull request this query is already standing on, so what the reader may do with it arrives with | |
| 1014 | * the conversation rather than costing a request of its own. | |
| 1015 | * | |
| 1016 | * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from | |
| 1017 | * the start, so a pull request with more than a hundred commits loses the newest ones from its | |
| 1018 | * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff | |
| 1019 | * wants, and stands in for the `gh` list wherever it came back non-empty. | |
| 1020 | */ | |
| 1021 | export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { | |
| 1022 | viewer { login } | |
| 1023 | repository(owner: $owner, name: $name) { | |
| 1024 | pullRequest(number: $number) { | |
| 1025 | reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1026 | totalCount | |
| 1027 | pageInfo { hasNextPage endCursor } | |
| 1028 | nodes { | |
| 1029 | id | |
| 1030 | isResolved | |
| 1031 | isOutdated | |
| 1032 | path | |
| 1033 | line | |
| 1034 | diffSide | |
| 1035 | comments(first: 10) { | |
| 1036 | totalCount | |
| 1037 | pageInfo { hasNextPage endCursor } | |
| 1038 | nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } | |
| 1039 | } | |
| 1040 | } | |
| 1041 | } | |
| 1042 | viewerCanUpdate | |
| 1043 | viewerDidAuthor | |
| 1044 | author { __typename login avatarUrl } | |
| 1045 | ${REACTION_GROUPS_FIELDS} | |
| 1046 | comments(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 1047 | nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } | |
| 1048 | } | |
| 1049 | reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } } | |
| 1050 | reviewRequests(first: 50) { | |
| 1051 | nodes { | |
| 1052 | requestedReviewer { | |
| 1053 | ... on User { login name avatarUrl } | |
| 1054 | ... on Bot { __typename login avatarUrl } | |
| 1055 | } | |
| 1056 | } | |
| 1057 | } | |
| 1058 | latestReviews(first: 50) { | |
| 1059 | nodes { state author { __typename login avatarUrl } } | |
| 1060 | } | |
| 1061 | reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { | |
| 1062 | pageInfo { hasNextPage endCursor } | |
| 1063 | nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } | |
| 1064 | } | |
| 1065 | commits(last: ${GRAPHQL_PAGE_SIZE}) { | |
| 1066 | nodes { | |
| 1067 | commit { | |
| 1068 | oid | |
| 1069 | messageHeadline | |
| 1070 | committedDate | |
| 1071 | additions | |
| 1072 | deletions | |
| 1073 | parents(first: 1) { totalCount } | |
| 1074 | authors(first: 3) { nodes { name avatarUrl user { login } } } | |
| 1075 | } | |
| 1076 | } | |
| 1077 | } | |
| 1078 | } | |
| 1079 | } | |
| 1080 | }`; | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * The rest of one thread's conversation. GraphQL pages a connection nested inside another only | |
| 1084 | * from the inner node itself, so a thread longer than a page is followed on its own — a request | |
| 1085 | * GitHub makes necessary, and one no ordinary pull request ever provokes. | |
| 1086 | */ | |
| 1087 | export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) { | |
| 1088 | viewer { login } | |
| 1089 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1090 | node(id: $threadId) { | |
| 1091 | ... on PullRequestReviewThread { | |
| 1092 | pullRequest { id } | |
| 1093 | comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1094 | pageInfo { hasNextPage endCursor } | |
| 1095 | nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } | |
| 1096 | } | |
| 1097 | } | |
| 1098 | } | |
| 1099 | }`; | |
| 1100 | ||
| 1101 | const RawReviewThreadCommentsSchema = Schema.Struct({ | |
| 1102 | data: Schema.Struct({ | |
| 1103 | viewer: Schema.optional( | |
| 1104 | Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 1105 | ), | |
| 1106 | repository: Schema.NullOr( | |
| 1107 | Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), | |
| 1108 | ), | |
| 1109 | /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ | |
| 1110 | node: Schema.NullOr( | |
| 1111 | Schema.Struct({ | |
| 1112 | pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), | |
| 1113 | comments: Schema.optional(RawThreadCommentsSchema), | |
| 1114 | }), | |
| 1115 | ), | |
| 1116 | }), | |
| 1117 | }); | |
| 1118 | ||
| 1119 | export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { | |
| 1120 | addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { | |
| 1121 | comment { id } | |
| 1122 | } | |
| 1123 | }`; | |
| 1124 | ||
| 1125 | /** | |
| 1126 | * The pull request's own node id, which is what a reaction on its description is addressed by. | |
| 1127 | * Read only when one is being written: the conversation carries an id for every remark in it, and | |
| 1128 | * the pull request is the one subject nothing in it names. | |
| 1129 | */ | |
| 1130 | export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 1131 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1132 | }`; | |
| 1133 | ||
| 1134 | const RawPullRequestNodeIdSchema = Schema.Struct({ | |
| 1135 | data: Schema.Struct({ | |
| 1136 | repository: Schema.Struct({ | |
| 1137 | pullRequest: Schema.Struct({ id: Schema.String }), | |
| 1138 | }), | |
| 1139 | }), | |
| 1140 | }); | |
| 1141 | ||
| 1142 | const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); | |
| 1143 | ||
| 1144 | export function decodePullRequestNodeIdJson(raw: string): Result.Result<string, DecodeFailure> { | |
| 1145 | const decoded = decodePullRequestNodeId(raw); | |
| 1146 | return Result.isSuccess(decoded) | |
| 1147 | ? Result.succeed(decoded.success.data.repository.pullRequest.id) | |
| 1148 | : Result.fail(decoded.failure); | |
| 1149 | } | |
| 1150 | ||
| 1151 | /** | |
| 1152 | * Where a client-given reaction subject actually hangs: the pull request itself, or the pull | |
| 1153 | * request an issue comment, a review comment, or a review belongs to. Read before a mutation | |
| 1154 | * reaches it, so a subject named for one pull request cannot react on another's behalf. | |
| 1155 | */ | |
| 1156 | export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { | |
| 1157 | repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } | |
| 1158 | node(id: $subjectId) { | |
| 1159 | id | |
| 1160 | ... on IssueComment { pullRequest { id } } | |
| 1161 | ... on PullRequestReviewComment { pullRequest { id } } | |
| 1162 | ... on PullRequestReview { pullRequest { id } } | |
| 1163 | } | |
| 1164 | }`; | |
| 1165 | ||
| 1166 | const RawReactionSubjectScopeSchema = Schema.Struct({ | |
| 1167 | data: Schema.Struct({ | |
| 1168 | repository: Schema.NullOr( | |
| 1169 | Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), | |
| 1170 | ), | |
| 1171 | node: Schema.NullOr( | |
| 1172 | Schema.Struct({ | |
| 1173 | id: Schema.String, | |
| 1174 | pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), | |
| 1175 | }), | |
| 1176 | ), | |
| 1177 | }), | |
| 1178 | }); | |
| 1179 | ||
| 1180 | const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); | |
| 1181 | ||
| 1182 | /** | |
| 1183 | * True when the subject named is the pull request itself, or hangs off it — false for anything | |
| 1184 | * else, including a subject or a pull request this host could not find. | |
| 1185 | */ | |
| 1186 | export function decodeReactionSubjectScopeJson(raw: string): Result.Result<boolean, DecodeFailure> { | |
| 1187 | const decoded = decodeReactionSubjectScope(raw); | |
| 1188 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1189 | const expected = decoded.success.data.repository?.pullRequest?.id ?? null; | |
| 1190 | const node = decoded.success.data.node; | |
| 1191 | const actual = node === null ? null : (node.pullRequest?.id ?? node.id); | |
| 1192 | return Result.succeed(expected !== null && actual !== null && expected === actual); | |
| 1193 | } | |
| 1194 | ||
| 1195 | export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | |
| 1196 | addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | |
| 1197 | }`; | |
| 1198 | ||
| 1199 | export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | |
| 1200 | removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | |
| 1201 | }`; | |
| 1202 | ||
| 1203 | export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { | |
| 1204 | resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } | |
| 1205 | }`; | |
| 1206 | ||
| 1207 | export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { | |
| 1208 | unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } | |
| 1209 | }`; | |
| 1210 | ||
| 1211 | /** | |
| 1212 | * Rewrites the pull request's own words. Both are nullable so that one document serves a change | |
| 1213 | * to the title, to the description, or to the two together: a variable the request does not send | |
| 1214 | * puts no entry in the input at all, which leaves that field as it was rather than clearing it. | |
| 1215 | */ | |
| 1216 | export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { | |
| 1217 | updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { | |
| 1218 | pullRequest { id } | |
| 1219 | } | |
| 1220 | }`; | |
| 1221 | ||
| 1222 | /** Creates a new pull request that reverses a merged pull request. */ | |
| 1223 | export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1224 | revertPullRequest(input: { pullRequestId: $pullRequestId }) { | |
| 1225 | revertPullRequest { id } | |
| 1226 | } | |
| 1227 | }`; | |
| 1228 | ||
| 1229 | /** | |
| 1230 | * The two comment mutations name their comment differently. The variable is spelled the same in | |
| 1231 | * both, so a rewrite sends one set of variables whichever kind of remark it is. | |
| 1232 | */ | |
| 1233 | export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { | |
| 1234 | updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } | |
| 1235 | }`; | |
| 1236 | ||
| 1237 | export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { | |
| 1238 | updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { | |
| 1239 | pullRequestReviewComment { id } | |
| 1240 | } | |
| 1241 | }`; | |
| 1242 | ||
| 1243 | /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ | |
| 1244 | const ReviewSubmissionSchema = Schema.Struct({ | |
| 1245 | event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), | |
| 1246 | body: Schema.String, | |
| 1247 | comments: Schema.Array( | |
| 1248 | Schema.Struct({ | |
| 1249 | path: Schema.String, | |
| 1250 | line: Schema.Int, | |
| 1251 | side: Schema.Literals(["LEFT", "RIGHT"]), | |
| 1252 | body: Schema.String, | |
| 1253 | }), | |
| 1254 | ), | |
| 1255 | }); | |
| 1256 | ||
| 1257 | const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = { | |
| 1258 | comment: "COMMENT", | |
| 1259 | approve: "APPROVE", | |
| 1260 | "request-changes": "REQUEST_CHANGES", | |
| 1261 | }; | |
| 1262 | ||
| 1263 | /** | |
| 1264 | * The dismissal events past the page the thread read carries. A pull request rarely has any: | |
| 1265 | * this is followed only while the embedded page reports more. | |
| 1266 | */ | |
| 1267 | export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { | |
| 1268 | repository(owner: $owner, name: $name) { | |
| 1269 | pullRequest(number: $number) { | |
| 1270 | timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { | |
| 1271 | pageInfo { hasNextPage endCursor } | |
| 1272 | nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } | |
| 1273 | } | |
| 1274 | } | |
| 1275 | } | |
| 1276 | }`; | |
| 1277 | ||
| 1278 | function gitHubReviewPosition(position: PullRequestReviewPosition): { | |
| 1279 | readonly line: number; | |
| 1280 | readonly side: "LEFT" | "RIGHT"; | |
| 1281 | } { | |
| 1282 | switch (position.kind) { | |
| 1283 | case "added": | |
| 1284 | return { line: position.newLine, side: "RIGHT" }; | |
| 1285 | case "deleted": | |
| 1286 | return { line: position.oldLine, side: "LEFT" }; | |
| 1287 | case "context": | |
| 1288 | return position.side === "left" | |
| 1289 | ? { line: position.oldLine, side: "LEFT" } | |
| 1290 | : { line: position.newLine, side: "RIGHT" }; | |
| 1291 | } | |
| 1292 | } | |
| 1293 | ||
| 1294 | /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ | |
| 1295 | export function buildReviewSubmission(input: { | |
| 1296 | readonly verdict: PullRequestReviewVerdict; | |
| 1297 | readonly body: string; | |
| 1298 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 1299 | }): typeof ReviewSubmissionSchema.Type { | |
| 1300 | return { | |
| 1301 | event: REVIEW_EVENTS[input.verdict], | |
| 1302 | body: input.body, | |
| 1303 | comments: input.comments.map((comment) => ({ | |
| 1304 | path: comment.path, | |
| 1305 | ...gitHubReviewPosition(comment.position), | |
| 1306 | body: comment.body, | |
| 1307 | })), | |
| 1308 | }; | |
| 1309 | } | |
| 1310 | ||
| 1311 | export interface GitHubPullRequestListItem { | |
| 1312 | readonly stack?: PullRequestStackMembership; | |
| 1313 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ | |
| 1314 | readonly authorId: string | null; | |
| 1315 | readonly number: number; | |
| 1316 | readonly title: string; | |
| 1317 | readonly url: string; | |
| 1318 | readonly author: PullRequestActor | null; | |
| 1319 | readonly headBranch: string; | |
| 1320 | readonly baseBranch: string; | |
| 1321 | readonly state: PullRequestState; | |
| 1322 | readonly isDraft: boolean; | |
| 1323 | readonly mergeability: PullRequestMergeability; | |
| 1324 | /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ | |
| 1325 | readonly reviewDecision: PullRequestReviewDecision | null; | |
| 1326 | readonly additions: number; | |
| 1327 | readonly deletions: number; | |
| 1328 | readonly createdAt: string; | |
| 1329 | readonly updatedAt: string; | |
| 1330 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 1331 | /** At least one outstanding request targets a team rather than an individual login. */ | |
| 1332 | readonly hasTeamReviewRequest: boolean; | |
| 1333 | readonly labels: ReadonlyArray<PullRequestLabel>; | |
| 1334 | /** Null where the head commit reported no checks, which is not the same as passing none. */ | |
| 1335 | readonly checksState: PullRequestChecksState | null; | |
| 1336 | } | |
| 1337 | ||
| 1338 | export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { | |
| 1339 | /** True only when GitHub says the head belongs to another repository. */ | |
| 1340 | readonly isCrossRepository?: boolean; | |
| 1341 | /** The owner of the head branch's repository; null where `gh` did not say. */ | |
| 1342 | readonly headRepositoryOwner: string | null; | |
| 1343 | readonly headSha?: string | null; | |
| 1344 | readonly body: string; | |
| 1345 | readonly changedFiles: number; | |
| 1346 | readonly mergedAt: string | null; | |
| 1347 | readonly closedAt: string | null; | |
| 1348 | readonly checks: ReadonlyArray<PullRequestCheck>; | |
| 1349 | /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ | |
| 1350 | readonly autoMergeEnabled?: boolean; | |
| 1351 | /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ | |
| 1352 | readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 1353 | } | |
| 1354 | ||
| 1355 | export interface GitHubWorkflowRunApproval { | |
| 1356 | readonly id: number; | |
| 1357 | readonly name: string; | |
| 1358 | readonly url: string | null; | |
| 1359 | } | |
| 1360 | ||
| 1361 | export interface GitHubPullRequestHead { | |
| 1362 | readonly number: number; | |
| 1363 | readonly headSha: string; | |
| 1364 | readonly isCrossRepository?: boolean; | |
| 1365 | readonly headRepositoryOwner: string | null; | |
| 1366 | } | |
| 1367 | ||
| 1368 | export interface GitHubPullRequestActivity { | |
| 1369 | readonly author: PullRequestActor | null; | |
| 1370 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 1371 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 1372 | } | |
| 1373 | ||
| 1374 | function trimmed(value: string | null | undefined): string | null { | |
| 1375 | const text = value?.trim() ?? ""; | |
| 1376 | return text.length > 0 ? text : null; | |
| 1377 | } | |
| 1378 | ||
| 1379 | /** | |
| 1380 | * Null once a connection has nothing further, which is what ends every walk below. GitHub sends | |
| 1381 | * an `endCursor` on a page that is also the last one, so the flag is what decides, not the | |
| 1382 | * cursor's presence. | |
| 1383 | */ | |
| 1384 | function nextCursorOf( | |
| 1385 | pageInfo: Schema.Schema.Type<typeof RawPageInfoSchema> | undefined, | |
| 1386 | ): string | null { | |
| 1387 | return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; | |
| 1388 | } | |
| 1389 | ||
| 1390 | /** | |
| 1391 | * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. | |
| 1392 | * | |
| 1393 | * Updating is a permission, so an install that does not report it grants it and lets the host's | |
| 1394 | * own refusal explain anything that fails. Authorship is not a permission but a fact about who | |
| 1395 | * wrote the thing, and it is read to decide what an author may do to their own change — so an | |
| 1396 | * unknown answer is "not the author", which grants nothing it should not. | |
| 1397 | */ | |
| 1398 | function toPullRequestViewerFields( | |
| 1399 | raw: Schema.Schema.Type<typeof RawViewerFieldsSchema> | null | undefined, | |
| 1400 | ): { readonly canUpdate: boolean; readonly didAuthor: boolean } { | |
| 1401 | return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; | |
| 1402 | } | |
| 1403 | ||
| 1404 | function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefined) { | |
| 1405 | const login = trimmed(raw?.login); | |
| 1406 | return login === null | |
| 1407 | ? null | |
| 1408 | : { | |
| 1409 | login, | |
| 1410 | name: trimmed(raw?.name), | |
| 1411 | avatarUrl: trimmed(raw?.avatarUrl), | |
| 1412 | ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}), | |
| 1413 | }; | |
| 1414 | } | |
| 1415 | ||
| 1416 | function toCommitActor( | |
| 1417 | raw: NonNullable<Schema.Schema.Type<typeof RawCommitSchema>["authors"]>[number], | |
| 1418 | ): PullRequestActor | null { | |
| 1419 | // An email-linked GitHub account has a login; an unlinked signature only has a name or email. | |
| 1420 | // Keep that signature visible instead of silently turning a co-authored commit into one author. | |
| 1421 | const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); | |
| 1422 | return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; | |
| 1423 | } | |
| 1424 | ||
| 1425 | /** An author off the GraphQL commits connection, which names an account by `user.login` where | |
| 1426 | * `gh pr view --json commits` names it by a flat `login` copied off the signature. */ | |
| 1427 | function toGraphqlCommitActor(raw: { | |
| 1428 | readonly name?: string | null | undefined; | |
| 1429 | readonly avatarUrl?: string | null | undefined; | |
| 1430 | readonly user?: { readonly login?: string | null | undefined } | null | undefined; | |
| 1431 | }): PullRequestActor | null { | |
| 1432 | const login = trimmed(raw.user?.login) ?? trimmed(raw.name); | |
| 1433 | return login === null | |
| 1434 | ? null | |
| 1435 | : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) }; | |
| 1436 | } | |
| 1437 | ||
| 1438 | function toState(raw: { | |
| 1439 | readonly state?: string | null | undefined; | |
| 1440 | readonly mergedAt?: string | null | undefined; | |
| 1441 | }): PullRequestState { | |
| 1442 | if (trimmed(raw.mergedAt) !== null) return "merged"; | |
| 1443 | const state = raw.state?.trim().toUpperCase(); | |
| 1444 | if (state === "MERGED") return "merged"; | |
| 1445 | if (state === "CLOSED") return "closed"; | |
| 1446 | return "open"; | |
| 1447 | } | |
| 1448 | ||
| 1449 | function toMergeability(value: string | null | undefined): PullRequestMergeability { | |
| 1450 | switch (value?.trim().toUpperCase()) { | |
| 1451 | case "MERGEABLE": | |
| 1452 | return "mergeable"; | |
| 1453 | case "CONFLICTING": | |
| 1454 | return "conflicting"; | |
| 1455 | default: | |
| 1456 | return "unknown"; | |
| 1457 | } | |
| 1458 | } | |
| 1459 | ||
| 1460 | function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { | |
| 1461 | switch (value?.trim().toUpperCase()) { | |
| 1462 | case "MERGE": | |
| 1463 | return "merge"; | |
| 1464 | case "SQUASH": | |
| 1465 | return "squash"; | |
| 1466 | case "REBASE": | |
| 1467 | return "rebase"; | |
| 1468 | default: | |
| 1469 | return undefined; | |
| 1470 | } | |
| 1471 | } | |
| 1472 | ||
| 1473 | /** | |
| 1474 | * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an | |
| 1475 | * approval from an app (a review bot) or from anyone without the required permission leaves it | |
| 1476 | * empty. The reviewers still said something, and a row should show it: when GitHub reports no | |
| 1477 | * verdict, the latest review per reviewer decides, changes requested outranking approval. | |
| 1478 | */ | |
| 1479 | function toReviewDecisionWithReviews( | |
| 1480 | value: string | null | undefined, | |
| 1481 | // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. | |
| 1482 | latestReviews: | |
| 1483 | | ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>> | |
| 1484 | | { readonly nodes: ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>> } | |
| 1485 | | null | |
| 1486 | | undefined, | |
| 1487 | ): PullRequestReviewDecision | null { | |
| 1488 | const summarized = toReviewDecision(value); | |
| 1489 | if (summarized === "approved" || summarized === "changes-requested") return summarized; | |
| 1490 | const reviews = | |
| 1491 | latestReviews === null || latestReviews === undefined | |
| 1492 | ? [] | |
| 1493 | : "nodes" in latestReviews | |
| 1494 | ? latestReviews.nodes | |
| 1495 | : latestReviews; | |
| 1496 | const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); | |
| 1497 | if (states.has("CHANGES_REQUESTED")) return "changes-requested"; | |
| 1498 | if (states.has("APPROVED")) return "approved"; | |
| 1499 | return summarized; | |
| 1500 | } | |
| 1501 | ||
| 1502 | function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { | |
| 1503 | switch (value?.trim().toUpperCase()) { | |
| 1504 | case "APPROVED": | |
| 1505 | return "approved"; | |
| 1506 | case "CHANGES_REQUESTED": | |
| 1507 | return "changes-requested"; | |
| 1508 | case "REVIEW_REQUIRED": | |
| 1509 | return "review-required"; | |
| 1510 | default: | |
| 1511 | return null; | |
| 1512 | } | |
| 1513 | } | |
| 1514 | ||
| 1515 | function toLabels( | |
| 1516 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawLabelSchema>> | undefined, | |
| 1517 | ): ReadonlyArray<PullRequestLabel> { | |
| 1518 | return (raw ?? []).flatMap((label) => { | |
| 1519 | const name = trimmed(label.name); | |
| 1520 | return name === null ? [] : [{ name, color: trimmed(label.color) }]; | |
| 1521 | }); | |
| 1522 | } | |
| 1523 | ||
| 1524 | /** | |
| 1525 | * User review requests only. Team requests are tracked separately because a slug cannot be | |
| 1526 | * compared with the viewer's login. | |
| 1527 | */ | |
| 1528 | function toReviewRequestLogins( | |
| 1529 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined, | |
| 1530 | ): ReadonlyArray<string> { | |
| 1531 | return (raw ?? []).flatMap((request) => { | |
| 1532 | const login = trimmed(request.login); | |
| 1533 | return login === null ? [] : [login]; | |
| 1534 | }); | |
| 1535 | } | |
| 1536 | ||
| 1537 | function hasTeamReviewRequest( | |
| 1538 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined, | |
| 1539 | ): boolean { | |
| 1540 | return (raw ?? []).some( | |
| 1541 | (request) => | |
| 1542 | trimmed(request.login) === null && | |
| 1543 | (trimmed(request.slug) !== null || trimmed(request.name) !== null), | |
| 1544 | ); | |
| 1545 | } | |
| 1546 | ||
| 1547 | function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequestCheckStatus { | |
| 1548 | // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` | |
| 1549 | // that only exists once the run has completed. | |
| 1550 | const status = raw.status?.trim().toUpperCase(); | |
| 1551 | if (status !== undefined && status !== "COMPLETED" && status !== "") { | |
| 1552 | return "pending"; | |
| 1553 | } | |
| 1554 | switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { | |
| 1555 | case "SUCCESS": | |
| 1556 | return "success"; | |
| 1557 | case "ACTION_REQUIRED": | |
| 1558 | return "action-required"; | |
| 1559 | case "FAILURE": | |
| 1560 | case "ERROR": | |
| 1561 | case "TIMED_OUT": | |
| 1562 | case "STARTUP_FAILURE": | |
| 1563 | return "failure"; | |
| 1564 | case "CANCELLED": | |
| 1565 | return "cancelled"; | |
| 1566 | case "SKIPPED": | |
| 1567 | return "skipped"; | |
| 1568 | case "PENDING": | |
| 1569 | case "EXPECTED": | |
| 1570 | return "pending"; | |
| 1571 | default: | |
| 1572 | return "neutral"; | |
| 1573 | } | |
| 1574 | } | |
| 1575 | ||
| 1576 | /** What GitHub writes where a run has not reached that moment yet, which is not a time. */ | |
| 1577 | const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; | |
| 1578 | ||
| 1579 | function realTimestamp(value: string | null | undefined): string | null { | |
| 1580 | const at = trimmed(value); | |
| 1581 | return at === null || at === UNSET_TIMESTAMP ? null : at; | |
| 1582 | } | |
| 1583 | ||
| 1584 | /** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ | |
| 1585 | function isNamelessCheck(raw: Schema.Schema.Type<typeof RawCheckSchema>): boolean { | |
| 1586 | return trimmed(raw.name) === null && trimmed(raw.context) === null; | |
| 1587 | } | |
| 1588 | ||
| 1589 | /** | |
| 1590 | * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last | |
| 1591 | * had something to say. A queued run reports a completion time it has not reached, so the start | |
| 1592 | * stands in for it rather than sorting the newest run to the bottom. | |
| 1593 | */ | |
| 1594 | function toCheckEntries( | |
| 1595 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1596 | ): ReadonlyArray<{ | |
| 1597 | readonly check: PullRequestCheck; | |
| 1598 | readonly workflowName: string | null; | |
| 1599 | readonly at: string | null; | |
| 1600 | }> { | |
| 1601 | return (raw ?? []).flatMap((check) => { | |
| 1602 | const name = trimmed(check.name) ?? trimmed(check.context); | |
| 1603 | if (name === null) return []; | |
| 1604 | return [ | |
| 1605 | { | |
| 1606 | check: { | |
| 1607 | name, | |
| 1608 | status: toCheckStatus(check), | |
| 1609 | description: trimmed(check.description), | |
| 1610 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), | |
| 1611 | ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), | |
| 1612 | }, | |
| 1613 | workflowName: trimmed(check.workflowName), | |
| 1614 | at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), | |
| 1615 | }, | |
| 1616 | ]; | |
| 1617 | }); | |
| 1618 | } | |
| 1619 | ||
| 1620 | /** | |
| 1621 | * The one word a listing row has space for. A failure outranks anything still running, the way | |
| 1622 | * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. | |
| 1623 | * | |
| 1624 | * Null rather than "passing" for a head commit with no checks at all, so a repository that runs | |
| 1625 | * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as | |
| 1626 | * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count | |
| 1627 | * towards neither, so the row and the detail header never disagree about one head commit. | |
| 1628 | * | |
| 1629 | * Counted off the deduped checks rather than the raw rollup, so the word and the list under it | |
| 1630 | * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is | |
| 1631 | * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one | |
| 1632 | * nameless row, and nothing nameless can collide with anything. | |
| 1633 | */ | |
| 1634 | function rollupChecksState( | |
| 1635 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1636 | ): PullRequestChecksState | null { | |
| 1637 | const statuses = [ | |
| 1638 | ...toChecks(raw).map((check) => check.status), | |
| 1639 | ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), | |
| 1640 | ]; | |
| 1641 | if (statuses.length === 0) return null; | |
| 1642 | if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; | |
| 1643 | if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; | |
| 1644 | return statuses.includes("success") ? "passing" : null; | |
| 1645 | } | |
| 1646 | ||
| 1647 | function toChecks( | |
| 1648 | raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined, | |
| 1649 | ): ReadonlyArray<PullRequestCheck> { | |
| 1650 | return dedupeChecks(toCheckEntries(raw)); | |
| 1651 | } | |
| 1652 | ||
| 1653 | /** The states that are a verdict in themselves, rather than a wrapper around line comments. */ | |
| 1654 | function isReviewVerdict(reviewState: string | null): boolean { | |
| 1655 | switch (reviewState?.toUpperCase()) { | |
| 1656 | case "APPROVED": | |
| 1657 | case "CHANGES_REQUESTED": | |
| 1658 | case "DISMISSED": | |
| 1659 | return true; | |
| 1660 | default: | |
| 1661 | return false; | |
| 1662 | } | |
| 1663 | } | |
| 1664 | ||
| 1665 | function toComments(raw: { | |
| 1666 | readonly comments?: ReadonlyArray<Schema.Schema.Type<typeof RawCommentSchema>> | undefined; | |
| 1667 | readonly reviews?: ReadonlyArray<Schema.Schema.Type<typeof RawReviewSchema>> | undefined; | |
| 1668 | }): ReadonlyArray<PullRequestComment> { | |
| 1669 | const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ | |
| 1670 | id: comment.id, | |
| 1671 | kind: "issue-comment", | |
| 1672 | author: toActor(comment.author), | |
| 1673 | body: comment.body ?? "", | |
| 1674 | createdAt: comment.createdAt, | |
| 1675 | editedAt: comment.lastEditedAt ?? null, | |
| 1676 | url: trimmed(comment.url), | |
| 1677 | path: null, | |
| 1678 | reviewState: null, | |
| 1679 | })); | |
| 1680 | // A review with no body is kept only when its state is the event itself — an approval, a | |
| 1681 | // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the | |
| 1682 | // container for line comments, and those comments are read from the review threads, so | |
| 1683 | // keeping the container too would show a row with a name and nothing under it. | |
| 1684 | const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray<PullRequestComment> => { | |
| 1685 | const submittedAt = trimmed(review.submittedAt); | |
| 1686 | const reviewState = trimmed(review.state); | |
| 1687 | if ( | |
| 1688 | submittedAt === null || | |
| 1689 | ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) | |
| 1690 | ) { | |
| 1691 | return []; | |
| 1692 | } | |
| 1693 | return [ | |
| 1694 | { | |
| 1695 | id: review.id, | |
| 1696 | kind: "review", | |
| 1697 | author: toActor(review.author), | |
| 1698 | body: review.body ?? "", | |
| 1699 | createdAt: submittedAt, | |
| 1700 | editedAt: review.lastEditedAt ?? null, | |
| 1701 | url: trimmed(review.url), | |
| 1702 | path: null, | |
| 1703 | reviewState, | |
| 1704 | }, | |
| 1705 | ]; | |
| 1706 | }); | |
| 1707 | return [...issueComments, ...reviews].toSorted((left, right) => | |
| 1708 | left.createdAt.localeCompare(right.createdAt), | |
| 1709 | ); | |
| 1710 | } | |
| 1711 | ||
| 1712 | function toCommits( | |
| 1713 | commits: ReadonlyArray<Schema.Schema.Type<typeof RawCommitSchema>> | undefined, | |
| 1714 | ): ReadonlyArray<PullRequestCommit> { | |
| 1715 | return (commits ?? []).map((commit) => ({ | |
| 1716 | oid: commit.oid, | |
| 1717 | messageHeadline: commit.messageHeadline ?? "", | |
| 1718 | committedDate: commit.committedDate, | |
| 1719 | authors: (commit.authors ?? []).flatMap((author) => { | |
| 1720 | const actor = toCommitActor(author); | |
| 1721 | return actor === null ? [] : [actor]; | |
| 1722 | }), | |
| 1723 | })); | |
| 1724 | } | |
| 1725 | ||
| 1726 | function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPullRequestListItem { | |
| 1727 | return { | |
| 1728 | authorId: trimmed(raw.author?.id), | |
| 1729 | number: raw.number, | |
| 1730 | title: raw.title, | |
| 1731 | url: raw.url, | |
| 1732 | author: toActor(raw.author), | |
| 1733 | headBranch: raw.headRefName, | |
| 1734 | baseBranch: raw.baseRefName, | |
| 1735 | state: toState(raw), | |
| 1736 | isDraft: raw.isDraft ?? false, | |
| 1737 | mergeability: toMergeability(raw.mergeable), | |
| 1738 | reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), | |
| 1739 | additions: raw.additions ?? 0, | |
| 1740 | deletions: raw.deletions ?? 0, | |
| 1741 | createdAt: raw.createdAt, | |
| 1742 | updatedAt: raw.updatedAt, | |
| 1743 | reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), | |
| 1744 | hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), | |
| 1745 | labels: toLabels(raw.labels), | |
| 1746 | checksState: rollupChecksState(raw.statusCheckRollup), | |
| 1747 | }; | |
| 1748 | } | |
| 1749 | ||
| 1750 | function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail { | |
| 1751 | const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); | |
| 1752 | return { | |
| 1753 | ...toListItem(raw), | |
| 1754 | ...(typeof raw.isCrossRepository === "boolean" | |
| 1755 | ? { isCrossRepository: raw.isCrossRepository } | |
| 1756 | : {}), | |
| 1757 | headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), | |
| 1758 | headSha: trimmed(raw.headRefOid), | |
| 1759 | body: raw.body ?? "", | |
| 1760 | changedFiles: raw.changedFiles ?? 0, | |
| 1761 | mergedAt: trimmed(raw.mergedAt), | |
| 1762 | closedAt: trimmed(raw.closedAt), | |
| 1763 | checks: toChecks(raw.statusCheckRollup), | |
| 1764 | // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and | |
| 1765 | // the difference survives here rather than being flattened into false. | |
| 1766 | ...(raw.autoMergeRequest === undefined | |
| 1767 | ? {} | |
| 1768 | : { autoMergeEnabled: raw.autoMergeRequest !== null }), | |
| 1769 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 1770 | }; | |
| 1771 | } | |
| 1772 | ||
| 1773 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 1774 | const decodeSearch = decodeJsonResult(RawSearchSchema); | |
| 1775 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); | |
| 1776 | const decodeStats = decodeJsonResult(RawStatsSchema); | |
| 1777 | const decodeDetail = decodeJsonResult(RawDetailSchema); | |
| 1778 | const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); | |
| 1779 | const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); | |
| 1780 | const decodeActivity = decodeJsonResult(RawActivitySchema); | |
| 1781 | const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); | |
| 1782 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); | |
| 1783 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); | |
| 1784 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); | |
| 1785 | ||
| 1786 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 1787 | ||
| 1788 | export interface GitHubPullRequestListBatch { | |
| 1789 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 1790 | /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 1791 | readonly rawCount: number; | |
| 1792 | /** Where the next page starts, or null once GitHub has handed over every row. */ | |
| 1793 | readonly endCursor: string | null; | |
| 1794 | } | |
| 1795 | ||
| 1796 | /** | |
| 1797 | * One page of a repository's own pull request list. Malformed rows are skipped rather than | |
| 1798 | * failing the page: one unexpected pull request must not blank the whole list. A repository the | |
| 1799 | * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. | |
| 1800 | */ | |
| 1801 | export function decodePullRequestListJson( | |
| 1802 | raw: string, | |
| 1803 | ): Result.Result<GitHubPullRequestListBatch, DecodeFailure> { | |
| 1804 | const decoded = decodeRepositoryPullRequests(raw); | |
| 1805 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1806 | const connection = decoded.success.data.repository?.pullRequests; | |
| 1807 | const nodes = connection?.nodes ?? []; | |
| 1808 | const items: GitHubPullRequestListItem[] = []; | |
| 1809 | for (const entry of nodes) { | |
| 1810 | const row = toRow(entry); | |
| 1811 | if (row !== null) items.push(row.item); | |
| 1812 | } | |
| 1813 | return Result.succeed({ | |
| 1814 | items, | |
| 1815 | rawCount: nodes.length, | |
| 1816 | endCursor: | |
| 1817 | connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, | |
| 1818 | }); | |
| 1819 | } | |
| 1820 | ||
| 1821 | /** | |
| 1822 | * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as | |
| 1823 | * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull | |
| 1824 | * request, or one whose fields no longer decode. | |
| 1825 | */ | |
| 1826 | function toRow( | |
| 1827 | entry: unknown, | |
| 1828 | ): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { | |
| 1829 | const decodedNode = decodeSearchItem(entry); | |
| 1830 | if (!Exit.isSuccess(decodedNode)) return null; | |
| 1831 | const node = decodedNode.value; | |
| 1832 | const stack = toStackMembership(node); | |
| 1833 | const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( | |
| 1834 | (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { | |
| 1835 | const reviewer = request?.requestedReviewer; | |
| 1836 | const login = trimmed(reviewer?.login); | |
| 1837 | if (login !== null) return [{ login }]; | |
| 1838 | const slug = trimmed(reviewer?.slug); | |
| 1839 | return slug === null ? [] : [{ slug }]; | |
| 1840 | }, | |
| 1841 | ); | |
| 1842 | return { | |
| 1843 | item: { | |
| 1844 | ...toListItem({ | |
| 1845 | ...node, | |
| 1846 | latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => | |
| 1847 | review === null ? [] : [review], | |
| 1848 | ), | |
| 1849 | reviewRequests, | |
| 1850 | labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), | |
| 1851 | // The rollup arrives as one enum. Dressed as a single check here so it is read the same | |
| 1852 | // way the detail's checks are. | |
| 1853 | statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { | |
| 1854 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | |
| 1855 | return state === null ? [] : [{ state }]; | |
| 1856 | }), | |
| 1857 | }), | |
| 1858 | ...(stack === undefined ? {} : { stack }), | |
| 1859 | }, | |
| 1860 | repository: trimmed(node.repository?.nameWithOwner), | |
| 1861 | }; | |
| 1862 | } | |
| 1863 | ||
| 1864 | export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { | |
| 1865 | /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ | |
| 1866 | readonly repository: string; | |
| 1867 | } | |
| 1868 | ||
| 1869 | export interface GitHubPullRequestSearchBatch { | |
| 1870 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 1871 | /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 1872 | readonly rawCount: number; | |
| 1873 | /** More rows than this slice asked for, which is truncation for every repository in it. */ | |
| 1874 | readonly hasNextPage: boolean; | |
| 1875 | /** Where the next page of the same search starts. */ | |
| 1876 | readonly endCursor: string | null; | |
| 1877 | } | |
| 1878 | ||
| 1879 | /** | |
| 1880 | * A search answers with the same row a repository's list does, each naming its repository. | |
| 1881 | * | |
| 1882 | * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing | |
| 1883 | * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. | |
| 1884 | */ | |
| 1885 | export function decodePullRequestSearchJson( | |
| 1886 | raw: string, | |
| 1887 | ): Result.Result<GitHubPullRequestSearchBatch, DecodeFailure> { | |
| 1888 | const decoded = decodeSearch(raw); | |
| 1889 | if (!Result.isSuccess(decoded)) { | |
| 1890 | return Result.fail(decoded.failure); | |
| 1891 | } | |
| 1892 | const search = decoded.success.data.search; | |
| 1893 | const nodes = search.nodes ?? []; | |
| 1894 | const items: GitHubPullRequestSearchItem[] = []; | |
| 1895 | for (const entry of nodes) { | |
| 1896 | const row = toRow(entry); | |
| 1897 | if (row === null || row.repository === null) continue; | |
| 1898 | items.push({ ...row.item, repository: row.repository }); | |
| 1899 | } | |
| 1900 | return Result.succeed({ | |
| 1901 | items, | |
| 1902 | rawCount: nodes.length, | |
| 1903 | hasNextPage: search.pageInfo?.hasNextPage ?? false, | |
| 1904 | endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, | |
| 1905 | }); | |
| 1906 | } | |
| 1907 | ||
| 1908 | function toStackMembership( | |
| 1909 | raw: Schema.Schema.Type<typeof RawStackMembershipSchema>, | |
| 1910 | ): PullRequestStackMembership | undefined { | |
| 1911 | return raw.stack && raw.stackEntry | |
| 1912 | ? { | |
| 1913 | number: raw.stack.number, | |
| 1914 | size: raw.stack.size, | |
| 1915 | base: raw.stack.baseRefName, | |
| 1916 | position: raw.stackEntry.position, | |
| 1917 | } | |
| 1918 | : undefined; | |
| 1919 | } | |
| 1920 | ||
| 1921 | /** What GitHub allows in an owner or repository name. */ | |
| 1922 | const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; | |
| 1923 | ||
| 1924 | /** | |
| 1925 | * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is | |
| 1926 | * one document, and a single unanswerable alias fails all of it, so these are refused up front. | |
| 1927 | */ | |
| 1928 | function repositoryParts( | |
| 1929 | repository: string, | |
| 1930 | ): { readonly owner: string; readonly name: string } | null { | |
| 1931 | const [owner, name, ...rest] = repository.trim().split("/"); | |
| 1932 | if (rest.length > 0 || owner === undefined || name === undefined) return null; | |
| 1933 | return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; | |
| 1934 | } | |
| 1935 | ||
| 1936 | const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; | |
| 1937 | ||
| 1938 | /** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ | |
| 1939 | function aliasedPullRequestsDocument( | |
| 1940 | name: string, | |
| 1941 | alias: string, | |
| 1942 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 1943 | selection: string, | |
| 1944 | ): GraphQlDocument | null { | |
| 1945 | const addressed = changeRequests.map((changeRequest) => ({ | |
| 1946 | parts: repositoryParts(changeRequest.repository), | |
| 1947 | number: changeRequest.number, | |
| 1948 | })); | |
| 1949 | if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { | |
| 1950 | return null; | |
| 1951 | } | |
| 1952 | return aliasedGraphQlDocument({ | |
| 1953 | operation: "query", | |
| 1954 | name, | |
| 1955 | alias, | |
| 1956 | items: addressed, | |
| 1957 | variables: ({ parts, number }) => ({ | |
| 1958 | owner: ["String!", parts!.owner], | |
| 1959 | name: ["String!", parts!.name], | |
| 1960 | number: ["Int!", number], | |
| 1961 | }), | |
| 1962 | field: ({ owner, name, number }) => | |
| 1963 | `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, | |
| 1964 | }); | |
| 1965 | } | |
| 1966 | ||
| 1967 | /** | |
| 1968 | * The line counts for rows a listing already handed over, as one aliased lookup each. | |
| 1969 | * | |
| 1970 | * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a | |
| 1971 | * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, | |
| 1972 | * which the caller reports rather than sends, and for an empty request. | |
| 1973 | */ | |
| 1974 | export function buildPullRequestStatsGraphQlQuery( | |
| 1975 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 1976 | ): GraphQlDocument | null { | |
| 1977 | return aliasedPullRequestsDocument( | |
| 1978 | "PullRequestStats", | |
| 1979 | "s", | |
| 1980 | changeRequests, | |
| 1981 | "additions deletions", | |
| 1982 | ); | |
| 1983 | } | |
| 1984 | ||
| 1985 | /** Stack membership for the visible rows of a per-repository listing. */ | |
| 1986 | export function buildPullRequestStackMembershipsGraphQlQuery( | |
| 1987 | repository: string, | |
| 1988 | numbers: ReadonlyArray<number>, | |
| 1989 | ): GraphQlDocument | null { | |
| 1990 | return aliasedPullRequestsDocument( | |
| 1991 | "PullRequestStackMemberships", | |
| 1992 | "s", | |
| 1993 | numbers.map((number) => ({ repository, number })), | |
| 1994 | STACK_MEMBERSHIP_SELECTION, | |
| 1995 | ); | |
| 1996 | } | |
| 1997 | ||
| 1998 | const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); | |
| 1999 | ||
| 2000 | export function decodePullRequestStackMembershipsJson( | |
| 2001 | raw: string, | |
| 2002 | ): Result.Result<ReadonlyMap<number, PullRequestStackMembership>, DecodeFailure> { | |
| 2003 | const decoded = decodeStackMemberships(raw); | |
| 2004 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2005 | const memberships = new Map<number, PullRequestStackMembership>(); | |
| 2006 | for (const [alias, value] of Object.entries(decoded.success.data)) { | |
| 2007 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2008 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2009 | const stack = toStackMembership(value.pullRequest); | |
| 2010 | if (stack !== undefined) memberships.set(Number(index), stack); | |
| 2011 | } | |
| 2012 | return Result.succeed(memberships); | |
| 2013 | } | |
| 2014 | ||
| 2015 | /** | |
| 2016 | * The counts by the position they were asked in. A repository or a pull request GitHub answered | |
| 2017 | * nothing for is simply absent, which leaves the row with whatever it already had. | |
| 2018 | */ | |
| 2019 | export function decodePullRequestStatsJson( | |
| 2020 | raw: string, | |
| 2021 | ): Result.Result< | |
| 2022 | ReadonlyMap<number, { readonly additions: number; readonly deletions: number }>, | |
| 2023 | DecodeFailure | |
| 2024 | > { | |
| 2025 | const decoded = decodeStats(raw); | |
| 2026 | if (!Result.isSuccess(decoded)) { | |
| 2027 | return Result.fail(decoded.failure); | |
| 2028 | } | |
| 2029 | const stats = new Map<number, { readonly additions: number; readonly deletions: number }>(); | |
| 2030 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2031 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2032 | const pullRequest = value?.pullRequest; | |
| 2033 | if (index === undefined || pullRequest == null) continue; | |
| 2034 | stats.set(Number(index), { | |
| 2035 | additions: pullRequest.additions ?? 0, | |
| 2036 | deletions: pullRequest.deletions ?? 0, | |
| 2037 | }); | |
| 2038 | } | |
| 2039 | return Result.succeed(stats); | |
| 2040 | } | |
| 2041 | ||
| 2042 | /** | |
| 2043 | * The fields a linked thread keeps current, for many pull requests in one aliased read. Same | |
| 2044 | * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup | |
| 2045 | * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. | |
| 2046 | */ | |
| 2047 | const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }"; | |
| 2048 | ||
| 2049 | const PULL_REQUEST_SUMMARY_SELECTION = | |
| 2050 | "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + | |
| 2051 | "updatedAt mergedAt closedAt headRefName baseRefName " + | |
| 2052 | "author { __typename login avatarUrl ... on User { name } } " + | |
| 2053 | "latestReviews(first: 20) { nodes { state author { login } } } " + | |
| 2054 | "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; | |
| 2055 | ||
| 2056 | /** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ | |
| 2057 | export function buildPullRequestSummariesGraphQlQuery( | |
| 2058 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 2059 | includeStacks = false, | |
| 2060 | ): GraphQlDocument | null { | |
| 2061 | return aliasedPullRequestsDocument( | |
| 2062 | "PullRequestSummaries", | |
| 2063 | "s", | |
| 2064 | changeRequests, | |
| 2065 | `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, | |
| 2066 | ); | |
| 2067 | } | |
| 2068 | ||
| 2069 | /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ | |
| 2070 | export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { | |
| 2071 | return `query($owner: String!, $name: String!, $number: Int!) { | |
| 2072 | s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } | |
| 2073 | }`; | |
| 2074 | } | |
| 2075 | ||
| 2076 | const RawSummarySchema = Schema.Struct({ | |
| 2077 | ...RawSearchItemSchema.fields, | |
| 2078 | changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2079 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2080 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 2081 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2082 | createdAt: Schema.optional(Schema.String), | |
| 2083 | }); | |
| 2084 | const decodeSummaries = decodeJsonResult( | |
| 2085 | Schema.Struct({ | |
| 2086 | data: Schema.optional( | |
| 2087 | Schema.NullOr( | |
| 2088 | Schema.Record( | |
| 2089 | Schema.String, | |
| 2090 | Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), | |
| 2091 | ), | |
| 2092 | ), | |
| 2093 | ), | |
| 2094 | }), | |
| 2095 | ); | |
| 2096 | const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); | |
| 2097 | ||
| 2098 | export interface GitHubPullRequestSummary { | |
| 2099 | readonly number: number; | |
| 2100 | readonly title: string; | |
| 2101 | readonly url: string; | |
| 2102 | readonly headBranch: string; | |
| 2103 | readonly baseBranch: string; | |
| 2104 | readonly state: PullRequestState; | |
| 2105 | readonly isDraft: boolean; | |
| 2106 | readonly closedAt: string | null; | |
| 2107 | readonly mergedAt: string | null; | |
| 2108 | readonly updatedAt: string; | |
| 2109 | readonly author: PullRequestActor | null; | |
| 2110 | readonly additions: number; | |
| 2111 | readonly deletions: number; | |
| 2112 | readonly changedFiles: number; | |
| 2113 | readonly reviewDecision: PullRequestReviewDecision | null; | |
| 2114 | readonly checksState: PullRequestChecksState | null; | |
| 2115 | readonly mergeability: PullRequestMergeability; | |
| 2116 | /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ | |
| 2117 | readonly stack?: PullRequestStackMembership | null; | |
| 2118 | } | |
| 2119 | ||
| 2120 | /** | |
| 2121 | * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or | |
| 2122 | * one whose fields no longer decode, is absent rather than failing the rest of the batch. | |
| 2123 | */ | |
| 2124 | export function decodePullRequestSummariesJson( | |
| 2125 | raw: string, | |
| 2126 | ): Result.Result<ReadonlyMap<number, GitHubPullRequestSummary>, DecodeFailure> { | |
| 2127 | const decoded = decodeSummaries(raw); | |
| 2128 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2129 | const summaries = new Map<number, GitHubPullRequestSummary>(); | |
| 2130 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2131 | const index = /^s(\d+)$/.exec(alias)?.[1]; | |
| 2132 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2133 | const entry = decodeSummaryEntry(value.pullRequest); | |
| 2134 | if (!Exit.isSuccess(entry)) continue; | |
| 2135 | const pr = entry.value; | |
| 2136 | summaries.set(Number(index), { | |
| 2137 | number: pr.number, | |
| 2138 | title: pr.title, | |
| 2139 | url: pr.url, | |
| 2140 | headBranch: pr.headRefName, | |
| 2141 | baseBranch: pr.baseRefName, | |
| 2142 | state: toState(pr), | |
| 2143 | isDraft: pr.isDraft ?? false, | |
| 2144 | closedAt: trimmed(pr.closedAt), | |
| 2145 | mergedAt: trimmed(pr.mergedAt), | |
| 2146 | updatedAt: pr.updatedAt, | |
| 2147 | author: toActor(pr.author), | |
| 2148 | additions: pr.additions ?? 0, | |
| 2149 | deletions: pr.deletions ?? 0, | |
| 2150 | changedFiles: pr.changedFiles ?? 0, | |
| 2151 | reviewDecision: toReviewDecisionWithReviews( | |
| 2152 | pr.reviewDecision, | |
| 2153 | (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), | |
| 2154 | ), | |
| 2155 | // One enum for the head commit, dressed as a single check like the search row's. | |
| 2156 | checksState: rollupChecksState( | |
| 2157 | (pr.commits?.nodes ?? []).flatMap((commitNode) => { | |
| 2158 | const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); | |
| 2159 | return state === null ? [] : [{ state }]; | |
| 2160 | }), | |
| 2161 | ), | |
| 2162 | mergeability: toMergeability(pr.mergeable), | |
| 2163 | ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), | |
| 2164 | }); | |
| 2165 | } | |
| 2166 | return Result.succeed(summaries); | |
| 2167 | } | |
| 2168 | ||
| 2169 | const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }"; | |
| 2170 | const WATCH_FINGERPRINT_CHECK_COUNTS = | |
| 2171 | "checkRunCountsByState { state count } statusContextCountsByState { state count }"; | |
| 2172 | ||
| 2173 | /** | |
| 2174 | * What a pull request watch needs to notice, priced by GitHub at one point for twenty-five pull | |
| 2175 | * requests, where the detail and activity reads it gates cost sixteen for one. Counts and the | |
| 2176 | * newest edit cover new comments, reviews (a reply in a review thread is a review), threads, and | |
| 2177 | * a bot rewriting its summary; check counts by state move whenever a check starts or finishes. | |
| 2178 | * Comments come most recently updated first, since an edit moves `updatedAt`, so an edit to an | |
| 2179 | * old comment on a long pull request is still in the page. Reviews have no such order: an edit | |
| 2180 | * to a review older than the last hundred waits for the watch's periodic full read, as do edits | |
| 2181 | * to comments inside review threads, which cost a point per pull request to ask for. | |
| 2182 | */ | |
| 2183 | const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = | |
| 2184 | "state mergeable headRefOid " + | |
| 2185 | `comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { ${WATCH_FINGERPRINT_EDITS} } ` + | |
| 2186 | `reviews(last: 100) { ${WATCH_FINGERPRINT_EDITS} } ` + | |
| 2187 | "reviewThreads { totalCount } " + | |
| 2188 | `commits(last: 1) { nodes { commit { statusCheckRollup { contexts { ${WATCH_FINGERPRINT_CHECK_COUNTS} } } } } }`; | |
| 2189 | ||
| 2190 | /** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ | |
| 2191 | export function buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 2192 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | |
| 2193 | ): GraphQlDocument | null { | |
| 2194 | return aliasedPullRequestsDocument( | |
| 2195 | "PullRequestWatchFingerprints", | |
| 2196 | "w", | |
| 2197 | changeRequests, | |
| 2198 | PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, | |
| 2199 | ); | |
| 2200 | } | |
| 2201 | ||
| 2202 | /** | |
| 2203 | * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks) | |
| 2204 | * needs the detail read, and `remarks` also needs the far costlier activity read. | |
| 2205 | */ | |
| 2206 | export interface GitHubPullRequestWatchFingerprint { | |
| 2207 | readonly status: string; | |
| 2208 | readonly remarks: string; | |
| 2209 | } | |
| 2210 | ||
| 2211 | const RawEditedSchema = Schema.Struct({ | |
| 2212 | totalCount: Schema.Int, | |
| 2213 | nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))), | |
| 2214 | }); | |
| 2215 | const RawStateCountsSchema = Schema.NullOr( | |
| 2216 | Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })), | |
| 2217 | ); | |
| 2218 | const decodeWatchFingerprintEntry = Schema.decodeUnknownExit( | |
| 2219 | Schema.Struct({ | |
| 2220 | state: Schema.String, | |
| 2221 | mergeable: Schema.NullOr(Schema.String), | |
| 2222 | headRefOid: Schema.String, | |
| 2223 | comments: RawEditedSchema, | |
| 2224 | reviews: RawEditedSchema, | |
| 2225 | reviewThreads: Schema.Struct({ totalCount: Schema.Int }), | |
| 2226 | commits: Schema.Struct({ | |
| 2227 | nodes: Schema.Array( | |
| 2228 | Schema.NullOr( | |
| 2229 | Schema.Struct({ | |
| 2230 | commit: Schema.Struct({ | |
| 2231 | statusCheckRollup: Schema.NullOr( | |
| 2232 | Schema.Struct({ | |
| 2233 | contexts: Schema.Struct({ | |
| 2234 | checkRunCountsByState: RawStateCountsSchema, | |
| 2235 | statusContextCountsByState: RawStateCountsSchema, | |
| 2236 | }), | |
| 2237 | }), | |
| 2238 | ), | |
| 2239 | }), | |
| 2240 | }), | |
| 2241 | ), | |
| 2242 | ), | |
| 2243 | }), | |
| 2244 | }), | |
| 2245 | ); | |
| 2246 | ||
| 2247 | // An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all. | |
| 2248 | const newestEdit = (connection: typeof RawEditedSchema.Type) => | |
| 2249 | connection.nodes.reduce( | |
| 2250 | (newest, node) => | |
| 2251 | node?.lastEditedAt != null && node.lastEditedAt > newest ? node.lastEditedAt : newest, | |
| 2252 | "", | |
| 2253 | ); | |
| 2254 | ||
| 2255 | const stateCounts = (counts: typeof RawStateCountsSchema.Type) => | |
| 2256 | (counts ?? []) | |
| 2257 | .filter(({ count }) => count > 0) | |
| 2258 | .map(({ state, count }) => `${state}:${count}`) | |
| 2259 | .toSorted() | |
| 2260 | .join(","); | |
| 2261 | ||
| 2262 | /** Fingerprints by alias index; a pull request GitHub returned nothing for is left out. */ | |
| 2263 | export function decodePullRequestWatchFingerprintsJson( | |
| 2264 | raw: string, | |
| 2265 | ): Result.Result<ReadonlyMap<number, GitHubPullRequestWatchFingerprint>, DecodeFailure> { | |
| 2266 | const decoded = decodeSummaries(raw); | |
| 2267 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2268 | const fingerprints = new Map<number, GitHubPullRequestWatchFingerprint>(); | |
| 2269 | for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { | |
| 2270 | const index = /^w(\d+)$/.exec(alias)?.[1]; | |
| 2271 | if (index === undefined || value?.pullRequest == null) continue; | |
| 2272 | const entry = decodeWatchFingerprintEntry(value.pullRequest); | |
| 2273 | if (!Exit.isSuccess(entry)) continue; | |
| 2274 | const pr = entry.value; | |
| 2275 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2276 | fingerprints.set(Number(index), { | |
| 2277 | status: [ | |
| 2278 | pr.state, | |
| 2279 | pr.mergeable ?? "", | |
| 2280 | pr.headRefOid, | |
| 2281 | stateCounts(contexts?.checkRunCountsByState ?? null), | |
| 2282 | stateCounts(contexts?.statusContextCountsByState ?? null), | |
| 2283 | ].join(" "), | |
| 2284 | remarks: [ | |
| 2285 | pr.comments.totalCount, | |
| 2286 | newestEdit(pr.comments), | |
| 2287 | pr.reviews.totalCount, | |
| 2288 | newestEdit(pr.reviews), | |
| 2289 | pr.reviewThreads.totalCount, | |
| 2290 | ].join(" "), | |
| 2291 | }); | |
| 2292 | } | |
| 2293 | return Result.succeed(fingerprints); | |
| 2294 | } | |
| 2295 | ||
| 2296 | export interface GitHubPullRequestCore extends GitHubPullRequestDetail { | |
| 2297 | readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; | |
| 2298 | readonly comparison: GitHubBaseComparison | null; | |
| 2299 | readonly checksTruncated: boolean; | |
| 2300 | } | |
| 2301 | ||
| 2302 | export function decodePullRequestCoreJson( | |
| 2303 | raw: string, | |
| 2304 | ): Result.Result<GitHubPullRequestCore, DecodeFailure> { | |
| 2305 | const decoded = decodeCore(raw); | |
| 2306 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2307 | const repository = decoded.success.data.repository; | |
| 2308 | const pr = repository.pullRequest; | |
| 2309 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2310 | return Result.succeed({ | |
| 2311 | ...toDetail({ | |
| 2312 | ...pr, | |
| 2313 | reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => | |
| 2314 | requestedReviewer === null ? [] : [requestedReviewer], | |
| 2315 | ), | |
| 2316 | labels: pr.labels.nodes, | |
| 2317 | statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), | |
| 2318 | }), | |
| 2319 | viewerAccess: { | |
| 2320 | canWrite: toCanWrite(repository.viewerPermission), | |
| 2321 | canTriage: toCanTriage(repository.viewerPermission), | |
| 2322 | ...toPullRequestViewerFields(pr), | |
| 2323 | mergeCapabilities: { | |
| 2324 | merge: repository.mergeCommitAllowed, | |
| 2325 | squash: repository.squashMergeAllowed, | |
| 2326 | rebase: repository.rebaseMergeAllowed, | |
| 2327 | }, | |
| 2328 | }, | |
| 2329 | comparison: | |
| 2330 | pr.state !== "OPEN" || pr.baseRef?.compare == null | |
| 2331 | ? null | |
| 2332 | : { | |
| 2333 | behindBy: pr.baseRef.compare.behindBy, | |
| 2334 | viewerCanUpdate: pr.viewerCanUpdateBranch, | |
| 2335 | }, | |
| 2336 | checksTruncated: contexts?.pageInfo.hasNextPage === true, | |
| 2337 | }); | |
| 2338 | } | |
| 2339 | ||
| 2340 | export function decodePullRequestDetailJson( | |
| 2341 | raw: string, | |
| 2342 | ): Result.Result<GitHubPullRequestDetail, DecodeFailure> { | |
| 2343 | const decoded = decodeDetail(raw); | |
| 2344 | return Result.isSuccess(decoded) | |
| 2345 | ? Result.succeed(toDetail(decoded.success)) | |
| 2346 | : Result.fail(decoded.failure); | |
| 2347 | } | |
| 2348 | ||
| 2349 | export interface GitHubWorkflowRunPage { | |
| 2350 | readonly runs: ReadonlyArray<GitHubWorkflowRunApproval>; | |
| 2351 | /** Runs on the page, counted before decoding, which is what decides whether to page on. */ | |
| 2352 | readonly rawCount: number; | |
| 2353 | } | |
| 2354 | ||
| 2355 | /** One page of `actions/runs`. */ | |
| 2356 | export function decodeWorkflowRunsJson( | |
| 2357 | raw: string, | |
| 2358 | ): Result.Result<GitHubWorkflowRunPage, DecodeFailure> { | |
| 2359 | const decoded = decodeWorkflowRuns(raw); | |
| 2360 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2361 | return Result.succeed({ | |
| 2362 | runs: decoded.success.workflow_runs.map((run) => ({ | |
| 2363 | id: run.id, | |
| 2364 | name: trimmed(run.name) ?? `Workflow run ${run.id}`, | |
| 2365 | url: trimmed(run.html_url), | |
| 2366 | })), | |
| 2367 | rawCount: decoded.success.workflow_runs.length, | |
| 2368 | }); | |
| 2369 | } | |
| 2370 | ||
| 2371 | /** Open pull requests whose head branch has one name, from whichever repository it lives in. */ | |
| 2372 | export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { | |
| 2373 | repository(owner: $owner, name: $name) { | |
| 2374 | pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { | |
| 2375 | pageInfo { hasNextPage endCursor } | |
| 2376 | nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } | |
| 2377 | } | |
| 2378 | } | |
| 2379 | }`; | |
| 2380 | ||
| 2381 | export function decodePullRequestHeadsJson(raw: string): Result.Result< | |
| 2382 | { | |
| 2383 | readonly heads: ReadonlyArray<GitHubPullRequestHead>; | |
| 2384 | readonly nextCursor: string | null; | |
| 2385 | }, | |
| 2386 | DecodeFailure | |
| 2387 | > { | |
| 2388 | const decoded = decodePullRequestHeads(raw); | |
| 2389 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2390 | const connection = decoded.success.data.repository?.pullRequests; | |
| 2391 | return Result.succeed({ | |
| 2392 | heads: (connection?.nodes ?? []).map((pullRequest) => ({ | |
| 2393 | number: pullRequest.number, | |
| 2394 | headSha: pullRequest.headRefOid, | |
| 2395 | ...(typeof pullRequest.isCrossRepository === "boolean" | |
| 2396 | ? { isCrossRepository: pullRequest.isCrossRepository } | |
| 2397 | : {}), | |
| 2398 | headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), | |
| 2399 | })), | |
| 2400 | nextCursor: nextCursorOf(connection?.pageInfo), | |
| 2401 | }); | |
| 2402 | } | |
| 2403 | ||
| 2404 | export interface GitHubPullRequestActivityPage { | |
| 2405 | /** Only on the first page, which is the one that asks for them. */ | |
| 2406 | readonly author?: PullRequestActor | null; | |
| 2407 | readonly commits?: ReadonlyArray<PullRequestCommit>; | |
| 2408 | /** Issue comments and reviews, each list unsorted. */ | |
| 2409 | readonly remarks: ReadonlyArray<PullRequestComment>; | |
| 2410 | readonly nextCommentsCursor: string | null; | |
| 2411 | readonly nextReviewsCursor: string | null; | |
| 2412 | } | |
| 2413 | ||
| 2414 | /** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ | |
| 2415 | export function decodePullRequestActivityJson( | |
| 2416 | raw: string, | |
| 2417 | ): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> { | |
| 2418 | const decoded = decodeActivity(raw); | |
| 2419 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2420 | const pr = decoded.success.data.repository.pullRequest; | |
| 2421 | return Result.succeed({ | |
| 2422 | ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), | |
| 2423 | ...(pr.commits === undefined | |
| 2424 | ? {} | |
| 2425 | : { | |
| 2426 | commits: toCommits( | |
| 2427 | (pr.commits?.nodes ?? []).map(({ commit }) => ({ | |
| 2428 | ...commit, | |
| 2429 | authors: (commit.authors?.nodes ?? []).map((author) => ({ | |
| 2430 | ...author, | |
| 2431 | login: author.user?.login ?? null, | |
| 2432 | })), | |
| 2433 | })), | |
| 2434 | ), | |
| 2435 | }), | |
| 2436 | remarks: toComments({ | |
| 2437 | comments: pr.comments?.nodes ?? [], | |
| 2438 | reviews: pr.reviews?.nodes ?? [], | |
| 2439 | }), | |
| 2440 | nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), | |
| 2441 | nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), | |
| 2442 | }); | |
| 2443 | } | |
| 2444 | ||
| 2445 | /** Every check context of the head commit, a page at a time, for a rollup past one page. */ | |
| 2446 | export const pullRequestCheckContextsGraphQlQuery = (host: string) => | |
| 2447 | `query($owner: String!, $name: String!, $number: Int!, $after: String) { | |
| 2448 | repository(owner: $owner, name: $name) { | |
| 2449 | pullRequest(number: $number) { | |
| 2450 | headRefOid | |
| 2451 | commits(last: 1) { | |
| 2452 | nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { | |
| 2453 | ${checkContextNodesSelection(host)} | |
| 2454 | pageInfo { hasNextPage endCursor } | |
| 2455 | } } } } | |
| 2456 | } | |
| 2457 | } | |
| 2458 | } | |
| 2459 | }`; | |
| 2460 | ||
| 2461 | const RawCheckContextNodeSchema = Schema.Struct({ | |
| 2462 | ...RawCheckSchema.fields, | |
| 2463 | checkSuite: Schema.optional( | |
| 2464 | Schema.NullOr( | |
| 2465 | Schema.Struct({ | |
| 2466 | workflowRun: Schema.NullOr( | |
| 2467 | Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), | |
| 2468 | ), | |
| 2469 | }), | |
| 2470 | ), | |
| 2471 | ), | |
| 2472 | }); | |
| 2473 | ||
| 2474 | const decodeCheckContexts = decodeJsonResult( | |
| 2475 | Schema.Struct({ | |
| 2476 | data: Schema.Struct({ | |
| 2477 | repository: Schema.Struct({ | |
| 2478 | pullRequest: Schema.Struct({ | |
| 2479 | headRefOid: Schema.String, | |
| 2480 | commits: Schema.Struct({ | |
| 2481 | nodes: Schema.Array( | |
| 2482 | Schema.Struct({ | |
| 2483 | commit: Schema.Struct({ | |
| 2484 | statusCheckRollup: Schema.NullOr( | |
| 2485 | Schema.Struct({ | |
| 2486 | contexts: Schema.Struct({ | |
| 2487 | nodes: Schema.Array(RawCheckContextNodeSchema), | |
| 2488 | pageInfo: RawPageInfoSchema, | |
| 2489 | }), | |
| 2490 | }), | |
| 2491 | ), | |
| 2492 | }), | |
| 2493 | }), | |
| 2494 | ), | |
| 2495 | }), | |
| 2496 | }), | |
| 2497 | }), | |
| 2498 | }), | |
| 2499 | }), | |
| 2500 | ); | |
| 2501 | ||
| 2502 | /** A check context as the rollup reports it; opaque outside this module. */ | |
| 2503 | export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>; | |
| 2504 | ||
| 2505 | function toCheckContexts( | |
| 2506 | nodes: ReadonlyArray<Schema.Schema.Type<typeof RawCheckContextNodeSchema>>, | |
| 2507 | ): ReadonlyArray<GitHubCheckContext> { | |
| 2508 | return nodes.map((check) => ({ | |
| 2509 | ...check, | |
| 2510 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | |
| 2511 | })); | |
| 2512 | } | |
| 2513 | ||
| 2514 | export function decodePullRequestCheckContextsJson(raw: string): Result.Result< | |
| 2515 | { | |
| 2516 | readonly headSha: string; | |
| 2517 | readonly contexts: ReadonlyArray<GitHubCheckContext>; | |
| 2518 | readonly nextCursor: string | null; | |
| 2519 | }, | |
| 2520 | DecodeFailure | |
| 2521 | > { | |
| 2522 | const decoded = decodeCheckContexts(raw); | |
| 2523 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 2524 | const pr = decoded.success.data.repository.pullRequest; | |
| 2525 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | |
| 2526 | return Result.succeed({ | |
| 2527 | headSha: pr.headRefOid, | |
| 2528 | contexts: toCheckContexts(contexts?.nodes ?? []), | |
| 2529 | nextCursor: nextCursorOf(contexts?.pageInfo), | |
| 2530 | }); | |
| 2531 | } | |
| 2532 | ||
| 2533 | /** The checks and their one-word verdict, from every context of the head commit. */ | |
| 2534 | export function pullRequestChecksFromContexts(contexts: ReadonlyArray<GitHubCheckContext>): { | |
| 2535 | readonly checks: ReadonlyArray<PullRequestCheck>; | |
| 2536 | readonly checksState: PullRequestChecksState | null; | |
| 2537 | } { | |
| 2538 | return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; | |
| 2539 | } | |
| 2540 | ||
| 2541 | export interface GitHubReviewThreadComments { | |
| 2542 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 2543 | /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ | |
| 2544 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2545 | /** Whole conversations, kept anchored so the diff can pin them to their line. */ | |
| 2546 | readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>; | |
| 2547 | /** The host's own count of the conversation, which a bounded read can fall short of. */ | |
| 2548 | readonly commentCount: number; | |
| 2549 | readonly truncated: boolean; | |
| 2550 | readonly reviewThreadsTruncated: boolean; | |
| 2551 | /** The pull request's own reactions, which sit on its description. */ | |
| 2552 | readonly reactions: ReadonlyArray<PullRequestReaction>; | |
| 2553 | /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ | |
| 2554 | readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | |
| 2555 | readonly editedAtById: ReadonlyMap<string, string>; | |
| 2556 | /** | |
| 2557 | * Everyone on the review: those still asked and those who have already answered. Whoever has | |
| 2558 | * reviewed is no longer an outstanding request, so asking only for requests reports nobody on | |
| 2559 | * a pull request that has in fact been reviewed. | |
| 2560 | */ | |
| 2561 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 2562 | /** | |
| 2563 | * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of | |
| 2564 | * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, | |
| 2565 | * so an app's avatar arrives the same way a person's does. | |
| 2566 | */ | |
| 2567 | readonly avatarsByLogin: ReadonlyMap<string, string>; | |
| 2568 | readonly botLogins: ReadonlySet<string>; | |
| 2569 | /** Per-commit line counts carried by the same bounded pull-request query. */ | |
| 2570 | readonly commitStats: ReadonlyMap< | |
| 2571 | string, | |
| 2572 | { readonly additions: number; readonly deletions: number } | |
| 2573 | >; | |
| 2574 | /** | |
| 2575 | * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. | |
| 2576 | * Empty wherever the read never happened (an install too old for the field, a degraded page), | |
| 2577 | * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has | |
| 2578 | * no commits". | |
| 2579 | */ | |
| 2580 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 2581 | /** What GitHub says the reader may do with this pull request, read off the same response. */ | |
| 2582 | readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; | |
| 2583 | } | |
| 2584 | ||
| 2585 | /** One thread as this page found it, with what it takes to finish reading it. */ | |
| 2586 | export interface GitHubReviewThreadEntry { | |
| 2587 | readonly thread: PullRequestReviewThread; | |
| 2588 | /** How many comments GitHub says the thread holds, read or not. */ | |
| 2589 | readonly commentCount: number; | |
| 2590 | /** Where the rest of this thread's comments carry on from, or null once it is whole. */ | |
| 2591 | readonly nextCommentCursor: string | null; | |
| 2592 | } | |
| 2593 | ||
| 2594 | export interface GitHubReviewThreadPage { | |
| 2595 | readonly threads: ReadonlyArray<GitHubReviewThreadEntry>; | |
| 2596 | /** Where the next page of threads starts, or null once the host has handed them all over. */ | |
| 2597 | readonly nextCursor: string | null; | |
| 2598 | /** The pull request's own reactions, which sit on its description. */ | |
| 2599 | readonly reactions: ReadonlyArray<PullRequestReaction>; | |
| 2600 | /** | |
| 2601 | * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers | |
| 2602 | * for without any. Only ids with a reaction are here; the rest carry none. | |
| 2603 | */ | |
| 2604 | readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | |
| 2605 | readonly editedAtById: ReadonlyMap<string, string>; | |
| 2606 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 2607 | readonly avatarsByLogin: ReadonlyMap<string, string>; | |
| 2608 | readonly botLogins: ReadonlySet<string>; | |
| 2609 | readonly commitStats: ReadonlyMap< | |
| 2610 | string, | |
| 2611 | { readonly additions: number; readonly deletions: number } | |
| 2612 | >; | |
| 2613 | readonly commits: ReadonlyArray<PullRequestCommit>; | |
| 2614 | readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; | |
| 2615 | /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ | |
| 2616 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2617 | /** Where the rest of the dismissal events start, or null once this page carried them all. */ | |
| 2618 | readonly nextDismissalCursor: string | null; | |
| 2619 | } | |
| 2620 | ||
| 2621 | /** | |
| 2622 | * The threads as one flat conversation, which is what the timeline reads. Every comment of | |
| 2623 | * every thread, resolved or not: a resolved conversation is still what was said, and a reply is | |
| 2624 | * as much of it as the remark it answers. | |
| 2625 | */ | |
| 2626 | export function reviewThreadConversation( | |
| 2627 | threads: ReadonlyArray<PullRequestReviewThread>, | |
| 2628 | ): ReadonlyArray<PullRequestComment> { | |
| 2629 | return threads.flatMap((thread) => | |
| 2630 | thread.comments.map((comment): PullRequestComment => ({ | |
| 2631 | id: comment.id, | |
| 2632 | kind: "review-comment", | |
| 2633 | author: comment.author, | |
| 2634 | body: comment.body, | |
| 2635 | createdAt: comment.createdAt, | |
| 2636 | editedAt: comment.editedAt ?? null, | |
| 2637 | url: comment.url, | |
| 2638 | path: thread.path, | |
| 2639 | reviewState: null, | |
| 2640 | reactions: comment.reactions ?? [], | |
| 2641 | })), | |
| 2642 | ); | |
| 2643 | } | |
| 2644 | ||
| 2645 | /** One page of review threads. Following the cursors it hands back is the caller's job. */ | |
| 2646 | function toDismissalEntries( | |
| 2647 | nodes: | |
| 2648 | | ReadonlyArray<{ | |
| 2649 | readonly dismissalMessage?: string | null | undefined; | |
| 2650 | readonly review?: { readonly id?: string | null | undefined } | null | undefined; | |
| 2651 | }> | |
| 2652 | | undefined, | |
| 2653 | ): Map<string, string> { | |
| 2654 | const entries = new Map<string, string>(); | |
| 2655 | for (const node of nodes ?? []) { | |
| 2656 | const reviewId = trimmed(node.review?.id); | |
| 2657 | const message = trimmed(node.dismissalMessage); | |
| 2658 | if (reviewId !== null && message !== null) entries.set(reviewId, message); | |
| 2659 | } | |
| 2660 | return entries; | |
| 2661 | } | |
| 2662 | ||
| 2663 | const RawReviewDismissalsSchema = Schema.Struct({ | |
| 2664 | data: Schema.Struct({ | |
| 2665 | repository: Schema.Struct({ | |
| 2666 | pullRequest: Schema.Struct({ | |
| 2667 | timelineItems: Schema.Struct({ | |
| 2668 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 2669 | nodes: Schema.Array( | |
| 2670 | Schema.Struct({ | |
| 2671 | dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2672 | review: Schema.optional( | |
| 2673 | Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 2674 | ), | |
| 2675 | }), | |
| 2676 | ), | |
| 2677 | }), | |
| 2678 | }), | |
| 2679 | }), | |
| 2680 | }), | |
| 2681 | }); | |
| 2682 | ||
| 2683 | const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); | |
| 2684 | ||
| 2685 | /** One further page of dismissal events, in the shape the thread read's own page carries. */ | |
| 2686 | export function decodeReviewDismissalsJson(raw: string): Result.Result< | |
| 2687 | { | |
| 2688 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2689 | readonly nextCursor: string | null; | |
| 2690 | }, | |
| 2691 | DecodeFailure | |
| 2692 | > { | |
| 2693 | const decoded = decodeReviewDismissals(raw); | |
| 2694 | if (!Result.isSuccess(decoded)) { | |
| 2695 | return Result.fail(decoded.failure); | |
| 2696 | } | |
| 2697 | const items = decoded.success.data.repository.pullRequest.timelineItems; | |
| 2698 | return Result.succeed({ | |
| 2699 | dismissalsByReviewId: toDismissalEntries(items.nodes), | |
| 2700 | nextCursor: nextCursorOf(items.pageInfo), | |
| 2701 | }); | |
| 2702 | } | |
| 2703 | ||
| 2704 | export function decodeReviewThreadsJson( | |
| 2705 | raw: string, | |
| 2706 | ): Result.Result<GitHubReviewThreadPage, DecodeFailure> { | |
| 2707 | const decoded = decodeReviewThreads(raw); | |
| 2708 | if (!Result.isSuccess(decoded)) { | |
| 2709 | return Result.fail(decoded.failure); | |
| 2710 | } | |
| 2711 | const viewer = trimmed(decoded.success.data.viewer?.login); | |
| 2712 | const threads = decoded.success.data.repository.pullRequest.reviewThreads; | |
| 2713 | const entries = threads.nodes.flatMap((thread): ReadonlyArray<GitHubReviewThreadEntry> => { | |
| 2714 | const path = trimmed(thread.path); | |
| 2715 | const id = trimmed(thread.id); | |
| 2716 | if (path === null || id === null || thread.comments.nodes.length === 0) return []; | |
| 2717 | return [ | |
| 2718 | { | |
| 2719 | thread: { | |
| 2720 | id, | |
| 2721 | path, | |
| 2722 | // Null once the thread's line has left the diff, which is exactly when GitHub reports | |
| 2723 | // it outdated. Such a thread is listed rather than pinned to a line it no longer has. | |
| 2724 | line: | |
| 2725 | thread.line !== null && thread.line !== undefined && thread.line > 0 | |
| 2726 | ? thread.line | |
| 2727 | : null, | |
| 2728 | side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", | |
| 2729 | isResolved: thread.isResolved === true, | |
| 2730 | isOutdated: thread.isOutdated === true, | |
| 2731 | comments: thread.comments.nodes.map((comment) => ({ | |
| 2732 | id: comment.id, | |
| 2733 | author: toActor(comment.author), | |
| 2734 | body: comment.body ?? "", | |
| 2735 | createdAt: comment.createdAt, | |
| 2736 | editedAt: comment.lastEditedAt ?? null, | |
| 2737 | url: trimmed(comment.url), | |
| 2738 | reactions: toReactions(comment.reactionGroups, viewer), | |
| 2739 | })), | |
| 2740 | }, | |
| 2741 | commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, | |
| 2742 | nextCommentCursor: nextCursorOf(thread.comments.pageInfo), | |
| 2743 | }, | |
| 2744 | ]; | |
| 2745 | }); | |
| 2746 | const pullRequest = decoded.success.data.repository.pullRequest; | |
| 2747 | const avatarsByLogin = new Map<string, string>(); | |
| 2748 | const botLogins = new Set<string>(); | |
| 2749 | for (const raw of [ | |
| 2750 | pullRequest.author, | |
| 2751 | ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), | |
| 2752 | ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author), | |
| 2753 | ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), | |
| 2754 | ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), | |
| 2755 | ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), | |
| 2756 | ]) { | |
| 2757 | const login = trimmed(raw?.login); | |
| 2758 | const avatarUrl = trimmed(raw?.avatarUrl); | |
| 2759 | if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); | |
| 2760 | if (login !== null && toActor(raw)?.isBot) botLogins.add(login); | |
| 2761 | } | |
| 2762 | const reviewers = new Map<string, PullRequestActor>(); | |
| 2763 | for (const raw of [ | |
| 2764 | ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), | |
| 2765 | ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), | |
| 2766 | ]) { | |
| 2767 | const actor = toActor(raw); | |
| 2768 | // Keyed by login, so someone who was asked and then answered appears once. | |
| 2769 | if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); | |
| 2770 | } | |
| 2771 | const commitStats = new Map<string, { readonly additions: number; readonly deletions: number }>(); | |
| 2772 | const commits: PullRequestCommit[] = []; | |
| 2773 | for (const node of pullRequest.commits?.nodes ?? []) { | |
| 2774 | const commit = node.commit; | |
| 2775 | const oid = trimmed(commit.oid); | |
| 2776 | if (oid === null) continue; | |
| 2777 | // GitHub measures a merge commit against its first parent, so merging the base into the head | |
| 2778 | // reports every upstream change as if it belonged to the pull request. There is no useful | |
| 2779 | // per-commit stat to show for that integration commit without another comparison request. | |
| 2780 | if ( | |
| 2781 | (commit.parents?.totalCount ?? 1) <= 1 && | |
| 2782 | commit.additions !== undefined && | |
| 2783 | commit.deletions !== undefined | |
| 2784 | ) { | |
| 2785 | commitStats.set(oid, { | |
| 2786 | additions: Math.max(0, commit.additions), | |
| 2787 | deletions: Math.max(0, commit.deletions), | |
| 2788 | }); | |
| 2789 | } | |
| 2790 | const committedDate = trimmed(commit.committedDate); | |
| 2791 | if (committedDate === null) continue; | |
| 2792 | commits.push({ | |
| 2793 | oid, | |
| 2794 | messageHeadline: commit.messageHeadline ?? "", | |
| 2795 | committedDate, | |
| 2796 | authors: (commit.authors?.nodes ?? []).flatMap((author) => { | |
| 2797 | const actor = toGraphqlCommitActor(author); | |
| 2798 | return actor === null ? [] : [actor]; | |
| 2799 | }), | |
| 2800 | }); | |
| 2801 | } | |
| 2802 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2803 | const editedAtById = new Map<string, string>(); | |
| 2804 | for (const node of [ | |
| 2805 | ...(pullRequest.comments?.nodes ?? []), | |
| 2806 | ...(pullRequest.reviews?.nodes ?? []), | |
| 2807 | ]) { | |
| 2808 | const id = trimmed(node.id); | |
| 2809 | if (id === null) continue; | |
| 2810 | const editedAt = trimmed(node.lastEditedAt); | |
| 2811 | if (editedAt !== null) editedAtById.set(id, editedAt); | |
| 2812 | const reactions = toReactions(node.reactionGroups, viewer); | |
| 2813 | if (reactions.length > 0) reactionsById.set(id, reactions); | |
| 2814 | } | |
| 2815 | return Result.succeed({ | |
| 2816 | threads: entries, | |
| 2817 | nextCursor: nextCursorOf(threads.pageInfo), | |
| 2818 | reactions: toReactions(pullRequest.reactionGroups, viewer), | |
| 2819 | reactionsById, | |
| 2820 | editedAtById, | |
| 2821 | reviewers: [...reviewers.values()], | |
| 2822 | avatarsByLogin, | |
| 2823 | botLogins, | |
| 2824 | commitStats, | |
| 2825 | commits, | |
| 2826 | viewer: toPullRequestViewerFields(pullRequest), | |
| 2827 | dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), | |
| 2828 | nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), | |
| 2829 | }); | |
| 2830 | } | |
| 2831 | ||
| 2832 | /** The rest of one thread's comments, in the shape the first page already delivered them. */ | |
| 2833 | export function decodeReviewThreadCommentsJson(raw: string): Result.Result< | |
| 2834 | { | |
| 2835 | readonly belongsToPullRequest: boolean; | |
| 2836 | readonly comments: ReadonlyArray<PullRequestThreadComment>; | |
| 2837 | readonly nextCursor: string | null; | |
| 2838 | }, | |
| 2839 | DecodeFailure | |
| 2840 | > { | |
| 2841 | const decoded = decodeReviewThreadComments(raw); | |
| 2842 | if (!Result.isSuccess(decoded)) { | |
| 2843 | return Result.fail(decoded.failure); | |
| 2844 | } | |
| 2845 | const viewer = trimmed(decoded.success.data.viewer?.login); | |
| 2846 | const comments = decoded.success.data.node?.comments; | |
| 2847 | return Result.succeed({ | |
| 2848 | belongsToPullRequest: | |
| 2849 | decoded.success.data.repository?.pullRequest?.id !== undefined && | |
| 2850 | decoded.success.data.repository?.pullRequest?.id === | |
| 2851 | decoded.success.data.node?.pullRequest?.id, | |
| 2852 | comments: (comments?.nodes ?? []).map((comment) => ({ | |
| 2853 | id: comment.id, | |
| 2854 | author: toActor(comment.author), | |
| 2855 | body: comment.body ?? "", | |
| 2856 | createdAt: comment.createdAt, | |
| 2857 | editedAt: comment.lastEditedAt ?? null, | |
| 2858 | url: trimmed(comment.url), | |
| 2859 | reactions: toReactions(comment.reactionGroups, viewer), | |
| 2860 | })), | |
| 2861 | nextCursor: nextCursorOf(comments?.pageInfo), | |
| 2862 | }); | |
| 2863 | } | |
| 2864 | ||
| 2865 | /** Repository settings returned alongside the pull request viewer permissions. */ | |
| 2866 | export interface GitHubRepositoryAccess { | |
| 2867 | readonly mergeCapabilities: PullRequestMergeCapabilities; | |
| 2868 | readonly canWrite: boolean; | |
| 2869 | } | |
| 2870 | ||
| 2871 | /** | |
| 2872 | * Whether the viewer's role on the repository is one that can push, which is what merging needs. | |
| 2873 | * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. | |
| 2874 | * | |
| 2875 | * An install that reports no permission at all does not count as write. This is the exception to | |
| 2876 | * "an unknown permission is granted": write is what merging and closing somebody else's change | |
| 2877 | * need, and offering those to a reader who cannot use them wastes the press and reads as the app | |
| 2878 | * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, | |
| 2879 | * because being unable to say something is the worse failure there. | |
| 2880 | */ | |
| 2881 | function toCanWrite(viewerPermission: string | null | undefined): boolean { | |
| 2882 | switch (viewerPermission?.trim().toUpperCase()) { | |
| 2883 | case "ADMIN": | |
| 2884 | case "MAINTAIN": | |
| 2885 | case "WRITE": | |
| 2886 | return true; | |
| 2887 | default: | |
| 2888 | return false; | |
| 2889 | } | |
| 2890 | } | |
| 2891 | ||
| 2892 | /** Triage is the least role GitHub lets label a pull request; it is not a write. */ | |
| 2893 | function toCanTriage(viewerPermission: string | null | undefined): boolean { | |
| 2894 | return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); | |
| 2895 | } | |
| 2896 | ||
| 2897 | /** | |
| 2898 | * Who a review may be asked of, and who it has already been asked of, in one read. | |
| 2899 | * | |
| 2900 | * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access | |
| 2901 | * to the repository — rather than `collaborators`, which the REST API refuses to anyone without | |
| 2902 | * push access and which would therefore be empty for exactly the reader most likely to be looking. | |
| 2903 | * | |
| 2904 | * Teams are asked for only where one has already been requested, so a request to a team can be | |
| 2905 | * taken back. The teams a repository could newly be sent to live on the owning organization and | |
| 2906 | * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses | |
| 2907 | * fails whole, taking the people down with the teams. | |
| 2908 | */ | |
| 2909 | export interface GitHubBaseComparison { | |
| 2910 | /** Null where the host could not compare, which the page reads as "unknown". */ | |
| 2911 | readonly behindBy: number | null; | |
| 2912 | readonly viewerCanUpdate: boolean; | |
| 2913 | } | |
| 2914 | ||
| 2915 | export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 2916 | repository(owner: $owner, name: $name) { | |
| 2917 | assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 2918 | pageInfo { hasNextPage } | |
| 2919 | nodes { login name avatarUrl } | |
| 2920 | } | |
| 2921 | pullRequest(number: $number) { | |
| 2922 | author { login } | |
| 2923 | reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { | |
| 2924 | nodes { | |
| 2925 | requestedReviewer { | |
| 2926 | ... on User { login name avatarUrl } | |
| 2927 | ... on Team { slug name avatarUrl } | |
| 2928 | ... on Bot { login avatarUrl } | |
| 2929 | } | |
| 2930 | } | |
| 2931 | } | |
| 2932 | } | |
| 2933 | } | |
| 2934 | }`; | |
| 2935 | ||
| 2936 | /** A team answers with a slug where a user answers with a login, and nothing else differs. */ | |
| 2937 | const RawRequestedReviewerSchema = Schema.Struct({ | |
| 2938 | ...RawActorSchema.fields, | |
| 2939 | slug: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2940 | }); | |
| 2941 | ||
| 2942 | const RawReviewerCandidatesSchema = Schema.Struct({ | |
| 2943 | data: Schema.Struct({ | |
| 2944 | repository: Schema.Struct({ | |
| 2945 | assignableUsers: Schema.Struct({ | |
| 2946 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 2947 | nodes: Schema.Array(Schema.NullOr(RawActorSchema)), | |
| 2948 | }), | |
| 2949 | /** Null for a number that names no pull request the viewer can see. */ | |
| 2950 | pullRequest: Schema.NullOr( | |
| 2951 | Schema.Struct({ | |
| 2952 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 2953 | reviewRequests: Schema.optional( | |
| 2954 | Schema.NullOr( | |
| 2955 | Schema.Struct({ | |
| 2956 | nodes: Schema.Array( | |
| 2957 | Schema.Struct({ | |
| 2958 | requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), | |
| 2959 | }), | |
| 2960 | ), | |
| 2961 | }), | |
| 2962 | ), | |
| 2963 | ), | |
| 2964 | }), | |
| 2965 | ), | |
| 2966 | }), | |
| 2967 | }), | |
| 2968 | }); | |
| 2969 | ||
| 2970 | const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); | |
| 2971 | ||
| 2972 | /** | |
| 2973 | * The people this pull request may be sent to, with whoever is already on it marked. The author is | |
| 2974 | * dropped rather than shown as an unusable row: GitHub refuses a review request from the person | |
| 2975 | * who opened the pull request, so offering them is offering a failure. | |
| 2976 | * | |
| 2977 | * Whoever has been asked leads the list even where GitHub does not count them assignable — an | |
| 2978 | * outside collaborator, an app — because a request that cannot be seen cannot be taken back. | |
| 2979 | */ | |
| 2980 | export function decodeReviewerCandidatesJson( | |
| 2981 | raw: string, | |
| 2982 | ): Result.Result<PullRequestReviewerCandidateList, DecodeFailure> { | |
| 2983 | const decoded = decodeReviewerCandidates(raw); | |
| 2984 | if (!Result.isSuccess(decoded)) { | |
| 2985 | return Result.fail(decoded.failure); | |
| 2986 | } | |
| 2987 | const repository = decoded.success.data.repository; | |
| 2988 | const pullRequest = repository.pullRequest; | |
| 2989 | const author = trimmed(pullRequest?.author?.login); | |
| 2990 | const candidates = new Map<string, PullRequestReviewerCandidate>(); | |
| 2991 | for (const node of pullRequest?.reviewRequests?.nodes ?? []) { | |
| 2992 | const raw = node.requestedReviewer; | |
| 2993 | const slug = trimmed(raw?.slug); | |
| 2994 | const id = slug ?? trimmed(raw?.login); | |
| 2995 | if (id === null) continue; | |
| 2996 | candidates.set(`${slug === null ? "user" : "team"} ${id}`, { | |
| 2997 | id, | |
| 2998 | kind: slug === null ? "user" : "team", | |
| 2999 | login: id, | |
| 3000 | name: trimmed(raw?.name), | |
| 3001 | avatarUrl: trimmed(raw?.avatarUrl), | |
| 3002 | isRequested: true, | |
| 3003 | }); | |
| 3004 | } | |
| 3005 | for (const node of repository.assignableUsers.nodes) { | |
| 3006 | const login = trimmed(node?.login); | |
| 3007 | if (login === null || login === author || candidates.has(`user ${login}`)) continue; | |
| 3008 | candidates.set(`user ${login}`, { | |
| 3009 | id: login, | |
| 3010 | kind: "user", | |
| 3011 | login, | |
| 3012 | name: trimmed(node?.name), | |
| 3013 | avatarUrl: trimmed(node?.avatarUrl), | |
| 3014 | isRequested: false, | |
| 3015 | }); | |
| 3016 | } | |
| 3017 | return Result.succeed({ | |
| 3018 | candidates: [...candidates.values()], | |
| 3019 | truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, | |
| 3020 | }); | |
| 3021 | } | |
| 3022 | ||
| 3023 | /** | |
| 3024 | * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which | |
| 3025 | * takes people and teams in two lists of its own. The same body serves both methods, because | |
| 3026 | * GitHub takes a request back from exactly whoever it was made of. | |
| 3027 | */ | |
| 3028 | const ReviewerRequestSchema = Schema.Struct({ | |
| 3029 | reviewers: Schema.Array(Schema.String), | |
| 3030 | team_reviewers: Schema.Array(Schema.String), | |
| 3031 | }); | |
| 3032 | ||
| 3033 | export function buildReviewerRequest( | |
| 3034 | reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, | |
| 3035 | ): typeof ReviewerRequestSchema.Type { | |
| 3036 | return { | |
| 3037 | reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), | |
| 3038 | team_reviewers: reviewers.flatMap((reviewer) => | |
| 3039 | reviewer.kind === "team" ? [reviewer.id] : [], | |
| 3040 | ), | |
| 3041 | }; | |
| 3042 | } | |
| 3043 | ||
| 3044 | export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 3045 | repository(owner: $owner, name: $name) { | |
| 3046 | labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { | |
| 3047 | pageInfo { hasNextPage } | |
| 3048 | nodes { name color description } | |
| 3049 | } | |
| 3050 | pullRequest(number: $number) { | |
| 3051 | labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } | |
| 3052 | } | |
| 3053 | } | |
| 3054 | }`; | |
| 3055 | ||
| 3056 | const RawLabelCandidatesSchema = Schema.Struct({ | |
| 3057 | data: Schema.Struct({ | |
| 3058 | repository: Schema.Struct({ | |
| 3059 | labels: Schema.optional( | |
| 3060 | Schema.NullOr( | |
| 3061 | Schema.Struct({ | |
| 3062 | pageInfo: Schema.optional(RawPageInfoSchema), | |
| 3063 | nodes: Schema.Array( | |
| 3064 | Schema.NullOr( | |
| 3065 | Schema.Struct({ | |
| 3066 | ...RawLabelSchema.fields, | |
| 3067 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3068 | }), | |
| 3069 | ), | |
| 3070 | ), | |
| 3071 | }), | |
| 3072 | ), | |
| 3073 | ), | |
| 3074 | /** Null for a number that names no pull request the viewer can see. */ | |
| 3075 | pullRequest: Schema.NullOr( | |
| 3076 | Schema.Struct({ | |
| 3077 | labels: Schema.optional( | |
| 3078 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), | |
| 3079 | ), | |
| 3080 | }), | |
| 3081 | ), | |
| 3082 | }), | |
| 3083 | }), | |
| 3084 | }); | |
| 3085 | ||
| 3086 | const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); | |
| 3087 | ||
| 3088 | /** | |
| 3089 | * The repository's labels, with the ones already on this pull request marked. A label the pull | |
| 3090 | * request wears that the repository no longer defines — deleted since, or past the page — leads | |
| 3091 | * the list anyway, because a label that cannot be seen cannot be taken off. | |
| 3092 | */ | |
| 3093 | export function decodeLabelCandidatesJson( | |
| 3094 | raw: string, | |
| 3095 | ): Result.Result<PullRequestLabelCandidateList, DecodeFailure> { | |
| 3096 | const decoded = decodeLabelCandidates(raw); | |
| 3097 | if (!Result.isSuccess(decoded)) { | |
| 3098 | return Result.fail(decoded.failure); | |
| 3099 | } | |
| 3100 | const repository = decoded.success.data.repository; | |
| 3101 | const applied = new Set( | |
| 3102 | (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { | |
| 3103 | const name = trimmed(label?.name); | |
| 3104 | return name === null ? [] : [name]; | |
| 3105 | }), | |
| 3106 | ); | |
| 3107 | const candidates = new Map<string, PullRequestLabelCandidate>(); | |
| 3108 | for (const node of repository.labels?.nodes ?? []) { | |
| 3109 | const name = trimmed(node?.name); | |
| 3110 | if (name === null) continue; | |
| 3111 | candidates.set(name, { | |
| 3112 | name, | |
| 3113 | color: trimmed(node?.color), | |
| 3114 | description: trimmed(node?.description), | |
| 3115 | isApplied: applied.has(name), | |
| 3116 | }); | |
| 3117 | } | |
| 3118 | const missing = [...applied].filter((name) => !candidates.has(name)); | |
| 3119 | return Result.succeed({ | |
| 3120 | candidates: [ | |
| 3121 | ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), | |
| 3122 | ...candidates.values(), | |
| 3123 | ], | |
| 3124 | truncated: repository.labels?.pageInfo?.hasNextPage === true, | |
| 3125 | }); | |
| 3126 | } | |
| 3127 | ||
| 3128 | /** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ | |
| 3129 | export function buildLabelRequest(labels: ReadonlyArray<string>): { | |
| 3130 | readonly labels: ReadonlyArray<string>; | |
| 3131 | } { | |
| 3132 | return { labels }; | |
| 3133 | } | |
| 3134 | ||
| 3135 | /** | |
| 3136 | * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the | |
| 3137 | * repository, the other two about this pull request in particular — which is why an author with | |
| 3138 | * only read access can still be told apart from a passer-by. | |
| 3139 | */ | |
| 3140 | export interface GitHubViewerAccess { | |
| 3141 | readonly canWrite: boolean; | |
| 3142 | /** | |
| 3143 | * The viewer's role reaches triage, which is the least that may label. Everyone who can write | |
| 3144 | * can triage; a triager is the one role that can label without being able to merge. | |
| 3145 | */ | |
| 3146 | readonly canTriage: boolean; | |
| 3147 | /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ | |
| 3148 | readonly canUpdate: boolean; | |
| 3149 | readonly didAuthor: boolean; | |
| 3150 | /** | |
| 3151 | * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is | |
| 3152 | * false for a branch that is already current, so it answers "may update, and there is | |
| 3153 | * something to update" at once. Absent where the comparison was not read. | |
| 3154 | */ | |
| 3155 | readonly canUpdateBranch?: boolean; | |
| 3156 | } | |
| 3157 | ||
| 3158 | /** Core detail and write checks share one read of permissions and merge settings. */ | |
| 3159 | export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | |
| 3160 | repository(owner: $owner, name: $name) { | |
| 3161 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | |
| 3162 | pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } | |
| 3163 | } | |
| 3164 | }`; | |
| 3165 | ||
| 3166 | const RawViewerPermissionsSchema = Schema.Struct({ | |
| 3167 | data: Schema.Struct({ | |
| 3168 | repository: Schema.Struct({ | |
| 3169 | ...RawRepositoryAccessSchema.fields, | |
| 3170 | /** Null for a number that names no pull request the viewer can see. */ | |
| 3171 | pullRequest: Schema.NullOr(RawViewerFieldsSchema), | |
| 3172 | }), | |
| 3173 | }), | |
| 3174 | }); | |
| 3175 | ||
| 3176 | const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); | |
| 3177 | ||
| 3178 | export function decodeViewerPermissionsJson( | |
| 3179 | raw: string, | |
| 3180 | ): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> { | |
| 3181 | const decoded = decodeViewerPermissions(raw); | |
| 3182 | if (!Result.isSuccess(decoded)) { | |
| 3183 | return Result.fail(decoded.failure); | |
| 3184 | } | |
| 3185 | const repository = decoded.success.data.repository; | |
| 3186 | return Result.succeed({ | |
| 3187 | mergeCapabilities: { | |
| 3188 | merge: repository.mergeCommitAllowed, | |
| 3189 | squash: repository.squashMergeAllowed, | |
| 3190 | rebase: repository.rebaseMergeAllowed, | |
| 3191 | }, | |
| 3192 | canWrite: toCanWrite(repository.viewerPermission), | |
| 3193 | canTriage: toCanTriage(repository.viewerPermission), | |
| 3194 | ...toPullRequestViewerFields(repository.pullRequest), | |
| 3195 | }); | |
| 3196 | } | |
| 3197 | ||
| 3198 | export interface GitHubPullRequestFilesPatch { | |
| 3199 | readonly patch: string; | |
| 3200 | /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ | |
| 3201 | readonly truncated: boolean; | |
| 3202 | /** Files GitHub returned, counted before decoding, so the caller can page. */ | |
| 3203 | readonly rawCount: number; | |
| 3204 | /** GitHub's own counts for the files whose hunks it withheld. */ | |
| 3205 | readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 3206 | } | |
| 3207 | ||
| 3208 | /** | |
| 3209 | * The files API returns hunks per file with no `diff --git` header, so the unified patch every | |
| 3210 | * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's | |
| 3211 | * job, which is why the raw file count comes back with the patch. | |
| 3212 | */ | |
| 3213 | export function decodePullRequestFilesJson( | |
| 3214 | raw: string, | |
| 3215 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | |
| 3216 | return Result.map(decodeUnknownList(raw), toFilesPatch); | |
| 3217 | } | |
| 3218 | ||
| 3219 | /** | |
| 3220 | * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped | |
| 3221 | * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. | |
| 3222 | */ | |
| 3223 | export function decodeCommitFilesJson( | |
| 3224 | raw: string, | |
| 3225 | ): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> { | |
| 3226 | return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); | |
| 3227 | } | |
| 3228 | ||
| 3229 | const decodeCommitFiles = decodeJsonResult( | |
| 3230 | Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), | |
| 3231 | ); | |
| 3232 | ||
| 3233 | function toFilesPatch(entries: ReadonlyArray<unknown>): GitHubPullRequestFilesPatch { | |
| 3234 | const sections: string[] = []; | |
| 3235 | const omittedFileStats: PullRequestOmittedFileStat[] = []; | |
| 3236 | let truncated = false; | |
| 3237 | for (const entry of entries) { | |
| 3238 | const file = decodeFileEntry(entry); | |
| 3239 | if (Exit.isFailure(file)) continue; | |
| 3240 | const value = file.value; | |
| 3241 | const hunks = value.patch ?? ""; | |
| 3242 | const status = value.status?.trim().toLowerCase(); | |
| 3243 | if (hunks.length === 0) { | |
| 3244 | // A file with no hunks is still a file that changed: a pure rename has none to give, and | |
| 3245 | // a binary one has none that can be shown. Both are listed, and only the second is a hole | |
| 3246 | // in the patch — leaving them out entirely would drop them from the change altogether. | |
| 3247 | const additions = value.additions ?? 0; | |
| 3248 | const deletions = value.deletions ?? 0; | |
| 3249 | if (additions + deletions > 0) { | |
| 3250 | truncated = true; | |
| 3251 | omittedFileStats.push({ path: value.filename, additions, deletions }); | |
| 3252 | } | |
| 3253 | } | |
| 3254 | // A rename counts its hunks against the old path, which is the only place it is named. | |
| 3255 | const oldPath = | |
| 3256 | status === "renamed" ? value.previous_filename || value.filename : value.filename; | |
| 3257 | const header = [ | |
| 3258 | `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, | |
| 3259 | // The files API reports no file mode, so the ordinary one stands in: the viewer reads | |
| 3260 | // these lines as "added" and "removed" rather than for the mode they carry. | |
| 3261 | ...(status === "added" ? ["new file mode 100644"] : []), | |
| 3262 | ...(status === "removed" ? ["deleted file mode 100644"] : []), | |
| 3263 | ...(status === "renamed" | |
| 3264 | ? [ | |
| 3265 | `rename from ${quoteGitPatchPath(oldPath)}`, | |
| 3266 | `rename to ${quoteGitPatchPath(value.filename)}`, | |
| 3267 | ] | |
| 3268 | : []), | |
| 3269 | `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, | |
| 3270 | `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, | |
| 3271 | ].join("\n"); | |
| 3272 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); | |
| 3273 | } | |
| 3274 | return { | |
| 3275 | patch: sections.join(""), | |
| 3276 | truncated, | |
| 3277 | rawCount: entries.length, | |
| 3278 | omittedFileStats, | |
| 3279 | }; | |
| 3280 | } | |
| 3281 | ||
| 3282 | /** | |
| 3283 | * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST | |
| 3284 | * files endpoint the patch is read from carries no viewed state, so this is a second read rather | |
| 3285 | * than a wider version of the first. | |
| 3286 | */ | |
| 3287 | export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { | |
| 3288 | repository(owner: $owner, name: $name) { | |
| 3289 | pullRequest(number: $number) { | |
| 3290 | files(first: 100, after: $after) { | |
| 3291 | pageInfo { hasNextPage endCursor } | |
| 3292 | nodes { path viewerViewedState } | |
| 3293 | } | |
| 3294 | } | |
| 3295 | } | |
| 3296 | }`; | |
| 3297 | ||
| 3298 | const RawPullRequestFilesViewedSchema = Schema.Struct({ | |
| 3299 | data: Schema.Struct({ | |
| 3300 | repository: Schema.NullOr( | |
| 3301 | Schema.Struct({ | |
| 3302 | pullRequest: Schema.NullOr( | |
| 3303 | Schema.Struct({ | |
| 3304 | files: Schema.Struct({ | |
| 3305 | pageInfo: Schema.Struct({ | |
| 3306 | hasNextPage: Schema.Boolean, | |
| 3307 | endCursor: Schema.NullOr(Schema.String), | |
| 3308 | }), | |
| 3309 | nodes: Schema.NullOr( | |
| 3310 | Schema.Array( | |
| 3311 | Schema.NullOr( | |
| 3312 | Schema.Struct({ | |
| 3313 | path: Schema.String, | |
| 3314 | // Decoded as a plain string and narrowed below: a GitHub release that adds | |
| 3315 | // a fourth state must not fail the whole page. | |
| 3316 | viewerViewedState: Schema.String, | |
| 3317 | }), | |
| 3318 | ), | |
| 3319 | ), | |
| 3320 | ), | |
| 3321 | }), | |
| 3322 | }), | |
| 3323 | ), | |
| 3324 | }), | |
| 3325 | ), | |
| 3326 | }), | |
| 3327 | }); | |
| 3328 | ||
| 3329 | const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); | |
| 3330 | ||
| 3331 | export interface GitHubPullRequestFilesViewedPage { | |
| 3332 | readonly files: ReadonlyArray<{ | |
| 3333 | readonly path: string; | |
| 3334 | readonly state: PullRequestFileViewedState; | |
| 3335 | }>; | |
| 3336 | /** Where the next page carries on, or null once the host has no more to give. */ | |
| 3337 | readonly nextCursor: string | null; | |
| 3338 | } | |
| 3339 | ||
| 3340 | /** Anything this host does not name is treated as unread, which is the state that asks for least. */ | |
| 3341 | function toFileViewedState(raw: string): PullRequestFileViewedState { | |
| 3342 | switch (raw.trim().toUpperCase()) { | |
| 3343 | case "VIEWED": | |
| 3344 | return "viewed"; | |
| 3345 | case "DISMISSED": | |
| 3346 | return "dismissed"; | |
| 3347 | default: | |
| 3348 | return "unviewed"; | |
| 3349 | } | |
| 3350 | } | |
| 3351 | ||
| 3352 | export function decodePullRequestFilesViewedJson( | |
| 3353 | raw: string, | |
| 3354 | ): Result.Result<GitHubPullRequestFilesViewedPage, DecodeFailure> { | |
| 3355 | const decoded = decodePullRequestFilesViewed(raw); | |
| 3356 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 3357 | const files = decoded.success.data.repository?.pullRequest?.files; | |
| 3358 | if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); | |
| 3359 | return Result.succeed({ | |
| 3360 | files: (files.nodes ?? []).flatMap((node) => | |
| 3361 | node === null || node.path.length === 0 | |
| 3362 | ? [] | |
| 3363 | : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], | |
| 3364 | ), | |
| 3365 | nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, | |
| 3366 | }); | |
| 3367 | } | |
| 3368 | ||
| 3369 | /** | |
| 3370 | * One document that clears and restores as many files as the reader ticked, rather than one | |
| 3371 | * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each | |
| 3372 | * take a single path, so the batching is done with aliases; top-level mutation fields run in | |
| 3373 | * write order, so the last word about a path is the one that sticks. | |
| 3374 | */ | |
| 3375 | export function buildSetFilesViewedGraphQlMutation( | |
| 3376 | pullRequestId: string, | |
| 3377 | files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, | |
| 3378 | ): GraphQlDocument | null { | |
| 3379 | return aliasedGraphQlDocument({ | |
| 3380 | operation: "mutation", | |
| 3381 | alias: "f", | |
| 3382 | items: files, | |
| 3383 | shared: { pullRequestId: ["ID!", pullRequestId] }, | |
| 3384 | variables: (file) => ({ path: ["String!", file.path] }), | |
| 3385 | field: ({ path }, file) => | |
| 3386 | `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, | |
| 3387 | }); | |
| 3388 | } | |
| 3389 | ||
| 3390 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ | |
| 3391 | const RawStackPullRequestSchema = Schema.Struct({ | |
| 3392 | title: Schema.optional(Schema.String), | |
| 3393 | draft: Schema.optional(Schema.Boolean), | |
| 3394 | number: Schema.Int, | |
| 3395 | head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), | |
| 3396 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3397 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3398 | }); | |
| 3399 | ||
| 3400 | /** | |
| 3401 | * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may | |
| 3402 | * still move. Only what a stack is made of is required — where it lives, what it stands on, and | |
| 3403 | * its pull requests — and `base` is accepted both as the ref object the preview sends today and | |
| 3404 | * as the bare branch name it started out as. | |
| 3405 | */ | |
| 3406 | const RawStackSchema = Schema.Struct({ | |
| 3407 | id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), | |
| 3408 | number: Schema.Int, | |
| 3409 | node_id: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3410 | url: Schema.String, | |
| 3411 | html_url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 3412 | base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), | |
| 3413 | pull_requests: Schema.Array(RawStackPullRequestSchema), | |
| 3414 | }); | |
| 3415 | ||
| 3416 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); | |
| 3417 | ||
| 3418 | export interface GitHubPullRequestStackLayer { | |
| 3419 | readonly title?: string; | |
| 3420 | readonly isDraft?: boolean; | |
| 3421 | readonly headSha?: string; | |
| 3422 | readonly number: number; | |
| 3423 | readonly headBranch: string; | |
| 3424 | readonly state: PullRequestState; | |
| 3425 | } | |
| 3426 | ||
| 3427 | export interface GitHubPullRequestStack { | |
| 3428 | readonly id: string; | |
| 3429 | readonly number: number; | |
| 3430 | readonly url: string; | |
| 3431 | readonly base: string; | |
| 3432 | /** Bottom to top, which is the order GitHub lists them in. */ | |
| 3433 | readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>; | |
| 3434 | } | |
| 3435 | ||
| 3436 | /** | |
| 3437 | * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in | |
| 3438 | * at most one stack, so the array is GitHub's way of saying "none" rather than a page. | |
| 3439 | */ | |
| 3440 | export function decodePullRequestStacksJson( | |
| 3441 | raw: string, | |
| 3442 | ): Result.Result<GitHubPullRequestStack | null, DecodeFailure> { | |
| 3443 | const decoded = decodeStacks(raw); | |
| 3444 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 3445 | const stack = decoded.success[0]; | |
| 3446 | if (stack === undefined) return Result.succeed(null); | |
| 3447 | return Result.succeed({ | |
| 3448 | id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), | |
| 3449 | number: stack.number, | |
| 3450 | // The page a person opens where the preview reports one; the API URL is what it always has. | |
| 3451 | url: trimmed(stack.html_url) ?? stack.url, | |
| 3452 | base: typeof stack.base === "string" ? stack.base : stack.base.ref, | |
| 3453 | layers: stack.pull_requests.map((pullRequest) => ({ | |
| 3454 | ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), | |
| 3455 | ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), | |
| 3456 | ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), | |
| 3457 | number: pullRequest.number, | |
| 3458 | headBranch: pullRequest.head.ref, | |
| 3459 | state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), | |
| 3460 | })), | |
| 3461 | }); | |
| 3462 | } |
packages/source-control-github/src/server/gitHubPullRequests.ts created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 9 | ||
| 10 | export interface NormalizedGitHubPullRequestRecord { | |
| 11 | readonly number: number; | |
| 12 | readonly title: string; | |
| 13 | readonly url: string; | |
| 14 | readonly baseRefName: string; | |
| 15 | readonly headRefName: string; | |
| 16 | /** The head commit, when the read asked for `headRefOid`. */ | |
| 17 | readonly headSha?: string; | |
| 18 | readonly state: "open" | "closed" | "merged"; | |
| 19 | readonly isDraft?: boolean; | |
| 20 | readonly closedAt?: string | null; | |
| 21 | readonly mergedAt?: string | null; | |
| 22 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 23 | readonly isCrossRepository?: boolean; | |
| 24 | readonly headRepositoryNameWithOwner?: string | null; | |
| 25 | readonly headRepositoryOwnerLogin?: string | null; | |
| 26 | } | |
| 27 | ||
| 28 | const GitHubPullRequestSchema = Schema.Struct({ | |
| 29 | number: PositiveInt, | |
| 30 | title: TrimmedNonEmptyString, | |
| 31 | url: TrimmedNonEmptyString, | |
| 32 | baseRefName: TrimmedNonEmptyString, | |
| 33 | headRefName: TrimmedNonEmptyString, | |
| 34 | headRefOid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 36 | isDraft: Schema.optional(Schema.Boolean), | |
| 37 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 38 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 39 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 40 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 41 | // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was | |
| 42 | // added later. Both fields stay optional so a version-drifted gh CLI can | |
| 43 | // never fail the decode and silently drop the PR from the list. | |
| 44 | headRepository: Schema.optional( | |
| 45 | Schema.NullOr( | |
| 46 | Schema.Struct({ | |
| 47 | nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), | |
| 48 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 49 | }), | |
| 50 | ), | |
| 51 | ), | |
| 52 | headRepositoryOwner: Schema.optional( | |
| 53 | Schema.NullOr( | |
| 54 | Schema.Struct({ | |
| 55 | login: Schema.optional(Schema.NullOr(Schema.String)), | |
| 56 | }), | |
| 57 | ), | |
| 58 | ), | |
| 59 | }); | |
| 60 | ||
| 61 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 62 | const trimmed = value?.trim() ?? ""; | |
| 63 | return trimmed.length > 0 ? trimmed : null; | |
| 64 | } | |
| 65 | ||
| 66 | function normalizeGitHubPullRequestState(input: { | |
| 67 | state?: string | null | undefined; | |
| 68 | mergedAt?: string | null | undefined; | |
| 69 | }): "open" | "closed" | "merged" { | |
| 70 | const normalizedState = input.state?.trim().toUpperCase(); | |
| 71 | if ( | |
| 72 | (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || | |
| 73 | normalizedState === "MERGED" | |
| 74 | ) { | |
| 75 | return "merged"; | |
| 76 | } | |
| 77 | if (normalizedState === "CLOSED") { | |
| 78 | return "closed"; | |
| 79 | } | |
| 80 | return "open"; | |
| 81 | } | |
| 82 | ||
| 83 | function normalizeGitHubPullRequestRecord( | |
| 84 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | |
| 85 | ): NormalizedGitHubPullRequestRecord { | |
| 86 | const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | |
| 87 | const headRepositoryName = trimOptionalString(raw.headRepository?.name); | |
| 88 | const headRepositoryOwnerLogin = | |
| 89 | trimOptionalString(raw.headRepositoryOwner?.login) ?? | |
| 90 | (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); | |
| 91 | const headRepositoryNameWithOwner = | |
| 92 | explicitNameWithOwner ?? | |
| 93 | (headRepositoryOwnerLogin && headRepositoryName | |
| 94 | ? `${headRepositoryOwnerLogin}/${headRepositoryName}` | |
| 95 | : null); | |
| 96 | const headSha = trimOptionalString(raw.headRefOid); | |
| 97 | ||
| 98 | return { | |
| 99 | number: raw.number, | |
| 100 | title: raw.title, | |
| 101 | url: raw.url, | |
| 102 | baseRefName: raw.baseRefName, | |
| 103 | headRefName: raw.headRefName, | |
| 104 | ...(headSha ? { headSha } : {}), | |
| 105 | state: normalizeGitHubPullRequestState(raw), | |
| 106 | ...(raw.isDraft === true ? { isDraft: true } : {}), | |
| 107 | closedAt: raw.closedAt ?? null, | |
| 108 | mergedAt: raw.mergedAt ?? null, | |
| 109 | updatedAt: raw.updatedAt ?? Option.none(), | |
| 110 | ...(typeof raw.isCrossRepository === "boolean" | |
| 111 | ? { isCrossRepository: raw.isCrossRepository } | |
| 112 | : {}), | |
| 113 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 114 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 119 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 120 | ||
| 121 | /** | |
| 122 | * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is | |
| 123 | * skipped, so one malformed pull request cannot hide the rest. | |
| 124 | */ | |
| 125 | export function decodeGitHubPullRequestEntries( | |
| 126 | entries: ReadonlyArray<unknown>, | |
| 127 | ): ReadonlyArray<NormalizedGitHubPullRequestRecord> { | |
| 128 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | |
| 129 | for (const entry of entries) { | |
| 130 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | |
| 131 | if (Exit.isSuccess(decodedEntry)) { | |
| 132 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | |
| 133 | } | |
| 134 | } | |
| 135 | return pullRequests; | |
| 136 | } | |
| 137 | ||
| 138 | export function decodeGitHubPullRequestListJson( | |
| 139 | raw: string, | |
| 140 | ): Result.Result< | |
| 141 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 142 | Cause.Cause<Schema.SchemaError> | |
| 143 | > { | |
| 144 | return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); | |
| 145 | } |
packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitHubApiHostForRemote, | |
| 5 | selectGitHubBaseRepository, | |
| 6 | } from "./gitHubRepositoryResolution.ts"; | |
| 7 | ||
| 8 | describe("selectGitHubBaseRepository", () => { | |
| 9 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 10 | entries | |
| 11 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 12 | .join("\n"); | |
| 13 | const select = (input: { remotes: string; resolved?: string }) => | |
| 14 | selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 15 | ||
| 16 | it("picks the repository gh reads without a prompt", () => { | |
| 17 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 18 | owner: "me", | |
| 19 | name: "web", | |
| 20 | }); | |
| 21 | const fork = remotes( | |
| 22 | ["origin", "git@github.com:me/web.git"], | |
| 23 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 24 | ); | |
| 25 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 26 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 27 | owner: "me", | |
| 28 | name: "web", | |
| 29 | }); | |
| 30 | assert.deepStrictEqual( | |
| 31 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 32 | { owner: "acme", name: "other" }, | |
| 33 | ); | |
| 34 | // gh ranks remote names in any case. | |
| 35 | assert.deepStrictEqual( | |
| 36 | select({ | |
| 37 | remotes: remotes( | |
| 38 | ["origin", "git@github.com:me/web.git"], | |
| 39 | ["Upstream", "git@github.com:acme/web.git"], | |
| 40 | ), | |
| 41 | }), | |
| 42 | { owner: "acme", name: "web" }, | |
| 43 | ); | |
| 44 | }); | |
| 45 | ||
| 46 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 47 | for (const input of [ | |
| 48 | { remotes: "" }, | |
| 49 | { | |
| 50 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 51 | }, | |
| 52 | { | |
| 53 | remotes: remotes( | |
| 54 | ["origin", "git@github.com:me/web.git"], | |
| 55 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 56 | ), | |
| 57 | }, | |
| 58 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 59 | { | |
| 60 | remotes: remotes( | |
| 61 | ["origin", "git@github.com:me/web.git"], | |
| 62 | ["Origin", "git@github.com:acme/web.git"], | |
| 63 | ), | |
| 64 | }, | |
| 65 | { | |
| 66 | remotes: remotes( | |
| 67 | ["origin", "git@github.com:me/web.git"], | |
| 68 | ["upstream", "git@github.com:acme/web.git"], | |
| 69 | ), | |
| 70 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 71 | }, | |
| 72 | ]) { | |
| 73 | assert.strictEqual(select(input), null); | |
| 74 | } | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | describe("gitHubApiHostForRemote", () => { | |
| 79 | it("reads an SSH alias through github.com and a real host as itself", () => { | |
| 80 | assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); | |
| 81 | assert.strictEqual( | |
| 82 | gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 83 | "github.example.com", | |
| 84 | ); | |
| 85 | assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); | |
| 86 | }); | |
| 87 | }); |
packages/source-control-github/src/server/gitHubRepositoryResolution.ts created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 2 | import { | |
| 3 | detectSourceControlProviderFromRemoteUrl, | |
| 4 | isSshRemoteUrl, | |
| 5 | } from "@t3tools/shared/sourceControl"; | |
| 6 | ||
| 7 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 8 | export interface GitHubRepositoryLocator { | |
| 9 | readonly host: string; | |
| 10 | readonly owner: string; | |
| 11 | readonly name: string; | |
| 12 | } | |
| 13 | ||
| 14 | /** The fetch remotes in `git remote -v` output, by name. */ | |
| 15 | export function parseFetchRemotes( | |
| 16 | remotes: string, | |
| 17 | ): ReadonlyArray<{ readonly name: string; readonly url: string }> { | |
| 18 | return remotes | |
| 19 | .split("\n") | |
| 20 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 21 | .filter((match): match is RegExpExecArray => match !== null) | |
| 22 | .map((match) => ({ name: match[1]!, url: match[2]! })); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 27 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 28 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 29 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 30 | * | |
| 31 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 32 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 33 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 34 | */ | |
| 35 | export function selectGitHubBaseRepository(input: { | |
| 36 | readonly remotes: string; | |
| 37 | readonly resolved: string; | |
| 38 | readonly host: string; | |
| 39 | }): { readonly owner: string; readonly name: string } | null { | |
| 40 | const host = input.host.toLowerCase(); | |
| 41 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 42 | for (const remote of parseFetchRemotes(input.remotes)) { | |
| 43 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); | |
| 44 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 45 | repositories.set(remote.name, { owner, name }); | |
| 46 | } | |
| 47 | const marks = input.resolved | |
| 48 | .split("\n") | |
| 49 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 50 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 51 | if (marks.length > 1) return null; | |
| 52 | const [mark] = marks; | |
| 53 | if (mark) { | |
| 54 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 55 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 56 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 57 | } | |
| 58 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 59 | // top place has no defined winner. | |
| 60 | const score = (remoteName: string) => | |
| 61 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 62 | const ranked = [...repositories.entries()].toSorted( | |
| 63 | ([left], [right]) => score(right) - score(left), | |
| 64 | ); | |
| 65 | const [top, next] = ranked; | |
| 66 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 67 | ? top[1] | |
| 68 | : null; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, | |
| 73 | * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller | |
| 74 | * names when no repository was found there. GH_REPO is the caller's to check first. | |
| 75 | */ | |
| 76 | export function resolveGitHubRepository(input: { | |
| 77 | readonly remotes: string; | |
| 78 | readonly resolved: string; | |
| 79 | /** The checkout's GitHub API host, when the caller already knows it. */ | |
| 80 | readonly hostHint: string | undefined; | |
| 81 | readonly defaultHost: string; | |
| 82 | }): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { | |
| 83 | const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ | |
| 84 | ...remote, | |
| 85 | host: gitHubApiHostForRemote(remote.url), | |
| 86 | })); | |
| 87 | const host = | |
| 88 | (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? | |
| 89 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 90 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 91 | input.defaultHost; | |
| 92 | const selected = selectGitHubBaseRepository({ | |
| 93 | remotes: input.remotes, | |
| 94 | resolved: input.resolved, | |
| 95 | host, | |
| 96 | }); | |
| 97 | if (selected !== null) return { host, locator: { host, ...selected } }; | |
| 98 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 99 | // the ones on this host (an SSH alias counts as its API host). | |
| 100 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 101 | const candidates = fetchRemotes | |
| 102 | .filter( | |
| 103 | (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host, | |
| 104 | ) | |
| 105 | .toSorted((left, right) => { | |
| 106 | const l = rank(left.name); | |
| 107 | const r = rank(right.name); | |
| 108 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 109 | }); | |
| 110 | for (const remote of candidates) { | |
| 111 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 112 | if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; | |
| 113 | } | |
| 114 | return { host, locator: null }; | |
| 115 | } | |
| 116 | ||
| 117 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 118 | export function parseGitHubRepositorySelector( | |
| 119 | selector: string, | |
| 120 | defaultHost: string, | |
| 121 | ): GitHubRepositoryLocator | null { | |
| 122 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 123 | if (/^https?:\/\//i.test(trimmed)) { | |
| 124 | try { | |
| 125 | const url = new URL(trimmed); | |
| 126 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 127 | return owner && name && rest.length === 0 | |
| 128 | ? { host: url.host.toLowerCase(), owner, name } | |
| 129 | : null; | |
| 130 | } catch { | |
| 131 | return null; | |
| 132 | } | |
| 133 | } | |
| 134 | const parts = trimmed.split("/").filter(Boolean); | |
| 135 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 136 | if (parts.length === 3) | |
| 137 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 138 | return null; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 143 | * URL, or a branch name. | |
| 144 | */ | |
| 145 | export function parsePullRequestReference( | |
| 146 | reference: string, | |
| 147 | ): | |
| 148 | | { readonly kind: "number"; readonly number: number } | |
| 149 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 150 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 151 | const trimmed = reference.trim(); | |
| 152 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 153 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 154 | if (/^https?:\/\//i.test(trimmed)) { | |
| 155 | try { | |
| 156 | const url = new URL(trimmed); | |
| 157 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 158 | if (match) { | |
| 159 | return { | |
| 160 | kind: "url", | |
| 161 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 162 | number: Number(match[3]), | |
| 163 | }; | |
| 164 | } | |
| 165 | } catch { | |
| 166 | // Not a URL after all; read it as a branch. | |
| 167 | } | |
| 168 | } | |
| 169 | return { kind: "branch", headSelector: trimmed }; | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 174 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 175 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 176 | */ | |
| 177 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 178 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 179 | if (provider === null) return null; | |
| 180 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 181 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 182 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 183 | return host.includes("github") ? "github.com" : null; | |
| 184 | } | |
| 185 | return provider.kind === "github" ? host : null; | |
| 186 | } | |
| 187 | ||
| 188 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 189 | function apiHostForHint(host: string): string { | |
| 190 | const normalized = host.toLowerCase(); | |
| 191 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 192 | } |
packages/source-control-github/src/server/githubGraphQl.test.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import { assert, describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; | |
| 5 | ||
| 6 | describe("aliasedGraphQlDocument", () => { | |
| 7 | it("keeps every value out of the document, each under its alias's own variable", () => { | |
| 8 | const document = aliasedGraphQlDocument({ | |
| 9 | operation: "query", | |
| 10 | name: "Lookups", | |
| 11 | alias: "s", | |
| 12 | items: ['acme") { x } #', "web"], | |
| 13 | shared: { owner: ["String!", "acme"] }, | |
| 14 | variables: (repo) => ({ name: ["String!", repo] }), | |
| 15 | field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, | |
| 16 | }); | |
| 17 | assert.isNotNull(document); | |
| 18 | expect(document!.query).toBe( | |
| 19 | "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + | |
| 20 | " s0: repository(owner: $owner, name: $s0_name) { id }\n" + | |
| 21 | " s1: repository(owner: $owner, name: $s1_name) { id }\n}", | |
| 22 | ); | |
| 23 | expect(document!.variables).toEqual({ | |
| 24 | owner: "acme", | |
| 25 | s0_name: 'acme") { x } #', | |
| 26 | s1_name: "web", | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | it("aliases by key and nests inside a shared parent when asked", () => { | |
| 31 | const document = aliasedGraphQlDocument({ | |
| 32 | operation: "query", | |
| 33 | alias: "pr", | |
| 34 | key: (number) => number, | |
| 35 | items: [2, 3], | |
| 36 | variables: (number) => ({ number: ["Int!", number] }), | |
| 37 | field: ({ number }) => `pullRequest(number: ${number}) { id }`, | |
| 38 | within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, | |
| 39 | }); | |
| 40 | expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); | |
| 41 | expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); | |
| 42 | expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); | |
| 43 | }); | |
| 44 | ||
| 45 | it("asks for nothing when there is nothing to ask", () => { | |
| 46 | expect( | |
| 47 | aliasedGraphQlDocument({ | |
| 48 | operation: "mutation", | |
| 49 | alias: "f", | |
| 50 | items: [], | |
| 51 | variables: () => ({}), | |
| 52 | field: () => "x", | |
| 53 | }), | |
| 54 | ).toBeNull(); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("readGraphQlPages", () => { | |
| 59 | const pages = (cursors: ReadonlyArray<string | null>) => { | |
| 60 | const asked: Array<string | null> = []; | |
| 61 | const read = (after: string | null) => | |
| 62 | Effect.sync(() => { | |
| 63 | asked.push(after); | |
| 64 | return { next: cursors[asked.length - 1] ?? null }; | |
| 65 | }); | |
| 66 | return { asked, read }; | |
| 67 | }; | |
| 68 | ||
| 69 | it.effect("follows the cursor until GitHub has no more", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const { asked, read } = pages(["a", "b", null]); | |
| 72 | const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); | |
| 73 | expect(asked).toEqual([null, "a", "b"]); | |
| 74 | expect(result.truncated).toBe(false); | |
| 75 | }), | |
| 76 | ); | |
| 77 | ||
| 78 | it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => | |
| 79 | Effect.gen(function* () { | |
| 80 | const limited = pages(["a", "b", "c"]); | |
| 81 | const atLimit = yield* readGraphQlPages(limited.read, { | |
| 82 | nextCursor: (page) => page.next, | |
| 83 | maxPages: 2, | |
| 84 | }); | |
| 85 | expect(limited.asked).toEqual([null, "a"]); | |
| 86 | expect(atLimit.truncated).toBe(true); | |
| 87 | ||
| 88 | const enough = pages(["a", "b", "c"]); | |
| 89 | const satisfied = yield* readGraphQlPages(enough.read, { | |
| 90 | nextCursor: (page) => page.next, | |
| 91 | until: (read) => read.length === 1, | |
| 92 | }); | |
| 93 | expect(enough.asked).toEqual([null]); | |
| 94 | expect(satisfied.truncated).toBe(true); | |
| 95 | ||
| 96 | const stuck = pages(["a", "a", "a"]); | |
| 97 | const repeated = yield* readGraphQlPages(stuck.read, { | |
| 98 | nextCursor: (page) => page.next, | |
| 99 | from: "start", | |
| 100 | }); | |
| 101 | expect(stuck.asked).toEqual(["start", "a"]); | |
| 102 | expect(repeated.truncated).toBe(true); | |
| 103 | }), | |
| 104 | ); | |
| 105 | }); |
packages/source-control-github/src/server/githubGraphQl.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | ||
| 3 | /** GraphQL variables by name, each with the type it is declared as and the value it carries. */ | |
| 4 | export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>; | |
| 5 | ||
| 6 | export interface GraphQlDocument { | |
| 7 | readonly query: string; | |
| 8 | readonly variables: Readonly<Record<string, unknown>>; | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * One document asking the same field for many items, each under its own alias: the prefix and | |
| 13 | * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub | |
| 14 | * has no bulk form of most lookups, so aliases are how a batch travels in one request. | |
| 15 | * | |
| 16 | * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those | |
| 17 | * placeholders rather than the values: nothing an item holds is written into the document. | |
| 18 | * `shared` variables are declared once and referenced by their own name; `within` nests the aliased | |
| 19 | * fields inside one parent selection, such as a single repository. | |
| 20 | * | |
| 21 | * Null for no items, since a document with no selection is not a document. | |
| 22 | */ | |
| 23 | export function aliasedGraphQlDocument<Item, Variables extends GraphQlVariables>(input: { | |
| 24 | readonly operation: "query" | "mutation"; | |
| 25 | /** The operation's name, which tells one batch from another in logs and recorded traffic. */ | |
| 26 | readonly name?: string; | |
| 27 | readonly alias: string; | |
| 28 | readonly key?: (item: Item, index: number) => number; | |
| 29 | readonly items: ReadonlyArray<Item>; | |
| 30 | readonly variables: (item: Item) => Variables; | |
| 31 | readonly field: ( | |
| 32 | placeholders: { readonly [Name in keyof Variables]: string }, | |
| 33 | item: Item, | |
| 34 | ) => string; | |
| 35 | readonly shared?: GraphQlVariables; | |
| 36 | readonly within?: (fields: string) => string; | |
| 37 | }): GraphQlDocument | null { | |
| 38 | if (input.items.length === 0) return null; | |
| 39 | const declarations: string[] = []; | |
| 40 | const variables: Record<string, unknown> = {}; | |
| 41 | for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { | |
| 42 | declarations.push(`$${name}: ${type}`); | |
| 43 | variables[name] = value; | |
| 44 | } | |
| 45 | const fields = input.items.map((item, index) => { | |
| 46 | const alias = `${input.alias}${input.key?.(item, index) ?? index}`; | |
| 47 | const placeholders: Record<string, string> = {}; | |
| 48 | for (const [name, [type, value]] of Object.entries(input.variables(item))) { | |
| 49 | const variable = `${alias}_${name}`; | |
| 50 | declarations.push(`$${variable}: ${type}`); | |
| 51 | variables[variable] = value; | |
| 52 | placeholders[name] = `$${variable}`; | |
| 53 | } | |
| 54 | return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; | |
| 55 | }); | |
| 56 | const selection = fields.join("\n"); | |
| 57 | const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; | |
| 58 | return { | |
| 59 | query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, | |
| 60 | variables, | |
| 61 | }; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * The pages of a GraphQL connection, read one after another from `from` (the first page by | |
| 66 | * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is | |
| 67 | * handed the pages so far, for reads that size or check the next page by what has already arrived. | |
| 68 | * | |
| 69 | * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time | |
| 70 | * would page forever, so it stops the read as truncated too. | |
| 71 | */ | |
| 72 | export const readGraphQlPages = <Page, E, R>( | |
| 73 | read: (after: string | null, pages: ReadonlyArray<Page>) => Effect.Effect<Page, E, R>, | |
| 74 | options: { | |
| 75 | readonly nextCursor: (page: NoInfer<Page>) => string | null; | |
| 76 | readonly from?: string | null; | |
| 77 | readonly maxPages?: number; | |
| 78 | readonly until?: (pages: ReadonlyArray<NoInfer<Page>>) => boolean; | |
| 79 | }, | |
| 80 | ): Effect.Effect<{ readonly pages: ReadonlyArray<Page>; readonly truncated: boolean }, E, R> => | |
| 81 | Effect.gen(function* () { | |
| 82 | const pages: Page[] = []; | |
| 83 | const seen = new Set<string>(); | |
| 84 | let after = options.from ?? null; | |
| 85 | while (true) { | |
| 86 | const page: Page = yield* read(after, pages); | |
| 87 | pages.push(page); | |
| 88 | const next = options.nextCursor(page); | |
| 89 | if (next === null) return { pages, truncated: false }; | |
| 90 | if ( | |
| 91 | seen.has(next) || | |
| 92 | pages.length >= (options.maxPages ?? Infinity) || | |
| 93 | options.until?.(pages) === true | |
| 94 | ) { | |
| 95 | return { pages, truncated: true }; | |
| 96 | } | |
| 97 | seen.add(next); | |
| 98 | after = next; | |
| 99 | } | |
| 100 | }); |
packages/source-control-github/src/server/githubStackActions.test.ts created+395| ... | ... | @@ -0,0 +1,395 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as Layer from "effect/Layer"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 11 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 12 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; | |
| 13 | ||
| 14 | /** | |
| 15 | * One request as the fake saw it, flattened to words: the path or document, then each variable | |
| 16 | * or body field as `name=value`, so an assertion reads as "this request carried that value". | |
| 17 | */ | |
| 18 | type Call = ReadonlyArray<string>; | |
| 19 | type Send = ( | |
| 20 | call: Call, | |
| 21 | kind: "graphql" | "rest", | |
| 22 | ) => Effect.Effect<string, GitHubApi.GitHubApiError>; | |
| 23 | ||
| 24 | /** Every git command the cascade ran, in order; none actually runs. */ | |
| 25 | let gitCalls: Array<ReadonlyArray<string>> = []; | |
| 26 | const fakeGit = Layer.mergeAll( | |
| 27 | TestSourceControlHost.layer({ | |
| 28 | process: { | |
| 29 | run: (input) => | |
| 30 | Effect.sync(() => { | |
| 31 | gitCalls.push(input.args); | |
| 32 | const stdout = | |
| 33 | input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; | |
| 34 | return { | |
| 35 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 36 | stdout, | |
| 37 | stderr: "", | |
| 38 | stdoutTruncated: false, | |
| 39 | stderrTruncated: false, | |
| 40 | }; | |
| 41 | }), | |
| 42 | }, | |
| 43 | }), | |
| 44 | FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), | |
| 45 | ); | |
| 46 | ||
| 47 | const runGitHubStackAction = (send: Send, input: Parameters<typeof runStackAction>[0]) => | |
| 48 | runStackAction(input).pipe( | |
| 49 | Effect.provide( | |
| 50 | Layer.mergeAll( | |
| 51 | fakeGit, | |
| 52 | Layer.mock(GitHubApi.GitHubApi)({ | |
| 53 | credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), | |
| 54 | graphql: (request) => { | |
| 55 | // GitHub refuses a document that declares a variable it never uses. | |
| 56 | const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); | |
| 57 | const unused = declared.filter( | |
| 58 | (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), | |
| 59 | ); | |
| 60 | if (unused.length > 0) { | |
| 61 | return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); | |
| 62 | } | |
| 63 | return send(words(request.query, request.variables), "graphql"); | |
| 64 | }, | |
| 65 | rest: (request) => | |
| 66 | send(words(request.path, request.body), "rest").pipe( | |
| 67 | Effect.map((body) => ({ | |
| 68 | status: 200, | |
| 69 | headers: {}, | |
| 70 | body, | |
| 71 | truncated: false, | |
| 72 | invalidUtf8: false, | |
| 73 | })), | |
| 74 | ), | |
| 75 | }), | |
| 76 | ), | |
| 77 | ), | |
| 78 | ); | |
| 79 | ||
| 80 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 81 | ||
| 82 | function words(head: string, fields: unknown): Call { | |
| 83 | return [ | |
| 84 | head, | |
| 85 | ...Object.entries((fields ?? {}) as Record<string, unknown>).map( | |
| 86 | ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, | |
| 87 | ), | |
| 88 | ]; | |
| 89 | } | |
| 90 | ||
| 91 | const isMutation = (call: Call) => call[0]!.startsWith("mutation"); | |
| 92 | ||
| 93 | const stack = [ | |
| 94 | { | |
| 95 | number: 50, | |
| 96 | url: "https://api.github.com/repos/acme/web/stacks/50", | |
| 97 | base: { ref: "main" }, | |
| 98 | pull_requests: [ | |
| 99 | { | |
| 100 | number: 1, | |
| 101 | title: "Base", | |
| 102 | head: { ref: "base", sha: "aaa" }, | |
| 103 | state: "closed", | |
| 104 | merged_at: "2026-01-01T00:00:00Z", | |
| 105 | }, | |
| 106 | { | |
| 107 | number: 2, | |
| 108 | title: "Middle", | |
| 109 | head: { ref: "middle", sha: "bbb" }, | |
| 110 | state: "open", | |
| 111 | draft: false, | |
| 112 | }, | |
| 113 | { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, | |
| 114 | ], | |
| 115 | }, | |
| 116 | ]; | |
| 117 | const input = { | |
| 118 | cwd: "/repo", | |
| 119 | repository: "acme/web", | |
| 120 | host: "github.com", | |
| 121 | number: 3, | |
| 122 | stackNumber: 50, | |
| 123 | expectedStackHeads: [ | |
| 124 | { number: 2, headSha: "bbb" }, | |
| 125 | { number: 3, headSha: "ccc" }, | |
| 126 | ], | |
| 127 | action: "merge" as const, | |
| 128 | }; | |
| 129 | const access = { | |
| 130 | data: { | |
| 131 | repository: { | |
| 132 | pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 133 | pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | |
| 134 | }, | |
| 135 | }, | |
| 136 | }; | |
| 137 | ||
| 138 | const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ | |
| 139 | data: { | |
| 140 | processed: processed.map((headRefOid) => ({ headRefOid })), | |
| 141 | repository: { | |
| 142 | pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, | |
| 143 | }, | |
| 144 | }, | |
| 145 | }); | |
| 146 | const rebased = { | |
| 147 | data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, | |
| 148 | }; | |
| 149 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; | |
| 150 | ||
| 151 | function fake(responses: readonly unknown[]) { | |
| 152 | const calls: Call[] = []; | |
| 153 | const execute: Send = (call) => | |
| 154 | Effect.sync(() => { | |
| 155 | calls.push(call); | |
| 156 | const value = responses[calls.length - 1]; | |
| 157 | if (value === undefined) throw new Error("Unexpected GitHub request"); | |
| 158 | return encodeJson(value); | |
| 159 | }); | |
| 160 | return { execute, calls }; | |
| 161 | } | |
| 162 | ||
| 163 | it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const api = fake([stack, { status: "enqueued", details: {} }]); | |
| 166 | yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); | |
| 167 | expect(api.calls).toHaveLength(2); | |
| 168 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 169 | expect(api.calls[1]).toContain("sha=ccc"); | |
| 170 | expect(api.calls[1]).toContain("merge_action=default"); | |
| 171 | expect(api.calls[1]).toContain("merge_method=squash"); | |
| 172 | }), | |
| 173 | ); | |
| 174 | ||
| 175 | it.effect("merges through the selected layer without including later draft layers", () => | |
| 176 | Effect.gen(function* () { | |
| 177 | const fiveLayers = [ | |
| 178 | { | |
| 179 | ...stack[0], | |
| 180 | pull_requests: Array.from({ length: 5 }, (_, index) => ({ | |
| 181 | number: index + 1, | |
| 182 | head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, | |
| 183 | state: "open", | |
| 184 | draft: index >= 3, | |
| 185 | })), | |
| 186 | }, | |
| 187 | ]; | |
| 188 | const api = fake([fiveLayers, { status: "merged", details: {} }]); | |
| 189 | yield* runGitHubStackAction(api.execute, { | |
| 190 | ...input, | |
| 191 | number: 3, | |
| 192 | expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), | |
| 193 | }); | |
| 194 | expect(api.calls).toHaveLength(2); | |
| 195 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | |
| 196 | expect(api.calls[1]).toContain("sha=sha-3"); | |
| 197 | expect(api.calls[1]).toContain("merge_action=default"); | |
| 198 | }), | |
| 199 | ); | |
| 200 | ||
| 201 | it.effect("rejects stale reviewed heads below a selected middle layer", () => | |
| 202 | Effect.gen(function* () { | |
| 203 | const api = fake([stack]); | |
| 204 | const result = yield* runGitHubStackAction(api.execute, { | |
| 205 | ...input, | |
| 206 | number: 2, | |
| 207 | expectedStackHeads: [{ number: 2, headSha: "old-head" }], | |
| 208 | }).pipe(Effect.result); | |
| 209 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | |
| 210 | expect(api.calls).toHaveLength(1); | |
| 211 | }), | |
| 212 | ); | |
| 213 | ||
| 214 | it.effect("does not merge from an already merged layer", () => | |
| 215 | Effect.gen(function* () { | |
| 216 | const api = fake([stack]); | |
| 217 | const result = yield* runGitHubStackAction(api.execute, { | |
| 218 | ...input, | |
| 219 | number: 1, | |
| 220 | expectedStackHeads: [], | |
| 221 | }).pipe(Effect.result); | |
| 222 | expect(result).toMatchObject({ | |
| 223 | _tag: "Failure", | |
| 224 | failure: { _tag: "GitHubStackUnsupportedError" }, | |
| 225 | }); | |
| 226 | expect(api.calls).toHaveLength(1); | |
| 227 | }), | |
| 228 | ); | |
| 229 | ||
| 230 | it.effect("polls an accepted merge and reports a later rule rejection", () => | |
| 231 | Effect.gen(function* () { | |
| 232 | const api = fake([ | |
| 233 | stack, | |
| 234 | { status: "pending", details: { uuid: "operation" } }, | |
| 235 | { status: "failed", details: { message: "Required checks have not passed" } }, | |
| 236 | ]); | |
| 237 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 238 | Effect.result, | |
| 239 | Effect.forkChild, | |
| 240 | ); | |
| 241 | yield* TestClock.adjust("1 second"); | |
| 242 | const result = yield* Fiber.join(fiber); | |
| 243 | expect(result).toMatchObject({ | |
| 244 | _tag: "Failure", | |
| 245 | failure: { _tag: "GitHubStackMergeRejectedError" }, | |
| 246 | }); | |
| 247 | expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); | |
| 248 | }), | |
| 249 | ); | |
| 250 | ||
| 251 | it.effect("retains stack identity and a rejection response without a message", () => | |
| 252 | Effect.gen(function* () { | |
| 253 | const rejection = { status: "failed", details: {} }; | |
| 254 | const api = fake([stack, rejection]); | |
| 255 | const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); | |
| 256 | expect(result).toMatchObject({ | |
| 257 | _tag: "Failure", | |
| 258 | failure: { | |
| 259 | _tag: "GitHubStackMergeRejectedError", | |
| 260 | repository: input.repository, | |
| 261 | number: input.number, | |
| 262 | stackNumber: input.stackNumber, | |
| 263 | cause: rejection, | |
| 264 | }, | |
| 265 | }); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | it.effect("refuses a changed stack before performing any mutation", () => | |
| 270 | Effect.gen(function* () { | |
| 271 | const api = fake([stack]); | |
| 272 | const result = yield* runGitHubStackAction(api.execute, { | |
| 273 | ...input, | |
| 274 | expectedStackHeads: [ | |
| 275 | { number: 2, headSha: "old" }, | |
| 276 | { number: 3, headSha: "ccc" }, | |
| 277 | ], | |
| 278 | }).pipe(Effect.result); | |
| 279 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | |
| 280 | expect(api.calls).toHaveLength(1); | |
| 281 | }), | |
| 282 | ); | |
| 283 | ||
| 284 | it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => | |
| 285 | Effect.gen(function* () { | |
| 286 | const api = fake([ | |
| 287 | stack, | |
| 288 | { | |
| 289 | data: { | |
| 290 | repository: { | |
| 291 | ...access.data.repository, | |
| 292 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | |
| 293 | }, | |
| 294 | }, | |
| 295 | }, | |
| 296 | ]); | |
| 297 | const result = yield* runGitHubStackAction(api.execute, { | |
| 298 | ...input, | |
| 299 | action: "update-branch", | |
| 300 | }).pipe(Effect.result); | |
| 301 | expect(result).toMatchObject({ | |
| 302 | _tag: "Failure", | |
| 303 | failure: { _tag: "GitHubStackPermissionError" }, | |
| 304 | }); | |
| 305 | expect(api.calls).toHaveLength(2); | |
| 306 | }), | |
| 307 | ); | |
| 308 | ||
| 309 | it.effect("allows a fork that explicitly permits maintainer updates", () => | |
| 310 | Effect.gen(function* () { | |
| 311 | const api = fake([ | |
| 312 | stack, | |
| 313 | { | |
| 314 | data: { | |
| 315 | repository: { | |
| 316 | ...access.data.repository, | |
| 317 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, | |
| 318 | }, | |
| 319 | }, | |
| 320 | }, | |
| 321 | ...rebaseResponses, | |
| 322 | ]); | |
| 323 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 324 | expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); | |
| 325 | }), | |
| 326 | ); | |
| 327 | ||
| 328 | it.effect("bounds polling and reports a still-running merge without claiming success", () => | |
| 329 | Effect.gen(function* () { | |
| 330 | const api = fake([ | |
| 331 | stack, | |
| 332 | ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), | |
| 333 | ]); | |
| 334 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | |
| 335 | Effect.result, | |
| 336 | Effect.forkChild, | |
| 337 | ); | |
| 338 | yield* TestClock.adjust("6 minutes"); | |
| 339 | expect(yield* Fiber.join(fiber)).toMatchObject({ | |
| 340 | _tag: "Failure", | |
| 341 | failure: { _tag: "GitHubStackMergePendingError" }, | |
| 342 | }); | |
| 343 | expect(api.calls.length).toBeLessThan(40); | |
| 344 | }), | |
| 345 | ); | |
| 346 | ||
| 347 | it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => | |
| 348 | Effect.gen(function* () { | |
| 349 | gitCalls = []; | |
| 350 | const api = fake([stack, access]); | |
| 351 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | |
| 352 | // GitHub is only read; every change is a git push with a lease on the reviewed head. | |
| 353 | expect(api.calls.some(isMutation)).toBe(false); | |
| 354 | const rebases = gitCalls.filter((args) => args[0] === "rebase"); | |
| 355 | expect(rebases).toEqual([ | |
| 356 | ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], | |
| 357 | ["rebase", "--quiet", "--onto", "new-sha", "bbb"], | |
| 358 | ]); | |
| 359 | expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ | |
| 360 | [ | |
| 361 | "push", | |
| 362 | "--quiet", | |
| 363 | "--force-with-lease=refs/heads/middle:bbb", | |
| 364 | "origin", | |
| 365 | "new-sha:refs/heads/middle", | |
| 366 | ], | |
| 367 | [ | |
| 368 | "push", | |
| 369 | "--quiet", | |
| 370 | "--force-with-lease=refs/heads/top:ccc", | |
| 371 | "origin", | |
| 372 | "new-sha:refs/heads/top", | |
| 373 | ], | |
| 374 | ]); | |
| 375 | }), | |
| 376 | ); | |
| 377 | ||
| 378 | it.effect("checks every layer's write access before any git runs", () => | |
| 379 | Effect.gen(function* () { | |
| 380 | gitCalls = []; | |
| 381 | const api = fake([ | |
| 382 | stack, | |
| 383 | { | |
| 384 | data: { | |
| 385 | repository: { | |
| 386 | ...access.data.repository, | |
| 387 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | |
| 388 | }, | |
| 389 | }, | |
| 390 | }, | |
| 391 | ]); | |
| 392 | yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); | |
| 393 | expect(gitCalls).toEqual([]); | |
| 394 | }), | |
| 395 | ); |
packages/source-control-github/src/server/githubStackActions.ts created+288| ... | ... | @@ -0,0 +1,288 @@ |
| 1 | import type { | |
| 2 | PullRequestAction, | |
| 3 | PullRequestMergeMethod, | |
| 4 | PullRequestStackHead, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | ||
| 11 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 12 | import { aliasedGraphQlDocument } from "./githubGraphQl.ts"; | |
| 13 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; | |
| 14 | import { cascadeRebaseStack } from "./githubStackRebase.ts"; | |
| 15 | ||
| 16 | const stackErrorIdentity = { | |
| 17 | repository: Schema.String, | |
| 18 | number: Schema.Int, | |
| 19 | stackNumber: Schema.Int, | |
| 20 | }; | |
| 21 | ||
| 22 | export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()( | |
| 23 | "GitHubStackChangedError", | |
| 24 | { ...stackErrorIdentity, completed: Schema.Int }, | |
| 25 | ) { | |
| 26 | override get message(): string { | |
| 27 | return this.completed > 0 | |
| 28 | ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` | |
| 29 | : "The stack changed. Refresh it before trying again."; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()( | |
| 34 | "GitHubStackUnsupportedError", | |
| 35 | stackErrorIdentity, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return "This operation is not supported for this stack."; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()( | |
| 43 | "GitHubStackResponseInvalidError", | |
| 44 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, | |
| 45 | ) { | |
| 46 | override get message(): string { | |
| 47 | return "GitHub returned an unreadable stack operation response."; | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()( | |
| 52 | "GitHubStackMergeRejectedError", | |
| 53 | { ...stackErrorIdentity, cause: Schema.Defect() }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()( | |
| 61 | "GitHubStackMergePendingError", | |
| 62 | stackErrorIdentity, | |
| 63 | ) { | |
| 64 | override get message(): string { | |
| 65 | return "The merge is still running on GitHub. Check its status there before submitting another request."; | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()( | |
| 70 | "GitHubStackPermissionError", | |
| 71 | stackErrorIdentity, | |
| 72 | ) { | |
| 73 | override get message(): string { | |
| 74 | return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()( | |
| 79 | "GitHubStackRebaseFailedError", | |
| 80 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, | |
| 81 | ) { | |
| 82 | override get message(): string { | |
| 83 | // A conflict or a refused push already says which layer and what to do about it. | |
| 84 | return this.cause instanceof Error && this.cause.message !== "" | |
| 85 | ? this.cause.message | |
| 86 | : `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | export type GitHubStackActionError = | |
| 91 | | GitHubStackChangedError | |
| 92 | | GitHubStackUnsupportedError | |
| 93 | | GitHubStackResponseInvalidError | |
| 94 | | GitHubStackMergeRejectedError | |
| 95 | | GitHubStackMergePendingError | |
| 96 | | GitHubStackPermissionError | |
| 97 | | GitHubStackRebaseFailedError; | |
| 98 | ||
| 99 | const MergeResponse = Schema.Struct({ | |
| 100 | status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), | |
| 101 | details: Schema.Struct({ | |
| 102 | uuid: Schema.optional(Schema.String), | |
| 103 | message: Schema.optional(Schema.String), | |
| 104 | }), | |
| 105 | }); | |
| 106 | ||
| 107 | const decodeBranchAccess = Schema.decodeEffect( | |
| 108 | Schema.fromJsonString( | |
| 109 | Schema.Struct({ | |
| 110 | data: Schema.Struct({ | |
| 111 | repository: Schema.NullOr( | |
| 112 | Schema.Record( | |
| 113 | Schema.String, | |
| 114 | Schema.NullOr( | |
| 115 | Schema.Struct({ | |
| 116 | headRepository: Schema.NullOr( | |
| 117 | Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), | |
| 118 | ), | |
| 119 | maintainerCanModify: Schema.Boolean, | |
| 120 | }), | |
| 121 | ), | |
| 122 | ), | |
| 123 | ), | |
| 124 | }), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ); | |
| 128 | ||
| 129 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); | |
| 130 | ||
| 131 | /** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */ | |
| 132 | export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { | |
| 133 | cwd: string; | |
| 134 | repository: string; | |
| 135 | host: string; | |
| 136 | number: number; | |
| 137 | stackNumber: number; | |
| 138 | expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 139 | action: PullRequestAction; | |
| 140 | mergeMethod?: PullRequestMergeMethod; | |
| 141 | }) { | |
| 142 | const api = yield* GitHubApi.GitHubApi; | |
| 143 | const identity = { | |
| 144 | repository: input.repository, | |
| 145 | number: input.number, | |
| 146 | stackNumber: input.stackNumber, | |
| 147 | }; | |
| 148 | if (input.action !== "merge" && input.action !== "update-branch") | |
| 149 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 150 | const endpoint = `repos/${input.repository}`; | |
| 151 | const read = yield* api.rest({ | |
| 152 | host: input.host, | |
| 153 | operation: "runGitHubStackAction", | |
| 154 | path: `${endpoint}/stacks?pull_request=${input.number}`, | |
| 155 | }); | |
| 156 | const decoded = decodePullRequestStacksJson(read.body); | |
| 157 | if (Result.isFailure(decoded)) | |
| 158 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); | |
| 159 | const stack = decoded.success; | |
| 160 | const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; | |
| 161 | const target = stack?.layers[targetIndex]; | |
| 162 | if ( | |
| 163 | stack?.number !== input.stackNumber || | |
| 164 | target === undefined || | |
| 165 | (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) | |
| 166 | ) { | |
| 167 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | |
| 168 | } | |
| 169 | const affectedLayers = | |
| 170 | input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; | |
| 171 | const open = affectedLayers.filter((layer) => layer.state !== "merged"); | |
| 172 | if (input.action === "merge" && target.state !== "open") | |
| 173 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 174 | if ( | |
| 175 | !input.expectedStackHeads || | |
| 176 | input.expectedStackHeads.length !== open.length || | |
| 177 | new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || | |
| 178 | open.some( | |
| 179 | (layer) => | |
| 180 | !layer.headSha || | |
| 181 | !input.expectedStackHeads?.some( | |
| 182 | (expected) => expected.number === layer.number && expected.headSha === layer.headSha, | |
| 183 | ), | |
| 184 | ) | |
| 185 | ) { | |
| 186 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | |
| 187 | } | |
| 188 | if (open.length === 0 || open.some((layer) => layer.state !== "open")) | |
| 189 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 190 | if (input.action === "update-branch") { | |
| 191 | const [owner, name] = input.repository.split("/"); | |
| 192 | const permissions = yield* api.graphql({ | |
| 193 | host: input.host, | |
| 194 | operation: "runGitHubStackAction", | |
| 195 | allowReserve: true, | |
| 196 | // `open` is non-empty here, so there is always a document. | |
| 197 | ...aliasedGraphQlDocument({ | |
| 198 | operation: "query", | |
| 199 | alias: "pr", | |
| 200 | key: (layer) => layer.number, | |
| 201 | items: open, | |
| 202 | shared: { owner: ["String!", owner], name: ["String!", name] }, | |
| 203 | variables: (layer) => ({ number: ["Int!", layer.number] }), | |
| 204 | field: ({ number }) => | |
| 205 | `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, | |
| 206 | within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, | |
| 207 | })!, | |
| 208 | }); | |
| 209 | const access = yield* decodeBranchAccess(permissions).pipe( | |
| 210 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | |
| 211 | ); | |
| 212 | // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent | |
| 213 | // will make it stale. Check branch write access separately before touching any layer. | |
| 214 | if ( | |
| 215 | open.some((layer) => { | |
| 216 | const pr = access.data.repository?.[`pr${layer.number}`]; | |
| 217 | return ( | |
| 218 | !pr?.headRepository || | |
| 219 | (!pr.maintainerCanModify && | |
| 220 | !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) | |
| 221 | ); | |
| 222 | }) | |
| 223 | ) | |
| 224 | return yield* new GitHubStackPermissionError({ ...identity }); | |
| 225 | // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old | |
| 226 | // copy of every lower layer into the one above it. The cascade moves each layer's own commits. | |
| 227 | yield* cascadeRebaseStack({ | |
| 228 | host: input.host, | |
| 229 | repository: input.repository, | |
| 230 | base: stack.base, | |
| 231 | layers: open.map((layer) => ({ | |
| 232 | number: layer.number, | |
| 233 | headBranch: layer.headBranch, | |
| 234 | headSha: layer.headSha!, | |
| 235 | })), | |
| 236 | }).pipe( | |
| 237 | Effect.mapError((cause) => | |
| 238 | cause._tag === "GitHubStackRebaseConflictError" || | |
| 239 | cause._tag === "GitHubStackRebaseGitError" | |
| 240 | ? new GitHubStackRebaseFailedError({ | |
| 241 | ...identity, | |
| 242 | number: cause.number, | |
| 243 | completed: cause.completed, | |
| 244 | cause, | |
| 245 | }) | |
| 246 | : cause, | |
| 247 | ), | |
| 248 | ); | |
| 249 | return; | |
| 250 | } | |
| 251 | if (open.some((layer) => layer.isDraft)) | |
| 252 | return yield* new GitHubStackUnsupportedError({ ...identity }); | |
| 253 | const decode = (raw: string) => | |
| 254 | decodeMergeResponse(raw).pipe( | |
| 255 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | |
| 256 | ); | |
| 257 | const request = yield* api.rest({ | |
| 258 | host: input.host, | |
| 259 | operation: "runGitHubStackAction", | |
| 260 | method: "PUT", | |
| 261 | path: `${endpoint}/pulls/${input.number}/merge-async`, | |
| 262 | body: { | |
| 263 | merge_method: input.mergeMethod ?? "merge", | |
| 264 | merge_action: "default", | |
| 265 | sha: target.headSha, | |
| 266 | }, | |
| 267 | }); | |
| 268 | let result = yield* decode(request.body); | |
| 269 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; | |
| 270 | for ( | |
| 271 | let attempt = 0; | |
| 272 | result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; | |
| 273 | attempt++ | |
| 274 | ) { | |
| 275 | const uuid = result.details.uuid; | |
| 276 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); | |
| 277 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); | |
| 278 | const poll = yield* api.rest({ | |
| 279 | host: input.host, | |
| 280 | operation: "runGitHubStackAction", | |
| 281 | path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | |
| 282 | }); | |
| 283 | result = yield* decode(poll.body); | |
| 284 | } | |
| 285 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); | |
| 286 | if (result.status === "failed") | |
| 287 | return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); | |
| 288 | }); |
packages/source-control-github/src/server/githubStackRebase.ts created+155| ... | ... | @@ -0,0 +1,155 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Redacted from "effect/Redacted"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as Base64 from "effect/encoding/Base64"; | |
| 6 | ||
| 7 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 8 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 9 | ||
| 10 | /** One open layer of a stack, bottom to top, with the head the reader reviewed. */ | |
| 11 | export interface CascadeLayer { | |
| 12 | readonly number: number; | |
| 13 | readonly headBranch: string; | |
| 14 | readonly headSha: string; | |
| 15 | } | |
| 16 | ||
| 17 | /** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ | |
| 18 | export class GitHubStackRebaseConflictError extends Schema.TaggedError<GitHubStackRebaseConflictError>()( | |
| 19 | "GitHubStackRebaseConflictError", | |
| 20 | { number: Schema.Int, completed: Schema.Int }, | |
| 21 | ) { | |
| 22 | override get message(): string { | |
| 23 | return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | /** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ | |
| 28 | export class GitHubStackRebaseGitError extends Schema.TaggedError<GitHubStackRebaseGitError>()( | |
| 29 | "GitHubStackRebaseGitError", | |
| 30 | { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, | |
| 31 | ) { | |
| 32 | override get message(): string { | |
| 33 | return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | /** The git URL of a repository on a GitHub host, which is also where its token is good. */ | |
| 38 | function remoteUrl(host: string, repository: string): string { | |
| 39 | return `https://${host.trim().toLowerCase()}/${repository}.git`; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer | |
| 44 | * below it (the bottom one onto the stack's base). Each layer moves only its own commits — | |
| 45 | * `git rebase --onto <new parent> <old parent>` — so a rebased lower layer is never replayed into | |
| 46 | * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. | |
| 47 | * | |
| 48 | * It works in a throwaway clone, so the environment's checkout never moves, and pushes each | |
| 49 | * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused | |
| 50 | * rather than overwritten. The stacks API has no rebase endpoint to call instead. | |
| 51 | */ | |
| 52 | export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { | |
| 53 | readonly host: string; | |
| 54 | readonly repository: string; | |
| 55 | readonly base: string; | |
| 56 | readonly layers: ReadonlyArray<CascadeLayer>; | |
| 57 | /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ | |
| 58 | readonly remote?: string; | |
| 59 | }) { | |
| 60 | const api = yield* GitHubApi.GitHubApi; | |
| 61 | const { process } = yield* SourceControlHost.SourceControlHost; | |
| 62 | const fileSystem = yield* FileSystem.FileSystem; | |
| 63 | const { token } = yield* api.credential(input.host); | |
| 64 | const first = input.layers[0]; | |
| 65 | if (first === undefined) return 0; | |
| 66 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( | |
| 67 | Effect.mapError( | |
| 68 | (cause) => | |
| 69 | new GitHubStackRebaseGitError({ | |
| 70 | step: "preparing", | |
| 71 | number: first.number, | |
| 72 | completed: 0, | |
| 73 | cause, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ); | |
| 77 | // The token rides in a header for this process only: never in the URL, argv, or git config. | |
| 78 | const remote = input.remote ?? remoteUrl(input.host, input.repository); | |
| 79 | const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; | |
| 80 | const env = { | |
| 81 | GIT_TERMINAL_PROMPT: "0", | |
| 82 | GIT_CONFIG_COUNT: "3", | |
| 83 | GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, | |
| 84 | GIT_CONFIG_VALUE_0: authorization, | |
| 85 | GIT_CONFIG_KEY_1: "user.name", | |
| 86 | GIT_CONFIG_VALUE_1: "T3 Code", | |
| 87 | GIT_CONFIG_KEY_2: "user.email", | |
| 88 | GIT_CONFIG_VALUE_2: "noreply@t3.codes", | |
| 89 | }; | |
| 90 | const git = (args: ReadonlyArray<string>, allowNonZeroExit = false) => | |
| 91 | process.run({ | |
| 92 | operation: "cascadeRebaseStack", | |
| 93 | command: "git", | |
| 94 | args, | |
| 95 | cwd: directory, | |
| 96 | env, | |
| 97 | timeoutMs: 120_000, | |
| 98 | ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), | |
| 99 | }); | |
| 100 | const failed = (step: string, number: number, completed: number) => (cause: unknown) => | |
| 101 | new GitHubStackRebaseGitError({ step, number, completed, cause }); | |
| 102 | ||
| 103 | yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); | |
| 104 | yield* git(["remote", "add", "origin", remote]).pipe( | |
| 105 | Effect.mapError(failed("preparing", first.number, 0)), | |
| 106 | ); | |
| 107 | yield* git([ | |
| 108 | "fetch", | |
| 109 | "--quiet", | |
| 110 | "--no-tags", | |
| 111 | "origin", | |
| 112 | `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, | |
| 113 | ...input.layers.map( | |
| 114 | (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, | |
| 115 | ), | |
| 116 | ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); | |
| 117 | ||
| 118 | let parentOld = `origin/${input.base}`; | |
| 119 | let parentNew = `origin/${input.base}`; | |
| 120 | for (const [index, layer] of input.layers.entries()) { | |
| 121 | // The bottom layer's old parent is where it forked from the base; above that it is the | |
| 122 | // reviewed head of the layer below, which is exactly the commits this layer must not replay. | |
| 123 | const upstream = | |
| 124 | index === 0 | |
| 125 | ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( | |
| 126 | Effect.mapError(failed("reading the fork point", layer.number, index)), | |
| 127 | )).stdout.trim() | |
| 128 | : parentOld; | |
| 129 | yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( | |
| 130 | Effect.mapError(failed("checking out", layer.number, index)), | |
| 131 | ); | |
| 132 | const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( | |
| 133 | Effect.mapError(failed("rebasing", layer.number, index)), | |
| 134 | ); | |
| 135 | if (rebase.exitCode !== 0) { | |
| 136 | yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); | |
| 137 | return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); | |
| 138 | } | |
| 139 | const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( | |
| 140 | Effect.mapError(failed("rebasing", layer.number, index)), | |
| 141 | )).stdout.trim(); | |
| 142 | if (rebased !== layer.headSha) { | |
| 143 | yield* git([ | |
| 144 | "push", | |
| 145 | "--quiet", | |
| 146 | `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, | |
| 147 | "origin", | |
| 148 | `${rebased}:refs/heads/${layer.headBranch}`, | |
| 149 | ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); | |
| 150 | } | |
| 151 | parentOld = layer.headSha; | |
| 152 | parentNew = rebased; | |
| 153 | } | |
| 154 | return input.layers.length; | |
| 155 | }, Effect.scoped); |
packages/source-control-github/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
packages/source-control-testing/package.json+1| ... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | 15 | "@t3tools/contracts": "workspace:*", |
| 16 | "@t3tools/shared": "workspace:*", | |
| 16 | 17 | "@t3tools/source-control-core": "workspace:*", |
| 17 | 18 | "effect": "catalog:" |
| 18 | 19 | }, |
packages/source-control-testing/src/TestSourceControlHost.ts+39-15| ... | ... | @@ -1,15 +1,23 @@ |
| 1 | 1 | /** |
| 2 | * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test | |
| 3 | * supplies its own. Process runs and git operations go to what the test supplies; anything else | |
| 4 | * dies, so an unexpected CLI or git call is visible. | |
| 2 | * A `SourceControlHost.SourceControlHost` for provider tests. Settings start from the defaults | |
| 3 | * or what the test supplies, and `TestSourceControlHostSettings` patches them mid-test the way a | |
| 4 | * client's settings write would. Process runs and git operations go to what the test supplies; | |
| 5 | * anything else dies, so an unexpected CLI or git call is visible. | |
| 5 | 6 | * |
| 6 | 7 | * @module source-control-testing/TestSourceControlHost |
| 7 | 8 | */ |
| 8 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | DEFAULT_SERVER_SETTINGS, | |
| 11 | type ServerSettings, | |
| 12 | type ServerSettingsPatch, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 9 | 15 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 16 | import * as Context from "effect/Context"; | |
| 10 | 17 | import * as Effect from "effect/Effect"; |
| 11 | 18 | import * as Layer from "effect/Layer"; |
| 12 | 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 20 | import * as Ref from "effect/Ref"; | |
| 13 | 21 | |
| 14 | 22 | export interface TestSourceControlHostOptions { |
| 15 | 23 | readonly settings?: ServerSettings; |
| ... | ... | @@ -17,12 +25,20 @@ export interface TestSourceControlHostOptions { |
| 17 | 25 | readonly git?: Partial<SourceControlHost.SourceControlHost["Service"]["git"]>; |
| 18 | 26 | } |
| 19 | 27 | |
| 28 | /** Lets a test change the settings its `layer` reports. */ | |
| 29 | export class TestSourceControlHostSettings extends Context.Service< | |
| 30 | TestSourceControlHostSettings, | |
| 31 | { readonly update: (patch: ServerSettingsPatch) => Effect.Effect<void> } | |
| 32 | >()("@t3tools/source-control-testing/TestSourceControlHost/TestSourceControlHostSettings") {} | |
| 33 | ||
| 20 | 34 | type Git = SourceControlHost.SourceControlHost["Service"]["git"]; |
| 21 | 35 | |
| 22 | 36 | const unexpectedGit = (operation: string) => () => |
| 23 | 37 | Effect.die(`Unexpected git ${operation} in a test that supplied none.`); |
| 24 | 38 | |
| 25 | 39 | const failingGit: Git = { |
| 40 | execute: unexpectedGit("execute"), | |
| 41 | resolveCommit: unexpectedGit("resolveCommit"), | |
| 26 | 42 | remotes: unexpectedGit("remotes"), |
| 27 | 43 | readConfigValue: unexpectedGit("readConfigValue"), |
| 28 | 44 | resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"), |
| ... | ... | @@ -36,17 +52,25 @@ const failingGit: Git = { |
| 36 | 52 | |
| 37 | 53 | export const layer = ( |
| 38 | 54 | options: TestSourceControlHostOptions = {}, |
| 39 | ): Layer.Layer<SourceControlHost.SourceControlHost> => | |
| 40 | Layer.succeed( | |
| 41 | SourceControlHost.SourceControlHost, | |
| 42 | SourceControlHost.SourceControlHost.of({ | |
| 43 | settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) }, | |
| 44 | process: { | |
| 45 | run: | |
| 46 | options.process?.run ?? | |
| 47 | ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), | |
| 48 | }, | |
| 49 | git: { ...failingGit, ...options.git }, | |
| 55 | ): Layer.Layer<SourceControlHost.SourceControlHost | TestSourceControlHostSettings> => | |
| 56 | Layer.effectContext( | |
| 57 | Effect.gen(function* () { | |
| 58 | const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); | |
| 59 | const host = SourceControlHost.SourceControlHost.of({ | |
| 60 | settings: { get: Ref.get(settings) }, | |
| 61 | process: { | |
| 62 | run: | |
| 63 | options.process?.run ?? | |
| 64 | ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), | |
| 65 | }, | |
| 66 | git: { ...failingGit, ...options.git }, | |
| 67 | }); | |
| 68 | return Context.make(SourceControlHost.SourceControlHost, host).pipe( | |
| 69 | Context.add(TestSourceControlHostSettings, { | |
| 70 | update: (patch) => | |
| 71 | Ref.update(settings, (current) => applyServerSettingsPatch(current, patch)), | |
| 72 | }), | |
| 73 | ); | |
| 50 | 74 | }), |
| 51 | 75 | ); |
| 52 | 76 |
pnpm-lock.yaml+40| ... | ... | @@ -660,6 +660,9 @@ importers: |
| 660 | 660 | '@t3tools/source-control-forgejo': |
| 661 | 661 | specifier: workspace:* |
| 662 | 662 | version: link:../../packages/source-control-forgejo |
| 663 | '@t3tools/source-control-github': | |
| 664 | specifier: workspace:* | |
| 665 | version: link:../../packages/source-control-github | |
| 663 | 666 | '@t3tools/source-control-gitlab': |
| 664 | 667 | specifier: workspace:* |
| 665 | 668 | version: link:../../packages/source-control-gitlab |
| ... | ... | @@ -1640,6 +1643,40 @@ importers: |
| 1640 | 1643 | specifier: 1.0.0 |
| 1641 | 1644 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1642 | 1645 | |
| 1646 | packages/source-control-github: | |
| 1647 | dependencies: | |
| 1648 | '@t3tools/contracts': | |
| 1649 | specifier: workspace:* | |
| 1650 | version: link:../contracts | |
| 1651 | '@t3tools/provider-core': | |
| 1652 | specifier: workspace:* | |
| 1653 | version: link:../provider-core | |
| 1654 | '@t3tools/shared': | |
| 1655 | specifier: workspace:* | |
| 1656 | version: link:../shared | |
| 1657 | '@t3tools/source-control-core': | |
| 1658 | specifier: workspace:* | |
| 1659 | version: link:../source-control-core | |
| 1660 | effect: | |
| 1661 | specifier: 4.0.2 | |
| 1662 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | |
| 1663 | devDependencies: | |
| 1664 | '@effect/platform-node': | |
| 1665 | specifier: 4.0.2 | |
| 1666 | version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6) | |
| 1667 | '@effect/vitest': | |
| 1668 | specifier: 4.0.2 | |
| 1669 | version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1670 | '@t3tools/source-control-testing': | |
| 1671 | specifier: workspace:* | |
| 1672 | version: link:../source-control-testing | |
| 1673 | '@types/node': | |
| 1674 | specifier: 24.12.4 | |
| 1675 | version: 24.12.4 | |
| 1676 | vite-plus: | |
| 1677 | specifier: 1.0.0 | |
| 1678 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1679 | ||
| 1643 | 1680 | packages/source-control-gitlab: |
| 1644 | 1681 | dependencies: |
| 1645 | 1682 | '@t3tools/contracts': |
| ... | ... | @@ -1673,6 +1710,9 @@ importers: |
| 1673 | 1710 | '@t3tools/contracts': |
| 1674 | 1711 | specifier: workspace:* |
| 1675 | 1712 | version: link:../contracts |
| 1713 | '@t3tools/shared': | |
| 1714 | specifier: workspace:* | |
| 1715 | version: link:../shared | |
| 1676 | 1716 | '@t3tools/source-control-core': |
| 1677 | 1717 | specifier: workspace:* |
| 1678 | 1718 | version: link:../source-control-core |
scripts/release-smoke.ts+1| ... | ... | @@ -41,6 +41,7 @@ const workspaceFiles = [ |
| 41 | 41 | "packages/source-control-bitbucket/package.json", |
| 42 | 42 | "packages/source-control-core/package.json", |
| 43 | 43 | "packages/source-control-forgejo/package.json", |
| 44 | "packages/source-control-github/package.json", | |
| 44 | 45 | "packages/source-control-gitlab/package.json", |
| 45 | 46 | "packages/source-control-testing/package.json", |
| 46 | 47 | "packages/effect-acp/package.json", |