authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:50:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:50:02-07:00
log507361bc7301982279b9f115bbae8f22268ce9d6
tree8b877901476da6f09d6857e111804c433ef3e0d8
parent8d1858d9d608d78b67268cdc52bceb4302ae8870
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): GitHub lives in @t3tools/source-control-github (#17607)

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 @@
4949 "@t3tools/source-control-bitbucket": "workspace:*",
5050 "@t3tools/source-control-core": "workspace:*",
5151 "@t3tools/source-control-forgejo": "workspace:*",
52 "@t3tools/source-control-github": "workspace:*",
5253 "@t3tools/source-control-gitlab": "workspace:*",
5354 "effect": "catalog:",
5455 "jose": "catalog:",
apps/server/scripts/evaluate-thread-titles.ts+1-4
......@@ -23,7 +23,6 @@ import {
2323} from "@t3tools/provider-core/server/threadTitleContext";
2424import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
2525import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
2726import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
2827import * as ServerSettings from "../src/serverSettings.ts";
2928import * as VcsProcess from "../src/vcs/VcsProcess.ts";
......@@ -147,9 +146,7 @@ await Effect.runPromise(
147146 Layer.mergeAll(
148147 ProcessRunner.layer,
149148 SourceControlProviderRegistry.layer.pipe(
150 Layer.provide(
151 Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer),
152 ),
149 Layer.provide(SourceControlBuiltInDrivers.layer),
153150 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
154151 Layer.provide(ServerSettings.layerTest()),
155152 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";
1010import * as Schema from "effect/Schema";
1111import { FetchHttpClient } from "effect/http";
1212
13import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts";
14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
13import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
14import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider";
15import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
16import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
17import * as ServerConfig from "../src/config.ts";
1718import * as ServerSettings from "../src/serverSettings.ts";
18import * as GitHubQuota from "../src/sourceControl/githubQuota.ts";
19import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota";
1920import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
21import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
22import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
23import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
2024import * as VcsProcess from "../src/vcs/VcsProcess.ts";
25import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";
2126
2227const [repository, ...numbers] = process.argv.slice(2);
2328if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) {
......@@ -41,6 +46,17 @@ function withRateLimit(query: string): string {
4146 : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`;
4247}
4348
49// The server's host port over default settings: gh's own account choice, no saved token.
50const 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
4460const measuredApi = Layer.effect(
4561 GitHubApi.GitHubApi,
4662 Effect.gen(function* () {
......@@ -73,18 +89,15 @@ const measuredApi = Layer.effect(
7389 });
7490 }),
7591).pipe(
76 Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))),
92 Layer.provide(GitHubCredentials.layer),
7793 Layer.provide(GitHubQuota.layer),
7894 Layer.provide(SourceControlRateLimit.layer),
7995 Layer.provide(FetchHttpClient.layer),
80 Layer.provide(VcsProcess.layer),
81 Layer.provide(NodeServices.layer),
8296);
8397
8498const services = GitHubPullRequestApi.layer.pipe(
8599 Layer.provideMerge(measuredApi),
86 Layer.provideMerge(VcsProcess.layer),
87 Layer.provideMerge(NodeServices.layer),
100 Layer.provideMerge(sourceControlHost),
88101);
89102
90103const 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
3535import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
3636import { openMediaFile } from "./MediaFile.ts";
3737import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
38import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
38import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
3939import { githubMediaResponse } from "./GitHubMediaFetch.ts";
4040
4141vi.mock("node:fs/promises", async (importOriginal) => {
apps/server/src/assets/GitHubMediaFetch.ts+1-1
......@@ -12,7 +12,7 @@ import {
1212 type HttpClientResponse,
1313} from "effect/http";
1414
15import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
15import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
1616
1717/**
1818 * 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 {
4343 ThreadId,
4444} from "@t3tools/contracts";
4545import * as DateTime from "effect/DateTime";
46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
46import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests";
4747import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
4848import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
4949import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts deleted-4588
......@@ -1,4588 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { afterEach, assert, expect, it, vi } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as Redacted from "effect/Redacted";
12
13import { HttpClient, HttpClientResponse } from "effect/http";
14
15import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubQuota from "../sourceControl/githubQuota.ts";
19import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
23
24const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
25
26const 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. */
57type ApiCall =
58 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
59 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
60
61type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
62
63const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
64const defaultCredential = (host: string) =>
65 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
66const mockedCredential = vi.fn(defaultCredential);
67const 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 */
75const 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
101const 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. */
111function 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`. */
121function 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 */
134function 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. */
170function 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. */
183function 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. */
201const emptySearch = () => output(pullRequests(0, 1));
202const emptyList = () => output(listedPullRequests(0, 1));
203
204function 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. */
215function 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
227function 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
239function 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
258function 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. */
276const diffRefused = new GitHubApi.GitHubApiResponseError({
277 host: "github.com",
278 operation: "getPullRequestDiff",
279 status: 406,
280});
281
282/** The nth request the CLI made. */
283function 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. */
290function 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. */
297function 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. */
303function 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. */
309function 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. */
315function 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
334function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
335 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
336}
337
338const 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 */
344function 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. */
353function 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. */
360function 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 */
370const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
371const 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. */
379const 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. */
406const 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. */
409const 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. */
418const 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
434const 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. */
443function 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. */
464const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
465const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
466
467afterEach(() => {
468 mockedExecute.mockReset();
469 mockedStackMemberships.mockReset();
470 mockedCredential.mockReset();
471 mockedCredential.mockImplementation(defaultCredential);
472});
473
474it.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
554layer("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 @@
1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import {
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
45import * as GitHubApi from "../sourceControl/GitHubApi.ts";
46import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";
47import * as VcsProcess from "../vcs/VcsProcess.ts";
48import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
49import {
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";
131import 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 */
140export 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. */
154export 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. */
166export 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. */
180export 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. */
192export 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. */
204export 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. */
220export 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 */
241export 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. */
254export 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. */
267export 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. */
289export 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. */
302export 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
314export 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. */
332const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
333const DIFF_TIMEOUT = Duration.seconds(60);
334/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
335const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
336
337/** What `gh pr list --state` asked a repository's own list for, without search. */
338const 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
347interface 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. */
354const 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. */
357const 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 */
363const 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 */
370export 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 */
377const REVIEW_THREAD_PAGES = 10;
378
379/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
380const CHECK_CONTEXT_PAGES = 10;
381
382export 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
389export 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 */
400const STAT_ALIASES_PER_REQUEST = 25;
401const 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 */
407const SUMMARY_BATCH_WINDOW = "10 millis";
408
409class 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
420class 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
431export 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
437export 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
447export 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
453export 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 */
803function 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 */
816function 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 */
825function 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 */
839function 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. */
844const 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 */
856function qualifierValue(value: string): string {
857 return `"${value.replaceAll('"', "").trim()}"`;
858}
859
860function 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 */
890function 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. */
914function 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. */
935const 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 */
949function 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
980const MERGE_MESSAGE_GRAPHQL_QUERY = `
981query 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. */
992const 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
1003const 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);
1018const 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. */
1022const ACTION_STATE_GRAPHQL_QUERY = `
1023query 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
1035const 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);
1056const decodeActionState = (raw: string) =>
1057 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1058
1059const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1060 mergePullRequest(input: $input) { clientMutationId }
1061}`;
1062const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1063 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1064}`;
1065const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1066 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1067}`;
1068const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1069 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1070}`;
1071const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1072 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1073}`;
1074const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1075 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1076}`;
1077const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1078 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1079}`;
1080const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1081 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1082}`;
1083const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1084 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1085}`;
1086
1087const 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. */
1094const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1095
1096/** The mutations that only need the pull request's node id. */
1097const 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. */
1106export 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
2857export const layer = Layer.effect(GitHubPullRequestApi, make);
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts deleted-1067
......@@ -1,1067 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import * as Result from "effect/Result";
6import type { PullRequestReaction } from "@t3tools/contracts";
7
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
11import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
12import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
13import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
14
15const 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
27it.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
65it.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
115it.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
156it.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
186it.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
215describe("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
502const 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
535it.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
568it.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
600it.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
647it.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
686describe("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
821describe("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
925describe("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
993describe("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
1048describe("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 @@
1import * as Effect from "effect/Effect";
2import type {
3 PullRequestActor,
4 PullRequestCapabilities,
5 PullRequestCheck,
6 PullRequestReaction,
7 PullRequestViewerPermissions,
8} from "@t3tools/contracts";
9
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import {
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";
20import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
21
22const 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 */
72export 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. */
106export 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 */
139function 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
152function 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 */
191export 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. */
196const rendersEmpty = (body: string): boolean =>
197 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;
198
199export 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";
22
33import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
44import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
5import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
66import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
77import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
88import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";
99import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider";
10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
10import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider";
1111import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";
1212
1313const cause = new Error("redacted provider failure");
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+3-16
......@@ -3,9 +3,6 @@ import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import type { SourceControlProviderKind } from "@t3tools/contracts";
55
6import * as GitHubApi from "../sourceControl/GitHubApi.ts";
7import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
8import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
96import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
107import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
118
......@@ -36,24 +33,14 @@ export function fromProviders(
3633 * @public Service construction is part of the canonical Effect module API.
3734 */
3835export const make = Effect.gen(function* () {
39 const providers = yield* Effect.all([GitHubPullRequestProvider.make]);
4036 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
4137 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),
4238 );
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 );
4742});
4843
4944export 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 ),
5845 Layer.provide(BuiltInDrivers.layer),
5946);
apps/server/src/pullRequest/PullRequestService.ts+1-1
......@@ -77,7 +77,7 @@ import {
7777import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
7878import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7979
80import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
80import { AllowGitHubReserve } from "@t3tools/source-control-github/server/GitHubApi";
8181import * as ProjectService from "../project/ProjectService.ts";
8282import * as ServerSettings from "../serverSettings.ts";
8383import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
apps/server/src/pullRequest/gitHubConditionalChecks.test.ts deleted-245
......@@ -1,245 +0,0 @@
1import { expect, it } from "@effect/vitest";
2import * as TestClock from "effect/testing/TestClock";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as Redacted from "effect/Redacted";
7import type { PullRequestCheck } from "@t3tools/contracts";
8
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
11
12const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
13
14const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 };
15const credential = {
16 host: "github.com",
17 token: Redacted.make("token"),
18 credentialFingerprint: "one",
19};
20
21it.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
125it.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
215it.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 @@
1import * as Clock from "effect/Clock";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
9
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";
13import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider";
14
15const 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});
23const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema));
24
25const 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});
33const decodeWorkflowRuns = Schema.decodeUnknownOption(
34 Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })),
35);
36
37export 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 */
43export const KnownWorkflowRuns = Context.Reference<{
44 readonly headSha: string;
45 readonly runs: ReadonlyArray<KnownWorkflowRun>;
46} | null>("t3/pullRequest/KnownWorkflowRuns", { defaultValue: () => null });
47
48type 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. */
51const UNSUPPORTED_STATUSES = new Set([404, 405, 501]);
52
53export 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 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
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 */
38function 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
73function 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
79describe("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
194describe("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
248describe("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
502describe("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
820describe("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
907describe("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
955describe("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
1033describe("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
1088describe("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
1331describe("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
1343describe("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
1361describe("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
1464describe("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
1483describe("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
1520describe("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
1698describe("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
1755describe("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
1796describe("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
1900describe("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
1940describe("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
2029describe("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 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import 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";
35import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
36import { decodeJsonResult } from "@t3tools/shared/schemaJson";
37
38import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts";
39import { 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 */
45const 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
61const RawLabelSchema = Schema.Struct({
62 name: Schema.String,
63 color: Schema.optional(Schema.NullOr(Schema.String)),
64});
65
66const 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. */
73const RawLatestReviewSchema = Schema.Struct({
74 author: Schema.optional(Schema.NullOr(RawActorSchema)),
75 state: Schema.optional(Schema.NullOr(Schema.String)),
76});
77
78const 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
101const 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
130const 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 */
144const 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
229const 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
243const RawSearchSchema = Schema.Struct({
244 data: Schema.Struct({ search: RawRowConnectionSchema }),
245});
246
247const 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. */
254const 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
276const 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. */
284const 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 */
290const 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. */
305const 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
316const 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
327export function gitHubReactionContent(content: PullRequestReactionContent): string {
328 return GITHUB_REACTION_BY_CONTENT[content];
329}
330
331const 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
358type 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 */
366function 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
387const 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
398const 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
408const 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
424const 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. */
442const 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
452const 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
474const 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
484const 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. */
531function RawPageInfoSchemaFields() {
532 return Schema.Struct({
533 hasNextPage: Schema.optional(Schema.Boolean),
534 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
535 });
536}
537const 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 */
544const RawViewerFieldsSchema = Schema.Struct({
545 viewerCanUpdate: Schema.optional(Schema.Boolean),
546 viewerDidAuthor: Schema.optional(Schema.Boolean),
547});
548
549const 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. */
556const 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. */
694const 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
706const 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});
760const decodeCore = decodeJsonResult(RawCoreSchema);
761
762const 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. */
775export 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
782const RawActorAvatarsSchema = Schema.Struct({
783 data: Schema.Struct({
784 nodes: Schema.Array(Schema.NullOr(RawActorSchema)),
785 }),
786});
787
788const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema);
789
790export 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 */
811function 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
824export 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
852export 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
861const 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
879export 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 */
894export 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. */
914const 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 */
920export 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 */
938export 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 */
959export 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
970function 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. */
975function 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 */
1021export 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 */
1087export 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
1101const 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
1119export 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 */
1130export 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
1134const RawPullRequestNodeIdSchema = Schema.Struct({
1135 data: Schema.Struct({
1136 repository: Schema.Struct({
1137 pullRequest: Schema.Struct({ id: Schema.String }),
1138 }),
1139 }),
1140});
1141
1142const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema);
1143
1144export 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 */
1156export 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
1166const 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
1180const 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 */
1186export 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
1195export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1196 addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1197}`;
1198
1199export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1200 removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1201}`;
1202
1203export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {
1204 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
1205}`;
1206
1207export 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 */
1216export 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. */
1223export 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 */
1233export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
1234 updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } }
1235}`;
1236
1237export 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. */
1244const 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
1257const 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 */
1267export 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
1278function 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. */
1295export 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
1311export 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
1338export 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
1355export interface GitHubWorkflowRunApproval {
1356 readonly id: number;
1357 readonly name: string;
1358 readonly url: string | null;
1359}
1360
1361export interface GitHubPullRequestHead {
1362 readonly number: number;
1363 readonly headSha: string;
1364 readonly isCrossRepository?: boolean;
1365 readonly headRepositoryOwner: string | null;
1366}
1367
1368export interface GitHubPullRequestActivity {
1369 readonly author: PullRequestActor | null;
1370 readonly comments: ReadonlyArray<PullRequestComment>;
1371 readonly commits: ReadonlyArray<PullRequestCommit>;
1372}
1373
1374function 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 */
1384function 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 */
1398function 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
1404function 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
1416function 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. */
1427function 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
1438function 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
1449function 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
1460function 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 */
1479function 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
1502function 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
1515function 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 */
1528function 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
1537function 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
1547function 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. */
1577const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z";
1578
1579function 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. */
1585function 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 */
1594function 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 */
1634function 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
1647function 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. */
1654function 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
1665function 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
1712function 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
1726function 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
1750function 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
1773const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
1774const decodeSearch = decodeJsonResult(RawSearchSchema);
1775const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
1776const decodeStats = decodeJsonResult(RawStatsSchema);
1777const decodeDetail = decodeJsonResult(RawDetailSchema);
1778const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
1779const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema);
1780const decodeActivity = decodeJsonResult(RawActivitySchema);
1781const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema);
1782const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1783const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
1784const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
1785
1786type DecodeFailure = Cause.Cause<Schema.SchemaError>;
1787
1788export 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 */
1801export 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 */
1826function 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
1864export 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
1869export 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 */
1885export 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
1908function 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. */
1922const 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 */
1928function 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
1936const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0;
1937
1938/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */
1939function 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 */
1974export 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. */
1986export 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
1998const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema);
1999
2000export 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 */
2019export 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 */
2047const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }";
2048
2049const 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. */
2057export 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. */
2070export 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
2076const 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});
2084const 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);
2096const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema);
2097
2098export 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 */
2124export 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
2169const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }";
2170const 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 */
2183const 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. */
2191export 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 */
2206export interface GitHubPullRequestWatchFingerprint {
2207 readonly status: string;
2208 readonly remarks: string;
2209}
2210
2211const RawEditedSchema = Schema.Struct({
2212 totalCount: Schema.Int,
2213 nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))),
2214});
2215const RawStateCountsSchema = Schema.NullOr(
2216 Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })),
2217);
2218const 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.
2248const 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
2255const 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. */
2263export 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
2296export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
2297 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
2298 readonly comparison: GitHubBaseComparison | null;
2299 readonly checksTruncated: boolean;
2300}
2301
2302export 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
2340export 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
2349export 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`. */
2356export 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. */
2372export 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
2381export 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
2404export 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`. */
2415export 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. */
2446export 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
2461const 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
2474const 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. */
2503export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>;
2504
2505function 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
2514export 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. */
2534export 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
2541export 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. */
2586export 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
2594export 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 */
2626export 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. */
2646function 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
2663const 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
2683const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema);
2684
2685/** One further page of dismissal events, in the shape the thread read's own page carries. */
2686export 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
2704export 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. */
2833export 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. */
2866export 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 */
2881function 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. */
2893function 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 */
2909export 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
2915export 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. */
2937const RawRequestedReviewerSchema = Schema.Struct({
2938 ...RawActorSchema.fields,
2939 slug: Schema.optional(Schema.NullOr(Schema.String)),
2940});
2941
2942const 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
2970const 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 */
2980export 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 */
3028const ReviewerRequestSchema = Schema.Struct({
3029 reviewers: Schema.Array(Schema.String),
3030 team_reviewers: Schema.Array(Schema.String),
3031});
3032
3033export 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
3044export 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
3056const 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
3086const 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 */
3093export 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. */
3129export 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 */
3140export 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. */
3159export 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
3166const 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
3176const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);
3177
3178export 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
3198export 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 */
3213export 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 */
3223export function decodeCommitFilesJson(
3224 raw: string,
3225): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
3226 return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? []));
3227}
3228
3229const decodeCommitFiles = decodeJsonResult(
3230 Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }),
3231);
3232
3233function 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 */
3287export 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
3298const 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
3329const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema);
3330
3331export 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. */
3341function 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
3352export 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 */
3375export 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. */
3391const 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 */
3406const 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
3416const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
3417
3418export 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
3427export 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 */
3440export 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 @@
1import { expect, it } from "@effect/vitest";
2import * as Layer from "effect/Layer";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7import * as FileSystem from "effect/FileSystem";
8import * as Redacted from "effect/Redacted";
9import { ChildProcessSpawner } from "effect/process";
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import { 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 */
18type Call = ReadonlyArray<string>;
19type 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. */
25let gitCalls: Array<ReadonlyArray<string>> = [];
26const 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
45const 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
78const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
79
80function 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
89const isMutation = (call: Call) => call[0]!.startsWith("mutation");
90
91const 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];
115const 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};
127const access = {
128 data: {
129 repository: {
130 pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
131 pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
132 },
133 },
134};
135
136const 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});
144const rebased = {
145 data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } },
146};
147const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
148
149function 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
161it.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
173it.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
199it.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
212it.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
228it.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
249it.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
267it.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
282it.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
307it.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
326it.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
345it.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
376it.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 @@
1import type {
2 PullRequestAction,
3 PullRequestMergeMethod,
4 PullRequestStackHead,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Clock from "effect/Clock";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10
11import * as GitHubApi from "../sourceControl/GitHubApi.ts";
12import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts";
13import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
14import { cascadeRebaseStack } from "./githubStackRebase.ts";
15
16const stackErrorIdentity = {
17 repository: Schema.String,
18 number: Schema.Int,
19 stackNumber: Schema.Int,
20};
21
22export 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
33export 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
42export 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
51export 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
60export 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
69export 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
78export 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
90export type GitHubStackActionError =
91 | GitHubStackChangedError
92 | GitHubStackUnsupportedError
93 | GitHubStackResponseInvalidError
94 | GitHubStackMergeRejectedError
95 | GitHubStackMergePendingError
96 | GitHubStackPermissionError
97 | GitHubStackRebaseFailedError;
98
99const 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
107const 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
129const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
130
131/** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */
132export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7
8import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import { cascadeRebaseStack } from "./githubStackRebase.ts";
11
12const 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 */
23const 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
73it.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 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Redacted from "effect/Redacted";
4import * as Schema from "effect/Schema";
5import * as Base64 from "effect/encoding/Base64";
6
7import * as GitHubApi from "../sourceControl/GitHubApi.ts";
8import * as VcsProcess from "../vcs/VcsProcess.ts";
9
10/** One open layer of a stack, bottom to top, with the head the reader reviewed. */
11export 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. */
18export 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. */
28export 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. */
38function 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 */
52export 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";
5252import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
5353import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
5454import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
55import * as GitHubApi from "./sourceControl/GitHubApi.ts";
5655import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
5756import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
5857import * as TextGeneration from "./textGeneration/TextGeneration.ts";
......@@ -296,7 +295,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l
296295const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer));
297296
298297const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe(
299 Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)),
298 Layer.provideMerge(SourceControlBuiltInDrivers.layer),
300299 Layer.provideMerge(GitVcsDriver.layer),
301300 Layer.provideMerge(layerVcsDriverRegistry),
302301);
......@@ -585,9 +584,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
585584 Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
586585 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
587586 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.
589589 Layer.provideMerge(layerSourceControlProviderRegistry),
590 Layer.provideMerge(GitHubApi.layerWithDependencies),
591590 Layer.provideMerge(layerGit),
592591 Layer.provideMerge(layerVcs),
593592 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubApi.test.ts deleted-525
......@@ -1,525 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import * as PlatformError from "effect/PlatformError";
7import * as TestClock from "effect/testing/TestClock";
8import * as Tracer from "effect/Tracer";
9import { ChildProcessSpawner } from "effect/process";
10import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts";
11import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http";
12
13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as ServerSettings from "../serverSettings.ts";
19
20const NOW = Date.parse("2026-10-05T12:00:00.000Z");
21
22function 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
58const json = (body: unknown, init?: ResponseInit) =>
59 new Response(JSON.stringify(body), {
60 ...init,
61 headers: { "content-type": "application/json", ...init?.headers },
62 });
63
64describe("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
80describe("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
99describe("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
441describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
12
13import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
17
18const DEFAULT_TIMEOUT = Duration.seconds(30);
19const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
20/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */
21const CONCURRENCY = 8;
22const 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 */
29export 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 */
40export const AllowGitHubReserve = Context.Reference<boolean>(
41 "t3/sourceControl/AllowGitHubReserve",
42 { defaultValue: () => false },
43);
44
45export 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
54export 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
63export 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
76export 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 */
90export 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
106export type GitHubApiError =
107 | GitHubCredentials.GitHubCredentialUnavailableError
108 | GitHubApiRequestError
109 | GitHubApiAuthenticationError
110 | GitHubApiRateLimitError
111 | GitHubApiNotFoundError
112 | GitHubApiResponseError
113 | SourceControlRateLimit.SourceControlRateLimitPausedError;
114
115export 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
124export 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
142export 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
151export 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
167function 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 */
175export 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. */
190const TRACED_QUERY_MAX_CHARS = 4_000;
191
192/** A stable short id for a GraphQL document, so traces group by query without its text. */
193function 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. */
203function 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. */
222function 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
232const 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 }] }`. */
246const 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
266function 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. */
286type Answer = Data.TaggedEnum<{
287 Ok: {};
288 RateLimited: {};
289 Unauthorized: {};
290 NotFound: {};
291 Failed: { readonly messages: ReadonlyArray<string> | undefined };
292}>;
293const 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 */
300function 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. */
344export 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
588export 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 */
594export 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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { ChildProcessSpawner } from "effect/process";
7
8import * as ServerSettings from "../serverSettings.ts";
9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubCredentials from "./GitHubCredentials.ts";
11
12const TOKEN_VARIABLES = [
13 "GH_TOKEN",
14 "GITHUB_TOKEN",
15 "GH_ENTERPRISE_TOKEN",
16 "GITHUB_ENTERPRISE_TOKEN",
17] as const;
18
19function 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
58describe("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 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13
14import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
15
16import * as ServerSettings from "../serverSettings.ts";
17import * 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. */
20const TOKEN_TTL = Duration.minutes(5);
21/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
22const MISSING_TTL = Duration.seconds(10);
23
24export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]);
25export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
26
27export 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. */
36export 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. */
46export 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. */
58export 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. */
68export 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. */
78export 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 */
88export 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
99function 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. */
104function 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 */
113export 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. */
130export 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
262export const layer = Layer.effect(GitHubCredentials, make);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts deleted-1164
......@@ -1,1164 +0,0 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { ChildProcessSpawner } from "effect/process";
11import * as TestClock from "effect/testing/TestClock";
12
13import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as GitHubApi from "./GitHubApi.ts";
16import * as GitHubCredentials from "./GitHubCredentials.ts";
17import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
18import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
19
20const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
21
22const 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
36const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
37 exitCode: ChildProcessSpawner.ExitCode(exitCode),
38 stdout,
39 stderr: "",
40 stdoutTruncated: false,
41 stderrTruncated: false,
42});
43
44const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
45 entries
46 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
47 .join("\n");
48
49const 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
57const 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 */
74function 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. */
131const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
132 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
133
134const 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
140describe("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
225describe("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
391describe("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
436describe("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
513describe("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
647it("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
690it("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
725it("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
790it("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
825it("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
840it.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
872it.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
901const 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
917it("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
930it("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
938it("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
950it("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
973it.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
1076it.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 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Request from "effect/Request";
8import * as RequestResolver from "effect/RequestResolver";
9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
11import {
12 DEFAULT_SERVER_SETTINGS,
13 SourceControlProviderError,
14 TrimmedNonEmptyString,
15 type ChangeRequest,
16 type GitHubSettings,
17 type SourceControlProviderDiscoveryItem,
18 type SourceControlRepositoryCloneUrls,
19} from "@t3tools/contracts";
20import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
21import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
22import { decodeJsonResult } from "@t3tools/shared/schemaJson";
23import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
24
25import * as ServerSettings from "../serverSettings.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
27import * as GitHubApi from "./GitHubApi.ts";
28import {
29 decodeGitHubPullRequestEntries,
30 type NormalizedGitHubPullRequestRecord,
31} from "./gitHubPullRequests.ts";
32import {
33 gitHubApiHostForRemote,
34 parseFetchRemotes,
35 parseGitHubRepositorySelector,
36 parsePullRequestReference,
37 resolveGitHubRepository,
38 type GitHubRepositoryLocator,
39} from "./gitHubRepositoryResolution.ts";
40import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
41import {
42 effectiveGitHubAccount,
43 findAuthenticatedGitHubAccount,
44 parseGitHubAuthStatus,
45 type GitHubAuthStatusAccount,
46} from "./gitHubAuthStatus.ts";
47import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
48import {
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";
57import * as VcsProcess from "../vcs/VcsProcess.ts";
58
59const decodeLinkSubject = Schema.decodeUnknownEffect(
60 Schema.fromJsonString(
61 Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }),
62 ),
63);
64
65function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
66 return { provider: "github", ...record };
67}
68
69function 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 */
86export 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
166export 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
178const 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. */
183function 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 */
193export 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 */
290class GitHubFailure extends Data.TaggedError("GitHubFailure")<{
291 readonly detail: string;
292 readonly cause: unknown;
293}> {}
294
295const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause });
296
297const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again.";
298const 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. */
301function 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
336const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause));
337
338const 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});
344const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
345
346function 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
352const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
353
354type PullRequestListState = "open" | "closed" | "merged" | "all";
355
356/** The pull request fields every read selects, in GraphQL. */
357const PULL_REQUEST_NODE_SELECTION =
358 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
359const 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 */
369const 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 */
376const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
377const 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 */
383const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
384/** A full document is 5,000 rows of well under 2 KB each. */
385const 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 */
391const OWNER_HEAD_SCAN_LIMIT = 100;
392
393class 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. */
408function 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
430const 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
436const 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
449const 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. */
458export 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. */
475const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) =>
476 context === undefined ? undefined : new URL(context.provider.baseUrl).host;
477
478export 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(
2424 settings: { get: serverSettings.getSettings },
2525 process: { run: process.run },
2626 git: {
27 execute: git.execute,
28 resolveCommit: git.resolveCommit,
2729 remotes: (cwd) =>
2830 vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))),
2931 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
2020import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
2121import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
2222import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
23import * as GitHubApi from "./GitHubApi.ts";
23import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
24import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
2425import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2526import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2627import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
......@@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: {
4647 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
4748 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
4849 ServerSettings.ServerSettingsService.layerTest(),
50 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}),
4951 Layer.mock(GitHubApi.GitHubApi)({}),
5052 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
5153 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
1818import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
1919import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
2020import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
21import * as GitHubApi from "./GitHubApi.ts";
22import * as GitHubCredentials from "./GitHubCredentials.ts";
21import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
22import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
23import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
2324import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2425import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2526import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
......@@ -115,6 +116,7 @@ function makeRegistry(input: {
115116 Layer.mock(BitbucketApi.BitbucketApi)({}),
116117 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
117118 ServerSettings.ServerSettingsService.layerTest(),
119 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}),
118120 Layer.mock(GitHubApi.GitHubApi)({
119121 // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed.
120122 credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })),
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1-16
......@@ -11,7 +11,6 @@ import {
1111import type { SourceControlProviderKind } from "@t3tools/contracts";
1212import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1313
14import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1514import * as BuiltInDrivers from "./builtInDrivers.ts";
1615import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
1716import {
......@@ -302,8 +301,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
302301);
303302
304303export const make = Effect.gen(function* () {
305 const github = yield* GitHubSourceControlProvider.make;
306 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
307304 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
308305 driver.make.pipe(
309306 Effect.map((instance): SourceControlProviderRegistration => ({
......@@ -313,19 +310,7 @@ export const make = Effect.gen(function* () {
313310 })),
314311 ),
315312 );
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);
329314});
330315
331316export 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
1515import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver";
1616import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
1717import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
18import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
19import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
20import * as GitHubDriver from "@t3tools/source-control-github/server/driver";
1821import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
1922import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2023import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver";
......@@ -24,6 +27,7 @@ import * as Layer from "effect/Layer";
2427import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2528
2629const drivers = [
30 GitHubDriver.driver,
2731 GitLabDriver.driver,
2832 AzureDevOpsDriver.driver,
2933 BitbucketDriver.driver,
......@@ -34,12 +38,16 @@ const drivers = [
3438export type BuiltInSourceControlDriversEnv =
3539 (typeof drivers)[number] extends SourceControlDriver<infer R> ? R : never;
3640
41/** Ordered as the hosts appear in discovery. */
3742export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<
3843 SourceControlDriver<BuiltInSourceControlDriversEnv>
3944> = drivers;
4045
4146/** The services the built-in drivers' packages own, plus the host port they all run against. */
4247export 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)),
4351 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
4452 BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)),
4553 ForgejoCli.layer,
apps/server/src/sourceControl/gitHubAuthStatus.ts deleted-103
......@@ -1,103 +0,0 @@
1import type { GitHubSettings } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5const 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
14const GitHubAuthStatusSchema = Schema.Struct({
15 hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)),
16});
17
18const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption(
19 Schema.fromJsonString(GitHubAuthStatusSchema),
20);
21
22export 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
32export interface GitHubAuthStatus {
33 readonly parsed: boolean;
34 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;
35}
36
37const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u;
38
39function nonEmptyString(value: string): string | null {
40 const trimmed = value.trim();
41 return trimmed.length > 0 ? trimmed : null;
42}
43
44export 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
74export 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 */
88export 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 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export 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
28const 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
61function trimOptionalString(value: string | null | undefined): string | null {
62 const trimmed = value?.trim() ?? "";
63 return trimmed.length > 0 ? trimmed : null;
64}
65
66function 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
83function 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
118const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
119const 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 */
125export 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
138export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 gitHubApiHostForRemote,
5 selectGitHubBaseRepository,
6} from "./gitHubRepositoryResolution.ts";
7
8describe("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
78describe("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 @@
1import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2import {
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. */
8export 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. */
15export 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 */
35export 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 */
76export 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. */
118export 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 */
145export 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 */
177export 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. */
189function 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 @@
1import { assert, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts";
5
6describe("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
58describe("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 @@
1import * as Effect from "effect/Effect";
2
3/** GraphQL variables by name, each with the type it is declared as and the value it carries. */
4export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>;
5
6export 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 */
23export 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 */
72export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubQuota from "./githubQuota.ts";
6import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit";
7
8const RESET = Date.parse("2099-08-13T14:00:00Z");
9const 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
16describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import * 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. */
10const 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 */
16type GitHubQuotaResource = string;
17
18interface 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 */
28function 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 */
60export 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
75const 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
122export const layer = Layer.effect(GitHubQuota, make);
apps/server/src/sourceControl/githubStackRebase.test.ts created+173
......@@ -0,0 +1,173 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7
8import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
9import { cascadeRebaseStack } from "@t3tools/source-control-github/server/githubStackRebase";
10
11import * as ServerSettings from "../serverSettings.ts";
12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
16
17const 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 */
34const 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
84it.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";
229229import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
230230import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
231231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
232import * as GitHubApi from "./sourceControl/GitHubApi.ts";
233232import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
234233import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
235234import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
......@@ -3199,12 +3198,7 @@ export const layer = Layer.unwrap(
31993198 SourceControlDiscovery.layer.pipe(
32003199 Layer.provide(
32013200 SourceControlProviderRegistry.layer.pipe(
3202 Layer.provide(
3203 Layer.mergeAll(
3204 GitHubApi.layerWithDependencies,
3205 SourceControlBuiltInDrivers.layer,
3206 ),
3207 ),
3201 Layer.provide(SourceControlBuiltInDrivers.layer),
32083202 Layer.provideMerge(GitVcsDriver.layer),
32093203 Layer.provide(
32103204 VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)),
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "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",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/source-control-core/src/server/SourceControlHost.ts+15
......@@ -54,6 +54,11 @@ export interface SourceControlProcessOutput {
5454 readonly stderrInvalidUtf8?: boolean;
5555}
5656
57export type SourceControlGitOutput = Omit<
58 SourceControlProcessOutput,
59 "stdoutInvalidUtf8" | "stderrInvalidUtf8"
60>;
61
5762export interface SourceControlGitRemoteBranch {
5863 readonly cwd: string;
5964 readonly remoteName: string;
......@@ -73,6 +78,16 @@ export class SourceControlHost extends Context.Service<
7378 ) => Effect.Effect<SourceControlProcessOutput, VcsError>;
7479 };
7580 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>;
7691 /**
7792 * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository
7893 * 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as PlatformError from "effect/PlatformError";
8import * as TestClock from "effect/testing/TestClock";
9import * as Tracer from "effect/Tracer";
10import { ChildProcessSpawner } from "effect/process";
11import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts";
12import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http";
13
14import * as GitHubApi from "./GitHubApi.ts";
15import * as GitHubCredentials from "./GitHubCredentials.ts";
16import * as GitHubQuota from "./GitHubQuota.ts";
17import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
18import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
19import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
20
21const NOW = Date.parse("2026-10-05T12:00:00.000Z");
22
23function 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
59const json = (body: unknown, init?: ResponseInit) =>
60 new Response(JSON.stringify(body), {
61 ...init,
62 headers: { "content-type": "application/json", ...init?.headers },
63 });
64
65describe("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
81describe("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
100describe("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
442describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
12
13import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubQuota from "./GitHubQuota.ts";
16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
17
18const DEFAULT_TIMEOUT = Duration.seconds(30);
19const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
20/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */
21const CONCURRENCY = 8;
22const 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 */
29export 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 */
42export const AllowGitHubReserve = Context.Reference<boolean>(
43 "@t3tools/source-control-github/server/GitHubApi/AllowGitHubReserve",
44 { defaultValue: () => false },
45);
46
47export 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
56export 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
65export 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
78export 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 */
92export 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
108export type GitHubApiError =
109 | GitHubCredentials.GitHubCredentialUnavailableError
110 | GitHubApiRequestError
111 | GitHubApiAuthenticationError
112 | GitHubApiRateLimitError
113 | GitHubApiNotFoundError
114 | GitHubApiResponseError
115 | SourceControlRateLimit.SourceControlRateLimitPausedError;
116
117export 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
126export 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
144export 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
153export 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
169function 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 */
177export 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. */
192const TRACED_QUERY_MAX_CHARS = 4_000;
193
194/** A stable short id for a GraphQL document, so traces group by query without its text. */
195function 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. */
205function 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. */
224function 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
234const 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 }] }`. */
248const 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
268function 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. */
288type Answer = Data.TaggedEnum<{
289 Ok: {};
290 RateLimited: {};
291 Unauthorized: {};
292 NotFound: {};
293 Failed: { readonly messages: ReadonlyArray<string> | undefined };
294}>;
295const 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 */
302function 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. */
346export 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
590export 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 */
596export 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 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { ChildProcessSpawner } from "effect/process";
7
8import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
9import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
10import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
11import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
12import * as GitHubCredentials from "./GitHubCredentials.ts";
13
14const 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. */
22const 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
29function 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
70describe("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 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13
14import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
15
16import * 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. */
19const TOKEN_TTL = Duration.minutes(5);
20/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
21const MISSING_TTL = Duration.seconds(10);
22
23export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]);
24export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
25
26export 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. */
35export 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. */
45export 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. */
57export 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. */
67export 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. */
77export 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 */
87export 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
98function 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. */
103function 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 */
112export 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. */
129export 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
261export const layer = Layer.effect(GitHubCredentials, make);
packages/source-control-github/src/server/GitHubPullRequestApi.test.ts created+4589
......@@ -0,0 +1,4589 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { afterEach, assert, expect, it, vi } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as Redacted from "effect/Redacted";
12
13import { HttpClient, HttpClientResponse } from "effect/http";
14
15import { AllowGitHubReserve } from "./GitHubApi.ts";
16import * as GitHubApi from "./GitHubApi.ts";
17import * as GitHubCredentials from "./GitHubCredentials.ts";
18import * as GitHubQuota from "./GitHubQuota.ts";
19import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
20import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
23
24const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
25
26const 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. */
57type ApiCall =
58 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
59 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
60
61type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
62
63const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
64const defaultCredential = (host: string) =>
65 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
66const mockedCredential = vi.fn(defaultCredential);
67const 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 */
75const 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
101const 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. */
112function 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`. */
122function 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 */
135function 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. */
171function 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. */
184function 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. */
202const emptySearch = () => output(pullRequests(0, 1));
203const emptyList = () => output(listedPullRequests(0, 1));
204
205function 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. */
216function 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
228function 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
240function 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
259function 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. */
277const diffRefused = new GitHubApi.GitHubApiResponseError({
278 host: "github.com",
279 operation: "getPullRequestDiff",
280 status: 406,
281});
282
283/** The nth request the CLI made. */
284function 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. */
291function 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. */
298function 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. */
304function 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. */
310function 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. */
316function 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
335function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
336 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
337}
338
339const 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 */
345function 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. */
354function 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. */
361function 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 */
371const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
372const 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. */
380const 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. */
407const 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. */
410const 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. */
419const 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
435const 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. */
444function 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. */
465const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
466const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
467
468afterEach(() => {
469 mockedExecute.mockReset();
470 mockedStackMemberships.mockReset();
471 mockedCredential.mockReset();
472 mockedCredential.mockImplementation(defaultCredential);
473});
474
475it.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
555layer("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 @@
1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import {
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
45import * as GitHubApi from "./GitHubApi.ts";
46import { readGraphQlPages } from "./githubGraphQl.ts";
47import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
48import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
49import {
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";
131import 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 */
140export 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. */
154export 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. */
166export 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. */
180export 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. */
192export 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. */
204export 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. */
220export 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 */
241export 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. */
254export 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. */
267export 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. */
289export 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. */
302export 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
314export 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. */
332const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
333const DIFF_TIMEOUT = Duration.seconds(60);
334/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
335const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
336
337/** What `gh pr list --state` asked a repository's own list for, without search. */
338const 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
347interface 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. */
354const 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. */
357const 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 */
363const 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 */
370export 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 */
377const REVIEW_THREAD_PAGES = 10;
378
379/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
380const CHECK_CONTEXT_PAGES = 10;
381
382export 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
389export 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 */
400const STAT_ALIASES_PER_REQUEST = 25;
401const 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 */
407const SUMMARY_BATCH_WINDOW = "10 millis";
408
409class 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
420class 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
431export 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
437export 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
447export 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
453export 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 */
803function 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 */
816function 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 */
825function 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 */
839function 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. */
844const 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 */
856function qualifierValue(value: string): string {
857 return `"${value.replaceAll('"', "").trim()}"`;
858}
859
860function 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 */
890function 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. */
914function 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. */
935const 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 */
949function 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
980const MERGE_MESSAGE_GRAPHQL_QUERY = `
981query 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. */
992const 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
1003const 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);
1018const 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. */
1022const ACTION_STATE_GRAPHQL_QUERY = `
1023query 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
1035const 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);
1056const decodeActionState = (raw: string) =>
1057 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1058
1059const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1060 mergePullRequest(input: $input) { clientMutationId }
1061}`;
1062const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1063 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1064}`;
1065const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1066 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1067}`;
1068const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1069 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1070}`;
1071const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1072 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1073}`;
1074const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1075 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1076}`;
1077const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1078 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1079}`;
1080const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1081 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1082}`;
1083const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1084 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1085}`;
1086
1087const 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. */
1094const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1095
1096/** The mutations that only need the pull request's node id. */
1097const 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. */
1106export 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
2857export const layer = Layer.effect(GitHubPullRequestApi, make);
packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts created+1067
......@@ -0,0 +1,1067 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import * as Result from "effect/Result";
6import type { PullRequestReaction } from "@t3tools/contracts";
7
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "./GitHubApi.ts";
10import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
11import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
12import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
13import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
14
15const 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
27it.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
65it.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
115it.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
156it.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
186it.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
215describe("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
502const 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
535it.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
568it.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
600it.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
647it.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
686describe("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
821describe("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
925describe("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
993describe("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
1048describe("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 @@
1import * as Effect from "effect/Effect";
2import type {
3 PullRequestActor,
4 PullRequestCapabilities,
5 PullRequestCheck,
6 PullRequestReaction,
7 PullRequestViewerPermissions,
8} from "@t3tools/contracts";
9
10import * as GitHubApi from "./GitHubApi.ts";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import {
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";
20import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
21
22const 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 */
72export 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. */
106export 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 */
139function 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
152function 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 */
191export 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. */
196const rendersEmpty = (body: string): boolean =>
197 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;
198
199export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubQuota from "./GitHubQuota.ts";
6import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit";
7
8const RESET = Date.parse("2099-08-13T14:00:00Z");
9const 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
16describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import * 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. */
10const 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 */
16type GitHubQuotaResource = string;
17
18interface 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 */
28function 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 */
60export 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
75const 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
122export const layer = Layer.effect(GitHubQuota, make);
packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts created+1169
......@@ -0,0 +1,1169 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { ChildProcessSpawner } from "effect/process";
11import * as TestClock from "effect/testing/TestClock";
12
13import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
14import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
15import * as GitHubApi from "./GitHubApi.ts";
16import * as GitHubCredentials from "./GitHubCredentials.ts";
17import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
18import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
19
20const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
21
22const 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
36const 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
47const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
48 entries
49 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
50 .join("\n");
51
52const 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
60const 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 */
77function 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. */
136const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
137 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
138
139const 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
145describe("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
230describe("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
396describe("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
441describe("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
518describe("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
652it("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
695it("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
730it("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
795it("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
830it("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
845it.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
877it.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
906const 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
922it("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
935it("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
943it("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
955it("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
978it.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
1081it.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 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Request from "effect/Request";
8import * as RequestResolver from "effect/RequestResolver";
9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
11import {
12 DEFAULT_SERVER_SETTINGS,
13 SourceControlProviderError,
14 TrimmedNonEmptyString,
15 type ChangeRequest,
16 type GitHubSettings,
17 type SourceControlProviderDiscoveryItem,
18 type SourceControlRepositoryCloneUrls,
19} from "@t3tools/contracts";
20import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
21import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
22import { decodeJsonResult } from "@t3tools/shared/schemaJson";
23import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
24
25import * as GitHubApi from "./GitHubApi.ts";
26import {
27 decodeGitHubPullRequestEntries,
28 type NormalizedGitHubPullRequestRecord,
29} from "./gitHubPullRequests.ts";
30import {
31 gitHubApiHostForRemote,
32 parseFetchRemotes,
33 parseGitHubRepositorySelector,
34 parsePullRequestReference,
35 resolveGitHubRepository,
36 type GitHubRepositoryLocator,
37} from "./gitHubRepositoryResolution.ts";
38import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
39import {
40 effectiveGitHubAccount,
41 findAuthenticatedGitHubAccount,
42 parseGitHubAuthStatus,
43 type GitHubAuthStatusAccount,
44} from "./gitHubAuthStatus.ts";
45import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
46import {
47 combinedAuthOutput,
48 firstSafeAuthLine,
49 probeSourceControlProvider,
50 providerAuth,
51 type SourceControlAuthProbeInput,
52 type SourceControlCliDiscoverySpec,
53 type SourceControlManagedCliDiscoverySpec,
54} from "@t3tools/source-control-core/server/discovery";
55import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
56
57const decodeLinkSubject = Schema.decodeUnknownEffect(
58 Schema.fromJsonString(
59 Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }),
60 ),
61);
62
63function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
64 return { provider: "github", ...record };
65}
66
67function 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 */
84export 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
164export 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
176const 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. */
181function 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 */
191export 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 */
288class GitHubFailure extends Data.TaggedError("GitHubFailure")<{
289 readonly detail: string;
290 readonly cause: unknown;
291}> {}
292
293const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause });
294
295const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again.";
296const 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. */
299function 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
334const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause));
335
336const 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});
342const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
343
344function 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
350const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
351
352type PullRequestListState = "open" | "closed" | "merged" | "all";
353
354/** The pull request fields every read selects, in GraphQL. */
355const PULL_REQUEST_NODE_SELECTION =
356 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
357const 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 */
367const 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 */
374const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
375const 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 */
381const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
382/** A full document is 5,000 rows of well under 2 KB each. */
383const 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 */
389const OWNER_HEAD_SCAN_LIMIT = 100;
390
391class 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. */
406function 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
428const 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
434const 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
447const 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. */
456export 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. */
473const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) =>
474 context === undefined ? undefined : new URL(context.provider.baseUrl).host;
475
476export 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 */
7import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
8import * as Effect from "effect/Effect";
9
10import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
12
13export 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 @@
1import type { GitHubSettings } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5const 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
14const GitHubAuthStatusSchema = Schema.Struct({
15 hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)),
16});
17
18const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption(
19 Schema.fromJsonString(GitHubAuthStatusSchema),
20);
21
22export 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
32export interface GitHubAuthStatus {
33 readonly parsed: boolean;
34 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;
35}
36
37const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u;
38
39function nonEmptyString(value: string): string | null {
40 const trimmed = value.trim();
41 return trimmed.length > 0 ? trimmed : null;
42}
43
44export 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
74export 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 */
88export 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 @@
1import { expect, it } from "@effect/vitest";
2import * as TestClock from "effect/testing/TestClock";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as Redacted from "effect/Redacted";
7import type { PullRequestCheck } from "@t3tools/contracts";
8
9import * as GitHubApi from "./GitHubApi.ts";
10import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
11
12const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
13
14const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 };
15const credential = {
16 host: "github.com",
17 token: Redacted.make("token"),
18 credentialFingerprint: "one",
19};
20
21it.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
125it.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
215it.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 @@
1import * as Clock from "effect/Clock";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
9
10import * as GitHubApi from "./GitHubApi.ts";
11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";
13import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider";
14
15const 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});
23const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema));
24
25const 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});
33const decodeWorkflowRuns = Schema.decodeUnknownOption(
34 Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })),
35);
36
37export 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 */
43export 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
50type 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. */
53const UNSUPPORTED_STATUSES = new Set([404, 405, 501]);
54
55export 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 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
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 */
38function 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
73function 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
79describe("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
194describe("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
248describe("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
502describe("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
820describe("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
907describe("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
955describe("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
1033describe("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
1088describe("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
1331describe("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
1343describe("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
1361describe("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
1464describe("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
1483describe("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
1520describe("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
1698describe("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
1755describe("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
1796describe("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
1900describe("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
1940describe("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
2029describe("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 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import 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";
35import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
36import { decodeJsonResult } from "@t3tools/shared/schemaJson";
37
38import { aliasedGraphQlDocument, type GraphQlDocument } from "./githubGraphQl.ts";
39import { 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 */
45const 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
61const RawLabelSchema = Schema.Struct({
62 name: Schema.String,
63 color: Schema.optional(Schema.NullOr(Schema.String)),
64});
65
66const 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. */
73const RawLatestReviewSchema = Schema.Struct({
74 author: Schema.optional(Schema.NullOr(RawActorSchema)),
75 state: Schema.optional(Schema.NullOr(Schema.String)),
76});
77
78const 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
101const 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
130const 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 */
144const 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
229const 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
243const RawSearchSchema = Schema.Struct({
244 data: Schema.Struct({ search: RawRowConnectionSchema }),
245});
246
247const 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. */
254const 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
276const 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. */
284const 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 */
290const 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. */
305const 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
316const 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
327export function gitHubReactionContent(content: PullRequestReactionContent): string {
328 return GITHUB_REACTION_BY_CONTENT[content];
329}
330
331const 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
358type 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 */
366function 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
387const 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
398const 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
408const 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
424const 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. */
442const 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
452const 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
474const 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
484const 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. */
531function RawPageInfoSchemaFields() {
532 return Schema.Struct({
533 hasNextPage: Schema.optional(Schema.Boolean),
534 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
535 });
536}
537const 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 */
544const RawViewerFieldsSchema = Schema.Struct({
545 viewerCanUpdate: Schema.optional(Schema.Boolean),
546 viewerDidAuthor: Schema.optional(Schema.Boolean),
547});
548
549const 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. */
556const 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. */
694const 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
706const 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});
760const decodeCore = decodeJsonResult(RawCoreSchema);
761
762const 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. */
775export 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
782const RawActorAvatarsSchema = Schema.Struct({
783 data: Schema.Struct({
784 nodes: Schema.Array(Schema.NullOr(RawActorSchema)),
785 }),
786});
787
788const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema);
789
790export 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 */
811function 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
824export 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
852export 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
861const 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
879export 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 */
894export 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. */
914const 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 */
920export 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 */
938export 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 */
959export 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
970function 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. */
975function 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 */
1021export 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 */
1087export 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
1101const 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
1119export 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 */
1130export 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
1134const RawPullRequestNodeIdSchema = Schema.Struct({
1135 data: Schema.Struct({
1136 repository: Schema.Struct({
1137 pullRequest: Schema.Struct({ id: Schema.String }),
1138 }),
1139 }),
1140});
1141
1142const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema);
1143
1144export 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 */
1156export 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
1166const 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
1180const 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 */
1186export 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
1195export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1196 addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1197}`;
1198
1199export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1200 removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1201}`;
1202
1203export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {
1204 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
1205}`;
1206
1207export 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 */
1216export 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. */
1223export 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 */
1233export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
1234 updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } }
1235}`;
1236
1237export 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. */
1244const 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
1257const 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 */
1267export 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
1278function 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. */
1295export 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
1311export 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
1338export 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
1355export interface GitHubWorkflowRunApproval {
1356 readonly id: number;
1357 readonly name: string;
1358 readonly url: string | null;
1359}
1360
1361export interface GitHubPullRequestHead {
1362 readonly number: number;
1363 readonly headSha: string;
1364 readonly isCrossRepository?: boolean;
1365 readonly headRepositoryOwner: string | null;
1366}
1367
1368export interface GitHubPullRequestActivity {
1369 readonly author: PullRequestActor | null;
1370 readonly comments: ReadonlyArray<PullRequestComment>;
1371 readonly commits: ReadonlyArray<PullRequestCommit>;
1372}
1373
1374function 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 */
1384function 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 */
1398function 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
1404function 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
1416function 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. */
1427function 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
1438function 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
1449function 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
1460function 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 */
1479function 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
1502function 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
1515function 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 */
1528function 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
1537function 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
1547function 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. */
1577const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z";
1578
1579function 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. */
1585function 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 */
1594function 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 */
1634function 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
1647function 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. */
1654function 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
1665function 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
1712function 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
1726function 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
1750function 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
1773const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
1774const decodeSearch = decodeJsonResult(RawSearchSchema);
1775const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
1776const decodeStats = decodeJsonResult(RawStatsSchema);
1777const decodeDetail = decodeJsonResult(RawDetailSchema);
1778const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
1779const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema);
1780const decodeActivity = decodeJsonResult(RawActivitySchema);
1781const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema);
1782const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1783const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
1784const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
1785
1786type DecodeFailure = Cause.Cause<Schema.SchemaError>;
1787
1788export 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 */
1801export 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 */
1826function 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
1864export 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
1869export 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 */
1885export 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
1908function 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. */
1922const 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 */
1928function 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
1936const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0;
1937
1938/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */
1939function 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 */
1974export 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. */
1986export 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
1998const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema);
1999
2000export 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 */
2019export 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 */
2047const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }";
2048
2049const 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. */
2057export 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. */
2070export 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
2076const 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});
2084const 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);
2096const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema);
2097
2098export 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 */
2124export 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
2169const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }";
2170const 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 */
2183const 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. */
2191export 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 */
2206export interface GitHubPullRequestWatchFingerprint {
2207 readonly status: string;
2208 readonly remarks: string;
2209}
2210
2211const RawEditedSchema = Schema.Struct({
2212 totalCount: Schema.Int,
2213 nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))),
2214});
2215const RawStateCountsSchema = Schema.NullOr(
2216 Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })),
2217);
2218const 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.
2248const 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
2255const 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. */
2263export 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
2296export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
2297 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
2298 readonly comparison: GitHubBaseComparison | null;
2299 readonly checksTruncated: boolean;
2300}
2301
2302export 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
2340export 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
2349export 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`. */
2356export 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. */
2372export 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
2381export 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
2404export 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`. */
2415export 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. */
2446export 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
2461const 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
2474const 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. */
2503export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>;
2504
2505function 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
2514export 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. */
2534export 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
2541export 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. */
2586export 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
2594export 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 */
2626export 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. */
2646function 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
2663const 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
2683const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema);
2684
2685/** One further page of dismissal events, in the shape the thread read's own page carries. */
2686export 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
2704export 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. */
2833export 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. */
2866export 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 */
2881function 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. */
2893function 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 */
2909export 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
2915export 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. */
2937const RawRequestedReviewerSchema = Schema.Struct({
2938 ...RawActorSchema.fields,
2939 slug: Schema.optional(Schema.NullOr(Schema.String)),
2940});
2941
2942const 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
2970const 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 */
2980export 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 */
3028const ReviewerRequestSchema = Schema.Struct({
3029 reviewers: Schema.Array(Schema.String),
3030 team_reviewers: Schema.Array(Schema.String),
3031});
3032
3033export 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
3044export 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
3056const 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
3086const 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 */
3093export 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. */
3129export 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 */
3140export 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. */
3159export 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
3166const 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
3176const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);
3177
3178export 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
3198export 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 */
3213export 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 */
3223export function decodeCommitFilesJson(
3224 raw: string,
3225): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
3226 return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? []));
3227}
3228
3229const decodeCommitFiles = decodeJsonResult(
3230 Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }),
3231);
3232
3233function 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 */
3287export 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
3298const 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
3329const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema);
3330
3331export 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. */
3341function 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
3352export 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 */
3375export 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. */
3391const 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 */
3406const 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
3416const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
3417
3418export 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
3427export 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 */
3440export 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 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export 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
28const 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
61function trimOptionalString(value: string | null | undefined): string | null {
62 const trimmed = value?.trim() ?? "";
63 return trimmed.length > 0 ? trimmed : null;
64}
65
66function 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
83function 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
118const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
119const 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 */
125export 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
138export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 gitHubApiHostForRemote,
5 selectGitHubBaseRepository,
6} from "./gitHubRepositoryResolution.ts";
7
8describe("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
78describe("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 @@
1import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2import {
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. */
8export 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. */
15export 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 */
35export 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 */
76export 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. */
118export 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 */
145export 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 */
177export 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. */
189function 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 @@
1import { assert, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts";
5
6describe("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
58describe("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 @@
1import * as Effect from "effect/Effect";
2
3/** GraphQL variables by name, each with the type it is declared as and the value it carries. */
4export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>;
5
6export 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 */
23export 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 */
72export 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 @@
1import { expect, it } from "@effect/vitest";
2import * as Layer from "effect/Layer";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7import * as FileSystem from "effect/FileSystem";
8import * as Redacted from "effect/Redacted";
9import { ChildProcessSpawner } from "effect/process";
10import * as GitHubApi from "./GitHubApi.ts";
11import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
12import { 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 */
18type Call = ReadonlyArray<string>;
19type 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. */
25let gitCalls: Array<ReadonlyArray<string>> = [];
26const 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
47const 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
80const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
81
82function 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
91const isMutation = (call: Call) => call[0]!.startsWith("mutation");
92
93const 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];
117const 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};
129const access = {
130 data: {
131 repository: {
132 pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
133 pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
134 },
135 },
136};
137
138const 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});
146const rebased = {
147 data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } },
148};
149const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
150
151function 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
163it.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
175it.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
201it.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
214it.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
230it.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
251it.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
269it.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
284it.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
309it.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
328it.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
347it.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
378it.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 @@
1import type {
2 PullRequestAction,
3 PullRequestMergeMethod,
4 PullRequestStackHead,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Clock from "effect/Clock";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10
11import * as GitHubApi from "./GitHubApi.ts";
12import { aliasedGraphQlDocument } from "./githubGraphQl.ts";
13import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
14import { cascadeRebaseStack } from "./githubStackRebase.ts";
15
16const stackErrorIdentity = {
17 repository: Schema.String,
18 number: Schema.Int,
19 stackNumber: Schema.Int,
20};
21
22export 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
33export 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
42export 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
51export 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
60export 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
69export 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
78export 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
90export type GitHubStackActionError =
91 | GitHubStackChangedError
92 | GitHubStackUnsupportedError
93 | GitHubStackResponseInvalidError
94 | GitHubStackMergeRejectedError
95 | GitHubStackMergePendingError
96 | GitHubStackPermissionError
97 | GitHubStackRebaseFailedError;
98
99const 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
107const 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
129const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
130
131/** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */
132export 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 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Redacted from "effect/Redacted";
4import * as Schema from "effect/Schema";
5import * as Base64 from "effect/encoding/Base64";
6
7import * as GitHubApi from "./GitHubApi.ts";
8import * 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. */
11export 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. */
18export 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. */
28export 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. */
38function 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 */
52export 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 @@
1313 },
1414 "dependencies": {
1515 "@t3tools/contracts": "workspace:*",
16 "@t3tools/shared": "workspace:*",
1617 "@t3tools/source-control-core": "workspace:*",
1718 "effect": "catalog:"
1819 },
packages/source-control-testing/src/TestSourceControlHost.ts+39-15
......@@ -1,15 +1,23 @@
11/**
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.
56 *
67 * @module source-control-testing/TestSourceControlHost
78 */
8import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
9import {
10 DEFAULT_SERVER_SETTINGS,
11 type ServerSettings,
12 type ServerSettingsPatch,
13} from "@t3tools/contracts";
14import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
915import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
16import * as Context from "effect/Context";
1017import * as Effect from "effect/Effect";
1118import * as Layer from "effect/Layer";
1219import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
20import * as Ref from "effect/Ref";
1321
1422export interface TestSourceControlHostOptions {
1523 readonly settings?: ServerSettings;
......@@ -17,12 +25,20 @@ export interface TestSourceControlHostOptions {
1725 readonly git?: Partial<SourceControlHost.SourceControlHost["Service"]["git"]>;
1826}
1927
28/** Lets a test change the settings its `layer` reports. */
29export class TestSourceControlHostSettings extends Context.Service<
30 TestSourceControlHostSettings,
31 { readonly update: (patch: ServerSettingsPatch) => Effect.Effect<void> }
32>()("@t3tools/source-control-testing/TestSourceControlHost/TestSourceControlHostSettings") {}
33
2034type Git = SourceControlHost.SourceControlHost["Service"]["git"];
2135
2236const unexpectedGit = (operation: string) => () =>
2337 Effect.die(`Unexpected git ${operation} in a test that supplied none.`);
2438
2539const failingGit: Git = {
40 execute: unexpectedGit("execute"),
41 resolveCommit: unexpectedGit("resolveCommit"),
2642 remotes: unexpectedGit("remotes"),
2743 readConfigValue: unexpectedGit("readConfigValue"),
2844 resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"),
......@@ -36,17 +52,25 @@ const failingGit: Git = {
3652
3753export const layer = (
3854 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 );
5074 }),
5175 );
5276
pnpm-lock.yaml+40
......@@ -660,6 +660,9 @@ importers:
660660 '@t3tools/source-control-forgejo':
661661 specifier: workspace:*
662662 version: link:../../packages/source-control-forgejo
663 '@t3tools/source-control-github':
664 specifier: workspace:*
665 version: link:../../packages/source-control-github
663666 '@t3tools/source-control-gitlab':
664667 specifier: workspace:*
665668 version: link:../../packages/source-control-gitlab
......@@ -1640,6 +1643,40 @@ importers:
16401643 specifier: 1.0.0
16411644 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)
16421645
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
16431680 packages/source-control-gitlab:
16441681 dependencies:
16451682 '@t3tools/contracts':
......@@ -1673,6 +1710,9 @@ importers:
16731710 '@t3tools/contracts':
16741711 specifier: workspace:*
16751712 version: link:../contracts
1713 '@t3tools/shared':
1714 specifier: workspace:*
1715 version: link:../shared
16761716 '@t3tools/source-control-core':
16771717 specifier: workspace:*
16781718 version: link:../source-control-core
scripts/release-smoke.ts+1
......@@ -41,6 +41,7 @@ const workspaceFiles = [
4141 "packages/source-control-bitbucket/package.json",
4242 "packages/source-control-core/package.json",
4343 "packages/source-control-forgejo/package.json",
44 "packages/source-control-github/package.json",
4445 "packages/source-control-gitlab/package.json",
4546 "packages/source-control-testing/package.json",
4647 "packages/effect-acp/package.json",